Compare commits
188 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b84b02f1b4 | |||
| c33ec53230 | |||
| 7dcca3a2ae | |||
| 70752b2caa | |||
| 8c5568bcf8 | |||
| 30b79ad1f7 | |||
| 87747d1782 | |||
| f7a182e51f | |||
| 9774abb296 | |||
| 724cd381cc | |||
| 5aa1a21a97 | |||
| 933e5b5ffe | |||
| d886d780fc | |||
| 68b37efbe9 | |||
| e810d3152f | |||
| 99c22b0064 | |||
| 4c0fbefca9 | |||
| 8fdc99a7f9 | |||
| 8d27c4e245 | |||
| 0e3ccb4356 | |||
| 306835d0fc | |||
| 10e5b83ac9 | |||
| 33d936e5c8 | |||
| a73f07fa0c | |||
| 77f358e865 | |||
| 2afc71c281 | |||
| 9cf19aa8fe | |||
| 4b8121b0b7 | |||
| 970e107261 | |||
| a0e169e9b4 | |||
| a93022343c | |||
| 81fca02e39 | |||
| d1977e3da2 | |||
| 69d0e223ed | |||
| b1b0776d68 | |||
| e4a1a33a53 | |||
| 9cbed2edf5 | |||
| f27eb3a102 | |||
| e3f4071b16 | |||
| 3c87043d12 | |||
| 1a6f326aa4 | |||
| 5fc2d90a85 | |||
| bc3a315790 | |||
| 4a244c05e4 | |||
| 79e333db44 | |||
| 40be8fb60c | |||
| 9e022443ca | |||
| 5fd5db0604 | |||
| adc5926fa7 | |||
| 655a73e828 | |||
| 02418af616 | |||
| 3d4f75bff0 | |||
| 7b9828ae87 | |||
| fb7ea468fa | |||
| 745438ded3 | |||
| 82d58c29f6 | |||
| aa5c180be0 | |||
| e745c73cb5 | |||
| f531182541 | |||
| 2eadc79ee3 | |||
| 52d423ef8a | |||
| 2b529d5a15 | |||
| 5a17bcdfaf | |||
| fd59678944 | |||
| 23605d2b81 | |||
| efce39a4c9 | |||
| ba335432a2 | |||
| 1b9cb27030 | |||
| e000ff9c8f | |||
| fdd09d5501 | |||
| 0504c46fbd | |||
| 6270ae14bf | |||
| 2a296ef2ef | |||
| a8f2b31494 | |||
| 52a5871330 | |||
| 1f10a3c092 | |||
| 9579ae0612 | |||
| a436527248 | |||
| 94e44df124 | |||
| dfed3774b7 | |||
| 3950aa75db | |||
| db25c1dfa9 | |||
| e38762deaa | |||
| d2c1d425fc | |||
| ef993baa25 | |||
| adf36b28cb | |||
| 223dd00f91 | |||
| fd2f716e61 | |||
| 33a414c7e7 | |||
| d128f245b7 | |||
| 1c7a3afc81 | |||
| c0627b53e3 | |||
| ea8695d1fc | |||
| bf3f9ca5ac | |||
| f023d3a8c8 | |||
| 5de2d2bba1 | |||
| 9e545329fc | |||
| 7ee4cd633f | |||
| b973baef03 | |||
| e10e049bdd | |||
| 8f5fcafecb | |||
| bef5df8a06 | |||
| a46a6c173a | |||
| 34c4dadec3 | |||
| a24f5d4fc1 | |||
| 402783d8ae | |||
| 2479cce94d | |||
| bd0bd21ae7 | |||
| abcd34f23d | |||
| 83cac58fca | |||
| dbf46d88d0 | |||
| a0cb930b77 | |||
| 4a4145c4ed | |||
| d35ec2680a | |||
| 8523ab0c08 | |||
| 74e3e88853 | |||
| 36441437db | |||
| 00860b3b18 | |||
| 8fe9509e97 | |||
| 43a9e05ccb | |||
| d4de7a3028 | |||
| e2584fb898 | |||
| 3785c55af1 | |||
| 848566bd69 | |||
| 61255da5f4 | |||
| 1a63638f73 | |||
| c8bc5c782d | |||
| 4e2fbac340 | |||
| aeb4efe2b6 | |||
| 7ef138ba19 | |||
| 8aeee3ee56 | |||
| 70446c300d | |||
| 007a4dbfb8 | |||
| 582a2825be | |||
| b2f13dc093 | |||
| c41aba12f1 | |||
| 5a01aee69f | |||
| e6880e149b | |||
| a9c1e1924a | |||
| 938c3ff042 | |||
| cd9ab32f04 | |||
| 47f616e980 | |||
| 908349ac67 | |||
| 64f8201eb6 | |||
| 5735e1fc36 | |||
| 4a6aa8c447 | |||
| b2a304e9de | |||
| 6eeb4567b1 | |||
| b92abb80a8 | |||
| dcd3c9293a | |||
| f952be5f83 | |||
| bd8eb34e43 | |||
| 7d9583d862 | |||
| 8847a5c093 | |||
| 37b7387830 | |||
| 968990c3ff | |||
| c32d265bb1 | |||
| a1bee6f630 | |||
| 2fd0087e93 | |||
| a967311d1e | |||
| c7db7a9c6f | |||
| 3db9854a76 | |||
| c970d879bc | |||
| 7d90d3af2d | |||
| 117c0c7c06 | |||
| 71b20e382d | |||
| 3561888781 | |||
| 2e3410b830 | |||
| de0f6c7956 | |||
| 5f41b74023 | |||
| c5eeef124c | |||
| e270b95996 | |||
| 8d8b01f148 | |||
| c5922f672f | |||
| bb1d8558e7 | |||
| e5151758fb | |||
| a575da57f8 | |||
| 9352c77cd7 | |||
| add7646720 | |||
| 0d8006d981 | |||
| f9d253117c | |||
| c6b144661f | |||
| a55005a212 | |||
| 5ae6f4963d | |||
| f64e028709 | |||
| 33f44dc823 | |||
| dd88047d76 | |||
| b9d06e4e68 |
@@ -27,6 +27,12 @@ PG_HOST_PORT=5532
|
||||
COOPBACK_HOST_PORT=2998
|
||||
DESKTOP_HOST_PORT=2999
|
||||
OPENSEARCH_HOST_PORT=9200
|
||||
MINIO_HOST_PORT=9000
|
||||
MINIO_CONSOLE_HOST_PORT=9001
|
||||
|
||||
# MinIO root credentials (только для локального dev; на prod подменяются плейбуком).
|
||||
MINIO_ROOT_USER=minioadmin
|
||||
MINIO_ROOT_PASSWORD=minioadmin
|
||||
|
||||
# URL для скриптов и кода, запускаемого с хоста (boot, networks.sh,
|
||||
# preactivate.sh, health.ts). Должны соответствовать host-портам выше.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
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,7 +18,6 @@ name: Build bootstrap container
|
||||
#
|
||||
# Секреты:
|
||||
# DOCKERHUB_USERNAME / DOCKERHUB_TOKEN
|
||||
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -85,17 +84,3 @@ 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})"
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
name: Build Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Debug info
|
||||
run: |
|
||||
echo "Текущая ветка:"
|
||||
git branch --show-current
|
||||
echo "Последние коммиты:"
|
||||
git log -n 3 --oneline
|
||||
echo "Проверяем файлы в директории components/desktop/src-ssr:"
|
||||
ls -la components/desktop/src-ssr/ || echo "Директория не найдена!"
|
||||
echo "Проверяем файлы в middlewares:"
|
||||
ls -la components/desktop/src-ssr/middlewares/ || echo "Директория middlewares не найдена!"
|
||||
|
||||
- name: Set docker tags
|
||||
run: |
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
TAG_NAME=${GITHUB_REF#refs/tags/}
|
||||
echo "DOCKER_TAG=$TAG_NAME" >> $GITHUB_ENV
|
||||
|
||||
# Проверяем, является ли тег продакшн-тегом (не содержит alpha, beta, rc и т.д.)
|
||||
if [[ ! $TAG_NAME =~ -(alpha|beta|rc|test) ]]; then
|
||||
echo "IS_PRODUCTION_TAG=true" >> $GITHUB_ENV
|
||||
echo "Это продакшн тег, будем добавлять latest"
|
||||
else
|
||||
echo "IS_PRODUCTION_TAG=false" >> $GITHUB_ENV
|
||||
echo "Это не продакшн тег, latest не добавляем"
|
||||
fi
|
||||
else
|
||||
echo "DOCKER_TAG=latest" >> $GITHUB_ENV
|
||||
echo "IS_PRODUCTION_TAG=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Сначала собираем базовый образ с runtime
|
||||
- name: Build base image
|
||||
run: |
|
||||
docker build --target runtime -t dicoop/mono-base:${{ env.DOCKER_TAG }} .
|
||||
docker push dicoop/mono-base:${{ env.DOCKER_TAG }}
|
||||
|
||||
# Если это продакшн тег, добавляем latest
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/mono-base:${{ env.DOCKER_TAG }} dicoop/mono-base:latest
|
||||
docker push dicoop/mono-base:latest
|
||||
fi
|
||||
|
||||
# Создаем сервисные образы на основе базового
|
||||
- name: Build desktop image
|
||||
run: |
|
||||
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.desktop
|
||||
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/desktop\", \"run\", \"start\"]" >> Dockerfile.desktop
|
||||
docker build -t dicoop/desktop:${{ env.DOCKER_TAG }} -f Dockerfile.desktop .
|
||||
docker push dicoop/desktop:${{ env.DOCKER_TAG }}
|
||||
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/desktop:${{ env.DOCKER_TAG }} dicoop/desktop:latest
|
||||
docker push dicoop/desktop:latest
|
||||
fi
|
||||
|
||||
- name: Build controller image
|
||||
run: |
|
||||
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.coopback
|
||||
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/controller\", \"run\", \"start\"]" >> Dockerfile.coopback
|
||||
docker build -t dicoop/coopback:${{ env.DOCKER_TAG }} -f Dockerfile.coopback .
|
||||
docker push dicoop/coopback:${{ env.DOCKER_TAG }}
|
||||
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/coopback:${{ env.DOCKER_TAG }} dicoop/coopback:latest
|
||||
docker push dicoop/coopback:latest
|
||||
fi
|
||||
|
||||
# TEMP: парсер используется существующим деплоем как dicoop/cooparser,
|
||||
# пока не мигрировали на dicoop/parser из отдельного coopenomics/parser repo.
|
||||
# Оставляем сборку и пуш образа до завершения миграции потребителей.
|
||||
- name: Build parser image
|
||||
run: |
|
||||
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.cooparser
|
||||
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/parser\", \"run\", \"start\"]" >> Dockerfile.cooparser
|
||||
docker build -t dicoop/cooparser:${{ env.DOCKER_TAG }} -f Dockerfile.cooparser .
|
||||
docker push dicoop/cooparser:${{ env.DOCKER_TAG }}
|
||||
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/cooparser:${{ env.DOCKER_TAG }} dicoop/cooparser:latest
|
||||
docker push dicoop/cooparser:latest
|
||||
fi
|
||||
|
||||
- name: Build notificator image
|
||||
run: |
|
||||
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.notificator
|
||||
echo "CMD [\"pnpm\", \"-F\", \"coop-notificator\", \"run\", \"start\"]" >> Dockerfile.notificator
|
||||
docker build -t dicoop/notificator:${{ env.DOCKER_TAG }} -f Dockerfile.notificator .
|
||||
docker push dicoop/notificator:${{ env.DOCKER_TAG }}
|
||||
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/notificator:${{ env.DOCKER_TAG }} dicoop/notificator:latest
|
||||
docker push dicoop/notificator:latest
|
||||
fi
|
||||
|
||||
- name: Build notifications image
|
||||
run: |
|
||||
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.notifications
|
||||
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/notifications\", \"run\", \"sync\"]" >> Dockerfile.notifications
|
||||
docker build -t dicoop/notifications:${{ env.DOCKER_TAG }} -f Dockerfile.notifications .
|
||||
docker push dicoop/notifications:${{ env.DOCKER_TAG }}
|
||||
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/notifications:${{ env.DOCKER_TAG }} dicoop/notifications:latest
|
||||
docker push dicoop/notifications:latest
|
||||
fi
|
||||
|
||||
# Отправка хука для деплоя
|
||||
- name: Trigger deployment webhook
|
||||
if: ${{ success() }}
|
||||
run: |
|
||||
if [[ $GITHUB_REF == refs/tags/*alpha* ]]; then
|
||||
# Хук для тестнета (alpha теги)
|
||||
curl -X POST ${{ vars.TESTNET_WEBHOOK_URL }} \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '${{ env.DOCKER_TAG }}'
|
||||
elif [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
# Хук для продакшена (остальные теги)
|
||||
curl -X POST ${{ vars.PRODUCTION_WEBHOOK_URL }} \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '${{ env.DOCKER_TAG }}'
|
||||
fi
|
||||
|
||||
# Уведомление в Telegram об успехе
|
||||
- name: Telegram notify success
|
||||
if: ${{ success() }}
|
||||
run: |
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
ADDITIONAL_INFO=" (с тегом latest)"
|
||||
else
|
||||
ADDITIONAL_INFO=""
|
||||
fi
|
||||
|
||||
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
|
||||
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
|
||||
-d text="✅ [GITHUB MONO] Успешная сборка контейнеров: $GITHUB_REPOSITORY ($GITHUB_REF) [${{ env.DOCKER_TAG }}]$ADDITIONAL_INFO"
|
||||
|
||||
# Уведомление в Telegram об ошибке
|
||||
- name: Telegram notify failure
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
|
||||
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
|
||||
-d text="❌ [GITHUB MONO] Ошибка при сборке контейнеров: $GITHUB_REPOSITORY ($GITHUB_REF) [${{ env.DOCKER_TAG }}]"
|
||||
@@ -1,18 +0,0 @@
|
||||
# .github/workflows/trigger-coopenomics.yml
|
||||
name: Trigger Contracts Docs Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, testnet, main, capital] # или когда нужно триггерить
|
||||
|
||||
jobs:
|
||||
trigger-coopenomics:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Coopenomics deployment
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.COOPENOMICS_PAT }}
|
||||
repository: coopenomics/coopenomics # укажи правильный owner/repo
|
||||
event-type: deploy_from_mono
|
||||
client-payload: '{"repository": "${{ github.repository }}", "sha": "${{ github.sha }}", "ref": "${{ github.ref }}", "actor": "${{ github.actor }}"}'
|
||||
@@ -1,206 +0,0 @@
|
||||
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).
|
||||
# Маппинг:
|
||||
# dev → IS_TESTNET=ON, tag=dev
|
||||
# testnet → IS_TESTNET=ON, tag=testnet
|
||||
# main → IS_TESTNET=OFF, tag=main + latest
|
||||
#
|
||||
# Сам 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:
|
||||
push:
|
||||
branches: [dev, testnet, main]
|
||||
paths:
|
||||
- 'components/contracts/cpp/**'
|
||||
- 'components/contracts/CMakeLists.txt'
|
||||
- 'components/contracts/build.sh'
|
||||
- 'components/contracts/build-all.sh'
|
||||
- 'components/contracts/docker/**'
|
||||
- '.github/workflows/build-contracts.yaml'
|
||||
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})"
|
||||
@@ -1,135 +0,0 @@
|
||||
name: Publish Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- testnet
|
||||
- dev
|
||||
- reports
|
||||
- marketplace2
|
||||
|
||||
jobs:
|
||||
build-and-publish-docs:
|
||||
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 }}"}'
|
||||
@@ -1,37 +0,0 @@
|
||||
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 }}
|
||||
@@ -0,0 +1,462 @@
|
||||
name: Release
|
||||
|
||||
# Единый релизный workflow по push'у тэга `v*`.
|
||||
#
|
||||
# Порядок (через jobs.needs):
|
||||
# 1) release — контракты + контейнеры + webhook деплоя (атомарно)
|
||||
# 2) publish-packages — npm publish через lerna (если не -alpha)
|
||||
# 3) publish-docs — mkdocs + standards-site → gh-pages (если не -alpha + ветка main)
|
||||
# 4) trigger-coopenomics — repository_dispatch в coopenomics/coopenomics (тот же гейт, что docs)
|
||||
#
|
||||
# Зачем последовательно: пакеты/доки/внешний триггер не должны уезжать,
|
||||
# если релиз контрактов или контейнеров провалился. Раньше четыре workflow'а
|
||||
# стартовали параллельно по одному тэгу — отсюда гонка из инцидента 2026-05-13
|
||||
# с ledger2 walletop sweep'ом (см. историю release.yaml до объединения).
|
||||
#
|
||||
# Гейт `-alpha` унифицирован: пакеты + доки + триггер docs пропускают только
|
||||
# не-alpha тэги. -beta/-rc/-test в проекте не используются (см. CLAUDE.md),
|
||||
# поэтому проще иметь один паттерн `!contains(github.ref, '-alpha')`.
|
||||
# IS_PROD в release-job остаётся на `(alpha|beta|rc|test)` — он определяет
|
||||
# деплой-webhook (testnet vs production) и тэг `:latest` у образов;
|
||||
# намеренно строже, чтобы случайный -rc-тэг не уехал на прод.
|
||||
#
|
||||
# Резолв ветки. Тэг семантики окружения не несёт (например `v2026.5.13-alpha-2`
|
||||
# может лежать на testnet или dev — определяет именно ветка, на которую сделан
|
||||
# merge перед тэгом). Поэтому ищем первое совпадение SHA с remote-ветками
|
||||
# main/testnet/dev. branch экспортируется как outputs.branch для downstream jobs.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# Гейт TS-типов перед сборкой образов. Reusable workflow `typecheck.yaml`
|
||||
# запускает vue-tsc на desktop и tsc --noEmit на controller. Нужно потому,
|
||||
# что `lerna run build` в корневом Dockerfile НЕ ловит TS-ошибки:
|
||||
# quasar build идёт через esbuild с выключенным vueTsc, а у controller'а
|
||||
# build-скрипта вообще нет (ts-node в рантайме).
|
||||
typecheck:
|
||||
uses: ./.github/workflows/typecheck.yaml
|
||||
|
||||
release:
|
||||
needs: typecheck
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag_name: ${{ steps.resolve.outputs.tag_name }}
|
||||
branch: ${{ steps.resolve.outputs.branch }}
|
||||
is_prod: ${{ steps.resolve.outputs.is_prod }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve branch, build mode and tags
|
||||
id: resolve
|
||||
run: |
|
||||
TAG_NAME="${{ github.ref_name }}"
|
||||
SHA="${{ github.sha }}"
|
||||
BRANCH=""
|
||||
for candidate in main testnet dev; do
|
||||
if git branch -r --contains "$SHA" 2>/dev/null | grep -qE "^[[:space:]]*origin/${candidate}$"; then
|
||||
BRANCH="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "::error::Тэг $TAG_NAME ($SHA) не лежит ни на одной из dev/testnet/main"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$BRANCH" in
|
||||
main)
|
||||
BUILD_MODE=prod
|
||||
CONTRACTS_TAG=main
|
||||
CONTRACTS_EXTRA=latest
|
||||
;;
|
||||
testnet)
|
||||
BUILD_MODE=test
|
||||
CONTRACTS_TAG=testnet
|
||||
CONTRACTS_EXTRA=
|
||||
;;
|
||||
dev)
|
||||
BUILD_MODE=test
|
||||
CONTRACTS_TAG=dev
|
||||
CONTRACTS_EXTRA=
|
||||
;;
|
||||
esac
|
||||
|
||||
# IS_PROD определяет webhook деплоя (PRODUCTION vs TESTNET) и тэг :latest.
|
||||
# Сознательно строже гейта publish-* — случайный -rc/-beta тэг не должен
|
||||
# уехать на прод-вебхук, даже если в npm/docs его опубликовали.
|
||||
if [[ "$TAG_NAME" =~ -(alpha|beta|rc|test) ]]; then
|
||||
IS_PROD=false
|
||||
WEBHOOK_URL='${{ vars.TESTNET_WEBHOOK_URL }}'
|
||||
else
|
||||
IS_PROD=true
|
||||
WEBHOOK_URL='${{ vars.PRODUCTION_WEBHOOK_URL }}'
|
||||
fi
|
||||
|
||||
{
|
||||
echo "TAG_NAME=$TAG_NAME"
|
||||
echo "SHA=$SHA"
|
||||
echo "BRANCH=$BRANCH"
|
||||
echo "BUILD_MODE=$BUILD_MODE"
|
||||
echo "CONTRACTS_TAG=$CONTRACTS_TAG"
|
||||
echo "CONTRACTS_EXTRA=$CONTRACTS_EXTRA"
|
||||
echo "IS_PROD=$IS_PROD"
|
||||
echo "WEBHOOK_URL=$WEBHOOK_URL"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
{
|
||||
echo "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
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# === Этап 1: контракты ===
|
||||
|
||||
- name: Pull CDT toolchain image
|
||||
run: docker pull dicoop/blockchain_v5.1.1:dev
|
||||
|
||||
- name: Compile contracts
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
./build-all.sh "$BUILD_MODE"
|
||||
echo "--- build/contracts ---"
|
||||
ls -la build/contracts/
|
||||
|
||||
- name: Stage contracts docker context
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
ROOT="docker/.context"
|
||||
rm -rf "$ROOT"
|
||||
mkdir -p "$ROOT/contracts"
|
||||
|
||||
# Имена контрактов = аргументы add_contract_build(...) только в
|
||||
# незакомменченных строках (CMake-комментарий начинается с `#`).
|
||||
NAMES=$(grep -E '^[[:space:]]*add_contract_build\(' CMakeLists.txt \
|
||||
| sed -E 's/^[[:space:]]*add_contract_build\(([^)]*)\).*/\1/')
|
||||
echo "Contracts to package:"
|
||||
echo "$NAMES"
|
||||
|
||||
MANIFEST="$ROOT/contracts/manifest.json"
|
||||
|
||||
pack_one() {
|
||||
local NAME="$1" WASM="$2" ABI="$3"
|
||||
if [ ! -f "$WASM" ] || [ ! -f "$ABI" ]; then
|
||||
echo "::warning::Skipping $NAME: artifacts missing ($WASM / $ABI)" >&2
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$ROOT/contracts/$NAME"
|
||||
cp "$WASM" "$ROOT/contracts/$NAME/$NAME.wasm"
|
||||
cp "$ABI" "$ROOT/contracts/$NAME/$NAME.abi"
|
||||
local SHA_WASM=$(sha256sum "$WASM" | awk '{print $1}')
|
||||
local SHA_ABI=$(sha256sum "$ABI" | awk '{print $1}')
|
||||
printf ' {"name":"%s","sha256_wasm":"%s","sha256_abi":"%s"}' \
|
||||
"$NAME" "$SHA_WASM" "$SHA_ABI"
|
||||
}
|
||||
|
||||
{
|
||||
printf '{\n'
|
||||
printf ' "build_mode": "%s",\n' "$BUILD_MODE"
|
||||
printf ' "git_sha": "%s",\n' "$SHA"
|
||||
printf ' "tag": "%s",\n' "$TAG_NAME"
|
||||
printf ' "branch": "%s",\n' "$BRANCH"
|
||||
printf ' "built_at": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
printf ' "contracts": [\n'
|
||||
|
||||
FIRST=1
|
||||
emit() {
|
||||
entry=$(pack_one "$@") || return 0
|
||||
[ $FIRST -eq 0 ] && printf ',\n'
|
||||
FIRST=0
|
||||
printf '%s' "$entry"
|
||||
}
|
||||
|
||||
for c in $NAMES; do
|
||||
if [ "$c" = "system" ]; then
|
||||
# У system многоконтрактная сборка: разносим под-контракты
|
||||
# eosio.* как полноценные элементы каталога, сам "system"
|
||||
# как имя пропускаем.
|
||||
for sub in build/contracts/system/contracts/eosio.*/; do
|
||||
[ -d "$sub" ] || continue
|
||||
sub_name=$(basename "$sub")
|
||||
emit "$sub_name" "$sub/$sub_name.wasm" "$sub/$sub_name.abi"
|
||||
done
|
||||
else
|
||||
emit "$c" "build/contracts/$c/$c.wasm" "build/contracts/$c/$c.abi"
|
||||
fi
|
||||
done
|
||||
|
||||
printf '\n ]\n'
|
||||
printf '}\n'
|
||||
} > "$MANIFEST"
|
||||
|
||||
cp docker/Dockerfile "$ROOT/Dockerfile"
|
||||
cp docker/entrypoint.sh "$ROOT/entrypoint.sh"
|
||||
chmod +x "$ROOT/entrypoint.sh"
|
||||
|
||||
echo "--- manifest.json ---"
|
||||
cat "$MANIFEST"
|
||||
|
||||
- name: Build and push contracts image
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
IMAGE="dicoop/contracts"
|
||||
SHORT_SHA="${SHA::7}"
|
||||
docker build \
|
||||
--label "org.opencontainers.image.revision=$SHA" \
|
||||
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--label "build.mode=$BUILD_MODE" \
|
||||
-t "$IMAGE:$CONTRACTS_TAG" \
|
||||
./docker/.context
|
||||
docker push "$IMAGE:$CONTRACTS_TAG"
|
||||
|
||||
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
|
||||
docker push "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
|
||||
|
||||
if [ -n "$CONTRACTS_EXTRA" ]; then
|
||||
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_EXTRA"
|
||||
docker push "$IMAGE:$CONTRACTS_EXTRA"
|
||||
fi
|
||||
|
||||
- name: Verify pushed contracts image
|
||||
run: |
|
||||
docker run --rm "dicoop/contracts:$CONTRACTS_TAG" list
|
||||
echo "---"
|
||||
docker run --rm "dicoop/contracts:$CONTRACTS_TAG" sha256
|
||||
|
||||
# === Этап 2: контейнеры приложений ===
|
||||
|
||||
- name: Build and push base image
|
||||
run: |
|
||||
docker build --target runtime -t "dicoop/mono-base:$TAG_NAME" .
|
||||
docker push "dicoop/mono-base:$TAG_NAME"
|
||||
if [ "$IS_PROD" = "true" ]; then
|
||||
docker tag "dicoop/mono-base:$TAG_NAME" dicoop/mono-base:latest
|
||||
docker push dicoop/mono-base:latest
|
||||
fi
|
||||
|
||||
- name: Build and push service images
|
||||
run: |
|
||||
build_service() {
|
||||
local SVC="$1" PKG="$2" CMD="$3"
|
||||
local DOCKERFILE="Dockerfile.$SVC"
|
||||
{
|
||||
echo "FROM dicoop/mono-base:$TAG_NAME"
|
||||
echo "CMD [\"pnpm\", \"-F\", \"$PKG\", \"run\", \"$CMD\"]"
|
||||
} > "$DOCKERFILE"
|
||||
docker build -t "dicoop/$SVC:$TAG_NAME" -f "$DOCKERFILE" .
|
||||
docker push "dicoop/$SVC:$TAG_NAME"
|
||||
if [ "$IS_PROD" = "true" ]; then
|
||||
docker tag "dicoop/$SVC:$TAG_NAME" "dicoop/$SVC:latest"
|
||||
docker push "dicoop/$SVC:latest"
|
||||
fi
|
||||
}
|
||||
|
||||
build_service desktop '@coopenomics/desktop' start
|
||||
build_service coopback '@coopenomics/controller' start
|
||||
# TEMP: cooparser — пока не мигрировали потребителей на dicoop/parser
|
||||
# из отдельного coopenomics/parser repo.
|
||||
build_service cooparser '@coopenomics/parser' start
|
||||
build_service notificator 'coop-notificator' start
|
||||
build_service notifications '@coopenomics/notifications' sync
|
||||
|
||||
# === Этап 3: webhook деплоя ===
|
||||
|
||||
- name: Trigger deployment webhook
|
||||
run: |
|
||||
curl -X POST "$WEBHOOK_URL" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$TAG_NAME"
|
||||
|
||||
# ============================================================================
|
||||
# publish-packages — npm publish (бывший publish-packages.yaml).
|
||||
# Гейт: не-alpha. Не зависит от ветки.
|
||||
# Жёстко прибиваем версию pnpm, которой сгенерён lockfile (lockfileVersion 9.0
|
||||
# = pnpm 9/10). Иначе action-setup может подтянуть другую версию и переписать
|
||||
# pnpm-lock.yaml → Lerna EUNCOMMIT.
|
||||
# ============================================================================
|
||||
publish-packages:
|
||||
needs: release
|
||||
if: ${{ !contains(github.ref, '-alpha') }}
|
||||
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
|
||||
- run: pnpm lerna publish from-package --yes
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
# ============================================================================
|
||||
# publish-docs — mkdocs + standards-site → gh-pages (бывший publish-docs.yaml).
|
||||
# Гейт: не-alpha + ветка main.
|
||||
# ВНИМАНИЕ: gh-pages step пушит на github.com/coopenomics/mono — это GitHub
|
||||
# Pages. При переезде на Gitea Actions нужно будет либо подложить PAT для
|
||||
# внешнего push'а в GitHub, либо переключить публикацию на Gitea Pages.
|
||||
# ============================================================================
|
||||
publish-docs:
|
||||
needs: release
|
||||
if: ${{ !contains(github.ref, '-alpha') && needs.release.outputs.branch == 'main' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Версия pnpm берётся из `packageManager` корневого package.json,
|
||||
# синхронно с publish-packages job'ом и Dockerfile'ами.
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install Python requirements
|
||||
run: |
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install mkdocs-material mkdocs-macros-plugin mkdocs-section-index pymdown-extensions
|
||||
working-directory: ./components/docs
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: pnpm install
|
||||
working-directory: ./components/docs
|
||||
|
||||
- name: Patch spectaql-config.yml for CI
|
||||
run: |
|
||||
sed -i.bak "0,/url:.*/s|url:.*|url: 'https://testnet.coopenomics.world/backend/v1/graphql'|" spectaql-config.yml
|
||||
working-directory: ./components/controller
|
||||
|
||||
- name: Show patched spectaql-config.yml
|
||||
run: cat spectaql-config.yml
|
||||
working-directory: ./components/controller
|
||||
|
||||
- name: Build cooptypes
|
||||
run: pnpm run build
|
||||
working-directory: ./components/cooptypes
|
||||
|
||||
- name: Generate controller docs
|
||||
run: pnpm run docs
|
||||
working-directory: ./components/controller
|
||||
|
||||
- name: Copy controller docs
|
||||
run: |
|
||||
mkdir -p ./components/docs/docs/graphql
|
||||
cp -r ./components/controller/docs/* ./components/docs/docs/graphql/
|
||||
|
||||
- name: Generate sdk docs
|
||||
run: pnpm run docs
|
||||
working-directory: ./components/sdk
|
||||
|
||||
- name: Copy sdk docs
|
||||
run: |
|
||||
mkdir -p ./components/docs/docs/sdk
|
||||
cp -r ./components/sdk/docs/* ./components/docs/docs/sdk/
|
||||
|
||||
- name: Generate cooptypes docs
|
||||
run: pnpm run docs
|
||||
working-directory: ./components/cooptypes
|
||||
|
||||
- name: Copy cooptypes docs
|
||||
run: |
|
||||
mkdir -p ./components/docs/docs/cooptypes
|
||||
cp -r ./components/cooptypes/docs/* ./components/docs/docs/cooptypes/
|
||||
|
||||
# ── standards-site → /standards/ на docs.цифровой-кооператив.рф ──
|
||||
# Отдельный Vue-сайт с BPMN-графом кооперативных стандартов,
|
||||
# публикуется как поддиректория на том же домене. Vite собирает
|
||||
# с base='/standards/' (см. vite.config.ts), относительные пути
|
||||
# внутри dist/ и hash-router работают корректно.
|
||||
# dist кладём в docs/standards/ ДО mkdocs build — так же, как
|
||||
# graphql/sdk/cooptypes; mkdocs сам включит его в итоговый site/.
|
||||
- name: Build standards-site
|
||||
run: pnpm run build
|
||||
working-directory: ./components/contracts/standards-site
|
||||
|
||||
- name: Copy standards-site into docs/standards
|
||||
run: |
|
||||
mkdir -p ./components/docs/docs/standards
|
||||
cp -r ./components/contracts/standards-site/dist/* ./components/docs/docs/standards/
|
||||
|
||||
- name: Build docs (mkdocs)
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
mkdocs build
|
||||
working-directory: ./components/docs
|
||||
|
||||
- name: Remove specific large file before publishing
|
||||
run: |
|
||||
rm -f ./components/docs/site/sdk/typedoc.json
|
||||
if [ -f "./components/docs/site/sdk/typedoc.json" ]; then
|
||||
echo "ERROR: typedoc.json still exists!"
|
||||
exit 1
|
||||
else
|
||||
echo "SUCCESS: typedoc.json removed successfully"
|
||||
fi
|
||||
|
||||
- name: Publish to GitHub Pages
|
||||
run: npx gh-pages --nojekyll -d site --repo https://x-access-token:${GITHUB_TOKEN}@github.com/coopenomics/mono.git
|
||||
working-directory: ./components/docs
|
||||
env:
|
||||
GIT_AUTHOR_NAME: github-actions
|
||||
GIT_AUTHOR_EMAIL: github-actions@github.com
|
||||
GIT_COMMITTER_NAME: github-actions
|
||||
GIT_COMMITTER_EMAIL: github-actions@github.com
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger docs deployment webhook
|
||||
if: ${{ success() }}
|
||||
run: |
|
||||
curl -X POST "${{ vars.DOCS_DEPLOY_WEBHOOK_URL }}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"ref":"${{ github.ref }}","sha":"${{ github.sha }}","branch":"${{ github.ref_name }}"}'
|
||||
|
||||
# ============================================================================
|
||||
# trigger-coopenomics-docs — repository_dispatch в coopenomics/coopenomics
|
||||
# (бывший build-contracts-docs.yaml). Гейт идентичен publish-docs.
|
||||
# ============================================================================
|
||||
trigger-coopenomics-docs:
|
||||
needs: release
|
||||
if: ${{ !contains(github.ref, '-alpha') && needs.release.outputs.branch == 'main' }}
|
||||
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,96 @@
|
||||
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 как `needs:` у release-job,
|
||||
# чтобы битый тэг не уехал в DockerHub.
|
||||
#
|
||||
# Push в dev/testnet/main НЕ триггерит — намеренно (PR-гейт достаточен,
|
||||
# а тэги покрывает workflow_call из release.yaml).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
workflow_call:
|
||||
|
||||
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
|
||||
@@ -21,3 +21,4 @@ components/reports-standarts/**/СФР_1118018397_*.xml
|
||||
components/contracts/cpp/ledger2/scripts/out/
|
||||
|
||||
.env.testnet
|
||||
.pnpm-store
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
store-dir=./.pnpm-store
|
||||
supported-architectures.os[]=darwin
|
||||
supported-architectures.os[]=linux
|
||||
supported-architectures.cpu[]=x64
|
||||
supported-architectures.cpu[]=arm64
|
||||
supported-architectures.libc[]=glibc
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
# 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.
|
||||
|
||||
**Не 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'ов.
|
||||
|
||||
## 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).
|
||||
@@ -0,0 +1,139 @@
|
||||
# Запуск 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-режима |
|
||||
+12
-1
@@ -83,12 +83,23 @@ 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/*
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& update-ca-certificates
|
||||
|
||||
# Python venv с WeasyPrint, готовый к использованию.
|
||||
COPY --from=builder /venv /venv
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
### 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.5.10-4",
|
||||
"version": "2026.5.23-4",
|
||||
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -41,6 +41,7 @@ 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'
|
||||
|
||||
@@ -237,6 +238,48 @@ 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')
|
||||
|
||||
@@ -14,6 +14,8 @@ 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,11 +3,16 @@
|
||||
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,
|
||||
@@ -42,6 +47,121 @@ 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
|
||||
@@ -184,13 +304,16 @@ export async function pullProjectCommunicationArtifacts(
|
||||
try {
|
||||
const tKey = row.project_hash
|
||||
const tExIso = cursors.transcriptionLastEndedExclusiveByProject[tKey]
|
||||
// Как сообщения с after=0: без курсора — полная выгрузка завершённых транскрипций в meetings/.
|
||||
// (GitHub-синк при первом запуске только ставит курсор без файлов — для локального зеркала так не делаем.)
|
||||
// Курсор `transcriptionLastEndedExclusiveByProject` влияет ТОЛЬКО на скачивание meeting.md
|
||||
// (тяжёлый GetTranscription с сегментами). Sibling-файл `.memo.md` синхронизируется для всех
|
||||
// COMPLETED-транскрипций каждый pull — поле `memo` приходит в лёгком GetTranscriptions.
|
||||
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) {
|
||||
@@ -203,18 +326,23 @@ 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 })
|
||||
byId.set(t.id, {
|
||||
id: t.id,
|
||||
endedAt: end,
|
||||
memo: typeof t.memo === 'string' ? t.memo : '',
|
||||
updatedAt: dateFromUnknown(t.updatedAt),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
const candidates = [...byId.values()].sort((a, b) => a.endedAt.getTime() - b.endedAt.getTime())
|
||||
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())
|
||||
let maxEnded: Date | null = null
|
||||
for (const c of candidates) {
|
||||
for (const c of newMeetings) {
|
||||
const packQ = await ctx.client.Query(Queries.ChatCoop.GetTranscription.query, {
|
||||
variables: { data: { id: c.id } },
|
||||
})
|
||||
@@ -253,10 +381,28 @@ 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()
|
||||
}
|
||||
|
||||
@@ -4,11 +4,34 @@ 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 })
|
||||
}
|
||||
@@ -139,6 +162,21 @@ 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')
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// Публикация поля 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,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@coopenomics/boot",
|
||||
"type": "module",
|
||||
"version": "2026.5.10-4",
|
||||
"version": "2026.5.23-4",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
"description": "CLI-утилита инициализации блокчейна и кооператива",
|
||||
|
||||
@@ -21,15 +21,15 @@ docker compose stop node || true
|
||||
# Удаляем blockchain data
|
||||
echo "Удаляем blockchain data..."
|
||||
# sudo chmod -R 755 ../blockchain-data/ 2>/dev/null || true
|
||||
sudo rm -rf ../blockchain-data/
|
||||
docker run --rm -v "$(cd .. && pwd)/blockchain-data:/d" alpine sh -c 'rm -rf /d/* /d/.[!.]* 2>/dev/null || true'
|
||||
|
||||
# Пересоздаем и запускаем базы данных
|
||||
echo "Пересоздаем и запускаем базы данных..."
|
||||
docker compose up -d mongo postgres monoredis
|
||||
|
||||
# Ждем готовности MongoDB
|
||||
# Ждем готовности MongoDB (standalone, ping вместо ожидания PRIMARY).
|
||||
echo "Ждем готовности MongoDB..."
|
||||
until docker compose exec -T mongo mongosh --eval "db.adminCommand('ping')" --quiet > /dev/null 2>&1; do
|
||||
until docker compose exec -T mongo mongosh --quiet --eval "db.adminCommand({ping:1}).ok" > /dev/null 2>&1; do
|
||||
echo "MongoDB еще не готов, ждем..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
@@ -4,6 +4,8 @@ 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'
|
||||
@@ -156,13 +158,17 @@ export class CooperativeClass {
|
||||
|
||||
console.log('Отправляем подписанное положение о ЦПП Кошелька оператору')
|
||||
|
||||
await this.blockchain.sendAgreement({
|
||||
coopname: config.provider,
|
||||
administrator: config.provider,
|
||||
username: username!,
|
||||
agreement_type: 'wallet',
|
||||
// После Эпика 2 / компонента 48 soviet::sndagreement отказывается на
|
||||
// program_id > 0; программные соглашения подписываются через
|
||||
// wallet::signagree (auth: coopname@active).
|
||||
await signProgramAgreement(
|
||||
this.blockchain,
|
||||
config.provider,
|
||||
username!,
|
||||
walletProgramId,
|
||||
walletDraftId,
|
||||
document,
|
||||
})
|
||||
)
|
||||
|
||||
console.log('Переводим аккаунт в кооператив')
|
||||
|
||||
@@ -172,8 +178,8 @@ export class CooperativeClass {
|
||||
params: {
|
||||
is_cooperative: true,
|
||||
coop_type: 'conscoop',
|
||||
announce: 'Тестовый кооператив',
|
||||
description: 'Тестовое описание',
|
||||
announce: 'voskhod-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}`,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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'
|
||||
@@ -11,7 +15,9 @@ import { sleep } from '../utils'
|
||||
import { generateRandomSHA256 } from '../utils/randomHash'
|
||||
import { initUsersInPostgres, initVaultInPostgres } from '../postgres-init'
|
||||
import { CooperativeClass } from './cooperative'
|
||||
import { generateRandomSHA256 } from '../utils/randomHash'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
export async function startInfra() {
|
||||
// инициализируем инстанс с ключами
|
||||
@@ -572,7 +578,182 @@ export async function installInitialData(blockchain: Blockchain, isExtended = fa
|
||||
}
|
||||
|
||||
export async function installExtraData(blockchain: Blockchain) {
|
||||
// В расширенном режиме пайщики уже добавлены в installInitialData
|
||||
// Здесь можно добавить дополнительную логику инициализации если потребуется
|
||||
console.log('Дополнительная инициализация для расширенного режима выполнена')
|
||||
// Регистрируем 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',
|
||||
})
|
||||
|
||||
// Ресурсы для партнёра: powerup CPU/NET. transfer токенов пропускаем —
|
||||
// eosio.token::transfer падает на проверке membership в wallet program,
|
||||
// которая для partner1 не настроена. Для provider sync + Hostkey-flow
|
||||
// токены не нужны: tx от имени partner1 пойдут от soviet/admin.
|
||||
await blockchain.powerup({
|
||||
payer: 'eosio',
|
||||
receiver: account.username,
|
||||
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`)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
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 config 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
|
||||
@@ -157,13 +155,17 @@ export class ParticipantsClass {
|
||||
|
||||
console.log('Отправляем подписанное положение о ЦПП Кошелька оператору')
|
||||
|
||||
await this.blockchain.sendAgreement({
|
||||
coopname: config.provider,
|
||||
administrator: config.provider,
|
||||
username: username!,
|
||||
agreement_type: 'wallet',
|
||||
document: fakeDocument,
|
||||
})
|
||||
// После Эпика 2 / компонента 48 soviet::sndagreement отказывается на
|
||||
// program_id > 0; программные соглашения подписываются через
|
||||
// wallet::signagree (auth: coopname@active).
|
||||
await signProgramAgreement(
|
||||
this.blockchain,
|
||||
config.provider,
|
||||
username!,
|
||||
walletProgramId,
|
||||
walletDraftId,
|
||||
fakeDocument,
|
||||
)
|
||||
|
||||
console.log('создаём кошелёк')
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
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,15 @@
|
||||
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 = 'blagorost'
|
||||
|
||||
@@ -1,28 +1,20 @@
|
||||
import { expect } from 'vitest'
|
||||
import { signAgreement } from '../soviet/signAgreement'
|
||||
import { getCoopProgramWallet, getUserProgramWallet } from '../wallet/walletUtils'
|
||||
import { capitalProgramName, sourceProgramId } from './consts'
|
||||
import { signProgramAgreement } from '../wallet/signProgramAgreement'
|
||||
import { capitalDraftId, capitalProgramId } from './consts'
|
||||
|
||||
// После Эпика 2 / компонента 48 подпись соглашения ЦПП «Благорост»
|
||||
// (program_id=4) идёт через `wallet::signagree`, а не `soviet::sndagreement`.
|
||||
export async function signCapitalAgreement(
|
||||
blockchain: any,
|
||||
coopname: string,
|
||||
username: string,
|
||||
fakeDocument: any,
|
||||
) {
|
||||
const txId = await signAgreement(blockchain, coopname, username, capitalProgramName, fakeDocument)
|
||||
|
||||
const wallet = await getUserProgramWallet(blockchain, coopname, username, sourceProgramId)
|
||||
|
||||
expect(wallet).toEqual(expect.objectContaining({
|
||||
return signProgramAgreement(
|
||||
blockchain,
|
||||
coopname,
|
||||
username,
|
||||
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 }
|
||||
capitalProgramId,
|
||||
capitalDraftId,
|
||||
fakeDocument,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,28 +1,20 @@
|
||||
import { expect } from 'vitest'
|
||||
import { signAgreement } from '../soviet/signAgreement'
|
||||
import { getCoopProgramWallet, getUserProgramWallet } from '../wallet/walletUtils'
|
||||
import { sourceProgramId, sourceProgramName } from './consts'
|
||||
import { signProgramAgreement } from '../wallet/signProgramAgreement'
|
||||
import { sourceDraftId, sourceProgramId } from './consts'
|
||||
|
||||
// После Эпика 2 / компонента 48 подпись соглашения ЦПП «Генератор»
|
||||
// (program_id=3) идёт через `wallet::signagree`, а не `soviet::sndagreement`.
|
||||
export async function signGenerationContract(
|
||||
blockchain: any,
|
||||
coopname: string,
|
||||
username: string,
|
||||
fakeDocument: any,
|
||||
) {
|
||||
const txId = await signAgreement(blockchain, coopname, username, sourceProgramName, fakeDocument)
|
||||
|
||||
const wallet = await getUserProgramWallet(blockchain, coopname, username, sourceProgramId)
|
||||
|
||||
expect(wallet).toEqual(expect.objectContaining({
|
||||
return signProgramAgreement(
|
||||
blockchain,
|
||||
coopname,
|
||||
username,
|
||||
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 }
|
||||
sourceProgramId,
|
||||
sourceDraftId,
|
||||
fakeDocument,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Реэкспорт из 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,26 +1,20 @@
|
||||
import { expect } from 'vitest'
|
||||
import { signAgreement } from '../soviet/signAgreement'
|
||||
import { getCoopProgramWallet, getUserProgramWallet } from './walletUtils'
|
||||
import { walletDraftId, walletProgramId } from '../capital/consts'
|
||||
import { signProgramAgreement } from './signProgramAgreement'
|
||||
|
||||
// После Эпика 2 / компонента 48 подпись соглашения ЦПП «Цифровой Кошелёк»
|
||||
// (program_id=1) идёт через `wallet::signagree`, а не `soviet::sndagreement`.
|
||||
export async function signWalletAgreement(
|
||||
blockchain: any,
|
||||
coopname: string,
|
||||
username: string,
|
||||
fakeDocument: any,
|
||||
) {
|
||||
const txId = await signAgreement(blockchain, coopname, username, 'wallet', fakeDocument)
|
||||
|
||||
const wallet = await getUserProgramWallet(blockchain, coopname, username, 1)
|
||||
expect(wallet).toEqual(expect.objectContaining({
|
||||
return signProgramAgreement(
|
||||
blockchain,
|
||||
coopname,
|
||||
username,
|
||||
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 }
|
||||
walletProgramId,
|
||||
walletDraftId,
|
||||
fakeDocument,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/cleos",
|
||||
"version": "2026.5.10-4",
|
||||
"version": "2026.5.23-4",
|
||||
"private": true,
|
||||
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
soviet/soviet.abi
|
||||
soviet/soviet.wasm
|
||||
registrator/registrator.abi
|
||||
registrator/registrator.wasm
|
||||
cpp/**/*.wasm
|
||||
cpp/**/*.abi
|
||||
!cpp/tests/test_contracts/**/*.wasm
|
||||
!cpp/tests/test_contracts/**/*.abi
|
||||
networks.sh
|
||||
ano/ano.wasm
|
||||
ano/ano.abi
|
||||
Testing/
|
||||
docs/html/*
|
||||
docs/xml/*
|
||||
|
||||
-25
@@ -99,31 +99,6 @@ void capital::regcontrib(eosio::name coopname, eosio::name username, checksum256
|
||||
Capital::Contributors::create_contributor(coopname, username, contributor_hash, is_external_contract, contract_for_send, rate_per_hour, hours_per_day);
|
||||
}
|
||||
|
||||
// Открываем кошельки для пайщика если необходимо
|
||||
if (!has_program_wallet(coopname, username, _source_program)) {
|
||||
Action::send<openprogwall_interface>(
|
||||
_soviet,
|
||||
Names::External::OPEN_PROGRAM_WALLET,
|
||||
_capital,
|
||||
coopname,
|
||||
username,
|
||||
_source_program,
|
||||
uint64_t(0)
|
||||
);
|
||||
}
|
||||
|
||||
if (!has_program_wallet(coopname, username, _capital_program)) {
|
||||
Action::send<openprogwall_interface>(
|
||||
_soviet,
|
||||
Names::External::OPEN_PROGRAM_WALLET,
|
||||
_capital,
|
||||
coopname,
|
||||
username,
|
||||
_capital_program,
|
||||
uint64_t(0)
|
||||
);
|
||||
}
|
||||
|
||||
std::string memo = "";
|
||||
|
||||
if (is_external_contract) {
|
||||
|
||||
@@ -34,6 +34,10 @@ void capital::convertsegm(eosio::name coopname, eosio::name username,
|
||||
document2 convert_statement) {
|
||||
require_auth(coopname);
|
||||
|
||||
// Проверяем подпись заявления о трансляции паевого взноса (шаблон 1080).
|
||||
// Без этого on-chain принял бы любую сконструированную document2 без валидной подписи.
|
||||
verify_document_or_fail(convert_statement, {username});
|
||||
|
||||
// Получаем сегмент пайщика
|
||||
auto segment = Capital::Segments::get_segment_or_fail(coopname, project_hash, username,
|
||||
"Сегмент пайщика не найден");
|
||||
@@ -108,6 +112,23 @@ void capital::convertsegm(eosio::name coopname, eosio::name username,
|
||||
// Инкрементируем счётчик сконвертированных сегментов
|
||||
Capital::Projects::increment_converted_segments(coopname, current_project.id);
|
||||
|
||||
// Линкуем заявление о трансляции (1080) к пакету процесса p.cap.rid
|
||||
// (package = result_hash, ведущий документ пакета — заявление о внесении РИД, 1040).
|
||||
// Не make_complete_document: тот вытаскивает 1080 на верхний уровень реестра
|
||||
// как самостоятельный документ. Нужен newlink — тот же канон, что в signact1/signact2:
|
||||
// off-chain controller добавляет документ в группу пакета по action+package, не как top-level.
|
||||
// Делаем ДО delete_result, чтобы линковка прошла, пока result_hash ещё анкер процесса.
|
||||
Action::send<newlink_interface>(
|
||||
_soviet,
|
||||
"newlink"_n,
|
||||
_capital,
|
||||
coopname,
|
||||
username,
|
||||
Names::Capital::CONVERT_SEGMENT,
|
||||
result_hash,
|
||||
convert_statement
|
||||
);
|
||||
|
||||
// Удаляем сегмент после конвертации
|
||||
Capital::Segments::remove_segment(coopname, segment.id);
|
||||
|
||||
|
||||
@@ -48,14 +48,17 @@ 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` на каждом одобрении
|
||||
// мастером конкретного коммита, на дельту `available_for_program`.
|
||||
// «Собираем на 08 частями по мере накопления коммитов».
|
||||
// 1) COMMIT_RID (Dr 08 / Cr 80) — в `approvecmmt` на каждом одобрении
|
||||
// мастером конкретного коммита, на полный `commit.amounts.total_contribution`.
|
||||
// 2) ACCEPT_RID (Dr 04 / Cr 08) — здесь, на полный накопленный
|
||||
// `segment.available_for_program`. «Переносим с 08 на 04 когда РИД
|
||||
// собран и подписан акт-2».
|
||||
// Инвариант: Σ COMMIT_RID (по коммитам сегмента) == ACCEPT_RID → GENERATOR_FUND
|
||||
// (w.cap.gen) закрывается в ноль, 08-й счёт закрывается в ноль по этому сегменту.
|
||||
// Инвариант (программный уровень, НЕ сегмент): Σ COMMIT_RID по всем коммитам
|
||||
// программы == Σ ACCEPT_RID по всем сегментам — `wallets2[w.cap.gen]` (единый
|
||||
// кооперативный пул, COOPERATIVE) закрывается в ноль после конвертации всех
|
||||
// сегментов программы. На уровне отдельного сегмента инвариант НЕ выполняется
|
||||
// (CRPS перераспределяет доли между сегментами без compensating TRANSFER) —
|
||||
// именно поэтому L3-разрез по пайщику снят (см. wallets.hpp:103).
|
||||
if (segment.available_for_program.amount > 0) {
|
||||
Ledger2::apply(_capital, coopname, operations::capital::ACCEPT_RID, segment.available_for_program, result -> username, result_hash, memo);
|
||||
}
|
||||
|
||||
@@ -117,23 +117,21 @@ public:
|
||||
std::string memo);
|
||||
|
||||
/**
|
||||
* @brief Миграция остатков с legacy-ledger в ledger2 (курсорный режим).
|
||||
* @brief Универсальное миграционное действие — точка расширения для
|
||||
* разовых исправлений состояния, которые можно провести автоматически
|
||||
* после деплоя контракта.
|
||||
*
|
||||
* @param from_coop_index начальный индекс в таблице registrator::coops (0 — с начала)
|
||||
* @param limit максимум кооп. за один вызов (UINT64_MAX — до конца)
|
||||
* Тело периодически переписывается под текущую задачу миграции; после
|
||||
* прогона на проде очищается до пустого `require_auth(get_self())`.
|
||||
* История прошлых миграций — в git-истории `ledger2/src/migrate/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).
|
||||
* Текущая задача — см. doxygen-блок в migrate.cpp.
|
||||
*
|
||||
* @ingroup public_ledger2_actions
|
||||
*
|
||||
* @note Авторизация требуется от аккаунта: @p ledger2 (get_self()).
|
||||
*/
|
||||
[[eosio::action]] void migrate(uint64_t from_coop_index, uint64_t limit);
|
||||
[[eosio::action]] void migrate();
|
||||
|
||||
/**
|
||||
* @brief Идемпотентная per-record миграция L3-балансов (Phase 1/2; ADR-008).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @brief Атомарная операция по кошельку (issue/transfer/block/unblock/burn).
|
||||
* @brief Атомарная операция по кошельку (issue/transfer/block/unblock/burn/burn_blocked).
|
||||
*
|
||||
* Внутренний action ledger2 — вызывается только через inline из apply().
|
||||
* Auth: только сам ledger2 (require_auth(get_self())).
|
||||
@@ -56,7 +56,10 @@ void ledger2::walletop(eosio::name coopname,
|
||||
// op_code = 5 (NONE) намеренно не допускается: NONE-операции — это только
|
||||
// бухпроводка без кошелькового движения, apply.cpp не диспатчит для них walletop.
|
||||
// Прямой вызов с op_code=5 был бы no-op и сбил бы инвариант parity.
|
||||
eosio::check(op_code <= 4, "walletop: неизвестный op_code");
|
||||
// Допустимы 0..4 (ISSUE/TRANSFER/BLOCK/UNBLOCK/BURN) и 6 (BURN_BLOCKED).
|
||||
eosio::check(op_code <= static_cast<uint8_t>(WalletOp::BURN_BLOCKED) &&
|
||||
op_code != static_cast<uint8_t>(WalletOp::NONE),
|
||||
"walletop: неизвестный op_code");
|
||||
|
||||
wallets2_index wallets(get_self(), coopname.value);
|
||||
userwallets_index user_wallets(get_self(), coopname.value);
|
||||
@@ -160,32 +163,19 @@ void ledger2::walletop(eosio::name coopname,
|
||||
" для кошелька " + wallet_id.to_string());
|
||||
};
|
||||
|
||||
// ── Post-mutation assert Σ L3 == L2 (Story 3.2; NFR2) ─────────────────
|
||||
// Последняя линия защиты инварианта L3 ⊆ L2. Считается только для
|
||||
// затронутых USER_SHARED-кошельков. Стоимость O(N_users) на кошелёк;
|
||||
// падение здесь = откат всей tx (включая бух-проводки в debit/credit).
|
||||
auto assert_sum_l3_equals_l2 = [&](eosio::name wallet_id) {
|
||||
if (wallet_id.value == 0) return;
|
||||
if (ledger2_get_wallet_kind(wallet_id) != WalletKind::USER_SHARED) return;
|
||||
|
||||
auto wallet_it = wallets.find(wallet_id.value);
|
||||
int64_t l2_avail = wallet_it == wallets.end() ? 0 : wallet_it->available.amount;
|
||||
int64_t l2_block = wallet_it == wallets.end() ? 0 : wallet_it->blocked.amount;
|
||||
|
||||
int64_t sum_avail = 0, sum_block = 0;
|
||||
auto idx = user_wallets.get_index<"bywallet"_n>();
|
||||
for (auto it = idx.lower_bound(wallet_id.value);
|
||||
it != idx.end() && it->wallet_name == wallet_id;
|
||||
++it) {
|
||||
sum_avail += it->available.amount;
|
||||
sum_block += it->blocked.amount;
|
||||
}
|
||||
eosio::check(sum_avail == l2_avail && sum_block == l2_block,
|
||||
std::string{"walletop: инвариант Σ L3 == L2 нарушен на "} +
|
||||
wallet_id.to_string() +
|
||||
" (L2: " + std::to_string(l2_avail) + "/" + std::to_string(l2_block) +
|
||||
", Σ L3: " + std::to_string(sum_avail) + "/" + std::to_string(sum_block) + ")");
|
||||
};
|
||||
// ── Post-mutation Σ L3 == L2 (Story 3.2; NFR2) ────────────────────────
|
||||
// ВРЕМЕННО СНЯТО (2026-05-12): runtime-сверка Σ L3 == L2 через полный
|
||||
// sweep secondary-индекса bywallet — O(N_users) на кошелёк на каждой
|
||||
// walletop-операции; на боевых coop'ах с сотнями пайщиков это
|
||||
// непропорциональная нагрузка на CPU billing, и сейчас на тестнете она
|
||||
// блокирует пользовательские tx из-за исторических расхождений после
|
||||
// миграций 048/049.
|
||||
//
|
||||
// walletop по построению применяет одно и то же `amount` к L2 и L3 (см.
|
||||
// ниже case'ы ISSUE/TRANSFER/BLOCK/UNBLOCK/BURN/BURN_BLOCKED), а sender-guard на
|
||||
// строке 46-47 запрещает обход. Поэтому инвариант сохраняется по
|
||||
// конструкции; полную сверку выполняет бэкенд («стол бухгалтера»),
|
||||
// вне транзакционного hot path.
|
||||
|
||||
// Pre-flight cross-contract check (до мутаций — fail-fast).
|
||||
assert_program_signed(wallet_from);
|
||||
@@ -311,17 +301,42 @@ void ledger2::walletop(eosio::name coopname,
|
||||
user_wallets.modify(uw_pri, payer, [&](auto& r) { r.available -= amount; });
|
||||
}
|
||||
|
||||
cleanup_l2_if_empty(wallet_from);
|
||||
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;
|
||||
}
|
||||
case WalletOp::BURN_BLOCKED: {
|
||||
eosio::check(wallet_from.value != 0, "walletop BURN_BLOCKED: требуется wallet_from");
|
||||
eosio::check(wallet_to.value == 0, "walletop BURN_BLOCKED: wallet_to должен быть пустым");
|
||||
|
||||
auto it = wallets.find(wallet_from.value);
|
||||
eosio::check(it != wallets.end() && it->blocked >= amount,
|
||||
std::string{"walletop BURN_BLOCKED: недостаточно blocked на кошельке "} +
|
||||
wallet_from.to_string());
|
||||
wallets.modify(it, payer, [&](auto& w) { w.blocked -= amount; });
|
||||
|
||||
if (is_user_shared_l3(wallet_from)) {
|
||||
auto uw = find_l3(wallet_from);
|
||||
eosio::check(uw != user_wallets.get_index<"byuserwallet"_n>().end() &&
|
||||
uw->blocked >= amount,
|
||||
std::string{"walletop BURN_BLOCKED: недостаточно L3-blocked у пайщика "} +
|
||||
username.to_string() + " на " + wallet_from.to_string());
|
||||
auto uw_pri = user_wallets.find(uw->id);
|
||||
user_wallets.modify(uw_pri, payer, [&](auto& r) { r.blocked -= amount; });
|
||||
}
|
||||
|
||||
cleanup_l2_if_empty(wallet_from);
|
||||
cleanup_l3_if_empty(wallet_from);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Post-mutation: инвариант Σ L3 == L2 на каждом затронутом кошельке.
|
||||
// Проверяется только для USER_SHARED-кошельков с непустым username
|
||||
// (для COOPERATIVE и L2-only-mode инварианта L3⊆L2 нет).
|
||||
if (username.value != 0) {
|
||||
assert_sum_l3_equals_l2(wallet_from);
|
||||
assert_sum_l3_equals_l2(wallet_to);
|
||||
}
|
||||
// Post-mutation Σ L3 == L2: см. блок выше (строки 163-175). Снято
|
||||
// временно — сверка делается на бэкенде вне hot path.
|
||||
}
|
||||
|
||||
@@ -1,598 +1,57 @@
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* @brief Миграция остатков с legacy-ledger на ledger2 (пересмотр 2026-04-20).
|
||||
* @brief Универсальное миграционное действие контракта ledger2 — точка
|
||||
* расширения для разовых исправлений состояния, которые можно провести
|
||||
* автоматически после деплоя.
|
||||
*
|
||||
* Разделение на два независимых потока:
|
||||
* Содержимое периодически переписывается под текущую задачу миграции, а
|
||||
* после её прогона на проде тело очищается до пустого `require_auth(get_self())`
|
||||
* (как в `capital::migrate`).
|
||||
*
|
||||
* A. **Бухгалтерские остатки** из `ledger::accounts` (scope=coopname,
|
||||
* contract=_ledger) → через 4 inline `apply(operations::migration::*)`
|
||||
* с полной двойной проводкой. Счета: 51 / 80 / 86 / 04.
|
||||
* Текущая задача (2026-05-21): чистка осиротевших L3-записей `w.cap.gen`
|
||||
* на voskhod после смены WalletKind GENERATOR_FUND с USER_SHARED на
|
||||
* COOPERATIVE (см. `lib/core/ledger2/wallets.hpp`).
|
||||
*
|
||||
* 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).
|
||||
* Контекст: на voskhod `convertsegm` падал «недостаточно L3-средств у
|
||||
* пайщика» в проектах, где CRPS перераспределял доли между сегментами.
|
||||
* w.cap.gen был USER_SHARED, а CRPS в approvecmmt не делал per-user
|
||||
* компенсирующих TRANSFER между сегментами: `Σ COMMIT_RID == Σ ACCEPT_RID`
|
||||
* соблюдался только на проекте, не на пайщике.
|
||||
*
|
||||
* Алгоритм на каждый кооператив:
|
||||
* Архитектурный фикс: w.cap.gen — COOPERATIVE-пул без L3. Чтобы UI/бэкенд
|
||||
* не врали остатками по «личным» w.cap.gen, удаляем осиротевшие L3-записи
|
||||
* прямым `userwallets.erase`. L2-баланс `wallets2[w.cap.gen]` уже верен
|
||||
* (синхронен с Σ старых userwallets), его не трогаем.
|
||||
*
|
||||
* 1. Чтение legacy-счетов (laccounts_index, scope=coopname):
|
||||
* cash_legacy = account[51].available + .blocked
|
||||
* share_legacy = account[80].available + .blocked
|
||||
* entry_legacy = account[861].available + .blocked
|
||||
*
|
||||
* 2. Чтение параметров кооператива (cooperative2, scope=_registrator):
|
||||
* coop.minimum — минимальный паевой для individual / entrepreneur
|
||||
* coop.org_minimum — минимальный паевой для organization (binary_extension)
|
||||
*
|
||||
* 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-сборке хардкод по фактам сохраняется.
|
||||
* Идемпотентно: повторный вызов на чистой БД — no-op (lower_bound пуст).
|
||||
* Только voskhod: на остальных кооперативах w.cap.gen не использовался.
|
||||
*
|
||||
* @ingroup public_ledger2_actions
|
||||
*/
|
||||
|
||||
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 в проде нулевые. Ненулевое — сигнал.
|
||||
eosio::check(false,
|
||||
std::string{"migrate: неожиданный ненулевой legacy acc id="} +
|
||||
std::to_string(acc_it->id));
|
||||
}
|
||||
}
|
||||
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.
|
||||
* @note Авторизация требуется от аккаунта: @p ledger2 (get_self()).
|
||||
*/
|
||||
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, А) = 176 800 — банк (хардкод 145 000) + 31 800 минП │
|
||||
* │ 04 (INTANGIBLE_ASSETS, А) = 62 353 311 — имущ. Благорост 56 903 311 + preimp 5М4 │
|
||||
* │ 08 (NON_CURRENT_INVESTMENTS) = 543 400 — балансировка (свод НМА → 80) │
|
||||
* │ 80 (SHARE_FUND, П) = 62 946 011 — 419 900 ЦК + 57 044 311 Благорост │
|
||||
* │ + 31 800 минП + 5 450 000 preimp Cr-side │
|
||||
* │ 86 (TARGET_RECEIPTS, П) = 127 500 — хоз.расходы 115К + legacy 861 12 500 │
|
||||
* │ │
|
||||
* │ Σ Dr = 176 800 + 62 353 311 + 543 400 = 63 073 511 │
|
||||
* │ Σ Cr = 62 946 011 + 127 500 = 63 073 511 ✓ │
|
||||
* └─────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* ┌────────────────────────────── wallets2 (L2-агрегаты) ─────────────────────────────┐
|
||||
* │ w.reg.minshr available = 31 800 (34 пайщика; minimum_amount; L3 → migrator-048)│
|
||||
* │ w.wal.share available = 419 900 (5 пайщиков ЦК-остаток; L3 → migrator-049) │
|
||||
* │ w.cap.blago blocked = 57 044 311 (12 пайщиков pid=4; L3 → migrator-049) │
|
||||
* │ w.cap.preimp available = 5 450 000 (5 пайщиков; L3 — ниже, прямой emplace) │
|
||||
* │ w.sov.expns available = 127 500 (хоз.расходы из числа целевого; COOPERATIVE) │
|
||||
* └────────────────────────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* L3 (userwallets) для USER_SHARED-кошельков заводит migrator (Phase 1 = 048
|
||||
* для w.reg.minshr; Phase 2 = 049 для w.wal.share / w.wal.member / w.cap.blago).
|
||||
* Здесь же — только 5 пред-импорт-преимп-записей, выпавших из progwallets
|
||||
* после ручных subbal'ов (см. ~/cleos.md, 5 пайщиков с РИД-взносами по
|
||||
* договорам УХД, не успевшим попасть в электронный учёт до миграции).
|
||||
*/
|
||||
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'768'000'000LL, sym)); // 176 800.0000 RUB
|
||||
emplace_account_balance(self_name, coopname, ledger2_accounts::INTANGIBLE_ASSETS,
|
||||
eosio::asset( 623'533'110'000LL, sym)); // 62 353 311.0000 RUB
|
||||
emplace_account_balance(self_name, coopname, ledger2_accounts::NON_CURRENT_INVESTMENTS,
|
||||
eosio::asset( 5'434'000'000LL, sym)); // 543 400.0000 RUB
|
||||
emplace_account_balance(self_name, coopname, ledger2_accounts::SHARE_FUND,
|
||||
eosio::asset( 629'460'110'000LL, sym)); // 62 946 011.0000 RUB
|
||||
emplace_account_balance(self_name, coopname, ledger2_accounts::TARGET_RECEIPTS,
|
||||
eosio::asset( 1'275'000'000LL, sym)); // 127 500.0000 RUB
|
||||
|
||||
// ───────── wallets2 (L2) ─────────
|
||||
emplace_wallet_balance(self_name, coopname, ledger2_wallets::MIN_SHARE_FUND,
|
||||
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) {
|
||||
// Деньги на паевой фонд: всё на 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);
|
||||
}
|
||||
|
||||
// ----- 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) {
|
||||
void ledger2::migrate() {
|
||||
require_auth(get_self());
|
||||
|
||||
ledger2_meta_index meta_tbl(get_self(), get_self().value);
|
||||
auto meta_it = meta_tbl.find(0);
|
||||
const eosio::name target_coop = "voskhod"_n;
|
||||
|
||||
// Полный прогон уже завершён — тихий no-op (Decision #D4 / Story 1.15 AC5).
|
||||
if (meta_it != meta_tbl.end() && meta_it->migrated) {
|
||||
return;
|
||||
userwallets_index user_wallets(get_self(), target_coop.value);
|
||||
auto idx = user_wallets.get_index<"bywallet"_n>();
|
||||
|
||||
// Собираем primary id записей до erase (модификация контейнера при
|
||||
// итерации через secondary index — небезопасна).
|
||||
std::vector<uint64_t> ids_to_erase;
|
||||
for (auto it = idx.lower_bound(ledger2_wallets::GENERATOR_FUND.value);
|
||||
it != idx.end() && it->wallet_name == ledger2_wallets::GENERATOR_FUND;
|
||||
++it) {
|
||||
ids_to_erase.push_back(it->id);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
uint64_t idx = 0;
|
||||
uint64_t done = 0;
|
||||
bool reached_end = true;
|
||||
|
||||
for (auto it = coops.begin(); it != coops.end(); ++it, ++idx) {
|
||||
if (idx < from_coop_index) continue;
|
||||
if (done >= limit) { reached_end = false; break; }
|
||||
|
||||
// Мигрируем только кооперативы (не отделения и не физ. записи).
|
||||
if (it->is_cooperative) {
|
||||
migrate_one_coop(get_self(), *it);
|
||||
for (uint64_t id : ids_to_erase) {
|
||||
auto pri = user_wallets.find(id);
|
||||
if (pri != user_wallets.end()) {
|
||||
user_wallets.erase(pri);
|
||||
}
|
||||
++done;
|
||||
}
|
||||
|
||||
if (done == 0 && from_coop_index == 0 && coops.begin() == coops.end()) {
|
||||
eosio::check(false, "migrate: таблица cooperatives пуста — повторите после регистрации");
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
*
|
||||
* Auto-delete: если переданы (available=0, blocked=0) — запись удаляется.
|
||||
*
|
||||
* Auth: `coopname@active`. Payer: `coopname` (NFR11 — RAM на коопе).
|
||||
* Auth: `ledger2@active` (через `get_self()`), payer: `ledger2`. Так миграция
|
||||
* работает для ЛЮБОГО кооператива без ключей от coopname — у нас по контракту
|
||||
* есть только своя подпись. RAM, расход на которую — это сам контракт.
|
||||
*
|
||||
* @ingroup public_ledger2_actions
|
||||
*/
|
||||
@@ -26,7 +28,7 @@ void ledger2::migrate3(eosio::name coopname,
|
||||
eosio::name username,
|
||||
eosio::asset available,
|
||||
eosio::asset blocked) {
|
||||
require_auth(coopname);
|
||||
require_auth(get_self());
|
||||
|
||||
eosio::check(coopname.value != 0, "migrate3: coopname пустой");
|
||||
get_cooperative_or_fail(coopname);
|
||||
@@ -34,6 +36,15 @@ void ledger2::migrate3(eosio::name coopname,
|
||||
eosio::check(wallet_name.value != 0, "migrate3: wallet_name пустой");
|
||||
eosio::check(ledger2_is_known_wallet(wallet_name),
|
||||
std::string{"migrate3: неизвестный wallet_name "} + wallet_name.to_string());
|
||||
|
||||
#ifndef IS_TESTNET
|
||||
// voskhod: мин.паевые перенесены на w.sov.mnused (COOPERATIVE) при миграции —
|
||||
// см. migrate_voskhod_facts. L3 для w.reg.minshr на voskhod не создаётся,
|
||||
// иначе сломается инвариант Σ L3 == L2 (L2 пуст, а 048 написал бы 34 L3-записи).
|
||||
eosio::check(!(coopname == "voskhod"_n && wallet_name == ledger2_wallets::MIN_SHARE_FUND),
|
||||
"migrate3: voskhod не использует w.reg.minshr — мин.паевые на w.sov.mnused "
|
||||
"(см. migrate_voskhod_facts); migrator-048 должен пропускать voskhod");
|
||||
#endif
|
||||
eosio::check(ledger2_get_wallet_kind(wallet_name) == WalletKind::USER_SHARED,
|
||||
std::string{"migrate3: wallet_name "} + wallet_name.to_string() +
|
||||
" не USER_SHARED — L3-запись не имеет смысла");
|
||||
@@ -57,7 +68,7 @@ void ledger2::migrate3(eosio::name coopname,
|
||||
if (it == idx.end()) {
|
||||
if (is_zero) return; // нечего создавать; безопасный no-op для повторов
|
||||
const uint64_t new_id = user_wallets.available_primary_key();
|
||||
user_wallets.emplace(coopname, [&](auto& uw) {
|
||||
user_wallets.emplace(get_self(), [&](auto& uw) {
|
||||
uw.id = new_id;
|
||||
uw.wallet_name = wallet_name;
|
||||
uw.username = username;
|
||||
@@ -69,7 +80,7 @@ void ledger2::migrate3(eosio::name coopname,
|
||||
idx.erase(it);
|
||||
} else {
|
||||
auto pri = user_wallets.find(it->id);
|
||||
user_wallets.modify(pri, coopname, [&](auto& uw) {
|
||||
user_wallets.modify(pri, get_self(), [&](auto& uw) {
|
||||
uw.available = available;
|
||||
uw.blocked = blocked;
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ namespace operations {
|
||||
// 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, TRANSFER SHARE_FUND_PAY → WITHDRAWALS_SINK).
|
||||
inline constexpr eosio::name COMPLETE_WITHDRAW = "o.wal.wthcpl"_n; ///< Завершение возврата паевого взноса (Dr 80 / Cr 51, BURN_BLOCKED SHARE_FUND_PAY — деньги уходят из системы, без wallet_to).
|
||||
inline constexpr eosio::name REQUEST_WITHDRAW = "o.wal.wthreq"_n; ///< Запрос на возврат паевого: BLOCK на SHARE_FUND_PAY (без Dr/Cr).
|
||||
inline constexpr eosio::name DECLINE_WITHDRAW = "o.wal.wthdec"_n; ///< Отклонение запроса на возврат: UNBLOCK на SHARE_FUND_PAY (без Dr/Cr).
|
||||
}
|
||||
@@ -130,12 +130,13 @@ namespace operations {
|
||||
* Compile-time правило `(debit==0) ⇔ (credit==0)` ловит смешанные пары.
|
||||
*/
|
||||
enum class WalletOp : uint8_t {
|
||||
ISSUE = 0, ///< первичный вход средств на кошелёк wallet_to (wallet_from = empty)
|
||||
TRANSFER = 1, ///< перемещение wallet_from → wallet_to (с Dr/Cr ИЛИ без — по парам account_id)
|
||||
BLOCK = 2, ///< available-=amount, blocked+=amount на wallet_from
|
||||
UNBLOCK = 3, ///< blocked-=amount, available+=amount на wallet_from
|
||||
BURN = 4, ///< изъятие amount с wallet_from, без 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->available, без wallet_to. Покрывает оба кейса: (a) штатное сжигание как бизнес-операция в OPERATION_REGISTRY; (b) зеркало ISSUE при `ledger2::revert` (различие — через operation_code: `o.adj.rev` для adjustment-mirror).
|
||||
NONE = 5, ///< только бухпроводка без перемещения средств (wallet_from = empty, wallet_to = empty, debit ≠ 0, credit ≠ 0). Покрывает кейсы внутрибалансовых проводок типа Dr 04 / Cr 08 (приём РИД в НМА), когда кошелёк уже на нужном программном фонде.
|
||||
BURN_BLOCKED = 6, ///< изъятие amount из wallet_from->blocked, без wallet_to. Используется при завершении возврата паевого (`o.wal.wthcpl`): средства предварительно заблокированы через BLOCK (REQUEST_WITHDRAW), на завершении сжигаются — выходят из системы (получателя на цепи нет). На L3 синхронно: from_uw->blocked -= amount.
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -144,7 +145,7 @@ enum class WalletOp : uint8_t {
|
||||
* Семантика полей по `wallet_op`:
|
||||
* - ISSUE: wallet_from = eosio::name{}, wallet_to = required.
|
||||
* - TRANSFER: wallet_from = required, wallet_to = required (≠ from).
|
||||
* - BLOCK / UNBLOCK / BURN: wallet_from = required, wallet_to = eosio::name{}.
|
||||
* - BLOCK / UNBLOCK / BURN / BURN_BLOCKED: wallet_from = required, wallet_to = eosio::name{}.
|
||||
*
|
||||
* Семантика бух.проводки:
|
||||
* - Без проводок: debit_account_id == 0 И credit_account_id == 0.
|
||||
@@ -156,7 +157,7 @@ struct OperationRegistryEntry {
|
||||
eosio::name process_type; ///< тип процесса с префиксом `p.<contract>.<noun>`
|
||||
WalletOp wallet_op;
|
||||
eosio::name wallet_from; ///< пустое имя для ISSUE
|
||||
eosio::name wallet_to; ///< пустое имя для BLOCK/UNBLOCK/BURN
|
||||
eosio::name wallet_to; ///< пустое имя для BLOCK/UNBLOCK/BURN/BURN_BLOCKED
|
||||
uint64_t debit_account_id; ///< 0 если без бухпроводки (тогда credit_account_id тоже == 0)
|
||||
uint64_t credit_account_id; ///< 0 если без бухпроводки (тогда debit_account_id тоже == 0)
|
||||
const char* human_name;
|
||||
@@ -183,9 +184,12 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
|
||||
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::SHARE_FUND,
|
||||
"Внесение пайщиком паевого взноса" },
|
||||
|
||||
// 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,
|
||||
// 4. Возврат паевого взноса: Dr 80 / Cr 51, BURN_BLOCKED SHARE_FUND_PAY.
|
||||
// Сжигание из заблокированной суммы пайщика (BLOCK был на REQUEST_WITHDRAW):
|
||||
// деньги уходят из системы (банковский перевод пайщику), получателя на цепи нет.
|
||||
// Бухгалтерия: паевой фонд уменьшается (Дт 80), расчётный счёт уменьшается (Кт 51).
|
||||
{ operations::wallet::COMPLETE_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::BURN_BLOCKED,
|
||||
ledger2_wallets::SHARE_FUND_PAY, eosio::name{},
|
||||
ledger2_accounts::SHARE_FUND, ledger2_accounts::BANK_ACCOUNT,
|
||||
"Возврат паевого взноса пайщику" },
|
||||
|
||||
@@ -336,7 +340,7 @@ static constexpr size_t OPERATION_REGISTRY_SIZE = sizeof(OPERATION_REGISTRY) / s
|
||||
// оба существуют в `LEDGER2_ACCOUNT_MAP`.
|
||||
// 4. Для TRANSFER: `wallet_from` ≠ `wallet_to`, оба ≠ 0.
|
||||
// 5. Для ISSUE: `wallet_from` == 0 и `wallet_to` ≠ 0.
|
||||
// 6. Для BLOCK / UNBLOCK / BURN: `wallet_from` ≠ 0, `wallet_to` == 0.
|
||||
// 6. Для BLOCK / UNBLOCK / BURN / BURN_BLOCKED: `wallet_from` ≠ 0, `wallet_to` == 0.
|
||||
// 7. Все id кошельков из записей существуют в `LEDGER2_WALLET_REGISTRY`.
|
||||
namespace ledger2_registry_detail {
|
||||
constexpr bool operation_codes_unique() {
|
||||
@@ -380,11 +384,11 @@ namespace ledger2_registry_detail {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Правило 6: BURN — wallet_from required, wallet_to == 0 (ADR-003).
|
||||
// Правило 6: BURN / BURN_BLOCKED — wallet_from required, wallet_to == 0 (ADR-003).
|
||||
constexpr bool burn_pattern_correct() {
|
||||
for (size_t i = 0; i < OPERATION_REGISTRY_SIZE; ++i) {
|
||||
const auto& e = OPERATION_REGISTRY[i];
|
||||
if (e.wallet_op != WalletOp::BURN) continue;
|
||||
if (e.wallet_op != WalletOp::BURN && e.wallet_op != WalletOp::BURN_BLOCKED) continue;
|
||||
if (e.wallet_from.value == 0) return false;
|
||||
if (e.wallet_to.value != 0) return false;
|
||||
}
|
||||
@@ -436,7 +440,7 @@ static_assert(ledger2_registry_detail::dr_ne_cr_when_posting(),
|
||||
static_assert(ledger2_registry_detail::transfer_wallet_from_ne_to(),
|
||||
"OPERATION_REGISTRY: TRANSFER с wallet_from == wallet_to или одним из них == 0");
|
||||
static_assert(ledger2_registry_detail::burn_pattern_correct(),
|
||||
"OPERATION_REGISTRY: BURN требует wallet_from ≠ 0 и wallet_to == 0");
|
||||
"OPERATION_REGISTRY: BURN / BURN_BLOCKED требует wallet_from ≠ 0 и wallet_to == 0");
|
||||
static_assert(ledger2_registry_detail::none_pattern_correct(),
|
||||
"OPERATION_REGISTRY: NONE требует wallet_from == 0, wallet_to == 0 и обе проводки заполненными");
|
||||
static_assert(ledger2_registry_detail::accounts_exist_in_map(),
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* Группы:
|
||||
* w.wal.* — Паевой фонд (журнал Cr 80) и возвраты пайщикам
|
||||
* w.reg.* — Регистрация (минимальный паевой, вступительные)
|
||||
* w.sov.* — Целевое финансирование (членские, делегатские)
|
||||
* w.sov.* — Совет-level фонды (целевое финансирование, использованные паевые)
|
||||
* w.cap.* — Программы паевого фонда (Благорост, Генератор) и займы
|
||||
* w.mkt.* — Маркетплейс (выплаты поставщикам)
|
||||
*
|
||||
@@ -46,21 +46,22 @@ 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; ///< Возвраты паевых взносов пайщикам (sink TRANSFER, COOPERATIVE)
|
||||
static constexpr eosio::name WITHDRAWALS_SINK = "w.wal.wthdrw"_n; ///< DEPRECATED 2026-05-21: после переключения o.wal.wthcpl с TRANSFER на BURN_BLOCKED кошелёк больше не получает новых средств. Оставлен в реестре для исторических L2-балансов (накопленные возвраты до перехода). Не использовать в новых операциях.
|
||||
|
||||
// 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)
|
||||
|
||||
// soviet — членские (инфраструктура) + делегатские + хоз.расходы
|
||||
// soviet — членские (инфраструктура) + делегатские + хоз.расходы + использованные паевые
|
||||
static constexpr eosio::name INFRA_FEES = "w.sov.infra"_n; ///< Членские взносы за инфраструктуру кооп. платформы (COOPERATIVE)
|
||||
static constexpr eosio::name DELEGATE_FEES = "w.sov.delgte"_n; ///< Делегатские членские взносы (цель CONVERT_TO_AXN, COOPERATIVE)
|
||||
static constexpr eosio::name SOV_EXPENSES = "w.sov.expns"_n; ///< Хозяйственные расходы из числа целевого финансирования (COOPERATIVE)
|
||||
static constexpr eosio::name MIN_SHARE_USED = "w.sov.mnused"_n; ///< Использованные минимальные паевые взносы (Cr 80 source, перешедшие в 08; COOPERATIVE)
|
||||
|
||||
// capital — единые программные кошельки + займы + пред-импорт
|
||||
static constexpr eosio::name LOAN_ISSUED = "w.cap.loan"_n; ///< Выданные пайщикам беспроцентные займы (COOPERATIVE; Dr 58 / Cr 51)
|
||||
static constexpr eosio::name BLAGOROST_FUND = "w.cap.blago"_n; ///< Благорост — единый агрегированный кошелёк программы (USER_SHARED; ADR-009)
|
||||
static constexpr eosio::name GENERATOR_FUND = "w.cap.gen"_n; ///< Генератор — единый агрегированный кошелёк программы (USER_SHARED; ADR-009)
|
||||
static constexpr eosio::name GENERATOR_FUND = "w.cap.gen"_n; ///< Генератор — единый агрегированный кошелёк программы (COOPERATIVE — кооперативный пул, без L3-разреза по пайщику; L3-разрез из ADR-009 отменён из-за несовместимости с CRPS-перераспределением, см. wallets.hpp:107)
|
||||
static constexpr eosio::name PREIMP_FUND = "w.cap.preimp"_n; ///< Первичный учёт РИД-взносов до перехода на электронный учёт (USER_SHARED; o.cap.preimp / o.cap.drppre)
|
||||
|
||||
// marketplace — выплаты
|
||||
@@ -93,21 +94,26 @@ struct Ledger2WalletMeta {
|
||||
WalletKind kind;
|
||||
};
|
||||
|
||||
inline constexpr std::array<Ledger2WalletMeta, 13> LEDGER2_WALLET_REGISTRY = {{
|
||||
// USER_SHARED (6) — L3-разрез по пайщику
|
||||
inline constexpr std::array<Ledger2WalletMeta, 14> LEDGER2_WALLET_REGISTRY = {{
|
||||
// USER_SHARED (5) — L3-разрез по пайщику
|
||||
{ ledger2_wallets::MIN_SHARE_FUND, "Минимальный паевой взнос", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::SHARE_FUND_PAY, "Паевой взнос пайщика", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::CK_MEMBER, "ЦК — членская часть пайщика", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::BLAGOROST_FUND, "ЦПП «Благорост» — единый кошелёк программы у пайщика", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::GENERATOR_FUND, "ЦПП «Генератор» — единый кошелёк программы у пайщика", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::PREIMP_FUND, "Первичный учёт РИД-взносов до перехода на электронный учёт", WalletKind::USER_SHARED },
|
||||
|
||||
// COOPERATIVE (7) — единый кооперативный баланс, без L3
|
||||
// COOPERATIVE (9) — единый кооперативный баланс, без L3
|
||||
// GENERATOR_FUND переведён сюда из USER_SHARED (см. wallets.hpp:64) —
|
||||
// CRPS-распределение между сегментами проекта не поддерживает per-user
|
||||
// компенсирующие TRANSFER на approvecmmt, поэтому L3-проверка walletop
|
||||
// ломала convertsegm у пайщиков, чья доля выросла через CRPS.
|
||||
{ ledger2_wallets::GENERATOR_FUND, "ЦПП «Генератор» — единый кошелёк программы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::ENTRANCE_FEES, "Вступительные взносы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::WITHDRAWALS_SINK, "Возвраты паевых взносов пайщикам", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::WITHDRAWALS_SINK, "Возвраты паевых взносов пайщикам (deprecated, не используется в новых операциях)", 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 },
|
||||
}};
|
||||
|
||||
@@ -124,5 +124,6 @@ namespace Names {
|
||||
constexpr eosio::name CREATE_WITHDRAW_2 = "createwthd2"_n; // акцепт возврата из проекта
|
||||
constexpr eosio::name CREATE_WITHDRAW_3 = "createwthd3"_n; // акцепт возврата из программы
|
||||
constexpr eosio::name CREATE_RESULT = "createresult"_n; // акцепт результата
|
||||
constexpr eosio::name CONVERT_SEGMENT = "convertsegm"_n; // финальная фаза p.cap.rid — трансляция паевого взноса
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
# ↓
|
||||
# authorized=true → exec → callback в исходный контракт (confirmreg /
|
||||
# authmeet / approvereg / authpgprp / authrslt
|
||||
# / authcontrib / approvewthd / ... — конкретный
|
||||
# / authcontrib / authwthd / ... — конкретный
|
||||
# handler заранее зашит в decision.type).
|
||||
#
|
||||
# Документ повестки приходит из контракта-инициатора (без registry_id —
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -1,59 +0,0 @@
|
||||
{
|
||||
"____comment": "This file was generated with eosio-abigen. DO NOT EDIT ",
|
||||
"version": "eosio::abi/1.2",
|
||||
"types": [],
|
||||
"structs": [
|
||||
{
|
||||
"name": "counts",
|
||||
"base": "",
|
||||
"fields": [
|
||||
{
|
||||
"name": "key",
|
||||
"type": "name"
|
||||
},
|
||||
{
|
||||
"name": "secondary_key",
|
||||
"type": "name"
|
||||
},
|
||||
{
|
||||
"name": "value",
|
||||
"type": "uint64"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "hello",
|
||||
"base": "",
|
||||
"fields": [
|
||||
{
|
||||
"name": "message",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"name": "hello",
|
||||
"type": "hello",
|
||||
"ricardian_contract": "---\nspec_version: \"0.2.0\"\ntitle: Тестовое действие\nsummary: Контракт вызывает тестовое действие\nvars: \n - name: message\n label: Сообщение\n hint: Введите сообщение\n\n---\n\nСообщение: {{message}}\n\nВыражаю своё согласие с тем, что информация, отправляемая Обществом в sms-сообщениях на указанный мной номер телефона или в сообщениях на указанный мной адрес электронной почты или в сообщениях телеграм-бота, привязанного к моему телефону, приравняется к уведомлению меня Обществом в письменной форме."
|
||||
}
|
||||
],
|
||||
"tables": [
|
||||
{
|
||||
"name": "counts",
|
||||
"type": "counts",
|
||||
"index_type": "i64",
|
||||
"key_names": [],
|
||||
"key_types": []
|
||||
}
|
||||
],
|
||||
"ricardian_clauses": [
|
||||
{
|
||||
"id": "UserAgreement",
|
||||
"body": "User agreement for the chain can go here."
|
||||
}
|
||||
],
|
||||
"variants": [],
|
||||
"action_results": []
|
||||
}
|
||||
Binary file not shown.
@@ -2,13 +2,15 @@
|
||||
# Стандарт «Возврат паевого взноса» — деньгами, через целевую потребительскую
|
||||
# программу «Цифровой Кошелёк» (SHARE_FUND_PAY).
|
||||
#
|
||||
# Пятиактовый процесс с авторизацией советом. Запись заявки живёт в таблице
|
||||
# `withdraws` от создания до выплаты или отказа. На закрывающем действии
|
||||
# completewthd срабатывает одна операция o.wal.wthcpl (TRANSFER w.wal.share → w.wal.wthdrw).
|
||||
# Четырёхактовый процесс с одной авторизацией советом. Запись заявки живёт в
|
||||
# таблице `withdraws` от создания до выплаты или отказа. На закрывающем
|
||||
# действии completewthd срабатывает одна операция o.wal.wthcpl — сжигание
|
||||
# заблокированной суммы (BURN_BLOCKED w.wal.share): деньги уходят из системы
|
||||
# пайщику банковским переводом, получателя на цепи нет.
|
||||
#
|
||||
# Источники правды в коде:
|
||||
# • cpp/wallet/wallet.hpp — actions
|
||||
# • cpp/wallet/src/withdraw/{createwthd,authwthd,approvewthd,completewthd,declinewthd}.cpp
|
||||
# • cpp/wallet/src/withdraw/{createwthd,authwthd,completewthd,declinewthd}.cpp
|
||||
# • cpp/lib/domain/table_wallet_withdraws.hpp — entity withdraws
|
||||
# • cpp/lib/core/ledger2/operations.hpp — o.wal.wthcpl
|
||||
# • cpp/lib/core/ledger2/processes.hpp — processes::wallet::WITHDRAW
|
||||
@@ -22,8 +24,8 @@ slug: withdraw
|
||||
status: proposed
|
||||
contract: wallet
|
||||
summary: >
|
||||
Пайщик получает обратно ранее внесённый паевой взнос деньгами. Процесс
|
||||
авторизуется советом и закрывается выплатой через Gateway.
|
||||
Пайщик получает обратно ранее внесённый паевой взнос деньгами. Заявку
|
||||
авторизует совет, кассир подтверждает выплату через Gateway.
|
||||
purpose: >
|
||||
«Возврат паевого взноса» — пайщик получает обратно ранее внесённые
|
||||
деньги. Заявку рассматривает совет, после авторизации выплата
|
||||
@@ -43,21 +45,14 @@ actions:
|
||||
purpose: >
|
||||
Пайщик создаёт заявку на возврат паевого взноса. Контракт блокирует
|
||||
сумму на кошельке SHARE_FUND_PAY (статус available → blocked) и
|
||||
создаёт повестку в совете о возврате.
|
||||
- name: wallet::approvewthd
|
||||
human: Одобрить решением совета
|
||||
actor: soviet
|
||||
role: progress
|
||||
purpose: >
|
||||
Совет добавляет к заявке документ-решение об одобрении возврата.
|
||||
Заявка получает подписанное решение, но платёж ещё не отправлен.
|
||||
создаёт повестку в совете о возврате с callback'ом авторизации.
|
||||
- name: wallet::authwthd
|
||||
human: Авторизовать выплату
|
||||
actor: soviet
|
||||
role: progress
|
||||
purpose: >
|
||||
Совет авторизует выплату: контракт меняет статус заявки на
|
||||
`authorized` и отправляет в Gateway исходящий платёж с
|
||||
Совет авторизует выплату единым решением: контракт переводит заявку
|
||||
в статус `authorized` и отправляет в Gateway исходящий платёж с
|
||||
callback'ами completewthd / declinewthd.
|
||||
- name: wallet::completewthd
|
||||
human: Подтвердить выплату
|
||||
@@ -65,16 +60,16 @@ actions:
|
||||
role: closer
|
||||
purpose: >
|
||||
Gateway подтвердил списание средств в пользу пайщика. Это закрывающее
|
||||
действие: применяется операция o.wal.wthcpl (TRANSFER w.wal.share → w.wal.wthdrw),
|
||||
разблокированная сумма уходит со счёта 80 на счёт 51, запись заявки
|
||||
удаляется.
|
||||
действие: применяется операция o.wal.wthcpl (BURN_BLOCKED w.wal.share)
|
||||
— заблокированная сумма сжигается, в бухгалтерии проходит обратная
|
||||
проводка Дт 80 / Кт 51. Запись заявки удаляется.
|
||||
- name: wallet::declinewthd
|
||||
human: Отклонить
|
||||
actor: soviet
|
||||
role: reject
|
||||
purpose: >
|
||||
Отказ на любом из этапов до выплаты. Сумма разблокируется на кошельке
|
||||
пайщика, запись заявки удаляется, операция в ledger2 не создаётся.
|
||||
пайщика (UNBLOCK через o.wal.wthdec), запись заявки удаляется.
|
||||
|
||||
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
|
||||
entity: wallet::withdraw
|
||||
@@ -88,12 +83,6 @@ states:
|
||||
Заявка создана, сумма заблокирована на кошельке пайщика (available → blocked),
|
||||
повестка отправлена в совет.
|
||||
kind: normal
|
||||
- name: approved
|
||||
human: Решение совета подписано
|
||||
description: >
|
||||
Совет добавил к заявке документ с положительным решением, но
|
||||
исходящий платёж в Gateway ещё не отправлен.
|
||||
kind: normal
|
||||
- name: authorized
|
||||
human: Выплата отправлена
|
||||
description: >
|
||||
@@ -103,15 +92,15 @@ states:
|
||||
- name: completed
|
||||
human: Возврат выплачен
|
||||
description: >
|
||||
Gateway подтвердил списание, средства переведены с кошелька пайщика
|
||||
на системный кошелёк выплат (w.wal.share → w.wal.wthdrw), в бухгалтерии прошла
|
||||
обратная проводка Дт 80 / Кт 51. Запись заявки удалена.
|
||||
Gateway подтвердил списание, заблокированная сумма сжигается на кошельке
|
||||
пайщика (BURN_BLOCKED w.wal.share). В бухгалтерии прошла обратная
|
||||
проводка Дт 80 / Кт 51. Запись заявки удалена.
|
||||
kind: final
|
||||
- name: removed
|
||||
human: Отклонено
|
||||
description: >
|
||||
Возврат не состоялся: сумма разблокирована, запись удалена,
|
||||
операция в ledger2 не создавалась.
|
||||
Возврат не состоялся: сумма разблокирована (UNBLOCK через o.wal.wthdec),
|
||||
запись удалена.
|
||||
kind: virtual
|
||||
virtual: true
|
||||
|
||||
@@ -120,6 +109,9 @@ transitions:
|
||||
to: pending
|
||||
action: wallet::createwthd
|
||||
actor: contributor
|
||||
ledger_code: o.wal.wthreq
|
||||
operations:
|
||||
- o.wal.wthreq
|
||||
guards:
|
||||
- Пайщик имеет статус active.
|
||||
- Сумма возврата ≤ доступного остатка на SHARE_FUND_PAY (w.wal.share).
|
||||
@@ -127,18 +119,11 @@ transitions:
|
||||
- Заявление подписано ЭЦП пайщика.
|
||||
|
||||
- from: pending
|
||||
to: approved
|
||||
action: wallet::approvewthd
|
||||
actor: soviet
|
||||
guards:
|
||||
- Решение совета по повестке возврата подписано.
|
||||
|
||||
- from: approved
|
||||
to: authorized
|
||||
action: wallet::authwthd
|
||||
actor: soviet
|
||||
guards:
|
||||
- Документ авторизации совета подписан.
|
||||
- Решение совета по повестке возврата подписано и авторизовано.
|
||||
|
||||
- from: authorized
|
||||
to: completed
|
||||
@@ -154,20 +139,19 @@ transitions:
|
||||
to: removed
|
||||
action: wallet::declinewthd
|
||||
actor: soviet
|
||||
ledger_code: o.wal.wthdec
|
||||
operations:
|
||||
- o.wal.wthdec
|
||||
guards:
|
||||
- Совет отклонил возврат либо платёж не прошёл.
|
||||
|
||||
- from: approved
|
||||
to: removed
|
||||
action: wallet::declinewthd
|
||||
actor: soviet
|
||||
guards:
|
||||
- Совет отклонил возврат либо платёж не прошёл.
|
||||
- Совет отклонил возврат.
|
||||
|
||||
- from: authorized
|
||||
to: removed
|
||||
action: wallet::declinewthd
|
||||
actor: gateway_operator
|
||||
ledger_code: o.wal.wthdec
|
||||
operations:
|
||||
- o.wal.wthdec
|
||||
guards:
|
||||
- Платёж не прошёл / отклонён Gateway.
|
||||
|
||||
@@ -182,7 +166,7 @@ scenario:
|
||||
Пайщик оформляет и подписывает заявление о возврате паевого взноса.
|
||||
Контракт создаёт запись в таблице withdraws со статусом `pending`,
|
||||
блокирует сумму на кошельке SHARE_FUND_PAY и направляет в совет
|
||||
повестку о возврате.
|
||||
повестку о возврате с callback'ом авторизации.
|
||||
pre:
|
||||
- Пайщик — активный участник кооператива.
|
||||
- Сумма возврата ≤ доступного остатка.
|
||||
@@ -192,46 +176,34 @@ scenario:
|
||||
- Совет получил повестку о возврате.
|
||||
|
||||
- step: 2
|
||||
title: Одобрение советом
|
||||
actor: soviet
|
||||
action: wallet::approvewthd
|
||||
description: >
|
||||
Совет принимает положительное решение по повестке возврата и
|
||||
добавляет к заявке документ-решение. Статус заявки → `approved`.
|
||||
pre:
|
||||
- Заявка в статусе `pending`.
|
||||
- Решение совета подписано.
|
||||
post:
|
||||
- Статус заявки `approved`, документ-решение сохранён.
|
||||
|
||||
- step: 3
|
||||
title: Авторизация выплаты
|
||||
title: Авторизация выплаты советом
|
||||
actor: soviet
|
||||
action: wallet::authwthd
|
||||
description: >
|
||||
Совет подписывает авторизацию выплаты — контракт инициирует
|
||||
исходящий платёж в Gateway с callback'ами completewthd / declinewthd.
|
||||
Совет принимает положительное решение по повестке возврата и
|
||||
авторизует выплату единым шагом. Контракт инициирует исходящий
|
||||
платёж в Gateway с callback'ами completewthd / declinewthd.
|
||||
Статус заявки → `authorized`.
|
||||
pre:
|
||||
- Заявка в статусе `approved`.
|
||||
- Документ авторизации совета подписан.
|
||||
- Заявка в статусе `pending`.
|
||||
- Решение совета подписано и авторизовано председателем.
|
||||
post:
|
||||
- Статус заявки `authorized`.
|
||||
- В Gateway создан исходящий платёж с callback'ами.
|
||||
|
||||
- step: 4
|
||||
- step: 3
|
||||
title: Подтверждение выплаты
|
||||
actor: gateway_operator
|
||||
action: wallet::completewthd
|
||||
description: >
|
||||
Gateway подтверждает списание средств в пользу пайщика. Контракт
|
||||
применяет ledger2-операцию o.wal.wthcpl (TRANSFER w.wal.share → w.wal.wthdrw с
|
||||
применяет ledger2-операцию o.wal.wthcpl (BURN_BLOCKED w.wal.share с
|
||||
обратной проводкой Дт 80 / Кт 51) и удаляет запись заявки.
|
||||
pre:
|
||||
- Заявка в статусе `authorized`.
|
||||
- Gateway подтвердил списание.
|
||||
post:
|
||||
- Сумма переведена с SHARE_FUND_PAY (w.wal.share) на WITHDRAWALS (w.wal.wthdrw).
|
||||
- Заблокированная сумма сожжена с SHARE_FUND_PAY (w.wal.share).
|
||||
- В ledger2 применена операция o.wal.wthcpl.
|
||||
- Запись заявки удалена.
|
||||
|
||||
@@ -242,8 +214,8 @@ scenario:
|
||||
actor: soviet
|
||||
description: >
|
||||
Совет принял отрицательное решение либо Gateway отклонил платёж.
|
||||
Сумма разблокирована на кошельке пайщика, запись удалена, операция
|
||||
в ledger2 не создаётся.
|
||||
Сумма разблокирована на кошельке пайщика (UNBLOCK через o.wal.wthdec),
|
||||
запись удалена.
|
||||
|
||||
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
|
||||
documents:
|
||||
@@ -254,12 +226,6 @@ documents:
|
||||
stored_in: withdraws.statement
|
||||
|
||||
- step: 2
|
||||
title: Решение совета о возврате паевого взноса (одобрение)
|
||||
registry_id: 901
|
||||
signed_by: [soviet]
|
||||
stored_in: withdraws.approved_statement
|
||||
|
||||
- step: 3
|
||||
title: Решение совета о возврате паевого взноса (авторизация на выплату)
|
||||
registry_id: 901
|
||||
signed_by: [soviet]
|
||||
@@ -267,18 +233,46 @@ documents:
|
||||
|
||||
# ── Секция 6. Операции ──────────────────────────────────────────────────────
|
||||
operations:
|
||||
- ledger_code: o.wal.wthreq
|
||||
human_name: Блокировка паевого под запрос на возврат
|
||||
wallet_op: BLOCK
|
||||
wallet_from: w.wal.share
|
||||
wallet_to: ~
|
||||
debit: ~
|
||||
credit: ~
|
||||
amount_ref: withdraw.quantity
|
||||
triggered_by: wallet::createwthd
|
||||
description: >
|
||||
На создании заявки контракт блокирует сумму на кошельке пайщика
|
||||
(SHARE_FUND_PAY): available → blocked. Бухгалтерская проводка не
|
||||
создаётся — движение внутри одного бухсчёта 80.
|
||||
|
||||
- ledger_code: o.wal.wthdec
|
||||
human_name: Разблокировка паевого после отклонения запроса на возврат
|
||||
wallet_op: UNBLOCK
|
||||
wallet_from: w.wal.share
|
||||
wallet_to: ~
|
||||
debit: ~
|
||||
credit: ~
|
||||
amount_ref: withdraw.quantity
|
||||
triggered_by: wallet::declinewthd
|
||||
description: >
|
||||
Зеркало REQUEST_WITHDRAW: blocked → available на кошельке пайщика.
|
||||
Бухгалтерская проводка не создаётся.
|
||||
|
||||
- ledger_code: o.wal.wthcpl
|
||||
human_name: Возврат паевого взноса пайщику
|
||||
wallet_op: TRANSFER
|
||||
wallet_op: BURN_BLOCKED
|
||||
wallet_from: w.wal.share # ЦПП «Цифровой Кошелёк» — паевые взносы деньгами
|
||||
wallet_to: w.wal.wthdrw # Возвраты паевых взносов пайщикам
|
||||
wallet_to: ~ # деньги уходят из системы, получателя на цепи нет
|
||||
debit: 80 # Паевой фонд (складочный капитал)
|
||||
credit: 51 # Расчётный счёт
|
||||
amount_ref: withdraw.quantity
|
||||
triggered_by: wallet::completewthd
|
||||
description: >
|
||||
Обратная операция к o.wal.depcpl: средства уходят с кошелька пайщика
|
||||
«ЦПП Цифровой Кошелёк» (w.wal.share) на системный кошелёк выплат (w.wal.wthdrw).
|
||||
Обратная операция к o.wal.depcpl: заблокированная сумма сжигается с
|
||||
кошелька пайщика «ЦПП Цифровой Кошелёк» (w.wal.share) — деньги уходят
|
||||
из системы банковским переводом пайщику. На цепи получателя нет.
|
||||
В бухгалтерии прошла проводка Дт 80 / Кт 51 — паевой фонд уменьшился,
|
||||
деньги ушли с расчётного счёта пайщику.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* (soviet::agreements3) и заполняет state как есть. Полные проверки делает
|
||||
* нормальный flow `signagree`.
|
||||
*
|
||||
* @param coopname Кооператив (auth: coopname@active, payer)
|
||||
* @param coopname Кооператив (ключей от него у нас нет — поэтому НЕ авторизатор)
|
||||
* @param username Пайщик
|
||||
* @param program_id Идентификатор программы
|
||||
* @param doc_hash Хэш документа из `agreements3.document.hash`
|
||||
@@ -22,7 +22,11 @@
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_wallet_actions
|
||||
*
|
||||
* @note Авторизация требуется от аккаунта: @p coopname (active)
|
||||
* @note Auth: `wallet@active` (через `get_self()`) ИЛИ `coopname@active`.
|
||||
* Первый путь — для централизованного migrator (раскатка из CI). Второй —
|
||||
* для self-service backfill из контроллера кооператива (BlockchainService
|
||||
* имеет WIF только coopname; для wallet@active отдельного ключа нет).
|
||||
* Payer: тот, кто подал auth (wallet или сам кооператив).
|
||||
*/
|
||||
[[eosio::action]] void wallet::migrate3(
|
||||
eosio::name coopname,
|
||||
@@ -33,7 +37,8 @@
|
||||
uint64_t draft_id,
|
||||
eosio::time_point signed_at
|
||||
) {
|
||||
require_auth(coopname);
|
||||
const eosio::name payer = has_auth(get_self()) ? get_self() : coopname;
|
||||
if (payer != get_self()) require_auth(coopname);
|
||||
|
||||
Wallet::users_index users(_wallet, coopname.value);
|
||||
auto user_it = users.find(username.value);
|
||||
@@ -47,12 +52,12 @@
|
||||
};
|
||||
|
||||
if (user_it == users.end()) {
|
||||
users.emplace(coopname, [&](auto &row) {
|
||||
users.emplace(payer, [&](auto &row) {
|
||||
row.username = username;
|
||||
row.programs = { pa };
|
||||
});
|
||||
} else {
|
||||
users.modify(user_it, coopname, [&](auto &row) {
|
||||
users.modify(user_it, payer, [&](auto &row) {
|
||||
auto it = std::find_if(
|
||||
row.programs.begin(), row.programs.end(),
|
||||
[&](const Wallet::program_agreement &p) { return p.program_id == program_id; });
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
void wallet::approvewthd(eosio::name coopname, checksum256 withdraw_hash, document2 approved_statement) {
|
||||
require_auth(_soviet);
|
||||
|
||||
auto exist_withdraw = Wallet::get_withdraw(coopname, withdraw_hash);
|
||||
eosio::check(exist_withdraw.has_value(), "Объект возврата паевого взноса не найден");
|
||||
|
||||
Wallet::withdraws_index withdraws(_wallet, coopname.value);
|
||||
auto withdraw = withdraws.find(exist_withdraw -> id);
|
||||
|
||||
withdraws.modify(withdraw, _wallet, [&](auto &w){
|
||||
w.status = "approved"_n;
|
||||
w.approved_statement = approved_statement;
|
||||
});
|
||||
|
||||
//отправляем в совет
|
||||
action(permission_level{ _wallet, "active"_n}, _soviet, "createagenda"_n,
|
||||
std::make_tuple(
|
||||
coopname,
|
||||
withdraw -> username,
|
||||
get_valid_soviet_action("withdraw"_n),
|
||||
withdraw_hash,
|
||||
_wallet,
|
||||
"authwthd"_n,
|
||||
"declinewthd"_n,
|
||||
withdraw -> statement,
|
||||
std::string("")
|
||||
)
|
||||
).send();
|
||||
|
||||
}
|
||||
@@ -6,7 +6,6 @@
|
||||
#include "src/deposit/createdpst.cpp"
|
||||
#include "src/deposit/declinedpst.cpp"
|
||||
|
||||
#include "src/withdraw/approvewthd.cpp"
|
||||
#include "src/withdraw/authwthd.cpp"
|
||||
#include "src/withdraw/completewthd.cpp"
|
||||
#include "src/withdraw/createwthd.cpp"
|
||||
|
||||
@@ -28,7 +28,6 @@ public:
|
||||
[[eosio::action]] void completewthd(COMPLETEWTHD_SIGNATURE);
|
||||
[[eosio::action]] void declinewthd(DECLINEWTHD_SIGNATURE);
|
||||
[[eosio::action]] void authwthd(AUTHWTHD_SIGNATURE);
|
||||
[[eosio::action]] void approvewthd(eosio::name coopname, checksum256 withdraw_hash, document2 approved_statement);
|
||||
|
||||
// программные соглашения (Эпик 2 компонента 48; ADR-008)
|
||||
[[eosio::action]] void signagree(eosio::name coopname, eosio::name username, uint64_t program_id, document2 document, uint64_t draft_id);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/contracts",
|
||||
"version": "2026.5.10-4",
|
||||
"version": "2026.5.23-4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"account_name":"wallet","abi":{"version":"eosio::abi/1.2","types":[],"structs":[{"name":"approvewthd","base":"","fields":[{"name":"coopname","type":"name"},{"name":"withdraw_hash","type":"checksum256"},{"name":"approved_statement","type":"document"}]},{"name":"authwthd","base":"","fields":[{"name":"coopname","type":"name"},{"name":"withdraw_hash","type":"checksum256"}]},{"name":"completedpst","base":"","fields":[{"name":"coopname","type":"name"},{"name":"deposit_hash","type":"checksum256"}]},{"name":"completewthd","base":"","fields":[{"name":"coopname","type":"name"},{"name":"withdraw_hash","type":"checksum256"},{"name":"memo","type":"string"}]},{"name":"createdpst","base":"","fields":[{"name":"coopname","type":"name"},{"name":"username","type":"name"},{"name":"deposit_hash","type":"checksum256"},{"name":"quantity","type":"asset"}]},{"name":"createwthd","base":"","fields":[{"name":"coopname","type":"name"},{"name":"username","type":"name"},{"name":"withdraw_hash","type":"checksum256"},{"name":"quantity","type":"asset"},{"name":"statement","type":"document"}]},{"name":"declinedpst","base":"","fields":[{"name":"coopname","type":"name"},{"name":"deposit_hash","type":"checksum256"},{"name":"reason","type":"string"}]},{"name":"declinewthd","base":"","fields":[{"name":"coopname","type":"name"},{"name":"withdraw_hash","type":"checksum256"},{"name":"reason","type":"string"}]},{"name":"deposit","base":"","fields":[{"name":"id","type":"uint64"},{"name":"coopname","type":"name"},{"name":"username","type":"name"},{"name":"deposit_hash","type":"checksum256"},{"name":"quantity","type":"asset"},{"name":"status","type":"name"},{"name":"created_at","type":"time_point_sec"}]},{"name":"document","base":"","fields":[{"name":"hash","type":"checksum256"},{"name":"public_key","type":"public_key"},{"name":"signature","type":"signature"},{"name":"meta","type":"string"}]},{"name":"migrate","base":"","fields":[]},{"name":"withdraw","base":"","fields":[{"name":"id","type":"uint64"},{"name":"username","type":"name"},{"name":"coopname","type":"name"},{"name":"withdraw_hash","type":"checksum256"},{"name":"status","type":"name"},{"name":"quantity","type":"asset"},{"name":"statement","type":"document"},{"name":"approved_statement","type":"document"},{"name":"authorization","type":"document"},{"name":"created_at","type":"time_point_sec"}]}],"actions":[{"name":"approvewthd","type":"approvewthd","ricardian_contract":""},{"name":"authwthd","type":"authwthd","ricardian_contract":""},{"name":"completedpst","type":"completedpst","ricardian_contract":""},{"name":"completewthd","type":"completewthd","ricardian_contract":""},{"name":"createdpst","type":"createdpst","ricardian_contract":""},{"name":"createwthd","type":"createwthd","ricardian_contract":""},{"name":"declinedpst","type":"declinedpst","ricardian_contract":""},{"name":"declinewthd","type":"declinewthd","ricardian_contract":""},{"name":"migrate","type":"migrate","ricardian_contract":""}],"tables":[{"name":"deposits","index_type":"i64","key_names":[],"key_types":[],"type":"deposit"},{"name":"withdraws","index_type":"i64","key_names":[],"key_types":[],"type":"withdraw"}],"ricardian_clauses":[],"error_messages":[],"abi_extensions":[],"variants":[],"action_results":[]}}
|
||||
{"account_name":"wallet","abi":{"version":"eosio::abi/1.2","types":[],"structs":[{"name":"authwthd","base":"","fields":[{"name":"coopname","type":"name"},{"name":"withdraw_hash","type":"checksum256"}]},{"name":"completedpst","base":"","fields":[{"name":"coopname","type":"name"},{"name":"deposit_hash","type":"checksum256"}]},{"name":"completewthd","base":"","fields":[{"name":"coopname","type":"name"},{"name":"withdraw_hash","type":"checksum256"},{"name":"memo","type":"string"}]},{"name":"createdpst","base":"","fields":[{"name":"coopname","type":"name"},{"name":"username","type":"name"},{"name":"deposit_hash","type":"checksum256"},{"name":"quantity","type":"asset"}]},{"name":"createwthd","base":"","fields":[{"name":"coopname","type":"name"},{"name":"username","type":"name"},{"name":"withdraw_hash","type":"checksum256"},{"name":"quantity","type":"asset"},{"name":"statement","type":"document"}]},{"name":"declinedpst","base":"","fields":[{"name":"coopname","type":"name"},{"name":"deposit_hash","type":"checksum256"},{"name":"reason","type":"string"}]},{"name":"declinewthd","base":"","fields":[{"name":"coopname","type":"name"},{"name":"withdraw_hash","type":"checksum256"},{"name":"reason","type":"string"}]},{"name":"deposit","base":"","fields":[{"name":"id","type":"uint64"},{"name":"coopname","type":"name"},{"name":"username","type":"name"},{"name":"deposit_hash","type":"checksum256"},{"name":"quantity","type":"asset"},{"name":"status","type":"name"},{"name":"created_at","type":"time_point_sec"}]},{"name":"document","base":"","fields":[{"name":"hash","type":"checksum256"},{"name":"public_key","type":"public_key"},{"name":"signature","type":"signature"},{"name":"meta","type":"string"}]},{"name":"migrate","base":"","fields":[]},{"name":"withdraw","base":"","fields":[{"name":"id","type":"uint64"},{"name":"username","type":"name"},{"name":"coopname","type":"name"},{"name":"withdraw_hash","type":"checksum256"},{"name":"status","type":"name"},{"name":"quantity","type":"asset"},{"name":"statement","type":"document"},{"name":"approved_statement","type":"document"},{"name":"authorization","type":"document"},{"name":"created_at","type":"time_point_sec"}]}],"actions":[{"name":"authwthd","type":"authwthd","ricardian_contract":""},{"name":"completedpst","type":"completedpst","ricardian_contract":""},{"name":"completewthd","type":"completewthd","ricardian_contract":""},{"name":"createdpst","type":"createdpst","ricardian_contract":""},{"name":"createwthd","type":"createwthd","ricardian_contract":""},{"name":"declinedpst","type":"declinedpst","ricardian_contract":""},{"name":"declinewthd","type":"declinewthd","ricardian_contract":""},{"name":"migrate","type":"migrate","ricardian_contract":""}],"tables":[{"name":"deposits","index_type":"i64","key_names":[],"key_types":[],"type":"deposit"},{"name":"withdraws","index_type":"i64","key_names":[],"key_types":[],"type":"withdraw"}],"ricardian_clauses":[],"error_messages":[],"abi_extensions":[],"variants":[],"action_results":[]}}
|
||||
@@ -84,3 +84,16 @@ OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=
|
||||
WHISPER_MODEL=whisper-1
|
||||
WHISPER_LANGUAGE=ru
|
||||
|
||||
# FILE STORAGE (MinIO в dev compose; на prod подменяется плейбуком).
|
||||
# Endpoint S3-совместимого бэкенда. В dev compose service name = minio.
|
||||
# Для запуска контроллера на хосте (вне docker) — http://127.0.0.1:9000.
|
||||
MINIO_ENDPOINT=http://minio:9000
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
# Имя физического бакета. По умолчанию `coop-${COOPNAME}`, можно перекрыть.
|
||||
MINIO_BUCKET=
|
||||
# HMAC-секрет для подписи URL отдачи. Если пусто — берётся SERVER_SECRET.
|
||||
FILE_STORAGE_SIGNING_SECRET=
|
||||
# База публичного URL контроллера для read-URL. Если пусто — берётся BACKEND_URL.
|
||||
FILE_STORAGE_PUBLIC_BASE_URL=
|
||||
|
||||
@@ -58,6 +58,7 @@ _Критичные правила и паттерны для AI-агентов
|
||||
### Language-Specific (TypeScript)
|
||||
|
||||
- **Bigint из PostgreSQL приходит как STRING.** Всегда `Number(blockNum) < Number(currentBlockNum)`, никогда не полагаться на `<`/`>` для `bigint`-колонок напрямую.
|
||||
- **block_num — разнобой типов в текущей кодовой базе (Story 4.3 audit).** `BaseTypeormEntity` (capital + shared) использует `@Column({ type: 'integer' }) block_num!: number` (PG возвращает number — корректно). Инфраструктурные entity (`ActionEntity`, `DeltaEntity`, `ForkEntity`, `SyncStateEntity`) используют `@Column({ type: 'bigint' }) block_num!: number` — **type-mismatch** (runtime будет string, TS говорит number). `ConsumerDedupEntity` — `bigint` + `string | null` (корректно). Это технический долг (Epic 9 backlog: bigint Transformer). Hot-path везде явно делает `Number()` либо использует TypeORM bind, так что runtime safe. При добавлении нового блокчейн-зеркала — выбрать одно из: (a) `integer` + `number`; (b) `bigint` + Transformer возвращающий `number`; (c) `bigint` + `string` с явным `Number()` в кодe. НЕ `bigint` + declaration `number` без Transformer.
|
||||
- **`Object.assign(this, blockchainData)` запрещено** в sync-сущностях — ломает типизацию. Только явное копирование полей.
|
||||
- **Lowercase hash полей (`project_hash`, `listing_hash`)** — нормализация **в конструкторе/mapValue**, НЕ в mapper'ах и НЕ повторно в `updateFromBlockchain`.
|
||||
- **Discriminated union для write-mutation response:** `{ status: 'applied' | 'pending' | 'failed' | 'conflict' }`. Клиент должен switch на статусе.
|
||||
@@ -76,6 +77,8 @@ _Критичные правила и паттерны для AI-агентов
|
||||
- `@Column({ type: 'bigint', nullable: true })` для `block_num`. Для `jsonb` — `@Column({ type: 'jsonb' })`.
|
||||
- `ADD COLUMN NOT NULL` на больших таблицах — **двухэтапно**: ADD nullable → backfill → ALTER NOT NULL.
|
||||
- Repository `extends BaseBlockchainRepository<DomainEntity, TypeormEntity>`. `findBySyncKey`, `createIfNotExists`, `deleteByBlockNumGreaterThan`, `restoreFromVersions` — **наследуются**, не реализовывать руками.
|
||||
- **Контракт «entity с block_num → repo extends BaseBlockchainRepository» (Story 4.3).** Любая `*.typeorm-entity.ts` extends `BaseTypeormEntity` ОБЯЗАНА иметь репозиторий extends `BaseBlockchainRepository` — иначе `entity_versions` не пишется (silent), а форк-rollback превращается в hard delete без восстановления. CI grep-guard: `tests/unit/blockchain/base-blockchain-repository.contract.test.ts`. Allowlist для 5 off-chain entity (`comment`, `cycle`, `issue`, `story`, `time-entry` — vestigial block_num, не блокчейн-зеркала). Новое исключение в allowlist — только с обоснованием в audit-report.
|
||||
- **Архив форка вместо hard-delete (Story 4.4).** `handleFork(N, eventId?)` НЕ удаляет live-ряды и снимки версий — переносит в `invalidated_entities` / `invalidated_entity_versions` (атомарно через DataSource.transaction). Порядок: archiveInvalidatedSince → restoreFromVersions → archiveInvalidatedVersionsSince. `fork_event_id` группирует записи одного форка (для forensic). Retention — `BlockchainArchiveRetentionService` ежечасно удаляет архив старше `LIB - 1000` блоков. LIB читается через `BlockchainService.getInfo()` (вариант C, RPC `/v1/chain/get_info`). Окно `RETENTION_HORIZON_BLOCKS = 1000` ХАРДКОД (свойство сети, не оператора). Env-переключатели: `BLOCKCHAIN_ARCHIVE_RETENTION_ENABLED` (default true), `BLOCKCHAIN_ARCHIVE_RETENTION_CRON` (default `0 * * * *`).
|
||||
|
||||
**parser2 integration:**
|
||||
- `ParserClient` subscribe с `subscriptionId = "controller-${coopname}"`, `consumerName = "primary"` (детерминирован), `startFromBlock: 'last_known'`.
|
||||
@@ -87,14 +90,16 @@ _Критичные правила и паттерны для AI-агентов
|
||||
- `{contract}{Entity}Updated` / `Deleted` / `RolledBack` / `PendingRetry` / `Failed` — **pubsub канал** для GraphQL subscriptions. Per-contract, не global.
|
||||
- `entitysynced::{contract}::{table}` — для business-side-effect listeners (Matrix / Notification / и т.п.).
|
||||
|
||||
### Composite-Entity (ADR-008) — СТРОГО
|
||||
### Composite-Entity (ADR-008) — будущая цель, не сейчас
|
||||
|
||||
- Namespaced: `entity.db.X` (DB-поля) / `entity.bc?.Y` (blockchain, nullable) / `entity.derived.Z` (computed getters).
|
||||
- **НЕ** писать `entity.X` напрямую — ломает изоляцию.
|
||||
- Конструктор `(databaseData, blockchainData?)` — обязан `throw` на sync-key mismatch.
|
||||
- `updateFromBlockchain` возвращает **новый экземпляр** (immutable) или мутирует только `this.bc`, `this.block_num`, `this.present` — БЕЗ `Object.assign`.
|
||||
- `derived` getter — детерминирован (NO `new Date()` в конструкторе / getter — ломает snapshot tests).
|
||||
- Все signed-document поля нормализуются через `AbstractDeltaMapper.normalizeSignedDocuments` на основе `signedDocumentFields: SignedDocField[]` декларативно, НЕ руками в mapper.
|
||||
ADR-008 описывает целевой паттерн `entity.db.X` / `entity.bc?.Y` / `entity.derived.Z`, заменяющий
|
||||
`Object.assign(this, blockchainData)`. Переход вынесен за пределы MVP-релиза parser2 в отдельный
|
||||
sync-arch sanitation-эпик: blast radius на 22 entity + потребители «плоских» полей в resolver'ах
|
||||
делают эту миграцию большой и рискованной задачей, несвязанной с заменой транспорта parser1→parser2.
|
||||
|
||||
До отдельного эпика — текущий код продолжает использовать flat-namespace + `Object.assign` в
|
||||
`updateFromBlockchain`. Не вводить namespace частично на одной entity — двойной канон хуже единого
|
||||
старого.
|
||||
|
||||
### Dispatch pipeline (ADR-002, ADR-009) — СТРОГО
|
||||
|
||||
@@ -216,7 +221,6 @@ return { tx_hash: tx.tx_hash, status: 'pending' };
|
||||
- `BLOCKCHAIN_RECONCILE_CRON` default `'0 * * * *'`
|
||||
- `BLOCKCHAIN_RECONCILE_SAMPLE_SIZE` default 100
|
||||
- `BLOCKCHAIN_RECONCILE_TOLERANCE_BLOCKS` default 10
|
||||
- `BLOCKCHAIN_FORK_PAUSE_TIMEOUT_MS` default 30000
|
||||
- `BLOCKCHAIN_DLQ_MAX_RETRIES` default 5
|
||||
- `BLOCKCHAIN_MAX_TX_RETRIES` default 3
|
||||
- `BLOCKCHAIN_PENDING_TX_MAX_AGE_SECONDS` default 3600
|
||||
@@ -251,12 +255,13 @@ return { tx_hash: tx.tx_hash, status: 'pending' };
|
||||
- `await capitalBlockchainPort.getProject(hash)` в resolver / application — **retire**. Только `repository.findBySyncKey`.
|
||||
- RPC fallback "если PG не отдал" — **запрещено**. Если PG null → pending status наверх.
|
||||
|
||||
### ❌ Composite-entity anti-patterns
|
||||
### ❌ Domain-entity anti-patterns
|
||||
|
||||
- `project.matrix_room_id` (flat access) — **запрещено**. Правильно: `project.db.matrix_room_id`.
|
||||
- `project.master` (flat access) — **запрещено**. Правильно: `project.bc?.master`.
|
||||
- `Object.assign(this, blockchainData)` в update — **запрещено**.
|
||||
- Новый `new Date()` в конструкторе или derived-getter — **запрещено** (ломает snapshot-tests).
|
||||
- Миграция на namespace `entity.db.X` / `entity.bc?.Y` — целевой паттерн ADR-008, но отложен
|
||||
в отдельный sync-arch sanitation-эпик: текущий код всё ещё использует flat-namespace +
|
||||
`Object.assign(this, blockchainData)` в `updateFromBlockchain`. Не разводить два стандарта
|
||||
частично.
|
||||
|
||||
### ❌ Fork handling anti-patterns
|
||||
|
||||
@@ -296,7 +301,6 @@ return { tx_hash: tx.tx_hash, status: 'pending' };
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- `JSON.stringify` на сущностях с nested signed-documents — использовать `json-stable-stringify` для canonical checksum.
|
||||
- Reconciliation cron — sample N=100 rows, **не** full scan на hot path.
|
||||
- IPFS fetch для signed-doc — **lazy resolver вне consumer critical path**, не в mapper.
|
||||
- `waitForDelta` memory leak — timer cleanup обязателен (см. INV-T10).
|
||||
|
||||
@@ -8,6 +8,9 @@ module.exports = {
|
||||
NODE_ENV: 'test',
|
||||
},
|
||||
restoreMocks: true,
|
||||
// Интеграционные тесты против внешних сервисов (MinIO и т.п.) — не часть штатного `jest`-прогона.
|
||||
// Запускаются явно через `npm run test:integration:file-storage`.
|
||||
testPathIgnorePatterns: ['/node_modules/', '\\.integration\\.spec\\.ts$'],
|
||||
coveragePathIgnorePatterns: ['node_modules', 'src/config', 'src/app.ts', 'tests'],
|
||||
coverageReporters: ['text', 'lcov', 'clover', 'html'],
|
||||
moduleNameMapper: {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
type MigrationLogger = {
|
||||
info: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
};
|
||||
|
||||
interface IChainAgreement {
|
||||
id: number;
|
||||
coopname: string;
|
||||
username: string;
|
||||
type: string;
|
||||
program_id: number;
|
||||
draft_id: number;
|
||||
version: number;
|
||||
document: {
|
||||
version?: string;
|
||||
hash?: string;
|
||||
doc_hash?: string;
|
||||
meta_hash?: string;
|
||||
meta?: string;
|
||||
signatures?: any[];
|
||||
};
|
||||
status: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill `agreements` таблицы из блокчейна (`soviet::agreements3`).
|
||||
*
|
||||
* На дев/тест-кооперативах controller был поднят со снапшота, и старые
|
||||
* подписи (status='') до момента запуска sync не попали в Postgres через
|
||||
* delta-stream. Фронту это видится так: пайщик подписывал когда-то, но
|
||||
* `RequireAgreements` снова просит подписать `user`/`signature`/`privacy`.
|
||||
*
|
||||
* Миграция читает `agreements3` целиком по `scope = COOPNAME` через RPC и
|
||||
* UPSERT'ит каждую запись по полю `id`. Не трогает domain-status (`status`),
|
||||
* чтобы не сломать состояния, выставленные нормальным flow — UPSERT с
|
||||
* `DO UPDATE` пишет только поля, известные из блокчейна.
|
||||
*
|
||||
* Источник истины — блокчейн (`BLOCKCHAIN_RPC` env). RPC недоступен / пусто —
|
||||
* падаем явно (не молча).
|
||||
*/
|
||||
export default {
|
||||
name: 'Backfill agreements из soviet::agreements3 в Postgres',
|
||||
|
||||
async up({ dataSource, logger }: { dataSource: DataSource; logger: MigrationLogger }): Promise<boolean> {
|
||||
try {
|
||||
const rpcUrl = process.env.BLOCKCHAIN_RPC;
|
||||
const coopname = process.env.COOPNAME ?? 'voskhod';
|
||||
|
||||
if (!rpcUrl) {
|
||||
logger.error('BLOCKCHAIN_RPC env не задан — миграция не применяется');
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info(`Чтение soviet::agreements3[scope=${coopname}] из ${rpcUrl} …`);
|
||||
const rows = await fetchAllAgreements(rpcUrl, coopname, logger);
|
||||
logger.info(`Получено ${rows.length} записей agreements3.`);
|
||||
|
||||
if (rows.length === 0) {
|
||||
logger.info('Нет agreements3 — нечего вгружать.');
|
||||
return true;
|
||||
}
|
||||
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
for (const row of rows) {
|
||||
// Программные соглашения отдаются на чтение из wallet::users.programs[],
|
||||
// не из `agreements`. Чтобы не получить дубль во фронте — заливаем
|
||||
// в БД только непрограммные.
|
||||
if (Number(row.program_id) > 0) { skipped += 1; continue; }
|
||||
|
||||
const document = convertChainDocumentToDomain(row.document);
|
||||
const blockchainStatus = String(row.status ?? '');
|
||||
const updatedAt = new Date(row.updated_at);
|
||||
|
||||
const result: Array<{ inserted: boolean }> = await dataSource.query(
|
||||
`
|
||||
INSERT INTO agreements (
|
||||
block_num, present, status, _created_at, _updated_at,
|
||||
id, coopname, username, type, program_id, draft_id, version,
|
||||
document, blockchain_status, updated_at
|
||||
) VALUES (
|
||||
0, true, 'registered', now(), now(),
|
||||
$1, $2, $3, $4, $5, $6, $7,
|
||||
$8::json, $9, $10
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
coopname = EXCLUDED.coopname,
|
||||
username = EXCLUDED.username,
|
||||
type = EXCLUDED.type,
|
||||
program_id = EXCLUDED.program_id,
|
||||
draft_id = EXCLUDED.draft_id,
|
||||
version = EXCLUDED.version,
|
||||
document = EXCLUDED.document,
|
||||
blockchain_status = EXCLUDED.blockchain_status,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
present = true,
|
||||
_updated_at = now()
|
||||
RETURNING (xmax = 0) AS inserted
|
||||
`,
|
||||
[
|
||||
row.id,
|
||||
row.coopname,
|
||||
row.username,
|
||||
row.type,
|
||||
row.program_id,
|
||||
row.draft_id,
|
||||
row.version,
|
||||
JSON.stringify(document),
|
||||
blockchainStatus,
|
||||
updatedAt,
|
||||
],
|
||||
);
|
||||
if (result[0]?.inserted) inserted += 1; else updated += 1;
|
||||
}
|
||||
|
||||
logger.info(`Backfill завершён: insert ${inserted}, update ${updated}, skip programmatic ${skipped}, всего ${rows.length}.`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
logger.error(`Ошибка backfill agreements: ${message}${stack ? `\n${stack}` : ''}`);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn(
|
||||
'Откат не выполняется: backfill идемпотентен по id, повторный up даст тот же результат.',
|
||||
);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
function convertChainDocumentToDomain(chainDoc: IChainAgreement['document']) {
|
||||
return {
|
||||
version: chainDoc?.version,
|
||||
hash: chainDoc?.hash,
|
||||
doc_hash: chainDoc?.doc_hash,
|
||||
meta_hash: chainDoc?.meta_hash,
|
||||
meta:
|
||||
typeof chainDoc?.meta === 'string'
|
||||
? chainDoc.meta === ''
|
||||
? {}
|
||||
: safeJsonParse(chainDoc.meta)
|
||||
: (chainDoc?.meta ?? {}),
|
||||
signatures: chainDoc?.signatures ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function safeJsonParse(s: string): unknown {
|
||||
try {
|
||||
return JSON.parse(s);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllAgreements(
|
||||
rpcUrl: string,
|
||||
coopname: string,
|
||||
logger: MigrationLogger,
|
||||
): Promise<IChainAgreement[]> {
|
||||
const items: IChainAgreement[] = [];
|
||||
let lower_bound: string | undefined = '';
|
||||
|
||||
for (let page = 0; page < 10000; page++) {
|
||||
const body = {
|
||||
json: true,
|
||||
code: 'soviet',
|
||||
scope: coopname,
|
||||
table: 'agreements3',
|
||||
limit: 1000,
|
||||
lower_bound,
|
||||
};
|
||||
|
||||
const res = await fetch(`${rpcUrl.replace(/\/$/, '')}/v1/chain/get_table_rows`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`RPC ${res.status} ${res.statusText}: ${await res.text()}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
rows: IChainAgreement[];
|
||||
more: boolean;
|
||||
next_key?: string;
|
||||
};
|
||||
|
||||
items.push(...data.rows);
|
||||
|
||||
if (!data.more) return items;
|
||||
if (!data.next_key) {
|
||||
logger.warn('RPC ответил more=true без next_key — прерываем пагинацию');
|
||||
return items;
|
||||
}
|
||||
lower_bound = data.next_key;
|
||||
}
|
||||
|
||||
throw new Error('Превышен лимит страниц при чтении soviet::agreements3');
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
import type { BlockchainService } from '~/infrastructure/blockchain/blockchain.service';
|
||||
import type { VaultDomainService } from '~/domain/vault/services/vault-domain.service';
|
||||
import { WalletContract } from 'cooptypes';
|
||||
import config from '~/config/config';
|
||||
|
||||
type MigrationLogger = {
|
||||
info: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
};
|
||||
|
||||
interface IDocSignature {
|
||||
signed_at?: string;
|
||||
signer?: string;
|
||||
}
|
||||
|
||||
interface IBlagorostDoc {
|
||||
hash?: string;
|
||||
doc_hash?: string;
|
||||
version?: string;
|
||||
signatures?: IDocSignature[];
|
||||
}
|
||||
|
||||
interface IRegcontribData {
|
||||
coopname: string;
|
||||
username: string;
|
||||
blagorost_agreement?: IBlagorostDoc | null;
|
||||
generator_agreement?: IBlagorostDoc | null;
|
||||
}
|
||||
|
||||
interface IProgramAgreementRow {
|
||||
program_id: number | string;
|
||||
}
|
||||
|
||||
interface IWalletUserRow {
|
||||
username: string;
|
||||
programs: IProgramAgreementRow[];
|
||||
}
|
||||
|
||||
interface ISovietProgramRow {
|
||||
id: number | string;
|
||||
draft_id: number | string;
|
||||
}
|
||||
|
||||
interface IDraftRow {
|
||||
id: number | string;
|
||||
version: number | string;
|
||||
}
|
||||
|
||||
const BLAGOROST_PROGRAM_ID = 4;
|
||||
const GENERATOR_PROGRAM_ID = 3;
|
||||
const ZERO_HASH = '0000000000000000000000000000000000000000000000000000000000000000';
|
||||
|
||||
/**
|
||||
* Backfill `wallet::users.programs[]` для исторических контрибуторов: их
|
||||
* `regcontrib` приехал ДО того, как capital научился делать inline
|
||||
* `wallet::signagree`. Подписанные документы лежат в `blockchain_actions.data`
|
||||
* как audit trail, но в state контракта `wallet::users` их не оказалось —
|
||||
* фронт ругается «У пайщика нет кошелька в программе Благорост».
|
||||
*
|
||||
* Поток:
|
||||
* 1. SELECT всех capital::regcontrib из локального controller-Postgres.
|
||||
* 2. Группировка по (coopname, username) — последний `regcontrib` выигрывает
|
||||
* (после importcontrib обычно ещё один regcontrib).
|
||||
* 3. Для каждой пары через RPC проверяем `wallet::users.programs[]`.
|
||||
* 4. Если program_id=4 (Благорост) ещё нет и `data.blagorost_agreement`
|
||||
* валидный — пушим `wallet::migrate3` от `coopname@active` с реальным
|
||||
* `doc_hash` и `signed_at = signatures[0].signed_at`. То же для
|
||||
* program_id=3 (Генератор).
|
||||
*
|
||||
* Auth: `coopname@active` — после расширения `wallet::migrate3` (контракт
|
||||
* принимает либо `wallet@active`, либо `coopname@active`).
|
||||
*
|
||||
* Идемпотентно: повторный запуск увидит уже подписанных и пропустит их.
|
||||
*
|
||||
* Активна до 2027-05-01: к этому моменту все исторические orphan'ы должны
|
||||
* быть закрыты, а новые регистрации идут через корректный `regcontrib`.
|
||||
*/
|
||||
export default {
|
||||
name: 'Backfill wallet::users.programs[] из capital::regcontrib (Благорост/Генератор)',
|
||||
validUntil: new Date('2027-05-01T00:00:00Z'),
|
||||
|
||||
async up({
|
||||
dataSource,
|
||||
blockchain,
|
||||
vault,
|
||||
logger,
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
blockchain: BlockchainService;
|
||||
vault: VaultDomainService;
|
||||
logger: MigrationLogger;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const rpcUrl = config.blockchain.url;
|
||||
if (!rpcUrl) {
|
||||
logger.error('BLOCKCHAIN_RPC не задан — миграция не применяется');
|
||||
return false;
|
||||
}
|
||||
|
||||
const rawRows = (await dataSource.query(
|
||||
`SELECT data
|
||||
FROM blockchain_actions
|
||||
WHERE account = 'capital' AND name = 'regcontrib'
|
||||
ORDER BY block_num ASC`,
|
||||
)) as Array<{ data: IRegcontribData }>;
|
||||
|
||||
logger.info(`regcontrib actions найдено: ${rawRows.length}`);
|
||||
if (rawRows.length === 0) {
|
||||
logger.info('Нет regcontrib в локальной БД — нечего бэкфиллить.');
|
||||
return true;
|
||||
}
|
||||
|
||||
const latestByPair = new Map<string, IRegcontribData>();
|
||||
for (const r of rawRows) {
|
||||
latestByPair.set(`${r.data.coopname}/${r.data.username}`, r.data);
|
||||
}
|
||||
logger.info(`Уникальных (coopname, username): ${latestByPair.size}`);
|
||||
|
||||
const coopnames = Array.from(new Set([...latestByPair.values()].map((d) => d.coopname)));
|
||||
const draftVersions = await fetchDraftVersions(rpcUrl, logger);
|
||||
|
||||
let pushed = 0;
|
||||
let skipped = 0;
|
||||
let missing = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const coopname of coopnames) {
|
||||
const wif = await vault.getWif(coopname);
|
||||
if (!wif) {
|
||||
logger.warn(`WIF для ${coopname} не найден в vault — пропускаю кооператив`);
|
||||
continue;
|
||||
}
|
||||
blockchain.initialize(coopname, wif);
|
||||
|
||||
const usersPrograms = await fetchWalletUserPrograms(rpcUrl, coopname, logger);
|
||||
const programs = await fetchSovietPrograms(rpcUrl, coopname, logger);
|
||||
|
||||
for (const [pairKey, regData] of latestByPair) {
|
||||
if (regData.coopname !== coopname) continue;
|
||||
|
||||
const targets: Array<{ programId: number; doc: IBlagorostDoc }> = [];
|
||||
if (isValidAgreement(regData.blagorost_agreement)) {
|
||||
targets.push({ programId: BLAGOROST_PROGRAM_ID, doc: regData.blagorost_agreement! });
|
||||
}
|
||||
if (isValidAgreement(regData.generator_agreement)) {
|
||||
targets.push({ programId: GENERATOR_PROGRAM_ID, doc: regData.generator_agreement! });
|
||||
}
|
||||
if (targets.length === 0) continue;
|
||||
|
||||
const already = usersPrograms.get(regData.username) ?? new Set<number>();
|
||||
|
||||
for (const { programId, doc } of targets) {
|
||||
if (already.has(programId)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const program = programs.get(programId);
|
||||
if (!program) {
|
||||
missing += 1;
|
||||
logger.warn(
|
||||
`${pairKey}: программа program_id=${programId} не найдена в soviet::programs — skip`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const draftId = Number(program.draft_id);
|
||||
const version = draftId > 0 ? draftVersions.get(draftId) ?? 0 : 0;
|
||||
const docHash = (doc.hash ?? doc.doc_hash) as string;
|
||||
const signedAt =
|
||||
doc.signatures?.[0]?.signed_at ?? new Date().toISOString().replace(/\.\d+Z$/, '');
|
||||
|
||||
const actionData: WalletContract.Actions.Migrate3.IMigrate3 = {
|
||||
coopname: regData.coopname,
|
||||
username: regData.username,
|
||||
program_id: programId,
|
||||
doc_hash: docHash,
|
||||
version,
|
||||
draft_id: draftId,
|
||||
signed_at: signedAt,
|
||||
};
|
||||
|
||||
try {
|
||||
await blockchain.transact({
|
||||
account: WalletContract.contractName.production,
|
||||
name: WalletContract.Actions.Migrate3.actionName,
|
||||
authorization: [{ actor: coopname, permission: 'active' }],
|
||||
data: actionData,
|
||||
});
|
||||
pushed += 1;
|
||||
logger.info(
|
||||
`migrate3 OK ${pairKey} program_id=${programId} draft_id=${draftId} ` +
|
||||
`ver=${version} hash=${docHash.slice(0, 12)}…`,
|
||||
);
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger.error(`migrate3 FAIL ${pairKey} program_id=${programId}: ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Backfill завершён: push ${pushed}, уже подписаны ${skipped}, программа не найдена ${missing}, ` +
|
||||
`ошибок ${failed}.`,
|
||||
);
|
||||
return failed === 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
logger.error(`Ошибка backfill blagorost signagree: ${message}${stack ? `\n${stack}` : ''}`);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async down({ logger }: { logger: MigrationLogger }): Promise<boolean> {
|
||||
logger.warn(
|
||||
'Откат не выполняется: backfill идемпотентен и работает только для orphan-программ. ' +
|
||||
'Удаление записей wallet::users делается вручную через wallet::revokeagree.',
|
||||
);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
function isValidAgreement(doc: IBlagorostDoc | null | undefined): doc is IBlagorostDoc {
|
||||
if (!doc) return false;
|
||||
const hash = doc.hash ?? doc.doc_hash;
|
||||
return typeof hash === 'string' && hash.length === 64 && hash !== ZERO_HASH;
|
||||
}
|
||||
|
||||
async function rpcGetTableRows<T>(
|
||||
rpcUrl: string,
|
||||
payload: Record<string, unknown>,
|
||||
logger: MigrationLogger,
|
||||
): Promise<T[]> {
|
||||
const items: T[] = [];
|
||||
let lower_bound: string | undefined = '';
|
||||
|
||||
for (let page = 0; page < 10_000; page++) {
|
||||
const res = await fetch(`${rpcUrl.replace(/\/$/, '')}/v1/chain/get_table_rows`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ json: true, limit: 1000, lower_bound, ...payload }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`RPC ${res.status} ${await res.text()}`);
|
||||
const data = (await res.json()) as { rows: T[]; more: boolean; next_key?: string };
|
||||
items.push(...data.rows);
|
||||
if (!data.more) return items;
|
||||
if (!data.next_key) {
|
||||
logger.warn('RPC ответил more=true без next_key — прерываем пагинацию');
|
||||
return items;
|
||||
}
|
||||
lower_bound = data.next_key;
|
||||
}
|
||||
throw new Error('Превышен лимит страниц get_table_rows');
|
||||
}
|
||||
|
||||
async function fetchWalletUserPrograms(
|
||||
rpcUrl: string,
|
||||
coopname: string,
|
||||
logger: MigrationLogger,
|
||||
): Promise<Map<string, Set<number>>> {
|
||||
const rows = await rpcGetTableRows<IWalletUserRow>(
|
||||
rpcUrl,
|
||||
{ code: 'wallet', scope: coopname, table: 'users' },
|
||||
logger,
|
||||
);
|
||||
const out = new Map<string, Set<number>>();
|
||||
for (const r of rows) {
|
||||
out.set(r.username, new Set(r.programs.map((p) => Number(p.program_id))));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchSovietPrograms(
|
||||
rpcUrl: string,
|
||||
coopname: string,
|
||||
logger: MigrationLogger,
|
||||
): Promise<Map<number, ISovietProgramRow>> {
|
||||
const rows = await rpcGetTableRows<ISovietProgramRow>(
|
||||
rpcUrl,
|
||||
{ code: 'soviet', scope: coopname, table: 'programs' },
|
||||
logger,
|
||||
);
|
||||
const out = new Map<number, ISovietProgramRow>();
|
||||
for (const r of rows) out.set(Number(r.id), r);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchDraftVersions(
|
||||
rpcUrl: string,
|
||||
logger: MigrationLogger,
|
||||
): Promise<Map<number, number>> {
|
||||
const rows = await rpcGetTableRows<IDraftRow>(
|
||||
rpcUrl,
|
||||
{ code: 'draft', scope: 'draft', table: 'drafts' },
|
||||
logger,
|
||||
);
|
||||
const out = new Map<number, number>();
|
||||
for (const r of rows) out.set(Number(r.id), Number(r.version));
|
||||
return out;
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "create-nodejs-express-app",
|
||||
"version": "2026.5.10-4",
|
||||
"version": "2026.5.23-4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "create-nodejs-express-app",
|
||||
"version": "2026.5.10-4",
|
||||
"version": "2026.5.23-4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@a2seven/yoo-checkout": "^1.1.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/controller",
|
||||
"version": "2026.5.10-4",
|
||||
"version": "2026.5.23-4",
|
||||
"description": "Бэкенд GraphQL API кооператива на NestJS",
|
||||
"private": true,
|
||||
"bin": "bin/createNodejsApp.js",
|
||||
@@ -18,10 +18,11 @@
|
||||
"scripts": {
|
||||
"deploy-testnet": "git checkout testnet && git merge main && git push origin testnet && git checkout main",
|
||||
"deploy-production": "git checkout production && git merge testnet && git push origin production && git checkout main",
|
||||
"start": "ts-node -r tsconfig-paths/register src/index.ts",
|
||||
"start": "ts-node -r tsconfig-paths/register src/index.ts",
|
||||
"dev": "nodemon --watch src --watch migrations --ext ts,js,env --exec 'ts-node -r tsconfig-paths/register' src/index.ts",
|
||||
"test": "jest -i --testPathPattern=tests/unit",
|
||||
"test:integration": "echo 'Integration tests run via @coopenomics/boot' && exit 0",
|
||||
"test:integration:file-storage": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --testRegex 'tests/file-storage/.*\\.integration\\.spec\\.ts$' --testPathIgnorePatterns='/node_modules/' --testTimeout=60000 -i",
|
||||
"test:watch": "jest -i --watchAll",
|
||||
"coverage": "jest -i --coverage",
|
||||
"coverage:coveralls": "jest -i --coverage --coverageReporters=text-lcov | coveralls",
|
||||
@@ -71,9 +72,11 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@a2seven/yoo-checkout": "^1.1.4",
|
||||
"@aws-sdk/client-s3": "^3.1035.0",
|
||||
"@coopenomics/factory": "workspace:*",
|
||||
"@coopenomics/inter": "workspace:*",
|
||||
"@coopenomics/notifications": "workspace:*",
|
||||
"@coopenomics/parser2": "^1.2.0",
|
||||
"@coopenomics/provider-client": "2025.11.12-alpha-1",
|
||||
"@coopenomics/sdk": "workspace:*",
|
||||
"@graphql-codegen/typescript-graphql-request": "^6.2.0",
|
||||
@@ -191,8 +194,11 @@
|
||||
"@antfu/eslint-config": "^2.24.1",
|
||||
"@graphql-codegen/cli": "^5.0.3",
|
||||
"@graphql-codegen/typescript": "^4.1.1",
|
||||
"@swc-node/register": "^1.11.1",
|
||||
"@swc/core": "^1.15.33",
|
||||
"@graphql-codegen/typescript-apollo-client-helpers": "^3.0.0",
|
||||
"@graphql-codegen/typescript-operations": "^4.3.1",
|
||||
"@nestjs/testing": "^11.1.19",
|
||||
"@redocly/cli": "^1.18.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.26",
|
||||
|
||||
@@ -1797,7 +1797,7 @@ type CapitalCommit {
|
||||
data: JSON
|
||||
|
||||
"""Описание коммита"""
|
||||
description: String!
|
||||
description: String
|
||||
|
||||
"""Отображаемое имя пользователя"""
|
||||
display_name: String
|
||||
@@ -1806,7 +1806,7 @@ type CapitalCommit {
|
||||
id: Int
|
||||
|
||||
"""Метаданные коммита"""
|
||||
meta: String!
|
||||
meta: String
|
||||
|
||||
"""Флаг присутствия записи в блокчейне"""
|
||||
present: Boolean!
|
||||
@@ -2987,9 +2987,15 @@ type CapitalProjectPermissions {
|
||||
"""Может ли устанавливать план проекта"""
|
||||
can_set_plan: Boolean!
|
||||
|
||||
"""Может ли просматривать артефакты (требования) проекта/компонента"""
|
||||
can_view_artifacts: Boolean!
|
||||
|
||||
"""Имеет ли подтвержденное приложение для проекта"""
|
||||
has_clearance: Boolean!
|
||||
|
||||
"""Имеет ли допуск к родительскому проекту (для компонента)"""
|
||||
has_parent_clearance: Boolean!
|
||||
|
||||
"""Является ли пользователь гостем (неавторизованным)"""
|
||||
is_guest: Boolean!
|
||||
|
||||
@@ -3887,6 +3893,28 @@ input CompleteCapitalRegistrationInputDTO {
|
||||
username: String!
|
||||
}
|
||||
|
||||
input CompleteExtensionOnboardingStepInput {
|
||||
"""Текст принимаемого решения (для generator=free_decision)"""
|
||||
decision: String
|
||||
|
||||
"""Имя расширения"""
|
||||
extension_name: String!
|
||||
|
||||
"""Hash повестки общего собрания (для generator=meet)"""
|
||||
proposal_hash: String
|
||||
|
||||
"""Вопрос предлагаемого совету решения (для generator=free_decision)"""
|
||||
question: String
|
||||
|
||||
"""Ключ шага из реестра онбординга"""
|
||||
step_key: String!
|
||||
|
||||
"""
|
||||
Заголовок предлагаемого совету решения (опционально, если не задано — берётся default_title шага)
|
||||
"""
|
||||
title: String
|
||||
}
|
||||
|
||||
input CompleteProcessStepInput {
|
||||
instance_id: String!
|
||||
step_id: String!
|
||||
@@ -5681,6 +5709,42 @@ type ExtensionLogsPaginationResult {
|
||||
totalPages: Int!
|
||||
}
|
||||
|
||||
type ExtensionOnboardingState {
|
||||
"""Все шаги завершены"""
|
||||
all_done: Boolean!
|
||||
|
||||
"""Имя расширения"""
|
||||
extension_name: String!
|
||||
|
||||
"""Момент истечения TTL онбординга (ISO)"""
|
||||
onboarding_expire_at: String!
|
||||
|
||||
"""Момент старта онбординга (ISO)"""
|
||||
onboarding_init_at: String!
|
||||
|
||||
"""Список шагов онбординга в порядке отображения"""
|
||||
steps: [ExtensionOnboardingStepState!]!
|
||||
}
|
||||
|
||||
type ExtensionOnboardingStepState {
|
||||
"""Заголовок шага по умолчанию (если payload не передал свой)"""
|
||||
default_title: String
|
||||
|
||||
"""Завершён ли шаг (ратифицирован)"""
|
||||
done: Boolean!
|
||||
|
||||
"""Hash опубликованного документа шага"""
|
||||
hash: String
|
||||
|
||||
"""Порядок отображения"""
|
||||
order: Int!
|
||||
|
||||
"""
|
||||
Идентификатор шага (соответствует config-полю onboarding_<step_key>_done)
|
||||
"""
|
||||
step_key: String!
|
||||
}
|
||||
|
||||
type FieldError {
|
||||
message: String!
|
||||
path: String!
|
||||
@@ -6095,6 +6159,53 @@ input GenerationContractSignedMetaDocumentInput {
|
||||
version: String!
|
||||
}
|
||||
|
||||
input GenerationConvertStatementGenerateDocumentInput {
|
||||
"""Сумма для перевода в программу «Благорост»"""
|
||||
blagorost_wallet_amount: String!
|
||||
|
||||
"""Номер блока, на котором был создан документ"""
|
||||
block_num: Int
|
||||
|
||||
"""Название кооператива, связанное с документом"""
|
||||
coopname: String!
|
||||
|
||||
"""Дата и время создания документа"""
|
||||
created_at: String
|
||||
|
||||
"""Имя генератора, использованного для создания документа"""
|
||||
generator: String
|
||||
|
||||
"""Язык документа"""
|
||||
lang: String
|
||||
|
||||
"""Ссылки, связанные с документом"""
|
||||
links: [String!]
|
||||
|
||||
"""Сумма для перевода в Цифровой Кошелёк"""
|
||||
main_wallet_amount: String!
|
||||
|
||||
"""Хэш проекта"""
|
||||
project_hash: String!
|
||||
|
||||
"""Часовой пояс, в котором был создан документ"""
|
||||
timezone: String
|
||||
|
||||
"""Название документа"""
|
||||
title: String
|
||||
|
||||
"""Признак перевода в программу «Благорост»"""
|
||||
to_blagorost: Boolean!
|
||||
|
||||
"""Признак перевода в Цифровой Кошелёк"""
|
||||
to_wallet: Boolean!
|
||||
|
||||
"""Имя пользователя, создавшего документ"""
|
||||
username: String!
|
||||
|
||||
"""Версия генератора, использованного для создания документа"""
|
||||
version: String
|
||||
}
|
||||
|
||||
input GenerationMoneyInvestStatementGenerateDocumentInput {
|
||||
"""Сумма инвестирования"""
|
||||
amount: String!
|
||||
@@ -6206,56 +6317,6 @@ input GenerationMoneyInvestStatementSignedMetaDocumentInput {
|
||||
version: String!
|
||||
}
|
||||
|
||||
input GenerationToMainWalletConvertStatementGenerateDocumentInput {
|
||||
"""Хэш приложения"""
|
||||
appendix_hash: String!
|
||||
|
||||
"""Сумма для перевода на благорост кошелек"""
|
||||
blagorost_wallet_amount: String!
|
||||
|
||||
"""Номер блока, на котором был создан документ"""
|
||||
block_num: Int
|
||||
|
||||
"""Название кооператива, связанное с документом"""
|
||||
coopname: String!
|
||||
|
||||
"""Дата и время создания документа"""
|
||||
created_at: String
|
||||
|
||||
"""Имя генератора, использованного для создания документа"""
|
||||
generator: String
|
||||
|
||||
"""Язык документа"""
|
||||
lang: String
|
||||
|
||||
"""Ссылки, связанные с документом"""
|
||||
links: [String!]
|
||||
|
||||
"""Сумма для перевода на основной кошелек"""
|
||||
main_wallet_amount: String!
|
||||
|
||||
"""Хэш проекта"""
|
||||
project_hash: String!
|
||||
|
||||
"""Часовой пояс, в котором был создан документ"""
|
||||
timezone: String
|
||||
|
||||
"""Название документа"""
|
||||
title: String
|
||||
|
||||
"""Перевод на благорост кошелек"""
|
||||
to_blagorost: Boolean!
|
||||
|
||||
"""Перевод на основной кошелек"""
|
||||
to_wallet: Boolean!
|
||||
|
||||
"""Имя пользователя, создавшего документ"""
|
||||
username: String!
|
||||
|
||||
"""Версия генератора, использованного для создания документа"""
|
||||
version: String
|
||||
}
|
||||
|
||||
input GetAccountInput {
|
||||
"""Имя аккаунта пользователя"""
|
||||
username: String!
|
||||
@@ -6643,6 +6704,11 @@ type IndividualCertificate {
|
||||
}
|
||||
|
||||
input Init {
|
||||
"""
|
||||
Признак того, что инициализация выполняется со стороны провайдера. При true coopback ставит init_by_server=true (org_data становится readonly для пользовательского визарда). Поле передаёт provider в callInitSystemMutation.
|
||||
"""
|
||||
is_server_init: Boolean
|
||||
|
||||
"""
|
||||
Объект организации кооператива, которая обслуживает данный экземпляр программного обеспечения MONO
|
||||
"""
|
||||
@@ -7461,6 +7527,11 @@ type Mutation {
|
||||
"""Сгенерировать генерационное соглашение"""
|
||||
capitalGenerateGenerationContract(data: GenerationContractGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
|
||||
|
||||
"""
|
||||
Сгенерировать заявление о конвертации целевого паевого взноса (в Цифровой Кошелёк и/или в программу «Благорост»)
|
||||
"""
|
||||
capitalGenerateGenerationConvertStatement(data: GenerationConvertStatementGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
|
||||
|
||||
"""Сгенерировать заявление об инвестировании в генерацию"""
|
||||
capitalGenerateGenerationMoneyInvestStatement(data: GenerationMoneyInvestStatementGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
|
||||
|
||||
@@ -7473,15 +7544,6 @@ type Mutation {
|
||||
"""Сгенерировать заявление об инвестировании имуществом в генерацию"""
|
||||
capitalGenerateGenerationPropertyInvestStatement(data: GenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
|
||||
|
||||
"""Сгенерировать заявление о конвертации из генерации в благорост"""
|
||||
capitalGenerateGenerationToCapitalizationConvertStatement(data: GenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
|
||||
|
||||
"""Сгенерировать заявление о конвертации из генерации в основной кошелек"""
|
||||
capitalGenerateGenerationToMainWalletConvertStatement(data: GenerationToMainWalletConvertStatementGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
|
||||
|
||||
"""Сгенерировать заявление о конвертации из генерации в проектный кошелек"""
|
||||
capitalGenerateGenerationToProjectConvertStatement(data: GenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
|
||||
|
||||
"""Сгенерировать решение о получении займа"""
|
||||
capitalGenerateGetLoanDecision(data: GenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
|
||||
|
||||
@@ -7613,6 +7675,11 @@ type Mutation {
|
||||
"""
|
||||
completeChairmanGeneralMeetStep(data: ChairmanOnboardingGeneralMeetInput!): ChairmanOnboardingState!
|
||||
|
||||
"""
|
||||
Выполнить шаг онбординга кооператива на расширение (решение совета или общее собрание)
|
||||
"""
|
||||
completeExtensionOnboardingStep(data: CompleteExtensionOnboardingStepInput!): ExtensionOnboardingState!
|
||||
|
||||
"""Завершить заявку по истечению гарантийного срока"""
|
||||
completeRequest(data: CompleteRequestInput!): Transaction!
|
||||
|
||||
@@ -8757,6 +8824,7 @@ type PaymentMethodPaginationResult {
|
||||
|
||||
"""Статус платежа"""
|
||||
enum PaymentStatus {
|
||||
AWAITING_AUTHORIZATION
|
||||
CANCELLED
|
||||
COMPLETED
|
||||
EXPIRED
|
||||
@@ -9664,6 +9732,9 @@ type Query {
|
||||
"""Получить логи расширений с фильтрацией и пагинацией"""
|
||||
getExtensionLogs(data: GetExtensionLogsInput, options: PaginationInput): ExtensionLogsPaginationResult!
|
||||
|
||||
"""Получить состояние онбординга кооператива на расширение"""
|
||||
getExtensionOnboardingState(extension_name: String!): ExtensionOnboardingState!
|
||||
|
||||
"""Получить список расширений"""
|
||||
getExtensions(data: GetExtensionsInput): [Extension!]!
|
||||
|
||||
@@ -9724,6 +9795,11 @@ type Query {
|
||||
"""Получить подписки пользователя у провайдера"""
|
||||
getProviderSubscriptions: [ProviderSubscription!]!
|
||||
|
||||
"""
|
||||
Получить список оферт для регистрации пайщика заданного типа аккаунта и (опционально) программы. Сливает базовые платформенные оферты с теми, что зарегистрировали расширения.
|
||||
"""
|
||||
getRegistrationAgreements(account_type: AccountType!, coopname: String!, program_key: String): [RegistrationAgreement!]!
|
||||
|
||||
"""Получить конфигурацию программ регистрации для кооператива"""
|
||||
getRegistrationConfig(account_type: AccountType!, coopname: String!): RegistrationConfig!
|
||||
|
||||
@@ -9998,6 +10074,39 @@ type RegisteredAccount {
|
||||
tokens: Tokens!
|
||||
}
|
||||
|
||||
"""Описание оферты, которую пайщик должен принять при регистрации"""
|
||||
type RegistrationAgreement {
|
||||
"""Тип соглашения для on-chain sendAgreement"""
|
||||
agreement_type: String!
|
||||
|
||||
"""Типы аккаунтов, для которых оферта применима"""
|
||||
applicable_account_types: [AccountType!]!
|
||||
|
||||
"""Текст для галочки"""
|
||||
checkbox_text: String!
|
||||
|
||||
"""Строковый идентификатор оферты"""
|
||||
id: String!
|
||||
|
||||
"""Нужно ли отправлять в блокчейн через sendAgreement"""
|
||||
is_blockchain_agreement: Boolean!
|
||||
|
||||
"""Текст ссылки для открытия диалога чтения"""
|
||||
link_text: String!
|
||||
|
||||
"""Нужно ли линковать хеш документа в заявление"""
|
||||
link_to_statement: Boolean!
|
||||
|
||||
"""Порядок отображения"""
|
||||
order: Int!
|
||||
|
||||
"""registry_id шаблона на фабрике документов"""
|
||||
registry_id: Int!
|
||||
|
||||
"""Человекочитаемое название оферты"""
|
||||
title: String!
|
||||
}
|
||||
|
||||
type RegistrationConfig {
|
||||
"""Доступные программы"""
|
||||
programs: [RegistrationProgram!]!
|
||||
@@ -11485,7 +11594,7 @@ input UpdateBankAccountInput {
|
||||
input UpdateCallTranscriptionMemoInput {
|
||||
id: String!
|
||||
|
||||
"""Текст заметки (до 4000 символов)"""
|
||||
"""Текст заметки"""
|
||||
memo: String!
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// app.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
|
||||
// Infrastructure modules
|
||||
@@ -9,6 +10,7 @@ import { GraphqlModule } from './infrastructure/graphql/graphql.module';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import config from '~/config/config';
|
||||
import { BlockchainModule } from './infrastructure/blockchain/blockchain.module';
|
||||
import { ForkRegistryModule } from './shared/sync/fork';
|
||||
import { GeneratorInfrastructureModule } from './infrastructure/generator/generator.module';
|
||||
import { RedisModule } from './infrastructure/redis/redis.module';
|
||||
import { NovuModule } from './infrastructure/novu/novu.module';
|
||||
@@ -16,6 +18,7 @@ import { EventsInfrastructureModule } from './infrastructure/events/events.modul
|
||||
import { FreeDecisionInfrastructureModule } from './infrastructure/free-decision/free-decision-infrastructure.module';
|
||||
import { DecisionTrackingInfrastructureModule } from './infrastructure/decision-tracking/decision-tracking-infrastructure.module';
|
||||
import { SearchInfrastructureModule } from './infrastructure/search/search-infrastructure.module';
|
||||
import { FileStorageInfrastructureModule } from './infrastructure/file-storage';
|
||||
|
||||
// Domain modules
|
||||
import { AccountDomainModule } from './domain/account/account-domain.module';
|
||||
@@ -41,6 +44,7 @@ import { ProcessRegistryDomainModule } from './domain/process-registry/process-r
|
||||
import { ParserDomainModule } from './domain/parser/parser-domain.module';
|
||||
import { SettingsInfrastructureModule } from './infrastructure/settings/settings-infrastructure.module';
|
||||
import { RegistrationDomainModule } from './domain/registration/registration-domain.module';
|
||||
import { OnboardingDomainModule } from './domain/onboarding/onboarding-domain.module';
|
||||
import { TokenDomainModule } from './domain/token/token-domain.module';
|
||||
import { MutationLogDomainModule } from './domain/mutation-log/mutation-log-domain.module';
|
||||
|
||||
@@ -74,6 +78,7 @@ import { UserModule } from './application/user/user.module';
|
||||
import { TokenApplicationModule } from './application/token/token-application.module';
|
||||
import { SettingsApplicationModule } from './application/settings/settings.module';
|
||||
import { RegistrationModule } from './application/registration/registration.module';
|
||||
import { OnboardingApplicationModule } from './application/onboarding/onboarding-application.module';
|
||||
import { SearchModule } from './application/search/search.module';
|
||||
import { MutationLoggingInterceptor } from './application/common/interceptors/mutation-logging.interceptor';
|
||||
|
||||
@@ -82,6 +87,7 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true, // Чтобы .env был доступен глобально
|
||||
}),
|
||||
ScheduleModule.forRoot(), // @Cron / @Interval / @Timeout (Story 4.4 retention)
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60000,
|
||||
@@ -92,6 +98,7 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
MongooseModule.forRoot(config.mongoose.url),
|
||||
DatabaseModule,
|
||||
GraphqlModule,
|
||||
ForkRegistryModule,
|
||||
BlockchainModule,
|
||||
GeneratorInfrastructureModule,
|
||||
RedisModule,
|
||||
@@ -100,9 +107,18 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
EventsInfrastructureModule,
|
||||
FreeDecisionInfrastructureModule,
|
||||
DecisionTrackingInfrastructureModule,
|
||||
FileStorageInfrastructureModule.forRoot({
|
||||
endpoint: config.file_storage.endpoint,
|
||||
accessKey: config.file_storage.access_key,
|
||||
secretKey: config.file_storage.secret_key,
|
||||
bucket: config.file_storage.bucket,
|
||||
signingSecret: config.file_storage.signing_secret,
|
||||
publicBaseUrl: config.file_storage.public_base_url,
|
||||
}),
|
||||
// Domain modules
|
||||
AuthDomainModule,
|
||||
RegistrationDomainModule,
|
||||
OnboardingDomainModule,
|
||||
AgendaDomainModule,
|
||||
AccountDomainModule,
|
||||
AccountInfrastructureModule,
|
||||
@@ -156,6 +172,7 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
TokenApplicationModule,
|
||||
SettingsApplicationModule,
|
||||
RegistrationModule,
|
||||
OnboardingApplicationModule,
|
||||
SearchModule,
|
||||
],
|
||||
providers: [
|
||||
|
||||
@@ -121,7 +121,7 @@ export class AgreementService {
|
||||
* Программные DTO синтезируются: `id`/`document` отсутствуют (полный документ
|
||||
* лежит в action data, а не в state); `status=CONFIRMED` (запись в `users`
|
||||
* существует только для подписанных соглашений). Поле `type` берётся из
|
||||
* `soviet::coagreements[program_id].type` (тот же 'wallet'/'blagorost' и т.п.,
|
||||
* `soviet::coagreements[program_id].type` (тот же 'wallet'/'capital' и т.п.,
|
||||
* который виджет `RequireAgreements` использует для матчинга подписи); если
|
||||
* программа без коагримента — fallback `'programmatic'`.
|
||||
*/
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { InputType, Field, IntersectionType, OmitType } from '@nestjs/graphql';
|
||||
import { IsString, IsBoolean } from 'class-validator';
|
||||
import { Cooperative } from 'cooptypes';
|
||||
import { GenerateMetaDocumentInputDTO } from '~/application/document/dto/generate-meta-document-input.dto';
|
||||
import { MetaDocumentInputDTO } from '~/application/document/dto/meta-document-input.dto';
|
||||
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
|
||||
|
||||
// утилита для выборки повторяющихся параметров из базовых интерфейсов
|
||||
type ExcludeCommonProps<T> = Omit<T, 'coopname' | 'username' | 'registry_id' | 'appendix_hash'>;
|
||||
|
||||
// интерфейс параметров для генерации
|
||||
type action = Cooperative.Registry.GenerationConvertStatement.Action;
|
||||
|
||||
@InputType(`BaseGenerationConvertStatementMetaDocumentInput`)
|
||||
class BaseGenerationConvertStatementMetaDocumentInputDTO implements ExcludeCommonProps<action> {
|
||||
@Field({ description: 'Хэш проекта' })
|
||||
@IsString()
|
||||
project_hash!: string;
|
||||
|
||||
@Field({ description: 'Сумма для перевода в Цифровой Кошелёк' })
|
||||
@IsString()
|
||||
main_wallet_amount!: string;
|
||||
|
||||
@Field({ description: 'Сумма для перевода в программу «Благорост»' })
|
||||
@IsString()
|
||||
blagorost_wallet_amount!: string;
|
||||
|
||||
@Field({ description: 'Признак перевода в Цифровой Кошелёк' })
|
||||
@IsBoolean()
|
||||
to_wallet!: boolean;
|
||||
|
||||
@Field({ description: 'Признак перевода в программу «Благорост»' })
|
||||
@IsBoolean()
|
||||
to_blagorost!: boolean;
|
||||
}
|
||||
|
||||
@InputType(`GenerationConvertStatementGenerateDocumentInput`)
|
||||
export class GenerationConvertStatementGenerateDocumentInputDTO extends IntersectionType(
|
||||
BaseGenerationConvertStatementMetaDocumentInputDTO,
|
||||
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
|
||||
) {
|
||||
registry_id!: number;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@InputType(`GenerationConvertStatementSignedMetaDocumentInput`)
|
||||
export class GenerationConvertStatementSignedMetaDocumentInputDTO
|
||||
extends IntersectionType(BaseGenerationConvertStatementMetaDocumentInputDTO, MetaDocumentInputDTO)
|
||||
implements action {
|
||||
@Field({ description: 'Хэш приложения к проекту' })
|
||||
@IsString()
|
||||
appendix_hash!: string;
|
||||
}
|
||||
|
||||
@InputType(`GenerationConvertStatementSignedDocumentInput`)
|
||||
export class GenerationConvertStatementSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
|
||||
@Field(() => GenerationConvertStatementSignedMetaDocumentInputDTO, {
|
||||
description: 'Метаинформация для документа заявления о конвертации целевого паевого взноса',
|
||||
})
|
||||
public readonly meta!: GenerationConvertStatementSignedMetaDocumentInputDTO;
|
||||
}
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
import { InputType, Field, IntersectionType, OmitType } from '@nestjs/graphql';
|
||||
import { IsString, IsBoolean } from 'class-validator';
|
||||
import { Cooperative } from 'cooptypes';
|
||||
import { GenerateMetaDocumentInputDTO } from '~/application/document/dto/generate-meta-document-input.dto';
|
||||
import { MetaDocumentInputDTO } from '~/application/document/dto/meta-document-input.dto';
|
||||
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
|
||||
|
||||
// утилита для выборки повторяющихся параметров из базовых интерфейсов
|
||||
type ExcludeCommonProps<T> = Omit<T, 'coopname' | 'username' | 'registry_id'>;
|
||||
|
||||
// интерфейс параметров для генерации
|
||||
type action = Cooperative.Registry.GenerationToMainWalletConvertStatement.Action;
|
||||
|
||||
@InputType(`BaseGenerationToMainWalletConvertStatementMetaDocumentInput`)
|
||||
class BaseGenerationToMainWalletConvertStatementMetaDocumentInputDTO implements ExcludeCommonProps<action> {
|
||||
@Field({ description: 'Хэш приложения' })
|
||||
@IsString()
|
||||
appendix_hash!: string;
|
||||
|
||||
@Field({ description: 'Хэш проекта' })
|
||||
@IsString()
|
||||
project_hash!: string;
|
||||
|
||||
@Field({ description: 'Сумма для перевода на основной кошелек' })
|
||||
@IsString()
|
||||
main_wallet_amount!: string;
|
||||
|
||||
@Field({ description: 'Сумма для перевода на благорост кошелек' })
|
||||
@IsString()
|
||||
blagorost_wallet_amount!: string;
|
||||
|
||||
@Field({ description: 'Перевод на основной кошелек' })
|
||||
@IsBoolean()
|
||||
to_wallet!: boolean;
|
||||
|
||||
@Field({ description: 'Перевод на благорост кошелек' })
|
||||
@IsBoolean()
|
||||
to_blagorost!: boolean;
|
||||
}
|
||||
|
||||
@InputType(`GenerationToMainWalletConvertStatementGenerateDocumentInput`)
|
||||
export class GenerationToMainWalletConvertStatementGenerateDocumentInputDTO extends IntersectionType(
|
||||
BaseGenerationToMainWalletConvertStatementMetaDocumentInputDTO,
|
||||
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
|
||||
) {
|
||||
registry_id!: number;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@InputType(`GenerationToMainWalletConvertStatementSignedMetaDocumentInput`)
|
||||
export class GenerationToMainWalletConvertStatementSignedMetaDocumentInputDTO
|
||||
extends IntersectionType(BaseGenerationToMainWalletConvertStatementMetaDocumentInputDTO, MetaDocumentInputDTO)
|
||||
implements action {}
|
||||
|
||||
@InputType(`GenerationToMainWalletConvertStatementSignedDocumentInput`)
|
||||
export class GenerationToMainWalletConvertStatementSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
|
||||
@Field(() => GenerationToMainWalletConvertStatementSignedMetaDocumentInputDTO, {
|
||||
description: 'Метаинформация для документа заявления о переводе части целевого паевого взноса',
|
||||
})
|
||||
public readonly meta!: GenerationToMainWalletConvertStatementSignedMetaDocumentInputDTO;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common';
|
||||
import { GatewayResolver } from './resolvers/gateway.resolver';
|
||||
import { GatewayService } from './services/gateway.service';
|
||||
import { PaymentNotificationService } from './services/payment-notification.service';
|
||||
import { WithdrawAuthorizationListener } from './services/withdraw-authorization.listener';
|
||||
import { PaymentController } from './controllers/payment.controller';
|
||||
import { GatewayInteractor } from './interactors/gateway.interactor';
|
||||
import { GatewayNotificationHandler } from './handlers/gateway-notification.handler';
|
||||
@@ -27,7 +28,14 @@ import { RedisModule } from '~/infrastructure/redis/redis.module';
|
||||
RedisModule,
|
||||
],
|
||||
controllers: [PaymentController],
|
||||
providers: [GatewayResolver, GatewayService, PaymentNotificationService, GatewayInteractor, GatewayNotificationHandler],
|
||||
providers: [
|
||||
GatewayResolver,
|
||||
GatewayService,
|
||||
PaymentNotificationService,
|
||||
GatewayInteractor,
|
||||
GatewayNotificationHandler,
|
||||
WithdrawAuthorizationListener,
|
||||
],
|
||||
exports: [GatewayService, PaymentNotificationService, GatewayInteractor],
|
||||
})
|
||||
export class GatewayModule {}
|
||||
|
||||
@@ -482,7 +482,10 @@ export class GatewayInteractor {
|
||||
type: PaymentTypeEnum.WITHDRAWAL,
|
||||
direction: PaymentDirectionEnum.OUTGOING,
|
||||
provider: provider,
|
||||
status: PaymentStatusEnum.PENDING,
|
||||
// Платёж создаётся до решения совета и не должен видеться кассиром как
|
||||
// готовый к выплате. Переход AWAITING_AUTHORIZATION → PENDING происходит
|
||||
// в WithdrawAuthorizationListener при on-chain action wallet::authwthd.
|
||||
status: PaymentStatusEnum.AWAITING_AUTHORIZATION,
|
||||
memo: `Возврат паевого взноса №${data.payment_hash.slice(0, 8)}`,
|
||||
secret: generateUniqueHash(),
|
||||
payment_method_id: data.method_id,
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { PAYMENT_REPOSITORY, PaymentRepository } from '~/domain/gateway/repositories/payment.repository';
|
||||
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
|
||||
import type { ActionDomainInterface } from '~/domain/parser/interfaces/action-domain.interface';
|
||||
|
||||
/**
|
||||
* Переключает статус исходящего платежа в зависимости от on-chain решения совета.
|
||||
*
|
||||
* Платёж создаётся в PG со статусом AWAITING_AUTHORIZATION в момент подачи
|
||||
* заявки на возврат (wallet.interactor.ts::createWithdraw → gateway.createWithdraw).
|
||||
* До тех пор пока совет не утвердил выплату — кассир такой платёж не видит.
|
||||
*
|
||||
* - wallet::authwthd → AWAITING_AUTHORIZATION → PENDING (кассир видит, может подтвердить).
|
||||
* - wallet::declinewthd → AWAITING_AUTHORIZATION → CANCELLED.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WithdrawAuthorizationListener {
|
||||
private readonly logger = new Logger(WithdrawAuthorizationListener.name);
|
||||
|
||||
constructor(
|
||||
@Inject(PAYMENT_REPOSITORY)
|
||||
private readonly paymentRepository: PaymentRepository,
|
||||
) {}
|
||||
|
||||
@OnEvent('action::wallet::authwthd')
|
||||
async onAuthWithdraw(action: ActionDomainInterface): Promise<void> {
|
||||
const withdraw_hash = action?.data?.withdraw_hash as string | undefined;
|
||||
if (!withdraw_hash) return;
|
||||
|
||||
const payment = await this.paymentRepository.findByHash(withdraw_hash);
|
||||
if (!payment || !payment.id) {
|
||||
this.logger.warn(`authwthd: платёж по hash=${withdraw_hash} не найден — пропуск`);
|
||||
return;
|
||||
}
|
||||
if (payment.status !== PaymentStatusEnum.AWAITING_AUTHORIZATION) {
|
||||
this.logger.debug(`authwthd: платёж ${payment.id} в статусе ${payment.status}, перевод не требуется`);
|
||||
return;
|
||||
}
|
||||
await this.paymentRepository.setPaymentStatus(payment.id, PaymentStatusEnum.PENDING);
|
||||
this.logger.log(`authwthd: платёж ${payment.id} → PENDING (совет авторизовал выплату)`);
|
||||
}
|
||||
|
||||
@OnEvent('action::wallet::declinewthd')
|
||||
async onDeclineWithdraw(action: ActionDomainInterface): Promise<void> {
|
||||
const withdraw_hash = action?.data?.withdraw_hash as string | undefined;
|
||||
if (!withdraw_hash) return;
|
||||
|
||||
const payment = await this.paymentRepository.findByHash(withdraw_hash);
|
||||
if (!payment || !payment.id) return;
|
||||
if (payment.status === PaymentStatusEnum.COMPLETED || payment.status === PaymentStatusEnum.CANCELLED) return;
|
||||
|
||||
await this.paymentRepository.setPaymentStatus(payment.id, PaymentStatusEnum.CANCELLED);
|
||||
this.logger.log(`declinewthd: платёж ${payment.id} → CANCELLED (совет/gateway отклонил)`);
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
@InputType('CompleteExtensionOnboardingStepInput')
|
||||
export class CompleteExtensionOnboardingStepInputDTO {
|
||||
@Field(() => String, { description: 'Имя расширения' })
|
||||
extension_name!: string;
|
||||
|
||||
@Field(() => String, { description: 'Ключ шага из реестра онбординга' })
|
||||
step_key!: string;
|
||||
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Заголовок предлагаемого совету решения (опционально, если не задано — берётся default_title шага)',
|
||||
})
|
||||
title?: string;
|
||||
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Вопрос предлагаемого совету решения (для generator=free_decision)',
|
||||
})
|
||||
question?: string;
|
||||
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Текст принимаемого решения (для generator=free_decision)',
|
||||
})
|
||||
decision?: string;
|
||||
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Hash повестки общего собрания (для generator=meet)',
|
||||
})
|
||||
proposal_hash?: string;
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { Field, ObjectType, Int } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('ExtensionOnboardingStepState')
|
||||
export class ExtensionOnboardingStepStateDTO {
|
||||
@Field(() => String, { description: 'Идентификатор шага (соответствует config-полю onboarding_<step_key>_done)' })
|
||||
step_key!: string;
|
||||
|
||||
@Field(() => Boolean, { description: 'Завершён ли шаг (ратифицирован)' })
|
||||
done!: boolean;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Hash опубликованного документа шага' })
|
||||
hash!: string | null;
|
||||
|
||||
@Field(() => Int, { description: 'Порядок отображения' })
|
||||
order!: number;
|
||||
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Заголовок шага по умолчанию (если payload не передал свой)',
|
||||
})
|
||||
default_title!: string | null;
|
||||
}
|
||||
|
||||
@ObjectType('ExtensionOnboardingState')
|
||||
export class ExtensionOnboardingStateDTO {
|
||||
@Field(() => String, { description: 'Имя расширения' })
|
||||
extension_name!: string;
|
||||
|
||||
@Field(() => [ExtensionOnboardingStepStateDTO], { description: 'Список шагов онбординга в порядке отображения' })
|
||||
steps!: ExtensionOnboardingStepStateDTO[];
|
||||
|
||||
@Field(() => String, { description: 'Момент старта онбординга (ISO)' })
|
||||
onboarding_init_at!: string;
|
||||
|
||||
@Field(() => String, { description: 'Момент истечения TTL онбординга (ISO)' })
|
||||
onboarding_expire_at!: string;
|
||||
|
||||
@Field(() => Boolean, { description: 'Все шаги завершены' })
|
||||
all_done!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './extension-onboarding-state.dto';
|
||||
export * from './complete-extension-onboarding-step.input';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ExtensionOnboardingResolver } from './resolvers/extension-onboarding.resolver';
|
||||
|
||||
/**
|
||||
* Application-модуль платформенного онбординга расширений: содержит
|
||||
* GraphQL-резолвер. ExtensionOnboardingService доступен глобально из
|
||||
* OnboardingDomainModule.
|
||||
*/
|
||||
@Module({
|
||||
providers: [ExtensionOnboardingResolver],
|
||||
})
|
||||
export class OnboardingApplicationModule {}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
|
||||
import { RolesGuard } from '~/application/auth/guards/roles.guard';
|
||||
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
|
||||
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
|
||||
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
|
||||
import { ExtensionOnboardingService } from '~/domain/onboarding/services/extension-onboarding.service';
|
||||
import { ExtensionOnboardingStateDTO } from '../dto/extension-onboarding-state.dto';
|
||||
import { CompleteExtensionOnboardingStepInputDTO } from '../dto/complete-extension-onboarding-step.input';
|
||||
|
||||
/**
|
||||
* Универсальный GraphQL-резолвер платформенного онбординга кооператива
|
||||
* на расширение. Любое расширение, зарегистрировавшее шаги через
|
||||
* ONBOARDING_STEP_REGISTRATION_PORT в initialize(), сразу же
|
||||
* пользуется этим endpoint'ом без своего DTO/резолвера.
|
||||
*
|
||||
* Query open для chairman/member/user — frontend (включая обычного
|
||||
* пайщика) должен уметь увидеть, что кооператив ещё не онбординулся.
|
||||
* Mutation — только chairman (онбординг кооператива — компетенция
|
||||
* председателя).
|
||||
*/
|
||||
@Resolver()
|
||||
export class ExtensionOnboardingResolver {
|
||||
constructor(private readonly onboardingService: ExtensionOnboardingService) {}
|
||||
|
||||
@Query(() => ExtensionOnboardingStateDTO, {
|
||||
name: 'getExtensionOnboardingState',
|
||||
description: 'Получить состояние онбординга кооператива на расширение',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman', 'member', 'user'])
|
||||
async getState(
|
||||
@Args('extension_name', { type: () => String }) extension_name: string
|
||||
): Promise<ExtensionOnboardingStateDTO> {
|
||||
const state = await this.onboardingService.getState(extension_name);
|
||||
return state as ExtensionOnboardingStateDTO;
|
||||
}
|
||||
|
||||
@Mutation(() => ExtensionOnboardingStateDTO, {
|
||||
name: 'completeExtensionOnboardingStep',
|
||||
description:
|
||||
'Выполнить шаг онбординга кооператива на расширение (решение совета или общее собрание)',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman'])
|
||||
async completeStep(
|
||||
@Args('data', { type: () => CompleteExtensionOnboardingStepInputDTO })
|
||||
data: CompleteExtensionOnboardingStepInputDTO,
|
||||
@CurrentUser() currentUser: MonoAccountDomainInterface
|
||||
): Promise<ExtensionOnboardingStateDTO> {
|
||||
const state = await this.onboardingService.completeStep(
|
||||
{
|
||||
extension_name: data.extension_name,
|
||||
step_key: data.step_key,
|
||||
title: data.title,
|
||||
question: data.question,
|
||||
decision: data.decision,
|
||||
proposal_hash: data.proposal_hash,
|
||||
},
|
||||
currentUser?.username
|
||||
);
|
||||
return state as ExtensionOnboardingStateDTO;
|
||||
}
|
||||
}
|
||||
+15
-8
@@ -262,7 +262,13 @@ export class ParticipantInteractor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразует AgreementId в соответствующий DocumentType
|
||||
* Преобразует agreementId в соответствующий DocumentType.
|
||||
*
|
||||
* Платформенные оферты (signature/wallet/user/privacy) сопоставляются
|
||||
* по AgreementId enum явно. Оферты расширений (например 'blagorost_offer',
|
||||
* 'generator_offer' от capital) сопоставляются identity-фолбэком —
|
||||
* строковое значение agreementId должно совпасть с одним из значений
|
||||
* DocumentType. Это позволяет ядру не знать о капитал-специфике.
|
||||
*/
|
||||
private mapAgreementIdToDocumentType(agreementId: string): DocumentType | null {
|
||||
switch (agreementId) {
|
||||
@@ -274,14 +280,15 @@ export class ParticipantInteractor {
|
||||
return DocumentType.USER_AGREEMENT;
|
||||
case AgreementId.PRIVACY_AGREEMENT:
|
||||
return DocumentType.PRIVACY_AGREEMENT;
|
||||
case AgreementId.BLAGOROST_OFFER:
|
||||
return DocumentType.BLAGOROST_OFFER;
|
||||
case AgreementId.GENERATOR_OFFER:
|
||||
return DocumentType.GENERATOR_OFFER;
|
||||
default:
|
||||
this.logger.warn(`Неизвестный agreementId: ${agreementId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const documentTypeValues = Object.values(DocumentType) as string[];
|
||||
if (documentTypeValues.includes(agreementId)) {
|
||||
return agreementId as DocumentType;
|
||||
}
|
||||
|
||||
this.logger.warn(`Неизвестный agreementId: ${agreementId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
async addParticipant(data: AddParticipantDomainInterface): Promise<AccountDomainEntity> {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ObjectType, Field, Int } from '@nestjs/graphql';
|
||||
import { IsArray, IsBoolean, IsNumber, IsString } from 'class-validator';
|
||||
import { AccountType } from '~/application/account/enum/account-type.enum';
|
||||
import type { IAgreementConfigItem } from '~/domain/registration/config/agreement-config.interface';
|
||||
|
||||
/**
|
||||
* GraphQL DTO спецификации оферты, требуемой при регистрации.
|
||||
*
|
||||
* Сливает платформенные оферты (signature/wallet/user/privacy) с
|
||||
* extension-зарегистрированными (например, capital → generator_offer,
|
||||
* blagorost_offer) — для SignUp на фронте конфигурация неотличима.
|
||||
*
|
||||
* Не путать с `AgreementDTO` (это уже on-chain подписанное соглашение
|
||||
* конкретного пайщика). Здесь — описание чекбокса и его документа,
|
||||
* который ещё предстоит сгенерировать и подписать.
|
||||
*/
|
||||
@ObjectType('RegistrationAgreement', {
|
||||
description: 'Описание оферты, которую пайщик должен принять при регистрации',
|
||||
})
|
||||
export class RegistrationAgreementDTO {
|
||||
@Field({ description: 'Строковый идентификатор оферты' })
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@Field({ description: 'Тип соглашения для on-chain sendAgreement' })
|
||||
@IsString()
|
||||
agreement_type!: string;
|
||||
|
||||
@Field(() => Int, { description: 'registry_id шаблона на фабрике документов' })
|
||||
@IsNumber()
|
||||
registry_id!: number;
|
||||
|
||||
@Field({ description: 'Человекочитаемое название оферты' })
|
||||
@IsString()
|
||||
title!: string;
|
||||
|
||||
@Field({ description: 'Текст для галочки' })
|
||||
@IsString()
|
||||
checkbox_text!: string;
|
||||
|
||||
@Field({ description: 'Текст ссылки для открытия диалога чтения' })
|
||||
@IsString()
|
||||
link_text!: string;
|
||||
|
||||
@Field({ description: 'Нужно ли отправлять в блокчейн через sendAgreement' })
|
||||
@IsBoolean()
|
||||
is_blockchain_agreement!: boolean;
|
||||
|
||||
@Field({ description: 'Нужно ли линковать хеш документа в заявление' })
|
||||
@IsBoolean()
|
||||
link_to_statement!: boolean;
|
||||
|
||||
@Field(() => [AccountType], {
|
||||
description: 'Типы аккаунтов, для которых оферта применима',
|
||||
})
|
||||
@IsArray()
|
||||
applicable_account_types!: AccountType[];
|
||||
|
||||
@Field(() => Int, { description: 'Порядок отображения' })
|
||||
@IsNumber()
|
||||
order!: number;
|
||||
|
||||
constructor(data?: IAgreementConfigItem) {
|
||||
if (data) {
|
||||
this.id = data.id;
|
||||
this.agreement_type = data.agreement_type;
|
||||
this.registry_id = data.registry_id;
|
||||
this.title = data.title;
|
||||
this.checkbox_text = data.checkbox_text;
|
||||
this.link_text = data.link_text;
|
||||
this.is_blockchain_agreement = data.is_blockchain_agreement;
|
||||
this.link_to_statement = data.link_to_statement;
|
||||
this.applicable_account_types = data.applicable_account_types;
|
||||
this.order = data.order;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import { GatewayPaymentDTO } from '~/application/gateway/dto/gateway-payment.dto
|
||||
import { CreateInitialPaymentInputDTO } from '~/application/gateway/dto/create-initial-payment-input.dto';
|
||||
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
|
||||
import { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
|
||||
import { RegistrationAgreementDTO } from '../dto/registration-agreement.dto';
|
||||
import { AccountType } from '~/application/account/enum/account-type.enum';
|
||||
|
||||
// Пагинированные результаты
|
||||
const paginatedCandidatesResult = createPaginationResult(CandidateOutputDTO, 'PaginatedCandidates');
|
||||
@@ -47,6 +49,24 @@ export class RegistrationResolver {
|
||||
return await this.registrationService.getCandidates(currentUser, filter, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Список оферт, требуемых при регистрации для данного типа аккаунта
|
||||
* (и опционально программы). Сливает платформенные + extension-
|
||||
* зарегистрированные через AgreementRegistry.
|
||||
*/
|
||||
@Query(() => [RegistrationAgreementDTO], {
|
||||
name: 'getRegistrationAgreements',
|
||||
description:
|
||||
'Получить список оферт для регистрации пайщика заданного типа аккаунта и (опционально) программы. Сливает базовые платформенные оферты с теми, что зарегистрировали расширения.',
|
||||
})
|
||||
async getRegistrationAgreements(
|
||||
@Args('coopname', { type: () => String }) coopname: string,
|
||||
@Args('account_type', { type: () => AccountType }) accountType: AccountType,
|
||||
@Args('program_key', { type: () => String, nullable: true }) programKey?: string
|
||||
): Promise<RegistrationAgreementDTO[]> {
|
||||
return this.registrationService.getRegistrationAgreements(coopname, accountType, programKey);
|
||||
}
|
||||
|
||||
@Mutation(() => GenerateRegistrationDocumentsOutputDTO, {
|
||||
name: 'generateRegistrationDocuments',
|
||||
description:
|
||||
|
||||
+20
-1
@@ -22,6 +22,9 @@ import { GeneratedDocumentDTO } from '~/application/document/dto/generated-docum
|
||||
import { ParticipantApplicationDecisionGenerateDocumentInputDTO } from '~/application/document/documents-dto/participant-application-decision-document.dto';
|
||||
import { ACCOUNT_DATA_PORT, AccountDataPort } from '~/domain/account/ports/account-data.port';
|
||||
import { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
|
||||
import { AGREEMENT_QUERY_PORT, AgreementQueryPort } from '~/domain/registration/ports/agreement-query.port';
|
||||
import { RegistrationAgreementDTO } from '../dto/registration-agreement.dto';
|
||||
import { AccountType } from '~/application/account/enum/account-type.enum';
|
||||
|
||||
@Injectable()
|
||||
export class RegistrationService {
|
||||
@@ -35,9 +38,25 @@ export class RegistrationService {
|
||||
@Inject(USER_CERTIFICATE_DOMAIN_PORT)
|
||||
private readonly userCertificateDomainPort: UserCertificateDomainPort,
|
||||
@Inject(ACCOUNT_DATA_PORT)
|
||||
private readonly accountDataPort: AccountDataPort
|
||||
private readonly accountDataPort: AccountDataPort,
|
||||
@Inject(AGREEMENT_QUERY_PORT)
|
||||
private readonly agreementQueryPort: AgreementQueryPort
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Возвращает список оферт, которые пайщик должен принять при
|
||||
* регистрации (сливает платформенные + extension-зарегистрированные).
|
||||
*/
|
||||
getRegistrationAgreements(
|
||||
coopname: string,
|
||||
accountType: AccountType,
|
||||
programKey?: string
|
||||
): RegistrationAgreementDTO[] {
|
||||
return this.agreementQueryPort
|
||||
.getAgreementsForAccountType(accountType, coopname, programKey)
|
||||
.map((item) => new RegistrationAgreementDTO(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерация пакета документов для регистрации
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
import { ValidateNested } from 'class-validator';
|
||||
import { IsBoolean, IsOptional, ValidateNested } from 'class-validator';
|
||||
import { CreateInitOrganizationDataInputDTO } from '~/application/account/dto/create-organization-data-input.dto';
|
||||
|
||||
@InputType('Init')
|
||||
@@ -9,4 +9,13 @@ export class InitDTO {
|
||||
})
|
||||
@ValidateNested()
|
||||
organization_data!: CreateInitOrganizationDataInputDTO;
|
||||
|
||||
@Field(() => Boolean, {
|
||||
nullable: true,
|
||||
description:
|
||||
'Признак того, что инициализация выполняется со стороны провайдера. При true coopback ставит init_by_server=true (org_data становится readonly для пользовательского визарда). Поле передаёт provider в callInitSystemMutation.',
|
||||
})
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
is_server_init?: boolean;
|
||||
}
|
||||
|
||||
@@ -27,10 +27,14 @@ export class InitInteractor {
|
||||
}
|
||||
|
||||
// Определяем тип инициализации:
|
||||
// - Если mono не существует - это ПЕРВАЯ инициализация (может быть как серверная так и пользовательская)
|
||||
// - Если existingMono.init_by_server уже установлен - сохраняем его (не меняем источник)
|
||||
// - Если mono существует но init_by_server не установлен - значит это пользовательская инициализация
|
||||
const isServerInit = !existingMono ? true : existingMono.init_by_server === true;
|
||||
// - Если data.is_server_init === true (вызов от провайдера через server-secret) — ВСЕГДА серверная.
|
||||
// Это разблокирует перезапись user-init данных провайдером при следующем initSystem.
|
||||
// - Иначе сохраняем прежнюю логику: первая инициализация = серверная, повторная — наследует флаг.
|
||||
const isServerInit = data.is_server_init === true
|
||||
? true
|
||||
: !existingMono
|
||||
? true
|
||||
: existingMono.init_by_server === true;
|
||||
|
||||
// Проверяем права на обновление:
|
||||
// Пользователь не может обновлять данные, установленные сервером
|
||||
@@ -58,11 +62,11 @@ export class InitInteractor {
|
||||
await this.monoStatusRepository.setStatus(SystemStatus.initialized);
|
||||
}
|
||||
|
||||
// Устанавливаем флаг источника инициализации только если это первая инициализация
|
||||
// - Для серверной инициализации (первый раз): устанавливаем true
|
||||
// - Для пользовательской инициализации (первый раз): устанавливаем false
|
||||
// - При повторной инициализации: НЕ меняем флаг (сохраняем изначальный источник)
|
||||
if (!existingMono) {
|
||||
// Устанавливаем флаг источника инициализации:
|
||||
// - Первая инициализация: ставим isServerInit как есть (true для server-side, true для user-side при первом init — историческое поведение).
|
||||
// - Повторная: если is_server_init=true пришёл от провайдера — поднимаем флаг в true (даже если до этого было user-init).
|
||||
// Это нужно чтобы провайдер мог перезаписать данные после того как пользователь успел заполнить форму первым.
|
||||
if (!existingMono || data.is_server_init === true) {
|
||||
await this.monoStatusRepository.setInitByServer(isServerInit);
|
||||
}
|
||||
|
||||
|
||||
@@ -192,6 +192,14 @@ export class InstallInteractor {
|
||||
users.push(user);
|
||||
}
|
||||
|
||||
// Лаг p2p-репликации на не-producer nodeos (например на dev-loop'е
|
||||
// partner-coopback соединён со своим p2p-репликой) — после accept'а
|
||||
// adduser в local state может ещё не быть soviet::participants[N]
|
||||
// к моменту push'а createBoard. Контракт soviet::createboard падает
|
||||
// «Один из аккаунтов не найден в реестре пайщиков». 2с гарантированно
|
||||
// перекрывают prod-задержку (~50ms) и dev-loop (1-3с).
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Создаём доску совета
|
||||
await this.blockchainPort.createBoard({
|
||||
coopname: config.coopname,
|
||||
|
||||
@@ -110,19 +110,10 @@ export class SystemService {
|
||||
* @returns конфигурация программ регистрации
|
||||
*/
|
||||
public async getRegistrationConfig(coopname: string, accountType: AccountType): Promise<RegistrationConfigDTO> {
|
||||
const config = this.agreementConfigService.getCooperativeProgramsConfig(coopname);
|
||||
|
||||
if (!config) {
|
||||
return new RegistrationConfigDTO({
|
||||
requires_selection: false,
|
||||
programs: [],
|
||||
});
|
||||
}
|
||||
|
||||
const programs = this.agreementConfigService.getAvailablePrograms(coopname, accountType);
|
||||
|
||||
return new RegistrationConfigDTO({
|
||||
requires_selection: config.requires_selection && programs.length > 1,
|
||||
requires_selection: programs.length > 1,
|
||||
programs,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,12 +49,4 @@ export class ProgramWalletSyncService
|
||||
|
||||
this.logger.debug('Сервис синхронизации программных кошельков полностью инициализирован с подписками на паттерны');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для программных кошельков
|
||||
* Подписывается на все форки независимо от контракта
|
||||
*/
|
||||
async handleProgramWalletFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,8 +93,9 @@ const envVarsSchema = z.object({
|
||||
.default('https://союз-русь.рф/anketa')
|
||||
.describe('ссылка на анкету для получения членства в союзе кооперативов'),
|
||||
IS_UNIONED: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.string()
|
||||
.default('true')
|
||||
.transform((v) => v === 'true')
|
||||
.describe('флаг, указывающий что требуется членство в союзе для подключения к кооперативной экономике'),
|
||||
MATRIX_UNION_PERSON_ID: z.string().optional().describe('Matrix userId представителя союза для связи с кооперативами'),
|
||||
MATRIX_UNION_NAME: z.string().default('СПО РУСЬ').describe('Название союза для подписания комнат связи'),
|
||||
@@ -122,7 +123,41 @@ const envVarsSchema = z.object({
|
||||
.string()
|
||||
.default('1000')
|
||||
.transform((val) => parseInt(val, 10)),
|
||||
|
||||
/**
|
||||
* Задержка (мс) перед emit'ом action-события во внутреннюю шину. Даёт
|
||||
* дельтам того же блока сохраниться в БД раньше, чем обработчики action
|
||||
* полезут читать состояние (DEC-007, ранее хардкод-константа 3000).
|
||||
*/
|
||||
BLOCKCHAIN_ACTION_EMIT_DELAY_MS: z
|
||||
.string()
|
||||
.default('3000')
|
||||
.transform((val) => parseInt(val, 10)),
|
||||
/**
|
||||
* Story 4.4: глобальный выключатель ежечасного retention-крона архива
|
||||
* invalidated_entities/invalidated_entity_versions. На малом объёме (нынешний
|
||||
* кооператив) данные могут копиться годами — отключить кроном.
|
||||
*/
|
||||
BLOCKCHAIN_ARCHIVE_RETENTION_ENABLED: z
|
||||
.string()
|
||||
.default('true')
|
||||
.transform((v) => v === 'true'),
|
||||
/**
|
||||
* Story 4.4: cron-расписание retention. Default — ежечасно. RETENTION_HORIZON_BLOCKS
|
||||
* (=1000) хардкод в BlockchainArchiveRetentionService — не вынесен в env намеренно
|
||||
* (свойство сети, не оператора).
|
||||
*/
|
||||
BLOCKCHAIN_ARCHIVE_RETENTION_CRON: z.string().default('0 * * * *'),
|
||||
/**
|
||||
* Story 6.5: при `true` mapper-fail (mapDeltaToBlockchainData → null) перестаёт
|
||||
* быть silent loss и поднимается `UnsupportedContractVersionError` из
|
||||
* `AbstractEntitySyncService.processDelta`. Парсер не ACK'ает delta — DLQ
|
||||
* сработает. Default `false` для не-ломать-прод-немедленно; включается после
|
||||
* подтверждения, что schema drift отсутствует (например на стенде).
|
||||
*/
|
||||
BLOCKCHAIN_UNSUPPORTED_VERSION_STRICT: z
|
||||
.string()
|
||||
.default('false')
|
||||
.transform((v) => v === 'true'),
|
||||
// Параметры NOVU
|
||||
NOVU_APP_ID: z.string().min(1, { message: 'Не должно быть пустым' }),
|
||||
NOVU_BACKEND_URL: z.string().min(1, { message: 'Не должно быть пустым' }).default('https://novu.coopenomics.world/api'),
|
||||
@@ -170,6 +205,28 @@ const envVarsSchema = z.object({
|
||||
OPENAI_BASE_URL: z.string().optional().describe('Базовый URL для Whisper API (через chatcoop-proxy nginx)'),
|
||||
WHISPER_MODEL: z.string().default('whisper-1').describe('Модель Whisper для STT'),
|
||||
WHISPER_LANGUAGE: z.string().default('ru').describe('Язык для Whisper STT'),
|
||||
|
||||
// Файловое хранилище (MinIO в dev/контуре кооператива; S3 в проде по плану E59-N).
|
||||
// MINIO_ENDPOINT без default: если не задан — file storage стартует в no-op,
|
||||
// HeadBucket не делается, операции get/put отдают понятную ошибку.
|
||||
MINIO_ENDPOINT: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Endpoint S3-совместимого бэкенда; в compose — service name. Пусто = file storage отключён.'),
|
||||
MINIO_ACCESS_KEY: z.string().default('minioadmin').describe('Access-key для MinIO/S3'),
|
||||
MINIO_SECRET_KEY: z.string().default('minioadmin').describe('Secret-key для MinIO/S3'),
|
||||
MINIO_BUCKET: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Имя физического бакета; по умолчанию `coop-${COOPNAME}`'),
|
||||
FILE_STORAGE_SIGNING_SECRET: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('HMAC-секрет для подписи read-URL; пусто — берётся SERVER_SECRET'),
|
||||
FILE_STORAGE_PUBLIC_BASE_URL: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('База публичного URL контроллера для read-URL; пусто — берётся BACKEND_URL'),
|
||||
});
|
||||
|
||||
const envInput = isSchemaGeneration ? { ...SCHEMA_GEN_ENV_DEFAULTS, ...process.env } : process.env;
|
||||
@@ -208,6 +265,10 @@ export default {
|
||||
root_precision: envVars.data.ROOT_PRECISION,
|
||||
root_govern_precision: envVars.data.ROOT_GOVERN_PRECISION,
|
||||
post_transact_chain_read_delay_ms: envVars.data.POST_TRANSACT_CHAIN_READ_DELAY_MS,
|
||||
action_emit_delay_ms: envVars.data.BLOCKCHAIN_ACTION_EMIT_DELAY_MS,
|
||||
archive_retention_enabled: envVars.data.BLOCKCHAIN_ARCHIVE_RETENTION_ENABLED,
|
||||
archive_retention_cron: envVars.data.BLOCKCHAIN_ARCHIVE_RETENTION_CRON,
|
||||
unsupported_version_strict: envVars.data.BLOCKCHAIN_UNSUPPORTED_VERSION_STRICT,
|
||||
},
|
||||
mongoose: {
|
||||
url: envVars.data.MONGODB_URL + (envVars.data.NODE_ENV === 'test' ? '-test' : ''),
|
||||
@@ -293,4 +354,12 @@ export default {
|
||||
whisper_model: envVars.data.WHISPER_MODEL,
|
||||
whisper_language: envVars.data.WHISPER_LANGUAGE,
|
||||
},
|
||||
file_storage: {
|
||||
endpoint: envVars.data.MINIO_ENDPOINT,
|
||||
access_key: envVars.data.MINIO_ACCESS_KEY,
|
||||
secret_key: envVars.data.MINIO_SECRET_KEY,
|
||||
bucket: envVars.data.MINIO_BUCKET || `coop-${envVars.data.COOPNAME}`,
|
||||
signing_secret: envVars.data.FILE_STORAGE_SIGNING_SECRET || envVars.data.SERVER_SECRET,
|
||||
public_base_url: envVars.data.FILE_STORAGE_PUBLIC_BASE_URL || envVars.data.BACKEND_URL,
|
||||
},
|
||||
};
|
||||
|
||||
+32
-1
@@ -1,7 +1,7 @@
|
||||
// domain/appstore/appstore-lifecycle-domain.service.ts
|
||||
|
||||
import { Injectable, type INestApplication } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||
import { ExtensionDomainService } from '~/domain/extension/services/extension-domain.service';
|
||||
import { ExtensionSchemaMigrationService } from './extension-schema-migration.service';
|
||||
import { AppRegistry } from '~/extensions/extensions.registry';
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
EXTENSION_APP_TERMINATE_EVENT,
|
||||
type ExtensionAppTerminatePayload,
|
||||
} from '~/domain/extension/extension-app-lifecycle.events';
|
||||
import {
|
||||
ONBOARDING_COMPLETED_EVENT,
|
||||
type OnboardingCompletedPayload,
|
||||
} from '~/domain/onboarding/events/onboarding-completed.event';
|
||||
|
||||
@Injectable()
|
||||
export class ExtensionLifecycleDomainService<TConfig = any> {
|
||||
@@ -106,4 +110,31 @@ export class ExtensionLifecycleDomainService<TConfig = any> {
|
||||
await this.runApp(appName);
|
||||
this.logger.info(`Расширение ${appName} перезапущено.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Авто-перезапуск расширения после завершения его L1-онбординга.
|
||||
*
|
||||
* Логика — раздел 7.1 плана C28-10 (вариант (в) auto-restart): сервис
|
||||
* онбординга расширения эмиттит ONBOARDING_COMPLETED_EVENT, когда все
|
||||
* _done флаги в extension.config встали; lifecycle ловит и перезапускает
|
||||
* расширение, чтобы initialize(config) увидел актуальный config и
|
||||
* перерегистрировал оферты/программы в AgreementRegistryService.
|
||||
*
|
||||
* Совет ничего не нажимает.
|
||||
*/
|
||||
@OnEvent(ONBOARDING_COMPLETED_EVENT)
|
||||
async onOnboardingCompleted(payload: OnboardingCompletedPayload) {
|
||||
const { extension_name } = payload;
|
||||
this.logger.info(
|
||||
`[ONBOARDING_COMPLETED] получено событие для ${extension_name}, инициирую restartApp`
|
||||
);
|
||||
try {
|
||||
await this.restartApp(extension_name);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[ONBOARDING_COMPLETED] restartApp(${extension_name}) упал: ${(error as Error).message}`,
|
||||
(error as Error).stack
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ export class PaymentDomainEntity implements PaymentDomainInterface {
|
||||
*/
|
||||
getStatusLabel(): string {
|
||||
const statusLabels: Record<PaymentStatusEnum, string> = {
|
||||
[PaymentStatusEnum.AWAITING_AUTHORIZATION]: 'Ожидает решения совета',
|
||||
[PaymentStatusEnum.PENDING]: 'Ожидает обработки',
|
||||
[PaymentStatusEnum.PROCESSING]: 'В процессе',
|
||||
[PaymentStatusEnum.PAID]: 'Оплачен',
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
* Покрывает все состояния входящих и исходящих платежей
|
||||
*/
|
||||
export enum PaymentStatusEnum {
|
||||
// Для исходящих платежей: совет ещё не утвердил выплату.
|
||||
// Кассиру такие платежи скрываются — он работает только с PENDING.
|
||||
AWAITING_AUTHORIZATION = 'awaiting_authorization',
|
||||
|
||||
// Начальные состояния
|
||||
PENDING = 'pending',
|
||||
|
||||
@@ -26,6 +30,7 @@ export enum PaymentStatusEnum {
|
||||
* Человекочитаемые названия статусов
|
||||
*/
|
||||
export const PAYMENT_STATUS_LABELS: Record<PaymentStatusEnum, string> = {
|
||||
[PaymentStatusEnum.AWAITING_AUTHORIZATION]: 'Ожидает решения совета',
|
||||
[PaymentStatusEnum.PENDING]: 'Ожидает оплаты',
|
||||
[PaymentStatusEnum.PROCESSING]: 'Обрабатывается',
|
||||
[PaymentStatusEnum.PAID]: 'Оплачен',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Платформенный TTL для L1-онбординга расширений.
|
||||
*
|
||||
* Один источник правды для capital и chairman (оба хардкодили
|
||||
* 30 * 24 * 60 * 60 * 1000 в своих модулях). При первом запуске
|
||||
* расширения сервис записывает `onboarding_expire_at` в config
|
||||
* плагина = startedAt + ONBOARDING_EXPIRY_MS; по истечении срока
|
||||
* `*-onboarding-events.service.isOnboardingExpired` возвращает true
|
||||
* и UI/процесс реагирует соответственно.
|
||||
*
|
||||
* Перенос в общий модуль (план C28-10 раздел 4.2) — чтобы изменение
|
||||
* политики (например, продление до 45 дней) не требовало правки
|
||||
* двух модулей и не дрейфовало по копиям.
|
||||
*/
|
||||
|
||||
export const ONBOARDING_EXPIRY_DAYS = 30;
|
||||
|
||||
export const ONBOARDING_EXPIRY_MS = ONBOARDING_EXPIRY_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Время истечения L1-онбординга, отталкиваясь от момента старта.
|
||||
* Возвращает ISO-строку, потому что в config'е плагина хранится строкой.
|
||||
*/
|
||||
export function computeOnboardingExpiresAt(startedAt: Date): string {
|
||||
return new Date(startedAt.getTime() + ONBOARDING_EXPIRY_MS).toISOString();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export type OnboardingStepEventType = 'SOVIET_DECISION' | 'MEET_DECISION';
|
||||
|
||||
export type OnboardingStepGenerator = 'free_decision' | 'meet';
|
||||
|
||||
export interface IExtensionOnboardingStepSpec {
|
||||
extension_name: string;
|
||||
step_key: string;
|
||||
event_type: OnboardingStepEventType;
|
||||
vars_field: string;
|
||||
generator: OnboardingStepGenerator;
|
||||
default_title?: string;
|
||||
order: number;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './extension-onboarding-step-spec';
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Событие: расширение завершило L1-онбординг
|
||||
* (все его шаги в extension.config.* проставлены в _done = true).
|
||||
*
|
||||
* Эмиттится сервисом онбординга расширения сразу после успешного
|
||||
* `completeStep`, когда последний `_done` флаг встал. Слушатель —
|
||||
* ExtensionLifecycleDomainService — автоматически вызывает
|
||||
* `restartApp(extension_name)`, чтобы расширение перерегистрировало
|
||||
* свои оферты/программы через `initialize(config)` с актуальным
|
||||
* содержимым extension.config (раздел 4.2 req 44 проекта 13;
|
||||
* принятая архитектурная предпосылка 7.1 в плане C28-10:
|
||||
* вариант (в) — auto-restart, без ручного действия совета).
|
||||
*/
|
||||
export const ONBOARDING_COMPLETED_EVENT = 'onboarding.completed' as const;
|
||||
|
||||
export interface OnboardingCompletedPayload {
|
||||
/** Имя расширения, у которого завершился L1 (совпадает с appName в AppRegistry). */
|
||||
extension_name: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './dto';
|
||||
export * from './ports';
|
||||
export * from './services';
|
||||
export * from './onboarding-domain.module';
|
||||
export * from './constants/onboarding-ttl';
|
||||
export * from './events/onboarding-completed.event';
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ExtensionDomainModule } from '~/domain/extension/extension-domain.module';
|
||||
import { FreeDecisionInfrastructureModule } from '~/infrastructure/free-decision/free-decision-infrastructure.module';
|
||||
import { DecisionTrackingInfrastructureModule } from '~/infrastructure/decision-tracking/decision-tracking-infrastructure.module';
|
||||
import { OnboardingStepsRegistryService } from './services/onboarding-steps-registry.service';
|
||||
import { ExtensionOnboardingService } from './services/extension-onboarding.service';
|
||||
import { ExtensionOnboardingEventsService } from './services/extension-onboarding-events.service';
|
||||
import { ONBOARDING_STEP_REGISTRATION_PORT } from './ports/onboarding-step-registration.port';
|
||||
import { ONBOARDING_STEP_QUERY_PORT } from './ports/onboarding-step-query.port';
|
||||
|
||||
/**
|
||||
* Глобальный domain-модуль платформенного онбординга кооператива на
|
||||
* расширение.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
ExtensionDomainModule,
|
||||
FreeDecisionInfrastructureModule,
|
||||
DecisionTrackingInfrastructureModule,
|
||||
],
|
||||
providers: [
|
||||
OnboardingStepsRegistryService,
|
||||
{
|
||||
provide: ONBOARDING_STEP_REGISTRATION_PORT,
|
||||
useExisting: OnboardingStepsRegistryService,
|
||||
},
|
||||
{
|
||||
provide: ONBOARDING_STEP_QUERY_PORT,
|
||||
useExisting: OnboardingStepsRegistryService,
|
||||
},
|
||||
ExtensionOnboardingService,
|
||||
ExtensionOnboardingEventsService,
|
||||
],
|
||||
exports: [
|
||||
OnboardingStepsRegistryService,
|
||||
ONBOARDING_STEP_REGISTRATION_PORT,
|
||||
ONBOARDING_STEP_QUERY_PORT,
|
||||
ExtensionOnboardingService,
|
||||
],
|
||||
})
|
||||
export class OnboardingDomainModule {}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './onboarding-step-registration.port';
|
||||
export * from './onboarding-step-query.port';
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { IExtensionOnboardingStepSpec } from '../dto/extension-onboarding-step-spec';
|
||||
|
||||
export interface OnboardingStepQueryPort {
|
||||
getStepsByExtension(extension_name: string): IExtensionOnboardingStepSpec[];
|
||||
getStep(
|
||||
extension_name: string,
|
||||
step_key: string
|
||||
): IExtensionOnboardingStepSpec | null;
|
||||
}
|
||||
|
||||
export const ONBOARDING_STEP_QUERY_PORT = Symbol('OnboardingStepQueryPort');
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import type { IExtensionOnboardingStepSpec } from '../dto/extension-onboarding-step-spec';
|
||||
|
||||
export interface OnboardingStepRegistrationPort {
|
||||
registerStep(spec: IExtensionOnboardingStepSpec): void;
|
||||
unregisterStepsByExtension(extension_name: string): void;
|
||||
}
|
||||
|
||||
export const ONBOARDING_STEP_REGISTRATION_PORT = Symbol(
|
||||
'OnboardingStepRegistrationPort'
|
||||
);
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||
import {
|
||||
EXTENSION_REPOSITORY,
|
||||
type ExtensionDomainRepository,
|
||||
} from '~/domain/extension/repositories/extension-domain.repository';
|
||||
import { DecisionTrackedEvent } from '~/domain/decision-tracking/events/decision-tracked.event';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import {
|
||||
ONBOARDING_COMPLETED_EVENT,
|
||||
type OnboardingCompletedPayload,
|
||||
} from '../events/onboarding-completed.event';
|
||||
import {
|
||||
ONBOARDING_STEP_QUERY_PORT,
|
||||
type OnboardingStepQueryPort,
|
||||
} from '../ports/onboarding-step-query.port';
|
||||
|
||||
/**
|
||||
* Расширения с собственным per-extension events-сервисом
|
||||
* (см. {Capital,Chairman}OnboardingEventsService). Generic-слушатель
|
||||
* пропускает их, чтобы избежать double-update флага и double-emit
|
||||
* ONBOARDING_COMPLETED. После удаления legacy сервисов в одном из
|
||||
* следующих эпиков (когда capital/chairman перестанут хардкодить свои
|
||||
* step-mapping'и) этот список схлопнётся до пустого.
|
||||
*/
|
||||
const LEGACY_EXTENSIONS_WITH_OWN_LISTENER: ReadonlyArray<string> = [
|
||||
'chairman',
|
||||
'capital',
|
||||
];
|
||||
|
||||
const doneKey = (step_key: string) => `onboarding_${step_key}_done`;
|
||||
|
||||
@Injectable()
|
||||
export class ExtensionOnboardingEventsService {
|
||||
constructor(
|
||||
@Inject(EXTENSION_REPOSITORY)
|
||||
private readonly extensionRepository: ExtensionDomainRepository<
|
||||
Record<string, unknown>
|
||||
>,
|
||||
@Inject(ONBOARDING_STEP_QUERY_PORT)
|
||||
private readonly stepsRegistry: OnboardingStepQueryPort,
|
||||
private readonly logger: WinstonLoggerService,
|
||||
private readonly eventEmitter: EventEmitter2
|
||||
) {
|
||||
this.logger.setContext(ExtensionOnboardingEventsService.name);
|
||||
}
|
||||
|
||||
@OnEvent(DecisionTrackedEvent.eventName)
|
||||
async handleDecisionTracked(event: DecisionTrackedEvent): Promise<void> {
|
||||
const { result } = event;
|
||||
if (!result.metadata?.onboarding_step || !result.metadata.extension) return;
|
||||
|
||||
const extension_name = String(result.metadata.extension);
|
||||
const step_key = String(result.metadata.onboarding_step);
|
||||
|
||||
if (LEGACY_EXTENSIONS_WITH_OWN_LISTENER.includes(extension_name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const spec = this.stepsRegistry.getStep(extension_name, step_key);
|
||||
if (!spec) {
|
||||
this.logger.debug(
|
||||
`Шаг ${extension_name}/${step_key} не найден в OnboardingStepsRegistry — пропуск`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const plugin = await this.extensionRepository.findByName(extension_name);
|
||||
if (!plugin) {
|
||||
this.logger.warn(`Расширение ${extension_name} не найдено в репозитории`);
|
||||
return;
|
||||
}
|
||||
|
||||
const flagKey = doneKey(step_key);
|
||||
const wasAlreadyDone = Boolean(plugin.config[flagKey]);
|
||||
if (wasAlreadyDone) return;
|
||||
|
||||
const updatedConfig: Record<string, unknown> = {
|
||||
...plugin.config,
|
||||
[flagKey]: true,
|
||||
};
|
||||
await this.extensionRepository.update({ ...plugin, config: updatedConfig });
|
||||
this.logger.info(`Онбординг ${extension_name}: ${flagKey} = true`);
|
||||
|
||||
const specs = this.stepsRegistry.getStepsByExtension(extension_name);
|
||||
const allDone =
|
||||
specs.length > 0 &&
|
||||
specs.every((s) => Boolean(updatedConfig[doneKey(s.step_key)]));
|
||||
if (allDone) {
|
||||
this.logger.info(
|
||||
`[ONBOARDING_COMPLETED] ${extension_name}: все шаги завершены`
|
||||
);
|
||||
this.eventEmitter.emit(ONBOARDING_COMPLETED_EVENT, {
|
||||
extension_name,
|
||||
} satisfies OnboardingCompletedPayload);
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(
|
||||
`Ошибка обработки DecisionTrackedEvent для ${extension_name}/${step_key}: ${err.message}`,
|
||||
err.stack
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user