Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 918125cbe5 |
@@ -1,3 +0,0 @@
|
||||
Trial run of Typecheck workflow on PR → dev.
|
||||
Created 2026-05-23 to measure end-to-end duration of vue-tsc (desktop) and tsc (controller).
|
||||
Do not merge; close after measurement.
|
||||
@@ -18,6 +18,7 @@ name: Build bootstrap container
|
||||
#
|
||||
# Секреты:
|
||||
# DOCKERHUB_USERNAME / DOCKERHUB_TOKEN
|
||||
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -84,3 +85,17 @@ jobs:
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
docker tag "$IMAGE:${{ env.DOCKER_TAG }}" "$IMAGE:${{ env.DOCKER_TAG }}-$SHORT_SHA"
|
||||
docker push "$IMAGE:${{ env.DOCKER_TAG }}-$SHORT_SHA"
|
||||
|
||||
- name: Telegram notify success
|
||||
if: ${{ success() }}
|
||||
run: |
|
||||
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
|
||||
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
|
||||
-d text="✅ [GITHUB MONO] dicoop/bootcoop:${{ env.DOCKER_TAG }} (sha=${GITHUB_SHA::7})"
|
||||
|
||||
- name: Telegram notify failure
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
|
||||
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
|
||||
-d text="❌ [GITHUB MONO] Ошибка сборки dicoop/bootcoop на ${{ github.ref_name }} (sha=${GITHUB_SHA::7})"
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Trigger Contracts Docs Deploy
|
||||
|
||||
# Триггерит rebuild docs в coopenomics/coopenomics только по продакшн-
|
||||
# тэгам на main. Раньше дёргался на каждый push в dev/testnet/main/capital,
|
||||
# теперь — только формальный релиз (без -alpha/-beta/-rc/-test).
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_trigger: ${{ steps.check.outputs.should_trigger }}
|
||||
steps:
|
||||
- name: Checkout (для git branch --contains)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Gate — main + non-alpha
|
||||
id: check
|
||||
run: |
|
||||
TAG="${{ github.ref_name }}"
|
||||
if [[ "$TAG" =~ -(alpha|beta|rc|test) ]]; then
|
||||
echo "Тэг $TAG — pre-release, docs deploy не дёргаем"
|
||||
echo "should_trigger=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if ! git branch -r --contains "${{ github.sha }}" | grep -qE "^[[:space:]]*origin/main$"; then
|
||||
echo "Тэг $TAG не на main — docs deploy не дёргаем"
|
||||
echo "should_trigger=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "Тэг $TAG — продакшн релиз на main, дёргаем coopenomics"
|
||||
echo "should_trigger=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
trigger-coopenomics:
|
||||
needs: gate
|
||||
if: needs.gate.outputs.should_trigger == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Coopenomics deployment
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.COOPENOMICS_PAT }}
|
||||
repository: coopenomics/coopenomics
|
||||
event-type: deploy_from_mono
|
||||
client-payload: '{"repository": "${{ github.repository }}", "sha": "${{ github.sha }}", "ref": "${{ github.ref }}", "actor": "${{ github.actor }}"}'
|
||||
@@ -0,0 +1,209 @@
|
||||
name: Build contracts container
|
||||
|
||||
# Сборка и публикация docker-образа `dicoop/contracts`, который содержит
|
||||
# wasm/abi всех контрактов кооперативной экономики (apps, marketplace,
|
||||
# ledger, meet, loan, wallet, capital, branch, contributor, fund, draft,
|
||||
# soviet, registrator, gateway, system, test). Образ потребляется
|
||||
# ke-bootstrap'ом (отдельный артефакт), который при старте ноды деплоит
|
||||
# контракты через `cleos set contract`.
|
||||
#
|
||||
# Триггер — push в одну из веток-окружений (dev / testnet / main) с
|
||||
# изменением .cpp/CMakeLists/build-скриптов. Маппинг:
|
||||
# dev → IS_TESTNET=ON, tag=dev
|
||||
# testnet → IS_TESTNET=ON, tag=testnet
|
||||
# main → IS_TESTNET=OFF, tag=main + latest
|
||||
#
|
||||
# ВАЖНО: тэги `v*` тут НЕ обрабатываются — они уезжают в `release.yaml`,
|
||||
# который делает релиз атомарно (контракты + контейнеры + webhook в
|
||||
# одном job'е). См. release.yaml для контекста — там в комментарии
|
||||
# описана причина (гонка build-contracts и build-containers по push'у
|
||||
# тэга, инцидент 2026-05-13 с ledger2 walletop sweep'ом).
|
||||
#
|
||||
# Сам compile идёт через CDT-образ `dicoop/blockchain_v5.1.1:dev`
|
||||
# (тот же, что использует `components/contracts/build.sh|build-all.sh`).
|
||||
# Артефакты копируются в slim alpine-образ.
|
||||
#
|
||||
# Секреты:
|
||||
# DOCKERHUB_USERNAME / DOCKERHUB_TOKEN — публикация в dicoop/*
|
||||
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID — уведомления
|
||||
|
||||
on:
|
||||
# Только ручной запуск. Тэги (релизы) обрабатывает release.yaml,
|
||||
# который собирает контракты атомарно вместе с контейнерами и
|
||||
# webhook'ом — отдельная сборка по push'у в ветку только мешает
|
||||
# (две параллельные сборки на каждый release-bump). Здесь оставляем
|
||||
# workflow_dispatch для ручной пересборки `dicoop/contracts:<branch>`
|
||||
# (например, чтобы прокатить wasm на dev-ноду без релизного тэга).
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Determine build mode and docker tag
|
||||
run: |
|
||||
case "${{ github.ref_name }}" in
|
||||
main)
|
||||
echo "BUILD_MODE=prod" >> "$GITHUB_ENV"
|
||||
echo "DOCKER_TAG=main" >> "$GITHUB_ENV"
|
||||
echo "EXTRA_TAG=latest" >> "$GITHUB_ENV"
|
||||
;;
|
||||
testnet)
|
||||
echo "BUILD_MODE=test" >> "$GITHUB_ENV"
|
||||
echo "DOCKER_TAG=testnet" >> "$GITHUB_ENV"
|
||||
echo "EXTRA_TAG=" >> "$GITHUB_ENV"
|
||||
;;
|
||||
dev)
|
||||
echo "BUILD_MODE=test" >> "$GITHUB_ENV"
|
||||
echo "DOCKER_TAG=dev" >> "$GITHUB_ENV"
|
||||
echo "EXTRA_TAG=" >> "$GITHUB_ENV"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported branch ${{ github.ref_name }}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Pull CDT toolchain image
|
||||
run: docker pull dicoop/blockchain_v5.1.1:dev
|
||||
|
||||
- name: Compile all contracts
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
./build-all.sh "$BUILD_MODE"
|
||||
echo "--- build/contracts ---"
|
||||
ls -la build/contracts/
|
||||
|
||||
- name: Stage docker context
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
ROOT="docker/.context"
|
||||
rm -rf "$ROOT"
|
||||
mkdir -p "$ROOT/contracts"
|
||||
|
||||
# Имена контрактов берём из CMakeLists (`add_contract_build(<name>)`)
|
||||
# — единая точка истины. Любое изменение списка там автоматически
|
||||
# подтянется в образ.
|
||||
# Имена контрактов = аргументы add_contract_build(...) только в
|
||||
# незакомменченных строках (CMake-комментарий начинается с `#`).
|
||||
NAMES=$(grep -E '^[[:space:]]*add_contract_build\(' CMakeLists.txt \
|
||||
| sed -E 's/^[[:space:]]*add_contract_build\(([^)]*)\).*/\1/')
|
||||
echo "Contracts to package:"
|
||||
echo "$NAMES"
|
||||
|
||||
MANIFEST="$ROOT/contracts/manifest.json"
|
||||
|
||||
# Хелпер: упаковывает одну пару (wasm, abi) в /contracts/<name>/
|
||||
# и добавляет JSON-запись в STDOUT (caller склеивает в массив).
|
||||
pack_one() {
|
||||
local NAME="$1" WASM="$2" ABI="$3"
|
||||
if [ ! -f "$WASM" ] || [ ! -f "$ABI" ]; then
|
||||
echo "::warning::Skipping $NAME: artifacts missing ($WASM / $ABI)" >&2
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$ROOT/contracts/$NAME"
|
||||
cp "$WASM" "$ROOT/contracts/$NAME/$NAME.wasm"
|
||||
cp "$ABI" "$ROOT/contracts/$NAME/$NAME.abi"
|
||||
local SHA_WASM=$(sha256sum "$WASM" | awk '{print $1}')
|
||||
local SHA_ABI=$(sha256sum "$ABI" | awk '{print $1}')
|
||||
printf ' {"name":"%s","sha256_wasm":"%s","sha256_abi":"%s"}' \
|
||||
"$NAME" "$SHA_WASM" "$SHA_ABI"
|
||||
}
|
||||
|
||||
{
|
||||
printf '{\n'
|
||||
printf ' "build_mode": "%s",\n' "$BUILD_MODE"
|
||||
printf ' "git_sha": "%s",\n' "$GITHUB_SHA"
|
||||
printf ' "ref": "%s",\n' "$GITHUB_REF_NAME"
|
||||
printf ' "built_at": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
printf ' "contracts": [\n'
|
||||
|
||||
FIRST=1
|
||||
emit() {
|
||||
entry=$(pack_one "$@") || return 0
|
||||
[ $FIRST -eq 0 ] && printf ',\n'
|
||||
FIRST=0
|
||||
printf '%s' "$entry"
|
||||
}
|
||||
|
||||
for c in $NAMES; do
|
||||
if [ "$c" = "system" ]; then
|
||||
# У system многоконтрактная сборка: разносим под-контракты
|
||||
# eosio.* как полноценные элементы каталога, а сам "system"
|
||||
# как имя пропускаем. test_contracts/* не упаковываем —
|
||||
# это тестовые stub'ы, не нужны на проде.
|
||||
for sub in build/contracts/system/contracts/eosio.*/; do
|
||||
[ -d "$sub" ] || continue
|
||||
sub_name=$(basename "$sub")
|
||||
emit "$sub_name" "$sub/$sub_name.wasm" "$sub/$sub_name.abi"
|
||||
done
|
||||
else
|
||||
emit "$c" "build/contracts/$c/$c.wasm" "build/contracts/$c/$c.abi"
|
||||
fi
|
||||
done
|
||||
|
||||
printf '\n ]\n'
|
||||
printf '}\n'
|
||||
} > "$MANIFEST"
|
||||
|
||||
cp docker/Dockerfile "$ROOT/Dockerfile"
|
||||
cp docker/entrypoint.sh "$ROOT/entrypoint.sh"
|
||||
chmod +x "$ROOT/entrypoint.sh"
|
||||
|
||||
echo "--- manifest.json ---"
|
||||
cat "$MANIFEST"
|
||||
echo "--- context tree ---"
|
||||
find "$ROOT" -maxdepth 3 -type f | sort
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push image
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
IMAGE="dicoop/contracts"
|
||||
docker build \
|
||||
--label "org.opencontainers.image.revision=${{ github.sha }}" \
|
||||
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--label "build.mode=$BUILD_MODE" \
|
||||
-t "$IMAGE:$DOCKER_TAG" \
|
||||
./docker/.context
|
||||
docker push "$IMAGE:$DOCKER_TAG"
|
||||
|
||||
if [ -n "$EXTRA_TAG" ]; then
|
||||
docker tag "$IMAGE:$DOCKER_TAG" "$IMAGE:$EXTRA_TAG"
|
||||
docker push "$IMAGE:$EXTRA_TAG"
|
||||
fi
|
||||
|
||||
# SHA-tag для воспроизводимости (если нужно запинить конкретный
|
||||
# коммит, а не двигающийся branch-tag).
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
docker tag "$IMAGE:$DOCKER_TAG" "$IMAGE:$DOCKER_TAG-$SHORT_SHA"
|
||||
docker push "$IMAGE:$DOCKER_TAG-$SHORT_SHA"
|
||||
|
||||
- name: Verify pushed image
|
||||
run: |
|
||||
docker run --rm dicoop/contracts:$DOCKER_TAG list
|
||||
echo "---"
|
||||
docker run --rm dicoop/contracts:$DOCKER_TAG sha256
|
||||
|
||||
- name: Telegram notify success
|
||||
if: ${{ success() }}
|
||||
run: |
|
||||
COUNT=$(docker run --rm dicoop/contracts:$DOCKER_TAG list | wc -l)
|
||||
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
|
||||
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
|
||||
-d text="✅ [GITHUB MONO] dicoop/contracts:$DOCKER_TAG ($COUNT контрактов, mode=$BUILD_MODE, sha=${GITHUB_SHA::7})"
|
||||
|
||||
- name: Telegram notify failure
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
|
||||
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
|
||||
-d text="❌ [GITHUB MONO] Ошибка сборки dicoop/contracts на ${{ github.ref_name }} (sha=${GITHUB_SHA::7})"
|
||||
@@ -0,0 +1,161 @@
|
||||
name: Publish Docs
|
||||
|
||||
# Публикация только по продакшн-тэгам, лежащим на main (без -alpha/-beta/-rc/-test).
|
||||
# Раньше триггерилось на каждый push в main/testnet/dev/reports/marketplace2 →
|
||||
# docs пересобирались по 5+ раз в день впустую. Теперь — только релиз.
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_publish: ${{ steps.check.outputs.should_publish }}
|
||||
steps:
|
||||
- name: Checkout (для git branch --contains)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Gate — main + non-alpha
|
||||
id: check
|
||||
run: |
|
||||
TAG="${{ github.ref_name }}"
|
||||
if [[ "$TAG" =~ -(alpha|beta|rc|test) ]]; then
|
||||
echo "Тэг $TAG — pre-release, docs не публикуем"
|
||||
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if ! git branch -r --contains "${{ github.sha }}" | grep -qE "^[[:space:]]*origin/main$"; then
|
||||
echo "Тэг $TAG не на main — docs не публикуем"
|
||||
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "Тэг $TAG — продакшн релиз на main, публикуем docs"
|
||||
echo "should_publish=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-and-publish-docs:
|
||||
needs: gate
|
||||
if: needs.gate.outputs.should_publish == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Версия pnpm берётся из `packageManager` корневого package.json,
|
||||
# синхронно с publish-packages.yaml и Dockerfile'ами.
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install 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: |
|
||||
# Удаляем конкретный большой файл sdk/typedoc.json
|
||||
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 }}"}'
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Publish Packages
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
if: |
|
||||
startsWith(github.ref, 'refs/tags/v') &&
|
||||
!contains(github.ref, '-alpha')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Жёстко прибиваем версию pnpm, которой сгенерён lockfile (lockfileVersion 9.0 = pnpm 9/10).
|
||||
# Иначе action-setup может подтянуть другую версию и переписать pnpm-lock.yaml → Lerna EUNCOMMIT.
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lerna run build
|
||||
- run: pnpm lerna publish from-package --yes
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
+63
-237
@@ -1,96 +1,57 @@
|
||||
name: Release
|
||||
|
||||
# Релизный workflow по push'у в testnet/main (FF-промоушн-модель, см.
|
||||
# scripts/RELEASE.md).
|
||||
# Атомарный релизный workflow. Триггерится исключительно push'ем тэга
|
||||
# `v*`. В одном job'е по порядку:
|
||||
# 1) собирает контракты под mode (prod/test) и пушит `dicoop/contracts:<branch>`,
|
||||
# 2) собирает базовый образ + сервисные контейнеры и пушит `dicoop/<svc>:<tag>`,
|
||||
# 3) шлёт webhook деплоя (testnet/production по типу тэга).
|
||||
#
|
||||
# Порядок (через jobs.needs):
|
||||
# 1) release — контракты + контейнеры + webhook деплоя (атомарно)
|
||||
# 2) publish-packages — npm publish через lerna (только ветка main)
|
||||
# 3) trigger-coopenomics — workflow_dispatch сборки сайта C9S/coopenomics на
|
||||
# Gitea (только ветка main); он сам тянет mono и
|
||||
# пересобирает доки.
|
||||
# Зачем атомарно: раньше `build-contracts` и `build-containers`
|
||||
# крутились параллельно по push'у тэга, и webhook от build-containers
|
||||
# (~8 мин) обгонял окончание build-contracts (~11 мин) → ансибл на
|
||||
# тестнете подхватывал ПРЕДЫДУЩИЙ образ контрактов и перетирал чейн
|
||||
# старым wasm. Попытка связать их через workflow_run упёрлась в
|
||||
# default-branch caveat и потерю head_sha в YAML-выражениях. Теперь
|
||||
# нет двух workflow'ов — нет гонки, нет fallback'ов.
|
||||
#
|
||||
# publish-docs (gh-pages на github.com) удалён 2026-05-25 — после переезда на
|
||||
# Gitea Pages-публикация невалидна; доки деплоятся через C9S/coopenomics.
|
||||
# Триггер по веткам (dev/testnet/main без тэга) тут НЕ обрабатывается —
|
||||
# для CI-обновления `dicoop/contracts:<branch>` без релиза остаётся
|
||||
# `build-contracts.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).
|
||||
#
|
||||
# Окружение определяет ВЕТКА (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.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [testnet, main]
|
||||
paths:
|
||||
- 'lerna.json'
|
||||
# Ручной деплой: выбрать окружение и (опционально) версию/коммит. Нужен для
|
||||
# отката на старую версию, редеплоя без бампа и точечного hotfix в один контур —
|
||||
# всё, что FF-промоушн (scripts/promote.sh, только вперёд) сделать не может.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: 'Куда деплоить'
|
||||
type: choice
|
||||
options:
|
||||
- testnet
|
||||
- main
|
||||
required: true
|
||||
ref:
|
||||
description: 'Релизный тег/коммит для сборки (пусто = верхушка выбранного окружения)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag_name: ${{ steps.resolve.outputs.tag_name }}
|
||||
branch: ${{ steps.resolve.outputs.branch }}
|
||||
is_prod: ${{ steps.resolve.outputs.is_prod }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# push → собираем пушнутый коммит; ручной запуск → выбранный ref
|
||||
# (пусто = верхушка ветки окружения, на которой запущен workflow).
|
||||
ref: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.ref) || github.sha }}
|
||||
|
||||
- name: Resolve branch, build mode and version
|
||||
id: resolve
|
||||
- name: Resolve branch, build mode and tags
|
||||
run: |
|
||||
# Окружение: при ручном workflow_dispatch — из inputs.environment;
|
||||
# при push — это ветка push'а (testnet/main). Версия/сборка берутся
|
||||
# из реально вычекнутого дерева, не из ветки-триггера.
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
BRANCH="${{ github.event.inputs.environment }}"
|
||||
else
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
fi
|
||||
SHA="$(git rev-parse HEAD)"
|
||||
|
||||
# Версия — из закоммиченного 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"
|
||||
TAG_NAME="${{ github.ref_name }}"
|
||||
SHA="${{ github.sha }}"
|
||||
BRANCH=""
|
||||
for candidate in main testnet dev; do
|
||||
if git branch -r --contains "$SHA" 2>/dev/null | grep -qE "^[[:space:]]*origin/${candidate}$"; then
|
||||
BRANCH="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "::error::Тэг $TAG_NAME ($SHA) не лежит ни на одной из dev/testnet/main"
|
||||
exit 1
|
||||
fi
|
||||
TAG_NAME="v${RAW_VERSION}"
|
||||
|
||||
case "$BRANCH" in
|
||||
main)
|
||||
@@ -108,22 +69,16 @@ 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
|
||||
# Прод-тэги (без -alpha/-beta/-rc/-test) уезжают на боевой
|
||||
# webhook + получают тэг :latest у сервисных образов.
|
||||
if [[ "$TAG_NAME" =~ -(alpha|beta|rc|test) ]]; then
|
||||
IS_PROD=false
|
||||
WEBHOOK_URL='${{ vars.TESTNET_WEBHOOK_URL }}'
|
||||
else
|
||||
IS_PROD=true
|
||||
WEBHOOK_URL='${{ vars.PRODUCTION_WEBHOOK_URL }}'
|
||||
fi
|
||||
|
||||
{
|
||||
@@ -137,12 +92,6 @@ jobs:
|
||||
echo "WEBHOOK_URL=$WEBHOOK_URL"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
{
|
||||
echo "tag_name=$TAG_NAME"
|
||||
echo "branch=$BRANCH"
|
||||
echo "is_prod=$IS_PROD"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Release $TAG_NAME → branch=$BRANCH, build_mode=$BUILD_MODE, contracts:$CONTRACTS_TAG, prod=$IS_PROD"
|
||||
|
||||
- name: Login to DockerHub
|
||||
@@ -152,41 +101,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/
|
||||
|
||||
@@ -267,17 +189,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 \
|
||||
@@ -286,14 +197,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
|
||||
@@ -306,31 +217,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"
|
||||
@@ -339,10 +234,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
|
||||
}
|
||||
|
||||
@@ -362,89 +257,20 @@ jobs:
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$TAG_NAME"
|
||||
|
||||
# ============================================================================
|
||||
# publish-packages — npm publish (бывший publish-packages.yaml).
|
||||
# Гейт: ветка main. Версия для publish — из закоммиченного package.json
|
||||
# (lerna publish from-package); её бампит cut-release.sh на dev, тот же коммит
|
||||
# доезжает до main по FF.
|
||||
# Жёстко прибиваем версию 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' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lerna run build
|
||||
|
||||
# schema.gql не коммитится и не создаётся ничем в этом job'е, а
|
||||
# @coopenomics/sdk's prepublishOnly (pnpm run docs → generate-index-comments.ts)
|
||||
# читает components/controller/schema.gql и падает ENOENT без него — валя весь
|
||||
# lerna publish (атомарный) молча с 30 мая. Скрипт изолированный (GraphQLSchemaBuilderModule,
|
||||
# без TypeORM/Mongo/Redis) — не требует БД, но типизация controller'а резолвится через
|
||||
# dist соседних workspace-пакетов (cooptypes/factory/notifications/inter) — ОБЯЗАТЕЛЬНО
|
||||
# после `lerna run build`, иначе ts-node падает на их устаревших dist-типах.
|
||||
- name: Generate controller schema.gql (required by sdk prepublishOnly)
|
||||
working-directory: components/controller
|
||||
run: pnpm run generate-schema
|
||||
|
||||
- run: pnpm lerna publish from-package --yes
|
||||
env:
|
||||
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.
|
||||
# ============================================================================
|
||||
trigger-coopenomics-docs:
|
||||
needs: release
|
||||
if: ${{ needs.release.outputs.branch == 'main' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger coopenomics website rebuild (Gitea workflow_dispatch)
|
||||
- name: Telegram notify success
|
||||
if: success()
|
||||
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"
|
||||
EXTRA=""
|
||||
if [ "$IS_PROD" = "true" ]; then
|
||||
EXTRA=" (с тегом latest)"
|
||||
fi
|
||||
curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||||
-d "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
|
||||
-d "text=✅ [GITHUB MONO] Релиз $TAG_NAME ($BRANCH, contracts:$CONTRACTS_TAG, sha=${SHA::7})$EXTRA"
|
||||
|
||||
# ============================================================================
|
||||
# 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
|
||||
- name: Telegram notify failure
|
||||
if: failure()
|
||||
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 }}"}'
|
||||
curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||||
-d "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
|
||||
-d "text=❌ [GITHUB MONO] Ошибка релиза $TAG_NAME ($BRANCH, sha=${SHA::7})"
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
name: Typecheck
|
||||
|
||||
# Валидация TypeScript-типов для desktop (Vue 3 + Quasar) и controller (NestJS).
|
||||
#
|
||||
# Зачем это отдельный workflow:
|
||||
# 1. `quasar build --mode ssr` в release.yaml использует Vite/esbuild и
|
||||
# vue-tsc отключён через vite-plugin-checker (см. quasar.config.cjs):
|
||||
# TS-ошибки в .vue/.ts НЕ ловятся при docker build образа desktop.
|
||||
# 2. У `@coopenomics/controller` нет build-скрипта вовсе — `lerna run build`
|
||||
# его молча пропускает; в проде `ts-node` стартует и валится на типах
|
||||
# только в рантайме при загрузке модуля. Кейс PR #392 (rename 1080→1020
|
||||
# в cooptypes) проскочил именно так.
|
||||
#
|
||||
# Триггеры:
|
||||
# - pull_request на dev — гейт перед мерджем в основную ветку;
|
||||
# - workflow_call — оставлен для переиспользования, но release.yaml его
|
||||
# больше НЕ вызывает (гейт из релиза убран как избыточный — типы
|
||||
# проверяются на PR в dev до того, как код доедет до тэга).
|
||||
#
|
||||
# Push в dev/testnet/main НЕ триггерит — намеренно (PR-гейт на dev достаточен).
|
||||
|
||||
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
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install workspace
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Полный lerna build по всему workspace — как в корневом Dockerfile.
|
||||
# Не --scope, потому что workspace-пакеты unbuild-типа (sdk, inter,
|
||||
# notifications, factory, cooptypes, …) экспортируют типы только из
|
||||
# сгенерированного dist/. Без dist/ tsc/vue-tsc валятся на TS2307
|
||||
# «Cannot find module». Локально это маскируется ts-node-ом, но в
|
||||
# CI пути резолвятся через package.json → types → dist/.
|
||||
# См. Graphiti / run #148 root cause 2026-05-23.
|
||||
- name: Build all workspace packages
|
||||
run: pnpm lerna run build
|
||||
|
||||
# quasar prepare генерирует .quasar/* (auto-imports d.ts, типы для
|
||||
# quasar плагинов). Без него vue-tsc ругается на отсутствующие
|
||||
# глобальные компоненты QBtn/QCard/etc.
|
||||
- name: Quasar prepare
|
||||
run: pnpm --filter @coopenomics/desktop run prepare
|
||||
|
||||
# vue-tsc проверяет и .ts, и <script lang="ts"> в .vue.
|
||||
# --skipLibCheck — иначе валится на типах из транзитивных deps
|
||||
# (Quasar/Vue/Vite сами по себе чистые, но в графе есть legacy).
|
||||
# Из памяти: на dev-сервере висло ~час из-за конкуренции с quasar dev;
|
||||
# на чистом ubuntu-latest без живого стека ожидаемо в разы быстрее.
|
||||
# Если упрётся в OOM/таймаут — fallback на `pnpm typecheck`
|
||||
# (tsc --noEmit --skipLibCheck без vue-tsc, без SFC шаблонов).
|
||||
- name: vue-tsc (desktop)
|
||||
run: pnpm --filter @coopenomics/desktop exec vue-tsc --noEmit --skipLibCheck
|
||||
|
||||
controller:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install workspace
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Полный lerna build по всему workspace — controller импортирует
|
||||
# @coopenomics/sdk, @coopenomics/inter, @coopenomics/notifications
|
||||
# помимо cooptypes/factory; их dist/ генерируется unbuild и без
|
||||
# этого шага tsc валится на 37 TS2307. См. root cause выше.
|
||||
- name: Build all workspace packages
|
||||
run: pnpm lerna run build
|
||||
|
||||
- name: tsc --noEmit (controller)
|
||||
run: pnpm --filter @coopenomics/controller run typecheck
|
||||
@@ -1,5 +1,4 @@
|
||||
node_modules/
|
||||
node_modules
|
||||
lerna-debug.log
|
||||
components/controller/graph.png
|
||||
blockchain-data/
|
||||
@@ -22,6 +21,3 @@ components/reports-standarts/**/СФР_1118018397_*.xml
|
||||
components/contracts/cpp/ledger2/scripts/out/
|
||||
|
||||
.env.testnet
|
||||
.pnpm-store
|
||||
schema.gql
|
||||
.claude
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
store-dir=./.pnpm-store
|
||||
supported-architectures.os[]=darwin
|
||||
supported-architectures.os[]=linux
|
||||
supported-architectures.cpu[]=x64
|
||||
supported-architectures.cpu[]=arm64
|
||||
supported-architectures.libc[]=glibc
|
||||
|
||||
@@ -1,332 +0,0 @@
|
||||
# mono (coopenomics/mono) — рабочие заметки
|
||||
|
||||
Этот файл — общая память агента для пяти чекаутов: `~/mono-ai-1`..`~/mono-ai-5`. Реальный файл лежит в `mono-ai-1/CLAUDE.md`, остальные четыре — симлинки сюда; правки коммитим из mono-ai-1 в ветку `dev`.
|
||||
|
||||
## Стек монорепы
|
||||
|
||||
| Слой | Где | Технология |
|
||||
|------|-----|------------|
|
||||
| Backend (coopback) | `components/controller/` | NestJS 10, TypeScript, TypeORM, GraphQL, EventEmitter2 |
|
||||
| Frontend (UI пайщика) | **`components/desktop/`** | **Vue 3 + Quasar** в SSR-режиме (`quasar dev --mode ssr`) |
|
||||
| Контракты | `components/contracts/` | EOSIO/CDT, C++ |
|
||||
| SDK для frontend | `components/sdk/` | TypeScript, авто-генерится из controller GraphQL schema |
|
||||
| Parser blockchain | `components/parser/` | TypeScript |
|
||||
| Boot/orchestration | `components/boot/` | TypeScript |
|
||||
|
||||
**Frontend (`components/desktop/`) — FSD структура:** `src/{app,pages,processes,widgets,features,entities,shared,stores,desktops,boot}/`.
|
||||
|
||||
Когда план говорит «UI компонент» / «frontend integration» — путь `components/desktop/src/{features|widgets|pages|processes}/<name>/`, расширение `.vue` (composition API + `<script setup lang="ts">`), стили Quasar (QChip/QBtn/QCard/QDialog…), GraphQL через Apollo Client + сгенерированные типы из `components/sdk/`. Dev — `pnpm --filter @coopenomics/desktop run dev` или `devnet` (без SSR).
|
||||
|
||||
**НЕ путать:** НЕТ `components/app-cooperative/` — не предлагать. Все frontend-сессии работают в той же монорепе, что и backend. В стеке **никакого React нигде нет**.
|
||||
|
||||
## Worktree-политика
|
||||
|
||||
**Для mono-ai-4 (базовая ветка `marketplace2`, Стол заказов):** **worktree приветствуется** — изоляция работ + параллельные ветки. Если worktree пуст от `node_modules` и `.env` (pnpm их не дублирует):
|
||||
|
||||
```bash
|
||||
WT=/home/admin/mono-ai-4/.claude/worktrees/<name>
|
||||
ln -s /home/admin/mono-ai-4/node_modules $WT/node_modules
|
||||
ln -s /home/admin/mono-ai-4/components/controller/node_modules $WT/components/controller/node_modules
|
||||
ln -s /home/admin/mono-ai-4/components/controller/.env $WT/components/controller/.env
|
||||
```
|
||||
|
||||
Аналогично для других пакетов, чьи тесты будут запускаться (`components/desktop/node_modules`, `components/sdk/node_modules`). `jest` из bin: `cd $WT/components/controller && ./node_modules/.bin/jest -i <test>` — корректно резолвит ts-jest и подхватывает `.env`.
|
||||
|
||||
**Подвох cooptypes:** когда `controller/node_modules` — симлинк на main, пакет внутри `node_modules/cooptypes -> ../../cooptypes` раскрывается **относительно main checkout**. `import { MarketContract } from 'cooptypes'` тянет d.ts из main, не из worktree. Если worktree обновлён, а main позади — TSC падает на отсутствующих типах. **Фикс:** перед TSC в worktree controller'а — `git pull` в main checkout до того же коммита (или хотя бы где cooptypes/src синхронен) + `pnpm build` в `main/components/cooptypes/`.
|
||||
|
||||
## PR-flow
|
||||
|
||||
**Базовая ветка 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)
|
||||
|
||||
Каждая story из MVP-эпиков «Стол заказов» (`coopenomics/mono`, базовая ветка `marketplace2`, чекаут `~/mono-ai-4`, BMad-spec'и `_blago/.../components/3-minimalnyy-produkt/_bmad-output/`) идёт через PR.
|
||||
|
||||
Workflow:
|
||||
1. Worktree от `marketplace2` на feature-ветке `feat/<E>-<S>-<slug>`.
|
||||
2. Edits + unit-тесты; tsc + jest должны быть зелёные.
|
||||
3. Commit + push feature-ветку.
|
||||
4. `gh pr create --base marketplace2 --head feat/<E>-<S>-<slug>`.
|
||||
5. Merge **пользователем на GitHub** (не вызывать `gh pr merge` без явной просьбы).
|
||||
6. После merge — fetch + checkout marketplace2 в основной чекаут, удалить feature-ветку + worktree, запустить e2e / blockchain тесты против обновлённого marketplace2.
|
||||
|
||||
### Umbrella-PR на эпик vs цепочка PR
|
||||
|
||||
При работе story-by-story в одной feature-area **не плодить отдельные PR на каждую story с одинаковой целевой веткой**. Каждый последующий PR показывает кумулятивный diff (всё, что в head минус то, что уже мёрджнуто в base). Если предыдущие PR ещё не смерджены — в diff вылазят дубликаты файлов всех предыдущих stories.
|
||||
|
||||
**Default — umbrella-PR на эпик** (для крупных эпиков с >3 stories): одна `feat/<E>-epic` ветка + один PR; каждая story — отдельный коммит. Worktree последовательно, push после каждой story, PR обновляется. Так закрыт Эпик 1 Стола заказов (umbrella #380).
|
||||
|
||||
**Альтернатива — stacked-PRs с `base:<prev-feat>`:** каждый PR таргетится на предыдущую feature-ветку. После merge нижнего GitHub автоматически перетарджетит верхние. Требует дисциплины и тулинга.
|
||||
|
||||
**Анти-паттерн:** worktree от `feat/1-2-...`, потом от `feat/1-3-...`, и каждый PR в `marketplace2`. Цепочка branches правильная (изоляция), но цепочка PR — нет. Кейс Эпика 1 Стола заказов 2026-05-14: 11 PR `#370-#380` подряд от `marketplace2`, каждый +N stories назад. Пользователь дошёл до review #372 и обнаружил дубли. Закрыл #372-#379, оставил только #380.
|
||||
|
||||
## Локальные тесты
|
||||
|
||||
**Не запускать полный jest локально** ни в mono-ai-1, ни в mono-ai-4: живой dev-стек в docker (`nodeos`, `controller dev` nodemon, `parser dev`, `n8n`) вешает CPU/RAM и блокирует chain. Полный suite — задача CI после push'а PR.
|
||||
|
||||
В mono-ai-4 **запрещён параллельный режим jest** (worker-pool по умолчанию) — это вешает сервер. Если нужен unit-тест — точечный с `--runInBand`:
|
||||
|
||||
```bash
|
||||
pnpm jest tests/unit/marketplace/marketplace-onboarding-service.test.ts --runInBand
|
||||
```
|
||||
|
||||
`pnpm generate-schema` / `pnpm generate-client` — **не запускать локально**; та же memory/CPU полка вешает контейнер controller'а. Либо CI, либо пользователь сам когда контейнер остановлен.
|
||||
|
||||
Перед коммитом достаточно `tsc --noEmit` (быстрый, не блокирует).
|
||||
|
||||
## SDK login canon
|
||||
|
||||
`@coopenomics/sdk` экспортирует `Client.create({api_url, chain_url, chain_id})` + метод `client.login(email, wif)`. Он сам:
|
||||
1. Генерит `now` (ISO timestamp).
|
||||
2. Подписывает приватным ключом (WIF) через eosjs.
|
||||
3. Зовёт `Mutations.Auth.Login.mutation` с `{email, now, signature}`.
|
||||
4. Возвращает `{tokens: {access: {token}, refresh: {token}}, account: {username}}`.
|
||||
|
||||
**Не дёргать `Mutations.Auth.Login` напрямую** — `LoginInput` ждёт `{email, now, signature}`, генерация подписи внутри SDK Client. Refresh: `Mutations.Auth.Refresh.mutation` с `{access_token, refresh_token}`. Канон используется в `blago-cli/src/session/index.ts` (loginInteractive) и в EMP-коннекторе `connectors/cooperative-tsk-login-connector` (Story 11.5).
|
||||
|
||||
## Backend (controller) каноны
|
||||
|
||||
### 3 базовые роли — User / Member / Chairman
|
||||
|
||||
- **User** — обычный пайщик. Базовые потребительские права (заказывать, публиковать оферту, видеть свои данные).
|
||||
- **Member** — **член совета** (не «член кооператива»!). User-права + read-only admin (видит склад, поток заказов, повестку — но не модерирует и не подписывает финальные действия).
|
||||
- **Chairman** — председатель. User + admin (модерация, KU/whitelist/витрины, closing signature АПП-приёмки/выдачи, повестка совета на write).
|
||||
|
||||
Маппинг core-роли на extension-роль явно: User → orderer + опционально offerer/operator; Chairman → admin (полный write); Member → read-only admin (board_readonly). Пайщик может быть одновременно в нескольких extension-ролях — массив, не enum. Guard'ы в расширениях — локальные сейчас, в Phase 2 переключатся на платформенный CASL.
|
||||
|
||||
### `agreements` ссылается на существующий document registry_id
|
||||
|
||||
Когда расширение controller'а (marketplace, blagorost, любое следующее) хранит факт подписи документа пайщиком в глобальной on-chain таблице `agreements`, ссылка идёт через **существующий `registry_id` из платформенного реестра документов**, не через отдельный type-string типа `marketplace.cpp.stol-zakazov-v1`.
|
||||
|
||||
Поле `document_id` в `agreements` — FK на существующий platform registry. Extension-таблицы `*_onboarding_requirement` — `document_registry_id` ссылается на существующий ID. API запросов вида «какие документы подписаны member'ом» — `agreementsByMember(member_id, document_id_filter=[...])`.
|
||||
|
||||
**Технический долг платформы:** «Договор УХД сейчас не проходит через `agreements`» — отдельная задача core controller'а, вне scope конкретных расширений.
|
||||
|
||||
### Трёхуровневый онбординг расширений
|
||||
|
||||
Платформенный паттерн, обязателен для всех новых расширений controller'а.
|
||||
|
||||
**L1 — Кооператив (one-time):** председатель/совет принимает решение совета о подключении ЦПП, принимается положение ЦПП (статический документ из platform registry, через document factory с подстановкой параметров кооператива), оферта регистрируется в `coop_registration_offers_registry`.
|
||||
|
||||
**L2 — Пайщик при вступлении (per-membership):** в registration-flow появляется **выбор** ЦПП. Пайщик отмечает интересующие, document factory рендерит оферту с `{cooperative_params, member_params, agreement_date}`. Подпись пишется в глобальную on-chain `agreements`. Эта подпись **нивелирует** gate на столе расширения.
|
||||
|
||||
**L3 — Пайщик на рабочем столе (per-extension first visit):** backend проверяет через `agreementsByMember` — подписана ли оферта ЦПП. ДА → gate не показывается. НЕТ → gate показывается как explicit consent.
|
||||
|
||||
При проектировании любого нового расширения — обязательно три истории под три уровня. На MVP допустимо упростить: одинаковый `document_registry_id` для положения и оферты (физически разные документы могут быть в Phase 2).
|
||||
|
||||
### Marketplace asset через DI
|
||||
|
||||
В сервисах `components/controller/src/extensions/marketplace/**/*.service.ts` запрещён хардкод вида `const ASSET_DECIMALS = 4` / `const ASSET_SYMBOL = 'RUB'`. Decimals и symbol — через DI `MARKETPLACE_ASSET_CONFIG`:
|
||||
|
||||
```ts
|
||||
@Inject(MARKETPLACE_ASSET_CONFIG) private readonly assetConfig: MarketplaceAssetConfig
|
||||
// assetConfig.symbol, assetConfig.decimals
|
||||
```
|
||||
|
||||
Канон-пример: `marketplace-order-create.service.ts`. См. `marketplace-asset.config.provider.ts` — мапит `config.blockchain.root_govern_symbol` / `root_govern_precision`. Разные среды (mainnet/testnet/dev) имеют разный symbol+precision.
|
||||
|
||||
### registry_id=800 (ReturnByAssetStatement) — клиринг, не членские взносы
|
||||
|
||||
Marketplace в монорепе живёт в **двух контурах**:
|
||||
1. **Система клиринга** (старый, не используется) — registry_id=800. Не использовать в новых фичах.
|
||||
2. **Система членских взносов** (текущий MVP) — документы лежат **рядом** с актами приёма-передачи и ТТН; другая группа registry_id.
|
||||
|
||||
Для нового документа Marketplace-членские-взносы: завести новый registry_id рядом с актами/ТТН и проложить цепочку `cooptypes → factory → controller → desktop`:
|
||||
1. `@coopenomics/cooptypes`: новый тип документа + регистрация в registry.
|
||||
2. `@coopenomics/factory`: generator (preview-структура + meta).
|
||||
3. `@coopenomics/controller`: signed-document DTO + verify в сервисе.
|
||||
4. `@coopenomics/desktop`: подпись через `Classes.Document` с новым registry_id.
|
||||
|
||||
## GraphQL каноны (controller + desktop)
|
||||
|
||||
### Описания @Field — бизнес-языком
|
||||
|
||||
В `@Field({ description })`, `@InputType`, `@ObjectType` нельзя писать «Story 4.1», «Эпик 3», «FR11a», «composite-entity», «dispatch pipeline», «tx_snapshot», «backend deterministic order_hash». Только пользовательский язык: «Идентификатор заказа», «ПВЗ получения», «Кол-во единиц товара». Story-ссылки и инвариант-комменты — только в inline-комментариях внутри сервиса.
|
||||
|
||||
### Enum вместо строковых литералов
|
||||
|
||||
Запрещены `if (offer.cycle_type === 'volume_based')`, `status: 'ACTIVE'`, `'PENDING_MODERATION'`. Любое поле с фиксированным набором (cycle_type, status, type, kind, role) — TypeScript `enum`, при необходимости `registerEnumType` для GraphQL. В тестах константы — тоже из enum, не дублирующие строки. Перед добавлением сравнения по строке — `enum MarketplaceOfferCycleType` / `MarketplaceOrderStatus` в `domain/entities/*.types.ts`.
|
||||
|
||||
### Пагинация — единый паттерн
|
||||
|
||||
В controller-resolver'ах пагинация делается единым каноническим паттерном:
|
||||
- Вход: `@Args('options', { nullable: true }) options?: PaginationInputDTO` (импорт из `~/application/common/dto/pagination.dto.ts`, поля page/limit/sortBy/sortOrder).
|
||||
- Выход: `createPaginationResult(ItemDTO, 'PaginatedXxx')` + сигнатура `Promise<PaginationResult<T>>` (items / totalCount / totalPages / currentPage).
|
||||
- Repository принимает `PaginationInputDTO`, сам считает offset/limit/sort через TypeORM `findAndCount`.
|
||||
|
||||
Канон: `time-tracker.resolver.ts`, `expenses-management.resolver.ts`, `generation.resolver.ts`. Никаких локальных `{ limit, offset }`.
|
||||
|
||||
### Никаких raw GraphQL-строк в desktop
|
||||
|
||||
В `components/desktop/src/pages/**/api/index.ts` и аналогах запрещены конструкции:
|
||||
```ts
|
||||
const QUERY = `query Foo { ... }`;
|
||||
await sendPOST('/v1/graphql', { query: QUERY, variables });
|
||||
```
|
||||
Даже с комментарием «техдолг до Zeus regen».
|
||||
|
||||
Обязательная процедура перед UI-кодом под новую GraphQL-операцию:
|
||||
1. Добавить/изменить DTO/resolver в `components/controller/src/...` (code-first).
|
||||
2. `pnpm run generate-schema` в `components/controller/` → пересоздать `controller/schema.gql`.
|
||||
3. `pnpm run generate-client` в `components/controller/` → graphql-zeus кладёт клиент в `components/sdk/src/zeus/`.
|
||||
4. `pnpm run build` в `components/sdk/` → unbuild собирает `dist/`.
|
||||
5. В desktop импортировать `Mutations.<Domain>.<Name>` / `Queries.<Domain>.<Name>` из `@coopenomics/sdk` — типизировано end-to-end.
|
||||
|
||||
Если на шаге 1 не хватает поля — добавить и запустить весь цикл; не оставлять заглушку «пока».
|
||||
|
||||
### Строгая типизация desktop API из SDK через IInput['data']
|
||||
|
||||
Каждый вызов `client.Mutation` / `client.Query` в `components/desktop/src/**/api/index.ts`:
|
||||
1. Принимает аргументом объект `data: IXxxInput`, где `IXxxInput = Mutations.<Domain>.<Action>.IInput['data']` (или `Queries...`) — тип берётся **прямо из @coopenomics/sdk**, не переописывается.
|
||||
2. Передаёт в `variables` объект `data` целиком: `{ variables: { data } }`. Запрещено разворачивать поля: `{ variables: { data: { a, b, c } } }`.
|
||||
|
||||
Канон — `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 коде **все имена идентификаторов — английские**:
|
||||
- Имена Vue-компонентов: `BaseInput`, `BaseDialog`, `WalletCard`, `IdentityPanel`.
|
||||
- Имена файлов и директорий: `shared/ui/BaseInput/BaseInput.vue`.
|
||||
- CSS-классы: `.base-card`, `.wallet-card__icon`, `.id-panel__hero`.
|
||||
- SCSS-переменные: `$primary`, `$prog-blagorost`, `$separator-color`.
|
||||
- TS-типы, интерфейсы, переменные, функции: `interface WalletCardProps`, `function useWalletData()`.
|
||||
|
||||
**Why:** Unicode-имена ломают тулчейн (Vite/Webpack резолверы и aliases часто на ASCII-only regex; TypeScript symbol-resolution на не-ASCII нестабильно; ESLint `vue/component-name-in-template-casing` ждёт PascalCase ASCII; импорты `import БазоваяКнопка from '@/shared/ui/БазоваяКнопка'` невыносимы при review).
|
||||
|
||||
**Заголовки в .vue, label-ы кнопок, тексты в UI — по-русски** (это user-facing strings). Не путать с правилом «онтологические class_id по-русски» — то про EMP/ТЭМ и blago-документы, не про frontend.
|
||||
|
||||
**Кейс 2026-05-18:** при подготовке UX-спецификации для components/desktop ошибочно применил правило русских имён к Vue-компонентам (`БазоваяКнопка`, `БазовоеПолеВвода`); пользователь поправил.
|
||||
|
||||
## Стандарты процессов (.standard.yaml) — бизнес-языком
|
||||
|
||||
`components/contracts/cpp/**/*.standard.yaml` — документация для методолога/бухгалтера, не для разработчиков контракта. В `purpose`, `description`, `note`, `human` запрещены технические термины:
|
||||
|
||||
- никаких «callback», «soviet::exec», «soviet::createagenda», «AUTHORIZE_CALLBACK_SIGNATURE», «type-string», «registry N», «proposed расширение enum'а»;
|
||||
- никаких «backend formирует», «controller вызывает», «contract отдаёт» — пишем кто что делает на уровне бизнеса (председатель / совет / заказчик / поставщик);
|
||||
- технические `marketplace::propwroff`-имена в полях `action`/`name`/`triggered_by` оставляем как identifier'ы, но всё человеко-читаемое в `human`/`purpose`/`description` — на бизнес-словаре;
|
||||
- бухгалтерские проводки `Дт 91 / Кт 10` — допустимы (бухгалтер их понимает);
|
||||
- если процесс встроен в более общий — ссылаемся на стандарт по имени-человеку («типовой процесс решения совета»), не на техническую реализацию повестки.
|
||||
|
||||
Эталоны: `p.mkt.return.standard.yaml`, `p.cap.rid.standard.yaml`, `reg.coop.standard.yaml`. Антипример — `p.mkt.wroff.standard.yaml` в PR #399 review 2026-05-18 (был забит callback-описаниями и «type=mktwroff»).
|
||||
|
||||
## Vault & SERVER_SECRET
|
||||
|
||||
WIF админ-аккаунта (например `voskhod`) хранится в `vaults` PostgreSQL зашифрованным AES-256-CBC с ключом `sha256(SERVER_SECRET)`. Если `SERVER_SECRET` потом меняли — **старые записи разрушаются**, `decipher.final()` бросает `error:1C800064:Provider routines::bad decrypt`.
|
||||
|
||||
**Симптомы:**
|
||||
- В логе coopback: `[VaultDomainService] Ошибка при получении WIF ключа для пользователя voskhod: bad decrypt`.
|
||||
- В UI каскад `SignAgreementDialog` не закрывается; SPA отправляет `sendAgreement` корректно, но `wallet::signagree` on-chain не происходит.
|
||||
- Следствие — `wallet::users[<coop>]` пуст, и любой последующий `is_can_transfer`/трансфер AXON падает на `Отправитель не является участником ЦПП кошелька`.
|
||||
|
||||
**Фикс:**
|
||||
1. Достать живой WIF (для voskhod в dev — `5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3` из boot config.ini, signature-provider).
|
||||
2. Шифрануть им current SERVER_SECRET (см. `controller/src/utils/aes.ts` — sha256(SECRET) → AES-256-CBC, IV 16 байт, формат `ivHex:cipherHex`).
|
||||
3. `UPDATE vaults SET wif='...', updated_at=now() WHERE username='voskhod' AND permission='active';`.
|
||||
4. Перезапуск coopback **не нужен** — он читает каждый раз.
|
||||
|
||||
**Дефолт всех mono-репозиториев — `SECRET`.** Если в каком-то `.env` стоит другое — девиация, не норма. Перед re-encrypt'ом vault'а ВСЕГДА сначала смотреть `~/mono-ai-1/components/controller/.env` (или соседнего) — это origin truth для SERVER_SECRET.
|
||||
|
||||
**Кейс 2026-05-18 (Эпик 0):** в `mono-ai-5/components/controller/.env` стоял `e2e-fixture-secret-DO-NOT-USE-IN-PROD`, а WIF voskhod в vaults был зашифрован оригиналом — `SECRET`. Фикс — откатить SERVER_SECRET к `SECRET` и подровнять provider'а.
|
||||
|
||||
## Capital — фиксы билетов времени (PR #387 merged 2026-05-15)
|
||||
|
||||
В `components/controller/src/extensions/capital/` была серия багов в распределении билетов времени, закрытая PR #387 в dev. Три бага:
|
||||
|
||||
1. **`recalcDoneEstimatesForContributorProject` / `applyExplicitEstimateToTimeEntries`** раздавал «общий остаток пула» `(estimate − total_committed) / N` всем creators, включая того, кто уже закоммитил. Фикс: личная доля `max(0, estimate/N − own_committed_estimate)` через общий helper `redistributeIssueEstimateEntries`.
|
||||
2. **`commitTime` partial split** создавал committed-запись без `entry_type` и `estimate_snapshot` — БД по default'у писала `entry_type='hourly'`, что ломало последующий recalc (фильтрует только `entry_type='estimate'`). Фикс: явно копировать оба поля.
|
||||
3. **`declineCommit` / `handleDeclineCommit`** меняли только `commit.status='declined'`, но не возвращали `time-entries` в `is_committed=false`. Часы оставались в `total_committed_hours`. Фикс: `revertEntriesForDeclinedCommit` через новые `findCommittedByCommitHash` / `revertCommittedEntriesByCommitHash`.
|
||||
|
||||
**При симптомах** «25 ч подтверждено непонятно откуда» / «доступные часы не вернулись после decline» / «парные коммиты на одну работу» — первый чек: `grep -c redistributeIssueEstimateEntries time-tracking.interactor.ts` в контейнере > 5. Если нет — деплой устарел. Если есть, но баг — посмотреть `entry_type` у committed-записей по issue: legacy записи из БАГ #2 могут до сих пор быть с `entry_type='hourly'` для split-результатов от estimate (видны по `commit_hash IS NOT NULL AND entry_type='hourly' AND estimate_snapshot IS NULL` на DONE-задаче с estimate>0). Чинятся UPDATE'ом `entry_type → estimate` с правильным `estimate_snapshot`.
|
||||
|
||||
**Legacy data caveat:** `capital_time_entries` с `entry_type='hourly'` + `commit_hash != NULL` могут быть как (а) настоящей hourly работой до установки estimate, так и (б) split-наследием БАГ #2. Различить: если у задачи `estimate>0` и `DONE`, и записи hourly от тех же creators что в estimate-долях — это (б), чинить.
|
||||
|
||||
**Edge-case дробных остатков:** при `estimate=2.5` и одном creator, после commit'а 2 ч (`Math.floor < 1` для остатка) остаётся 0.5 ч uncommitted estimate, который никогда не закоммитится. Либо ручной DELETE uncommitted остатка, либо изменить estimate на целое (но estimate в `capital_issues` — on-chain, через UI mutation, не DELETE'ом в БД).
|
||||
|
||||
## Стол Заказов MVP — текущее состояние
|
||||
|
||||
Проект `1-prilozhenie-stol-zakazov` в blago (coopname voskhod, hash `feabc749…3841f73`). Кооперативная закупка/распределение имущества участка (продукты, товары, услуги); пилот — Красногорск; цель 6 мес: 10 кооперативов / 1200+ пайщиков.
|
||||
|
||||
**Локация:** `~/blago/production/1-prilozhenie-stol-zakazov/`.
|
||||
|
||||
**Структура артефактов:**
|
||||
- `_bmad-output/planning-artifacts/` (project-уровень): `prd.md`, `prd-validation-report.md`, `ux-design-specification.md`, `architecture.md` (status:complete 2026-05-12).
|
||||
- **`components/3-minimalnyy-produkt/_bmad-output/planning-artifacts/epics.md`** (SoT для MVP) — `stepsCompleted=[1,2,3,4]`, `status: complete 2026-05-12`, 65 FRs → 11 эпиков → 57 stories. **НЕ создавать дубль на верхнем уровне `_bmad-output/planning-artifacts/`** — MVP-компонент имеет собственный bmad-output.
|
||||
- `requirements/` (5 файлов): `04-brif`, `0b-protsessy`, `0f-prd`, `7e-uxui`, `d6-arkhitektura` — дубли _bmad-output (намеренно, см. правило о дублях BMad-артефактов в global memory).
|
||||
|
||||
**Locked Decisions L1–L9:**
|
||||
- **L1:** порядок подписей АПП (приёмка поставщик→председатель финально / выдача председатель→заказчик финально).
|
||||
- **L2:** `o.mkt.payout` baseline `Дт 86 / Кт 51` без счёта 60.
|
||||
- **L3:** реверты исключены — compensating forward с собственным `operation_id`.
|
||||
- **L4:** геокарта КУ обязательна на трёх столах; lat/lng в `PlaceDomainEntity`.
|
||||
- **L5 / L9:** «Системный конструктор ЦПП» → реализован как трёхуровневый онбординг расширений (см. выше).
|
||||
- **L6:** без отрицательного баланса в MVP (отменяет NFR-R5 PRD).
|
||||
- **L7:** enum 10 кооп-категорий (овощи/фрукты, молочные, мясо, рыба, хлеб, бакалея, напитки, готовая еда, услуги, прочие).
|
||||
- **L8:** двухслойная схема подписания — глобальная on-chain `agreements` (SoT) + локальные `*_onboarding_requirement`/`*_onboarding_state` per-extension.
|
||||
- **L10/L11/L13:** consolidated request backend-only / cycle_type backend variation / pull-модель отчётности.
|
||||
|
||||
**Прогресс по эпикам (на 2026-05-15):**
|
||||
- **Эпик 1** — MERGED в `marketplace2`: PR #368/#370/#371/umbrella #380.
|
||||
- **Эпик 2 «Сеть ПВЗ»** — MERGED PR #381 (после rebase). Workspace `market-pvz`, KU details + Yandex geocoder, Zeus SDK.
|
||||
- **Эпик 11 Story 11.1 (Ledger2 canonical actions)** — MERGED PR #375 на C++ стороне. **TS-сторона cooptypes НЕ закрыта**: `cooptypes/src/contracts/marketplace/actions/index.ts` экспортирует только LEGACY клиринговые actions, `interfaces/marketplace.ts` auto-generated из устаревшего ABI. **Блокер Эпика 4** — нужен отдельный pre-эпик PR `feat/S11-1-cooptypes-canonical`: ABI regen через `eosio-abi2ts` из новой `marketplace.abi.json` после `coopcontracts` build, либо ручное добавление 18 canonical actions + canonical tables + canonical interfaces.
|
||||
- **Эпик 3 «Витрина»** — IN REVIEW. PR #382 (`feat/E3-vitrina`) OPEN MERGEABLE. 6 коммитов (Stories 3.1–3.5 + BC-sync seam), 67 unit-тестов. Stories 3.4 (CAS counters) — callback-target для Эпика 4 + scaffolding `marketplace-order-sync.service.ts` (skeleton, throw NOT_IMPLEMENTED).
|
||||
- **Эпики 4-10** — BACKLOG. Issues 598-7 … 598-13 разложены; story-requirements в `598-N-…-requirements/`.
|
||||
|
||||
**Открытые фоллоуапы Эпика 1:**
|
||||
- L3 mutation `marketplaceSignOnboardingOffer` (write-mutation pool + `sndagreement`).
|
||||
- Source-маркер `'registration_flow'` vs `'extension_gate'` в DTO.
|
||||
- `marketplaceAcceptCpp` валидация повестки совета — после Эпика 8 (FR40).
|
||||
- Финальный юридический текст оферты — `todo-tspp-templates.md` (блокирует Stories 1.7/1.9/1.11 в продовой подаче).
|
||||
- Story 1.5 интеграционный тест против тестовой ноды ЦК — после Эпика 11.
|
||||
|
||||
**Реальное состояние brownfield (на marketplace2 @ d98041d):**
|
||||
|
||||
| Слой | Готово (membership) | Donor-клиринг (нужно решить судьбу) |
|
||||
|---|---|---|
|
||||
| ledger2 C++/TS реестры | 12 операций `o.mkt.*`, счета 10/91, кошельки `w.mkt.member`/`w.mkt.payout`/`w.wal.member`, WalletOp `REVOKE` | — (legacy `o.mkt.supply/recv` удалены) |
|
||||
| YAML-стандарты `p.mkt.{supply,return,wroff}` | canonical имена actions | — |
|
||||
| C++ actions (`marketplace.hpp` + 30 .cpp) | **ничего** из canonical | `orderoffer/accept/authcontrib/authreturn/supply/...` ~1660 LOC; legacy Wallet, не ledger2 |
|
||||
| process-registry в controller | заглушки под membership (`p.mkt.reqst` убран) | — |
|
||||
| cooptypes/contracts/marketplace TS-actions | нет canonical | 24 клиринговых обёртки |
|
||||
| controller/application + domain/marketplace | нет canonical | 23 DTO + interactor + resolver + 6 interfaces |
|
||||
| controller/extensions/marketplace | — | для категорий Ozon (clean arch, 7 TypeORM entities); может пригодиться для каталога Offer'ов |
|
||||
| controller/extensions/marketplace-cards | — | минимальный каркас карточек |
|
||||
| desktop/pages/Marketplace | нет canonical | 11 страниц под клиринг; `desktop/extensions/market` пуст |
|
||||
|
||||
**Решение 2026-05-12:** стратегии миграции не делаем — donor уже не работает по старой модели; собираем новую membership-модель в существующем контуре, donor-код переписывается / удаляется напрямую без переходников.
|
||||
|
||||
В `/bmad-create-architecture` MVP **не вводить adapter-слой и не описывать миграционные пути**. Прямо фиксировать: какие C++ actions/DTO/Vue-страницы из donor-листа удаляются, какие переписываются под canonical (`signsupp/signchair/signiss1/signiss2/acceptbatch/declinebatch/expirecycle/prepship/createorder(новая сигнатура)/cancelorder`), какие сохраняются (shipment/coopstock — если попадают в MVP scope, отдельно проверить).
|
||||
|
||||
**Обязательная enforcement-база для backend** — `mono-ai-4/components/controller/CLAUDE.md` (Composite-Entity `db/bc/derived`, Write-mutation pool с placeholder/sync_key dedup, ParserClient + Redis Streams, ForkRegistry, ADR-002/008/009/011/012; параметры через `config/blockchain.config.ts`, не magic numbers).
|
||||
@@ -1,139 +0,0 @@
|
||||
# Запуск mono dev-стека на macOS
|
||||
|
||||
Полная пошаговая инструкция, как поднять backend (`coopback`) + parser (`cooparser`) + блокчейн-ноду + БД на macOS через Docker Desktop. Прошёл — отметь, ниже разобраны типичные грабли.
|
||||
|
||||
## TL;DR
|
||||
|
||||
```bash
|
||||
cd ~/dacom-code/foundation/monocoop
|
||||
docker compose up -d # 1. поднять базу
|
||||
./scripts/dev-setup-macos.sh # 2. одной командой (см. ниже скрипт)
|
||||
```
|
||||
|
||||
Если скрипта ещё нет — выполняй шаги ниже руками.
|
||||
|
||||
## Шаги
|
||||
|
||||
### 1. Поднять контейнеры
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Поднимутся: `node` (NodeOS), `mongo`, `monoredis`, `postgres`, `coopback`, `cooparser`. **MinIO** входит в дефолт. **OpenSearch** — нет (тяжёлый, см. опц. сервисы).
|
||||
|
||||
### 2. Проверить чтоб .env'ы указывали на service-имена, а не localhost
|
||||
|
||||
`components/controller/.env`:
|
||||
- `MONGODB_URL=mongodb://mongo:27017/cooperative-x`
|
||||
- `REDIS_HOST=monoredis`
|
||||
- `POSTGRES_HOST=postgres`, `POSTGRES_PASSWORD=postgres!23!23`
|
||||
- `BLOCKCHAIN_RPC=http://node:8888`
|
||||
- `SIMPLE_EXPLORER_API=http://cooparser:4000`
|
||||
- `MINIO_ENDPOINT=minio:9000`
|
||||
- `CHAIN_ID=<реальный chain_id, см. ниже>`
|
||||
|
||||
`components/parser/.env`:
|
||||
- `MONGO_EXPLORER_URI=mongodb://mongo:27017/cooperative-x`
|
||||
- `REDIS_HOST=monoredis`
|
||||
- `API=http://node:8888`
|
||||
- `SHIP=ws://node:8080`
|
||||
|
||||
**Почему service-имена:** все контейнеры в bridge-сети `monocoop_default`. Внутри контейнера `127.0.0.1` = сам контейнер, не host. На macOS `network_mode: host` в Docker Desktop работает плохо — используем bridge + service names.
|
||||
|
||||
### 3. Узнать живой chain_id
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8888/v1/chain/get_info | jq -r .chain_id
|
||||
```
|
||||
|
||||
И вписать в `components/controller/.env` → `CHAIN_ID=...`. **Если chain_id в .env не совпадает с живым — on-chain транзакции упадут на verify, хотя приложение запустится.**
|
||||
|
||||
### 4. Если правил .env — пересоздать контейнер (не restart!)
|
||||
|
||||
```bash
|
||||
docker compose up -d --force-recreate --no-deps coopback cooparser
|
||||
```
|
||||
|
||||
`docker restart` **не перечитывает** `env_file`. `--no-deps` — чтобы не пересоздавать БД (потеряются данные).
|
||||
|
||||
### 5. Native binding libxmljs2 — пересборка под Linux
|
||||
|
||||
На свежем чекауте `pnpm install` запускается на macOS, и `libxmljs2` собирает Mach-O бинарник. В Linux-контейнере он падает:
|
||||
|
||||
```
|
||||
Error: .../xmljs.node: invalid ELF header ERR_DLOPEN_FAILED
|
||||
```
|
||||
|
||||
**Фикс** (один раз после `pnpm install`):
|
||||
|
||||
```bash
|
||||
docker exec monocoop-coopback-1 sh -c '
|
||||
cd /app/node_modules/.pnpm/libxmljs2@0.37.0/node_modules/libxmljs2 \
|
||||
&& rm -rf build \
|
||||
&& PATH="$PWD/node_modules/.bin:$PATH" npm run install
|
||||
'
|
||||
docker restart monocoop-coopback-1
|
||||
```
|
||||
|
||||
Проверка — magic bytes Linux ELF:
|
||||
```bash
|
||||
docker exec monocoop-coopback-1 sh -c \
|
||||
'head -c 4 /app/node_modules/.pnpm/libxmljs2@0.37.0/node_modules/libxmljs2/build/Release/xmljs.node | od -c -N 4'
|
||||
# Должно быть: 177 E L F
|
||||
# Если cf fa ed fe — это macOS Mach-O, фикс не применился.
|
||||
```
|
||||
|
||||
### 6. Дождаться cold-start coopback (~5–6 минут)
|
||||
|
||||
`ts-node` без cache + DI tree NestJS = долго. Первые логи появляются спустя ~5 минут после старта контейнера.
|
||||
|
||||
```bash
|
||||
docker logs -f monocoop-coopback-1 | grep -E 'NestJS app|Nest application|Error'
|
||||
```
|
||||
|
||||
Готово, когда видишь:
|
||||
```
|
||||
Nest application successfully started
|
||||
NestJS app with Express routes running on port 2998
|
||||
```
|
||||
|
||||
Проверка:
|
||||
```bash
|
||||
curl -s -X POST -H 'Content-Type: application/json' \
|
||||
-d '{"query":"{__typename}"}' http://localhost:2998/v1/graphql
|
||||
# {"data":{"__typename":"Query"}}
|
||||
```
|
||||
|
||||
## Опциональные сервисы
|
||||
|
||||
### OpenSearch (поиск)
|
||||
|
||||
```bash
|
||||
docker compose --profile search up -d opensearch
|
||||
```
|
||||
|
||||
Затем в `components/controller/.env`:
|
||||
```
|
||||
OPENSEARCH_ENABLED=true
|
||||
```
|
||||
И пересоздать coopback:
|
||||
```bash
|
||||
docker compose up -d --force-recreate --no-deps coopback
|
||||
```
|
||||
|
||||
## Грабли (если опять провозился пол-дня)
|
||||
|
||||
| Симптом | Причина | Фикс |
|
||||
|---|---|---|
|
||||
| `invalid ELF header` | host'овый `pnpm install` положил Mach-O | См. п. 5 — пересборка libxmljs2 в контейнере |
|
||||
| `MongooseServerSelectionError: ECONNREFUSED 127.0.0.1:27017` | .env на localhost вместо service-имени | См. п. 2 |
|
||||
| `password authentication failed for user "postgres"` | в .env `POSTGRES_PASSWORD=postgres` вместо `postgres!23!23` | См. п. 2 |
|
||||
| Coopback ушёл в restart-loop, но рядом есть другой `monocoop-coopback-1` Up | Случайно запустился старый `components/controller/docker-compose.yml` (network_mode: host, без bind-mount, без CMD) | `docker stop coopback && docker rm coopback` |
|
||||
| `docker restart` не подхватил новые переменные .env | `restart` не перечитывает `env_file` | `docker compose up -d --force-recreate --no-deps <svc>` |
|
||||
| Coopback зависает на 5+ минут, лога нет | Это нормально для cold-start ts-node | Подождать. CPU должен крутиться ≥50%. Если CPU = 0% — другая проблема |
|
||||
| `[VaultDomainService] Ошибка при получении WIF... bad decrypt` | SERVER_SECRET не совпадает с тем, чем шифровали WIF в БД | См. CLAUDE.md → раздел Vault & SERVER_SECRET. Дефолт — `SECRET` |
|
||||
| Транзакции падают на on-chain verify | CHAIN_ID в .env не совпадает с живым | См. п. 3 |
|
||||
| `Custom endpoint \`minio://9000\` was not a valid URI` на bootstrap | `MINIO_ENDPOINT` без схемы | `MINIO_ENDPOINT=http://minio:9000` (со схемой!) |
|
||||
| nodeos в restart-loop с `Database dirty flag set` после sleep/crash Docker Desktop | unclean shutdown chain state | `docker stop monocoop-node-1`; `mv blockchain-data/state-history{,.broken-$(date +%s)}`; `docker compose up -d node` — nodeos сам сделает replay |
|
||||
| Cold-start coopback 10+ минут (вместо обычных 3-5) | ts-node без cache + bind-mount через osxfs + параллельная нагрузка (nodeos replay) | Подождать; в будущем — `tsx` / `ts-node --swc` для dev-режима |
|
||||
+1
-20
@@ -45,14 +45,6 @@ RUN corepack enable && npm install -g lerna --no-fund --no-audit
|
||||
# выкидывает node_modules/dist/.git, поэтому `COPY .` лёгкий.
|
||||
COPY . .
|
||||
|
||||
# electron (devDep desktop → @vue/devtools) и puppeteer нигде в build/runtime
|
||||
# не запускаются (unbuild/tsc/vite их не трогают) — только их JS-обёртка нужна
|
||||
# для типов. Их postinstall тянет тяжёлые бинарники (Electron zip с GitHub
|
||||
# Releases, Chromium) по сети — периодически валит билд TLS-таймаутом.
|
||||
# Отключаем сами загрузки, пакеты всё равно ставятся.
|
||||
ENV ELECTRON_SKIP_BINARY_DOWNLOAD=1 \
|
||||
PUPPETEER_SKIP_DOWNLOAD=true
|
||||
|
||||
# Используем существующий lockfile. Если он не совпадает с workspace —
|
||||
# падать сразу, не дрейфовать незаметно.
|
||||
RUN pnpm install --frozen-lockfile
|
||||
@@ -91,23 +83,12 @@ WORKDIR /app
|
||||
# ссылка на системный интерпретатор; без него `weasyprint` падает с
|
||||
# `python: not found`. `procps`/`wget` — для дебага/healthcheck'ов
|
||||
# из docker-compose.
|
||||
#
|
||||
# `ca-certificates` обязателен: node:22-slim его не содержит, а
|
||||
# native-биндинги типа @livekit/rtc-node (Rust + rustls + rustls-native-certs)
|
||||
# читают корни ТОЛЬКО из системного store. Без файла
|
||||
# /etc/ssl/certs/ca-certificates.crt любой TLS handshake из такого
|
||||
# биндинга падает с "invalid peer certificate: UnknownIssuer" —
|
||||
# даже для валидной LE-цепочки (Node-стек при этом работает,
|
||||
# у него свои вшитые корни). Инцидент 2026-05-21: secretary в
|
||||
# coopback не мог подключиться к wss://chatcooprtc.coopenomics.world.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
python3 \
|
||||
libpango-1.0-0 libpangoft2-1.0-0 libpangocairo-1.0-0 libcairo2 \
|
||||
libffi8 libjpeg62-turbo libopenjp2-7 zlib1g shared-mime-info \
|
||||
procps wget \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& update-ca-certificates
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Python venv с WeasyPrint, готовый к использованию.
|
||||
COPY --from=builder /venv /venv
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
### Blago Manage Transcription Memo
|
||||
|
||||
Назначение скилла — собрать краткое содержание (memo) встречи по транскрипции звонка, согласовать его с пользователем и опубликовать в backend через `blago` так, чтобы оно появилось в desktop UI на странице транскрипции звонка (поле «Заметка о звонке»).
|
||||
|
||||
Право редактировать memo на сервере есть только у председателя и членов совета — убедись, что активная сессия `blago login` принадлежит подходящей роли.
|
||||
|
||||
Перед началом прочитай: `~/.claude/config/blago/config.yaml`. Запомни оттуда `workspace_base`, `active_workspace_env` и путь из `workspaces`, соответствующий `active_workspace_env` — назовём его `workspace_path`.
|
||||
|
||||
#### Входной артефакт
|
||||
|
||||
Транскрипции звонков лежат в `<workspace_path>/<project-slug>/meetings/<stem>.md`. Имя файла `<stem>` — UTC-метка окончания звонка (`YYYY-MM-DD_HHMMSS_mmm`). Этот файл — pull-only: его текст приходит с сервера и в `blago push` не уходит.
|
||||
|
||||
Связь meeting-файла с UUID транскрипции хранится в `.blago/index.json` рабочей копии: запись с `entity_type=call_transcription`, `relative_path` равен пути к meeting-файлу, `entity_hash` — UUID транскрипции. Сам `blago transcription memo` этот UUID резолвит автоматически по пути.
|
||||
|
||||
`blago pull` всегда кладёт рядом с meeting-файлом `meetings/<stem>.memo.md` (тип `call_transcription_memo`, `entity_hash = "<uuid>:memo"`). Если на сервере memo пустой — файл создаётся пустым. Это сделано специально, чтобы редактирование и публикация шли в одно действие: открыть существующий sibling, дописать, опубликовать. Никаких отдельных шагов «создать файл» делать не нужно.
|
||||
|
||||
#### Формат содержимого `.memo.md`
|
||||
|
||||
Жёсткие требования к телу memo (применяй всегда, даже без напоминания пользователя):
|
||||
|
||||
1. **Первая строка — резюме одним предложением, длиной ≤150 символов.** Это «о чём вообще был звонок». Без markdown-форматирования (без `#`, `*`, `-`, ссылок, цитат), без переноса строки внутри. Считай длину в символах (Unicode codepoints), а не в словах. Если не помещается — переформулируй до сути, не сокращай прилагательными.
|
||||
2. Следующая пустая строка-разделитель, затем — развёртка по сути. Сжимай до минимума:
|
||||
- убирай этикет, разогрев, повторы, «слышно? — слышно», частицы;
|
||||
- объединяй похожие реплики в один пункт;
|
||||
- оставляй: о чём договорились, что обсудили, какие решения, какие открытые вопросы и кто за что взялся;
|
||||
- короткие маркированные списки лучше абзацев; имена/числа/даты сохраняй точно.
|
||||
3. Markdown отображается в desktop UI редактором (Milkdown через `WrappedEditor`). Можно: заголовки `##`/`###`, списки `-`, инлайн `**жирный**`, ссылки. Не используй декоративный markdown, который ничего не добавляет к смыслу.
|
||||
|
||||
#### Процедура
|
||||
|
||||
1. Получи от пользователя путь к meeting-файлу (или скажи ему: «дай путь к нужному meeting-файлу»). Прочитай meeting-файл целиком.
|
||||
2. Открой sibling-файл `meetings/<stem>.memo.md` (он уже создан `blago pull`, возможно пустой). Если файла нет — попроси пользователя выполнить `blago pull` для нужного проекта и повтори.
|
||||
3. Собери summary по правилам выше: первая строка — одно предложение до 150 символов, дальше пустая строка и сжатая суть. По прямому промпту пользователя адаптируй акценты (что выделить, какие выводы). Если промпта нет — попроси его. Запиши результат в существующий sibling.
|
||||
4. Покажи пользователю обновлённое содержимое `.memo.md` целиком (или дай явную ссылку на файл). Проверь сам: первая строка — одно предложение, ≤150 символов, без markdown. Если нет — переписывай, пока не выполнится. Спроси, всё ли устраивает. Не публикуй без явного «ок / публикуй / поехали».
|
||||
5. После подтверждения опубликуй memo на сервер:
|
||||
|
||||
```bash
|
||||
blago transcription memo <workspace_path>/<project-slug>/meetings/<stem>.md
|
||||
```
|
||||
|
||||
По умолчанию команда читает `.memo.md` рядом с указанным meeting-файлом. Альтернативы:
|
||||
|
||||
- `blago transcription memo <stem>.md --file path/to/draft.md` — взять текст из произвольного файла;
|
||||
- `blago transcription memo <UUID> --text "<строка>"` — передать текст строкой и адресовать транскрипцию напрямую по UUID.
|
||||
|
||||
6. Команда вернёт строку вида `Memo обновлено на сервере: транскрипция <uuid> (символов: N; источник: …)`. Если сервер ответил ошибкой (например, прав не хватает или сессия истекла) — останови работу, сообщи пользователю текст ошибки и предложи `blago login`.
|
||||
|
||||
#### Замечания
|
||||
|
||||
- `blago pull` всегда создаёт `meetings/<stem>.memo.md` рядом с каждой meeting-транскрипцией — пустой, если на сервере memo пустой. Файл сразу проиндексирован (`entity_type=call_transcription_memo`), поэтому редактирование→`blago transcription memo` идёт без warning'ов.
|
||||
- Файл `.memo.md` в `blago push` не уходит (артефакты `meetings/` исключены из staging) — это «локальный канал» вокруг серверного поля `memo`.
|
||||
- Если ты редактировал sibling локально и не публиковал, а на сервере появилась новая версия — повторный `blago pull` запишет git-style маркеры конфликта (`<<<<<<< blago/local` … `>>>>>>> blago/remote`); разрулите вручную и опубликуй через `blago transcription memo`.
|
||||
- Если на сервере memo всё ещё пустой, а у тебя локальный черновик не публиковался — `blago pull` сохранит локальный текст и проиндексирует его как baseline; никакие правки не пропадают.
|
||||
- Чтобы перезаписать опубликованный memo — повтори ту же команду; сервер хранит одно текущее значение поля `memo`.
|
||||
- Ограничения на длину больше нет (ни на фронте, ни на бэкенде) — можно публиковать развёрнутые саммари с выводами и пунктами.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/blago-cli",
|
||||
"version": "2026.7.21-1",
|
||||
"version": "2026.5.15-5",
|
||||
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -41,7 +41,6 @@ import { runPush } from '../sync/push.js'
|
||||
import { runClearStaging, runRemove } from '../sync/remove.js'
|
||||
import { restoreAllFromServer, RESTORE_ALL_PATH_SENTINELS, runRestore } from '../sync/restore.js'
|
||||
import { runStatus } from '../sync/status.js'
|
||||
import { runUpdateTranscriptionMemo } from '../sync/update-transcription-memo.js'
|
||||
import { writeWorkspaceIndexMarkdown } from '../sync/workspace-index.js'
|
||||
import { error, formatThrownValue, info, success, warn } from '../ui/output.js'
|
||||
|
||||
@@ -238,48 +237,6 @@ export async function runCli(argv: string[]): Promise<void> {
|
||||
},
|
||||
)
|
||||
|
||||
const transcriptionCmd = program
|
||||
.command('transcription')
|
||||
.description(
|
||||
'Управление транскрипциями звонков: публикация краткого содержания (memo) на сервер (отображается в desktop на странице транскрипции).',
|
||||
)
|
||||
|
||||
transcriptionCmd
|
||||
.command('memo')
|
||||
.description(
|
||||
'Опубликовать краткое содержание (memo) для транскрипции звонка через GraphQL-мутацию chatcoopUpdateTranscriptionMemo. <pathOrId>: путь к `meetings/<stem>.md` (id берётся из .blago/index.json, entityType=call_transcription) или UUID транскрипции. Без --text/--file читает sibling-файл `<stem>.memo.md` рядом с meeting. Доступно председателю и членам совета.',
|
||||
)
|
||||
.argument(
|
||||
'<pathOrId>',
|
||||
'путь к meeting-файлу или UUID транскрипции',
|
||||
)
|
||||
.option('--file <path>', 'путь к файлу с текстом memo (markdown)')
|
||||
.option('--text <inline>', 'текст memo строкой (приоритет над --file и sibling)')
|
||||
.action(
|
||||
async (
|
||||
pathOrId: string,
|
||||
opts: { file?: string, text?: string },
|
||||
) => {
|
||||
const root = requireRoot()
|
||||
const cfg = await loadConfig(root)
|
||||
const ctx = await ensureAuthenticatedContext(root, cfg)
|
||||
const result = await runUpdateTranscriptionMemo(
|
||||
ctx,
|
||||
pathOrId,
|
||||
{ inlineText: opts.text, filePath: opts.file },
|
||||
startDir(),
|
||||
)
|
||||
const sourceLabel = result.memoSource === 'inline'
|
||||
? '--text'
|
||||
: result.memoSource === 'file'
|
||||
? `--file ${result.memoFile ?? ''}`
|
||||
: `sibling ${result.memoFile ?? ''}`
|
||||
success(
|
||||
`Memo обновлено на сервере: транскрипция ${result.transcriptionId} (символов: ${result.memoLength}; источник: ${sourceLabel}).`,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const delCmd = program
|
||||
.command('del')
|
||||
.alias('delete')
|
||||
@@ -362,9 +319,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() : '—'}`,
|
||||
|
||||
@@ -14,8 +14,6 @@ export type BlagoEntityType =
|
||||
| 'room_message_day'
|
||||
/** Файл `meetings/*.md` (транскрипция звонка), только pull с сервера */
|
||||
| 'call_transcription'
|
||||
/** Файл `meetings/<stem>.memo.md` — краткое содержание звонка; pull с сервера + публикация через `blago transcription memo` */
|
||||
| 'call_transcription_memo'
|
||||
|
||||
export interface IndexEntry {
|
||||
entity_type: BlagoEntityType
|
||||
|
||||
@@ -3,16 +3,11 @@
|
||||
import type { AuthenticatedContext } from '../session/index.js'
|
||||
import type { IndexFile } from './index-store.js'
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
import { Queries, Zeus } from '@coopenomics/sdk'
|
||||
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
import { formatThrownValue, warn } from '../ui/output.js'
|
||||
import { findByHash, normalizeRelativePath, upsertEntry } from './index-store.js'
|
||||
|
||||
import {
|
||||
loadCommunicationCursors,
|
||||
@@ -25,7 +20,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 {
|
||||
@@ -47,121 +42,6 @@ function messageDayEntityHash(projectHash: string, utcDate: string): string {
|
||||
return createHash('sha256').update(`${projectHash}:${utcDate}`, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
function transcriptionMemoEntityHash(transcriptionId: string): string {
|
||||
return `${transcriptionId.toLowerCase()}:memo`
|
||||
}
|
||||
|
||||
async function readFileIfExists(abs: string): Promise<string | null> {
|
||||
try {
|
||||
return await fs.readFile(abs, 'utf8')
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMemoContent(memo: string): string {
|
||||
if (memo.length === 0) {
|
||||
return ''
|
||||
}
|
||||
return memo.endsWith('\n') ? memo : `${memo}\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Гарантирует наличие файла `meetings/<stem>.memo.md` после pull.
|
||||
*
|
||||
* Файл создаётся **всегда** (даже если на сервере memo пуст), чтобы пользователь мог редактировать
|
||||
* sibling «в одно касание», а команда `blago transcription memo` всегда находила его. Конфликты с
|
||||
* локальными правками разруливаются:
|
||||
*
|
||||
* - индекс есть → штатный `syncEntityFile` (он умеет merge-markers «<<<<<<< blago/local …»);
|
||||
* - индекса нет, файла нет → пишем серверный memo и индексируем (baseline = сервер);
|
||||
* - индекса нет, файл есть, содержимое совпадает с сервером → просто индексируем (baseline = совпавший контент);
|
||||
* - индекса нет, файл есть, серверный memo пустой → оставляем локальный черновик и индексируем его (baseline = локальный);
|
||||
* - индекса нет, файл есть, оба непустые и различаются → пишем merge-markers, индексируем merged.
|
||||
*/
|
||||
async function syncTranscriptionMemoFile(params: {
|
||||
root: string
|
||||
index: IndexFile
|
||||
transcriptionId: string
|
||||
relativePath: string
|
||||
serverMemo: string
|
||||
remoteUpdatedAtIso: string
|
||||
}): Promise<void> {
|
||||
const { root, index, transcriptionId, relativePath, serverMemo, remoteUpdatedAtIso } = params
|
||||
const rel = normalizeRelativePath(relativePath)
|
||||
const abs = path.join(root, rel)
|
||||
const entityHash = transcriptionMemoEntityHash(transcriptionId)
|
||||
const serverContent = normalizeMemoContent(serverMemo)
|
||||
const prev = findByHash(index, 'call_transcription_memo', entityHash)
|
||||
|
||||
if (prev) {
|
||||
await syncEntityFile({
|
||||
root,
|
||||
index,
|
||||
entityType: 'call_transcription_memo',
|
||||
entityHash,
|
||||
relativePath: rel,
|
||||
content: serverContent,
|
||||
remoteUpdatedAt: remoteUpdatedAtIso,
|
||||
label: `memo транскрипции ${transcriptionId}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true })
|
||||
const local = await readFileIfExists(abs)
|
||||
|
||||
if (local === null) {
|
||||
await fs.writeFile(abs, serverContent, 'utf8')
|
||||
upsertEntry(index, {
|
||||
entity_type: 'call_transcription_memo',
|
||||
entity_hash: entityHash,
|
||||
relative_path: rel,
|
||||
remote_updated_at: remoteUpdatedAtIso,
|
||||
content_etag_local: sha256Hex(serverContent),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (local === serverContent) {
|
||||
upsertEntry(index, {
|
||||
entity_type: 'call_transcription_memo',
|
||||
entity_hash: entityHash,
|
||||
relative_path: rel,
|
||||
remote_updated_at: remoteUpdatedAtIso,
|
||||
content_etag_local: sha256Hex(local),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (serverContent.trim().length === 0) {
|
||||
// Сервер ничего не знает — берём локальный черновик как baseline. Опубликовать его можно
|
||||
// через `blago transcription memo <meeting-path>`.
|
||||
upsertEntry(index, {
|
||||
entity_type: 'call_transcription_memo',
|
||||
entity_hash: entityHash,
|
||||
relative_path: rel,
|
||||
remote_updated_at: remoteUpdatedAtIso,
|
||||
content_etag_local: sha256Hex(local),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const merged = `<<<<<<< blago/local\n${local}\n=======\n${serverContent}\n>>>>>>> blago/remote\n`
|
||||
await fs.writeFile(abs, merged, 'utf8')
|
||||
upsertEntry(index, {
|
||||
entity_type: 'call_transcription_memo',
|
||||
entity_hash: entityHash,
|
||||
relative_path: rel,
|
||||
remote_updated_at: remoteUpdatedAtIso,
|
||||
content_etag_local: sha256Hex(merged),
|
||||
})
|
||||
warn(
|
||||
`Конфликт memo транскрипции ${transcriptionId}: локальный черновик и серверная версия различаются. В «${rel}» записаны маркеры слияния («<<<<<<< blago/local» … «>>>>>>> blago/remote»). Оставьте одну версию текста и опубликуйте через «blago transcription memo».`,
|
||||
)
|
||||
}
|
||||
|
||||
function dateFromUnknown(value: unknown): Date | undefined {
|
||||
if (value === null || value === undefined) {
|
||||
return undefined
|
||||
@@ -183,19 +63,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 +78,6 @@ export async function pullProjectCommunicationArtifacts(
|
||||
cursors = {
|
||||
messageLastTsByRoom: {},
|
||||
transcriptionLastEndedExclusiveByProject: {},
|
||||
transcriptionLastEndedExclusiveByRoom: {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,16 +184,13 @@ export async function pullProjectCommunicationArtifacts(
|
||||
try {
|
||||
const tKey = row.project_hash
|
||||
const tExIso = cursors.transcriptionLastEndedExclusiveByProject[tKey]
|
||||
// Курсор `transcriptionLastEndedExclusiveByProject` влияет ТОЛЬКО на скачивание meeting.md
|
||||
// (тяжёлый GetTranscription с сегментами). Sibling-файл `.memo.md` синхронизируется для всех
|
||||
// COMPLETED-транскрипций каждый pull — поле `memo` приходит в лёгком GetTranscriptions.
|
||||
// Как сообщения с after=0: без курсора — полная выгрузка завершённых транскрипций в meetings/.
|
||||
// (GitHub-синк при первом запуске только ставит курсор без файлов — для локального зеркала так не делаем.)
|
||||
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>()
|
||||
for (const roomId of matrixIds) {
|
||||
@@ -340,23 +203,18 @@ export async function pullProjectCommunicationArtifacts(
|
||||
if (t.status !== Zeus.TranscriptionStatus.COMPLETED || !end) {
|
||||
continue
|
||||
}
|
||||
if (!(end.getTime() > lowerBoundExclusive.getTime())) {
|
||||
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),
|
||||
})
|
||||
byId.set(t.id, { id: t.id, endedAt: end })
|
||||
}
|
||||
}
|
||||
}
|
||||
const allCompleted = [...byId.values()].sort((a, b) => a.endedAt.getTime() - b.endedAt.getTime())
|
||||
|
||||
// 1) meeting.md — только новые после курсора (тяжёлый GetTranscription с сегментами).
|
||||
const newMeetings = allCompleted.filter(c => c.endedAt.getTime() > lowerBoundExclusive.getTime())
|
||||
const candidates = [...byId.values()].sort((a, b) => a.endedAt.getTime() - b.endedAt.getTime())
|
||||
let maxEnded: Date | null = null
|
||||
for (const c of newMeetings) {
|
||||
for (const c of candidates) {
|
||||
const packQ = await ctx.client.Query(Queries.ChatCoop.GetTranscription.query, {
|
||||
variables: { data: { id: c.id } },
|
||||
})
|
||||
@@ -370,6 +228,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,
|
||||
@@ -394,28 +253,10 @@ export async function pullProjectCommunicationArtifacts(
|
||||
remoteUpdatedAt: toUpdatedIso(c.endedAt),
|
||||
label: `транскрипция ${c.id}`,
|
||||
})
|
||||
|
||||
if (!maxEnded || c.endedAt > maxEnded) {
|
||||
maxEnded = c.endedAt
|
||||
}
|
||||
}
|
||||
|
||||
// 2) sibling .memo.md — для ВСЕХ COMPLETED-транскрипций (бэкфил независимо от курсора).
|
||||
// Поле tr.memo пришло в лёгком GetTranscriptions, повторных запросов не делаем.
|
||||
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.transcriptionLastEndedExclusiveByProject[tKey] = maxEnded.toISOString()
|
||||
}
|
||||
@@ -435,207 +276,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,
|
||||
|
||||
@@ -4,34 +4,11 @@ import type { BlagoEntityType, IndexFile } from './index-store.js'
|
||||
import * as fs from 'node:fs/promises'
|
||||
|
||||
import * as path from 'node:path'
|
||||
import { parseBlagoMarkdown, serializeBlagoMarkdown } from '../format/index.js'
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
|
||||
import { warn } from '../ui/output.js'
|
||||
import { findByHash, normalizeRelativePath, upsertEntry } from './index-store.js'
|
||||
|
||||
/**
|
||||
* Канонический вид для сравнения «есть ли реальный конфликт», без волатильных
|
||||
* серверных меток времени. updated_at/created_at сервер бьёт при дочерних мутациях
|
||||
* (создание/удаление issue/story у родителя), и они попадают и во frontmatter, и в
|
||||
* content_etag_local. Из-за этого raw-sha файла расходится с etag, локальный файл
|
||||
* считается «грязным», а pull при изменившемся remote_updated_at пишет маркеры слияния —
|
||||
* хотя содержательно текст совпадает. Сравнение в каноне (без updated_at/created_at)
|
||||
* отличает настоящую правку от чистого bump'а времени.
|
||||
*/
|
||||
function canonicalForCompare(raw: string): string {
|
||||
try {
|
||||
const parsed = parseBlagoMarkdown(raw)
|
||||
const data = { ...parsed.data }
|
||||
delete data.updated_at
|
||||
delete data.created_at
|
||||
return serializeBlagoMarkdown(data, parsed.body)
|
||||
}
|
||||
catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDirForFile(absFile: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(absFile), { recursive: true })
|
||||
}
|
||||
@@ -162,21 +139,6 @@ export async function syncEntityFile(params: {
|
||||
const dirty
|
||||
= current !== null && current !== undefined && sha256Hex(current) !== prev.content_etag_local
|
||||
if (dirty && remoteUpdatedAt !== prev.remote_updated_at) {
|
||||
// Реальный конфликт — только если содержимое расходится вне волатильных меток времени.
|
||||
// Если локальный и серверный тексты совпадают по канону (отличие лишь в updated_at/created_at),
|
||||
// это не конфликт: принимаем серверную версию и лечим etag, без маркеров слияния.
|
||||
if (current !== null && current !== undefined && canonicalForCompare(current) === canonicalForCompare(content)) {
|
||||
await ensureDirForFile(absNew)
|
||||
await fs.writeFile(absNew, content, 'utf8')
|
||||
upsertEntry(index, {
|
||||
entity_type: entityType,
|
||||
entity_hash: entityHash,
|
||||
relative_path: rel,
|
||||
remote_updated_at: remoteUpdatedAt,
|
||||
content_etag_local: sha256Hex(await fs.readFile(absNew, 'utf8')),
|
||||
})
|
||||
return
|
||||
}
|
||||
const merged = wrapMergeConflictMarkers(current ?? '', content)
|
||||
await ensureDirForFile(absNew)
|
||||
await fs.writeFile(absNew, merged, 'utf8')
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
// Публикация поля memo транскрипции на сервер (мутация chatcoopUpdateTranscriptionMemo).
|
||||
//
|
||||
// Связь meeting-файла с UUID транскрипции: `.blago/index.json` хранит entity_type=call_transcription
|
||||
// и entity_hash=<uuid в lower-case> для каждого `meetings/<stem>.md` (см. pull-communication.ts).
|
||||
//
|
||||
// Текст memo берётся:
|
||||
// 1) --text "<inline>" — приоритет;
|
||||
// 2) --file <path> — читать markdown из файла;
|
||||
// 3) sibling .memo.md — если <pathOrId> это meeting-файл, берём `<meetings>/<stem>.memo.md` рядом.
|
||||
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { Mutations } from '@coopenomics/sdk'
|
||||
|
||||
import type { AuthenticatedContext } from '../session/index.js'
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
import {
|
||||
findByRelativePath,
|
||||
type IndexFile,
|
||||
loadIndex,
|
||||
normalizeRelativePath,
|
||||
saveIndex,
|
||||
upsertEntry,
|
||||
} from './index-store.js'
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
export interface UpdateTranscriptionMemoOptions {
|
||||
readonly inlineText?: string
|
||||
readonly filePath?: string
|
||||
}
|
||||
|
||||
export interface UpdateTranscriptionMemoResult {
|
||||
readonly transcriptionId: string
|
||||
readonly memoLength: number
|
||||
readonly memoSource: 'inline' | 'file' | 'sibling'
|
||||
readonly memoFile?: string
|
||||
}
|
||||
|
||||
/** Пара значений: id транскрипции для мутации + опциональный «meeting абсолютный путь» для sibling-резолва. */
|
||||
interface ResolvedTranscription {
|
||||
readonly id: string
|
||||
readonly meetingAbsPath?: string
|
||||
}
|
||||
|
||||
function resolveTranscription(
|
||||
ctxRoot: string,
|
||||
cwd: string,
|
||||
pathOrId: string,
|
||||
index: IndexFile,
|
||||
): ResolvedTranscription {
|
||||
const trimmed = pathOrId.trim()
|
||||
if (UUID_RE.test(trimmed)) {
|
||||
return { id: trimmed.toLowerCase() }
|
||||
}
|
||||
const abs = path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed)
|
||||
const rel = path.relative(ctxRoot, abs)
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
throw new Error(
|
||||
`Путь «${pathOrId}» вне рабочей копии blago (${ctxRoot}). Передайте путь относительно корня копии или UUID транскрипции.`,
|
||||
)
|
||||
}
|
||||
const entry = findByRelativePath(index, normalizeRelativePath(rel))
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`В .blago/index.json нет записи для «${rel}». Выполните «blago pull», чтобы подтянуть транскрипции.`,
|
||||
)
|
||||
}
|
||||
if (entry.entity_type !== 'call_transcription') {
|
||||
throw new Error(
|
||||
`Файл «${rel}» имеет тип «${entry.entity_type}», а не «call_transcription». Передайте путь к meeting-файлу или UUID.`,
|
||||
)
|
||||
}
|
||||
return { id: entry.entity_hash, meetingAbsPath: abs }
|
||||
}
|
||||
|
||||
function buildSiblingMemoPath(meetingAbsPath: string): string {
|
||||
const dir = path.dirname(meetingAbsPath)
|
||||
const ext = path.extname(meetingAbsPath)
|
||||
const stem = path.basename(meetingAbsPath, ext)
|
||||
return path.join(dir, `${stem}.memo.md`)
|
||||
}
|
||||
|
||||
async function readMemoText(
|
||||
cwd: string,
|
||||
resolved: ResolvedTranscription,
|
||||
options: UpdateTranscriptionMemoOptions,
|
||||
): Promise<{ text: string, source: UpdateTranscriptionMemoResult['memoSource'], file?: string }> {
|
||||
if (typeof options.inlineText === 'string') {
|
||||
return { text: options.inlineText, source: 'inline' }
|
||||
}
|
||||
if (options.filePath !== undefined && options.filePath.length > 0) {
|
||||
const abs = path.isAbsolute(options.filePath) ? options.filePath : path.resolve(cwd, options.filePath)
|
||||
const text = await fs.readFile(abs, 'utf8')
|
||||
return { text, source: 'file', file: abs }
|
||||
}
|
||||
if (!resolved.meetingAbsPath) {
|
||||
throw new Error(
|
||||
'Не указан текст memo. Передайте --text "<строка>" или --file <путь>, либо первым аргументом — путь к meeting-файлу (тогда читается `<stem>.memo.md` рядом).',
|
||||
)
|
||||
}
|
||||
const siblingAbs = buildSiblingMemoPath(resolved.meetingAbsPath)
|
||||
try {
|
||||
const text = await fs.readFile(siblingAbs, 'utf8')
|
||||
return { text, source: 'sibling', file: siblingAbs }
|
||||
}
|
||||
catch {
|
||||
throw new Error(
|
||||
`Рядом с «${path.basename(resolved.meetingAbsPath)}» нет файла «${path.basename(siblingAbs)}». Выполните «blago pull» (он создаст sibling-файл; пустой, если на сервере memo пустой), затем отредактируйте и повторите команду — либо передайте --file / --text.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function toIso(v: unknown, fallback: Date): string {
|
||||
if (v instanceof Date) {
|
||||
return v.toISOString()
|
||||
}
|
||||
if (typeof v === 'string' || typeof v === 'number') {
|
||||
const d = new Date(v)
|
||||
if (!Number.isNaN(d.getTime())) {
|
||||
return d.toISOString()
|
||||
}
|
||||
}
|
||||
return fallback.toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* После успешной мутации записываем `meetings/<stem>.memo.md` рядом с meeting-файлом и
|
||||
* заносим запись в `.blago/index.json` с типом `call_transcription_memo`, чтобы:
|
||||
* - следующий `blago pull` видел индексированный файл и шёл по штатной ветке syncEntityFile;
|
||||
* - `blago push` его не подхватывал — он внутри `meetings/`, исключённого через `pull-only-paths`.
|
||||
* Это шаг только при наличии meeting-пути (sibling-резолв из <pathOrId>); UUID-режим без файла — пропускаем.
|
||||
*/
|
||||
async function persistSiblingAndIndex(
|
||||
ctx: AuthenticatedContext,
|
||||
resolved: ResolvedTranscription,
|
||||
memoText: string,
|
||||
remoteUpdatedAtIso: string,
|
||||
): Promise<{ siblingPath: string } | null> {
|
||||
if (!resolved.meetingAbsPath) {
|
||||
return null
|
||||
}
|
||||
const ext = path.extname(resolved.meetingAbsPath)
|
||||
const stem = path.basename(resolved.meetingAbsPath, ext)
|
||||
const siblingAbs = path.join(path.dirname(resolved.meetingAbsPath), `${stem}.memo.md`)
|
||||
const content = memoText.endsWith('\n') ? memoText : `${memoText}\n`
|
||||
await fs.mkdir(path.dirname(siblingAbs), { recursive: true })
|
||||
await fs.writeFile(siblingAbs, content, 'utf8')
|
||||
|
||||
const rel = path.relative(ctx.root, siblingAbs)
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
return { siblingPath: siblingAbs }
|
||||
}
|
||||
const index = await loadIndex(ctx.root)
|
||||
upsertEntry(index, {
|
||||
entity_type: 'call_transcription_memo',
|
||||
entity_hash: `${resolved.id}:memo`,
|
||||
relative_path: normalizeRelativePath(rel),
|
||||
remote_updated_at: remoteUpdatedAtIso,
|
||||
content_etag_local: sha256Hex(content),
|
||||
})
|
||||
await saveIndex(ctx.root, index)
|
||||
return { siblingPath: siblingAbs }
|
||||
}
|
||||
|
||||
export async function runUpdateTranscriptionMemo(
|
||||
ctx: AuthenticatedContext,
|
||||
pathOrId: string,
|
||||
options: UpdateTranscriptionMemoOptions,
|
||||
cwd: string,
|
||||
): Promise<UpdateTranscriptionMemoResult> {
|
||||
const index = await loadIndex(ctx.root)
|
||||
const resolved = resolveTranscription(ctx.root, cwd, pathOrId, index)
|
||||
const memo = await readMemoText(cwd, resolved, options)
|
||||
|
||||
const mutationName = Mutations.ChatCoop.UpdateTranscriptionMemo.name
|
||||
const response = await ctx.client.Mutation(Mutations.ChatCoop.UpdateTranscriptionMemo.mutation, {
|
||||
variables: { data: { id: resolved.id, memo: memo.text } },
|
||||
})
|
||||
const row = response[mutationName] as { updatedAt?: unknown } | undefined
|
||||
if (!row) {
|
||||
throw new Error('Сервер вернул пустой ответ на chatcoopUpdateTranscriptionMemo.')
|
||||
}
|
||||
|
||||
const remoteUpdatedAtIso = toIso(row.updatedAt, new Date())
|
||||
const persisted = await persistSiblingAndIndex(ctx, resolved, memo.text, remoteUpdatedAtIso)
|
||||
|
||||
return {
|
||||
transcriptionId: resolved.id,
|
||||
memoLength: memo.text.length,
|
||||
memoSource: memo.source,
|
||||
memoFile: persisted?.siblingPath ?? memo.file,
|
||||
}
|
||||
}
|
||||
@@ -35,14 +35,7 @@ RUN npm install -g pnpm@10.33.0
|
||||
|
||||
COPY . .
|
||||
|
||||
# --ignore-scripts + точечный rebuild: --filter ограничивает только то, какие
|
||||
# проекты получают node_modules, но НЕ то, для каких пакетов общего lockfile
|
||||
# store выполняет lifecycle-скрипты при --frozen-lockfile. Без --ignore-scripts
|
||||
# сюда тянутся electron/puppeteer (из @coopenomics/desktop/notifications,
|
||||
# boot их не использует) — тяжёлые сетевые загрузки, изредка валящие билд по
|
||||
# TLS-таймауту. rebuild --filter уже честно скопирован на подграф boot.
|
||||
RUN pnpm install --frozen-lockfile --filter "@coopenomics/boot..." --ignore-scripts \
|
||||
&& pnpm rebuild --filter "@coopenomics/boot..."
|
||||
RUN pnpm install --frozen-lockfile --filter "@coopenomics/boot..."
|
||||
|
||||
# Билд графа: cooptypes/factory собирают свои dist/, boot собирает
|
||||
# свой bundle с inlined workspace-deps.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@coopenomics/boot",
|
||||
"type": "module",
|
||||
"version": "2026.7.21-1",
|
||||
"version": "2026.5.15-5",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
"description": "CLI-утилита инициализации блокчейна и кооператива",
|
||||
@@ -26,7 +26,6 @@
|
||||
"boot:remote": "esno src/index.ts boot:remote",
|
||||
"reboot:clean": "cd components/boot/scripts && bash clean_reboot.sh",
|
||||
"reboot:extra": "cd components/boot/scripts && bash extra_reboot.sh",
|
||||
"reboot:blago": "cd components/boot/scripts && bash blago_reboot.sh",
|
||||
"create-coop": "esno src/index.ts create-coop",
|
||||
"clear": "./scripts/clear.sh",
|
||||
"start": "./scripts/restart.sh",
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Полный перезапуск dev-среды для теста Благороста одной командой:
|
||||
# 1) extra_reboot.sh — чистая цепь + кооператив + совет 5 человек +
|
||||
# dev-shortcut онбординга capital (extensions.capital: все *_done=true);
|
||||
# 2) ожидание готовности controller (GraphQL отвечает);
|
||||
# 3) seed-capital до фазы $SEED_UP_TO (default 04-contributor): программы
|
||||
# УХД/Благорост + setconfig, проекты, регистрация ant как участника.
|
||||
#
|
||||
# Глубину сида можно менять: SEED_UP_TO=08-investments pnpm run reboot:blago
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
if [ -f "$ROOT_DIR/.env" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source "$ROOT_DIR/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
bash "$SCRIPT_DIR/extra_reboot.sh"
|
||||
|
||||
# Per-instance адрес controller'а — из корневого .env (API_URL); coopback после
|
||||
# force-recreate поднимается десятки секунд, seed-фазы без него падают.
|
||||
GRAPHQL_URL="${API_URL:-http://127.0.0.1:2998/v1/graphql}"
|
||||
echo "Ждём готовности controller ($GRAPHQL_URL)..."
|
||||
until curl -s --max-time 3 -o /dev/null -X POST -H 'Content-Type: application/json' \
|
||||
--data '{"query":"{__typename}"}' "$GRAPHQL_URL"; do
|
||||
sleep 3
|
||||
done
|
||||
echo "Controller готов."
|
||||
|
||||
UP_TO="${SEED_UP_TO:-04-contributor}"
|
||||
echo "Сидируем capital до фазы $UP_TO..."
|
||||
cd "$SCRIPT_DIR/.."
|
||||
CONTROLLER_GRAPHQL_URL="$GRAPHQL_URL" pnpm exec esno src/scripts/seed-capital/index.ts --up-to="$UP_TO"
|
||||
|
||||
echo "Готово: среда Благорост поднята (seed до $UP_TO)."
|
||||
@@ -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 "Перезапуск завершен!"
|
||||
|
||||
@@ -21,15 +21,15 @@ docker compose stop node || true
|
||||
# Удаляем 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/
|
||||
|
||||
# Пересоздаем и запускаем базы данных
|
||||
echo "Пересоздаем и запускаем базы данных..."
|
||||
docker compose up -d mongo postgres monoredis
|
||||
|
||||
# Ждем готовности 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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -128,9 +128,4 @@ export default [
|
||||
path: path.join(userBase, 'apps'),
|
||||
target: 'apps',
|
||||
},
|
||||
{
|
||||
name: 'expense',
|
||||
path: path.join(userBase, 'expense'),
|
||||
target: 'expense',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -154,10 +154,6 @@ export default {
|
||||
name: 'apps',
|
||||
code_permissions_to: ['apps'],
|
||||
},
|
||||
{
|
||||
name: 'expense',
|
||||
code_permissions_to: ['expense'],
|
||||
},
|
||||
// {
|
||||
// name: provider_chairman,
|
||||
// },
|
||||
|
||||
@@ -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,33 +0,0 @@
|
||||
{
|
||||
"protocol_feature_type": "builtin",
|
||||
"dependencies": [
|
||||
"09e86cb0accf8d81c9e85d34bea4b925ae936626d00c984e4691186891f5bc16",
|
||||
"1a99a59d87e06e09ec5b028a9cbb7749b4a5ad8819004365d02dc4379a8b7241",
|
||||
"2652f5f96006294109b3dd0bbde63693f55324af452b799ee137a81a905eed25",
|
||||
"299dcb6af692324b899b39f16d5a530a33062804e41f09dc97e9f156b4476707",
|
||||
"35c2186cc36f7bb4aeaf4487b36e57039ccf45a9136aa856a5d569ecca55ef2b",
|
||||
"4a90c00d55454dc5b059055ca213579c6ea856967712a56017487886a4d4cc0f",
|
||||
"4e7bf348da00a945489b2a681749eb56f5de00b900014e137ddae39f48f69d67",
|
||||
"4fca8bd82bbd181e714e283f83e1b45d95ca5af40fb89ad3977b653c448f78c2",
|
||||
"5443fcf88330c586bc0e5f3dee10e7f63c76c00249c87fe4fbf7f38c082006b4",
|
||||
"63320dd4a58212e4d32d1f58926b73ca33a247326c2a5e9fd39268d2384e011a",
|
||||
"68dcaa34c0517d19666e6b33add67351d8c5f69e999ca1e37931bc410a297428",
|
||||
"6bcb40a24e49c26d0a60513b6aeb8551d264e4717f306b81a37a5afb3b47cedc",
|
||||
"8ba52fe7a3956c5cd3a656a3174b931d3bb2abb45578befc59f283ecd816a405",
|
||||
"ad9e3d8f650687709fd68f4b90b41f7d825a365b02c23a636cef88ac2ac00c43",
|
||||
"bcd2a26394b36614fd4894241d3c451ab0f6fd110958c3423073621a70826e99",
|
||||
"c3a6138c5061cf291310887c0b5c71fcaffeab90d5deb50d3b9e687cead45071",
|
||||
"d528b9f6e9693f45ed277af93474fd473ce7d831dae2180cca35d907bd10cb40",
|
||||
"e0fb64b1085cc5538970158d05a009c24e276fb94e1a0bf6a528b48fbc4ff526",
|
||||
"ef43112c6543b88db2283a2e077278c315ae2c84719a8b25f25cc88565fbea99",
|
||||
"f0af56d2c5a48d60a4a5b5c903edfb7db3a736a94ed589d0b797df33ff9d3e1d",
|
||||
"fce57d2331667353a0eac6b4209b67b843a7262a848af0a49a6e2fa9f6584eb4"
|
||||
],
|
||||
"description_digest": "01a8bebb4922c38293a8909668f7d00ba4a21a455c69719ad7dcc33ea1e631c0",
|
||||
"subjective_restrictions": {
|
||||
"earliest_allowed_activation_time": "1970-01-01T00:00:00.000",
|
||||
"preactivation_required": true,
|
||||
"enabled": true
|
||||
},
|
||||
"builtin_feature_codename": "SAVANNA"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -4,8 +4,6 @@ import config, { GOVERN_SYMBOL, SYMBOL } from '../configs'
|
||||
import Blockchain from '../blockchain'
|
||||
import { generateRandomSHA256 } from '../utils/randomHash'
|
||||
import { processLastDecision } from '../tests/soviet/processLastDecision'
|
||||
import { signProgramAgreement } from './sign-program-agreement'
|
||||
import { walletDraftId, walletProgramId } from '../tests/capital/consts'
|
||||
|
||||
const test_hash
|
||||
= '157192b276da23cc84ab078fc8755c051c5f0430bf4802e55718221e6b76c777'
|
||||
@@ -76,7 +74,7 @@ export class CooperativeClass {
|
||||
await this.blockchain.createProgram({
|
||||
coopname,
|
||||
username: coopname,
|
||||
type: 'capital',
|
||||
type: 'blagorost',
|
||||
title: 'Целевая потребительская программа "Благорост"',
|
||||
announce: '',
|
||||
description: '',
|
||||
@@ -158,17 +156,13 @@ export class CooperativeClass {
|
||||
|
||||
console.log('Отправляем подписанное положение о ЦПП Кошелька оператору')
|
||||
|
||||
// После Эпика 2 / компонента 48 soviet::sndagreement отказывается на
|
||||
// program_id > 0; программные соглашения подписываются через
|
||||
// wallet::signagree (auth: coopname@active).
|
||||
await signProgramAgreement(
|
||||
this.blockchain,
|
||||
config.provider,
|
||||
username!,
|
||||
walletProgramId,
|
||||
walletDraftId,
|
||||
await this.blockchain.sendAgreement({
|
||||
coopname: config.provider,
|
||||
administrator: config.provider,
|
||||
username: username!,
|
||||
agreement_type: 'wallet',
|
||||
document,
|
||||
)
|
||||
})
|
||||
|
||||
console.log('Переводим аккаунт в кооператив')
|
||||
|
||||
@@ -178,8 +172,8 @@ export class CooperativeClass {
|
||||
params: {
|
||||
is_cooperative: true,
|
||||
coop_type: 'conscoop',
|
||||
announce: 'voskhod-dev.coopenomics.world',
|
||||
description: 'Восход — тестовый кооператив-оператор (dev-стенд)',
|
||||
announce: 'Тестовый кооператив',
|
||||
description: 'Тестовое описание',
|
||||
initial: `100.0000 ${config.token.govern_symbol}`,
|
||||
minimum: `300.0000 ${config.token.govern_symbol}`,
|
||||
org_initial: `1000.0000 ${config.token.govern_symbol}`,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import axios from 'axios'
|
||||
import ecc from 'eosjs-ecc'
|
||||
import { Generator, Registry } from '@coopenomics/factory'
|
||||
import type { Cooperative } from 'cooptypes'
|
||||
import { DraftContract } from 'cooptypes'
|
||||
@@ -15,12 +11,7 @@ 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)
|
||||
import { generateRandomSHA256 } from '../utils/randomHash'
|
||||
|
||||
export async function startInfra() {
|
||||
// инициализируем инстанс с ключами
|
||||
@@ -581,189 +572,7 @@ export async function installInitialData(blockchain: Blockchain, isExtended = fa
|
||||
}
|
||||
|
||||
export async function installExtraData(blockchain: Blockchain) {
|
||||
// Регистрируем partner1 как coop с авто-approve от провайдера (Восхода).
|
||||
// Без этого реальный UI flow требует ручной активации председателем —
|
||||
// провайдер не подтянет partner1 в свою DB пока в registrator.coops его
|
||||
// нет. На dev-стенде хотим иметь готовый partner1 active сразу после
|
||||
// `reboot:extra`, чтобы прыгать из 0 → 8 (Hostkey rent → install → ACTIVE)
|
||||
// одним нажатием.
|
||||
//
|
||||
// Упрощённый flow (без sendAgreement через soviet — он отказывается на
|
||||
// program_id > 0 с проверкой «подписывается через wallet::signagree»).
|
||||
// Минимум для попадания в registrator.coops со status=active:
|
||||
// newaccount → reguser(type=organization) → regcoop → stcoopstatus(active)
|
||||
console.log('\n=== installExtraData: регистрируем partner1 как coop (active) ===')
|
||||
|
||||
const username = 'partner1'
|
||||
|
||||
// Берём WIF из docs-harness/state/cooperatives/partner1.json, если он там есть.
|
||||
// Без этого boot:extra на каждом запуске генерирует случайный privateKey,
|
||||
// а harness 08 (chairman install wizard /partner1/install) подаёт WIF из
|
||||
// фикстуры на шаге 1 — они расходятся, startInstall mutation не принимает
|
||||
// ключ, wizard зависает на шаге 1 без видимой ошибки. На репозиторий
|
||||
// фикстура коммит'ится разработчиком вручную; здесь только читаем.
|
||||
let seededKeys: { privateKey: string; publicKey: string } | undefined
|
||||
try {
|
||||
const fixturePath = resolve(
|
||||
__dirname,
|
||||
'../../../docs-harness/state/cooperatives/partner1.json',
|
||||
)
|
||||
if (existsSync(fixturePath)) {
|
||||
const fx = JSON.parse(readFileSync(fixturePath, 'utf-8'))
|
||||
if (fx?.wif) {
|
||||
// publicKey не хранится в фикстуре — деривим из приватного.
|
||||
const pub = fx.publicKey || await ecc.privateToPublic(fx.wif)
|
||||
seededKeys = { privateKey: fx.wif, publicKey: pub }
|
||||
console.log(`✓ partner1: используем seeded WIF из фикстуры (pub=${pub.slice(0, 12)}…)`)
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn(`⚠ partner1 fixture read failed (${e.message}) — генерируем новый ключ`)
|
||||
}
|
||||
|
||||
const account = await blockchain.generateKeypair(username, seededKeys, 'Аккаунт partner1')
|
||||
|
||||
await blockchain.createAccount({
|
||||
coopname: config.provider,
|
||||
referer: '',
|
||||
username: account.username,
|
||||
public_key: account.publicKey,
|
||||
meta: '',
|
||||
})
|
||||
|
||||
const registration_hash = generateRandomSHA256()
|
||||
await blockchain.registerUser({
|
||||
coopname: config.provider,
|
||||
braname: '',
|
||||
username: account.username,
|
||||
type: 'organization',
|
||||
statement: {
|
||||
hash: registration_hash,
|
||||
signatures: [],
|
||||
meta: '{}',
|
||||
version: '1.0.0',
|
||||
doc_hash: registration_hash,
|
||||
meta_hash: registration_hash,
|
||||
} as any,
|
||||
registration_hash,
|
||||
})
|
||||
|
||||
await blockchain.registerCooperative({
|
||||
username: account.username,
|
||||
coopname: account.username,
|
||||
params: {
|
||||
is_cooperative: true,
|
||||
coop_type: 'conscoop',
|
||||
announce: 'partner-dev.coopenomics.world',
|
||||
description: 'Партнёрский кооператив (тестовый dev-стенд)',
|
||||
initial: `100.0000 ${config.token.govern_symbol}`,
|
||||
minimum: `300.0000 ${config.token.govern_symbol}`,
|
||||
org_initial: `1000.0000 ${config.token.govern_symbol}`,
|
||||
org_minimum: `3000.0000 ${config.token.govern_symbol}`,
|
||||
},
|
||||
document: {
|
||||
hash: registration_hash,
|
||||
signatures: [],
|
||||
meta: '{}',
|
||||
version: '1.0.0',
|
||||
doc_hash: registration_hash,
|
||||
meta_hash: registration_hash,
|
||||
} as any,
|
||||
})
|
||||
|
||||
await blockchain.preInit({
|
||||
coopname: account.username,
|
||||
username: config.provider,
|
||||
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.
|
||||
await blockchain.powerup({
|
||||
payer: 'eosio',
|
||||
receiver: account.username,
|
||||
days: config.powerup.days,
|
||||
payment: `100.0000 ${config.token.symbol}`,
|
||||
transfer: true,
|
||||
})
|
||||
|
||||
// === Засев partner1 в воскход-coopback (Mongo organizations + paymentMethods + Postgres users) ===
|
||||
//
|
||||
// Provider при initSystem-инициализации partner1 запрашивает у воскход-coopback'а
|
||||
// приватные данные организации через GraphQL `getAccount(partner1).private_account`.
|
||||
// Resolver идёт по ветке `username != config.coopname` → userRepository.findByUsername
|
||||
// (Postgres users), затем organizationRepository.findByUsername (Mongo organizations).
|
||||
// Без этих записей resolver вернёт private_account=null → провайдер упадёт
|
||||
// «Unsupported account type: undefined» при подготовке organization_data.
|
||||
console.log('Засеваем partner1 как пайщика-organization в воскход-coopback')
|
||||
|
||||
const partnerEmail = `chairman.${account.username}@example.com`
|
||||
|
||||
const generator = new Generator()
|
||||
await generator.connect(process.env.MONGO_URI as string)
|
||||
|
||||
await generator.save('organization', {
|
||||
username: account.username,
|
||||
type: 'coop',
|
||||
short_name: 'ПК "Партнёр-1"',
|
||||
full_name: 'Потребительский Кооператив "Партнёр-1"',
|
||||
represented_by: {
|
||||
first_name: 'Иван',
|
||||
last_name: 'Иванов',
|
||||
middle_name: 'Иванович',
|
||||
position: 'Председатель',
|
||||
based_on: 'Решение общего собрания №1',
|
||||
},
|
||||
country: 'Российская Федерация',
|
||||
city: 'Москва',
|
||||
fact_address: '117593 г. Москва, ул. Тестовая, дом 1',
|
||||
full_address: '117593 г. Москва, ул. Тестовая, дом 1',
|
||||
email: partnerEmail,
|
||||
phone: '+71234567891',
|
||||
details: {
|
||||
inn: '7728130612',
|
||||
ogrn: '1247700283347',
|
||||
kpp: '772801002',
|
||||
},
|
||||
} as any)
|
||||
|
||||
await generator.save('paymentMethod', {
|
||||
is_default: true,
|
||||
method_id: randomUUID(),
|
||||
method_type: 'bank_transfer',
|
||||
username: account.username,
|
||||
data: {
|
||||
account_number: '40703810038000110118',
|
||||
currency: 'RUB',
|
||||
card_number: '',
|
||||
bank_name: 'ПАО Сбербанк',
|
||||
details: {
|
||||
bik: '044525225',
|
||||
corr: '30101810400000000225',
|
||||
kpp: '772801002',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await mongoose.disconnect().catch(() => {})
|
||||
|
||||
await initUsersInPostgres([{
|
||||
username: account.username,
|
||||
email: partnerEmail,
|
||||
type: 'organization',
|
||||
role: 'user',
|
||||
status: 'joined',
|
||||
is_registered: true,
|
||||
}])
|
||||
|
||||
console.log(`=== partner1 (${account.username}) засеян в воскход-coopback (Mongo+Postgres) ===\n`)
|
||||
// В расширенном режиме пайщики уже добавлены в installInitialData
|
||||
// Здесь можно добавить дополнительную логику инициализации если потребуется
|
||||
console.log('Дополнительная инициализация для расширенного режима выполнена')
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import config from '../configs'
|
||||
import axios from 'axios'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Registry } from '@coopenomics/factory'
|
||||
import { RegistratorContract, Cooperative as TCooperative } from 'cooptypes'
|
||||
import type { Account, Contract, Keys } from '../types'
|
||||
import config, { GOVERN_SYMBOL, SYMBOL } from '../configs'
|
||||
import Blockchain from '../blockchain'
|
||||
import { sendPostToCoopbackWithSecret, sleep } from '../utils'
|
||||
import { generateRandomSHA256 } from '../utils/randomHash'
|
||||
import { fakeDocument } from '../tests/shared/fakeDocument'
|
||||
import { signProgramAgreement } from './sign-program-agreement'
|
||||
import { walletDraftId, walletProgramId } from '../tests/capital/consts'
|
||||
import { RegistratorContract } from 'cooptypes'
|
||||
import type { Keys } from '../types'
|
||||
|
||||
export class ParticipantsClass {
|
||||
public blockchain: Blockchain
|
||||
@@ -155,17 +157,13 @@ export class ParticipantsClass {
|
||||
|
||||
console.log('Отправляем подписанное положение о ЦПП Кошелька оператору')
|
||||
|
||||
// После Эпика 2 / компонента 48 soviet::sndagreement отказывается на
|
||||
// program_id > 0; программные соглашения подписываются через
|
||||
// wallet::signagree (auth: coopname@active).
|
||||
await signProgramAgreement(
|
||||
this.blockchain,
|
||||
config.provider,
|
||||
username!,
|
||||
walletProgramId,
|
||||
walletDraftId,
|
||||
fakeDocument,
|
||||
)
|
||||
await this.blockchain.sendAgreement({
|
||||
coopname: config.provider,
|
||||
administrator: config.provider,
|
||||
username: username!,
|
||||
agreement_type: 'wallet',
|
||||
document: fakeDocument,
|
||||
})
|
||||
|
||||
console.log('создаём кошелёк')
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { SovietContract, WalletContract } from 'cooptypes'
|
||||
import { getTotalRamUsage } from '../utils/getTotalRamUsage'
|
||||
|
||||
async function getCoopProgramWallet(blockchain: any, coopname: string, program_id: number) {
|
||||
const program = await blockchain.getTableRows(
|
||||
SovietContract.contractName.production,
|
||||
coopname,
|
||||
'programs',
|
||||
1000,
|
||||
program_id.toString(),
|
||||
program_id.toString(),
|
||||
)
|
||||
|
||||
return program[0]
|
||||
}
|
||||
|
||||
interface UserProgramAgreement {
|
||||
program_id: number | string
|
||||
doc_hash: string
|
||||
version: number | string
|
||||
draft_id: number | string
|
||||
signed_at: string
|
||||
}
|
||||
|
||||
interface WalletUserRecord {
|
||||
username: string
|
||||
programs: UserProgramAgreement[]
|
||||
}
|
||||
|
||||
// Подписание программного соглашения через `wallet::signagree` (ADR-008, Эпик 2).
|
||||
// После компонента 48 `soviet::sndagreement` отказывается на program_id > 0 —
|
||||
// программные соглашения переехали в контракт wallet, источник правды
|
||||
// «участник ЦПП X» = `wallet::users[username].programs[]`.
|
||||
//
|
||||
// Auth: coopname@active (или любой системный контракт из contracts_whitelist
|
||||
// контракта wallet). В boot-flow ключ кооператива всегда есть в keychain,
|
||||
// подписываем coopname'ом.
|
||||
//
|
||||
// Живёт в init/, а не в tests/, чтобы не тянуть vitest в production-цепочке
|
||||
// `boot → init/cooperative → init/participant`. Тесты реэкспортируют отсюда.
|
||||
export async function signProgramAgreement(
|
||||
blockchain: any,
|
||||
coopname: string,
|
||||
username: string,
|
||||
program_id: number,
|
||||
draft_id: number,
|
||||
document: any,
|
||||
) {
|
||||
const data: WalletContract.Actions.SignAgreement.ISignAgreement = {
|
||||
coopname,
|
||||
username,
|
||||
program_id,
|
||||
document,
|
||||
draft_id,
|
||||
}
|
||||
|
||||
const result = await blockchain.api.transact(
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
account: WalletContract.contractName.production,
|
||||
name: WalletContract.Actions.SignAgreement.actionName,
|
||||
authorization: [{ actor: coopname, permission: 'active' }],
|
||||
data,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ blocksBehind: 3, expireSeconds: 30 },
|
||||
)
|
||||
|
||||
getTotalRamUsage(result)
|
||||
if (!result.transaction_id)
|
||||
throw new Error('wallet::signagree не вернул transaction_id')
|
||||
|
||||
const userRows = await blockchain.getTableRows(
|
||||
WalletContract.contractName.production,
|
||||
coopname,
|
||||
WalletContract.Tables.Users.tableName,
|
||||
1000,
|
||||
) as WalletUserRecord[]
|
||||
|
||||
const userRecord = userRows.find(r => r.username === username)
|
||||
if (!userRecord)
|
||||
throw new Error(`wallet::users[${coopname}, ${username}] должен быть создан после signagree`)
|
||||
|
||||
const signedProgram = userRecord.programs.find(p => Number(p.program_id) === program_id)
|
||||
if (!signedProgram)
|
||||
throw new Error(`wallet::users[${username}].programs[] должен содержать program_id=${program_id}`)
|
||||
|
||||
const program = await getCoopProgramWallet(blockchain, coopname, program_id)
|
||||
if (!program)
|
||||
throw new Error(`soviet::programs[${coopname}, ${program_id}] должна существовать`)
|
||||
|
||||
return {
|
||||
txId: result.transaction_id,
|
||||
wallet: { username, ...signedProgram },
|
||||
program,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Фаза 01 — программы УХД (id=3, type='generator') и Благорост
|
||||
* (id=4, type='capital') + capital::setconfig.
|
||||
* (id=4, type='blagorost') + capital::setconfig.
|
||||
*
|
||||
* Идемпотентно: программы создаются только если их ещё нет в `programs`-таблице,
|
||||
* setconfig — только если `state` пуст.
|
||||
@@ -88,7 +88,7 @@ export async function phase01(): Promise<void> {
|
||||
fixed_membership_contribution: '0.0000 RUB',
|
||||
membership_percent_fee: '0',
|
||||
meta: '',
|
||||
type: 'capital',
|
||||
type: 'blagorost',
|
||||
}
|
||||
await blockchain.api.transact({
|
||||
actions: [{
|
||||
|
||||
@@ -300,8 +300,8 @@ async function ensureAgreementsAndWallet(blockchain: Blockchain, fix: IFixture):
|
||||
}
|
||||
// Согласие с ЦПП «Благорост» — для инвестиций в программу/компонент.
|
||||
// Блокировка идёт по своему пути; для безопасности дёргаем при первом запуске.
|
||||
await signAgreement(blockchain, fix.username, 'capital').catch((e) => {
|
||||
log(`signAgreement capital для ${fix.username} — пропуск (${(e as Error).message?.slice(0, 80)})`)
|
||||
await signAgreement(blockchain, fix.username, 'blagorost').catch((e) => {
|
||||
log(`signAgreement blagorost для ${fix.username} — пропуск (${(e as Error).message?.slice(0, 80)})`)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -234,7 +234,7 @@ describe('тест контракта CAPITAL', () => {
|
||||
fixed_membership_contribution: '0.0000 RUB',
|
||||
membership_percent_fee: '0',
|
||||
meta: '',
|
||||
type: 'capital',
|
||||
type: 'blagorost',
|
||||
}
|
||||
|
||||
const result = await blockchain.api.transact(
|
||||
|
||||
@@ -213,7 +213,7 @@ describe('тест контракта CAPITAL', () => {
|
||||
fixed_membership_contribution: '0.0000 RUB',
|
||||
membership_percent_fee: '0',
|
||||
meta: '',
|
||||
type: 'capital',
|
||||
type: 'blagorost',
|
||||
}
|
||||
|
||||
const result = await blockchain.api.transact(
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
export const sourceProgramId = 3
|
||||
export const capitalProgramId = 4
|
||||
export const walletProgramId = 1
|
||||
|
||||
// draft_id для программных соглашений — соответствует мапе `program_map` из
|
||||
// `lib/core/programs.hpp` (wallet→1, source/generator→0, capital/blagorost→1000).
|
||||
// Используются при `wallet::signagree` (program_id > 0 после Эпика 2 / компонента 48
|
||||
// ушло из soviet::sndagreement и подписывается через wallet).
|
||||
export const walletDraftId = 1
|
||||
export const sourceDraftId = 0
|
||||
export const capitalDraftId = 1000
|
||||
|
||||
export const ratePerHour = '1000.0000 RUB'
|
||||
export const sourceProgramName = 'generator'
|
||||
export const capitalProgramName = 'capital'
|
||||
export const capitalProgramName = 'blagorost'
|
||||
// ledger2::accounts.id хранит счёт со смещением *1000 (80 → 80000).
|
||||
export const circulationAccountId = 80_000
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import { signProgramAgreement } from '../wallet/signProgramAgreement'
|
||||
import { capitalDraftId, capitalProgramId } from './consts'
|
||||
import { expect } from 'vitest'
|
||||
import { signAgreement } from '../soviet/signAgreement'
|
||||
import { getCoopProgramWallet, getUserProgramWallet } from '../wallet/walletUtils'
|
||||
import { capitalProgramName, sourceProgramId } from './consts'
|
||||
|
||||
// После Эпика 2 / компонента 48 подпись соглашения ЦПП «Благорост»
|
||||
// (program_id=4) идёт через `wallet::signagree`, а не `soviet::sndagreement`.
|
||||
export async function signCapitalAgreement(
|
||||
blockchain: any,
|
||||
coopname: string,
|
||||
username: string,
|
||||
fakeDocument: any,
|
||||
) {
|
||||
return signProgramAgreement(
|
||||
blockchain,
|
||||
const txId = await signAgreement(blockchain, coopname, username, capitalProgramName, fakeDocument)
|
||||
|
||||
const wallet = await getUserProgramWallet(blockchain, coopname, username, sourceProgramId)
|
||||
|
||||
expect(wallet).toEqual(expect.objectContaining({
|
||||
coopname,
|
||||
username,
|
||||
capitalProgramId,
|
||||
capitalDraftId,
|
||||
fakeDocument,
|
||||
)
|
||||
available: expect.any(String),
|
||||
blocked: expect.any(String),
|
||||
program_id: sourceProgramId,
|
||||
membership_contribution: expect.any(String),
|
||||
}))
|
||||
|
||||
const program = await getCoopProgramWallet(blockchain, coopname, sourceProgramId)
|
||||
|
||||
return { wallet, program, txId }
|
||||
}
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import { signProgramAgreement } from '../wallet/signProgramAgreement'
|
||||
import { sourceDraftId, sourceProgramId } from './consts'
|
||||
import { expect } from 'vitest'
|
||||
import { signAgreement } from '../soviet/signAgreement'
|
||||
import { getCoopProgramWallet, getUserProgramWallet } from '../wallet/walletUtils'
|
||||
import { sourceProgramId, sourceProgramName } from './consts'
|
||||
|
||||
// После Эпика 2 / компонента 48 подпись соглашения ЦПП «Генератор»
|
||||
// (program_id=3) идёт через `wallet::signagree`, а не `soviet::sndagreement`.
|
||||
export async function signGenerationContract(
|
||||
blockchain: any,
|
||||
coopname: string,
|
||||
username: string,
|
||||
fakeDocument: any,
|
||||
) {
|
||||
return signProgramAgreement(
|
||||
blockchain,
|
||||
const txId = await signAgreement(blockchain, coopname, username, sourceProgramName, fakeDocument)
|
||||
|
||||
const wallet = await getUserProgramWallet(blockchain, coopname, username, sourceProgramId)
|
||||
|
||||
expect(wallet).toEqual(expect.objectContaining({
|
||||
coopname,
|
||||
username,
|
||||
sourceProgramId,
|
||||
sourceDraftId,
|
||||
fakeDocument,
|
||||
)
|
||||
available: expect.any(String),
|
||||
blocked: expect.any(String),
|
||||
program_id: sourceProgramId,
|
||||
membership_contribution: expect.any(String),
|
||||
}))
|
||||
|
||||
const program = await getCoopProgramWallet(blockchain, coopname, sourceProgramId)
|
||||
|
||||
return { wallet, program, txId }
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
// Реэкспорт из init/, чтобы существующие импорты в тестах продолжали работать.
|
||||
// Сама реализация переехала в src/init/sign-program-agreement.ts — иначе
|
||||
// production-цепочка `boot → init/cooperative → tests/wallet/signProgramAgreement`
|
||||
// затягивала vitest при загрузке модуля и валила esno-запуск boot'а.
|
||||
export { signProgramAgreement } from '../../init/sign-program-agreement'
|
||||
@@ -1,20 +1,26 @@
|
||||
import { walletDraftId, walletProgramId } from '../capital/consts'
|
||||
import { signProgramAgreement } from './signProgramAgreement'
|
||||
import { expect } from 'vitest'
|
||||
import { signAgreement } from '../soviet/signAgreement'
|
||||
import { getCoopProgramWallet, getUserProgramWallet } from './walletUtils'
|
||||
|
||||
// После Эпика 2 / компонента 48 подпись соглашения ЦПП «Цифровой Кошелёк»
|
||||
// (program_id=1) идёт через `wallet::signagree`, а не `soviet::sndagreement`.
|
||||
export async function signWalletAgreement(
|
||||
blockchain: any,
|
||||
coopname: string,
|
||||
username: string,
|
||||
fakeDocument: any,
|
||||
) {
|
||||
return signProgramAgreement(
|
||||
blockchain,
|
||||
const txId = await signAgreement(blockchain, coopname, username, 'wallet', fakeDocument)
|
||||
|
||||
const wallet = await getUserProgramWallet(blockchain, coopname, username, 1)
|
||||
expect(wallet).toEqual(expect.objectContaining({
|
||||
coopname,
|
||||
username,
|
||||
walletProgramId,
|
||||
walletDraftId,
|
||||
fakeDocument,
|
||||
)
|
||||
available: expect.any(String),
|
||||
blocked: expect.any(String),
|
||||
program_id: 1,
|
||||
membership_contribution: expect.any(String),
|
||||
}))
|
||||
|
||||
const program = await getCoopProgramWallet(blockchain, coopname, 1)
|
||||
|
||||
return { wallet, program, txId }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/cleos",
|
||||
"version": "2026.7.21-1",
|
||||
"version": "2026.5.15-5",
|
||||
"private": true,
|
||||
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
cpp/**/*.wasm
|
||||
cpp/**/*.abi
|
||||
!cpp/tests/test_contracts/**/*.wasm
|
||||
!cpp/tests/test_contracts/**/*.abi
|
||||
soviet/soviet.abi
|
||||
soviet/soviet.wasm
|
||||
registrator/registrator.abi
|
||||
registrator/registrator.wasm
|
||||
networks.sh
|
||||
ano/ano.wasm
|
||||
ano/ano.abi
|
||||
Testing/
|
||||
docs/html/*
|
||||
docs/xml/*
|
||||
|
||||
@@ -58,7 +58,6 @@ add_contract_build(registrator)
|
||||
add_contract_build(gateway)
|
||||
add_contract_build(marketplace)
|
||||
add_contract_build(apps)
|
||||
add_contract_build(expense)
|
||||
add_contract_build(system)
|
||||
|
||||
# Опции для тестов
|
||||
|
||||
@@ -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
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* @brief Инициировать программный расход через шасси expense.
|
||||
*
|
||||
* capital — только инициатор. Резервирует средства в @c program_expense_pool,
|
||||
* создаёт скелет записи progexpense (status=CREATED, как маркер pending), затем
|
||||
* шлёт inline action `expense::createexp(...)` с callback handler `{capital, onpgexpdone}`.
|
||||
* Шасси expense обслуживает весь дальнейший flow (авторизация совета, выдача аванса,
|
||||
* отчёт, закрытие либо отклонение) и на терминальном переходе шлёт inline `onpgexpdone`.
|
||||
*
|
||||
* @param coopname Кооператив
|
||||
* @param expense_hash Хеш расхода (= proposal_hash в шасси)
|
||||
* @param creator Инициатор расхода
|
||||
* @param items Позиции СЗ (см. ExpenseDomain::item); механика оплаты per-item
|
||||
* @param description Описание для отчётности
|
||||
* @param statement Документ-заявление (ProgramExpenseStatement)
|
||||
*
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
*
|
||||
* @note Авторизация требуется от @p coopname.
|
||||
*/
|
||||
void capital::createpgexp(name coopname, checksum256 expense_hash, name creator,
|
||||
std::vector<ExpenseDomain::item> items,
|
||||
std::string description, document2 statement) {
|
||||
require_auth(coopname);
|
||||
|
||||
verify_document_or_fail(statement, {creator});
|
||||
eosio::check(!items.empty(), "Программный расход должен содержать хотя бы один item");
|
||||
|
||||
// Сумма расхода — сумма planned_amount всех items.
|
||||
asset amount = items.front().planned_amount;
|
||||
amount.amount = 0;
|
||||
for (const auto &it : items) {
|
||||
amount += it.planned_amount;
|
||||
}
|
||||
Wallet::validate_asset(amount);
|
||||
eosio::check(amount.amount > 0, "Сумма программного расхода должна быть положительной");
|
||||
|
||||
auto exist = Capital::get_program_expense(coopname, expense_hash);
|
||||
eosio::check(!exist.has_value(), "Программный расход с указанным хэшем уже существует");
|
||||
|
||||
// Резерв в пуле до отправки в шасси — две параллельные заявки не превысят пул.
|
||||
Capital::State::reserve_program_expense(coopname, amount);
|
||||
|
||||
Capital::Expenses::create_program_expense(coopname, expense_hash, creator,
|
||||
amount, description, statement);
|
||||
|
||||
// Inline action в шасси expense с callback handler на capital::onpgexpdone.
|
||||
// data пустой — capital резолвит запись по expense_hash, дополнительный payload не нужен.
|
||||
ExpenseDomain::callback_handler callback{
|
||||
.contract = _capital,
|
||||
.action = Names::Capital::ON_PROGRAM_EXPENSE_DONE,
|
||||
.data = std::vector<char>{}
|
||||
};
|
||||
|
||||
// Authority — _capital: у capital@eosio.code нет coopname@active, а
|
||||
// expense::createexp принимает контракты-инициаторы по contracts_whitelist.
|
||||
eosio::action(
|
||||
eosio::permission_level{_capital, "active"_n},
|
||||
_expense,
|
||||
Names::External::CREATE_EXPENSE_PROPOSAL,
|
||||
std::make_tuple(coopname, creator, expense_hash,
|
||||
ledger2_wallets::PROGRAM_EXPENSE_POOL, items, callback, statement)
|
||||
).send();
|
||||
}
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* @brief Callback от шасси expense на финализацию program-расхода.
|
||||
*
|
||||
* Шасси expense шлёт его inline (authority — _expense@active) когда закончило
|
||||
* flow (closeexp или declexp). capital не делает ledger2-проводок —
|
||||
* они уже сделаны шасси на payexp/reportexp/returnexp/overspendexp. capital
|
||||
* только обновляет @c program_expense_pool и удаляет запись из @c progexpenses.
|
||||
*
|
||||
* status == ExpenseDomain::ProposalStatus::CLOSED (4):
|
||||
* total_actual <= reserved → consume(total_actual) + release(остаток);
|
||||
* total_actual > reserved (был overspendexp) → consume(reserved) +
|
||||
* прямое списание превышения из program_expense_pool.
|
||||
* status == ExpenseDomain::ProposalStatus::DECLINED (5):
|
||||
* release(reserved); decline возможен только до оплат (total_actual == 0).
|
||||
*
|
||||
* @param coopname Кооператив
|
||||
* @param expense_hash Хеш расхода (ключ в progexpenses)
|
||||
* @param status ExpenseDomain::ProposalStatus
|
||||
* @param total_actual Фактически потраченная сумма
|
||||
* @param data Произвольный payload от инициатора (не используется на MVP)
|
||||
*
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
*
|
||||
* @note Авторизация требуется от @p _expense — inline-callback шасси.
|
||||
*/
|
||||
void capital::onpgexpdone(name coopname, checksum256 expense_hash, uint8_t status,
|
||||
eosio::asset total_actual, std::vector<char> data) {
|
||||
require_auth(_expense);
|
||||
|
||||
auto pgexp = Capital::Expenses::get_program_expense_or_fail(coopname, expense_hash);
|
||||
const auto reserved = pgexp.amount;
|
||||
|
||||
if (status == static_cast<uint8_t>(ExpenseDomain::ProposalStatus::CLOSED)) {
|
||||
eosio::check(total_actual.symbol == reserved.symbol,
|
||||
"Символ total_actual не совпадает с зарезервированной суммой");
|
||||
eosio::check(total_actual.amount >= 0,
|
||||
"total_actual не может быть отрицательным");
|
||||
|
||||
if (total_actual.amount <= reserved.amount) {
|
||||
if (total_actual.amount > 0) {
|
||||
Capital::State::consume_program_expense(coopname, total_actual);
|
||||
}
|
||||
const asset leftover = reserved - total_actual;
|
||||
if (leftover.amount > 0) {
|
||||
Capital::State::release_program_expense(coopname, leftover);
|
||||
}
|
||||
} else {
|
||||
// Перерасход (overspendexp в шасси): резерв списываем полностью,
|
||||
// превышение — напрямую из свободного пула программных расходов.
|
||||
Capital::State::consume_program_expense(coopname, reserved);
|
||||
const asset excess = total_actual - reserved;
|
||||
Capital::State::spend_program_expense_pool(coopname, excess);
|
||||
}
|
||||
} else if (status == static_cast<uint8_t>(ExpenseDomain::ProposalStatus::DECLINED)) {
|
||||
// Шасси отклоняет СЗ только до первой оплаты — резерв возвращается целиком.
|
||||
eosio::check(total_actual.amount == 0,
|
||||
"DECLINED с ненулевым total_actual — нарушение инварианта шасси");
|
||||
Capital::State::release_program_expense(coopname, reserved);
|
||||
} else {
|
||||
eosio::check(false, "onpgexpdone принимает только статусы CLOSED либо DECLINED");
|
||||
}
|
||||
|
||||
Capital::Expenses::delete_program_expense(coopname, pgexp.id);
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* @brief Пополняет пул программных расходов из общего инвестиционного пула программы.
|
||||
*
|
||||
* Программные расходы — это целевые списания «Благорост», не аллоцированные ни в один проект
|
||||
* (хозяйственные расходы программы, оплата инфраструктуры программы и т.п.). Прежде чем
|
||||
* создавать запрос на программный расход, председатель пополняет дедикейтнутый пул
|
||||
* @c program_expense_pool из общего @c global_available_invest_pool. Помимо счётчиков
|
||||
* state наполняется КООПЕРАТИВНЫЙ ledger2-кошелёк @c PROGRAM_EXPENSE_POOL (w.cap.pgexp) —
|
||||
* именно из него шасси expense оплачивает СЗ; личные L3-кошельки пайщиков (w.cap.blago)
|
||||
* при расходах не изменяются.
|
||||
*
|
||||
* @param coopname Кооператив (scope state)
|
||||
* @param amount Сумма пополнения (символ строго совпадает с программой)
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
*
|
||||
* @note Авторизация требуется от аккаунта: @p coopname (председатель).
|
||||
*/
|
||||
void capital::topupprogexp(name coopname, eosio::asset amount) {
|
||||
require_auth(coopname);
|
||||
|
||||
Capital::State::topup_program_expense_pool(coopname, amount);
|
||||
|
||||
// Кошелёк-резерв пула: ISSUE w.cap.pgexp без бухпроводки (деньги физически
|
||||
// на 51 с момента взносов — выделяется только назначение под расходы).
|
||||
// Синтетический process_hash — у пополнения нет естественного анкера.
|
||||
const uint64_t now_us = eosio::current_time_point().time_since_epoch().count();
|
||||
const std::string anchor = coopname.to_string() + ":pgtopup:" + std::to_string(now_us);
|
||||
const checksum256 process_hash = eosio::sha256(anchor.c_str(), anchor.size());
|
||||
|
||||
Ledger2::apply(_capital, coopname, operations::capital::PROGRAM_EXPENSE_TOPUP, amount,
|
||||
name{}, process_hash, "capital:topupprogexp");
|
||||
}
|
||||
+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,
|
||||
|
||||
@@ -34,10 +34,6 @@ void capital::convertsegm(eosio::name coopname, eosio::name username,
|
||||
document2 convert_statement) {
|
||||
require_auth(coopname);
|
||||
|
||||
// Проверяем подпись заявления о трансляции паевого взноса (шаблон 1080).
|
||||
// Без этого on-chain принял бы любую сконструированную document2 без валидной подписи.
|
||||
verify_document_or_fail(convert_statement, {username});
|
||||
|
||||
// Получаем сегмент пайщика
|
||||
auto segment = Capital::Segments::get_segment_or_fail(coopname, project_hash, username,
|
||||
"Сегмент пайщика не найден");
|
||||
@@ -112,23 +108,6 @@ void capital::convertsegm(eosio::name coopname, eosio::name username,
|
||||
// Инкрементируем счётчик сконвертированных сегментов
|
||||
Capital::Projects::increment_converted_segments(coopname, current_project.id);
|
||||
|
||||
// Линкуем заявление о трансляции (1080) к пакету процесса p.cap.rid
|
||||
// (package = result_hash, ведущий документ пакета — заявление о внесении РИД, 1040).
|
||||
// Не make_complete_document: тот вытаскивает 1080 на верхний уровень реестра
|
||||
// как самостоятельный документ. Нужен newlink — тот же канон, что в signact1/signact2:
|
||||
// off-chain controller добавляет документ в группу пакета по action+package, не как top-level.
|
||||
// Делаем ДО delete_result, чтобы линковка прошла, пока result_hash ещё анкер процесса.
|
||||
Action::send<newlink_interface>(
|
||||
_soviet,
|
||||
"newlink"_n,
|
||||
_capital,
|
||||
coopname,
|
||||
username,
|
||||
Names::Capital::CONVERT_SEGMENT,
|
||||
result_hash,
|
||||
convert_statement
|
||||
);
|
||||
|
||||
// Удаляем сегмент после конвертации
|
||||
Capital::Segments::remove_segment(coopname, segment.id);
|
||||
|
||||
|
||||
@@ -48,17 +48,14 @@ void capital::signact2(eosio::name coopname, eosio::name chairman, checksum256 r
|
||||
// Приём результата интеллектуальной деятельности (РИД) в паевой фонд.
|
||||
// Схема ревью 2026-04-20 (Ангелина Matrix 2026-04-19): две раздельные проводки
|
||||
// в разных action'ах по фазам жизненного цикла РИД.
|
||||
// 1) COMMIT_RID (Dr 08 / Cr 80) — в `approvecmmt` на каждом одобрении
|
||||
// мастером конкретного коммита, на полный `commit.amounts.total_contribution`.
|
||||
// 1) COMMIT_RID (Dr 08 / Cr 80) — теперь в `approvecmmt` на каждом одобрении
|
||||
// мастером конкретного коммита, на дельту `available_for_program`.
|
||||
// «Собираем на 08 частями по мере накопления коммитов».
|
||||
// 2) ACCEPT_RID (Dr 04 / Cr 08) — здесь, на полный накопленный
|
||||
// `segment.available_for_program`. «Переносим с 08 на 04 когда РИД
|
||||
// собран и подписан акт-2».
|
||||
// Инвариант (программный уровень, НЕ сегмент): Σ COMMIT_RID по всем коммитам
|
||||
// программы == Σ ACCEPT_RID по всем сегментам — `wallets2[w.cap.gen]` (единый
|
||||
// кооперативный пул, COOPERATIVE) закрывается в ноль после конвертации всех
|
||||
// сегментов программы. На уровне отдельного сегмента инвариант НЕ выполняется
|
||||
// (CRPS перераспределяет доли между сегментами без compensating TRANSFER) —
|
||||
// именно поэтому L3-разрез по пайщику снят (см. wallets.hpp:103).
|
||||
// Инвариант: Σ COMMIT_RID (по коммитам сегмента) == ACCEPT_RID → GENERATOR_FUND
|
||||
// (w.cap.gen) закрывается в ноль, 08-й счёт закрывается в ноль по этому сегменту.
|
||||
if (segment.available_for_program.amount > 0) {
|
||||
Ledger2::apply(_capital, coopname, operations::capital::ACCEPT_RID, segment.available_for_program, result -> username, result_hash, memo);
|
||||
}
|
||||
|
||||
@@ -95,12 +95,6 @@
|
||||
#include "app/debt_managment/create_debt/declinedebt.cpp"
|
||||
#include "app/debt_managment/settle_debt/settledebt.cpp"
|
||||
|
||||
// Управление программными расходами через шасси expense.
|
||||
// capital — инициатор + получатель callback, шасси обслуживает весь flow.
|
||||
#include "app/expense_managment/program_expenses/topupprogexp.cpp"
|
||||
#include "app/expense_managment/program_expenses/createpgexp.cpp"
|
||||
#include "app/expense_managment/program_expenses/onpgexpdone.cpp"
|
||||
|
||||
// Управление расходами
|
||||
#include "app/expense_managment/create_expense/approveexpns.cpp"
|
||||
#include "app/expense_managment/create_expense/capauthexpns.cpp"
|
||||
|
||||
@@ -48,7 +48,6 @@ constexpr uint128_t CRPS_PRECISION_FACTOR = 100000000000000ULL; ///< Масшт
|
||||
#include <eosio/asset.hpp>
|
||||
#include "../lib/index.hpp"
|
||||
#include "domain/index.hpp"
|
||||
#include "../expense/expense.hpp" // ExpenseDomain::item / callback_handler — для inline-action в шасси
|
||||
|
||||
|
||||
using namespace eosio;
|
||||
@@ -323,38 +322,4 @@ public:
|
||||
[[eosio::action]]
|
||||
void settledebt(name coopname, name username, eosio::asset amount, document2 statement);
|
||||
|
||||
// Программные расходы через шасси expense.
|
||||
// capital — инициатор + получатель callback, шасси держит весь flow расхода.
|
||||
|
||||
[[eosio::action]]
|
||||
void topupprogexp(name coopname, eosio::asset amount);
|
||||
|
||||
/**
|
||||
* @brief Инициировать программный расход через шасси expense.
|
||||
* Резервирует amount в program_expense_pool, создаёт запись progexpense,
|
||||
* шлёт inline action expense::createexp с callback={capital, onpgexpdone}.
|
||||
* Шасси обслуживает весь flow (auth/pay/report/close|decline) и шлёт callback.
|
||||
*
|
||||
* items[] — позиции СЗ (см. ExpenseDomain::item); механика оплаты per-item
|
||||
* (item.mechanics: ADVANCE — аванс, DIRECT — прямая оплата).
|
||||
*/
|
||||
[[eosio::action]]
|
||||
void createpgexp(name coopname, checksum256 expense_hash, name creator,
|
||||
std::vector<ExpenseDomain::item> items,
|
||||
std::string description, document2 statement);
|
||||
|
||||
/**
|
||||
* @brief Callback от шасси expense на финализацию program-расхода.
|
||||
* Шасси шлёт его inline (authority — _expense@active, require_auth(_expense))
|
||||
* после терминального перехода flow (CLOSED либо DECLINED).
|
||||
*
|
||||
* status == CLOSED → consume(total_actual) + release(остаток резерва);
|
||||
* при total_actual > reserved (перерасход) превышение
|
||||
* списывается напрямую из program_expense_pool.
|
||||
* status == DECLINED → release(reserved); decline возможен только до оплат.
|
||||
*/
|
||||
[[eosio::action]]
|
||||
void onpgexpdone(name coopname, checksum256 expense_hash, uint8_t status,
|
||||
eosio::asset total_actual, std::vector<char> data);
|
||||
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -178,96 +178,8 @@ namespace Capital::Expenses {
|
||||
Capital::expense_index expenses(_capital, coopname.value);
|
||||
auto expense = expenses.find(expense_id);
|
||||
eosio::check(expense != expenses.end(), "Расход не найден");
|
||||
|
||||
|
||||
expenses.erase(expense);
|
||||
}
|
||||
|
||||
} // namespace Capital::Expenses
|
||||
|
||||
namespace Capital {
|
||||
|
||||
/**
|
||||
* @brief Таблица программных расходов — целевые списания «Благорост»,
|
||||
* не привязанные к конкретному проекту.
|
||||
* @par Область памяти (scope): coopname
|
||||
* @par Имя таблицы (table): progexpenses
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract(CAPITAL)]] program_expense {
|
||||
uint64_t id; ///< Внутренний ключ
|
||||
name coopname; ///< Имя кооператива
|
||||
name username; ///< Инициатор расхода (председатель / казначей)
|
||||
|
||||
name status = Expenses::Status::CREATED; ///< Статус (CREATED/APPROVED/AUTHORIZED/PAID/DECLINED — общий enum)
|
||||
checksum256 expense_hash; ///< Хеш расхода — анкер процесса p.cap.expns
|
||||
uint64_t fund_id; ///< Идентификатор фонда списания
|
||||
eosio::asset amount; ///< Сумма расхода
|
||||
std::string description; ///< Описание (для отчётности)
|
||||
|
||||
document2 expense_statement; ///< Служебная записка (ProgramExpenseStatement)
|
||||
document2 approved_statement; ///< Записка, принятая председателем
|
||||
document2 authorization; ///< Решение совета
|
||||
|
||||
time_point_sec spended_at = current_time_point();
|
||||
|
||||
uint64_t primary_key() const { return id; }
|
||||
uint64_t by_username() const { return username.value; }
|
||||
checksum256 by_expense_hash() const { return expense_hash; }
|
||||
};
|
||||
|
||||
typedef eosio::multi_index<
|
||||
"progexpenses"_n, program_expense,
|
||||
indexed_by<"byusername"_n, const_mem_fun<program_expense, uint64_t, &program_expense::by_username>>,
|
||||
indexed_by<"byhash"_n, const_mem_fun<program_expense, checksum256, &program_expense::by_expense_hash>>
|
||||
> program_expense_index;
|
||||
|
||||
inline std::optional<program_expense> get_program_expense(eosio::name coopname, const checksum256 &hash) {
|
||||
program_expense_index t(_capital, coopname.value);
|
||||
auto idx = t.get_index<"byhash"_n>();
|
||||
auto itr = idx.find(hash);
|
||||
if (itr == idx.end()) return std::nullopt;
|
||||
return program_expense(*itr);
|
||||
}
|
||||
|
||||
} // namespace Capital
|
||||
|
||||
namespace Capital::Expenses {
|
||||
|
||||
inline program_expense get_program_expense_or_fail(eosio::name coopname, const checksum256 &expense_hash) {
|
||||
auto v = Capital::get_program_expense(coopname, expense_hash);
|
||||
eosio::check(v.has_value(), "Программный расход с указанным хэшем не найден");
|
||||
return v.value();
|
||||
}
|
||||
|
||||
inline void create_program_expense(
|
||||
eosio::name coopname,
|
||||
const checksum256 &expense_hash,
|
||||
eosio::name username,
|
||||
const eosio::asset &amount,
|
||||
const std::string &description,
|
||||
const document2 &statement
|
||||
) {
|
||||
Capital::program_expense_index t(_capital, coopname.value);
|
||||
uint64_t id = get_global_id_in_scope(_capital, coopname, "progexpenses"_n);
|
||||
|
||||
t.emplace(coopname, [&](auto &e) {
|
||||
e.id = id;
|
||||
e.coopname = coopname;
|
||||
e.username = username;
|
||||
e.expense_hash = expense_hash;
|
||||
e.fund_id = Ledger::accounts::GENERAL_EXPENSES;
|
||||
e.status = Status::CREATED;
|
||||
e.spended_at = current_time_point();
|
||||
e.expense_statement = statement;
|
||||
e.amount = amount;
|
||||
e.description = description;
|
||||
});
|
||||
}
|
||||
|
||||
inline void delete_program_expense(eosio::name coopname, uint64_t id) {
|
||||
Capital::program_expense_index t(_capital, coopname.value);
|
||||
auto itr = t.find(id);
|
||||
eosio::check(itr != t.end(), "Программный расход не найден");
|
||||
t.erase(itr);
|
||||
}
|
||||
|
||||
} // namespace Capital::Expenses
|
||||
@@ -1,7 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/binary_extension.hpp>
|
||||
|
||||
using namespace eosio;
|
||||
using std::string;
|
||||
|
||||
@@ -42,18 +40,7 @@ namespace Capital {
|
||||
double program_membership_cumulative_reward_per_share; ///< Накопительное вознаграждение на долю в членских взносах
|
||||
|
||||
config config; ///< Управляемая конфигурация контракта
|
||||
|
||||
// ВНИМАНИЕ: поля программных расходов добавлены ПОСЛЕ config и обёрнуты в
|
||||
// binary_extension. Поля в таблицу EOSIO можно дописывать только в ХВОСТ
|
||||
// struct и только через binary_extension — иначе ломается десериализация
|
||||
// уже записанного global_state. Инцидент 2026-06-16: эти поля были вставлены
|
||||
// перед config напрямую (без extension) и задеплоены на прод — таблица state
|
||||
// перестала читаться (get table → "Invalid symbol", action'ы падали на unpack).
|
||||
// Инвариант: оба поля материализуются вместе (всегда оба has_value или оба
|
||||
// пустые), чтобы порядок хвостовых extension оставался консистентным.
|
||||
eosio::binary_extension<asset> program_expense_pool; ///< Доступная сумма программы под целевые расходы (без аллокации в проекты)
|
||||
eosio::binary_extension<asset> program_expense_reserved; ///< Сумма, зарезервированная под approved/authorized программные расходы
|
||||
|
||||
|
||||
uint64_t primary_key() const { return coopname.value; } ///< Первичный ключ (1)
|
||||
};
|
||||
|
||||
@@ -63,16 +50,6 @@ namespace Capital {
|
||||
|
||||
|
||||
namespace State {
|
||||
|
||||
/**
|
||||
* @brief Значение binary_extension<asset> или ноль с символом @p sym, если поле
|
||||
* ещё не материализовано (старая запись, созданная до добавления
|
||||
* program_expense_* через binary_extension).
|
||||
*/
|
||||
inline asset ext_or_zero(const eosio::binary_extension<asset>& v, const eosio::symbol& sym) {
|
||||
return v.has_value() ? v.value() : asset(0, sym);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Обновляет глобальное состояние новыми значениями.
|
||||
*
|
||||
@@ -101,7 +78,7 @@ inline global_state get_global_state(name coopname) {
|
||||
|
||||
/**
|
||||
* @brief Проверяет что контракт инициализирован.
|
||||
*
|
||||
*
|
||||
* @param coopname Имя кооператива.
|
||||
*/
|
||||
inline void is_initialized(name coopname) {
|
||||
@@ -109,116 +86,6 @@ inline void is_initialized(name coopname) {
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Пополняет пул программных расходов на @p amount из общего пула инвестиций программы.
|
||||
* Вызывается из action topupprogexp (председатель).
|
||||
*/
|
||||
inline void topup_program_expense_pool(eosio::name coopname, const asset &amount) {
|
||||
global_state_table global_state_inst(_capital, _capital.value);
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма пополнения должна быть положительной");
|
||||
eosio::check(itr->global_available_invest_pool >= amount,
|
||||
"Недостаточно свободных инвестиций программы для пополнения пула расходов");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
|
||||
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
|
||||
s.global_available_invest_pool -= amount;
|
||||
pool += amount;
|
||||
s.program_expense_pool = pool;
|
||||
s.program_expense_reserved = reserved;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Резервирует средства под approved/authorized программный расход:
|
||||
* program_expense_pool -= amount, program_expense_reserved += amount.
|
||||
*/
|
||||
inline void reserve_program_expense(eosio::name coopname, const asset &amount) {
|
||||
global_state_table global_state_inst(_capital, _capital.value);
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма резерва должна быть положительной");
|
||||
eosio::check(ext_or_zero(itr->program_expense_pool, amount.symbol) >= amount,
|
||||
"Недостаточно средств в пуле программных расходов");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
|
||||
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
|
||||
pool -= amount;
|
||||
reserved += amount;
|
||||
s.program_expense_pool = pool;
|
||||
s.program_expense_reserved = reserved;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Освобождает зарезервированное при отклонении программного расхода:
|
||||
* program_expense_reserved -= amount, program_expense_pool += amount.
|
||||
*/
|
||||
inline void release_program_expense(eosio::name coopname, const asset &amount) {
|
||||
global_state_table global_state_inst(_capital, _capital.value);
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма освобождения должна быть положительной");
|
||||
eosio::check(ext_or_zero(itr->program_expense_reserved, amount.symbol) >= amount,
|
||||
"Зарезервированных программных расходов меньше указанной суммы");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
|
||||
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
|
||||
reserved -= amount;
|
||||
pool += amount;
|
||||
s.program_expense_pool = pool;
|
||||
s.program_expense_reserved = reserved;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Окончательное списание программного расхода (callback onpgexpdone, CLOSED):
|
||||
* program_expense_reserved -= amount. Деньги уже ушли через шасси expense,
|
||||
* ledger2-проводки сделаны на payexp/reportexp/returnexp.
|
||||
*/
|
||||
inline void consume_program_expense(eosio::name coopname, const asset &amount) {
|
||||
global_state_table global_state_inst(_capital, _capital.value);
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма списания должна быть положительной");
|
||||
eosio::check(ext_or_zero(itr->program_expense_reserved, amount.symbol) >= amount,
|
||||
"Зарезервированных программных расходов меньше указанной суммы");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
|
||||
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
|
||||
reserved -= amount;
|
||||
s.program_expense_pool = pool;
|
||||
s.program_expense_reserved = reserved;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Прямое списание из свободного пула программных расходов — превышение
|
||||
* факта над резервом при закрытии расхода с перерасходом (overspendexp
|
||||
* в шасси выдал доплату из BLAGOROST_FUND сверх зарезервированного).
|
||||
*/
|
||||
inline void spend_program_expense_pool(eosio::name coopname, const asset &amount) {
|
||||
global_state_table global_state_inst(_capital, _capital.value);
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма списания должна быть положительной");
|
||||
eosio::check(ext_or_zero(itr->program_expense_pool, amount.symbol) >= amount,
|
||||
"Недостаточно средств в пуле программных расходов для покрытия перерасхода — пополните пул (topupprogexp)");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
asset pool = ext_or_zero(s.program_expense_pool, amount.symbol);
|
||||
asset reserved = ext_or_zero(s.program_expense_reserved, amount.symbol);
|
||||
pool -= amount;
|
||||
s.program_expense_pool = pool;
|
||||
s.program_expense_reserved = reserved;
|
||||
});
|
||||
}
|
||||
}// namespace State
|
||||
|
||||
}// namespace Capital
|
||||
@@ -1,10 +0,0 @@
|
||||
cmake_minimum_required(VERSION 3.5)
|
||||
|
||||
project(expense)
|
||||
|
||||
find_package(cdt)
|
||||
|
||||
add_contract(expense expense expense.cpp)
|
||||
if(IS_TESTNET)
|
||||
target_compile_definitions(expense PUBLIC IS_TESTNET=1)
|
||||
endif()
|
||||
@@ -1,425 +0,0 @@
|
||||
// expense.cpp — шасси расходов (первый потребитель: Благорост).
|
||||
//
|
||||
// Принцип: контракт-владелец расходных операций, агностичен к программе-источнику.
|
||||
// Кошелёк-пул приходит в createexp параметром source_wallet; ledger2-коды всех
|
||||
// операций жизненного цикла выводятся из него через EXPENSE_OPERATION_SETS
|
||||
// (фабричная настройка — подключение нового пула не меняет этот контракт).
|
||||
// Механика оплаты per-item (ADVANCE | DIRECT) выбирает код внутри набора;
|
||||
// callback-handler сохраняется как переменная.
|
||||
|
||||
#include "expense.hpp"
|
||||
|
||||
namespace D = ExpenseDomain;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
namespace {
|
||||
|
||||
asset zero_like(const asset& a) { return asset{0, a.symbol}; }
|
||||
|
||||
asset sum_planned(const std::vector<D::item>& items) {
|
||||
if (items.empty()) return asset{0, eosio::symbol{"RUB", 4}};
|
||||
asset total = zero_like(items.front().planned_amount);
|
||||
for (const auto& it : items) total += it.planned_amount;
|
||||
return total;
|
||||
}
|
||||
|
||||
// Набор ledger2-кодов операций шасси по кошельку-источнику СЗ. Один источник =
|
||||
// один набор (EXPENSE_OPERATION_SETS в ledger2) — в коде expense нет
|
||||
// захардкоженных operation_code, контракт настраивается на любой пул.
|
||||
const ExpenseOperationSet& expense_ops_for(eosio::name source_wallet) {
|
||||
const auto* set = find_expense_operation_set(source_wallet);
|
||||
eosio::check(set != nullptr,
|
||||
"Для кошелька-источника не настроен набор операций шасси расходов (EXPENSE_OPERATION_SETS)");
|
||||
return *set;
|
||||
}
|
||||
|
||||
D::proposals_index get_proposals(eosio::name self, eosio::name coopname) {
|
||||
return D::proposals_index(self, coopname.value);
|
||||
}
|
||||
|
||||
auto find_proposal(D::proposals_index& tbl, const eosio::checksum256& proposal_hash) {
|
||||
auto idx = tbl.get_index<"byhash"_n>();
|
||||
return idx.find(proposal_hash);
|
||||
}
|
||||
|
||||
void send_callback_if_any(const D::callback_handler& cb,
|
||||
eosio::name coopname,
|
||||
const eosio::checksum256& proposal_hash,
|
||||
uint8_t status,
|
||||
const eosio::asset& amount,
|
||||
eosio::name self) {
|
||||
if (cb.contract.value == 0) return;
|
||||
// Payload: (coopname, proposal_hash, status, total_actual, data).
|
||||
// status = ExpenseDomain::ProposalStatus — инициатор различает CLOSED vs DECLINED.
|
||||
// coopname нужен инициатору для скоупа таблиц (capital::progexpenses scope=coopname).
|
||||
eosio::action(
|
||||
eosio::permission_level{self, "active"_n},
|
||||
cb.contract,
|
||||
cb.action,
|
||||
std::make_tuple(coopname, proposal_hash, status, amount, cb.data)
|
||||
).send();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ── actions ──────────────────────────────────────────────────────────────────
|
||||
|
||||
void expense::createexp(name coopname, name username,
|
||||
checksum256 proposal_hash,
|
||||
name source_wallet,
|
||||
std::vector<D::item> items,
|
||||
D::callback_handler callback,
|
||||
document2 statement) {
|
||||
// Прямой вызов backend'а — авторизация кооператива; inline от контракта-инициатора
|
||||
// (capital::createpgexp и т.п.) — авторизация по whitelist, как в ledger2::apply.
|
||||
if (!has_auth(coopname)) {
|
||||
check_auth_and_get_payer_or_fail(contracts_whitelist);
|
||||
}
|
||||
|
||||
verify_document_or_fail(statement, {username});
|
||||
eosio::check(!items.empty(), "СЗ должен содержать хотя бы один item");
|
||||
eosio::check(ledger2_is_known_wallet(source_wallet),
|
||||
"source_wallet не найден в LEDGER2_WALLET_REGISTRY");
|
||||
// Источник определяет ledger2-коды всех операций жизненного цикла —
|
||||
// отказываем сразу при создании, а не в момент оплаты.
|
||||
expense_ops_for(source_wallet);
|
||||
|
||||
auto tbl = get_proposals(get_self(), coopname);
|
||||
eosio::check(find_proposal(tbl, proposal_hash) == tbl.get_index<"byhash"_n>().end(),
|
||||
"СЗ с таким proposal_hash уже существует");
|
||||
|
||||
// Механика оплаты задаётся на каждом item отдельно (ADVANCE | DIRECT) —
|
||||
// в одном СЗ допустимо смешение: ledger2-проводка payexp идёт по-позиционно
|
||||
// с кодом, выведенным из механики конкретного item'а.
|
||||
for (auto& it : items) {
|
||||
eosio::check(it.planned_amount.is_valid() && it.planned_amount.amount > 0,
|
||||
"planned_amount каждого item должен быть положительным");
|
||||
eosio::check(it.mechanics <= static_cast<uint8_t>(D::Mechanics::DIRECT),
|
||||
"Неизвестная mechanics item (0 = ADVANCE, 1 = DIRECT)");
|
||||
eosio::check(it.recipient_type <= static_cast<uint8_t>(D::RecipientType::ORG),
|
||||
"Неизвестный recipient_type item");
|
||||
|
||||
// Инвариант: пайщик (SELF/MEMBER) получает только аванс под отчёт на личные
|
||||
// реквизиты (расходом станет после его отчёта чеком); организация — только
|
||||
// прямую оплату по выставленным реквизитам, аванс ей не выдаётся.
|
||||
if (it.recipient_type == static_cast<uint8_t>(D::RecipientType::ORG)) {
|
||||
eosio::check(it.mechanics == static_cast<uint8_t>(D::Mechanics::DIRECT),
|
||||
"Организации/ИП доступна только оплата по счёту (DIRECT)");
|
||||
} else {
|
||||
eosio::check(it.mechanics == static_cast<uint8_t>(D::Mechanics::ADVANCE),
|
||||
"Пайщику средства выдаются только авансом под отчёт (ADVANCE)");
|
||||
eosio::check(it.recipient != name(),
|
||||
"recipient обязателен для получателя-пайщика");
|
||||
eosio::check(is_account(it.recipient),
|
||||
"Аккаунт получателя-пайщика не существует");
|
||||
}
|
||||
|
||||
it.status = static_cast<uint8_t>(D::ItemStatus::APPROVED);
|
||||
it.actual_amount = zero_like(it.planned_amount);
|
||||
}
|
||||
|
||||
const auto total = sum_planned(items);
|
||||
|
||||
tbl.emplace(get_self(), [&](auto& row) {
|
||||
row.id = tbl.available_primary_key();
|
||||
row.coopname = coopname;
|
||||
row.proposal_hash = proposal_hash;
|
||||
row.username = username;
|
||||
row.source_wallet = source_wallet;
|
||||
row.status = static_cast<uint8_t>(D::ProposalStatus::CREATED);
|
||||
row.items = items;
|
||||
row.total_planned = total;
|
||||
row.total_actual = zero_like(total);
|
||||
row.callback = callback;
|
||||
row.statement_doc = statement;
|
||||
row.decision_doc = document2{};
|
||||
row.created_at = eosio::current_time_point();
|
||||
row.updated_at = row.created_at;
|
||||
});
|
||||
|
||||
// СЗ утверждает совет (как возвраты паевых взносов): ставим вопрос в повестку.
|
||||
// Утверждение председателем после голосования → callback authexp от _soviet;
|
||||
// отклонение (отрицательный консенсус / просрочка) → declexp с причиной.
|
||||
::Soviet::create_agenda(
|
||||
_expense,
|
||||
coopname,
|
||||
username,
|
||||
get_valid_soviet_action("createexp"_n),
|
||||
proposal_hash,
|
||||
_expense,
|
||||
"authexp"_n,
|
||||
"declexp"_n,
|
||||
statement,
|
||||
std::string("")
|
||||
);
|
||||
}
|
||||
|
||||
void expense::authexp(name coopname, checksum256 proposal_hash, document2 decision) {
|
||||
require_auth(_soviet);
|
||||
|
||||
auto tbl = get_proposals(get_self(), coopname);
|
||||
auto idx = tbl.get_index<"byhash"_n>();
|
||||
auto it = idx.find(proposal_hash);
|
||||
eosio::check(it != idx.end(), "СЗ не найден");
|
||||
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::CREATED),
|
||||
"Авторизовать можно только СЗ в статусе CREATED");
|
||||
|
||||
verify_document_or_fail(decision);
|
||||
|
||||
idx.modify(it, get_self(), [&](auto& row) {
|
||||
row.decision_doc = decision;
|
||||
row.status = static_cast<uint8_t>(D::ProposalStatus::AUTHORIZED);
|
||||
row.updated_at = eosio::current_time_point();
|
||||
});
|
||||
}
|
||||
|
||||
void expense::declexp(name coopname, checksum256 proposal_hash, std::string reason) {
|
||||
require_auth(_soviet);
|
||||
|
||||
auto tbl = get_proposals(get_self(), coopname);
|
||||
auto idx = tbl.get_index<"byhash"_n>();
|
||||
auto it = idx.find(proposal_hash);
|
||||
eosio::check(it != idx.end(), "СЗ не найден");
|
||||
// Отклонение возможно только пока ни один item не оплачен: после payexp деньги
|
||||
// уже ушли через ledger2, и decline разъехался бы с учётом инициатора
|
||||
// (callback DECLINED возвращает инициатору весь резерв). Частично исполненный
|
||||
// СЗ завершается обычным путём: reportexp / returnexp → closeexp.
|
||||
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::CREATED) ||
|
||||
it->status == static_cast<uint8_t>(D::ProposalStatus::AUTHORIZED),
|
||||
"Отклонить можно только СЗ без оплат (статусы CREATED / AUTHORIZED)");
|
||||
|
||||
idx.modify(it, get_self(), [&](auto& row) {
|
||||
row.status = static_cast<uint8_t>(D::ProposalStatus::DECLINED);
|
||||
row.updated_at = eosio::current_time_point();
|
||||
});
|
||||
|
||||
// Callback инициатору (capital::onpgexpdone и т.п.); total_actual здесь всегда 0 —
|
||||
// оплат до decline не было (см. проверку статусов выше).
|
||||
send_callback_if_any(it->callback, coopname, proposal_hash,
|
||||
static_cast<uint8_t>(D::ProposalStatus::DECLINED),
|
||||
it->total_actual, get_self());
|
||||
}
|
||||
|
||||
void expense::payexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
|
||||
asset actual_amount) {
|
||||
require_auth(coopname);
|
||||
|
||||
auto tbl = get_proposals(get_self(), coopname);
|
||||
auto idx = tbl.get_index<"byhash"_n>();
|
||||
auto it = idx.find(proposal_hash);
|
||||
eosio::check(it != idx.end(), "СЗ не найден");
|
||||
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::AUTHORIZED) ||
|
||||
it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
|
||||
"Оплата возможна только из статусов AUTHORIZED / PARTIALLY_PAID");
|
||||
eosio::check(actual_amount.is_valid() && actual_amount.amount > 0,
|
||||
"actual_amount должен быть положительным");
|
||||
|
||||
bool item_found = false;
|
||||
uint8_t paid_mechanics = 0;
|
||||
name paid_recipient{};
|
||||
|
||||
idx.modify(it, get_self(), [&](auto& row) {
|
||||
bool all_reported = true;
|
||||
for (auto& i : row.items) {
|
||||
if (i.item_hash == item_hash) {
|
||||
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::APPROVED),
|
||||
"Item уже оплачен или закрыт");
|
||||
eosio::check(actual_amount.symbol == i.planned_amount.symbol,
|
||||
"Символ actual_amount не совпадает с планом item");
|
||||
eosio::check(actual_amount.amount <= i.planned_amount.amount,
|
||||
"Сумма оплаты не может превышать план item; доплата сверх плана — через overspendexp");
|
||||
i.actual_amount = actual_amount;
|
||||
// DIRECT не имеет фазы подотчёта (нет ADVANCE_HOLD): закрывающие документы
|
||||
// прикладывает сам кассир, item считается отчитанным сразу после оплаты.
|
||||
i.status = static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::DIRECT
|
||||
? static_cast<uint8_t>(D::ItemStatus::REPORTED)
|
||||
: static_cast<uint8_t>(D::ItemStatus::PAID);
|
||||
paid_mechanics = i.mechanics;
|
||||
paid_recipient = i.recipient;
|
||||
item_found = true;
|
||||
}
|
||||
if (i.status != static_cast<uint8_t>(D::ItemStatus::REPORTED)) {
|
||||
all_reported = false;
|
||||
}
|
||||
}
|
||||
eosio::check(item_found, "item с заданным hash не найден в СЗ");
|
||||
row.total_actual += actual_amount;
|
||||
// DIRECT-only СЗ становится готов к закрытию сразу после последней оплаты.
|
||||
row.status = all_reported
|
||||
? static_cast<uint8_t>(D::ProposalStatus::REPORT_SUBMITTED)
|
||||
: static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID);
|
||||
row.updated_at = eosio::current_time_point();
|
||||
});
|
||||
|
||||
// ledger2-проводка по механике оплаченного item'а — выдача аванса или прямая
|
||||
// оплата; коды выводятся из кошелька-источника СЗ. СЗ может смешивать механики.
|
||||
// username нужен ledger2 только для USER_SHARED-стороны (кошелёк-подотчёт) —
|
||||
// это ПОЛУЧАТЕЛЬ аванса, не автор СЗ.
|
||||
const auto& ops = expense_ops_for(it->source_wallet);
|
||||
const auto code = static_cast<D::Mechanics>(paid_mechanics) == D::Mechanics::ADVANCE
|
||||
? ops.advance
|
||||
: ops.direct;
|
||||
Ledger2::apply(get_self(), coopname, code, actual_amount,
|
||||
paid_recipient, proposal_hash, "expense:payexp");
|
||||
}
|
||||
|
||||
void expense::reportexp(name coopname, checksum256 proposal_hash, checksum256 item_hash) {
|
||||
require_auth(coopname);
|
||||
|
||||
auto tbl = get_proposals(get_self(), coopname);
|
||||
auto idx = tbl.get_index<"byhash"_n>();
|
||||
auto it = idx.find(proposal_hash);
|
||||
eosio::check(it != idx.end(), "СЗ не найден");
|
||||
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
|
||||
"Отчёт возможен только по СЗ в статусе PARTIALLY_PAID");
|
||||
|
||||
asset item_amount = asset{0, it->total_actual.symbol};
|
||||
bool item_found = false;
|
||||
name item_recipient{};
|
||||
|
||||
idx.modify(it, get_self(), [&](auto& row) {
|
||||
bool all_reported = true;
|
||||
for (auto& i : row.items) {
|
||||
if (i.item_hash == item_hash) {
|
||||
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::PAID),
|
||||
"Отчёт возможен только для оплаченных items");
|
||||
eosio::check(static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::ADVANCE,
|
||||
"reportexp применим только к ADVANCE-механике");
|
||||
i.status = static_cast<uint8_t>(D::ItemStatus::REPORTED);
|
||||
item_amount = i.actual_amount;
|
||||
item_recipient = i.recipient;
|
||||
item_found = true;
|
||||
}
|
||||
if (i.status != static_cast<uint8_t>(D::ItemStatus::REPORTED)) {
|
||||
all_reported = false;
|
||||
}
|
||||
}
|
||||
eosio::check(item_found, "item с заданным hash не найден в СЗ");
|
||||
if (all_reported) {
|
||||
row.status = static_cast<uint8_t>(D::ProposalStatus::REPORT_SUBMITTED);
|
||||
}
|
||||
row.updated_at = eosio::current_time_point();
|
||||
});
|
||||
|
||||
// ledger2: BURN кошелька-подотчёта на остаточный actual item'а (выдано −
|
||||
// возвращено + доплачено), без бухпроводки — проводка уже сделана при выдаче.
|
||||
// Подотчёт числится на получателе аванса — его username и закрывает burn.
|
||||
// Полный возврат аванса (actual == 0) — burn не нужен, подотчёт уже пуст.
|
||||
if (item_amount.amount > 0) {
|
||||
Ledger2::apply(get_self(), coopname, expense_ops_for(it->source_wallet).report, item_amount,
|
||||
item_recipient, proposal_hash, "expense:reportexp");
|
||||
}
|
||||
}
|
||||
|
||||
void expense::closeexp(name coopname, checksum256 proposal_hash) {
|
||||
require_auth(coopname);
|
||||
|
||||
auto tbl = get_proposals(get_self(), coopname);
|
||||
auto idx = tbl.get_index<"byhash"_n>();
|
||||
auto it = idx.find(proposal_hash);
|
||||
eosio::check(it != idx.end(), "СЗ не найден");
|
||||
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::REPORT_SUBMITTED),
|
||||
"Закрыть можно только СЗ в статусе REPORT_SUBMITTED");
|
||||
|
||||
idx.modify(it, get_self(), [&](auto& row) {
|
||||
row.status = static_cast<uint8_t>(D::ProposalStatus::CLOSED);
|
||||
row.updated_at = eosio::current_time_point();
|
||||
});
|
||||
|
||||
// Callback на финализацию (capital::onpgexpdone и т.п.) — если был установлен при createexp.
|
||||
send_callback_if_any(it->callback, coopname, proposal_hash,
|
||||
static_cast<uint8_t>(D::ProposalStatus::CLOSED),
|
||||
it->total_actual, get_self());
|
||||
}
|
||||
|
||||
void expense::returnexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
|
||||
asset return_amount) {
|
||||
require_auth(coopname);
|
||||
|
||||
auto tbl = get_proposals(get_self(), coopname);
|
||||
auto idx = tbl.get_index<"byhash"_n>();
|
||||
auto it = idx.find(proposal_hash);
|
||||
eosio::check(it != idx.end(), "СЗ не найден");
|
||||
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
|
||||
"Возврат возможен только по СЗ в статусе PARTIALLY_PAID");
|
||||
|
||||
bool item_found = false;
|
||||
name item_recipient{};
|
||||
|
||||
idx.modify(it, get_self(), [&](auto& row) {
|
||||
for (auto& i : row.items) {
|
||||
if (i.item_hash == item_hash) {
|
||||
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::PAID),
|
||||
"Возврат возможен только из статуса PAID");
|
||||
eosio::check(static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::ADVANCE,
|
||||
"returnexp применим только к ADVANCE-механике");
|
||||
eosio::check(return_amount.is_valid() &&
|
||||
return_amount.symbol == i.actual_amount.symbol,
|
||||
"Символ return_amount не совпадает с item");
|
||||
eosio::check(return_amount.amount > 0 && return_amount.amount <= i.actual_amount.amount,
|
||||
"return_amount должен быть положительным и не превышать actual_amount");
|
||||
// Settlement-запись: статус item НЕ меняется (остаётся PAID) — после
|
||||
// возврата остатка получатель штатно отчитывается reportexp по
|
||||
// фактически потраченной части (при полном возврате — actual == 0).
|
||||
i.actual_amount -= return_amount;
|
||||
item_recipient = i.recipient;
|
||||
item_found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
eosio::check(item_found, "item с заданным hash не найден в СЗ");
|
||||
row.total_actual -= return_amount;
|
||||
row.updated_at = eosio::current_time_point();
|
||||
});
|
||||
|
||||
// ledger2: TRANSFER подотчёт → пул-источник (зеркало выдачи аванса).
|
||||
// Подотчёт снимается с получателя аванса.
|
||||
Ledger2::apply(get_self(), coopname, expense_ops_for(it->source_wallet).refund, return_amount,
|
||||
item_recipient, proposal_hash, "expense:returnexp");
|
||||
}
|
||||
|
||||
void expense::overspendexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
|
||||
asset overspend_amount) {
|
||||
require_auth(coopname);
|
||||
eosio::check(overspend_amount.amount > 0, "overspend_amount должен быть положительным");
|
||||
|
||||
auto tbl = get_proposals(get_self(), coopname);
|
||||
auto idx = tbl.get_index<"byhash"_n>();
|
||||
auto it = idx.find(proposal_hash);
|
||||
eosio::check(it != idx.end(), "СЗ не найден");
|
||||
eosio::check(it->status == static_cast<uint8_t>(D::ProposalStatus::PARTIALLY_PAID),
|
||||
"Перерасход возможен только по СЗ в статусе PARTIALLY_PAID");
|
||||
|
||||
bool item_found = false;
|
||||
name item_recipient{};
|
||||
|
||||
idx.modify(it, get_self(), [&](auto& row) {
|
||||
for (auto& i : row.items) {
|
||||
if (i.item_hash == item_hash) {
|
||||
eosio::check(i.status == static_cast<uint8_t>(D::ItemStatus::PAID),
|
||||
"Перерасход регистрируется только из статуса PAID");
|
||||
eosio::check(static_cast<D::Mechanics>(i.mechanics) == D::Mechanics::ADVANCE,
|
||||
"overspendexp применим только к ADVANCE-механике");
|
||||
eosio::check(overspend_amount.is_valid() &&
|
||||
overspend_amount.symbol == i.actual_amount.symbol,
|
||||
"Символ overspend_amount не совпадает с item");
|
||||
// Settlement-запись: статус item НЕ меняется (остаётся PAID) — подотчёт
|
||||
// (теперь на полную сумму выдано + доплата) закрывается штатным reportexp,
|
||||
// который сделает burn ADVANCE_HOLD на итоговый actual.
|
||||
i.actual_amount += overspend_amount;
|
||||
item_recipient = i.recipient;
|
||||
item_found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
eosio::check(item_found, "item с заданным hash не найден в СЗ");
|
||||
row.total_actual += overspend_amount;
|
||||
row.updated_at = eosio::current_time_point();
|
||||
});
|
||||
|
||||
// ledger2: доплата перерасхода — TRANSFER пул-источник → подотчёт получателя
|
||||
// (зеркало выдачи аванса на сумму доплаты). Закрытие на полную сумму — в reportexp.
|
||||
Ledger2::apply(get_self(), coopname, expense_ops_for(it->source_wallet).overspend, overspend_amount,
|
||||
item_recipient, proposal_hash, "expense:overspend");
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/eosio.hpp>
|
||||
#include <eosio/asset.hpp>
|
||||
#include <eosio/crypto.hpp>
|
||||
#include "../lib/index.hpp"
|
||||
|
||||
using namespace eosio;
|
||||
using std::string;
|
||||
|
||||
/**
|
||||
* @defgroup public_expense Контракт EXPENSE
|
||||
* @brief Шасси расходов цифрового кооператива (MVP — Благорост).
|
||||
*
|
||||
* Универсальный контракт: principal storage = СЗ-расход с массивом items;
|
||||
* кошелёк-источник (пул) приходит параметром source_wallet, ledger2-коды всех
|
||||
* операций жизненного цикла выводятся из него через EXPENSE_OPERATION_SETS
|
||||
* (ledger2/operations.hpp) — подключение нового пула не меняет контракт;
|
||||
* механика оплаты per-item (ADVANCE | DIRECT) выбирает код внутри набора;
|
||||
* callback на финализацию (контракт+action+data) сохраняется при createexp
|
||||
* как переменная — `expense` агностичен к программе-получателю.
|
||||
*
|
||||
* See: components/desktop/extensions/expenses/NAMING-C28-28.md (v3).
|
||||
*/
|
||||
|
||||
namespace ExpenseDomain {
|
||||
|
||||
/**
|
||||
* @brief Способ оплаты item-а в СЗ.
|
||||
* ADVANCE — пайщик-получатель оплачивает, затем приносит чек.
|
||||
* DIRECT — кассир/председатель платит организации напрямую.
|
||||
*/
|
||||
enum class Mechanics : uint8_t {
|
||||
ADVANCE = 0,
|
||||
DIRECT = 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Получатель платежа в item-е.
|
||||
* SELF — сам создатель СЗ.
|
||||
* MEMBER — другой пайщик.
|
||||
* ORG — внешняя организация.
|
||||
*/
|
||||
enum class RecipientType : uint8_t {
|
||||
SELF = 0,
|
||||
MEMBER = 1,
|
||||
ORG = 2,
|
||||
};
|
||||
|
||||
enum class ProposalStatus : uint8_t {
|
||||
CREATED = 0, ///< СЗ создан, ждёт подписи signact1 создателя.
|
||||
AUTHORIZED = 1, ///< СЗ подписан signact2 (совет), готов к оплате.
|
||||
PARTIALLY_PAID = 2, ///< Часть item-ов оплачена.
|
||||
REPORT_SUBMITTED = 3, ///< Отчёт о расходе подан, ждёт авторизации совета.
|
||||
CLOSED = 4, ///< СЗ-отчёт авторизован — расход закрыт.
|
||||
DECLINED = 5, ///< СЗ отклонён до или после авторизации.
|
||||
};
|
||||
|
||||
enum class ItemStatus : uint8_t {
|
||||
APPROVED = 0, ///< Запланирован, ждёт оплаты.
|
||||
PAID = 1, ///< Оплачен (ADVANCE — выдан пайщику; DIRECT — оплачен организации).
|
||||
REPORTED = 2, ///< Закрыт отчётом (чек приложен). DIRECT-item получает его сразу при payexp.
|
||||
RETURNED = 3, ///< Зарезервировано, не используется: returnexp уменьшает actual_amount, статус остаётся PAID до отчёта.
|
||||
OVERSPENT = 4, ///< Зарезервировано, не используется: overspendexp увеличивает actual_amount, статус остаётся PAID до отчёта.
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Callback на финализацию — устанавливается при createexp как переменная.
|
||||
*
|
||||
* Контракт expense не знает про capital/blagorost — он просто вызывает inline
|
||||
* action на (contract, action) и прокидывает data наружу. Пустой contract = нет callback.
|
||||
*
|
||||
* Пример: для расхода в РИД-проект Благороста UI/backend заполняет
|
||||
* { "capital"_n, "onexpreport"_n, packed(wip_project_hash) }.
|
||||
*/
|
||||
struct callback_handler {
|
||||
eosio::name contract;
|
||||
eosio::name action;
|
||||
std::vector<char> data;
|
||||
EOSLIB_SERIALIZE(callback_handler, (contract)(action)(data))
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Item — одна строка СЗ-расхода.
|
||||
*/
|
||||
struct item {
|
||||
eosio::checksum256 item_hash;
|
||||
uint8_t mechanics; ///< Mechanics
|
||||
uint8_t recipient_type; ///< RecipientType
|
||||
eosio::name recipient; ///< username (для SELF/MEMBER), либо name-идентификатор (для ORG)
|
||||
std::string description;
|
||||
eosio::asset planned_amount;
|
||||
eosio::asset actual_amount; ///< 0 пока не оплачено / не отчиталось
|
||||
uint8_t status; ///< ItemStatus
|
||||
|
||||
EOSLIB_SERIALIZE(item, (item_hash)(mechanics)(recipient_type)(recipient)(description)(planned_amount)(actual_amount)(status))
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Таблица СЗ-расходов. Scope = coopname.
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract("expense")]] proposal {
|
||||
uint64_t id;
|
||||
eosio::name coopname; ///< кооператив (= scope таблицы; дублируем полем по канону)
|
||||
eosio::checksum256 proposal_hash;
|
||||
eosio::name username; ///< создатель
|
||||
eosio::name source_wallet; ///< семантически — из какого ЦПП/фонда (w.cap.blago, w.sov.expns, …)
|
||||
uint8_t status; ///< ProposalStatus
|
||||
std::vector<item> items;
|
||||
eosio::asset total_planned;
|
||||
eosio::asset total_actual;
|
||||
callback_handler callback; ///< опционально; contract.value == 0 = нет callback
|
||||
document2 statement_doc; ///< type=2010, signact1 создателя
|
||||
document2 decision_doc; ///< type=2011, signact2 совета
|
||||
eosio::time_point_sec created_at;
|
||||
eosio::time_point_sec updated_at;
|
||||
|
||||
uint64_t primary_key() const { return id; }
|
||||
eosio::checksum256 by_hash() const { return proposal_hash; }
|
||||
uint64_t by_username() const { return username.value; }
|
||||
uint64_t by_status() const { return static_cast<uint64_t>(status); }
|
||||
|
||||
EOSLIB_SERIALIZE(proposal,
|
||||
(id)(coopname)(proposal_hash)(username)(source_wallet)(status)
|
||||
(items)(total_planned)(total_actual)(callback)(statement_doc)(decision_doc)
|
||||
(created_at)(updated_at))
|
||||
};
|
||||
|
||||
using proposals_index = eosio::multi_index<"proposals"_n, proposal,
|
||||
eosio::indexed_by<"byhash"_n, eosio::const_mem_fun<proposal, eosio::checksum256, &proposal::by_hash>>,
|
||||
eosio::indexed_by<"byusername"_n, eosio::const_mem_fun<proposal, uint64_t, &proposal::by_username>>,
|
||||
eosio::indexed_by<"bystatus"_n, eosio::const_mem_fun<proposal, uint64_t, &proposal::by_status>>
|
||||
>;
|
||||
|
||||
} // namespace ExpenseDomain
|
||||
|
||||
/**
|
||||
* @ingroup public_contracts
|
||||
* @brief Шасси расходов — 8 actions, MVP только Благорост.
|
||||
*/
|
||||
class [[eosio::contract("expense")]] expense : public contract {
|
||||
public:
|
||||
using contract::contract;
|
||||
|
||||
/**
|
||||
* @brief Создать и подать СЗ-расход.
|
||||
*
|
||||
* Создание и подача — одна транзакция (slug createexp). На входе: items + источник
|
||||
* (source_wallet) + опц. callback на финализацию.
|
||||
* Подписывает создатель (signact1 statement_doc, type=2010).
|
||||
*
|
||||
* Авторизация: coopname (прямой вызов backend'а) либо контракт-инициатор из
|
||||
* contracts_whitelist (inline, например capital::createpgexp).
|
||||
* Механика оплаты — per-item (item.mechanics: ADVANCE | DIRECT); ledger2-код
|
||||
* операции выводится из механики позиции в момент оплаты payexp.
|
||||
*
|
||||
* Ставит вопрос в повестку совета (soviet::createagenda, тип createexp):
|
||||
* утверждение совета → callback authexp, отклонение → declexp.
|
||||
*/
|
||||
[[eosio::action]]
|
||||
void createexp(name coopname, name username,
|
||||
checksum256 proposal_hash,
|
||||
name source_wallet,
|
||||
std::vector<ExpenseDomain::item> items,
|
||||
ExpenseDomain::callback_handler callback,
|
||||
document2 statement);
|
||||
|
||||
/**
|
||||
* @brief Авторизовать СЗ советом (signact2 decision_doc, type=2011).
|
||||
* Callback решения совета — вызывается только контрактом soviet после
|
||||
* голосования и утверждения председателем. После этого расход доступен для оплаты.
|
||||
*/
|
||||
[[eosio::action]]
|
||||
void authexp(name coopname, checksum256 proposal_hash, document2 decision);
|
||||
|
||||
/**
|
||||
* @brief Отклонить СЗ. Callback решения совета (отрицательный консенсус
|
||||
* голосов «против» либо просрочка повестки) — вызывается только контрактом
|
||||
* soviet. Возможно только до первой оплаты (CREATED / AUTHORIZED):
|
||||
* после payexp средства уже ушли через ledger2, и СЗ завершается обычным
|
||||
* путём reportexp / returnexp → closeexp.
|
||||
*/
|
||||
[[eosio::action]]
|
||||
void declexp(name coopname, checksum256 proposal_hash, std::string reason);
|
||||
|
||||
/**
|
||||
* @brief Оплатить item — выдача аванса (ADVANCE) или прямая оплата организации (DIRECT).
|
||||
* Контракт зовёт Ledger2::apply с кодом из набора операций кошелька-источника
|
||||
* (EXPENSE_OPERATION_SETS: source_wallet + механика item'а → operation_code).
|
||||
* actual_amount не может превышать план item (доплата — через overspendexp).
|
||||
* DIRECT-item не имеет фазы подотчёта и помечается REPORTED сразу; когда все
|
||||
* items REPORTED — proposal переходит в REPORT_SUBMITTED прямо из payexp.
|
||||
*/
|
||||
[[eosio::action]]
|
||||
void payexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
|
||||
asset actual_amount);
|
||||
|
||||
/**
|
||||
* @brief Отчёт о расходе (ADVANCE) — пайщик закрывает item чеком.
|
||||
* Зовёт Ledger2::apply(o.exp.advrpt) на остаточный actual item'а (при полном
|
||||
* возврате аванса burn пропускается). Когда все items reported — статус
|
||||
* proposal становится REPORT_SUBMITTED.
|
||||
*/
|
||||
[[eosio::action]]
|
||||
void reportexp(name coopname, checksum256 proposal_hash, checksum256 item_hash);
|
||||
|
||||
/**
|
||||
* @brief Закрытие расхода советом — финальный signact2 СЗ-отчёта.
|
||||
* Если у proposal заполнен callback — отправляет inline action на (callback.contract, callback.action, callback.data).
|
||||
*/
|
||||
[[eosio::action]]
|
||||
void closeexp(name coopname, checksum256 proposal_hash);
|
||||
|
||||
/**
|
||||
* @brief Возврат неиспользованного аванса (ADVANCE-остаток).
|
||||
* Settlement-запись: уменьшает actual_amount item'а, статус остаётся PAID —
|
||||
* фактически потраченная часть закрывается затем штатным reportexp.
|
||||
* Зовёт Ledger2::apply(o.exp.advret).
|
||||
*/
|
||||
[[eosio::action]]
|
||||
void returnexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
|
||||
asset return_amount);
|
||||
|
||||
/**
|
||||
* @brief Доплата при перерасходе (ADVANCE).
|
||||
* Settlement-запись: увеличивает actual_amount item'а, статус остаётся PAID.
|
||||
* Зовёт Ledger2::apply(o.exp.over) — выдача доплаты; закрытие подотчёта на
|
||||
* полную сумму (выдано + доплата) делает последующий reportexp.
|
||||
*/
|
||||
[[eosio::action]]
|
||||
void overspendexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
|
||||
asset overspend_amount);
|
||||
};
|
||||
@@ -117,21 +117,23 @@ public:
|
||||
std::string memo);
|
||||
|
||||
/**
|
||||
* @brief Универсальное миграционное действие — точка расширения для
|
||||
* разовых исправлений состояния, которые можно провести автоматически
|
||||
* после деплоя контракта.
|
||||
* @brief Миграция остатков с legacy-ledger в ledger2 (курсорный режим).
|
||||
*
|
||||
* Тело периодически переписывается под текущую задачу миграции; после
|
||||
* прогона на проде очищается до пустого `require_auth(get_self())`.
|
||||
* История прошлых миграций — в git-истории `ledger2/src/migrate/migrate.cpp`.
|
||||
* @param from_coop_index начальный индекс в таблице registrator::coops (0 — с начала)
|
||||
* @param limit максимум кооп. за один вызов (UINT64_MAX — до конца)
|
||||
*
|
||||
* Текущая задача — см. doxygen-блок в migrate.cpp.
|
||||
* Полный прогон: `migrate(0, UINT64_MAX)`. Продовый прогон порциями:
|
||||
* `migrate(0, 10)`, `migrate(10, 10)`, ... Мета-таблица хранит
|
||||
* `last_migrated_coop_index` для возобновления.
|
||||
*
|
||||
* Все балансы вносятся через inline `apply(operations::migration::*)` — единый путь учёта
|
||||
* с полной двойной проводкой и audit-trail (пересмотр 2026-04-20:
|
||||
* детерминированное разнесение на 6 целевых кошельков без транзитного
|
||||
* счёта 99 и без зеркала CASH_MAIN).
|
||||
*
|
||||
* @ingroup public_ledger2_actions
|
||||
*
|
||||
* @note Авторизация требуется от аккаунта: @p ledger2 (get_self()).
|
||||
*/
|
||||
[[eosio::action]] void migrate();
|
||||
[[eosio::action]] void migrate(uint64_t from_coop_index, uint64_t limit);
|
||||
|
||||
/**
|
||||
* @brief Идемпотентная per-record миграция L3-балансов (Phase 1/2; ADR-008).
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
* Внутренний action ledger2 — вызывается только через inline из apply().
|
||||
* Auth: только сам ledger2 (require_auth(get_self())).
|
||||
@@ -53,15 +53,10 @@ 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.
|
||||
eosio::check(op_code <= 4, "walletop: неизвестный op_code");
|
||||
|
||||
wallets2_index wallets(get_self(), coopname.value);
|
||||
userwallets_index user_wallets(get_self(), coopname.value);
|
||||
@@ -174,7 +169,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), а sender-guard на
|
||||
// строке 46-47 запрещает обход. Поэтому инвариант сохраняется по
|
||||
// конструкции; полную сверку выполняет бэкенд («стол бухгалтера»),
|
||||
// вне транзакционного hot path.
|
||||
@@ -229,6 +224,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 должен быть пустым");
|
||||
@@ -253,12 +302,6 @@ void ledger2::walletop(eosio::name coopname,
|
||||
cleanup_l3_if_empty(wallet_from);
|
||||
break;
|
||||
}
|
||||
case WalletOp::NONE: {
|
||||
// Недостижимо: проверка op_code != NONE стоит на входе action; этот case
|
||||
// нужен только чтобы покрыть enum в switch (-Wswitch).
|
||||
eosio::check(false, "walletop NONE: запрещённый op_code");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Post-mutation Σ L3 == L2: см. блок выше (строки 163-175). Снято
|
||||
|
||||
@@ -1,144 +1,634 @@
|
||||
#include <array>
|
||||
|
||||
/**
|
||||
* @brief Универсальное миграционное действие контракта ledger2 — точка
|
||||
* расширения для разовых исправлений состояния, которые можно провести
|
||||
* автоматически после деплоя.
|
||||
* @brief Миграция остатков с legacy-ledger на ledger2 (пересмотр 2026-04-20).
|
||||
*
|
||||
* Содержимое периодически переписывается под текущую задачу миграции, а
|
||||
* после её прогона на проде тело очищается до пустого `require_auth(get_self())`
|
||||
* (как в `capital::migrate`).
|
||||
* Разделение на два независимых потока:
|
||||
*
|
||||
* Текущая задача (2026-05-24): свёртка `blocked → available` по ВСЕМ коопам.
|
||||
* A. **Бухгалтерские остатки** из `ledger::accounts` (scope=coopname,
|
||||
* contract=_ledger) → через 4 inline `apply(operations::migration::*)`
|
||||
* с полной двойной проводкой. Счета: 51 / 80 / 86 / 04.
|
||||
*
|
||||
* Контекст: механика «заблокированного» баланса упразднена (см.
|
||||
* `lib/core/ledger2/operations.hpp` — удалены WalletOp BLOCK/UNBLOCK/BURN_BLOCKED;
|
||||
* резерв возврата паевого теперь выражается переводом на кошелёк-резерв
|
||||
* `w.wal.wpend`). Поле `blocked` остаётся в таблицах `wallets2`/`userwallets`
|
||||
* как deprecated (физическое удаление поля = небезопасная смена layout таблицы
|
||||
* на живых коопах, выносится в отдельный cleanup-деплой). Перед тем как поле
|
||||
* перестанет поддерживаться кодом, накопленные `blocked`-остатки нужно вернуть
|
||||
* в `available`, чтобы средства не «зависли» на упразднённом субсчёте.
|
||||
* B. **Программные кошельки** из `soviet::progwallets` (scope=coopname,
|
||||
* contract=_soviet) → прямой `wallets2.emplace` в ledger2 БЕЗ
|
||||
* бух-проводок. Legacy::accounts и soviet::progwallets — параллельные
|
||||
* системы учёта, progwallet.blocked не проводится через 80-й счёт,
|
||||
* поэтому любая попытка провести его через Dr 51 / Cr 80 вызовет
|
||||
* двойной учёт на бухуровне. Вместо этого переносим только
|
||||
* wallet-аналитику (w.cap.blago BLAGOROST_FUND, w.cap.gen GENERATOR_FUND — ADR-009).
|
||||
*
|
||||
* Действие: пройти всех кооперативов (cooperatives2 в scope registrator) и для
|
||||
* каждого свернуть `blocked → available` на уровнях L2 (`wallets2`) и L3
|
||||
* (`userwallets`): `available += blocked; blocked = 0`. Сумма средств на кошельке
|
||||
* не меняется — только субсчёт.
|
||||
* Алгоритм на каждый кооператив:
|
||||
*
|
||||
* Идемпотентно: после свёртки `blocked == 0`, повторный прогон — no-op.
|
||||
* 1. Чтение legacy-счетов (laccounts_index, scope=coopname):
|
||||
* cash_legacy = account[51].available + .blocked
|
||||
* share_legacy = account[80].available + .blocked
|
||||
* entry_legacy = account[861].available + .blocked
|
||||
*
|
||||
* Сигнатура без аргументов — действие вызывается автоматически при деплое
|
||||
* контракта (как и прочие задачи migrate); проходит по всем кооперативам сам.
|
||||
* 2. Чтение параметров кооператива (cooperative2, scope=_registrator):
|
||||
* coop.minimum — минимальный паевой для individual / entrepreneur
|
||||
* coop.org_minimum — минимальный паевой для organization (binary_extension)
|
||||
*
|
||||
* ПРЕДУСЛОВИЕ (операционное): на момент прогона не должно быть заявок на возврат
|
||||
* «в полёте» (статусы pending/authorized в `wallet::withdraws`) — их `blocked`
|
||||
* относится к старой механике и при свёртке в `available` вернётся пайщику как
|
||||
* свободные средства, а последующий `completewthd` (BURN с `w.wal.wpend`) не
|
||||
* найдёт резерва. Незавершённые заявки нужно довести (complete/decline) ДО
|
||||
* деплоя с этой миграцией.
|
||||
* 3. Вычисление распределения:
|
||||
* share_money = cash_legacy − entry_legacy
|
||||
* min_total = Σ participant.minimum_amount по accepted-пайщикам
|
||||
* (фактический зафиксированный минимум каждого пайщика;
|
||||
* см. compute_min_total_by_type).
|
||||
* share_remain = share_money − min_total
|
||||
*
|
||||
* Инварианты (eosio::check):
|
||||
* cash_legacy >= entry_legacy
|
||||
* share_legacy == share_money (legacy 80 без РИД-части — иначе abort)
|
||||
* min_total <= share_money (данные пайщиков сходятся с legacy[80])
|
||||
*
|
||||
* 4. Отправка 3 inline apply (ненулевые пропускаются):
|
||||
* apply(migration::MIN_SHARE, min_total) → Dr 51 / Cr 80, MIN_SHARE_FUND (w.reg.minshr)
|
||||
* apply(migration::SHARE, share_remain) → Dr 51 / Cr 80, SHARE_FUND_PAY (w.wal.share)
|
||||
* apply(migration::ENTRY, entry_legacy) → Dr 51 / Cr 86, ENTRANCE_FEES (w.reg.entry)
|
||||
*
|
||||
* 5. Прямой emplace в wallets2 для progwallets (БЕЗ бух-проводок):
|
||||
* Σ progwallet[blagorost].blocked → wallets2[BLAGOROST_FUND w.cap.blago]
|
||||
* Σ progwallet[generator].blocked → wallets2[GENERATOR_FUND w.cap.gen]
|
||||
*
|
||||
* Если запись wallets2 уже есть (после inline apply сработали) —
|
||||
* aggregate через wallets.modify(available += sum). Иначе — emplace.
|
||||
*
|
||||
* Курсорный режим: `migrate(from_coop_index, limit)`. Полный прогон —
|
||||
* `migrate(0, UINT64_MAX)`. Мета фиксирует `last_migrated_coop_index`.
|
||||
*
|
||||
* Поле `writeoff` legacy-ledger игнорируется целиком (PRD §4.1.2 FR-L-7).
|
||||
*
|
||||
* Сборка test mode (IS_TESTNET=1): спец-ветка voskhod ОТКЛЮЧЕНА — voskhod
|
||||
* прогоняется по стандартному арифметическому пути наравне с остальными
|
||||
* кооперативами. Это нужно, чтобы на тестнете invariant-фейлы (cash≥entry,
|
||||
* legacy 80 без РИД-части и т.п.) показывали понятные ошибки, а не маскировались
|
||||
* заранее заведёнными суммами. В prod-сборке хардкод по фактам сохраняется.
|
||||
*
|
||||
* @ingroup public_ledger2_actions
|
||||
*
|
||||
* @note Авторизация требуется от аккаунта: @p ledger2 (get_self()).
|
||||
*/
|
||||
void ledger2::migrate() {
|
||||
|
||||
namespace {
|
||||
|
||||
struct LegacyBalances {
|
||||
eosio::asset cash; ///< 51 — Расчётный счёт
|
||||
eosio::asset share; ///< 80 — Паевой фонд
|
||||
eosio::asset entry; ///< 861 — Вступительные взносы
|
||||
};
|
||||
|
||||
inline LegacyBalances read_legacy_balances(eosio::name coopname) {
|
||||
laccounts_index old_accounts(_ledger, coopname.value);
|
||||
|
||||
LegacyBalances r{
|
||||
eosio::asset(0, _root_govern_symbol),
|
||||
eosio::asset(0, _root_govern_symbol),
|
||||
eosio::asset(0, _root_govern_symbol),
|
||||
};
|
||||
|
||||
for (auto acc_it = old_accounts.begin(); acc_it != old_accounts.end(); ++acc_it) {
|
||||
// writeoff игнорируется (FR-L-7).
|
||||
const eosio::asset total = acc_it->available + acc_it->blocked;
|
||||
if (total.amount == 0) continue;
|
||||
|
||||
eosio::check(total.symbol == _root_govern_symbol,
|
||||
std::string{"migrate: legacy acc "} + std::to_string(acc_it->id) +
|
||||
" имеет неожиданный symbol");
|
||||
|
||||
switch (acc_it->id) {
|
||||
case Ledger::accounts::BANK_ACCOUNT: r.cash = total; break; // 51
|
||||
case Ledger::accounts::SHARE_FUND: r.share = total; break; // 80
|
||||
case Ledger::accounts::ENTRANCE_FEES: r.entry = total; break; // 861
|
||||
default:
|
||||
// Прочие legacy-id (862..867 — RESERVE/INDIVISIBLE/ECONOMIC/MUTUAL/DEVELOPMENT/DELEGATE_FEES)
|
||||
// в ledger2 как отдельные кошельки не выделены. Намеренно пропускаем —
|
||||
// не падаем, чтобы не блокировать миграцию кооператива.
|
||||
break;
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Суммирует `blocked` по `progwallets` (soviet, scope=coopname) для program_id.
|
||||
*/
|
||||
inline eosio::asset sum_progwallet_blocked(eosio::name coopname, uint64_t program_id) {
|
||||
progwallets_index progwallets(_soviet, coopname.value);
|
||||
auto byprog = progwallets.get_index<"byprogram"_n>();
|
||||
|
||||
eosio::asset total(0, _root_govern_symbol);
|
||||
|
||||
for (auto it = byprog.lower_bound(program_id); it != byprog.end() && it->program_id == program_id; ++it) {
|
||||
if (!it->blocked.has_value()) continue;
|
||||
const auto& b = it->blocked.value();
|
||||
if (b.amount == 0) continue;
|
||||
eosio::check(b.symbol == _root_govern_symbol,
|
||||
std::string{"migrate: progwallet program_id="} + std::to_string(program_id) +
|
||||
" имеет неожиданный symbol");
|
||||
total += b;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Считает суммарный «минимальный паевой» по всем accepted-пайщикам коопа.
|
||||
*
|
||||
* Берёт ФАКТИЧЕСКИЙ минимум, ЗАФИКСИРОВАННЫЙ на пайщике
|
||||
* (`participant.minimum_amount`), а НЕ актуальный coop.minimum/org_minimum.
|
||||
* coop.minimum мог быть повышен/понижен после вступления, но реальный взнос
|
||||
* пайщика, попавший в legacy::accounts[80], равен `participant.minimum_amount`.
|
||||
* Иначе после миграции Σ L3 (по фактическим минимумам) разойдётся с L2,
|
||||
* который мы построим по этой сумме.
|
||||
*
|
||||
* Поле `minimum_amount` — binary_extension; для legacy-записей до выставления
|
||||
* этого поля fallback на coop.minimum / coop.org_minimum.
|
||||
*/
|
||||
inline eosio::asset compute_min_total_by_type(eosio::name coopname, const cooperative2& coop) {
|
||||
eosio::check(coop.minimum.symbol == _root_govern_symbol,
|
||||
std::string{"migrate: cooperative2.minimum имеет неожиданный symbol на "} +
|
||||
coopname.to_string());
|
||||
|
||||
const eosio::asset org_min_fallback =
|
||||
coop.org_minimum.has_value() ? coop.org_minimum.value() : coop.minimum;
|
||||
eosio::check(org_min_fallback.symbol == _root_govern_symbol,
|
||||
std::string{"migrate: cooperative2.org_minimum имеет неожиданный symbol на "} +
|
||||
coopname.to_string());
|
||||
|
||||
participants_index parts(_soviet, coopname.value);
|
||||
int64_t total_raw = 0;
|
||||
for (auto it = parts.begin(); it != parts.end(); ++it) {
|
||||
if (it->status != "accepted"_n) continue;
|
||||
|
||||
if (it->minimum_amount.has_value()) {
|
||||
const auto& m = it->minimum_amount.value();
|
||||
eosio::check(m.symbol == _root_govern_symbol,
|
||||
std::string{"migrate: participant.minimum_amount имеет неожиданный symbol на "} +
|
||||
coopname.to_string() + "/" + it->username.to_string());
|
||||
total_raw += m.amount;
|
||||
} else {
|
||||
// Legacy-запись без зафиксированного minimum_amount: fallback по типу.
|
||||
const bool is_org =
|
||||
it->type.has_value() && it->type.value() == "organization"_n;
|
||||
total_raw += is_org ? org_min_fallback.amount : coop.minimum.amount;
|
||||
}
|
||||
}
|
||||
|
||||
return eosio::asset(total_raw, _root_govern_symbol);
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет inline apply для одной миграционной операции, если amount > 0.
|
||||
* process_hash детерминирован по (coopname, operation_code).
|
||||
*/
|
||||
inline void send_transit(eosio::name self_name,
|
||||
eosio::name coopname,
|
||||
eosio::name operation_code,
|
||||
const eosio::asset& amt) {
|
||||
if (amt.amount == 0) return;
|
||||
|
||||
const std::string hash_src =
|
||||
std::string{"mig::"} + coopname.to_string() + std::string{"::"} + operation_code.to_string();
|
||||
const eosio::checksum256 proc_hash = hashit(hash_src);
|
||||
|
||||
Ledger2::apply(
|
||||
self_name,
|
||||
coopname,
|
||||
operation_code,
|
||||
amt,
|
||||
eosio::name{}, // username не применим к транзитной проводке
|
||||
proc_hash,
|
||||
std::string{"Транзитная миграция остатков legacy → ledger2"}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Прямой emplace/add в wallets2 БЕЗ бух-проводок. Используется для переноса
|
||||
* соwiet::progwallets → wallets2, т.к. progwallets не проводятся через
|
||||
* ledger::accounts (параллельная система учёта — двойная запись не нужна
|
||||
* и привела бы к двойному учёту на 80).
|
||||
*/
|
||||
inline void emplace_wallet_only(eosio::name self_name,
|
||||
eosio::name coopname,
|
||||
eosio::name wallet_id,
|
||||
const eosio::asset& amt) {
|
||||
if (amt.amount == 0) return;
|
||||
|
||||
wallets2_index wallets(self_name, coopname.value);
|
||||
const auto human_view = ledger2_get_wallet_human_name(wallet_id);
|
||||
eosio::check(!human_view.empty(),
|
||||
std::string{"migrate: unknown wallet "} + wallet_id.to_string());
|
||||
|
||||
auto it = wallets.find(wallet_id.value);
|
||||
if (it == wallets.end()) {
|
||||
wallets.emplace(self_name, [&](auto& w) {
|
||||
w.id = wallet_id;
|
||||
w.name = std::string(human_view);
|
||||
w.available = amt;
|
||||
w.blocked = eosio::asset(0, amt.symbol);
|
||||
});
|
||||
} else {
|
||||
// Если кошелёк уже создан (например, предыдущим inline apply TRANSIT_*) —
|
||||
// просто доливаем сумму в available. Не должно случаться при чистой
|
||||
// миграции (w.cap.blago/w.cap.gen не используются базовыми TRANSIT_*),
|
||||
// но безопасно.
|
||||
wallets.modify(it, self_name, [&](auto& w) { w.available += amt; });
|
||||
}
|
||||
}
|
||||
|
||||
#ifndef IS_TESTNET
|
||||
/**
|
||||
* Прямой emplace в wallets2 с произвольным available/blocked. Используется
|
||||
* только из voskhod-спец-ветки: там L2-агрегаты нужно завести строго по
|
||||
* фактическому распределению chain (Благорост — blocked, не available).
|
||||
*/
|
||||
inline void emplace_wallet_balance(eosio::name self_name,
|
||||
eosio::name coopname,
|
||||
eosio::name wallet_id,
|
||||
const eosio::asset& available,
|
||||
const eosio::asset& blocked) {
|
||||
if (available.amount == 0 && blocked.amount == 0) return;
|
||||
|
||||
wallets2_index wallets(self_name, coopname.value);
|
||||
const auto human_view = ledger2_get_wallet_human_name(wallet_id);
|
||||
eosio::check(!human_view.empty(),
|
||||
std::string{"migrate: unknown wallet "} + wallet_id.to_string());
|
||||
|
||||
auto it = wallets.find(wallet_id.value);
|
||||
eosio::check(it == wallets.end(),
|
||||
std::string{"migrate: wallet "} + wallet_id.to_string() + " уже создан");
|
||||
wallets.emplace(self_name, [&](auto& w) {
|
||||
w.id = wallet_id;
|
||||
w.name = std::string(human_view);
|
||||
w.available = available;
|
||||
w.blocked = blocked;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Прямой emplace в accounts2 БЕЗ бух-проводок. Используется только из
|
||||
* voskhod-спец-ветки: после хардкода в кошельках замыкаем баланс прямой
|
||||
* записью в accounts2 (отдельные «миграционные» операции под Dr 04 / Dr 08
|
||||
* усложнили бы общий реестр).
|
||||
*
|
||||
* Тип счёта определяется планом (LEDGER2_ACCOUNT_MAP):
|
||||
* - ACTIVE — записываем `balance` в `debit_balance`, `credit_balance = 0`;
|
||||
* - PASSIVE — записываем `balance` в `credit_balance`, `debit_balance = 0`.
|
||||
*/
|
||||
inline void emplace_account_balance(eosio::name self_name,
|
||||
eosio::name coopname,
|
||||
uint64_t account_id,
|
||||
const eosio::asset& balance) {
|
||||
if (balance.amount == 0) return;
|
||||
const auto* meta = ledger2_find_account_meta(account_id);
|
||||
eosio::check(meta != nullptr,
|
||||
std::string{"migrate: unknown account_id "} + std::to_string(account_id));
|
||||
|
||||
accounts2_index accounts(self_name, coopname.value);
|
||||
auto it = accounts.find(account_id);
|
||||
eosio::check(it == accounts.end(),
|
||||
std::string{"migrate: account "} + std::to_string(account_id) + " уже создан");
|
||||
|
||||
const eosio::asset zero(0, balance.symbol);
|
||||
const uint8_t type_u8 = static_cast<uint8_t>(meta->type);
|
||||
|
||||
accounts.emplace(self_name, [&](auto& a) {
|
||||
a.id = account_id;
|
||||
a.name = std::string(meta->name);
|
||||
a.account_type = type_u8;
|
||||
if (meta->type == AccountType::PASSIVE) {
|
||||
a.debit_balance = zero;
|
||||
a.credit_balance = balance;
|
||||
} else { // ACTIVE / ACTIVE_PASSIVE
|
||||
a.debit_balance = balance;
|
||||
a.credit_balance = zero;
|
||||
}
|
||||
a.balance = account2::compute_balance(type_u8, a.debit_balance, a.credit_balance);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Прямой emplace L3-записи в userwallets БЕЗ бух-проводок. Только для
|
||||
* `kind == USER_SHARED`. Записи с (0,0) — пропускаются.
|
||||
*/
|
||||
inline void emplace_userwallet_only(eosio::name self_name,
|
||||
eosio::name coopname,
|
||||
eosio::name wallet_id,
|
||||
eosio::name username,
|
||||
const eosio::asset& available,
|
||||
const eosio::asset& blocked) {
|
||||
if (available.amount == 0 && blocked.amount == 0) return;
|
||||
|
||||
const WalletKind kind = ledger2_get_wallet_kind(wallet_id);
|
||||
eosio::check(kind == WalletKind::USER_SHARED,
|
||||
std::string{"migrate: userwallets допустим только для USER_SHARED, wallet="} +
|
||||
wallet_id.to_string());
|
||||
|
||||
userwallets_index user_wallets(self_name, coopname.value);
|
||||
auto idx = user_wallets.get_index<"byuserwallet"_n>();
|
||||
const auto key = combine_ids(wallet_id.value, username.value);
|
||||
auto it = idx.find(key);
|
||||
eosio::check(it == idx.end(),
|
||||
std::string{"migrate: userwallet (wallet="} + wallet_id.to_string() +
|
||||
", user=" + username.to_string() + ") уже существует");
|
||||
user_wallets.emplace(self_name, [&](auto& uw) {
|
||||
uw.id = user_wallets.available_primary_key();
|
||||
uw.wallet_name = wallet_id;
|
||||
uw.username = username;
|
||||
uw.available = available;
|
||||
uw.blocked = blocked;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Заводит L3-запись `w.cap.preimp` для пайщика, если он существует в
|
||||
* `soviet::participants` коопа. Если нет (тестнет/чужой контекст) — silently skip.
|
||||
*
|
||||
* Использовать ИСКЛЮЧИТЕЛЬНО для воскходных пред-импорт-РИД-учётов (5 пайщиков
|
||||
* по договору УХД, заведённых до перехода на электронный учёт: ЭГОЛ/Анацко/
|
||||
* Кочетков/Мильшин/Манакин). Их деньги списаны с `progwallets[pid=1]` cleos-
|
||||
* командами; здесь фиксируем их как РИД-взносы на отдельном кошельке для
|
||||
* последующего drppre при importcontr.
|
||||
*/
|
||||
inline void emplace_preimp_if_present(eosio::name self_name,
|
||||
eosio::name coopname,
|
||||
eosio::name username,
|
||||
int64_t amount_raw) {
|
||||
participants_index parts(_soviet, coopname.value);
|
||||
if (parts.find(username.value) == parts.end()) {
|
||||
return; // тестнет / иной контекст — пропускаем без падения
|
||||
}
|
||||
const eosio::asset amt(amount_raw, _root_govern_symbol);
|
||||
const eosio::asset zero(0, _root_govern_symbol);
|
||||
emplace_userwallet_only(self_name, coopname, ledger2_wallets::PREIMP_FUND, username, amt, zero);
|
||||
}
|
||||
#endif // !IS_TESTNET
|
||||
|
||||
#ifndef IS_TESTNET
|
||||
/**
|
||||
* Спец-ветка миграции для voskhod — РУЧНОЙ ХАРДКОД балансов (accounts2 +
|
||||
* wallets2 + L3 для пред-импорт-учётов).
|
||||
*
|
||||
* Включена только в prod-сборке (без IS_TESTNET). На тестнете voskhod идёт
|
||||
* по стандартному арифметическому пути наравне с остальными кооперативами,
|
||||
* чтобы любые invariant-несостыковки давали внятную ошибку, а не маскировались
|
||||
* заранее заведёнными суммами. Дополнительная защита внутри ветки —
|
||||
* `participants.find()`-guard в emplace_preimp_if_present, чтобы случайный
|
||||
* запуск voskhod-веточки в чужом окружении не падал.
|
||||
*
|
||||
* Цифры заведены вручную после сверки с фактическими данными mainnet
|
||||
* (snapshot 2026-05-10) и согласованы с председателем (см. project-memory
|
||||
* `voskhod migrate2 — финальный план миграции`).
|
||||
*
|
||||
* Причина хардкода: legacy::accounts и soviet::progwallets рассинхронизированы
|
||||
* (имущ. Благорост ~57M на legacy 80 без отражения в 04, бумажный шлейф на 51,
|
||||
* непокрытые хоз.расходы на 86). Арифметической сшивки нет — заводим суммы
|
||||
* напрямую по согласованной с бухгалтером картине.
|
||||
*
|
||||
* ┌────────────────────────────────────── accounts2 ──────────────────────────────────────┐
|
||||
* │ 51 (BANK_ACCOUNT, А) = 145 000 — 145 000 деньги │
|
||||
* │ 04 (INTANGIBLE_ASSETS, А) = 62 353 311 — имущ. Благорост 56 903 311 + preimp 5М4 │
|
||||
* │ 08 (NON_CURRENT_INVESTMENTS) = 575 200 — свод (балансировка) + 31 800 минП │
|
||||
* │ 80 (SHARE_FUND, П) = 62 946 011 — 419 900 ЦК + 57 044 311 Благорост │
|
||||
* │ + 31 800 минП + 5 450 000 preimp Cr-side │
|
||||
* │ 86 (TARGET_RECEIPTS, П) = 127 500 — хоз.расходы 115К + legacy 861 12 500 │
|
||||
* │ │
|
||||
* │ Σ Dr = 145 000 + 62 353 311 + 575 200 = 63 073 511 │
|
||||
* │ Σ Cr = 62 946 011 + 127 500 = 63 073 511 ✓ │
|
||||
* └─────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* ┌────────────────────────────── wallets2 (L2-агрегаты) ─────────────────────────────┐
|
||||
* │ w.sov.mnused available = 31 800 (мин.паевые, ушедшие в 08; COOPERATIVE) │
|
||||
* │ w.wal.share available = 419 900 (5 пайщиков ЦК-остаток; L3 → migrator-049) │
|
||||
* │ w.cap.blago blocked = 57 044 311 (12 пайщиков pid=4; L3 → migrator-049) │
|
||||
* │ w.cap.preimp available = 5 450 000 (5 пайщиков; L3 — ниже, прямой emplace) │
|
||||
* │ w.sov.expns available = 127 500 (хоз.расходы из числа целевого; COOPERATIVE) │
|
||||
* └────────────────────────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* w.reg.minshr на voskhod НЕ создаётся: мин.паевые (31 800) уже инвестированы в
|
||||
* НМА (08), поэтому L2-аналитика лежит на w.sov.mnused (COOPERATIVE, без L3).
|
||||
* Бухгалтерия (Cr 80 += 31 800; Dr 08 += 31 800) при этом сохранена — обязательство
|
||||
* перед пайщиками на 80 счёте не теряется. Из-за этого migrator-048 (L3 для
|
||||
* w.reg.minshr) на voskhod не запускается; жёсткий guard в migrate3 отвергает
|
||||
* запись L3(voskhod, w.reg.minshr).
|
||||
*
|
||||
* L3 (userwallets) для USER_SHARED-кошельков заводит migrator (Phase 2 = 049
|
||||
* для w.wal.share / w.wal.member / w.cap.blago). Здесь же — только 5 пред-импорт-
|
||||
* преимп-записей, выпавших из progwallets после ручных subbal'ов (см. ~/cleos.md,
|
||||
* 5 пайщиков с РИД-взносами по договорам УХД, не успевшим попасть в электронный
|
||||
* учёт до миграции).
|
||||
*/
|
||||
inline void migrate_voskhod_facts(eosio::name self_name, const cooperative2& coop) {
|
||||
const eosio::name coopname = coop.username;
|
||||
const eosio::symbol sym = _root_govern_symbol;
|
||||
const eosio::asset zero(0, sym);
|
||||
|
||||
// ───────── accounts2 ─────────
|
||||
emplace_account_balance(self_name, coopname, ledger2_accounts::BANK_ACCOUNT,
|
||||
eosio::asset( 1'450'000'000LL, sym)); // 145 000.0000 RUB
|
||||
emplace_account_balance(self_name, coopname, ledger2_accounts::INTANGIBLE_ASSETS,
|
||||
eosio::asset( 623'533'110'000LL, sym)); // 62 353 311.0000 RUB
|
||||
emplace_account_balance(self_name, coopname, ledger2_accounts::NON_CURRENT_INVESTMENTS,
|
||||
eosio::asset( 5'752'000'000LL, sym)); // 575 200.0000 RUB
|
||||
emplace_account_balance(self_name, coopname, ledger2_accounts::SHARE_FUND,
|
||||
eosio::asset( 629'460'110'000LL, sym)); // 62 946 011.0000 RUB
|
||||
emplace_account_balance(self_name, coopname, ledger2_accounts::TARGET_RECEIPTS,
|
||||
eosio::asset( 1'275'000'000LL, sym)); // 127 500.0000 RUB
|
||||
|
||||
// ───────── wallets2 (L2) ─────────
|
||||
// Мин.паевые на voskhod сразу размещены на w.sov.mnused (COOPERATIVE):
|
||||
// бухгалтерски они уже потрачены через 08 (Dr 08 += 31 800), но обязательство
|
||||
// Cr 80 += 31 800 сохранено. L3 для w.reg.minshr НЕ создаётся — см. guard в migrate3.
|
||||
emplace_wallet_balance(self_name, coopname, ledger2_wallets::MIN_SHARE_USED,
|
||||
eosio::asset( 318'000'000LL, sym), // 31 800.0000 RUB available
|
||||
zero);
|
||||
emplace_wallet_balance(self_name, coopname, ledger2_wallets::SHARE_FUND_PAY,
|
||||
eosio::asset( 4'199'000'000LL, sym), // 419 900.0000 RUB available
|
||||
zero);
|
||||
emplace_wallet_balance(self_name, coopname, ledger2_wallets::BLAGOROST_FUND,
|
||||
zero,
|
||||
eosio::asset(570'443'110'000LL, sym)); // 57 044 311.0000 RUB blocked
|
||||
emplace_wallet_balance(self_name, coopname, ledger2_wallets::PREIMP_FUND,
|
||||
eosio::asset(54'500'000'000LL, sym), // 5 450 000.0000 RUB available
|
||||
zero);
|
||||
emplace_wallet_balance(self_name, coopname, ledger2_wallets::SOV_EXPENSES,
|
||||
eosio::asset( 1'275'000'000LL, sym), // 127 500.0000 RUB available
|
||||
zero);
|
||||
|
||||
// ───────── L3: преимп-учёты (5 пайщиков с РИД-взносами по договорам УХД) ─────────
|
||||
// Сумма по таблице = 5 450 000 RUB = wallets2[w.cap.preimp].available ✓
|
||||
emplace_preimp_if_present(self_name, coopname, "honruwpdxtty"_n, 500'000'000LL); // 50 000.0000 ЭГОЛ
|
||||
emplace_preimp_if_present(self_name, coopname, "zlvsujtoctal"_n, 50'000'000'000LL); // 5 000 000.0000 Анацко
|
||||
emplace_preimp_if_present(self_name, coopname, "yxkjufikzxri"_n, 1'000'000'000LL); // 100 000.0000 Кочетков
|
||||
emplace_preimp_if_present(self_name, coopname, "vvqamckynxod"_n, 1'000'000'000LL); // 100 000.0000 Мильшин
|
||||
emplace_preimp_if_present(self_name, coopname, "hntppjjknmsu"_n, 2'000'000'000LL); // 200 000.0000 Манакин
|
||||
}
|
||||
#endif // !IS_TESTNET
|
||||
|
||||
/**
|
||||
* Мигрирует один кооператив. Отправляет до 4 inline apply (бух-проводки)
|
||||
* + прямой emplace программных кошельков (без проводок).
|
||||
*
|
||||
* В prod-сборке для voskhod — спец-ветка по фактам (см. migrate_voskhod_facts).
|
||||
* В test-сборке (IS_TESTNET=1) спец-ветка отключена — voskhod проходит
|
||||
* стандартный арифметический путь, чтобы invariant-фейлы были видимы.
|
||||
*/
|
||||
inline void migrate_one_coop(eosio::name self_name, const cooperative2& coop) {
|
||||
const eosio::name coopname = coop.username;
|
||||
|
||||
#ifndef IS_TESTNET
|
||||
// voskhod — особый кейс в prod: переносим по фактическим суммам, не арифметически.
|
||||
// В test-сборке этот блок выключен — voskhod идёт по стандартному пути.
|
||||
if (coopname == "voskhod"_n) {
|
||||
migrate_voskhod_facts(self_name, coop);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
const LegacyBalances b = read_legacy_balances(coopname);
|
||||
|
||||
// Программные кошельки читаем всегда (Благорост + Генератор).
|
||||
const eosio::asset blagorost_invest = sum_progwallet_blocked(coopname, 4); // ЦПП «Благорост»
|
||||
const eosio::asset generator_commit = sum_progwallet_blocked(coopname, 3); // ЦПП «Генератор»
|
||||
|
||||
// Ранний выход: нечего мигрировать (ни legacy, ни программных кошельков).
|
||||
if (b.cash.amount == 0 && b.share.amount == 0 && b.entry.amount == 0 &&
|
||||
blagorost_invest.amount == 0 && generator_commit.amount == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ----- A. Бухгалтерский перенос legacy::accounts через 3 TRANSIT_* -----
|
||||
if (b.cash.amount > 0 || b.share.amount > 0 || b.entry.amount > 0) {
|
||||
#ifdef IS_TESTNET
|
||||
// На тестнете терпим грязные данные: clamp'им вместо abort.
|
||||
// Тестовые остатки (entry > cash, РИД-часть на 80, Σmin > share) — игнорятся.
|
||||
const int64_t entry_amt = b.entry.amount < b.cash.amount ? b.entry.amount : b.cash.amount;
|
||||
const int64_t share_money_amt = b.cash.amount - entry_amt;
|
||||
const eosio::asset entry_eff(entry_amt, _root_govern_symbol);
|
||||
const eosio::asset share_money(share_money_amt, _root_govern_symbol);
|
||||
|
||||
eosio::asset min_total = compute_min_total_by_type(coopname, coop);
|
||||
if (min_total.amount > share_money.amount) {
|
||||
min_total = share_money;
|
||||
}
|
||||
|
||||
const eosio::asset share_remain(
|
||||
share_money.amount - min_total.amount,
|
||||
_root_govern_symbol
|
||||
);
|
||||
|
||||
send_transit(self_name, coopname, operations::migration::MIN_SHARE, min_total);
|
||||
send_transit(self_name, coopname, operations::migration::SHARE, share_remain);
|
||||
send_transit(self_name, coopname, operations::migration::ENTRY, entry_eff);
|
||||
#else
|
||||
// Деньги на паевой фонд: всё на 51 сверх вступительных.
|
||||
const eosio::asset share_money(
|
||||
b.cash.amount >= b.entry.amount ? b.cash.amount - b.entry.amount : 0,
|
||||
_root_govern_symbol
|
||||
);
|
||||
|
||||
eosio::check(b.cash.amount >= b.entry.amount,
|
||||
std::string{"migrate: entry > cash на кооп "} + coopname.to_string());
|
||||
// Инвариант: legacy::accounts[80] (паевой фонд) равен money-части (51 - 86).
|
||||
// РИД-часть на legacy 80 не поддерживается этим путём (ADR-009: РИД-программа
|
||||
// переезжает в w.cap.blago отдельно); если такой остаток есть — миграция
|
||||
// отказывается, кооператив должен закрыть РИД-часть до миграции.
|
||||
eosio::check(b.share.amount == share_money.amount,
|
||||
std::string{"migrate: legacy 80 содержит РИД-часть на кооп "} + coopname.to_string() +
|
||||
" — ledger2 не поддерживает РИД-перенос (ADR-009). Закрыть РИД до миграции.");
|
||||
|
||||
// Минимальный паевой: Σ p.minimum_amount по accepted-пайщикам.
|
||||
// По конструкции эта сумма равна реальному взносу на legacy[80] и не может
|
||||
// превосходить share_money. Если превосходит — данные кооператива
|
||||
// не консистентны (например, у кого-то на participant.minimum_amount
|
||||
// выставлено больше, чем фактически попало на 80). Падаем явно — пусть
|
||||
// оператор разбирается ad-hoc до миграции; clamping мы сознательно НЕ
|
||||
// делаем, иначе L2 получится меньше Σ L3 и сломаем инвариант.
|
||||
const eosio::asset min_total = compute_min_total_by_type(coopname, coop);
|
||||
eosio::check(min_total.amount <= share_money.amount,
|
||||
std::string{"migrate: Σ participant.minimum_amount > share_money на кооп "} +
|
||||
coopname.to_string() + " (Σ min=" + min_total.to_string() +
|
||||
", share=" + share_money.to_string() +
|
||||
"). Данные кооп-пайщиков не сходятся с legacy[80]; чинить ad-hoc до миграции.");
|
||||
|
||||
const eosio::asset share_remain(
|
||||
share_money.amount - min_total.amount,
|
||||
_root_govern_symbol
|
||||
);
|
||||
|
||||
send_transit(self_name, coopname, operations::migration::MIN_SHARE, min_total);
|
||||
send_transit(self_name, coopname, operations::migration::SHARE, share_remain);
|
||||
send_transit(self_name, coopname, operations::migration::ENTRY, b.entry);
|
||||
#endif
|
||||
}
|
||||
|
||||
// ----- B. Программные кошельки — прямой emplace, без Dr/Cr -----
|
||||
// progwallets.blocked НЕ входит в legacy::accounts[80], поэтому
|
||||
// проводить их через apply(operations::migration::*) вызвало бы двойной учёт.
|
||||
emplace_wallet_only(self_name, coopname, ledger2_wallets::BLAGOROST_FUND, blagorost_invest);
|
||||
emplace_wallet_only(self_name, coopname, ledger2_wallets::GENERATOR_FUND, generator_commit);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ledger2::migrate(uint64_t from_coop_index, uint64_t limit) {
|
||||
require_auth(get_self());
|
||||
|
||||
ledger2_meta_index meta_tbl(get_self(), get_self().value);
|
||||
auto meta_it = meta_tbl.find(0);
|
||||
|
||||
// Полный прогон уже завершён — тихий no-op (Decision #D4 / Story 1.15 AC5).
|
||||
if (meta_it != meta_tbl.end() && meta_it->migrated) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (meta_it != meta_tbl.end()) {
|
||||
eosio::check(from_coop_index == meta_it->last_migrated_coop_index,
|
||||
std::string{"migrate: from_coop_index должен продолжать с last_migrated_coop_index="} +
|
||||
std::to_string(meta_it->last_migrated_coop_index));
|
||||
} else {
|
||||
eosio::check(from_coop_index == 0,
|
||||
"migrate: первый вызов должен начинаться с from_coop_index=0");
|
||||
}
|
||||
|
||||
eosio::check(limit > 0, "migrate: limit должен быть больше 0");
|
||||
|
||||
cooperatives2_index coops(_registrator, _registrator.value);
|
||||
|
||||
for (auto c = coops.begin(); c != coops.end(); ++c) {
|
||||
const eosio::name coopname = c->username;
|
||||
uint64_t idx = 0;
|
||||
uint64_t done = 0;
|
||||
bool reached_end = true;
|
||||
|
||||
// --- 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);
|
||||
});
|
||||
}
|
||||
for (auto it = coops.begin(); it != coops.end(); ++it, ++idx) {
|
||||
if (idx < from_coop_index) continue;
|
||||
if (done >= limit) { reached_end = false; break; }
|
||||
|
||||
// --- 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);
|
||||
});
|
||||
// Мигрируем только активные кооперативы (не отделения, не физ. записи,
|
||||
// не приостановленные/архивные). apply() всё равно отказывает не-active
|
||||
// через get_cooperative_or_fail — здесь явно пропускаем, чтобы курсор шёл.
|
||||
const bool is_active = it->status.has_value() && it->status.value() == "active"_n;
|
||||
if (it->is_cooperative && is_active) {
|
||||
migrate_one_coop(get_self(), *it);
|
||||
}
|
||||
++done;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Разовая коррекция СЧЕТОВ под удаление фантомных пайщиков 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
|
||||
if (done == 0 && from_coop_index == 0 && coops.begin() == coops.end()) {
|
||||
eosio::check(false, "migrate: таблица cooperatives пуста — повторите после регистрации");
|
||||
}
|
||||
|
||||
eosio::asset removed(0, _root_govern_symbol);
|
||||
const uint64_t new_last_index = from_coop_index + done;
|
||||
const uint64_t coops_total = meta_it == meta_tbl.end() ? done : meta_it->migrated_coops + done;
|
||||
const auto now = eosio::current_time_point();
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (meta_it == meta_tbl.end()) {
|
||||
meta_tbl.emplace(get_self(), [&](auto& m) {
|
||||
m.id = 0;
|
||||
m.migrated = reached_end;
|
||||
m.migrated_coops = coops_total;
|
||||
m.last_migrated_coop_index = new_last_index;
|
||||
m.migrated_at = now;
|
||||
});
|
||||
} else {
|
||||
meta_tbl.modify(meta_it, get_self(), [&](auto& m) {
|
||||
m.migrated = reached_end;
|
||||
m.migrated_coops = coops_total;
|
||||
m.last_migrated_coop_index = new_last_index;
|
||||
m.migrated_at = now;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,13 +80,12 @@ static constexpr eosio::name _wallet_program = "wallet"_n; ///< Главный
|
||||
static constexpr eosio::name _marketplace_program = "marketplace"_n; ///< Кошелёк программы "Маркетплейс"
|
||||
|
||||
static constexpr eosio::name _source_program = "generator"_n; ///< Кошелёк для генерации по договору УХД
|
||||
static constexpr eosio::name _capital_program = "capital"_n; ///< Кошелёк программы "Благорост"
|
||||
static constexpr eosio::name _capital_program = "blagorost"_n; ///< Кошелёк программы "Благорост"
|
||||
|
||||
|
||||
static const std::set<eosio::name> soviet_actions = {
|
||||
"joincoop"_n, //регистрация пайщика
|
||||
"leavecoop"_n, //выход пайщика из кооператива (возврат паевого взноса)
|
||||
|
||||
|
||||
//MEET
|
||||
"creategm"_n,//предложение повестки планового общего собрание
|
||||
"completegm"_n, //решение общего собрания пайщиков
|
||||
@@ -103,10 +102,7 @@ static const std::set<eosio::name> soviet_actions = {
|
||||
|
||||
//WALLET
|
||||
"createwthd"_n, //создать заявление на возврат паевого взноса
|
||||
|
||||
//EXPENSE
|
||||
"createexp"_n, //служебная записка-смета о расходах (шасси расходов)
|
||||
|
||||
|
||||
//LEDGER
|
||||
"ledgerwthd"_n, //заявление на списание со счета через ledger
|
||||
|
||||
@@ -169,7 +165,6 @@ static constexpr uint64_t _capital_program_id = 4;
|
||||
static constexpr eosio::name _ledger = "ledger"_n;
|
||||
static constexpr eosio::name _ledger2 = "ledger2"_n;
|
||||
static constexpr eosio::name _apps = "apps"_n;
|
||||
static constexpr eosio::name _expense = "expense"_n;
|
||||
static constexpr eosio::name _power_account = "eosio.power"_n;
|
||||
static constexpr eosio::name _saving_account = "eosio.saving"_n;
|
||||
|
||||
@@ -198,8 +193,7 @@ static constexpr uint64_t _capital_program_id = 4;
|
||||
"ledger"_n,
|
||||
"ledger2"_n,
|
||||
"capital"_n,
|
||||
"apps"_n,
|
||||
"expense"_n
|
||||
"apps"_n
|
||||
// Добавьте остальные стандартные или пользовательские контракты по необходимости
|
||||
};
|
||||
|
||||
@@ -245,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,22 +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 MOVE_MINSHARE = "o.reg.mvmin"_n; ///< Перенос минимального паевого на главный паевой при выходе из кооператива (TRANSFER MIN_SHARE_FUND → SHARE_FUND_PAY, без Dr/Cr — оба кошелька на счёте 80). Готовит полный паевой к возврату.
|
||||
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, TRANSFER SHARE_FUND_PAY → WITHDRAWALS_SINK).
|
||||
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
|
||||
@@ -86,7 +80,6 @@ namespace operations {
|
||||
inline constexpr eosio::name WITHDRAW_FROM_CAPITAL = "o.cap.wthcap"_n; ///< Возврат паевого из ЦПП «Благорост» в кошелёк пайщика (TRANSFER BLAGOROST_FUND → SHARE_FUND_PAY, без Dr/Cr).
|
||||
inline constexpr eosio::name CONVERT_TO_SHARE = "o.cap.cnvshr"_n; ///< Конвертация сегмента: РИД → главный кошелёк (TRANSFER GENERATOR_FUND → SHARE_FUND_PAY, без Dr/Cr — бухпроводка уже была сделана в ACCEPT_RID).
|
||||
inline constexpr eosio::name CONVERT_TO_BLAGO = "o.cap.cnvbl"_n; ///< Конвертация сегмента: РИД → ЦПП «Благорост» (TRANSFER GENERATOR_FUND → BLAGOROST_FUND, без Dr/Cr — бухпроводка уже была сделана в ACCEPT_RID).
|
||||
inline constexpr eosio::name PROGRAM_EXPENSE_TOPUP = "o.cap.pgtop"_n; ///< Пополнение пула программных расходов из инвестиций программы (ISSUE PROGRAM_EXPENSE_POOL, без Dr/Cr — деньги уже на 51, выделяется кооперативный резерв под расходы; паевые L3-кошельки пайщиков не трогаются).
|
||||
}
|
||||
|
||||
// marketplace
|
||||
@@ -100,27 +93,6 @@ namespace operations {
|
||||
inline constexpr eosio::name CONVERT_AXN = "o.sov.axncnv"_n; ///< Трансляция паевого взноса в членский (Dr 80 / Cr 86, TRANSFER SHARE_FUND_PAY → DELEGATE_FEES).
|
||||
}
|
||||
|
||||
// expense — шасси расходов (MVP: только Благорост; хозрасходы из членских — отдельный эпик).
|
||||
//
|
||||
// Принципы (см. components/desktop/extensions/expenses/NAMING-C28-28.md):
|
||||
// - Контракт `expense` универсальный: operation_code передаётся в payload.
|
||||
// - Источник оплат — КООПЕРАТИВНЫЙ пул расходов (PROGRAM_EXPENSE_POOL),
|
||||
// пополняемый o.cap.pgtop; личные L3-кошельки пайщиков (w.cap.blago)
|
||||
// при оплатах СЗ не изменяются.
|
||||
// - При расходе из Благороста паевой фонд (80) НЕ трогается: меняется только форма
|
||||
// актива 51 → 08 (Дт 08 / Кт 51 для обеих механик).
|
||||
// - ADVANCE-отчёт (`o.exp.advrpt`) НЕ создаёт новой бухпроводки: проводка уже
|
||||
// сделана на `o.exp.blgadv` при выдаче.
|
||||
// - Callback на финализацию — переменная (`callback{contract, action, data}`),
|
||||
// заполняется при `expense::createexp`; expense ничего не знает про capital.
|
||||
namespace expense {
|
||||
inline constexpr eosio::name BLAGO_ADVANCE = "o.exp.blgadv"_n; ///< Выдача подотчётных из пула расходов (TRANSFER PROGRAM_EXPENSE_POOL → ADVANCE_HOLD, Dr 08 / Cr 51).
|
||||
inline constexpr eosio::name BLAGO_DIRECT = "o.exp.blgdir"_n; ///< Прямая оплата из пула расходов (BURN PROGRAM_EXPENSE_POOL, Dr 08 / Cr 51).
|
||||
inline constexpr eosio::name ADVANCE_REPORT = "o.exp.advrpt"_n; ///< Закрытие подотчёта пайщика (BURN ADVANCE_HOLD, без бухпроводки — canal 08/51 уже сделан на blgadv).
|
||||
inline constexpr eosio::name ADVANCE_RETURN = "o.exp.advret"_n; ///< Возврат неиспользованного подотчёта (TRANSFER ADVANCE_HOLD → PROGRAM_EXPENSE_POOL, Dr 51 / Cr 08).
|
||||
inline constexpr eosio::name OVERSPEND = "o.exp.over"_n; ///< Доплата сверх подотчёта (TRANSFER PROGRAM_EXPENSE_POOL → ADVANCE_HOLD, Dr 08 / Cr 51); сразу за ней expense вызывает ADVANCE_REPORT.
|
||||
}
|
||||
|
||||
// migration (только из migrate.cpp)
|
||||
//
|
||||
// В OPERATION_REGISTRY включены **только** те транзиты, которые проводятся
|
||||
@@ -157,23 +129,13 @@ 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)
|
||||
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 (приём РИД в НМА), когда кошелёк уже на нужном программном фонде.
|
||||
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, без 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 (приём РИД в НМА), когда кошелёк уже на нужном программном фонде.
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -182,7 +144,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: wallet_from = required, wallet_to = eosio::name{}.
|
||||
*
|
||||
* Семантика бух.проводки:
|
||||
* - Без проводок: debit_account_id == 0 И credit_account_id == 0.
|
||||
@@ -194,7 +156,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
|
||||
uint64_t debit_account_id; ///< 0 если без бухпроводки (тогда credit_account_id тоже == 0)
|
||||
uint64_t credit_account_id; ///< 0 если без бухпроводки (тогда debit_account_id тоже == 0)
|
||||
const char* human_name;
|
||||
@@ -216,53 +178,14 @@ 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,
|
||||
"Возврат регистрационного взноса при отказе совета" },
|
||||
|
||||
// 2e. Перенос минимального паевого на главный при выходе из кооператива:
|
||||
// TRANSFER MIN_SHARE_FUND → SHARE_FUND_PAY (без Dr/Cr — оба кошелька на счёте 80).
|
||||
// Консолидирует минимальный паевой на главный, чтобы вернуть его вместе с
|
||||
// основным паевым через wallet-withdraw (o.wal.wthcpl, Дт 80 / Кт 51).
|
||||
{ operations::registrator::MOVE_MINSHARE, processes::wallet::WITHDRAW, WalletOp::TRANSFER,
|
||||
ledger2_wallets::MIN_SHARE_FUND, ledger2_wallets::SHARE_FUND_PAY,
|
||||
0, 0,
|
||||
"Перенос минимального паевого на главный при выходе из кооператива" },
|
||||
|
||||
// 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):
|
||||
// деньги уходят из системы (банковский перевод пайщику), получателя на цепи нет.
|
||||
// Бухгалтерия: паевой фонд уменьшается (Дт 80), расчётный счёт уменьшается (Кт 51).
|
||||
{ operations::wallet::COMPLETE_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::BURN,
|
||||
ledger2_wallets::WITHDRAW_PENDING, eosio::name{},
|
||||
// 4. Возврат паевого взноса: Dr 80 / Cr 51, TRANSFER SHARE_FUND_PAY → WITHDRAWALS_SINK
|
||||
{ operations::wallet::COMPLETE_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::TRANSFER,
|
||||
ledger2_wallets::SHARE_FUND_PAY, ledger2_wallets::WITHDRAWALS_SINK,
|
||||
ledger2_accounts::SHARE_FUND, ledger2_accounts::BANK_ACCOUNT,
|
||||
"Возврат паевого взноса пайщику" },
|
||||
|
||||
@@ -346,19 +269,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,
|
||||
@@ -384,72 +305,6 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
|
||||
0, 0,
|
||||
"Конвертация сегмента: РИД → ЦПП «Благорост»" },
|
||||
|
||||
// 19a. Пополнение пула программных расходов: ISSUE PROGRAM_EXPENSE_POOL, без Dr/Cr.
|
||||
// Совет выделяет часть свободных инвестиций программы под целевые расходы:
|
||||
// деньги физически на 51 с момента взносов, здесь появляется кооперативный
|
||||
// резерв-кошелёк, из которого шасси expense оплачивает СЗ. Паевые L3-кошельки
|
||||
// пайщиков (w.cap.blago, счёт 80) не изменяются — права требования сохраняются.
|
||||
{ operations::capital::PROGRAM_EXPENSE_TOPUP, processes::capital::PGEXP, WalletOp::ISSUE,
|
||||
eosio::name{}, ledger2_wallets::PROGRAM_EXPENSE_POOL,
|
||||
0, 0,
|
||||
"Пополнение пула программных расходов ЦПП «Благорост»" },
|
||||
|
||||
// ----- Шасси расходов (o.exp.*) — вызываются из контракта expense -----
|
||||
//
|
||||
// Базовое состояние Благороста ДО расхода:
|
||||
// - Деньги физически на 51 с момента o.wal.depcpl (Dr 51 / Cr 80).
|
||||
// - 80 (паевой) наполнен; кошелёк w.cap.blago.
|
||||
// - 08 пустой (`o.cap.invest` — TRANSFER без проводок).
|
||||
//
|
||||
// Принцип: расход не уменьшает паевой фонд (80). Меняется только форма актива
|
||||
// 51 → 08 (банк уходит, появляется WIP-проект).
|
||||
|
||||
// 20. Выдача подотчётных из пула программных расходов: Dr 08 / Cr 51,
|
||||
// TRANSFER PROGRAM_EXPENSE_POOL → ADVANCE_HOLD.
|
||||
// Источник — КООПЕРАТИВНЫЙ пул расходов (пополняется o.cap.pgtop); личные
|
||||
// L3-кошельки пайщиков (w.cap.blago) при оплате СЗ не трогаются — их паевые
|
||||
// взносы в программе не уменьшаются. Деньги физически уходят пайщику (Cr 51),
|
||||
// стоимость капитализируется в WIP (Dr 08). ADVANCE_HOLD фиксирует
|
||||
// ответственность получателя аванса (USER_SHARED) до отчёта.
|
||||
{ operations::expense::BLAGO_ADVANCE, processes::expense::PROPOSAL, WalletOp::TRANSFER,
|
||||
ledger2_wallets::PROGRAM_EXPENSE_POOL, ledger2_wallets::ADVANCE_HOLD,
|
||||
ledger2_accounts::NON_CURRENT_INVESTMENTS, ledger2_accounts::BANK_ACCOUNT,
|
||||
"Выдача подотчётных из пула расходов ЦПП «Благорост»" },
|
||||
|
||||
// 21. Прямая оплата из пула программных расходов (DIRECT): Dr 08 / Cr 51,
|
||||
// BURN PROGRAM_EXPENSE_POOL. Оплата организации по счёту; деньги уходят с 51,
|
||||
// стоимость капитализируется в 08. Кошелёк-резерв не задействован.
|
||||
{ operations::expense::BLAGO_DIRECT, processes::expense::PROPOSAL, WalletOp::BURN,
|
||||
ledger2_wallets::PROGRAM_EXPENSE_POOL, eosio::name{},
|
||||
ledger2_accounts::NON_CURRENT_INVESTMENTS, ledger2_accounts::BANK_ACCOUNT,
|
||||
"Прямая оплата из пула расходов ЦПП «Благорост»" },
|
||||
|
||||
// 22. Закрытие подотчёта пайщика по отчёту: BURN ADVANCE_HOLD, БЕЗ бухпроводки.
|
||||
// Проводка Dr 08 / Cr 51 уже сделана на BLAGO_ADVANCE при выдаче. При отчёте только
|
||||
// снимается кошелёк-резерв пайщика — никакого canal 08/51 второй раз.
|
||||
{ operations::expense::ADVANCE_REPORT, processes::expense::PROPOSAL, WalletOp::BURN,
|
||||
ledger2_wallets::ADVANCE_HOLD, eosio::name{},
|
||||
0, 0,
|
||||
"Закрытие подотчёта пайщика по отчёту" },
|
||||
|
||||
// 23. Возврат неиспользованного подотчёта: Dr 51 / Cr 08,
|
||||
// TRANSFER ADVANCE_HOLD → PROGRAM_EXPENSE_POOL.
|
||||
// Зеркало BLAGO_ADVANCE: деньги возвращаются на 51, WIP-стоимость уменьшается,
|
||||
// остаток снова доступен пулу расходов.
|
||||
{ operations::expense::ADVANCE_RETURN, processes::expense::PROPOSAL, WalletOp::TRANSFER,
|
||||
ledger2_wallets::ADVANCE_HOLD, ledger2_wallets::PROGRAM_EXPENSE_POOL,
|
||||
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::NON_CURRENT_INVESTMENTS,
|
||||
"Возврат неиспользованного подотчёта в пул расходов" },
|
||||
|
||||
// 24. Доплата сверх подотчёта (перерасход): Dr 08 / Cr 51,
|
||||
// TRANSFER PROGRAM_EXPENSE_POOL → ADVANCE_HOLD.
|
||||
// Зеркало BLAGO_ADVANCE на сумму перерасхода. Контракт expense сразу за OVERSPEND
|
||||
// вызывает ADVANCE_REPORT — две последовательные записи в одной транзакции `expense::overspendexp`.
|
||||
{ operations::expense::OVERSPEND, processes::expense::PROPOSAL, WalletOp::TRANSFER,
|
||||
ledger2_wallets::PROGRAM_EXPENSE_POOL, ledger2_wallets::ADVANCE_HOLD,
|
||||
ledger2_accounts::NON_CURRENT_INVESTMENTS, ledger2_accounts::BANK_ACCOUNT,
|
||||
"Доплата сверх подотчёта (перерасход)" },
|
||||
|
||||
// ----- Миграционные (o.mig.*) — вызываются только из migrate.cpp -----
|
||||
|
||||
// 15. Миграция: минимальный паевой: Dr 51 / Cr 80, ISSUE MIN_SHARE_FUND
|
||||
@@ -481,7 +336,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: `wallet_from` ≠ 0, `wallet_to` == 0.
|
||||
// 7. Все id кошельков из записей существуют в `LEDGER2_WALLET_REGISTRY`.
|
||||
namespace ledger2_registry_detail {
|
||||
constexpr bool operation_codes_unique() {
|
||||
@@ -632,105 +487,3 @@ inline constexpr const OperationAdjustmentEntry* find_adjustment(eosio::name ope
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Наборы операций шасси расходов — фабричная настройка контракта expense.
|
||||
// =====================================================================
|
||||
//
|
||||
// Контракт `expense` агностичен к программе-источнику: кошелёк-пул приходит в
|
||||
// `createexp` параметром `source_wallet`, а ledger2-коды всех пяти операций
|
||||
// жизненного цикла (аванс / прямая оплата / отчёт / возврат / перерасход)
|
||||
// выводятся из этого кошелька через таблицу ниже — в коде expense нет ни
|
||||
// одного захардкоженного operation_code.
|
||||
//
|
||||
// Подключение шасси к новому пулу (например, кошельку членских взносов
|
||||
// кооперативного участка) = добавить 5 операций в OPERATION_REGISTRY и одну
|
||||
// строку здесь. Контракт expense при этом не меняется.
|
||||
struct ExpenseOperationSet {
|
||||
eosio::name source_wallet; ///< пул-источник средств (COOPERATIVE-кошелёк)
|
||||
eosio::name advance; ///< выдача аванса под отчёт (TRANSFER pool → подотчёт)
|
||||
eosio::name direct; ///< прямая оплата организации по счёту (BURN pool)
|
||||
eosio::name report; ///< закрытие подотчёта по отчёту (BURN подотчёта)
|
||||
eosio::name refund; ///< возврат неиспользованного аванса (TRANSFER подотчёт → pool)
|
||||
eosio::name overspend; ///< доплата при перерасходе (TRANSFER pool → подотчёт)
|
||||
};
|
||||
|
||||
static constexpr ExpenseOperationSet EXPENSE_OPERATION_SETS[] = {
|
||||
// Пул программных расходов ЦПП «Благорост» — source_wallet заполняет
|
||||
// capital::createpgexp при создании СЗ через inline expense::createexp.
|
||||
{ ledger2_wallets::PROGRAM_EXPENSE_POOL,
|
||||
operations::expense::BLAGO_ADVANCE,
|
||||
operations::expense::BLAGO_DIRECT,
|
||||
operations::expense::ADVANCE_REPORT,
|
||||
operations::expense::ADVANCE_RETURN,
|
||||
operations::expense::OVERSPEND },
|
||||
};
|
||||
|
||||
static constexpr size_t EXPENSE_OPERATION_SETS_SIZE =
|
||||
sizeof(EXPENSE_OPERATION_SETS) / sizeof(EXPENSE_OPERATION_SETS[0]);
|
||||
|
||||
// Compile-time валидация наборов: каждый код существует в OPERATION_REGISTRY,
|
||||
// тип wallet-операции и привязка кошельков соответствуют роли кода в наборе.
|
||||
namespace ledger2_expense_sets_detail {
|
||||
constexpr const OperationRegistryEntry* find_op(eosio::name code) {
|
||||
for (size_t i = 0; i < OPERATION_REGISTRY_SIZE; ++i) {
|
||||
if (OPERATION_REGISTRY[i].code == code) return &OPERATION_REGISTRY[i];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
constexpr bool source_wallets_unique() {
|
||||
for (size_t i = 0; i < EXPENSE_OPERATION_SETS_SIZE; ++i) {
|
||||
for (size_t j = i + 1; j < EXPENSE_OPERATION_SETS_SIZE; ++j) {
|
||||
if (EXPENSE_OPERATION_SETS[i].source_wallet == EXPENSE_OPERATION_SETS[j].source_wallet) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
constexpr bool sets_consistent() {
|
||||
for (size_t i = 0; i < EXPENSE_OPERATION_SETS_SIZE; ++i) {
|
||||
const auto& s = EXPENSE_OPERATION_SETS[i];
|
||||
const auto* adv = find_op(s.advance);
|
||||
const auto* dir = find_op(s.direct);
|
||||
const auto* rep = find_op(s.report);
|
||||
const auto* ref = find_op(s.refund);
|
||||
const auto* ovr = find_op(s.overspend);
|
||||
if (!adv || !dir || !rep || !ref || !ovr) return false;
|
||||
// Аванс: пул → кошелёк-подотчёт.
|
||||
if (adv->wallet_op != WalletOp::TRANSFER || adv->wallet_from != s.source_wallet) return false;
|
||||
const eosio::name hold = adv->wallet_to;
|
||||
// Прямая оплата: сжигание с пула (подотчёт не задействован).
|
||||
if (dir->wallet_op != WalletOp::BURN || dir->wallet_from != s.source_wallet) return false;
|
||||
// Отчёт: сжигание подотчёта.
|
||||
if (rep->wallet_op != WalletOp::BURN || rep->wallet_from != hold) return false;
|
||||
// Возврат: подотчёт → пул.
|
||||
if (ref->wallet_op != WalletOp::TRANSFER ||
|
||||
ref->wallet_from != hold || ref->wallet_to != s.source_wallet) return false;
|
||||
// Перерасход: пул → подотчёт (зеркало аванса на сумму доплаты).
|
||||
if (ovr->wallet_op != WalletOp::TRANSFER ||
|
||||
ovr->wallet_from != s.source_wallet || ovr->wallet_to != hold) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} // namespace ledger2_expense_sets_detail
|
||||
|
||||
static_assert(ledger2_expense_sets_detail::source_wallets_unique(),
|
||||
"EXPENSE_OPERATION_SETS: source_wallet должен быть уникален");
|
||||
static_assert(ledger2_expense_sets_detail::sets_consistent(),
|
||||
"EXPENSE_OPERATION_SETS: набор операций не согласован с OPERATION_REGISTRY "
|
||||
"(коды/типы wallet-операций/привязка кошельков)");
|
||||
|
||||
/**
|
||||
* @brief Поиск набора операций шасси расходов по кошельку-источнику.
|
||||
*/
|
||||
inline const ExpenseOperationSet* find_expense_operation_set(eosio::name source_wallet) {
|
||||
for (size_t i = 0; i < EXPENSE_OPERATION_SETS_SIZE; ++i) {
|
||||
if (EXPENSE_OPERATION_SETS[i].source_wallet == source_wallet) {
|
||||
return &EXPENSE_OPERATION_SETS[i];
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -54,7 +53,6 @@ namespace processes {
|
||||
inline constexpr eosio::name PROPERTY = "p.cap.prop"_n; ///< Приём имущественного паевого взноса (одноактовый).
|
||||
inline constexpr eosio::name PREIMP = "p.cap.preimp"_n; ///< Первичный учёт РИД-взноса до перехода на электронный учёт (одноактовый, anchor = preimp register hash).
|
||||
inline constexpr eosio::name WTHCAP = "p.cap.wthcap"_n; ///< Возврат паевого из ЦПП «Благорост» в кошелёк пайщика (одноактовый).
|
||||
inline constexpr eosio::name PGEXP = "p.cap.pgexp"_n; ///< Пул программных расходов: пополнение из инвестиций программы (o.cap.pgtop, одноактовый).
|
||||
}
|
||||
|
||||
// marketplace
|
||||
@@ -62,11 +60,6 @@ namespace processes {
|
||||
inline constexpr eosio::name REQUEST = "p.mkt.reqst"_n; ///< Цикл запроса маркетплейса (o.mkt.supply + o.mkt.recv).
|
||||
}
|
||||
|
||||
// expense — шасси расходов (СЗ → авторизация → платёж → отчёт → закрытие)
|
||||
namespace expense {
|
||||
inline constexpr eosio::name PROPOSAL = "p.exp.expns"_n; ///< Цикл расхода по СЗ: o.exp.blgadv|blgdir (платёж) + опц. o.exp.over (перерасход) + o.exp.advrpt (отчёт ADVANCE) + опц. o.exp.advret (возврат). Анкер процесса — proposal_hash.
|
||||
}
|
||||
|
||||
// soviet
|
||||
namespace soviet {
|
||||
inline constexpr eosio::name AXN_CONVERT = "p.sov.axncnv"_n; ///< Конвертация паевого RUB → делегатский ЧВ (одноактовый).
|
||||
|
||||
@@ -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; ///< Возвраты паевых взносов пайщикам (sink TRANSFER, COOPERATIVE)
|
||||
|
||||
// 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)
|
||||
@@ -63,18 +61,11 @@ struct ledger2_wallets {
|
||||
// capital — единые программные кошельки + займы + пред-импорт
|
||||
static constexpr eosio::name LOAN_ISSUED = "w.cap.loan"_n; ///< Выданные пайщикам беспроцентные займы (COOPERATIVE; Dr 58 / Cr 51)
|
||||
static constexpr eosio::name BLAGOROST_FUND = "w.cap.blago"_n; ///< Благорост — единый агрегированный кошелёк программы (USER_SHARED; ADR-009)
|
||||
static constexpr eosio::name GENERATOR_FUND = "w.cap.gen"_n; ///< Генератор — единый агрегированный кошелёк программы (COOPERATIVE — кооперативный пул, без L3-разреза по пайщику; L3-разрез из ADR-009 отменён из-за несовместимости с CRPS-перераспределением, см. wallets.hpp:107)
|
||||
static constexpr eosio::name GENERATOR_FUND = "w.cap.gen"_n; ///< Генератор — единый агрегированный кошелёк программы (USER_SHARED; ADR-009)
|
||||
static constexpr eosio::name PREIMP_FUND = "w.cap.preimp"_n; ///< Первичный учёт РИД-взносов до перехода на электронный учёт (USER_SHARED; o.cap.preimp / o.cap.drppre)
|
||||
static constexpr eosio::name PROGRAM_EXPENSE_POOL = "w.cap.pgexp"_n; ///< Пул программных расходов ЦПП «Благорост» (COOPERATIVE) — кооперативный кошелёк, из которого шасси expense оплачивает СЗ; пополняется topupprogexp (o.cap.pgtop), паевые L3-кошельки пайщиков (w.cap.blago) при расходах не трогаются
|
||||
|
||||
// marketplace — выплаты
|
||||
static constexpr eosio::name SUPPLIER_PAYMENTS = "w.mkt.payout"_n; ///< Выплаты поставщикам (sink RECEIVE_CONFIRM, COOPERATIVE)
|
||||
|
||||
// expense — шасси расходов (подотчёт пайщика, USER_SHARED)
|
||||
// Зеркало паттерна w.wal.wpend: кошелёк-резерв на пайщике-получателе ADVANCE-механики.
|
||||
// На момент выдачи аванса фиксирует ответственность пайщика; на отчёте — BURN без новой бухпроводки
|
||||
// (canal Дт 08 / Кт 51 уже сделан на o.exp.blgadv).
|
||||
static constexpr eosio::name ADVANCE_HOLD = "w.exp.adv"_n; ///< Подотчётные средства пайщика (USER_SHARED; резерв при ADVANCE-механике шасси расходов)
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -103,32 +94,24 @@ struct Ledger2WalletMeta {
|
||||
WalletKind kind;
|
||||
};
|
||||
|
||||
inline constexpr std::array<Ledger2WalletMeta, 18> LEDGER2_WALLET_REGISTRY = {{
|
||||
// USER_SHARED (7) — L3-разрез по пайщику
|
||||
inline constexpr std::array<Ledger2WalletMeta, 14> LEDGER2_WALLET_REGISTRY = {{
|
||||
// USER_SHARED (6) — L3-разрез по пайщику
|
||||
{ ledger2_wallets::MIN_SHARE_FUND, "Минимальный паевой взнос", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::SHARE_FUND_PAY, "Паевой взнос пайщика", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::CK_MEMBER, "ЦК — членская часть пайщика", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::BLAGOROST_FUND, "ЦПП «Благорост» — единый кошелёк программы у пайщика", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::GENERATOR_FUND, "ЦПП «Генератор» — единый кошелёк программы у пайщика", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::PREIMP_FUND, "Первичный учёт РИД-взносов до перехода на электронный учёт", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::ADVANCE_HOLD, "Подотчётные средства пайщика", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::REGISTRATION_PENDING, "Регистрационный взнос в ожидании решения совета", WalletKind::USER_SHARED },
|
||||
|
||||
// COOPERATIVE (10) — единый кооперативный баланс, без L3 (Generator + 9 «единых пулов»)
|
||||
// GENERATOR_FUND переведён сюда из USER_SHARED (см. wallets.hpp:64) —
|
||||
// CRPS-распределение между сегментами проекта не поддерживает per-user
|
||||
// компенсирующие TRANSFER на approvecmmt, поэтому L3-проверка walletop
|
||||
// ломала convertsegm у пайщиков, чья доля выросла через CRPS.
|
||||
{ ledger2_wallets::GENERATOR_FUND, "ЦПП «Генератор» — единый кошелёк программы", WalletKind::COOPERATIVE },
|
||||
// COOPERATIVE (8) — единый кооперативный баланс, без L3
|
||||
{ ledger2_wallets::ENTRANCE_FEES, "Вступительные взносы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::WITHDRAWALS_SINK, "Возвраты паевых взносов пайщикам (deprecated, не используется в новых операциях)", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::WITHDRAW_PENDING, "Резерв паевого под заявку на возврат", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::WITHDRAWALS_SINK, "Возвраты паевых взносов пайщикам", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::INFRA_FEES, "Членские взносы за инфраструктуру кооп. платформы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::DELEGATE_FEES, "Делегатские членские взносы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::SOV_EXPENSES, "Хозяйственные расходы из числа целевого финансирования", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::MIN_SHARE_USED, "Использованные минимальные паевые взносы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::LOAN_ISSUED, "Выданные пайщикам беспроцентные займы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::SUPPLIER_PAYMENTS, "Выплаты поставщикам", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::PROGRAM_EXPENSE_POOL, "Пул программных расходов ЦПП «Благорост»", WalletKind::COOPERATIVE },
|
||||
}};
|
||||
|
||||
static constexpr size_t LEDGER2_WALLET_REGISTRY_SIZE = LEDGER2_WALLET_REGISTRY.size();
|
||||
@@ -241,46 +224,13 @@ struct Ledger2WalletProgramMapping {
|
||||
uint64_t required_program_id; // 0 = исключение (без проверки)
|
||||
};
|
||||
|
||||
inline constexpr std::array<Ledger2WalletProgramMapping, 8> 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::ADVANCE_HOLD, 0 /* w.exp.adv — подотчёт пайщика по СЗ; программа-источник проверена контрактом expense, повторная gate не нужна */ },
|
||||
{ ledger2_wallets::REGISTRATION_PENDING, 0 /* w.reg.pend — кандидат ещё не член, соглашения нет, без проверки */ },
|
||||
}};
|
||||
|
||||
/**
|
||||
* @brief Сет «боевых» (паевых) кошельков пайщика, возвращаемых при выходе из
|
||||
* кооператива (заявление registry 200 → одобрение совета `confirmexit`).
|
||||
*
|
||||
* Единый источник истины для суммы возврата. При одобрении выхода советом
|
||||
* `registrator::confirmexit` обходит этот сет в цикле, аккумулирует доступный
|
||||
* L3-баланс пайщика по каждому кошельку (>0), консолидирует на главный паевой
|
||||
* (`w.wal.share`) и ставит всю сумму на возврат единым платежом. Тот же сет
|
||||
* генерируется в cooptypes (`gen:from-cpp` → `wallets.generated.ts`) и
|
||||
* используется backend-preview, поэтому расчёт на фронте всегда совпадает с
|
||||
* тем, что реально вернёт контракт.
|
||||
*
|
||||
* Состав — только паевые/возвратные USER_SHARED-кошельки:
|
||||
* w.reg.minshr — минимальный паевой взнос;
|
||||
* w.wal.share — целевой паевой взнос (ЦК);
|
||||
* w.cap.blago — паевой взнос в ЦПП «Благорост».
|
||||
*
|
||||
* НЕ входят: w.wal.member (членский — невозвратный), w.exp.adv (подотчёт под
|
||||
* расход), w.cap.gen (Генератор — COOPERATIVE, без L3-разреза по пайщику),
|
||||
* w.cap.preimp (пред-импорт-учёт РИД).
|
||||
*
|
||||
* Каждому не-главному кошельку сета должна соответствовать операция переноса
|
||||
* на `w.wal.share` в `Registrator::consolidate_share_to_main` (exit_helpers.hpp)
|
||||
* — иначе runtime упадёт с явным сообщением (защита от тихой потери средств).
|
||||
*/
|
||||
inline constexpr std::array<eosio::name, 3> LEDGER2_EXIT_REFUND_WALLETS = {{
|
||||
ledger2_wallets::MIN_SHARE_FUND,
|
||||
ledger2_wallets::SHARE_FUND_PAY,
|
||||
ledger2_wallets::BLAGOROST_FUND,
|
||||
}};
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,10 +50,6 @@ namespace Names {
|
||||
// Коллбэки для расходов
|
||||
constexpr eosio::name AUTHORIZE_EXPENSE = "capauthexpns"_n;
|
||||
constexpr eosio::name DECLINE_EXPENSE = "capdeclexpns"_n;
|
||||
|
||||
// Программные расходы обслуживаются шасси expense; capital видит только
|
||||
// инициирование (createpgexp) и этот callback завершения flow.
|
||||
constexpr eosio::name ON_PROGRAM_EXPENSE_DONE = "onpgexpdone"_n;
|
||||
|
||||
// Коллбэки для долгов
|
||||
constexpr eosio::name AUTHORIZE_DEBT = "debtauthcnfr"_n;
|
||||
@@ -84,13 +80,10 @@ namespace Names {
|
||||
// Внешние действия в других контрактах
|
||||
constexpr eosio::name CREATE_OUTPAY = "createoutpay"_n;
|
||||
constexpr eosio::name OPEN_PROGRAM_WALLET = "openprogwall"_n;
|
||||
|
||||
|
||||
// Действия для отправки в soviet
|
||||
constexpr eosio::name CREATE_APPROVAL = "createapprv"_n;
|
||||
constexpr eosio::name CREATE_AGENDA = "createagenda"_n;
|
||||
|
||||
// Шасси expense
|
||||
constexpr eosio::name CREATE_EXPENSE_PROPOSAL = "createexp"_n;
|
||||
}
|
||||
|
||||
namespace SovietActions {
|
||||
@@ -127,11 +120,9 @@ namespace Names {
|
||||
constexpr eosio::name CREATE_INVESTMENT = "createinvest"_n; // акцепт инвестиции
|
||||
constexpr eosio::name CREATE_PROGRAM_INVESTMENT = "createpinv"_n; // акцепт программной инвестиции
|
||||
constexpr eosio::name CREATE_EXPENSE = "createexpnse"_n; // акцепт расхода
|
||||
constexpr eosio::name CREATE_PROGRAM_EXPENSE = "createpgexp"_n; // акцепт программного расхода
|
||||
constexpr eosio::name CREATE_WITHDRAW_1 = "createwthd1"_n; // акцепт возврата из задания
|
||||
constexpr eosio::name CREATE_WITHDRAW_2 = "createwthd2"_n; // акцепт возврата из проекта
|
||||
constexpr eosio::name CREATE_WITHDRAW_3 = "createwthd3"_n; // акцепт возврата из программы
|
||||
constexpr eosio::name CREATE_RESULT = "createresult"_n; // акцепт результата
|
||||
constexpr eosio::name CONVERT_SEGMENT = "convertsegm"_n; // финальная фаза p.cap.rid — трансляция паевого взноса
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
#include "table_registrator_verification.hpp"
|
||||
#include "table_registrator_candidates.hpp"
|
||||
#include "table_registrator_candidates_legacy.hpp"
|
||||
#include "table_registrator_exits.hpp"
|
||||
|
||||
// coops (soviet)
|
||||
#include "coops_access_helpers.hpp"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include <eosio/asset.hpp>
|
||||
#include <eosio/crypto.hpp>
|
||||
#include <eosio/eosio.hpp>
|
||||
|
||||
#include "../consts.hpp"
|
||||
#include "document_core.hpp"
|
||||
|
||||
namespace Registrator {
|
||||
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* @ingroup public_tables
|
||||
* @ingroup public_registrator_tables
|
||||
* @par table: exits (registrator)
|
||||
*
|
||||
* @brief Заявление пайщика на выход из кооператива и сопровождающий его
|
||||
* процесс возврата паевого взноса.
|
||||
*
|
||||
* Жизненный цикл (зеркало вступления reguser→confirmreg и возврата
|
||||
* wallet::createwthd→authwthd→completewthd):
|
||||
* - exitcoop → запись со статусом `pending`, повестка совета `leavecoop`;
|
||||
* - confirmexit → совет одобрил: статус `authorized`, консолидация
|
||||
* минимального паевого на главный (o.reg.mvmin), резерв
|
||||
* суммы возврата (o.wal.wthreq) и исходящий платёж в gateway;
|
||||
* - completexit → кассир подтвердил выплату: проводка Дт80/Кт51
|
||||
* (o.wal.wthcpl), пайщик удаляется, аккаунт блокируется,
|
||||
* запись стирается;
|
||||
* - declinexit → отказ совета или отклонение платежа: снятие резерва
|
||||
* (o.wal.wthdec, если он был сделан) и стирание записи.
|
||||
*
|
||||
* `quantity` — итоговая сумма возврата, вычисляется контрактом в момент
|
||||
* одобрения советом (минимальный + целевой паевой пайщика по L3-балансам
|
||||
* ledger2), а не доверяется клиенту.
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract(REGISTRATOR)]] exit {
|
||||
name username;
|
||||
name coopname;
|
||||
name status; ///< pending | authorized
|
||||
time_point_sec created_at;
|
||||
document2 statement; ///< заявление о выходе (registry 200)
|
||||
document2 approved_statement; ///< решение совета о выходе (авторизация)
|
||||
checksum256 exit_hash;
|
||||
asset quantity; ///< итоговая сумма возврата (заполняется на confirmexit)
|
||||
|
||||
uint64_t primary_key() const { return username.value; }
|
||||
checksum256 by_hash() const { return exit_hash; }
|
||||
};
|
||||
|
||||
typedef multi_index<
|
||||
"exits"_n, exit,
|
||||
indexed_by<"byhash"_n, const_mem_fun<exit, checksum256, &exit::by_hash>>>
|
||||
exits_index;
|
||||
|
||||
inline std::optional<exit> get_exit_by_hash(name coopname, const checksum256 &hash) {
|
||||
exits_index primary_index(_registrator, coopname.value);
|
||||
auto secondary_index = primary_index.get_index<"byhash"_n>();
|
||||
|
||||
auto itr = secondary_index.find(hash);
|
||||
if (itr == secondary_index.end()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return *itr;
|
||||
}
|
||||
|
||||
} // namespace Registrator
|
||||
@@ -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,16 +8,8 @@
|
||||
#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/exit/exit_helpers.hpp"
|
||||
#include "src/exit/exitcoop.cpp"
|
||||
#include "src/exit/confirmexit.cpp"
|
||||
#include "src/exit/completexit.cpp"
|
||||
#include "src/exit/declinexit.cpp"
|
||||
|
||||
#include "src/account/createbranch.cpp"
|
||||
#include "src/account/newaccount.cpp"
|
||||
#include "src/account/updateaccnt.cpp"
|
||||
|
||||
@@ -85,17 +85,9 @@ public:
|
||||
[[eosio::action]] void changekey(eosio::name coopname, eosio::name changer, eosio::name username, eosio::public_key public_key);
|
||||
|
||||
[[eosio::action]] void confirmreg(eosio::name coopname, checksum256 registration_hash, document2 authorization);
|
||||
|
||||
// Выход пайщика из кооператива (возврат паевого взноса) — src/exit/*.cpp
|
||||
[[eosio::action]] void exitcoop(eosio::name coopname, eosio::name username, checksum256 exit_hash, document2 statement);
|
||||
[[eosio::action]] void confirmexit(eosio::name coopname, checksum256 exit_hash, document2 authorization);
|
||||
[[eosio::action]] void completexit(eosio::name coopname, checksum256 exit_hash);
|
||||
[[eosio::action]] void declinexit(eosio::name coopname, checksum256 exit_hash, std::string reason);
|
||||
[[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,35 +0,0 @@
|
||||
/**
|
||||
* @brief Завершение возврата паевого взноса при выходе из кооператива.
|
||||
* Кассир подтвердил исходящий платёж — gateway вызывает этот коллбэк.
|
||||
* Списываем зарезервированную сумму (o.wal.wthcpl: Дт80/Кт51, BURN с
|
||||
* w.wal.wpend), удаляем пайщика из реестра совета и блокируем его аккаунт.
|
||||
* Возврат назад невозможен — повторное участие только через новую регистрацию.
|
||||
* @param coopname Наименование кооператива
|
||||
* @param exit_hash Хэш процесса выхода
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p gateway
|
||||
*/
|
||||
void registrator::completexit(eosio::name coopname, checksum256 exit_hash) {
|
||||
require_auth(_gateway);
|
||||
|
||||
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
|
||||
eosio::check(exist.has_value(), "Объект выхода не найден");
|
||||
|
||||
Registrator::exits_index exits(_registrator, coopname.value);
|
||||
auto e = exits.find(exist->username.value);
|
||||
eosio::check(e->status == "authorized"_n, "Только одобренные заявления на выход могут быть завершены");
|
||||
|
||||
eosio::name username = e->username;
|
||||
|
||||
// Проводка возврата паевого: списываем резерв w.wal.wpend, Дт80/Кт51.
|
||||
std::string memo = "Возврат паевого взноса при выходе из кооператива, username=" + username.to_string();
|
||||
Ledger2::apply(_registrator, coopname, operations::wallet::COMPLETE_WITHDRAW,
|
||||
e->quantity, username, exit_hash, memo);
|
||||
|
||||
// Финализируем выход: удаляем пайщика и блокируем аккаунт.
|
||||
Registrator::finalize_member_exit(coopname, username);
|
||||
|
||||
exits.erase(e);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* @brief Одобрение советом выхода пайщика из кооператива.
|
||||
* Совет одобрил заявление о выходе. Контракт сам вычисляет сумму возврата:
|
||||
* обходит сет паевых кошельков LEDGER2_EXIT_REFUND_WALLETS (w.reg.minshr +
|
||||
* w.wal.share + w.cap.blago), собирает доступный L3-баланс каждого, консолидирует
|
||||
* на главный паевой (w.wal.share), резервирует всю сумму (o.wal.wthreq) и создаёт
|
||||
* исходящий платёж в gateway. Если возвращать нечего — выход завершается сразу
|
||||
* без платежа.
|
||||
* @param coopname Наименование кооператива
|
||||
* @param exit_hash Хэш процесса выхода
|
||||
* @param authorization Документ-решение совета о выходе
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p soviet
|
||||
*/
|
||||
void registrator::confirmexit(eosio::name coopname, checksum256 exit_hash, document2 authorization) {
|
||||
require_auth(_soviet);
|
||||
|
||||
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
|
||||
eosio::check(exist.has_value(), "Объект выхода не найден");
|
||||
|
||||
Registrator::exits_index exits(_registrator, coopname.value);
|
||||
auto e = exits.find(exist->username.value);
|
||||
eosio::check(e->status == "pending"_n, "Только ожидающие заявления на выход могут быть одобрены");
|
||||
|
||||
eosio::name username = e->username;
|
||||
|
||||
// оповещаем пайщика
|
||||
require_recipient(username);
|
||||
|
||||
// Контракт сам считает сумму возврата по L3-балансам ledger2 — не доверяем
|
||||
// клиенту. Обходим сет паевых («боевых») кошельков LEDGER2_EXIT_REFUND_WALLETS
|
||||
// (источник истины — wallets.hpp; он же генерируется в cooptypes для
|
||||
// backend-preview, поэтому расчёт на фронте совпадает с этим): аккумулируем
|
||||
// доступный баланс каждого (>0) и тут же консолидируем его на главный паевой
|
||||
// (w.wal.share), чтобы единым платежом вернуть весь паевой через o.wal.*.
|
||||
eosio::asset total_return = eosio::asset(0, _root_govern_symbol);
|
||||
for (const auto &wallet_name : LEDGER2_EXIT_REFUND_WALLETS) {
|
||||
eosio::asset balance = Registrator::get_user_wallet_available(coopname, wallet_name, username);
|
||||
if (balance.amount <= 0) continue;
|
||||
total_return += balance;
|
||||
Registrator::consolidate_share_to_main(coopname, username, wallet_name, balance, exit_hash);
|
||||
}
|
||||
|
||||
exits.modify(e, _soviet, [&](auto &row) {
|
||||
row.status = "authorized"_n;
|
||||
row.approved_statement = authorization;
|
||||
row.quantity = total_return;
|
||||
});
|
||||
|
||||
if (total_return.amount > 0) {
|
||||
// Резервируем сумму возврата: w.wal.share → w.wal.wpend (o.wal.wthreq).
|
||||
std::string memo_req = "Резерв паевого взноса под выход из кооператива, username=" + username.to_string();
|
||||
Ledger2::apply(_registrator, coopname, operations::wallet::REQUEST_WITHDRAW,
|
||||
total_return, username, exit_hash, memo_req);
|
||||
|
||||
// Создаём исходящий платёж в gateway с коллбэками completexit/declinexit.
|
||||
Action::send<createoutpay_interface>(
|
||||
_gateway,
|
||||
"createoutpay"_n,
|
||||
_registrator,
|
||||
coopname,
|
||||
username,
|
||||
exit_hash,
|
||||
total_return,
|
||||
_registrator,
|
||||
"completexit"_n,
|
||||
"declinexit"_n
|
||||
);
|
||||
} else {
|
||||
// Возвращать нечего — финализируем выход без платежа.
|
||||
Registrator::finalize_member_exit(coopname, username);
|
||||
exits.erase(e);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* @brief Отказ в выходе из кооператива.
|
||||
* Вызывается либо советом при отклонении заявления (до резервирования средств),
|
||||
* либо gateway при отклонении исходящего платежа (после резервирования). Если
|
||||
* сумма возврата уже была зарезервирована — снимаем резерв (o.wal.wthdec:
|
||||
* w.wal.wpend → w.wal.share). Пайщик остаётся действующим членом кооператива.
|
||||
* @param coopname Наименование кооператива
|
||||
* @param exit_hash Хэш процесса выхода
|
||||
* @param reason Причина отказа
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p soviet или @p gateway
|
||||
*/
|
||||
void registrator::declinexit(eosio::name coopname, checksum256 exit_hash, std::string reason) {
|
||||
check_auth_and_get_payer_or_fail({_soviet, _gateway});
|
||||
|
||||
auto exist = Registrator::get_exit_by_hash(coopname, exit_hash);
|
||||
eosio::check(exist.has_value(), "Объект выхода не найден");
|
||||
|
||||
Registrator::exits_index exits(_registrator, coopname.value);
|
||||
auto e = exits.find(exist->username.value);
|
||||
|
||||
eosio::name username = e->username;
|
||||
|
||||
// Если резерв уже был сделан (отказ платежа после одобрения советом) —
|
||||
// снимаем его, возвращая средства пайщику на главный паевой кошелёк.
|
||||
// Минимальный паевой, ранее консолидированный на главный (o.reg.mvmin),
|
||||
// остаётся на w.wal.share: пайщик сохраняет полную сумму паевого, аналитика
|
||||
// минимального/целевого разреза при несостоявшемся выходе не восстанавливается.
|
||||
if (e->status == "authorized"_n && e->quantity.amount > 0) {
|
||||
std::string memo = "Снятие резерва паевого при отмене выхода из кооператива: " + reason;
|
||||
Ledger2::apply(_registrator, coopname, operations::wallet::DECLINE_WITHDRAW,
|
||||
e->quantity, username, exit_hash, memo);
|
||||
}
|
||||
|
||||
// оповещаем пайщика
|
||||
require_recipient(username);
|
||||
|
||||
exits.erase(e);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/asset.hpp>
|
||||
#include <eosio/eosio.hpp>
|
||||
|
||||
#include "../../../lib/index.hpp"
|
||||
|
||||
/**
|
||||
* @brief Вспомогательные функции процедуры выхода пайщика из кооператива.
|
||||
*
|
||||
* Вынесены в отдельный заголовок (а не в table_registrator_exits.hpp), т.к.
|
||||
* опираются на ledger2-кошельки и userwallets, которые подключаются позже в
|
||||
* domain/index.hpp. Подключается в registrator.cpp перед exit-экшенами, когда
|
||||
* весь lib/index.hpp уже доступен.
|
||||
*/
|
||||
namespace Registrator {
|
||||
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* @brief Доступный L3-баланс пайщика на USER_SHARED-кошельке ledger2.
|
||||
*
|
||||
* Возвращает available (без blocked) пары `(wallet_name, username)` из таблицы
|
||||
* userwallets контракта ledger2. Если записи нет — нулевая сумма в базовой
|
||||
* валюте управления.
|
||||
*/
|
||||
inline asset get_user_wallet_available(name coopname, name wallet_name, name username) {
|
||||
userwallets_index userwallets(_ledger2, coopname.value);
|
||||
auto idx = userwallets.get_index<"byuserwallet"_n>();
|
||||
auto it = idx.find(combine_ids(wallet_name.value, username.value));
|
||||
if (it == idx.end()) {
|
||||
return asset(0, _root_govern_symbol);
|
||||
}
|
||||
return it->available;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Консолидация доступного паевого кошелька пайщика на главный
|
||||
* (`w.wal.share`) перед резервом возврата при выходе.
|
||||
*
|
||||
* Главный кошелёк (`w.wal.share`) уже целевой — перенос не нужен. Для остальных
|
||||
* кошельков сета `LEDGER2_EXIT_REFUND_WALLETS` применяется операция переноса на
|
||||
* главный:
|
||||
* w.reg.minshr → o.reg.mvmin (MOVE_MINSHARE);
|
||||
* w.cap.blago → o.cap.wthcap (WITHDRAW_FROM_CAPITAL).
|
||||
*
|
||||
* Кошелёк сета без операции переноса (новый паевой кошелёк забыли смаппить)
|
||||
* валит транзакцию с явным сообщением — защита от тихой потери средств.
|
||||
*/
|
||||
inline void consolidate_share_to_main(name coopname, name username, name wallet_name, asset amount, checksum256 exit_hash) {
|
||||
if (wallet_name == ledger2_wallets::SHARE_FUND_PAY) return; // уже на главном паевом
|
||||
|
||||
eosio::name op;
|
||||
if (wallet_name == ledger2_wallets::MIN_SHARE_FUND) {
|
||||
op = operations::registrator::MOVE_MINSHARE; // w.reg.minshr → w.wal.share
|
||||
} else if (wallet_name == ledger2_wallets::BLAGOROST_FUND) {
|
||||
op = operations::capital::WITHDRAW_FROM_CAPITAL; // w.cap.blago → w.wal.share
|
||||
} else {
|
||||
eosio::check(false,
|
||||
std::string{"Нет операции консолидации паевого кошелька "} + wallet_name.to_string() +
|
||||
" на главный при выходе — добавьте маппинг в consolidate_share_to_main");
|
||||
}
|
||||
|
||||
std::string memo = "Консолидация паевого взноса при выходе, кошелёк=" +
|
||||
wallet_name.to_string() + ", username=" + username.to_string();
|
||||
Ledger2::apply(_registrator, coopname, op, amount, username, exit_hash, memo);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Финализация выхода: удаление пайщика из реестра совета и блокировка
|
||||
* аккаунта в registrator.
|
||||
*
|
||||
* Вызывается по завершении возврата паевого взноса (completexit) либо сразу,
|
||||
* если возвращать нечего (нулевой паевой). После этого `get_participant_or_fail`
|
||||
* для пайщика начинает падать — он лишён права подавать заявления.
|
||||
*/
|
||||
inline void finalize_member_exit(name coopname, name username) {
|
||||
// удаляем пайщика из реестра совета (уменьшит счётчик активных пайщиков)
|
||||
action(
|
||||
permission_level{_registrator, "active"_n},
|
||||
_soviet,
|
||||
"delpartcpnt"_n,
|
||||
std::make_tuple(coopname, username)
|
||||
).send();
|
||||
|
||||
// блокируем аккаунт в картотеке registrator
|
||||
accounts_index accounts(_registrator, _registrator.value);
|
||||
auto account = accounts.find(username.value);
|
||||
eosio::check(account != accounts.end(), "Аккаунт не найден");
|
||||
accounts.modify(account, _registrator, [&](auto &a) {
|
||||
a.status = "blocked"_n;
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace Registrator
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* @brief Подача заявления на выход из кооператива.
|
||||
* Действующий пайщик подаёт подписанное заявление о выходе (registry 200).
|
||||
* Создаётся объект выхода и повестка совета `leavecoop` на рассмотрение.
|
||||
* Возврат паевого взноса произойдёт после одобрения советом (confirmexit).
|
||||
* @param coopname Наименование кооператива
|
||||
* @param username Имя пайщика, выходящего из кооператива
|
||||
* @param exit_hash Хэш процесса выхода
|
||||
* @param statement Подписанное заявление о выходе (registry 200)
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_registrator_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p coopname
|
||||
*/
|
||||
void registrator::exitcoop(eosio::name coopname, eosio::name username, checksum256 exit_hash, document2 statement) {
|
||||
require_auth(coopname);
|
||||
|
||||
get_cooperative_or_fail(coopname);
|
||||
|
||||
// выйти может только действующий пайщик (не заблокированный)
|
||||
get_participant_or_fail(coopname, username);
|
||||
|
||||
// повторная подача запрещена — у пайщика может быть только один процесс выхода
|
||||
Registrator::exits_index exits(_registrator, coopname.value);
|
||||
auto existing = exits.find(username.value);
|
||||
eosio::check(existing == exits.end(), "Заявление на выход уже подано");
|
||||
|
||||
auto by_hash = Registrator::get_exit_by_hash(coopname, exit_hash);
|
||||
eosio::check(!by_hash.has_value(), "Объект выхода уже существует с указанным хэшем");
|
||||
|
||||
// проверяем подпись заявления о выходе
|
||||
verify_document_or_fail(statement);
|
||||
|
||||
exits.emplace(coopname, [&](auto &e) {
|
||||
e.username = username;
|
||||
e.coopname = coopname;
|
||||
e.status = "pending"_n;
|
||||
e.created_at = current_time_point();
|
||||
e.statement = statement;
|
||||
e.exit_hash = exit_hash;
|
||||
e.quantity = asset(0, _root_govern_symbol);
|
||||
});
|
||||
|
||||
// повестка совета на рассмотрение выхода с коллбэками confirmexit/declinexit
|
||||
::Soviet::create_agenda(
|
||||
_registrator,
|
||||
coopname,
|
||||
username,
|
||||
get_valid_soviet_action("leavecoop"_n),
|
||||
exit_hash,
|
||||
_registrator, // callback_contract
|
||||
"confirmexit"_n, // callback при одобрении
|
||||
"declinexit"_n, // callback при отказе
|
||||
statement,
|
||||
std::string("")
|
||||
);
|
||||
}
|
||||
@@ -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, "Отмена возврата регистрационного взноса невозможна");
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user