Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b84b02f1b4 | |||
| c33ec53230 | |||
| 7dcca3a2ae | |||
| 70752b2caa | |||
| 8c5568bcf8 | |||
| 30b79ad1f7 | |||
| 87747d1782 | |||
| f7a182e51f | |||
| 9774abb296 | |||
| 724cd381cc | |||
| 5aa1a21a97 | |||
| 933e5b5ffe | |||
| d886d780fc | |||
| 68b37efbe9 | |||
| e810d3152f | |||
| 99c22b0064 | |||
| 4c0fbefca9 | |||
| 8fdc99a7f9 | |||
| 8d27c4e245 | |||
| 0e3ccb4356 | |||
| 306835d0fc | |||
| 10e5b83ac9 | |||
| 33d936e5c8 | |||
| a73f07fa0c | |||
| 77f358e865 | |||
| 2afc71c281 | |||
| 9cf19aa8fe | |||
| 4b8121b0b7 |
+160
-104
@@ -5,12 +5,8 @@ name: Release
|
||||
# Порядок (через jobs.needs):
|
||||
# 1) release — контракты + контейнеры + webhook деплоя (атомарно)
|
||||
# 2) publish-packages — npm publish через lerna (если не -alpha)
|
||||
# 3) trigger-coopenomics — workflow_dispatch сборки сайта C9S/coopenomics на
|
||||
# Gitea (если не -alpha + ветка main); он сам тянет
|
||||
# mono и пересобирает доки.
|
||||
#
|
||||
# publish-docs (gh-pages на github.com) удалён 2026-05-25 — после переезда на
|
||||
# Gitea Pages-публикация невалидна; доки деплоятся через C9S/coopenomics.
|
||||
# 3) publish-docs — mkdocs + standards-site → gh-pages (если не -alpha + ветка main)
|
||||
# 4) trigger-coopenomics — repository_dispatch в coopenomics/coopenomics (тот же гейт, что docs)
|
||||
#
|
||||
# Зачем последовательно: пакеты/доки/внешний триггер не должны уезжать,
|
||||
# если релиз контрактов или контейнеров провалился. Раньше четыре workflow'а
|
||||
@@ -38,7 +34,16 @@ 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 }}
|
||||
@@ -122,41 +127,14 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# === Этап 1: контракты ===
|
||||
# CDT ставим из .deb (C9S/cdt v4.2.0) прямо в окружение job'а и
|
||||
# компилируем напрямую — БЕЗ вложенного docker. Почему не build-all.sh:
|
||||
# тот монтирует $(pwd):/project в sibling-контейнер, а под Gitea
|
||||
# act_runner сам job исполняется в контейнере → хостовый демон не видит
|
||||
# этот путь, /project пуст, cmake падает "no CMakeLists.txt".
|
||||
# build-all.sh остаётся для локальной сборки (оборачивает тот же
|
||||
# build_contracts_cdt.sh в docker).
|
||||
# CMakeLists хардкодит toolchain /cdt/build/...; .deb кладёт CDT в
|
||||
# /usr/opt/cdt/4.2.0, а сам CDTWasmToolchain.cmake указывает на /usr
|
||||
# абсолютно — поэтому симлинк /cdt/build → /usr/opt/cdt/4.2.0 сводит пути
|
||||
# без правки CMakeLists.
|
||||
|
||||
- name: Install CDT 4.2.0 toolchain (.deb)
|
||||
run: |
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
|
||||
$SUDO apt-get update
|
||||
# build-essential — для host-компилятора (project() в CMakeLists);
|
||||
# libz3-4/libtinfo6/libxml2/zlib1g — рантайм бинарей CDT (clang-9,
|
||||
# ld.lld и т.д.). Пакет cdt объявляет только libcurl4-gnutls-dev,
|
||||
# поэтому остальные .so ставим явно — в образе dicoop/blockchain они
|
||||
# были из сборки исходников, из .deb не тянутся.
|
||||
$SUDO apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates cmake make build-essential \
|
||||
libz3-4 libtinfo6 libxml2 zlib1g
|
||||
curl -fsSL -o /tmp/cdt.deb \
|
||||
https://git.coopenomics.world/C9S/cdt/releases/download/v4.2.0/cdt_4.2.0-1_amd64.deb
|
||||
$SUDO apt-get install -y /tmp/cdt.deb
|
||||
$SUDO mkdir -p /cdt
|
||||
$SUDO ln -sfn /usr/opt/cdt/4.2.0 /cdt/build
|
||||
cdt-cpp --version || true
|
||||
- name: Pull CDT toolchain image
|
||||
run: docker pull dicoop/blockchain_v5.1.1:dev
|
||||
|
||||
- name: Compile contracts
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
./build_contracts_cdt.sh "$BUILD_MODE"
|
||||
./build-all.sh "$BUILD_MODE"
|
||||
echo "--- build/contracts ---"
|
||||
ls -la build/contracts/
|
||||
|
||||
@@ -237,17 +215,6 @@ jobs:
|
||||
- name: Build and push contracts image
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
# Ретрай push'а: резолвинг registry-1.docker.io на runner'е изредка
|
||||
# моргает (DNS-таймаут к 127.0.0.53) — 3 попытки с паузой.
|
||||
dpush() {
|
||||
local ref="$1" n=1
|
||||
until docker push "$ref"; do
|
||||
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
|
||||
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
|
||||
n=$((n+1)); sleep 10
|
||||
done
|
||||
}
|
||||
|
||||
IMAGE="dicoop/contracts"
|
||||
SHORT_SHA="${SHA::7}"
|
||||
docker build \
|
||||
@@ -256,14 +223,14 @@ jobs:
|
||||
--label "build.mode=$BUILD_MODE" \
|
||||
-t "$IMAGE:$CONTRACTS_TAG" \
|
||||
./docker/.context
|
||||
dpush "$IMAGE:$CONTRACTS_TAG"
|
||||
docker push "$IMAGE:$CONTRACTS_TAG"
|
||||
|
||||
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
|
||||
dpush "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
|
||||
docker push "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
|
||||
|
||||
if [ -n "$CONTRACTS_EXTRA" ]; then
|
||||
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_EXTRA"
|
||||
dpush "$IMAGE:$CONTRACTS_EXTRA"
|
||||
docker push "$IMAGE:$CONTRACTS_EXTRA"
|
||||
fi
|
||||
|
||||
- name: Verify pushed contracts image
|
||||
@@ -276,31 +243,15 @@ jobs:
|
||||
|
||||
- name: Build and push base image
|
||||
run: |
|
||||
dpush() {
|
||||
local ref="$1" n=1
|
||||
until docker push "$ref"; do
|
||||
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
|
||||
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
|
||||
n=$((n+1)); sleep 10
|
||||
done
|
||||
}
|
||||
docker build --target runtime -t "dicoop/mono-base:$TAG_NAME" .
|
||||
dpush "dicoop/mono-base:$TAG_NAME"
|
||||
docker push "dicoop/mono-base:$TAG_NAME"
|
||||
if [ "$IS_PROD" = "true" ]; then
|
||||
docker tag "dicoop/mono-base:$TAG_NAME" dicoop/mono-base:latest
|
||||
dpush dicoop/mono-base:latest
|
||||
docker push dicoop/mono-base:latest
|
||||
fi
|
||||
|
||||
- name: Build and push service images
|
||||
run: |
|
||||
dpush() {
|
||||
local ref="$1" n=1
|
||||
until docker push "$ref"; do
|
||||
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
|
||||
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
|
||||
n=$((n+1)); sleep 10
|
||||
done
|
||||
}
|
||||
build_service() {
|
||||
local SVC="$1" PKG="$2" CMD="$3"
|
||||
local DOCKERFILE="Dockerfile.$SVC"
|
||||
@@ -309,10 +260,10 @@ jobs:
|
||||
echo "CMD [\"pnpm\", \"-F\", \"$PKG\", \"run\", \"$CMD\"]"
|
||||
} > "$DOCKERFILE"
|
||||
docker build -t "dicoop/$SVC:$TAG_NAME" -f "$DOCKERFILE" .
|
||||
dpush "dicoop/$SVC:$TAG_NAME"
|
||||
docker push "dicoop/$SVC:$TAG_NAME"
|
||||
if [ "$IS_PROD" = "true" ]; then
|
||||
docker tag "dicoop/$SVC:$TAG_NAME" "dicoop/$SVC:latest"
|
||||
dpush "dicoop/$SVC:latest"
|
||||
docker push "dicoop/$SVC:latest"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -363,44 +314,149 @@ jobs:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
# ============================================================================
|
||||
# trigger-coopenomics-docs — пересборка сайта C9S/coopenomics.
|
||||
# publish-docs — mkdocs + standards-site → gh-pages (бывший publish-docs.yaml).
|
||||
# Гейт: не-alpha + ветка main.
|
||||
#
|
||||
# coopenomics переехал на Gitea (C9S/coopenomics); его publish-docs.yaml
|
||||
# клонирует mono и пересобирает сайт. Gitea НЕ имеет API для
|
||||
# repository_dispatch — только workflow_dispatch, поэтому дёргаем целевой
|
||||
# workflow через Gitea API с PAT (secret DOCS_DISPATCH_TOKEN — префикс GITEA_
|
||||
# у секретов зарезервирован Gitea, нельзя; права write на C9S/coopenomics).
|
||||
# Целевой workflow слушает workflow_dispatch с входами
|
||||
# mono_sha/mono_ref.
|
||||
# ВНИМАНИЕ: 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 website rebuild (Gitea workflow_dispatch)
|
||||
run: |
|
||||
curl -fsSL -X POST \
|
||||
-H "Authorization: token ${{ secrets.DOCS_DISPATCH_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ref":"master","inputs":{"mono_sha":"${{ github.sha }}","mono_ref":"${{ github.ref }}"}}' \
|
||||
"${{ github.server_url }}/api/v1/repos/C9S/coopenomics/actions/workflows/publish-docs.yaml/dispatches"
|
||||
|
||||
# ============================================================================
|
||||
# trigger-mono-docs — деплой ВТОРОЙ документации (сайт доков mono) через
|
||||
# webhook DOCS_DEPLOY_WEBHOOK_URL. Отдельная от coopenomics публикация —
|
||||
# обе доки уезжают синхронно по релизу. Извлечён из бывшего publish-docs:
|
||||
# gh-pages на github.com выпилен как мёртвый, остался реальный deploy-webhook
|
||||
# (приёмник деплоит доки на своей стороне). Гейт: не-alpha + ветка main.
|
||||
# ============================================================================
|
||||
trigger-mono-docs:
|
||||
needs: release
|
||||
if: ${{ !contains(github.ref, '-alpha') && needs.release.outputs.branch == 'main' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger docs deployment webhook
|
||||
run: |
|
||||
curl -fsSL -X POST "${{ vars.DOCS_DEPLOY_WEBHOOK_URL }}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"ref":"${{ github.ref }}","sha":"${{ github.sha }}","branch":"${{ github.ref_name }}"}'
|
||||
- name: Trigger Coopenomics deployment
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.COOPENOMICS_PAT }}
|
||||
repository: coopenomics/coopenomics
|
||||
event-type: deploy_from_mono
|
||||
client-payload: '{"repository": "${{ github.repository }}", "sha": "${{ github.sha }}", "ref": "${{ github.ref }}", "actor": "${{ github.actor }}"}'
|
||||
|
||||
@@ -13,11 +13,11 @@ name: Typecheck
|
||||
#
|
||||
# Триггеры:
|
||||
# - pull_request на dev — гейт перед мерджем в основную ветку;
|
||||
# - workflow_call — оставлен для переиспользования, но release.yaml его
|
||||
# больше НЕ вызывает (гейт из релиза убран как избыточный — типы
|
||||
# проверяются на PR в dev до того, как код доедет до тэга).
|
||||
# - workflow_call — переиспользуется из release.yaml как `needs:` у release-job,
|
||||
# чтобы битый тэг не уехал в DockerHub.
|
||||
#
|
||||
# Push в dev/testnet/main НЕ триггерит — намеренно (PR-гейт на dev достаточен).
|
||||
# Push в dev/testnet/main НЕ триггерит — намеренно (PR-гейт достаточен,
|
||||
# а тэги покрывает workflow_call из release.yaml).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
node_modules/
|
||||
node_modules
|
||||
lerna-debug.log
|
||||
components/controller/graph.png
|
||||
blockchain-data/
|
||||
@@ -23,5 +22,3 @@ components/contracts/cpp/ledger2/scripts/out/
|
||||
|
||||
.env.testnet
|
||||
.pnpm-store
|
||||
schema.gql
|
||||
.claude
|
||||
|
||||
@@ -72,7 +72,7 @@ Workflow:
|
||||
pnpm jest tests/unit/marketplace/marketplace-onboarding-service.test.ts --runInBand
|
||||
```
|
||||
|
||||
`pnpm generate-schema` / `pnpm generate-client` — **запускать локально можно** (ограничение снято 2026-05-26). Раньше боялись, что memory/CPU-полка повесит контейнер controller'а, но на практике генерация отрабатывает; запускать при поднятом controller'е.
|
||||
`pnpm generate-schema` / `pnpm generate-client` — **не запускать локально**; та же memory/CPU полка вешает контейнер controller'а. Либо CI, либо пользователь сам когда контейнер остановлен.
|
||||
|
||||
Перед коммитом достаточно `tsc --noEmit` (быстрый, не блокирует).
|
||||
|
||||
@@ -184,31 +184,6 @@ await sendPOST('/v1/graphql', { query: QUERY, variables });
|
||||
|
||||
Канон — `features/Branch/CreateBranch/{api,model}/index.ts`: тип в model `export type IXxxInput = Mutations.X.Y.IInput['data']`; функция в api `function (data: IXxxInput) { ... variables: { data } }`. Не делать `as` cast'ов.
|
||||
|
||||
## ⚠️ ДИЗАЙН-КАНОН desktop — ОБЯЗАТЕЛЕН К ИСПОЛЬЗОВАНИЮ
|
||||
|
||||
**ПРИ ЛЮБОЙ ВЁРСТКЕ В `components/desktop/` НЕ ВЫДУМЫВАТЬ СТИЛИ.** Всегда сначала свериться с каноном. Не строй гипотез о цветах/радиусах/типографике/паттернах из памяти — иди и читай.
|
||||
|
||||
**Два источника правды (именно в этом порядке):**
|
||||
|
||||
1. **HTML-канон дизайн-системы** — единственный SoT для визуала (цвета, токены, радиусы, тени, типографика, патерны компонентов):
|
||||
|
||||
`/home/admin/blago/production/shared/MONO Design System.html`
|
||||
|
||||
Это самодостаточный HTML с всеми CSS-токенами `--p-*`, образцами компонентов (`.base-button`, `.base-card`, `.toast`, `.chip`, `.banner` и т.д.), цветовой палитрой и demo-блоками. **При сомнении в каноне — открыть этот файл и читать.** Не путать с `auth-prototype/index.html`, не путать с `ux-design-specification.md` — они НЕ канон.
|
||||
|
||||
2. **Живая реализация канона** в самом репозитории — `/_dev/ui` страница:
|
||||
|
||||
`components/desktop/src/pages/_dev/ui/index.vue`
|
||||
|
||||
Это уже заверстанная по канону witness-страница со всеми base-/widget-компонентами в действии (BaseButton, BaseInput, BaseCard, BaseChip, EmptyState, Toasts, AppHeader/AppDrawer фрагменты, WalletCard, IdentityPanel, DocumentRow, SignatureCard, ActivityTimeline, KpiCard, VerticalStepper и т.д.). **Когда делаешь новый компонент — сначала посмотри как уже сверстаны соседние в этой странице, используй те же токены / классы / структуру.** Эту страницу можно открыть в браузере (`/_dev/ui` в dev-сборке) — она же служит и для визуальной верификации.
|
||||
|
||||
**Запреты:**
|
||||
- Никаких magic-цветов в hex/rgb прямо в `<style>` Vue-файла — только токены `var(--p-*)` (см. `components/desktop/src/css/mono-platform/*.css`).
|
||||
- Никаких локальных переопределений `.q-btn`/`.q-card`/`.q-notification` в feature-файлах — все Quasar-overrides централизованно в `components/desktop/src/css/mono-platform/quasar-canon.css`.
|
||||
- Никаких ад-хок размеров — отступы/радиусы/typescale из токенов (`--p-1`..`--p-12`, `--p-r-sm/md/lg`, `--p-fs-h1..body-sm`).
|
||||
|
||||
**Кейс 2026-05-28:** после компакта забыл, что shared/MONO Design System.html — основной SoT, и нашёл «канон» в auth-prototype, который НЕ канон; перевёрстал тосты под нейтральный фон вместо тёмного, пришлось переделывать.
|
||||
|
||||
## Frontend desktop — English имена
|
||||
|
||||
В `components/desktop/` и любом Vue/TS frontend коде **все имена идентификаторов — английские**:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/blago-cli",
|
||||
"version": "2026.5.28-1",
|
||||
"version": "2026.5.23-4",
|
||||
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -362,9 +362,9 @@ export async function runCli(argv: string[]): Promise<void> {
|
||||
)
|
||||
.action(async (paths: string[]) => {
|
||||
const root = requireRoot()
|
||||
const { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts, skippedNonEntity } = await runAdd(root, paths)
|
||||
const { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts } = await runAdd(root, paths)
|
||||
success(
|
||||
`Staging обновлён: в списке ${stagedPaths.length} путь(ей). Пропущено без изменений относительно индекса: ${skippedUnchanged}; по .blagoignore: ${skippedIgnored}; артефакты только pull (messages/ и meetings/): ${skippedPullOnlyArtifacts}; не blago-сущности (нет type=project/issue/story во frontmatter): ${skippedNonEntity}.`,
|
||||
`Staging обновлён: в списке ${stagedPaths.length} путь(ей). Пропущено без изменений относительно индекса: ${skippedUnchanged}; по .blagoignore: ${skippedIgnored}; артефакты только pull (messages/ и meetings/): ${skippedPullOnlyArtifacts}.`,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -38,22 +38,6 @@ export function parseBlagoMarkdown(raw: string): ParsedBlagoFile {
|
||||
}
|
||||
}
|
||||
|
||||
/** Не бросает: возвращает тип сущности blago, если файл — корректный project/issue/story; иначе undefined. */
|
||||
export function peekBlagoEntityType(raw: string): EntityFrontmatterType | undefined {
|
||||
let file: ReturnType<typeof matter>
|
||||
try {
|
||||
file = matter(raw)
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
const t = file.data?.type
|
||||
if (t === 'project' || t === 'issue' || t === 'story') {
|
||||
return t
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function serializeBlagoMarkdown(data: Record<string, unknown>, body: string): string {
|
||||
const trimmed = body.endsWith('\n') ? body : `${body}\n`
|
||||
// matter.stringify(string, data) сначала парсит string как frontmatter — body, начинающийся
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { peekBlagoEntityType } from '../format/index.js'
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
import { expandBlagoUserTargetsToRelativePaths } from './capital-target-expand.js'
|
||||
import { isBlagoSyncExcludedDirName, isIgnoredRelativePath, loadBlagoIgnoreRules } from './ignore.js'
|
||||
@@ -29,39 +28,19 @@ async function collectMarkdownFiles(absDir: string): Promise<string[]> {
|
||||
return out
|
||||
}
|
||||
|
||||
type FileCheck =
|
||||
| { kind: 'unreadable' }
|
||||
| { kind: 'non-entity' }
|
||||
| { kind: 'clean' }
|
||||
| { kind: 'dirty' }
|
||||
|
||||
/**
|
||||
* Решение по одному файлу для staging:
|
||||
* — нечитаемый → dirty (push сам сообщит вменяемую ошибку),
|
||||
* — без корректного frontmatter type → non-entity (README/notes/CLAUDE/AGENTS и пр. не отправляем),
|
||||
* — есть в индексе и sha256 совпал → clean,
|
||||
* — иначе dirty.
|
||||
*/
|
||||
async function classifyFileForAdd(
|
||||
root: string,
|
||||
rel: string,
|
||||
index: Awaited<ReturnType<typeof loadIndex>>,
|
||||
): Promise<FileCheck> {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await fs.readFile(path.join(root, rel), 'utf8')
|
||||
}
|
||||
catch {
|
||||
return { kind: 'unreadable' }
|
||||
}
|
||||
if (peekBlagoEntityType(raw) === undefined) {
|
||||
return { kind: 'non-entity' }
|
||||
}
|
||||
/** true — файл стоит поставить в staging: нет в индексе или SHA256 на диске ≠ content_etag_local. */
|
||||
async function isDirtyVsIndex(root: string, rel: string, index: Awaited<ReturnType<typeof loadIndex>>): Promise<boolean> {
|
||||
const entry = findByRelativePath(index, rel)
|
||||
if (!entry) {
|
||||
return { kind: 'dirty' }
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(root, rel), 'utf8')
|
||||
return sha256Hex(raw) !== entry.content_etag_local
|
||||
}
|
||||
catch {
|
||||
return true
|
||||
}
|
||||
return sha256Hex(raw) === entry.content_etag_local ? { kind: 'clean' } : { kind: 'dirty' }
|
||||
}
|
||||
|
||||
export interface RunAddResult {
|
||||
@@ -71,8 +50,6 @@ export interface RunAddResult {
|
||||
skippedIgnored: number
|
||||
/** messages/ и meetings/ — только синхронизация с сервера, в push не идут */
|
||||
skippedPullOnlyArtifacts: number
|
||||
/** Файлы без корректного blago-frontmatter (README, notes, CLAUDE/AGENTS и пр.) — в staging не попадают */
|
||||
skippedNonEntity: number
|
||||
}
|
||||
|
||||
export async function runAdd(root: string, targets: string[]): Promise<RunAddResult> {
|
||||
@@ -88,7 +65,6 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
|
||||
let skippedUnchanged = 0
|
||||
let skippedIgnored = 0
|
||||
let skippedPullOnlyArtifacts = 0
|
||||
let skippedNonEntity = 0
|
||||
|
||||
for (const t of expanded) {
|
||||
const abs = path.resolve(root, t)
|
||||
@@ -110,12 +86,7 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
|
||||
skippedPullOnlyArtifacts += 1
|
||||
continue
|
||||
}
|
||||
const check = await classifyFileForAdd(root, rel, index)
|
||||
if (check.kind === 'non-entity') {
|
||||
skippedNonEntity += 1
|
||||
continue
|
||||
}
|
||||
if (check.kind === 'clean') {
|
||||
if (!(await isDirtyVsIndex(root, rel, index))) {
|
||||
skippedUnchanged += 1
|
||||
continue
|
||||
}
|
||||
@@ -125,5 +96,5 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
|
||||
|
||||
const stagedPaths = [...set].sort()
|
||||
await saveStaging(root, { paths: stagedPaths })
|
||||
return { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts, skippedNonEntity }
|
||||
return { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts }
|
||||
}
|
||||
|
||||
@@ -9,15 +9,12 @@ export interface CommunicationCursorsFile {
|
||||
messageLastTsByRoom: Record<string, number>
|
||||
/** project_hash → ISO instant: транскрипции с endedAt ≤ этого момента уже выгружены */
|
||||
transcriptionLastEndedExclusiveByProject: Record<string, string>
|
||||
/** matrixRoomId → ISO instant: транскрипции непроектной комнаты с endedAt ≤ этого момента уже выгружены */
|
||||
transcriptionLastEndedExclusiveByRoom: Record<string, string>
|
||||
}
|
||||
|
||||
function empty(): CommunicationCursorsFile {
|
||||
return {
|
||||
messageLastTsByRoom: {},
|
||||
transcriptionLastEndedExclusiveByProject: {},
|
||||
transcriptionLastEndedExclusiveByRoom: {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,11 +32,6 @@ export async function loadCommunicationCursors(root: string): Promise<Communicat
|
||||
&& typeof parsed.transcriptionLastEndedExclusiveByProject === 'object'
|
||||
? { ...parsed.transcriptionLastEndedExclusiveByProject }
|
||||
: {},
|
||||
transcriptionLastEndedExclusiveByRoom:
|
||||
parsed.transcriptionLastEndedExclusiveByRoom !== undefined
|
||||
&& typeof parsed.transcriptionLastEndedExclusiveByRoom === 'object'
|
||||
? { ...parsed.transcriptionLastEndedExclusiveByRoom }
|
||||
: {},
|
||||
}
|
||||
}
|
||||
catch {
|
||||
|
||||
@@ -43,6 +43,7 @@ export function projectCommunicationDayToMarkdown(
|
||||
}
|
||||
|
||||
export interface TranscriptionHeaderModel {
|
||||
matrixRoomId: string
|
||||
roomId: string
|
||||
startedAt: Date | string
|
||||
endedAt: Date | string | null | undefined
|
||||
@@ -70,6 +71,7 @@ export function renderCallTranscriptionMarkdown(
|
||||
const header = [
|
||||
`# Транскрипция звонка`,
|
||||
``,
|
||||
`- Matrix room: \`${transcription.matrixRoomId}\``,
|
||||
`- LiveKit room: \`${transcription.roomId}\``,
|
||||
`- Начало: ${start.toISOString()}`,
|
||||
`- Окончание: ${end ? end.toISOString() : '—'}`,
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
transcriptionMeetingFileStemUtc,
|
||||
type CommunicationDayLine,
|
||||
} from './communication-markdown.js'
|
||||
import { generateSlug, workspaceBasePath, type ProjectPathModel } from './layout.js'
|
||||
import { workspaceBasePath, type ProjectPathModel } from './layout.js'
|
||||
import { syncEntityFile } from './sync-entity-file.js'
|
||||
|
||||
interface ProjectRowLite {
|
||||
@@ -183,19 +183,6 @@ async function listRooms(ctx: AuthenticatedContext, projectHash: string) {
|
||||
return q[Queries.ChatCoop.ListProjectCommunicationRooms.name] ?? []
|
||||
}
|
||||
|
||||
/** Стабильная папка непроектной комнаты в `rooms/`. Системные — фиксированные, комнаты секретаря — slug + хвост id (уникальность). */
|
||||
function nonProjectRoomFolder(kind: string, matrixRoomId: string, displayLabel: string): string {
|
||||
if (kind === 'MEMBERS') {
|
||||
return 'komnata-paishchikov'
|
||||
}
|
||||
if (kind === 'COUNCIL') {
|
||||
return 'komnata-soveta'
|
||||
}
|
||||
const slug = generateSlug(displayLabel) || 'komnata'
|
||||
const shortId = createHash('sha256').update(matrixRoomId, 'utf8').digest('hex').slice(0, 6)
|
||||
return `${slug}-${shortId}`
|
||||
}
|
||||
|
||||
export async function pullProjectCommunicationArtifacts(
|
||||
ctx: AuthenticatedContext,
|
||||
index: IndexFile,
|
||||
@@ -211,7 +198,6 @@ export async function pullProjectCommunicationArtifacts(
|
||||
cursors = {
|
||||
messageLastTsByRoom: {},
|
||||
transcriptionLastEndedExclusiveByProject: {},
|
||||
transcriptionLastEndedExclusiveByRoom: {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +356,7 @@ export async function pullProjectCommunicationArtifacts(
|
||||
= dateFromUnknown(tr.endedAt) ?? (tr.endedAt as Date | string | null | undefined)
|
||||
const md = renderCallTranscriptionMarkdown(
|
||||
{
|
||||
matrixRoomId: String(tr.matrixRoomId),
|
||||
roomId: String(tr.roomId),
|
||||
startedAt,
|
||||
endedAt: endedAtTr,
|
||||
@@ -435,207 +422,3 @@ export async function pullProjectCommunicationArtifacts(
|
||||
warn(`Не удалось сохранить курсоры переписки: ${formatThrownValue(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull переписки и транскрипций из комнат ВНЕ проектов Capital (пайщики, совет, комнаты секретаря).
|
||||
* Раскладка — отдельная верхняя папка `rooms/<folder>/{messages,meetings}/`, чтобы не смешивать с
|
||||
* проектными `meetings/`. Логика идентична проектной, но bucket = одна комната, курсор транскрипций — по matrixRoomId.
|
||||
*/
|
||||
export async function pullNonProjectCommunicationArtifacts(
|
||||
ctx: AuthenticatedContext,
|
||||
index: IndexFile,
|
||||
): Promise<void> {
|
||||
let rooms: { matrixRoomId: string, displayLabel: string, kind: string }[]
|
||||
try {
|
||||
const q = await ctx.client.Query(Queries.ChatCoop.ListNonProjectCommunicationRooms.query, {})
|
||||
rooms = (q[Queries.ChatCoop.ListNonProjectCommunicationRooms.name] ?? []) as typeof rooms
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Список непроектных комнат (chatcoopListNonProjectCommunicationRooms): ${formatThrownValue(e)}`)
|
||||
return
|
||||
}
|
||||
if (rooms.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let cursors: CommunicationCursorsFile
|
||||
try {
|
||||
cursors = await loadCommunicationCursors(ctx.root)
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Курсоры переписки (комнаты): не удалось прочитать, начинаем с пустых: ${formatThrownValue(e)}`)
|
||||
cursors = {
|
||||
messageLastTsByRoom: {},
|
||||
transcriptionLastEndedExclusiveByProject: {},
|
||||
transcriptionLastEndedExclusiveByRoom: {},
|
||||
}
|
||||
}
|
||||
|
||||
for (const room of rooms) {
|
||||
const folder = nonProjectRoomFolder(room.kind, room.matrixRoomId, room.displayLabel)
|
||||
const basePath = `rooms/${folder}`
|
||||
const roomTitle = room.displayLabel || room.matrixRoomId
|
||||
|
||||
// Сообщения комнаты — по календарным суткам UTC новее курсора.
|
||||
try {
|
||||
const last = cursors.messageLastTsByRoom[room.matrixRoomId]
|
||||
const afterTs = last ?? 0
|
||||
const datesQ = await ctx.client.Query(Queries.ChatCoop.ListUtcDatesWithNewRoomMessages.query, {
|
||||
variables: { data: { matrixRoomId: room.matrixRoomId, afterOriginServerTsExclusive: afterTs } },
|
||||
})
|
||||
const dates = (datesQ[Queries.ChatCoop.ListUtcDatesWithNewRoomMessages.name] ?? []).sort()
|
||||
for (const utcDate of dates) {
|
||||
const mq = await ctx.client.Query(Queries.ChatCoop.GetRoomMessagesForUtcDate.query, {
|
||||
variables: { data: { matrixRoomId: room.matrixRoomId, utcDate } },
|
||||
})
|
||||
const linesRaw = mq[Queries.ChatCoop.GetRoomMessagesForUtcDate.name] ?? []
|
||||
const lines: CommunicationDayLine[] = linesRaw.map(m => ({
|
||||
originServerTs: m.originServerTs,
|
||||
authorLabel: m.authorLabel,
|
||||
coopUsername: m.coopUsername,
|
||||
kind: String(m.kind),
|
||||
bodyText: m.bodyText,
|
||||
}))
|
||||
if (lines.length === 0) {
|
||||
continue
|
||||
}
|
||||
const content = projectCommunicationDayToMarkdown(roomTitle, room.matrixRoomId, utcDate, [
|
||||
{ displayLabel: room.displayLabel, matrixRoomId: room.matrixRoomId, lines },
|
||||
])
|
||||
const rel = `${basePath}/messages/${utcDate}.md`
|
||||
const entityHash = messageDayEntityHash(room.matrixRoomId, utcDate)
|
||||
await syncEntityFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entityType: 'room_message_day',
|
||||
entityHash,
|
||||
relativePath: rel,
|
||||
content,
|
||||
remoteUpdatedAt: `${utcDate}T23:59:59.999Z`,
|
||||
label: `переписка ${utcDate} (${room.matrixRoomId})`,
|
||||
})
|
||||
}
|
||||
|
||||
const maxQ = await ctx.client.Query(Queries.ChatCoop.GetMaxOriginServerTsForRoom.query, {
|
||||
variables: { data: { matrixRoomId: room.matrixRoomId } },
|
||||
})
|
||||
const maxTs = maxQ[Queries.ChatCoop.GetMaxOriginServerTsForRoom.name] as number | null | undefined
|
||||
if (maxTs !== undefined && maxTs !== null && Number.isFinite(maxTs)) {
|
||||
cursors.messageLastTsByRoom[room.matrixRoomId] = maxTs
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Переписка Matrix, комната ${room.matrixRoomId} (${roomTitle}): ${formatThrownValue(e)}`)
|
||||
}
|
||||
|
||||
// Транскрипции звонков комнаты + sibling memo.
|
||||
try {
|
||||
const tExIso = cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId]
|
||||
const lowerBoundExclusive = tExIso === undefined ? new Date(0) : new Date(tExIso)
|
||||
|
||||
interface TranscriptionCandidate {
|
||||
id: string
|
||||
endedAt: Date
|
||||
memo: string
|
||||
updatedAt: Date | undefined
|
||||
}
|
||||
const byId = new Map<string, TranscriptionCandidate>()
|
||||
const tq = await ctx.client.Query(Queries.ChatCoop.GetTranscriptions.query, {
|
||||
variables: { data: { matrixRoomId: room.matrixRoomId, limit: CHATCOOP_TRANSCRIPTIONS_QUERY_LIMIT, offset: 0 } },
|
||||
})
|
||||
const list = tq[Queries.ChatCoop.GetTranscriptions.name] ?? []
|
||||
for (const t of list) {
|
||||
const end = dateFromUnknown(t.endedAt)
|
||||
if (t.status !== Zeus.TranscriptionStatus.COMPLETED || !end) {
|
||||
continue
|
||||
}
|
||||
const prev = byId.get(t.id)
|
||||
if (!prev || end > prev.endedAt) {
|
||||
byId.set(t.id, {
|
||||
id: t.id,
|
||||
endedAt: end,
|
||||
memo: typeof t.memo === 'string' ? t.memo : '',
|
||||
updatedAt: dateFromUnknown(t.updatedAt),
|
||||
})
|
||||
}
|
||||
}
|
||||
const allCompleted = [...byId.values()].sort((a, b) => a.endedAt.getTime() - b.endedAt.getTime())
|
||||
|
||||
const newMeetings = allCompleted.filter(c => c.endedAt.getTime() > lowerBoundExclusive.getTime())
|
||||
let maxEnded: Date | null = null
|
||||
for (const c of newMeetings) {
|
||||
const packQ = await ctx.client.Query(Queries.ChatCoop.GetTranscription.query, {
|
||||
variables: { data: { id: c.id } },
|
||||
})
|
||||
const pack = packQ[Queries.ChatCoop.GetTranscription.name]
|
||||
if (!pack?.transcription || pack.transcription.status !== Zeus.TranscriptionStatus.COMPLETED) {
|
||||
continue
|
||||
}
|
||||
const tr = pack.transcription
|
||||
const startedAt: Date | string = dateFromUnknown(tr.startedAt) ?? (tr.startedAt as Date | string)
|
||||
const endedAtTr: Date | string | null | undefined
|
||||
= dateFromUnknown(tr.endedAt) ?? (tr.endedAt as Date | string | null | undefined)
|
||||
const md = renderCallTranscriptionMarkdown(
|
||||
{
|
||||
roomId: String(tr.roomId),
|
||||
startedAt,
|
||||
endedAt: endedAtTr,
|
||||
},
|
||||
pack.segments.map(s => ({
|
||||
speakerName: s.speakerName,
|
||||
text: s.text,
|
||||
startOffset: s.startOffset,
|
||||
endOffset: s.endOffset,
|
||||
})),
|
||||
)
|
||||
const stem = transcriptionMeetingFileStemUtc(c.endedAt)
|
||||
const rel = `${basePath}/meetings/${stem}.md`
|
||||
const entityHash = c.id.toLowerCase()
|
||||
await syncEntityFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entityType: 'call_transcription',
|
||||
entityHash,
|
||||
relativePath: rel,
|
||||
content: md,
|
||||
remoteUpdatedAt: toUpdatedIso(c.endedAt),
|
||||
label: `транскрипция ${c.id}`,
|
||||
})
|
||||
if (!maxEnded || c.endedAt > maxEnded) {
|
||||
maxEnded = c.endedAt
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of allCompleted) {
|
||||
const stem = transcriptionMeetingFileStemUtc(c.endedAt)
|
||||
const memoRel = `${basePath}/meetings/${stem}.memo.md`
|
||||
const memoRemoteUpdatedAt = toUpdatedIso(c.updatedAt ?? c.endedAt)
|
||||
await syncTranscriptionMemoFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
transcriptionId: c.id,
|
||||
relativePath: memoRel,
|
||||
serverMemo: c.memo,
|
||||
remoteUpdatedAtIso: memoRemoteUpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
if (maxEnded) {
|
||||
cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId] = maxEnded.toISOString()
|
||||
}
|
||||
else if (tExIso === undefined) {
|
||||
cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId] = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Транскрипции звонков, комната ${room.matrixRoomId}: ${formatThrownValue(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await saveCommunicationCursors(ctx.root, cursors)
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Не удалось сохранить курсоры переписки (комнаты): ${formatThrownValue(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
storyFileRelativePath,
|
||||
workspaceBasePath,
|
||||
} from './layout.js'
|
||||
import { pullNonProjectCommunicationArtifacts, pullProjectCommunicationArtifacts } from './pull-communication.js'
|
||||
import { pullProjectCommunicationArtifacts } from './pull-communication.js'
|
||||
import { scaffoldBmadWorkspacesAfterPull } from './scaffold-bmad-workspace.js'
|
||||
import { syncEntityFile } from './sync-entity-file.js'
|
||||
import { writeWorkspaceIndexMarkdown } from './workspace-index.js'
|
||||
@@ -329,8 +329,6 @@ export async function runPull(ctx: AuthenticatedContext, options: RunPullOptions
|
||||
|
||||
await pullProjectCommunicationArtifacts(ctx, index, allProjects, projectByHash)
|
||||
|
||||
await pullNonProjectCommunicationArtifacts(ctx, index)
|
||||
|
||||
await scaffoldBmadWorkspacesAfterPull(ctx, allProjects, projectByHash)
|
||||
|
||||
await saveIndex(ctx.root, index)
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { Mutations, Queries } from '@coopenomics/sdk'
|
||||
import { parseBlagoMarkdown, peekBlagoEntityType } from '../format/index.js'
|
||||
import { parseBlagoMarkdown } from '../format/index.js'
|
||||
import { sha256Hex } from '../lib/hash.js'
|
||||
import { effectiveParentHash } from '../lib/parent-hash.js'
|
||||
import { warn } from '../ui/output.js'
|
||||
@@ -58,30 +58,6 @@ export async function runPush(ctx: AuthenticatedContext): Promise<void> {
|
||||
}
|
||||
staging = await loadStaging(ctx.root)
|
||||
}
|
||||
// Не-сущности (README/notes/CLAUDE/AGENTS и пр., в .md без type=project|issue|story) тоже не должны валить весь push.
|
||||
// Чистим их автоматически — иначе одна забытая заметка блокирует отправку реальных правок.
|
||||
const stagingAbs = (p: string): string => path.join(ctx.root, normalizeRelativePath(p))
|
||||
const nonEntityInStaging: string[] = []
|
||||
for (const p of staging.paths) {
|
||||
try {
|
||||
const raw = await fs.readFile(stagingAbs(p), 'utf8')
|
||||
if (peekBlagoEntityType(raw) === undefined) {
|
||||
nonEntityInStaging.push(normalizeRelativePath(p))
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// нечитаемый файл — пусть основной цикл бросит понятную ошибку с путём
|
||||
}
|
||||
}
|
||||
if (nonEntityInStaging.length > 0) {
|
||||
const drop = new Set(nonEntityInStaging)
|
||||
const kept = staging.paths.filter(p => !drop.has(normalizeRelativePath(p)))
|
||||
await saveStaging(ctx.root, { paths: [...new Set(kept.map(p => normalizeRelativePath(p)))].sort() })
|
||||
for (const p of nonEntityInStaging) {
|
||||
warn(`Убрано из staging (нет blago-frontmatter type=project|issue|story): ${p}`)
|
||||
}
|
||||
staging = await loadStaging(ctx.root)
|
||||
}
|
||||
if (staging.paths.length === 0) {
|
||||
throw new Error('Нечего отправлять. Добавьте файлы: blago add <путь | id проекта | projectId-issueId>')
|
||||
}
|
||||
@@ -99,24 +75,8 @@ export async function runPush(ctx: AuthenticatedContext): Promise<void> {
|
||||
}
|
||||
const abs = path.join(ctx.root, n)
|
||||
const raw = await fs.readFile(abs, 'utf8')
|
||||
let parsed: ReturnType<typeof parseBlagoMarkdown>
|
||||
try {
|
||||
parsed = parseBlagoMarkdown(raw)
|
||||
}
|
||||
catch (err) {
|
||||
// Без префикса путём из staging вылезает «type: undefined» без указания файла.
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
throw new Error(`Файл «${n}»: ${msg}`)
|
||||
}
|
||||
let type: ReturnType<typeof validateParsedForPush>['type']
|
||||
let hash: string
|
||||
try {
|
||||
({ type, hash } = validateParsedForPush(parsed))
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
throw new Error(`Файл «${n}»: ${msg}`)
|
||||
}
|
||||
const parsed = parseBlagoMarkdown(raw)
|
||||
const { type, hash } = validateParsedForPush(parsed)
|
||||
const entry = findByHash(index, type, hash)
|
||||
const pKind = pendingKindForEntityType(type)
|
||||
const pending = pKind ? await findPendingForParsed(ctx.root, pKind, hash) : undefined
|
||||
|
||||
@@ -367,6 +367,7 @@ export async function runRestore(ctx: AuthenticatedContext, userPath: string): P
|
||||
if (entry.entity_type === 'call_transcription') {
|
||||
interface TranscriptionRestorePack {
|
||||
transcription: {
|
||||
matrixRoomId: string
|
||||
roomId: string
|
||||
startedAt: Date | string
|
||||
endedAt: Date | string | null | undefined
|
||||
@@ -393,6 +394,7 @@ export async function runRestore(ctx: AuthenticatedContext, userPath: string): P
|
||||
}
|
||||
const content = renderCallTranscriptionMarkdown(
|
||||
{
|
||||
matrixRoomId: tr.matrixRoomId,
|
||||
roomId: tr.roomId,
|
||||
startedAt: tr.startedAt,
|
||||
endedAt: tr.endedAt,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@coopenomics/boot",
|
||||
"type": "module",
|
||||
"version": "2026.5.27-2",
|
||||
"version": "2026.5.23-4",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
"description": "CLI-утилита инициализации блокчейна и кооператива",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Загружаем per-instance конфиг из корня репо (для CHAIN_URL/MONGODB_URL/API_URL,
|
||||
# которые читают TS-код boot и шелл-скрипты networks.sh/preactivate.sh).
|
||||
# Загружаем per-instance конфиг из корня репо.
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
if [ -f "$ROOT_DIR/.env" ]; then
|
||||
set -a
|
||||
@@ -10,34 +9,26 @@ if [ -f "$ROOT_DIR/.env" ]; then
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Останавливаем и удаляем контейнеры (сеть НЕ трогаем — к ней может быть
|
||||
# подключён внешний контейнер вроде provider-backend; down её снёс бы с ошибкой).
|
||||
echo "Останавливаем и удаляем контейнеры..."
|
||||
docker compose rm -fsv mongo postgres monoredis cooparser coopback || true
|
||||
# Останавливаем и удаляем контейнеры вместе с volumes
|
||||
echo "Останавливаем и удаляем контейнеры с volumes..."
|
||||
docker compose down -v mongo postgres cooparser || true
|
||||
|
||||
# Останавливаем blockchain контейнер перед удалением данных
|
||||
echo "Останавливаем blockchain контейнер..."
|
||||
docker compose stop node || true
|
||||
|
||||
# Удаляем тома баз данных для чистого старта (named volumes rm -v не чистит)
|
||||
echo "Удаляем тома баз данных..."
|
||||
PROJECT="${COMPOSE_PROJECT_NAME:-$(basename "$ROOT_DIR")}"
|
||||
docker volume rm "${PROJECT}_postgres_data" "${PROJECT}_mongo_data" 2>/dev/null || true
|
||||
|
||||
# Удаляем blockchain data через контейнер (alpine под root стирает данные
|
||||
# независимо от владельца — без sudo на любой ноде; на проде nodeos пишет под root).
|
||||
# Удаляем blockchain data
|
||||
echo "Удаляем blockchain data..."
|
||||
docker run --rm -v "$(cd .. && pwd)/blockchain-data:/d" alpine sh -c 'rm -rf /d/* /d/.[!.]* 2>/dev/null || true'
|
||||
# sudo chmod -R 755 ../blockchain-data/ 2>/dev/null || true
|
||||
sudo rm -rf ../blockchain-data/
|
||||
|
||||
# Пересоздаем и запускаем базы данных + Redis (monoredis).
|
||||
# monoredis ОБЯЗАТЕЛЕН: без него coopback падает на старте с
|
||||
# `getaddrinfo EAI_AGAIN monoredis` → MaxRetriesPerRequestError → nodemon crash.
|
||||
# Пересоздаем и запускаем базы данных
|
||||
echo "Пересоздаем и запускаем базы данных..."
|
||||
docker compose up -d mongo postgres monoredis
|
||||
docker compose up -d mongo postgres
|
||||
|
||||
# Ждем готовности MongoDB (standalone, ping вместо ожидания PRIMARY).
|
||||
# Ждем готовности MongoDB
|
||||
echo "Ждем готовности MongoDB..."
|
||||
until docker compose exec -T mongo mongosh --quiet --eval "db.adminCommand({ping:1}).ok" > /dev/null 2>&1; do
|
||||
until docker compose exec -T mongo mongosh --eval "db.adminCommand('ping')" --quiet > /dev/null 2>&1; do
|
||||
echo "MongoDB еще не готов, ждем..."
|
||||
sleep 2
|
||||
done
|
||||
@@ -51,7 +42,7 @@ until docker compose exec -T postgres pg_isready -U postgres -d voskhod > /dev/n
|
||||
done
|
||||
echo "PostgreSQL готов!"
|
||||
|
||||
# Запускаем boot процесс (clean: только программы Благорост/маркетплейс)
|
||||
# Запускаем boot процесс
|
||||
echo "Запускаем boot процесс..."
|
||||
pnpm run boot:clean
|
||||
|
||||
@@ -60,6 +51,6 @@ echo "Запускаем parser..."
|
||||
docker compose up -d cooparser
|
||||
|
||||
echo "Запускаем контроллер..."
|
||||
docker compose up -d --force-recreate coopback || true
|
||||
docker compose restart coopback || true
|
||||
|
||||
echo "Перезапуск завершен!"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Загружаем per-instance конфиг из корня репо (для CHAIN_URL/MONGODB_URL/API_URL,
|
||||
# которые читают TS-код boot и шелл-скрипты networks.sh/preactivate.sh).
|
||||
# Загружаем per-instance конфиг из корня репо.
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
if [ -f "$ROOT_DIR/.env" ]; then
|
||||
set -a
|
||||
@@ -10,32 +9,30 @@ if [ -f "$ROOT_DIR/.env" ]; then
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Останавливаем контроллер перед очисткой данных
|
||||
echo "Останавливаем контроллер..."
|
||||
docker compose down coopback || true
|
||||
|
||||
# Останавливаем и удаляем контейнеры вместе с volumes
|
||||
echo "Останавливаем и удаляем контейнеры с volumes..."
|
||||
docker compose down -v mongo postgres monoredis cooparser coopback || true
|
||||
docker compose down -v mongo postgres cooparser || true
|
||||
|
||||
# Останавливаем blockchain контейнер перед удалением данных
|
||||
echo "Останавливаем blockchain контейнер..."
|
||||
docker compose stop node || true
|
||||
|
||||
# Удаляем blockchain data.
|
||||
# Контейнерный wipe (alpine под root) стирает данные независимо от их владельца —
|
||||
# без sudo на любой ноде (на Pi нет passwordless sudo; на проде nodeos пишет
|
||||
# данные под root). Единый способ с reboot.sh / clean_reboot.sh.
|
||||
# Удаляем blockchain data
|
||||
echo "Удаляем blockchain data..."
|
||||
# sudo chmod -R 755 ../blockchain-data/ 2>/dev/null || true
|
||||
docker run --rm -v "$(cd .. && pwd)/blockchain-data:/d" alpine sh -c 'rm -rf /d/* /d/.[!.]* 2>/dev/null || true'
|
||||
sudo rm -rf ../blockchain-data/
|
||||
|
||||
# Пересоздаем и запускаем базы данных + Redis (monoredis).
|
||||
# monoredis ОБЯЗАТЕЛЕН: без него coopback падает на старте с
|
||||
# `getaddrinfo EAI_AGAIN monoredis` → MaxRetriesPerRequestError → nodemon crash,
|
||||
# и провайдер не может взять org-данные partner1 (PROVIDER_URL=coopback:2998).
|
||||
# Пересоздаем и запускаем базы данных
|
||||
echo "Пересоздаем и запускаем базы данных..."
|
||||
docker compose up -d mongo postgres monoredis
|
||||
docker compose up -d mongo postgres
|
||||
|
||||
# Ждем готовности MongoDB (standalone, ping вместо ожидания PRIMARY).
|
||||
# Ждем готовности MongoDB
|
||||
echo "Ждем готовности MongoDB..."
|
||||
until docker compose exec -T mongo mongosh --quiet --eval "db.adminCommand({ping:1}).ok" > /dev/null 2>&1; do
|
||||
until docker compose exec -T mongo mongosh --eval "db.adminCommand('ping')" --quiet > /dev/null 2>&1; do
|
||||
echo "MongoDB еще не готов, ждем..."
|
||||
sleep 2
|
||||
done
|
||||
@@ -49,7 +46,7 @@ until docker compose exec -T postgres pg_isready -U postgres -d voskhod > /dev/n
|
||||
done
|
||||
echo "PostgreSQL готов!"
|
||||
|
||||
# Запускаем boot процесс (расширенный: совет + пайщики; partner1 — при EXTRA_RENT=1)
|
||||
# Запускаем boot процесс
|
||||
echo "Запускаем boot процесс..."
|
||||
pnpm run boot:extra
|
||||
|
||||
@@ -58,6 +55,6 @@ echo "Запускаем parser..."
|
||||
docker compose up -d cooparser
|
||||
|
||||
echo "Запускаем контроллер..."
|
||||
docker compose up -d --force-recreate coopback || true
|
||||
docker compose up -d coopback
|
||||
|
||||
echo "Перезапуск завершен!"
|
||||
|
||||
@@ -291,46 +291,31 @@ export default class Blockchain {
|
||||
async activateFeature(feature: Feature) {
|
||||
await this.update_pass_instance()
|
||||
|
||||
try {
|
||||
await this.api.transact(
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
account: 'eosio',
|
||||
name: 'activate',
|
||||
authorization: [
|
||||
{
|
||||
actor: 'eosio',
|
||||
permission: 'active',
|
||||
},
|
||||
],
|
||||
data: {
|
||||
feature_digest: feature.hash,
|
||||
await this.api.transact(
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
account: 'eosio',
|
||||
name: 'activate',
|
||||
authorization: [
|
||||
{
|
||||
actor: 'eosio',
|
||||
permission: 'active',
|
||||
},
|
||||
],
|
||||
data: {
|
||||
feature_digest: feature.hash,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
blocksBehind: 3,
|
||||
expireSeconds: 30,
|
||||
},
|
||||
)
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
blocksBehind: 3,
|
||||
expireSeconds: 30,
|
||||
},
|
||||
)
|
||||
|
||||
console.log('Фича активирована: ', feature.name)
|
||||
}
|
||||
catch (e: any) {
|
||||
// На уже забутстрапленном (не обнулённом) чейне протокол-фича уже активна —
|
||||
// nodeos отдаёт protocol_feature_exception (code 3250000). Это не повод
|
||||
// ронять весь boot: фича на месте, нужный результат достигнут, продолжаем.
|
||||
const errName = e?.json?.error?.name
|
||||
const errCode = e?.json?.error?.code
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
if (errName === 'protocol_feature_exception' || errCode === 3250000 || /already activated/i.test(msg)) {
|
||||
console.warn(`Фича уже активирована, пропускаю: ${feature.name}`)
|
||||
return
|
||||
}
|
||||
throw e
|
||||
}
|
||||
console.log('Фича активирована: ', feature.name)
|
||||
}
|
||||
|
||||
async createToken(params: TokenContract.Interfaces.ICreate) {
|
||||
|
||||
@@ -128,9 +128,4 @@ export default [
|
||||
path: path.join(userBase, 'apps'),
|
||||
target: 'apps',
|
||||
},
|
||||
{
|
||||
name: 'billing',
|
||||
path: path.join(userBase, 'billing'),
|
||||
target: 'billing',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -154,10 +154,6 @@ export default {
|
||||
name: 'apps',
|
||||
code_permissions_to: ['apps'],
|
||||
},
|
||||
{
|
||||
name: 'billing',
|
||||
code_permissions_to: ['billing'],
|
||||
},
|
||||
// {
|
||||
// name: provider_chairman,
|
||||
// },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import config from '../configs'
|
||||
import { initExtensionsInPostgres, initSystemStatus } from '../postgres-init'
|
||||
import { installExtraData, installInitialData, startInfra } from './infra'
|
||||
import { startCoop } from './cooperative'
|
||||
import { CooperativeClass, startCoop } from './cooperative'
|
||||
|
||||
export async function boot() {
|
||||
const blockchain = await startInfra()
|
||||
@@ -13,39 +14,18 @@ export async function boot() {
|
||||
}
|
||||
|
||||
export async function bootClean() {
|
||||
// Только инфраструктура: контракты, фичи, токен, системные параметры.
|
||||
// Ни совета, ни программ — программы (createPrograms) требуют существующий
|
||||
// совет и создаются в boot/bootExtra внутри installInitialData. В clean их
|
||||
// не делаем (иначе soviet::createprog падает с «Совет не найден»).
|
||||
await startInfra()
|
||||
const blockchain = await startInfra()
|
||||
|
||||
console.log('Создаём программы (Благорост и маркетплейс)')
|
||||
|
||||
const cooperative = new CooperativeClass(blockchain)
|
||||
await cooperative.createPrograms(config.provider)
|
||||
}
|
||||
|
||||
export async function bootExtra() {
|
||||
const blockchain = await startInfra()
|
||||
await installInitialData(blockchain, true) // Создать расширенный совет (устанавливает статус 'active' в MongoDB)
|
||||
|
||||
// installExtraData регистрирует partner1 как coop с auto-approve от провайдера —
|
||||
// это и есть программная on-chain активация, которая триггерит аренду VM в
|
||||
// провайдере (PENDING→RENT). boot:extra используется НЕ только для аренды
|
||||
// сервера (например, просто пересев совета/чейна для других задач), поэтому
|
||||
// провижининг partner1 включается ТОЛЬКО под флагом EXTRA_RENT=1.
|
||||
//
|
||||
// EXTRA_SEED=1 (взаимоисключающее с EXTRA_RENT) — сидит partner1 как
|
||||
// пайщика-organization Восхода (newaccount + reguser + wallet-signagree +
|
||||
// засев coopback) БЕЗ regcoop+preInit. Pre-state для ручного прохода
|
||||
// ConnectionAgreement: signin под WIF из фикстуры → UI выбора тарифа /
|
||||
// подписания / активации руками. EXTRA_RENT приоритетен.
|
||||
if (process.env.EXTRA_RENT === '1') {
|
||||
console.log('EXTRA_RENT=1 → installExtraData: провижининг partner1 (триггер аренды)')
|
||||
await installExtraData(blockchain, 'full')
|
||||
}
|
||||
else if (process.env.EXTRA_SEED === '1') {
|
||||
console.log('EXTRA_SEED=1 → installExtraData (candidate): partner1 — пайщик-organization, готов к UI Connect')
|
||||
await installExtraData(blockchain, 'candidate')
|
||||
}
|
||||
else {
|
||||
console.log('EXTRA_RENT/EXTRA_SEED не заданы → пропускаем провижининг partner1')
|
||||
}
|
||||
await installExtraData(blockchain) // Добавить дополнительных пайщиков
|
||||
|
||||
console.log('Инициализируем статус системы в PostgreSQL')
|
||||
await initSystemStatus() // Устанавливает статус 'active' в PostgreSQL
|
||||
|
||||
@@ -15,9 +15,6 @@ import { sleep } from '../utils'
|
||||
import { generateRandomSHA256 } from '../utils/randomHash'
|
||||
import { initUsersInPostgres, initVaultInPostgres } from '../postgres-init'
|
||||
import { CooperativeClass } from './cooperative'
|
||||
import { signProgramAgreement } from './sign-program-agreement'
|
||||
import { fakeDocument } from '../tests/shared/fakeDocument'
|
||||
import { walletDraftId, walletProgramId } from '../tests/capital/consts'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
@@ -580,10 +577,7 @@ export async function installInitialData(blockchain: Blockchain, isExtended = fa
|
||||
console.log('Начальные данные установлены')
|
||||
}
|
||||
|
||||
export async function installExtraData(
|
||||
blockchain: Blockchain,
|
||||
mode: 'full' | 'candidate' = 'full',
|
||||
) {
|
||||
export async function installExtraData(blockchain: Blockchain) {
|
||||
// Регистрируем partner1 как coop с авто-approve от провайдера (Восхода).
|
||||
// Без этого реальный UI flow требует ручной активации председателем —
|
||||
// провайдер не подтянет partner1 в свою DB пока в registrator.coops его
|
||||
@@ -595,58 +589,33 @@ export async function installExtraData(
|
||||
// program_id > 0 с проверкой «подписывается через wallet::signagree»).
|
||||
// Минимум для попадания в registrator.coops со status=active:
|
||||
// newaccount → reguser(type=organization) → regcoop → stcoopstatus(active)
|
||||
//
|
||||
// mode='candidate' — пропускает regcoop+preInit. partner1 остаётся
|
||||
// пайщиком-organization Восхода: можно signin под WIF из фикстуры и пройти
|
||||
// UI «Connect / выбор тарифа / подписание / активация» руками. Используется
|
||||
// для отладки UX подключения без авто-провижининга.
|
||||
console.log(`\n=== installExtraData (${mode}): partner1 ===`)
|
||||
console.log('\n=== installExtraData: регистрируем partner1 как coop (active) ===')
|
||||
|
||||
const username = 'partner1'
|
||||
|
||||
// Источники WIF (по приоритету): env PARTNER1_WIF → фикстура
|
||||
// docs-harness/state/cooperatives/partner1.json → DEFAULT_PARTNER1_WIF.
|
||||
//
|
||||
// DEFAULT_PARTNER1_WIF — общеизвестный test-ключ EOSIO (тот же, что у root
|
||||
// системного eosio в boot/configs/config.ini). На dev-стенде использовать
|
||||
// безопасно: контур закрытый, на проде partner1 не существует. Стабильный
|
||||
// дефолт удобнее env-override'ов и фикстур — каждый reboot:extra даёт тот
|
||||
// же signin, без записи в .env.
|
||||
const DEFAULT_PARTNER1_WIF = '5KQwrPbwdL6PhXujxW37FSSQZ1JiwsST4cqQzDeyXtP79zkvFD3'
|
||||
// Берём 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
|
||||
const envWif = process.env.PARTNER1_WIF?.trim()
|
||||
if (envWif) {
|
||||
try {
|
||||
const pub = await ecc.privateToPublic(envWif)
|
||||
seededKeys = { privateKey: envWif, publicKey: pub }
|
||||
console.log(`✓ partner1: используем PARTNER1_WIF из env (pub=${pub.slice(0, 12)}…)`)
|
||||
} catch (e: any) {
|
||||
console.warn(`⚠ PARTNER1_WIF недопустим (${e.message}) — пробуем фикстуру`)
|
||||
}
|
||||
}
|
||||
if (!seededKeys) {
|
||||
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)}…)`)
|
||||
}
|
||||
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}) — пробуем дефолт`)
|
||||
}
|
||||
}
|
||||
if (!seededKeys) {
|
||||
const pub = await ecc.privateToPublic(DEFAULT_PARTNER1_WIF)
|
||||
seededKeys = { privateKey: DEFAULT_PARTNER1_WIF, publicKey: pub }
|
||||
console.log(`✓ partner1: используем DEFAULT_PARTNER1_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')
|
||||
@@ -676,51 +645,39 @@ export async function installExtraData(
|
||||
registration_hash,
|
||||
})
|
||||
|
||||
if (mode === 'full') {
|
||||
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.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',
|
||||
})
|
||||
}
|
||||
else {
|
||||
console.log('candidate-mode: пропускаем regcoop+preInit (UI Connect делает руками)')
|
||||
}
|
||||
await blockchain.preInit({
|
||||
coopname: account.username,
|
||||
username: config.provider,
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
// Записываем partner1 в ЦПП Кошелька оператора (voskhod, program_id=1).
|
||||
// БЕЗ этого провайдерский performInitialTransfers (150 AXON на partner1 ДО
|
||||
// перехода в ACTIVE, см. provider CLAUDE.md §13) падает ассертом
|
||||
// eosio.token::is_can_transfer «Получатель не является участником целевой
|
||||
// потребительской программы кошелька» — и инстанс навсегда застревает в
|
||||
// INSTALL. wallet::signagree (auth voskhod@active) делает partner1 членом
|
||||
// ЦПП Кошелька. Та же механика, что для обычных пайщиков в participant.ts.
|
||||
console.log('Подписываем wallet-соглашение за partner1 (членство в ЦПП Кошелька)')
|
||||
await signProgramAgreement(blockchain, config.provider, account.username, walletProgramId, walletDraftId, fakeDocument)
|
||||
|
||||
// Ресурсы для партнёра: powerup CPU/NET.
|
||||
// Ресурсы для партнёра: powerup CPU/NET. transfer токенов пропускаем —
|
||||
// eosio.token::transfer падает на проверке membership в wallet program,
|
||||
// которая для partner1 не настроена. Для provider sync + Hostkey-flow
|
||||
// токены не нужны: tx от имени partner1 пойдут от soviet/admin.
|
||||
await blockchain.powerup({
|
||||
payer: 'eosio',
|
||||
receiver: account.username,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/cleos",
|
||||
"version": "2026.5.27-2",
|
||||
"version": "2026.5.23-4",
|
||||
"private": true,
|
||||
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
|
||||
"scripts": {
|
||||
|
||||
@@ -58,7 +58,6 @@ add_contract_build(registrator)
|
||||
add_contract_build(gateway)
|
||||
add_contract_build(marketplace)
|
||||
add_contract_build(apps)
|
||||
add_contract_build(billing)
|
||||
add_contract_build(system)
|
||||
|
||||
# Опции для тестов
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Локальная сборка всех контрактов: оборачивает build_contracts_cdt.sh в
|
||||
# CDT-образ. Сам cmake/make живёт в build_contracts_cdt.sh — он же
|
||||
# используется в CI (там CDT ставится из .deb, без docker), поэтому
|
||||
# флаги сборки в одном месте и не разъезжаются.
|
||||
|
||||
mode="${1:-prod}"
|
||||
|
||||
if [ "$mode" = "test" ]; then
|
||||
is_testnet="ON"
|
||||
else
|
||||
is_testnet="OFF"
|
||||
fi
|
||||
|
||||
docker run --rm --name cdt \
|
||||
--volume "$(pwd):/project" \
|
||||
-w /project \
|
||||
dicoop/blockchain:latest \
|
||||
/bin/bash -c "./build_contracts_cdt.sh $mode"
|
||||
/bin/bash -c "mkdir -p build && cd build && cmake -DBUILD_TARGET= -DTEST_TARGET= -DVERBOSE=ON -DBUILD_TESTS=OFF -DIS_TESTNET=$is_testnet .. && make"
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Компиляция всех контрактов кооперативной экономики через CDT.
|
||||
#
|
||||
# ВАЖНО: скрипт запускается УЖЕ внутри окружения с установленным CDT —
|
||||
# тулчейн ожидается по пути /cdt/build/lib/cmake/cdt/CDTWasmToolchain.cmake
|
||||
# (он захардкожен в CMakeLists.txt). Здесь НЕТ docker — только cmake/make.
|
||||
#
|
||||
# Два способа подготовить это окружение:
|
||||
# - локально: build-all.sh оборачивает скрипт в `docker run dicoop/blockchain:latest`
|
||||
# (в образе CDT уже лежит в /cdt/build);
|
||||
# - в CI: CDT ставится из .deb (C9S/cdt), а /cdt/build симлинкуется на
|
||||
# /usr/opt/cdt/<ver> (см. .github/workflows/release.yaml).
|
||||
#
|
||||
# Один источник cmake-флагов для обоих путей — чтобы локальная и CI-сборка
|
||||
# не разъезжались.
|
||||
set -euo pipefail
|
||||
|
||||
mode="${1:-prod}"
|
||||
if [ "$mode" = "test" ]; then
|
||||
is_testnet="ON"
|
||||
else
|
||||
is_testnet="OFF"
|
||||
fi
|
||||
|
||||
mkdir -p build
|
||||
cd build
|
||||
cmake -DBUILD_TARGET= -DTEST_TARGET= -DVERBOSE=ON -DBUILD_TESTS=OFF -DIS_TESTNET="$is_testnet" ..
|
||||
make
|
||||
@@ -1,14 +0,0 @@
|
||||
cmake_minimum_required(VERSION 3.5)
|
||||
|
||||
# Проект
|
||||
project(billing)
|
||||
|
||||
find_package(cdt)
|
||||
|
||||
# Добавление контракта
|
||||
add_contract(billing billing billing.cpp)
|
||||
if(IS_TESTNET)
|
||||
target_compile_definitions(billing PUBLIC IS_TESTNET=1)
|
||||
else()
|
||||
# Не определяем IS_TESTNET для продакшн режима
|
||||
endif()
|
||||
@@ -1,18 +0,0 @@
|
||||
#include "billing.hpp"
|
||||
#include <ctime>
|
||||
#include <eosio/transaction.hpp>
|
||||
|
||||
#include "src/convert.cpp"
|
||||
#include "src/pay.cpp"
|
||||
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* @brief Миграция данных контракта.
|
||||
* @ingroup public_billing_actions
|
||||
* @note Авторизация требуется от аккаунта: @p billing
|
||||
*/
|
||||
[[eosio::action]]
|
||||
void billing::migrate() {
|
||||
require_auth(_billing);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/asset.hpp>
|
||||
#include <eosio/eosio.hpp>
|
||||
#include "../lib/index.hpp"
|
||||
|
||||
/**
|
||||
\defgroup public_billing Контракт BILLING
|
||||
|
||||
* Минимальный смарт-контракт оплаты инфраструктурных подписок членскими взносами
|
||||
* пайщика (Epic 12). Ровно два действия:
|
||||
* - `convert` — трансляция паевого взноса пайщика в членский на персональный
|
||||
* биллинг-кошелёк (`w.wal.bill`) по подписанному заявлению (document2);
|
||||
* - `pay` — списание с биллинг-кошелька суммарной стоимости подписок в
|
||||
* инфраструктурный кошелёк кооператива (`w.sov.infra`) по идентификатору
|
||||
* платежа (`payment_hash`), идемпотентно.
|
||||
*
|
||||
* Состав, цены и даты подписок on-chain НЕ хранятся — это зона оператора
|
||||
* (provider backend); контракт несёт только сумму + `payment_hash` + memo.
|
||||
*/
|
||||
|
||||
/**
|
||||
\defgroup public_billing_actions Действия
|
||||
\ingroup public_billing
|
||||
*/
|
||||
|
||||
/**
|
||||
\defgroup public_billing_tables Таблицы
|
||||
\ingroup public_billing
|
||||
*/
|
||||
|
||||
namespace Billing {
|
||||
/**
|
||||
* @brief Проверка, что аккаунт-плательщик является кооперативом (presence-only).
|
||||
*
|
||||
* По требованию @ant (2026-05-25): проверяем только НАЛИЧИЕ записи в реестре
|
||||
* кооперативов регистратора и флаг `is_cooperative`, БЕЗ проверки статуса —
|
||||
* заблокированные/на рассмотрении кооперативы тоже проходят этот guard
|
||||
* (статус контролируется выше по потоку, на стороне оператора/ledger2).
|
||||
*/
|
||||
inline void assert_payer_is_cooperative(eosio::name coopname) {
|
||||
cooperatives2_index coops(_registrator, _registrator.value);
|
||||
auto org = coops.find(coopname.value);
|
||||
eosio::check(org != coops.end(), "Плательщик не найден в реестре кооперативов");
|
||||
eosio::check(org->is_coop(), "Плательщик не является кооперативом");
|
||||
}
|
||||
} // namespace Billing
|
||||
|
||||
/**
|
||||
* \ingroup public_contracts
|
||||
* @brief Контракт Billing (оплата подписок членскими взносами).
|
||||
*/
|
||||
class [[eosio::contract]] billing : public contract {
|
||||
public:
|
||||
using contract::contract;
|
||||
|
||||
[[eosio::action]] void migrate();
|
||||
|
||||
// Конвертация паевого взноса в членский на биллинг-кошелёк пайщика.
|
||||
// convert_hash — детерминированный sha256 от (coopname, username, amount,
|
||||
// anchor) — служит process-якорем: используется как ключ операции ledger2
|
||||
// и как package_hash для Soviet::make_complete_document (документ в реестре
|
||||
// ищется именно по convert_hash). Повтор с тем же convert_hash —
|
||||
// идемпотентный no-op на уровне реестра soviet (newresolved уже зафиксирован).
|
||||
[[eosio::action]] void convert(eosio::name coopname, eosio::name username,
|
||||
eosio::asset amount, eosio::checksum256 convert_hash,
|
||||
document2 document);
|
||||
|
||||
// Списание с биллинг-кошелька стоимости подписок (оплата провайдеру).
|
||||
[[eosio::action]] void pay(eosio::name coopname, eosio::name username,
|
||||
eosio::asset amount, eosio::checksum256 payment_hash,
|
||||
std::string memo);
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* @brief Конвертация паевого взноса пайщика в членский на биллинг-кошелёк.
|
||||
*
|
||||
* Пайщик подаёт подписанное заявление (document2) «прошу транслировать мой
|
||||
* паевой взнос в членский за использование инфраструктуры». Контракт одним
|
||||
* движением ledger2 (`o.bil.fund`) переводит средства с паевого кошелька
|
||||
* `w.wal.share` на персональный биллинг-кошелёк `w.wal.bill` (Dr 80 / Cr 86):
|
||||
* паевой взнос (возвратный) превращается в членский (целевой, невозвратный),
|
||||
* зарезервированный под оплату подписок. Сам факт конвертации трактуется как
|
||||
* согласие пайщика на последующие рекуррентные списания (см. `pay`).
|
||||
*
|
||||
* @param coopname Кооператив-плательщик (его подписью релеит бэкенд после JWT).
|
||||
* @param username Пайщик, чей паевой конвертируется.
|
||||
* @param amount Сумма конвертации (RUB, > 0).
|
||||
* @param convert_hash Детерминированный sha256-якорь процесса конвертации
|
||||
* (coopname, username, amount, anchor). Используется как
|
||||
* process_hash ledger2-операции и как package_hash в
|
||||
* Soviet::make_complete_document — по нему документ
|
||||
* находится в реестре кооператива. Обеспечивает связность
|
||||
* процесса и идемпотентность на уровне soviet-реестра.
|
||||
* @param document Подписанное пайщиком заявление (обязательно, непустой hash).
|
||||
*
|
||||
* @ingroup public_billing_actions
|
||||
* @note Авторизация требуется от аккаунта: @p coopname
|
||||
*/
|
||||
void billing::convert(name coopname, name username, asset amount,
|
||||
checksum256 convert_hash, document2 document) {
|
||||
require_auth(coopname);
|
||||
|
||||
// Плательщик должен быть кооперативом (presence-only, без статуса).
|
||||
Billing::assert_payer_is_cooperative(coopname);
|
||||
|
||||
check(amount.is_valid() && amount.amount > 0, "Сумма конвертации должна быть положительной");
|
||||
check(amount.symbol == _root_govern_symbol, "Неверный символ валюты для конвертации");
|
||||
check(convert_hash != checksum256{}, "convert_hash обязателен (детерминированный якорь процесса)");
|
||||
|
||||
// Заявление обязательно и должно быть подписано пайщиком. Ловушка: пустой
|
||||
// checksum256 ломает ABI-кодирование/верификацию — документ должен быть реальным.
|
||||
check(!is_empty_document(document), "Заявление на конвертацию обязательно (пустой документ недопустим)");
|
||||
verify_document_or_fail(document, std::vector<name>{username});
|
||||
|
||||
const std::string memo =
|
||||
"Трансляция паевого взноса в членский на биллинг-кошелёк, пайщик=" + username.to_string();
|
||||
|
||||
Ledger2::apply(
|
||||
_billing,
|
||||
coopname,
|
||||
operations::billing::CONVERT,
|
||||
amount,
|
||||
username,
|
||||
convert_hash,
|
||||
memo
|
||||
);
|
||||
|
||||
// Фиксируем заявление в общем реестре документов кооператива (newsubmitted +
|
||||
// newresolved в soviet — как в registrator::regcoop / capital::createpinv /
|
||||
// soviet::converttoaxn). action_name = operations::billing::CONVERT
|
||||
// (= "o.bil.fund"_n) — уникальное имя ledger2-операции, не пересекается с
|
||||
// generic "convert"_n из других контрактов. package_hash = convert_hash —
|
||||
// документ привязан к конкретному процессу; повтор с тем же convert_hash на
|
||||
// уровне soviet-реестра идемпотентен.
|
||||
Soviet::make_complete_document(
|
||||
_billing,
|
||||
coopname,
|
||||
username,
|
||||
operations::billing::CONVERT,
|
||||
convert_hash,
|
||||
document
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
using namespace eosio;
|
||||
|
||||
/**
|
||||
* @brief Списание стоимости подписок с биллинг-кошелька пайщика (оплата).
|
||||
*
|
||||
* Оператор (Восход) одним действием списывает с биллинг-кошелька `w.wal.bill`
|
||||
* пайщика суммарную стоимость его активных подписок и зачисляет её в
|
||||
* инфраструктурный кошелёк кооператива `w.sov.infra` (ledger2 `o.bil.pay`).
|
||||
* Отдельная подпись пайщика не требуется — авторизация обеспечена нахождением
|
||||
* контракта в `contracts_whitelist` + фактом конвертации (`convert` = согласие).
|
||||
*
|
||||
* Состав подписок on-chain не раскрывается: контракт несёт только сумму,
|
||||
* `payment_hash` (ссылка на запись в БД провайдера) и memo. Дедуп по
|
||||
* `payment_hash` — у provider'а (Single-Hub v5: парсер Восхода ловит pay-event,
|
||||
* coopback Восхода дёргает provider `POST /billing/payment-confirmed`, provider
|
||||
* сам отвечает на повторы). Контракт своих таблиц не ведёт.
|
||||
*
|
||||
* @param coopname Кооператив-плательщик.
|
||||
* @param username Пайщик, с чьего биллинг-кошелька списываем.
|
||||
* @param amount Сумма к оплате (RUB, > 0).
|
||||
* @param payment_hash Идентификатор платежа из БД провайдера (обязателен).
|
||||
* @param memo Произвольный комментарий (< 256 символов).
|
||||
*
|
||||
* @ingroup public_billing_actions
|
||||
* @note Авторизация требуется от аккаунта: @p coopname
|
||||
*/
|
||||
void billing::pay(name coopname, name username, asset amount,
|
||||
checksum256 payment_hash, std::string memo) {
|
||||
require_auth(coopname);
|
||||
|
||||
// Плательщик должен быть кооперативом (presence-only, без статуса).
|
||||
Billing::assert_payer_is_cooperative(coopname);
|
||||
|
||||
check(amount.is_valid() && amount.amount > 0, "Сумма оплаты должна быть положительной");
|
||||
check(amount.symbol == _root_govern_symbol, "Неверный символ валюты для оплаты");
|
||||
check(payment_hash != checksum256{}, "payment_hash обязателен");
|
||||
check(memo.size() < 256, "memo не должен превышать 255 символов");
|
||||
|
||||
const std::string ledger_memo =
|
||||
memo.empty() ? std::string("Оплата подписки за инфраструктуру") : memo;
|
||||
|
||||
// Списание членских взносов пайщика → инфраструктурный кошелёк кооператива.
|
||||
// Если на w.wal.bill недостаточно средств — walletop упадёт доменной ошибкой;
|
||||
// оператор фиксирует подписку как просроченную (Epic 4: past_due → grace).
|
||||
Ledger2::apply(
|
||||
_billing,
|
||||
coopname,
|
||||
operations::billing::PAY,
|
||||
amount,
|
||||
username,
|
||||
payment_hash,
|
||||
ledger_memo
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -72,8 +72,8 @@ void capital::importcontrib(eosio::name coopname, eosio::name username, checksum
|
||||
auto idx = user_wallets.get_index<"byuserwallet"_n>();
|
||||
auto preimp_it = idx.find(combine_ids(ledger2_wallets::PREIMP_FUND.value, username.value));
|
||||
if (preimp_it != idx.end() && preimp_it->available.amount > 0) {
|
||||
// Поле blocked упразднено (2026-05-24): резерв/блокировка на кошельках
|
||||
// больше не используется, проверка preimp.blocked == 0 излишня.
|
||||
eosio::check(preimp_it->blocked.amount == 0,
|
||||
"preimp.blocked > 0 не поддерживается при импорте — обратиться в поддержку");
|
||||
Ledger2::apply(
|
||||
_capital, coopname, operations::capital::DROP_PREIMP,
|
||||
preimp_it->available, username, contributor_hash,
|
||||
|
||||
@@ -9,26 +9,23 @@ namespace Capital::Core {
|
||||
* @brief Совокупный баланс программы Благорост на уровне кооператива.
|
||||
*
|
||||
* Источник — ledger2 L2 (`wallets[BLAGOROST_FUND]`), агрегированный по всем
|
||||
* пайщикам. Σ L3.available[w.cap.blago] == L2.available (Эпик 3 invariant),
|
||||
* поэтому L2 — авторитетная сумма для CRPS-знаменателя. Если кошелёк ещё не
|
||||
* создан (никто не инвестировал) — нулевой баланс.
|
||||
*
|
||||
* Поле blocked упразднено (2026-05-24): субсчёт «заблокировано» больше не
|
||||
* используется, баланс кошелька = available.
|
||||
* пайщикам. Σ L3.{available, blocked}[w.cap.blago] == L2.{available, blocked}
|
||||
* (Эпик 3 invariant), поэтому L2 — авторитетная сумма для CRPS-знаменателя.
|
||||
* Если кошелёк ещё не создан (никто не инвестировал) — нулевой баланс.
|
||||
*/
|
||||
eosio::asset get_capital_program_share_balance(eosio::name coopname) {
|
||||
wallets2_index wallets(_ledger2, coopname.value);
|
||||
auto it = wallets.find(ledger2_wallets::BLAGOROST_FUND.value);
|
||||
if (it == wallets.end()) return eosio::asset(0, _root_govern_symbol);
|
||||
return it->available;
|
||||
return it->available + it->blocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Доля пайщика в Благоросте (available) из ledger2 L3.
|
||||
* @brief Доля пайщика в Благоросте (available + blocked) из ledger2 L3.
|
||||
*
|
||||
* Источник — `userwallets[(w.cap.blago, username)]`. Запись существует
|
||||
* только пока available > 0 (Эпик 3 §6: auto-delete на нуле), поэтому
|
||||
* отсутствие = нулевой баланс. Поле blocked упразднено (2026-05-24).
|
||||
* только пока available + blocked > 0 (Эпик 3 §6: auto-delete на нуле),
|
||||
* поэтому отсутствие = нулевой баланс.
|
||||
*/
|
||||
eosio::asset get_capital_program_user_share_balance(eosio::name coopname, eosio::name username) {
|
||||
userwallets_index user_wallets(_ledger2, coopname.value);
|
||||
@@ -36,6 +33,6 @@ namespace Capital::Core {
|
||||
auto key = combine_ids(ledger2_wallets::BLAGOROST_FUND.value, username.value);
|
||||
auto it = idx.find(key);
|
||||
if (it == idx.end()) return eosio::asset(0, _root_govern_symbol);
|
||||
return it->available;
|
||||
return it->available + it->blocked;
|
||||
}
|
||||
}
|
||||
@@ -67,12 +67,12 @@ void ledger2::revert(eosio::name coopname,
|
||||
eosio::check(original_operation_id != 0, "revert: original_operation_id обязателен");
|
||||
|
||||
// -------- validate mirror_wallet_op --------
|
||||
// Зеркало revert — только TRANSFER (обмен wallet_from/wallet_to) либо BURN
|
||||
// (зеркало ISSUE: изъятие с wallet_from). Бывшие BLOCK/UNBLOCK упразднены
|
||||
// (2026-05-24); ISSUE/NONE как зеркало смысла не имеют.
|
||||
eosio::check(mirror_wallet_op == static_cast<uint8_t>(WalletOp::TRANSFER) ||
|
||||
mirror_wallet_op == static_cast<uint8_t>(WalletOp::BURN),
|
||||
"revert: mirror_wallet_op должен быть TRANSFER или BURN");
|
||||
eosio::check(mirror_wallet_op <= 5, "revert: неизвестный mirror_wallet_op");
|
||||
// Не позволяем откатывать через BLOCK/UNBLOCK — они асимметричны и нет
|
||||
// адекватного зеркала в одной операции (BLOCK + UNBLOCK — обратные сами по себе).
|
||||
eosio::check(mirror_wallet_op != static_cast<uint8_t>(WalletOp::BLOCK) &&
|
||||
mirror_wallet_op != static_cast<uint8_t>(WalletOp::UNBLOCK),
|
||||
"revert: BLOCK/UNBLOCK не подлежат откату через revert (они симметричны сами себе)");
|
||||
|
||||
// -------- validate mirror wallets/accounts --------
|
||||
if (mirror_wallet_from.value != 0) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @brief Атомарная операция по кошельку (issue/transfer/burn).
|
||||
* @brief Атомарная операция по кошельку (issue/transfer/block/unblock/burn/burn_blocked).
|
||||
*
|
||||
* Внутренний action ledger2 — вызывается только через inline из apply().
|
||||
* Auth: только сам ledger2 (require_auth(get_self())).
|
||||
@@ -53,15 +53,13 @@ void ledger2::walletop(eosio::name coopname,
|
||||
eosio::check(amount.symbol == _root_govern_symbol,
|
||||
"walletop: некорректный символ валюты");
|
||||
eosio::check(memo.size() < 256, "walletop: memo > 255");
|
||||
// Допустимы только ISSUE(0), TRANSFER(1), BURN(4). NONE(5) — это только
|
||||
// бухпроводка без кошелькового движения, apply.cpp для неё walletop не диспатчит;
|
||||
// прямой вызов был бы no-op и сбил бы parity. Бывшие BLOCK(2)/UNBLOCK(3)/
|
||||
// BURN_BLOCKED(6) упразднены (2026-05-24): резерв возврата теперь TRANSFER на
|
||||
// w.wal.wpend, их op_code больше не валиден.
|
||||
eosio::check(op_code == static_cast<uint8_t>(WalletOp::ISSUE) ||
|
||||
op_code == static_cast<uint8_t>(WalletOp::TRANSFER) ||
|
||||
op_code == static_cast<uint8_t>(WalletOp::BURN),
|
||||
"walletop: недопустимый op_code");
|
||||
// op_code = 5 (NONE) намеренно не допускается: NONE-операции — это только
|
||||
// бухпроводка без кошелькового движения, apply.cpp не диспатчит для них walletop.
|
||||
// Прямой вызов с op_code=5 был бы no-op и сбил бы инвариант parity.
|
||||
// Допустимы 0..4 (ISSUE/TRANSFER/BLOCK/UNBLOCK/BURN) и 6 (BURN_BLOCKED).
|
||||
eosio::check(op_code <= static_cast<uint8_t>(WalletOp::BURN_BLOCKED) &&
|
||||
op_code != static_cast<uint8_t>(WalletOp::NONE),
|
||||
"walletop: неизвестный op_code");
|
||||
|
||||
wallets2_index wallets(get_self(), coopname.value);
|
||||
userwallets_index user_wallets(get_self(), coopname.value);
|
||||
@@ -174,7 +172,7 @@ void ledger2::walletop(eosio::name coopname,
|
||||
// миграций 048/049.
|
||||
//
|
||||
// walletop по построению применяет одно и то же `amount` к L2 и L3 (см.
|
||||
// ниже case'ы ISSUE/TRANSFER/BURN), а sender-guard на
|
||||
// ниже case'ы ISSUE/TRANSFER/BLOCK/UNBLOCK/BURN/BURN_BLOCKED), а sender-guard на
|
||||
// строке 46-47 запрещает обход. Поэтому инвариант сохраняется по
|
||||
// конструкции; полную сверку выполняет бэкенд («стол бухгалтера»),
|
||||
// вне транзакционного hot path.
|
||||
@@ -229,6 +227,60 @@ void ledger2::walletop(eosio::name coopname,
|
||||
cleanup_l3_if_empty(wallet_from);
|
||||
break;
|
||||
}
|
||||
case WalletOp::BLOCK: {
|
||||
eosio::check(wallet_from.value != 0, "walletop BLOCK: требуется wallet_from");
|
||||
eosio::check(wallet_to.value == 0, "walletop BLOCK: wallet_to должен быть пустым");
|
||||
|
||||
auto it = wallets.find(wallet_from.value);
|
||||
eosio::check(it != wallets.end() && it->available >= amount,
|
||||
std::string{"walletop BLOCK: недостаточно available на кошельке "} +
|
||||
wallet_from.to_string());
|
||||
wallets.modify(it, payer, [&](auto& w) {
|
||||
w.available -= amount;
|
||||
w.blocked += amount;
|
||||
});
|
||||
|
||||
if (is_user_shared_l3(wallet_from)) {
|
||||
auto uw = find_l3(wallet_from);
|
||||
eosio::check(uw != user_wallets.get_index<"byuserwallet"_n>().end() &&
|
||||
uw->available >= amount,
|
||||
std::string{"walletop BLOCK: недостаточно L3-available у пайщика "} +
|
||||
username.to_string() + " на " + wallet_from.to_string());
|
||||
auto uw_pri = user_wallets.find(uw->id);
|
||||
user_wallets.modify(uw_pri, payer, [&](auto& r) {
|
||||
r.available -= amount;
|
||||
r.blocked += amount;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case WalletOp::UNBLOCK: {
|
||||
eosio::check(wallet_from.value != 0, "walletop UNBLOCK: требуется wallet_from");
|
||||
eosio::check(wallet_to.value == 0, "walletop UNBLOCK: wallet_to должен быть пустым");
|
||||
|
||||
auto it = wallets.find(wallet_from.value);
|
||||
eosio::check(it != wallets.end() && it->blocked >= amount,
|
||||
std::string{"walletop UNBLOCK: недостаточно blocked на кошельке "} +
|
||||
wallet_from.to_string());
|
||||
wallets.modify(it, payer, [&](auto& w) {
|
||||
w.blocked -= amount;
|
||||
w.available += amount;
|
||||
});
|
||||
|
||||
if (is_user_shared_l3(wallet_from)) {
|
||||
auto uw = find_l3(wallet_from);
|
||||
eosio::check(uw != user_wallets.get_index<"byuserwallet"_n>().end() &&
|
||||
uw->blocked >= amount,
|
||||
std::string{"walletop UNBLOCK: недостаточно L3-blocked у пайщика "} +
|
||||
username.to_string() + " на " + wallet_from.to_string());
|
||||
auto uw_pri = user_wallets.find(uw->id);
|
||||
user_wallets.modify(uw_pri, payer, [&](auto& r) {
|
||||
r.blocked -= amount;
|
||||
r.available += amount;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case WalletOp::BURN: {
|
||||
eosio::check(wallet_from.value != 0, "walletop BURN: требуется wallet_from");
|
||||
eosio::check(wallet_to.value == 0, "walletop BURN: wallet_to должен быть пустым");
|
||||
@@ -259,6 +311,30 @@ void ledger2::walletop(eosio::name coopname,
|
||||
eosio::check(false, "walletop NONE: запрещённый op_code");
|
||||
break;
|
||||
}
|
||||
case WalletOp::BURN_BLOCKED: {
|
||||
eosio::check(wallet_from.value != 0, "walletop BURN_BLOCKED: требуется wallet_from");
|
||||
eosio::check(wallet_to.value == 0, "walletop BURN_BLOCKED: wallet_to должен быть пустым");
|
||||
|
||||
auto it = wallets.find(wallet_from.value);
|
||||
eosio::check(it != wallets.end() && it->blocked >= amount,
|
||||
std::string{"walletop BURN_BLOCKED: недостаточно blocked на кошельке "} +
|
||||
wallet_from.to_string());
|
||||
wallets.modify(it, payer, [&](auto& w) { w.blocked -= amount; });
|
||||
|
||||
if (is_user_shared_l3(wallet_from)) {
|
||||
auto uw = find_l3(wallet_from);
|
||||
eosio::check(uw != user_wallets.get_index<"byuserwallet"_n>().end() &&
|
||||
uw->blocked >= amount,
|
||||
std::string{"walletop BURN_BLOCKED: недостаточно L3-blocked у пайщика "} +
|
||||
username.to_string() + " на " + wallet_from.to_string());
|
||||
auto uw_pri = user_wallets.find(uw->id);
|
||||
user_wallets.modify(uw_pri, payer, [&](auto& r) { r.blocked -= amount; });
|
||||
}
|
||||
|
||||
cleanup_l2_if_empty(wallet_from);
|
||||
cleanup_l3_if_empty(wallet_from);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Post-mutation Σ L3 == L2: см. блок выше (строки 163-175). Снято
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* @brief Универсальное миграционное действие контракта ledger2 — точка
|
||||
* расширения для разовых исправлений состояния, которые можно провести
|
||||
@@ -7,33 +9,23 @@
|
||||
* после её прогона на проде тело очищается до пустого `require_auth(get_self())`
|
||||
* (как в `capital::migrate`).
|
||||
*
|
||||
* Текущая задача (2026-05-24): свёртка `blocked → available` по ВСЕМ коопам.
|
||||
* Текущая задача (2026-05-21): чистка осиротевших L3-записей `w.cap.gen`
|
||||
* на voskhod после смены WalletKind GENERATOR_FUND с USER_SHARED на
|
||||
* COOPERATIVE (см. `lib/core/ledger2/wallets.hpp`).
|
||||
*
|
||||
* Контекст: механика «заблокированного» баланса упразднена (см.
|
||||
* `lib/core/ledger2/operations.hpp` — удалены WalletOp BLOCK/UNBLOCK/BURN_BLOCKED;
|
||||
* резерв возврата паевого теперь выражается переводом на кошелёк-резерв
|
||||
* `w.wal.wpend`). Поле `blocked` остаётся в таблицах `wallets2`/`userwallets`
|
||||
* как deprecated (физическое удаление поля = небезопасная смена layout таблицы
|
||||
* на живых коопах, выносится в отдельный cleanup-деплой). Перед тем как поле
|
||||
* перестанет поддерживаться кодом, накопленные `blocked`-остатки нужно вернуть
|
||||
* в `available`, чтобы средства не «зависли» на упразднённом субсчёте.
|
||||
* Контекст: на voskhod `convertsegm` падал «недостаточно L3-средств у
|
||||
* пайщика» в проектах, где CRPS перераспределял доли между сегментами.
|
||||
* w.cap.gen был USER_SHARED, а CRPS в approvecmmt не делал per-user
|
||||
* компенсирующих TRANSFER между сегментами: `Σ COMMIT_RID == Σ ACCEPT_RID`
|
||||
* соблюдался только на проекте, не на пайщике.
|
||||
*
|
||||
* Действие: пройти всех кооперативов (cooperatives2 в scope registrator) и для
|
||||
* каждого свернуть `blocked → available` на уровнях L2 (`wallets2`) и L3
|
||||
* (`userwallets`): `available += blocked; blocked = 0`. Сумма средств на кошельке
|
||||
* не меняется — только субсчёт.
|
||||
* Архитектурный фикс: w.cap.gen — COOPERATIVE-пул без L3. Чтобы UI/бэкенд
|
||||
* не врали остатками по «личным» w.cap.gen, удаляем осиротевшие L3-записи
|
||||
* прямым `userwallets.erase`. L2-баланс `wallets2[w.cap.gen]` уже верен
|
||||
* (синхронен с Σ старых userwallets), его не трогаем.
|
||||
*
|
||||
* Идемпотентно: после свёртки `blocked == 0`, повторный прогон — no-op.
|
||||
*
|
||||
* Сигнатура без аргументов — действие вызывается автоматически при деплое
|
||||
* контракта (как и прочие задачи migrate); проходит по всем кооперативам сам.
|
||||
*
|
||||
* ПРЕДУСЛОВИЕ (операционное): на момент прогона не должно быть заявок на возврат
|
||||
* «в полёте» (статусы pending/authorized в `wallet::withdraws`) — их `blocked`
|
||||
* относится к старой механике и при свёртке в `available` вернётся пайщику как
|
||||
* свободные средства, а последующий `completewthd` (BURN с `w.wal.wpend`) не
|
||||
* найдёт резерва. Незавершённые заявки нужно довести (complete/decline) ДО
|
||||
* деплоя с этой миграцией.
|
||||
* Идемпотентно: повторный вызов на чистой БД — no-op (lower_bound пуст).
|
||||
* Только voskhod: на остальных кооперативах w.cap.gen не использовался.
|
||||
*
|
||||
* @ingroup public_ledger2_actions
|
||||
*
|
||||
@@ -42,31 +34,24 @@
|
||||
void ledger2::migrate() {
|
||||
require_auth(get_self());
|
||||
|
||||
cooperatives2_index coops(_registrator, _registrator.value);
|
||||
const eosio::name target_coop = "voskhod"_n;
|
||||
|
||||
for (auto c = coops.begin(); c != coops.end(); ++c) {
|
||||
const eosio::name coopname = c->username;
|
||||
userwallets_index user_wallets(get_self(), target_coop.value);
|
||||
auto idx = user_wallets.get_index<"bywallet"_n>();
|
||||
|
||||
// --- L3: userwallets[coopname] ---
|
||||
// Модифицируем только не-ключевые поля (available/blocked) — итерация по
|
||||
// первичному индексу с modify безопасна (порядок строк не меняется).
|
||||
userwallets_index user_wallets(get_self(), coopname.value);
|
||||
for (auto it = user_wallets.begin(); it != user_wallets.end(); ++it) {
|
||||
if (it->blocked.amount <= 0) continue;
|
||||
user_wallets.modify(it, get_self(), [&](auto& r) {
|
||||
r.available += r.blocked;
|
||||
r.blocked = eosio::asset(0, r.blocked.symbol);
|
||||
});
|
||||
}
|
||||
// Собираем 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);
|
||||
}
|
||||
|
||||
// --- L2: wallets2[coopname] ---
|
||||
wallets2_index wallets(get_self(), coopname.value);
|
||||
for (auto it = wallets.begin(); it != wallets.end(); ++it) {
|
||||
if (it->blocked.amount <= 0) continue;
|
||||
wallets.modify(it, get_self(), [&](auto& w) {
|
||||
w.available += w.blocked;
|
||||
w.blocked = eosio::asset(0, w.blocked.symbol);
|
||||
});
|
||||
for (uint64_t id : ids_to_erase) {
|
||||
auto pri = user_wallets.find(id);
|
||||
if (pri != user_wallets.end()) {
|
||||
user_wallets.erase(pri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,6 @@ static constexpr uint64_t _capital_program_id = 4;
|
||||
#define LEDGER "ledger"
|
||||
#define LEDGER2 "ledger2"
|
||||
#define APPS "apps"
|
||||
#define BILLING "billing"
|
||||
|
||||
|
||||
/**
|
||||
@@ -166,7 +165,6 @@ static constexpr uint64_t _capital_program_id = 4;
|
||||
static constexpr eosio::name _ledger = "ledger"_n;
|
||||
static constexpr eosio::name _ledger2 = "ledger2"_n;
|
||||
static constexpr eosio::name _apps = "apps"_n;
|
||||
static constexpr eosio::name _billing = "billing"_n;
|
||||
static constexpr eosio::name _power_account = "eosio.power"_n;
|
||||
static constexpr eosio::name _saving_account = "eosio.saving"_n;
|
||||
|
||||
@@ -195,8 +193,7 @@ static constexpr uint64_t _capital_program_id = 4;
|
||||
"ledger"_n,
|
||||
"ledger2"_n,
|
||||
"capital"_n,
|
||||
"apps"_n,
|
||||
"billing"_n
|
||||
"apps"_n
|
||||
// Добавьте остальные стандартные или пользовательские контракты по необходимости
|
||||
};
|
||||
|
||||
@@ -242,7 +239,7 @@ static constexpr uint64_t _capital_program_id = 4;
|
||||
#ifdef IS_TESTNET
|
||||
static constexpr uint64_t MIN_SOVIET_MEMBERS_COUNT = 1; /*!< минимальное количество членов совета (тест) */
|
||||
#else
|
||||
static constexpr uint64_t MIN_SOVIET_MEMBERS_COUNT = 3; /*!< минимальное количество членов совета (прод) */
|
||||
static constexpr uint64_t MIN_SOVIET_MEMBERS_COUNT = 5; /*!< минимальное количество членов совета (прод) */
|
||||
#endif
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,9 +31,9 @@
|
||||
* - `WalletOp::WALLET_ONLY` удалён (ADR-003): «без бухпроводок» определяется
|
||||
* парой `(debit_account_id == 0, credit_account_id == 0)` на уровне записи.
|
||||
* Для `o.cap.invest` теперь TRANSFER без проводок (оба account_id == 0).
|
||||
* - `WalletOp::BURN` (ADR-003): `available -= amount` на `wallet_from`,
|
||||
* без `wallet_to`. Используется в `o.wal.wthcpl` (сжигание резерва возврата
|
||||
* с `w.wal.wpend`), `o.cap.drppre` и как зеркало ISSUE в `revert`.
|
||||
* - Добавлен `WalletOp::BURN` (ADR-003): `available -= amount` на `wallet_from`,
|
||||
* без `wallet_to`. На текущем этапе в `OPERATION_REGISTRY` не используется —
|
||||
* зарезервирован под будущие операции штатного сжигания.
|
||||
* - Единые программные кошельки: `BLAGOROST_FUND` (`w.cap.blago`) и
|
||||
* `GENERATOR_FUND` (`w.cap.gen`) — заменили ранее декомпозированные
|
||||
* `bginv/bgprop/bgrid/bgmem` и `gncom/gnmem` (ADR-009).
|
||||
@@ -61,9 +61,9 @@ 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, BURN с WITHDRAW_PENDING — деньги уходят из системы, без wallet_to).
|
||||
inline constexpr eosio::name REQUEST_WITHDRAW = "o.wal.wthreq"_n; ///< Запрос на возврат паевого: TRANSFER SHARE_FUND_PAY → WITHDRAW_PENDING (резерв, без Dr/Cr).
|
||||
inline constexpr eosio::name DECLINE_WITHDRAW = "o.wal.wthdec"_n; ///< Отклонение запроса на возврат: TRANSFER WITHDRAW_PENDING → SHARE_FUND_PAY (снятие резерва, без Dr/Cr).
|
||||
inline constexpr eosio::name COMPLETE_WITHDRAW = "o.wal.wthcpl"_n; ///< Завершение возврата паевого взноса (Dr 80 / Cr 51, BURN_BLOCKED SHARE_FUND_PAY — деньги уходят из системы, без wallet_to).
|
||||
inline constexpr eosio::name REQUEST_WITHDRAW = "o.wal.wthreq"_n; ///< Запрос на возврат паевого: BLOCK на SHARE_FUND_PAY (без Dr/Cr).
|
||||
inline constexpr eosio::name DECLINE_WITHDRAW = "o.wal.wthdec"_n; ///< Отклонение запроса на возврат: UNBLOCK на SHARE_FUND_PAY (без Dr/Cr).
|
||||
}
|
||||
|
||||
// capital
|
||||
@@ -93,14 +93,6 @@ namespace operations {
|
||||
inline constexpr eosio::name CONVERT_AXN = "o.sov.axncnv"_n; ///< Трансляция паевого взноса в членский (Dr 80 / Cr 86, TRANSFER SHARE_FUND_PAY → DELEGATE_FEES).
|
||||
}
|
||||
|
||||
// billing (Epic 12 — контракт billing)
|
||||
// ВНИМАНИЕ: имя константы НЕ `FUND` — `FUND` занят макросом `#define FUND "fund"`
|
||||
// в consts.hpp (имя контракта fund). Используем `CONVERT` (бэкенд-имя действия).
|
||||
namespace billing {
|
||||
inline constexpr eosio::name CONVERT = "o.bil.fund"_n; ///< Трансляция паевого взноса в членский на биллинг-кошелёк пайщика (Dr 80 / Cr 86, TRANSFER SHARE_FUND_PAY → BILLING_FUND_PAY).
|
||||
inline constexpr eosio::name PAY = "o.bil.pay"_n; ///< Оплата подписки членскими взносами пайщика (TRANSFER BILLING_FUND_PAY → INFRA_FEES, без Dr/Cr — оба разреза счёта 86).
|
||||
}
|
||||
|
||||
// migration (только из migrate.cpp)
|
||||
//
|
||||
// В OPERATION_REGISTRY включены **только** те транзиты, которые проводятся
|
||||
@@ -137,23 +129,14 @@ namespace operations {
|
||||
* `(debit_account_id == 0, credit_account_id == 0)` на уровне записи реестра.
|
||||
* Compile-time правило `(debit==0) ⇔ (credit==0)` ловит смешанные пары.
|
||||
*/
|
||||
//
|
||||
// Удаление BLOCK/UNBLOCK/BURN_BLOCKED (2026-05-24): механика «заблокированного»
|
||||
// баланса упразднена. Резерв средств под заявку на возврат паевого теперь
|
||||
// выражается переводом на отдельный кошелёк-резерв `w.wal.wpend` (TRANSFER),
|
||||
// возврат резерва — обратным TRANSFER, завершение — BURN с резерва. Поле
|
||||
// `blocked` в таблицах wallets2/userwallets оставлено deprecated (всегда 0
|
||||
// после ledger2::migrate-свёртки) — физическое удаление поля = небезопасная
|
||||
// смена layout таблицы на живых коопах, выносится в отдельный cleanup-деплой.
|
||||
//
|
||||
// Числовые значения ISSUE/TRANSFER/BURN/NONE СОХРАНЕНЫ (не перенумерованы),
|
||||
// чтобы исторические op_code в blockchain_actions читались бэкендом без сдвига
|
||||
// смысла (2 и 3 — бывшие BLOCK/UNBLOCK — больше не выдаются и невалидны на входе).
|
||||
enum class WalletOp : uint8_t {
|
||||
ISSUE = 0, ///< первичный вход средств на кошелёк wallet_to (wallet_from = empty)
|
||||
TRANSFER = 1, ///< перемещение wallet_from → wallet_to (с Dr/Cr ИЛИ без — по парам account_id)
|
||||
BLOCK = 2, ///< available-=amount, blocked+=amount на wallet_from
|
||||
UNBLOCK = 3, ///< blocked-=amount, available+=amount на wallet_from
|
||||
BURN = 4, ///< изъятие amount с wallet_from->available, без wallet_to. Покрывает оба кейса: (a) штатное сжигание как бизнес-операция в OPERATION_REGISTRY; (b) зеркало ISSUE при `ledger2::revert` (различие — через operation_code: `o.adj.rev` для adjustment-mirror).
|
||||
NONE = 5, ///< только бухпроводка без перемещения средств (wallet_from = empty, wallet_to = empty, debit ≠ 0, credit ≠ 0). Покрывает кейсы внутрибалансовых проводок типа Dr 04 / Cr 08 (приём РИД в НМА), когда кошелёк уже на нужном программном фонде.
|
||||
BURN_BLOCKED = 6, ///< изъятие amount из wallet_from->blocked, без wallet_to. Используется при завершении возврата паевого (`o.wal.wthcpl`): средства предварительно заблокированы через BLOCK (REQUEST_WITHDRAW), на завершении сжигаются — выходят из системы (получателя на цепи нет). На L3 синхронно: from_uw->blocked -= amount.
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -162,7 +145,7 @@ enum class WalletOp : uint8_t {
|
||||
* Семантика полей по `wallet_op`:
|
||||
* - ISSUE: wallet_from = eosio::name{}, wallet_to = required.
|
||||
* - TRANSFER: wallet_from = required, wallet_to = required (≠ from).
|
||||
* - BURN: wallet_from = required, wallet_to = eosio::name{}.
|
||||
* - BLOCK / UNBLOCK / BURN / BURN_BLOCKED: wallet_from = required, wallet_to = eosio::name{}.
|
||||
*
|
||||
* Семантика бух.проводки:
|
||||
* - Без проводок: debit_account_id == 0 И credit_account_id == 0.
|
||||
@@ -174,7 +157,7 @@ struct OperationRegistryEntry {
|
||||
eosio::name process_type; ///< тип процесса с префиксом `p.<contract>.<noun>`
|
||||
WalletOp wallet_op;
|
||||
eosio::name wallet_from; ///< пустое имя для ISSUE
|
||||
eosio::name wallet_to; ///< пустое имя для BURN
|
||||
eosio::name wallet_to; ///< пустое имя для BLOCK/UNBLOCK/BURN/BURN_BLOCKED
|
||||
uint64_t debit_account_id; ///< 0 если без бухпроводки (тогда credit_account_id тоже == 0)
|
||||
uint64_t credit_account_id; ///< 0 если без бухпроводки (тогда debit_account_id тоже == 0)
|
||||
const char* human_name;
|
||||
@@ -201,12 +184,12 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
|
||||
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::SHARE_FUND,
|
||||
"Внесение пайщиком паевого взноса" },
|
||||
|
||||
// 4. Возврат паевого взноса: Dr 80 / Cr 51, BURN WITHDRAW_PENDING.
|
||||
// Сжигание из кошелька-резерва (TRANSFER в резерв был на REQUEST_WITHDRAW):
|
||||
// 4. Возврат паевого взноса: Dr 80 / Cr 51, BURN_BLOCKED SHARE_FUND_PAY.
|
||||
// Сжигание из заблокированной суммы пайщика (BLOCK был на REQUEST_WITHDRAW):
|
||||
// деньги уходят из системы (банковский перевод пайщику), получателя на цепи нет.
|
||||
// Бухгалтерия: паевой фонд уменьшается (Дт 80), расчётный счёт уменьшается (Кт 51).
|
||||
{ operations::wallet::COMPLETE_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::BURN,
|
||||
ledger2_wallets::WITHDRAW_PENDING, eosio::name{},
|
||||
{ operations::wallet::COMPLETE_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::BURN_BLOCKED,
|
||||
ledger2_wallets::SHARE_FUND_PAY, eosio::name{},
|
||||
ledger2_accounts::SHARE_FUND, ledger2_accounts::BANK_ACCOUNT,
|
||||
"Возврат паевого взноса пайщику" },
|
||||
|
||||
@@ -290,19 +273,17 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
|
||||
ledger2_accounts::SHARE_FUND, ledger2_accounts::TARGET_RECEIPTS,
|
||||
"Трансляция паевого взноса из ЦПП «Цифровой Кошелёк» в членский взнос за пользование инфраструктурой" },
|
||||
|
||||
// 15. Запрос на возврат паевого: TRANSFER SHARE_FUND_PAY → WITHDRAW_PENDING
|
||||
// (без Dr/Cr — оба кошелька на счёте 80; резерв средств на время рассмотрения).
|
||||
{ operations::wallet::REQUEST_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::TRANSFER,
|
||||
ledger2_wallets::SHARE_FUND_PAY, ledger2_wallets::WITHDRAW_PENDING,
|
||||
// 15. Запрос на возврат паевого: BLOCK SHARE_FUND_PAY (без Dr/Cr — внутри одного бухсчёта 80).
|
||||
{ operations::wallet::REQUEST_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::BLOCK,
|
||||
ledger2_wallets::SHARE_FUND_PAY, eosio::name{},
|
||||
0, 0,
|
||||
"Резервирование паевого под запрос на возврат" },
|
||||
"Блокировка паевого под запрос на возврат" },
|
||||
|
||||
// 16. Отклонение запроса на возврат: TRANSFER WITHDRAW_PENDING → SHARE_FUND_PAY
|
||||
// (без Dr/Cr — зеркало REQUEST_WITHDRAW; возврат резерва пайщику).
|
||||
{ operations::wallet::DECLINE_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::TRANSFER,
|
||||
ledger2_wallets::WITHDRAW_PENDING, ledger2_wallets::SHARE_FUND_PAY,
|
||||
// 16. Отклонение запроса на возврат: UNBLOCK SHARE_FUND_PAY (без Dr/Cr — зеркало REQUEST_WITHDRAW).
|
||||
{ operations::wallet::DECLINE_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::UNBLOCK,
|
||||
ledger2_wallets::SHARE_FUND_PAY, eosio::name{},
|
||||
0, 0,
|
||||
"Снятие резерва паевого после отклонения запроса на возврат" },
|
||||
"Разблокировка паевого после отклонения запроса на возврат" },
|
||||
|
||||
// 17. Возврат из ЦПП «Благорост» в Цифровой Кошелёк: TRANSFER BLAGOROST_FUND → SHARE_FUND_PAY (без Dr/Cr — оба счёта 80, зеркало INVEST).
|
||||
{ operations::capital::WITHDRAW_FROM_CAPITAL, processes::capital::WTHCAP, WalletOp::TRANSFER,
|
||||
@@ -328,27 +309,6 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
|
||||
0, 0,
|
||||
"Конвертация сегмента: РИД → ЦПП «Благорост»" },
|
||||
|
||||
// 20. Биллинг — пополнение биллинг-кошелька: Dr 80 / Cr 86, TRANSFER SHARE_FUND_PAY → BILLING_FUND_PAY.
|
||||
// Зеркало o.sov.axncnv (трансляция паевого в членский), но назначение —
|
||||
// персональный USER_SHARED биллинг-кошелёк пайщика, а не кооперативный пул
|
||||
// делегатских взносов. После конвертации средства невозвратны (членские),
|
||||
// лежат на личном разрезе пайщика до списания за подписки (o.bil.pay). (Epic 12)
|
||||
{ operations::billing::CONVERT, processes::billing::CONVERT, WalletOp::TRANSFER,
|
||||
ledger2_wallets::SHARE_FUND_PAY, ledger2_wallets::BILLING_FUND_PAY,
|
||||
ledger2_accounts::SHARE_FUND, ledger2_accounts::TARGET_RECEIPTS,
|
||||
"Трансляция паевого взноса в членский на биллинг-кошелёк пайщика" },
|
||||
|
||||
// 21. Биллинг — оплата подписки: TRANSFER BILLING_FUND_PAY → INFRA_FEES, без Dr/Cr.
|
||||
// Оба кошелька — аналитические разрезы счёта 86 (целевое финансирование):
|
||||
// перенос «членский взнос пайщика, зарезервированный под подписки» →
|
||||
// «членские взносы за инфраструктуру платформы». Бухпроводка не требуется
|
||||
// (реклассификация внутри 86), по аналогии с o.cap.invest/o.wal.wthreq. (Epic 12)
|
||||
{ operations::billing::PAY, processes::billing::PAY, WalletOp::TRANSFER,
|
||||
ledger2_wallets::BILLING_FUND_PAY, ledger2_wallets::INFRA_FEES,
|
||||
0, 0,
|
||||
"Оплата подписки за инфраструктуру членскими взносами пайщика" },
|
||||
|
||||
|
||||
// ----- Миграционные (o.mig.*) — вызываются только из migrate.cpp -----
|
||||
|
||||
// 15. Миграция: минимальный паевой: Dr 51 / Cr 80, ISSUE MIN_SHARE_FUND
|
||||
@@ -380,7 +340,7 @@ static constexpr size_t OPERATION_REGISTRY_SIZE = sizeof(OPERATION_REGISTRY) / s
|
||||
// оба существуют в `LEDGER2_ACCOUNT_MAP`.
|
||||
// 4. Для TRANSFER: `wallet_from` ≠ `wallet_to`, оба ≠ 0.
|
||||
// 5. Для ISSUE: `wallet_from` == 0 и `wallet_to` ≠ 0.
|
||||
// 6. Для BURN: `wallet_from` ≠ 0, `wallet_to` == 0.
|
||||
// 6. Для BLOCK / UNBLOCK / BURN / BURN_BLOCKED: `wallet_from` ≠ 0, `wallet_to` == 0.
|
||||
// 7. Все id кошельков из записей существуют в `LEDGER2_WALLET_REGISTRY`.
|
||||
namespace ledger2_registry_detail {
|
||||
constexpr bool operation_codes_unique() {
|
||||
@@ -424,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;
|
||||
}
|
||||
@@ -480,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(),
|
||||
|
||||
@@ -65,14 +65,6 @@ namespace processes {
|
||||
inline constexpr eosio::name AXN_CONVERT = "p.sov.axncnv"_n; ///< Конвертация паевого RUB → делегатский ЧВ (одноактовый).
|
||||
}
|
||||
|
||||
// billing (Epic 12 — контракт billing)
|
||||
// ВНИМАНИЕ: имя константы НЕ `FUND` — `FUND` занят макросом `#define FUND "fund"`
|
||||
// в consts.hpp (имя контракта fund). Используем `CONVERT` (бэкенд-имя действия).
|
||||
namespace billing {
|
||||
inline constexpr eosio::name CONVERT = "p.bil.fund"_n; ///< Пополнение биллинг-кошелька пайщика (трансляция паевого в членский, одноактовый; anchor = hash заявления document2).
|
||||
inline constexpr eosio::name PAY = "p.bil.pay"_n; ///< Оплата подписки с биллинг-кошелька (одноактовый; anchor = payment_hash из БД провайдера).
|
||||
}
|
||||
|
||||
// migration
|
||||
namespace migration {
|
||||
inline constexpr eosio::name TRANSIT = "p.mig.trans"_n; ///< Транзитный перенос остатков legacy (серия apply на кооп).
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
* w.mkt.* — Маркетплейс (выплаты поставщикам)
|
||||
*
|
||||
* Sentinel `eosio::name{}` (пустое имя, value=0) — «кошелёк вне системы»
|
||||
* для ISSUE (нет wallet_from) и для BURN (нет wallet_to).
|
||||
* для ISSUE (нет wallet_from) и для BURN/BLOCK/UNBLOCK (нет wallet_to).
|
||||
*
|
||||
* При первом ISSUE/TRANSFER кошелёк создаётся автоматически по записи
|
||||
* из WALLET_REGISTRY. При обнулении available+blocked запись удаляется.
|
||||
@@ -46,9 +46,7 @@ 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 BILLING_FUND_PAY = "w.wal.bill"_n; ///< ЦК — биллинг (USER_SHARED). Источник для контракта `billing`: o.bil.fund зачисляет (паевой→членский), o.bil.pay списывает в w.sov.infra.
|
||||
static constexpr eosio::name WITHDRAWALS_SINK = "w.wal.wthdrw"_n; ///< DEPRECATED 2026-05-21: исторический sink возвратов. Оставлен в реестре для исторических L2-балансов (накопленные возвраты до перехода). Не использовать в новых операциях.
|
||||
static constexpr eosio::name WITHDRAW_PENDING = "w.wal.wpend"_n; ///< Резерв паевого под заявку на возврат (COOPERATIVE-пул). o.wal.wthreq переводит сюда с w.wal.share, o.wal.wthdec возвращает обратно, o.wal.wthcpl сжигает отсюда. Заменил механику blocked/BLOCK/UNBLOCK 2026-05-24.
|
||||
static constexpr eosio::name WITHDRAWALS_SINK = "w.wal.wthdrw"_n; ///< DEPRECATED 2026-05-21: после переключения o.wal.wthcpl с TRANSFER на BURN_BLOCKED кошелёк больше не получает новых средств. Оставлен в реестре для исторических L2-балансов (накопленные возвраты до перехода). Не использовать в новых операциях.
|
||||
|
||||
// registrator — минимальный паевой + вступительные
|
||||
static constexpr eosio::name MIN_SHARE_FUND = "w.reg.minshr"_n; ///< Минимальный паевой взнос пайщика (USER_SHARED, без сверки соглашений)
|
||||
@@ -96,16 +94,15 @@ struct Ledger2WalletMeta {
|
||||
WalletKind kind;
|
||||
};
|
||||
|
||||
inline constexpr std::array<Ledger2WalletMeta, 16> LEDGER2_WALLET_REGISTRY = {{
|
||||
// USER_SHARED (6) — L3-разрез по пайщику
|
||||
inline constexpr std::array<Ledger2WalletMeta, 14> LEDGER2_WALLET_REGISTRY = {{
|
||||
// USER_SHARED (5) — L3-разрез по пайщику
|
||||
{ ledger2_wallets::MIN_SHARE_FUND, "Минимальный паевой взнос", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::SHARE_FUND_PAY, "ЦК — паевой", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::CK_MEMBER, "ЦК — членский", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::BILLING_FUND_PAY, "ЦК — биллинг", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::SHARE_FUND_PAY, "Паевой взнос пайщика", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::CK_MEMBER, "ЦК — членская часть пайщика", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::BLAGOROST_FUND, "ЦПП «Благорост» — единый кошелёк программы у пайщика", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::PREIMP_FUND, "Первичный учёт РИД-взносов до перехода на электронный учёт", WalletKind::USER_SHARED },
|
||||
|
||||
// COOPERATIVE (10) — единый кооперативный баланс, без L3
|
||||
// COOPERATIVE (9) — единый кооперативный баланс, без L3
|
||||
// GENERATOR_FUND переведён сюда из USER_SHARED (см. wallets.hpp:64) —
|
||||
// CRPS-распределение между сегментами проекта не поддерживает per-user
|
||||
// компенсирующие TRANSFER на approvecmmt, поэтому L3-проверка walletop
|
||||
@@ -113,7 +110,6 @@ inline constexpr std::array<Ledger2WalletMeta, 16> LEDGER2_WALLET_REGISTRY = {{
|
||||
{ ledger2_wallets::GENERATOR_FUND, "ЦПП «Генератор» — единый кошелёк программы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::ENTRANCE_FEES, "Вступительные взносы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::WITHDRAWALS_SINK, "Возвраты паевых взносов пайщикам (deprecated, не используется в новых операциях)", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::WITHDRAW_PENDING, "Резерв паевого под заявку на возврат", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::INFRA_FEES, "Членские взносы за инфраструктуру кооп. платформы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::DELEGATE_FEES, "Делегатские членские взносы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::SOV_EXPENSES, "Хозяйственные расходы из числа целевого финансирования", WalletKind::COOPERATIVE },
|
||||
@@ -232,11 +228,10 @@ struct Ledger2WalletProgramMapping {
|
||||
uint64_t required_program_id; // 0 = исключение (без проверки)
|
||||
};
|
||||
|
||||
inline constexpr std::array<Ledger2WalletProgramMapping, 7> LEDGER2_USER_SHARED_PROGRAM_MAPPING = {{
|
||||
inline constexpr std::array<Ledger2WalletProgramMapping, 6> LEDGER2_USER_SHARED_PROGRAM_MAPPING = {{
|
||||
{ ledger2_wallets::MIN_SHARE_FUND, 0 /* w.reg.minshr — без проверки */ },
|
||||
{ ledger2_wallets::SHARE_FUND_PAY, 1 /* ЦК — паевой */ },
|
||||
{ ledger2_wallets::CK_MEMBER, 1 /* ЦК — членский */ },
|
||||
{ ledger2_wallets::BILLING_FUND_PAY, 1 /* ЦК — биллинг */ },
|
||||
{ ledger2_wallets::SHARE_FUND_PAY, 1 /* ЦК */ },
|
||||
{ ledger2_wallets::CK_MEMBER, 1 /* ЦК */ },
|
||||
{ ledger2_wallets::BLAGOROST_FUND, 4 /* Благорост */ },
|
||||
{ ledger2_wallets::GENERATOR_FUND, 3 /* Генератор */ },
|
||||
{ ledger2_wallets::PREIMP_FUND, 0 /* w.cap.preimp — РИД-учёт до перехода на электронный учёт, без проверки */ },
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
# программу «Цифровой Кошелёк» (SHARE_FUND_PAY).
|
||||
#
|
||||
# Четырёхактовый процесс с одной авторизацией советом. Запись заявки живёт в
|
||||
# таблице `withdraws` от создания до выплаты или отказа. На подаче заявки сумма
|
||||
# переводится в кошелёк-резерв (WITHDRAW_PENDING, w.wal.wpend). На закрывающем
|
||||
# действии completewthd срабатывает операция o.wal.wthcpl — сжигание суммы из
|
||||
# резерва (BURN w.wal.wpend): деньги уходят из системы пайщику банковским
|
||||
# переводом, получателя на цепи нет.
|
||||
# таблице `withdraws` от создания до выплаты или отказа. На закрывающем
|
||||
# действии completewthd срабатывает одна операция o.wal.wthcpl — сжигание
|
||||
# заблокированной суммы (BURN_BLOCKED w.wal.share): деньги уходят из системы
|
||||
# пайщику банковским переводом, получателя на цепи нет.
|
||||
#
|
||||
# Источники правды в коде:
|
||||
# • cpp/wallet/wallet.hpp — actions
|
||||
@@ -44,8 +43,8 @@ actions:
|
||||
actor: contributor
|
||||
role: opener
|
||||
purpose: >
|
||||
Пайщик создаёт заявку на возврат паевого взноса. Контракт переводит
|
||||
сумму с кошелька SHARE_FUND_PAY в кошелёк-резерв WITHDRAW_PENDING и
|
||||
Пайщик создаёт заявку на возврат паевого взноса. Контракт блокирует
|
||||
сумму на кошельке SHARE_FUND_PAY (статус available → blocked) и
|
||||
создаёт повестку в совете о возврате с callback'ом авторизации.
|
||||
- name: wallet::authwthd
|
||||
human: Авторизовать выплату
|
||||
@@ -61,17 +60,16 @@ actions:
|
||||
role: closer
|
||||
purpose: >
|
||||
Gateway подтвердил списание средств в пользу пайщика. Это закрывающее
|
||||
действие: применяется операция o.wal.wthcpl (BURN w.wal.wpend)
|
||||
— сумма из резерва сжигается, в бухгалтерии проходит обратная
|
||||
действие: применяется операция o.wal.wthcpl (BURN_BLOCKED w.wal.share)
|
||||
— заблокированная сумма сжигается, в бухгалтерии проходит обратная
|
||||
проводка Дт 80 / Кт 51. Запись заявки удаляется.
|
||||
- name: wallet::declinewthd
|
||||
human: Отклонить
|
||||
actor: soviet
|
||||
role: reject
|
||||
purpose: >
|
||||
Отказ на любом из этапов до выплаты. Сумма возвращается из резерва на
|
||||
кошелёк пайщика (o.wal.wthdec: WITHDRAW_PENDING → SHARE_FUND_PAY),
|
||||
запись заявки удаляется.
|
||||
Отказ на любом из этапов до выплаты. Сумма разблокируется на кошельке
|
||||
пайщика (UNBLOCK через o.wal.wthdec), запись заявки удаляется.
|
||||
|
||||
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
|
||||
entity: wallet::withdraw
|
||||
@@ -82,8 +80,8 @@ states:
|
||||
- name: pending
|
||||
human: Ожидает решения совета
|
||||
description: >
|
||||
Заявка создана, сумма переведена в кошелёк-резерв (SHARE_FUND_PAY →
|
||||
WITHDRAW_PENDING), повестка отправлена в совет.
|
||||
Заявка создана, сумма заблокирована на кошельке пайщика (available → blocked),
|
||||
повестка отправлена в совет.
|
||||
kind: normal
|
||||
- name: authorized
|
||||
human: Выплата отправлена
|
||||
@@ -94,15 +92,15 @@ states:
|
||||
- name: completed
|
||||
human: Возврат выплачен
|
||||
description: >
|
||||
Gateway подтвердил списание, сумма из резерва сжигается
|
||||
(BURN w.wal.wpend). В бухгалтерии прошла обратная
|
||||
Gateway подтвердил списание, заблокированная сумма сжигается на кошельке
|
||||
пайщика (BURN_BLOCKED w.wal.share). В бухгалтерии прошла обратная
|
||||
проводка Дт 80 / Кт 51. Запись заявки удалена.
|
||||
kind: final
|
||||
- name: removed
|
||||
human: Отклонено
|
||||
description: >
|
||||
Возврат не состоялся: сумма возвращена из резерва на кошелёк пайщика
|
||||
(o.wal.wthdec: WITHDRAW_PENDING → SHARE_FUND_PAY), запись удалена.
|
||||
Возврат не состоялся: сумма разблокирована (UNBLOCK через o.wal.wthdec),
|
||||
запись удалена.
|
||||
kind: virtual
|
||||
virtual: true
|
||||
|
||||
@@ -174,7 +172,7 @@ scenario:
|
||||
- Сумма возврата ≤ доступного остатка.
|
||||
post:
|
||||
- В таблице withdraws создана запись со статусом `pending`.
|
||||
- Сумма переведена с SHARE_FUND_PAY в кошелёк-резерв WITHDRAW_PENDING.
|
||||
- На кошельке пайщика сумма переведена available → blocked.
|
||||
- Совет получил повестку о возврате.
|
||||
|
||||
- step: 2
|
||||
@@ -199,13 +197,13 @@ scenario:
|
||||
action: wallet::completewthd
|
||||
description: >
|
||||
Gateway подтверждает списание средств в пользу пайщика. Контракт
|
||||
применяет ledger2-операцию o.wal.wthcpl (BURN w.wal.wpend с
|
||||
применяет ledger2-операцию o.wal.wthcpl (BURN_BLOCKED w.wal.share с
|
||||
обратной проводкой Дт 80 / Кт 51) и удаляет запись заявки.
|
||||
pre:
|
||||
- Заявка в статусе `authorized`.
|
||||
- Gateway подтвердил списание.
|
||||
post:
|
||||
- Сумма из резерва сожжена (WITHDRAW_PENDING, w.wal.wpend).
|
||||
- Заблокированная сумма сожжена с SHARE_FUND_PAY (w.wal.share).
|
||||
- В ledger2 применена операция o.wal.wthcpl.
|
||||
- Запись заявки удалена.
|
||||
|
||||
@@ -216,8 +214,8 @@ scenario:
|
||||
actor: soviet
|
||||
description: >
|
||||
Совет принял отрицательное решение либо Gateway отклонил платёж.
|
||||
Сумма возвращена из резерва на кошелёк пайщика (o.wal.wthdec:
|
||||
WITHDRAW_PENDING → SHARE_FUND_PAY), запись удалена.
|
||||
Сумма разблокирована на кошельке пайщика (UNBLOCK через o.wal.wthdec),
|
||||
запись удалена.
|
||||
|
||||
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
|
||||
documents:
|
||||
@@ -236,45 +234,45 @@ documents:
|
||||
# ── Секция 6. Операции ──────────────────────────────────────────────────────
|
||||
operations:
|
||||
- ledger_code: o.wal.wthreq
|
||||
human_name: Резервирование паевого под запрос на возврат
|
||||
wallet_op: TRANSFER
|
||||
human_name: Блокировка паевого под запрос на возврат
|
||||
wallet_op: BLOCK
|
||||
wallet_from: w.wal.share
|
||||
wallet_to: w.wal.wpend
|
||||
wallet_to: ~
|
||||
debit: ~
|
||||
credit: ~
|
||||
amount_ref: withdraw.quantity
|
||||
triggered_by: wallet::createwthd
|
||||
description: >
|
||||
На создании заявки контракт переводит сумму с кошелька пайщика
|
||||
(SHARE_FUND_PAY) в кошелёк-резерв (WITHDRAW_PENDING). Бухгалтерская
|
||||
проводка не создаётся — движение внутри одного бухсчёта 80.
|
||||
На создании заявки контракт блокирует сумму на кошельке пайщика
|
||||
(SHARE_FUND_PAY): available → blocked. Бухгалтерская проводка не
|
||||
создаётся — движение внутри одного бухсчёта 80.
|
||||
|
||||
- ledger_code: o.wal.wthdec
|
||||
human_name: Снятие резерва паевого после отклонения запроса на возврат
|
||||
wallet_op: TRANSFER
|
||||
wallet_from: w.wal.wpend
|
||||
wallet_to: w.wal.share
|
||||
human_name: Разблокировка паевого после отклонения запроса на возврат
|
||||
wallet_op: UNBLOCK
|
||||
wallet_from: w.wal.share
|
||||
wallet_to: ~
|
||||
debit: ~
|
||||
credit: ~
|
||||
amount_ref: withdraw.quantity
|
||||
triggered_by: wallet::declinewthd
|
||||
description: >
|
||||
Зеркало REQUEST_WITHDRAW: сумма возвращается из резерва (WITHDRAW_PENDING)
|
||||
на кошелёк пайщика (SHARE_FUND_PAY). Бухгалтерская проводка не создаётся.
|
||||
Зеркало REQUEST_WITHDRAW: blocked → available на кошельке пайщика.
|
||||
Бухгалтерская проводка не создаётся.
|
||||
|
||||
- ledger_code: o.wal.wthcpl
|
||||
human_name: Возврат паевого взноса пайщику
|
||||
wallet_op: BURN
|
||||
wallet_from: w.wal.wpend # кошелёк-резерв возвратов
|
||||
wallet_op: BURN_BLOCKED
|
||||
wallet_from: w.wal.share # ЦПП «Цифровой Кошелёк» — паевые взносы деньгами
|
||||
wallet_to: ~ # деньги уходят из системы, получателя на цепи нет
|
||||
debit: 80 # Паевой фонд (складочный капитал)
|
||||
credit: 51 # Расчётный счёт
|
||||
amount_ref: withdraw.quantity
|
||||
triggered_by: wallet::completewthd
|
||||
description: >
|
||||
Обратная операция к o.wal.depcpl: зарезервированная сумма сжигается с
|
||||
кошелька-резерва (w.wal.wpend) — деньги уходят из системы банковским
|
||||
переводом пайщику. На цепи получателя нет.
|
||||
Обратная операция к o.wal.depcpl: заблокированная сумма сжигается с
|
||||
кошелька пайщика «ЦПП Цифровой Кошелёк» (w.wal.share) — деньги уходят
|
||||
из системы банковским переводом пайщику. На цепи получателя нет.
|
||||
В бухгалтерии прошла проводка Дт 80 / Кт 51 — паевой фонд уменьшился,
|
||||
деньги ушли с расчётного счёта пайщику.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/contracts",
|
||||
"version": "2026.5.27-2",
|
||||
"version": "2026.5.23-4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"watch": ["./src"],
|
||||
"ext": "ts",
|
||||
"delay": 2000,
|
||||
"signal": "SIGTERM"
|
||||
"ext": "ts"
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "create-nodejs-express-app",
|
||||
"version": "2026.5.27-2",
|
||||
"version": "2026.5.23-4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "create-nodejs-express-app",
|
||||
"version": "2026.5.27-2",
|
||||
"version": "2026.5.23-4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@a2seven/yoo-checkout": "^1.1.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/controller",
|
||||
"version": "2026.5.27-2",
|
||||
"version": "2026.5.23-4",
|
||||
"description": "Бэкенд GraphQL API кооператива на NestJS",
|
||||
"private": true,
|
||||
"bin": "bin/createNodejsApp.js",
|
||||
@@ -76,6 +76,7 @@
|
||||
"@coopenomics/factory": "workspace:*",
|
||||
"@coopenomics/inter": "workspace:*",
|
||||
"@coopenomics/notifications": "workspace:*",
|
||||
"@coopenomics/parser2": "^1.2.0",
|
||||
"@coopenomics/provider-client": "2025.11.12-alpha-1",
|
||||
"@coopenomics/sdk": "workspace:*",
|
||||
"@graphql-codegen/typescript-graphql-request": "^6.2.0",
|
||||
@@ -193,12 +194,12 @@
|
||||
"@antfu/eslint-config": "^2.24.1",
|
||||
"@graphql-codegen/cli": "^5.0.3",
|
||||
"@graphql-codegen/typescript": "^4.1.1",
|
||||
"@swc-node/register": "^1.11.1",
|
||||
"@swc/core": "^1.15.33",
|
||||
"@graphql-codegen/typescript-apollo-client-helpers": "^3.0.0",
|
||||
"@graphql-codegen/typescript-operations": "^4.3.1",
|
||||
"@nestjs/testing": "^11.1.19",
|
||||
"@redocly/cli": "^1.18.0",
|
||||
"@swc-node/register": "^1.11.1",
|
||||
"@swc/core": "^1.15.33",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.26",
|
||||
"@typescript-eslint/eslint-plugin": "^5.10.0",
|
||||
|
||||
@@ -1359,162 +1359,6 @@ type BaseCapitalProject {
|
||||
voting: CapitalProjectVotingData!
|
||||
}
|
||||
|
||||
input BillingConversionStatementGenerateDocumentInput {
|
||||
"""Номер блока, на котором был создан документ"""
|
||||
block_num: Int
|
||||
|
||||
"""Сумма к конвертации паевого в членский на биллинг-кошелёк"""
|
||||
convert_amount: String!
|
||||
|
||||
"""Название кооператива, связанное с документом"""
|
||||
coopname: String!
|
||||
|
||||
"""Дата и время создания документа"""
|
||||
created_at: String
|
||||
|
||||
"""Имя генератора, использованного для создания документа"""
|
||||
generator: String
|
||||
|
||||
"""Язык документа"""
|
||||
lang: String
|
||||
|
||||
"""Ссылки, связанные с документом"""
|
||||
links: [String!]
|
||||
|
||||
"""Часовой пояс, в котором был создан документ"""
|
||||
timezone: String
|
||||
|
||||
"""Название документа"""
|
||||
title: String
|
||||
|
||||
"""Имя пользователя, создавшего документ"""
|
||||
username: String!
|
||||
|
||||
"""Версия генератора, использованного для создания документа"""
|
||||
version: String
|
||||
}
|
||||
|
||||
input BillingConversionStatementSignedDocumentInput {
|
||||
"""Хэш содержимого документа"""
|
||||
doc_hash: String!
|
||||
|
||||
"""Общий хэш (doc_hash + meta_hash)"""
|
||||
hash: String!
|
||||
|
||||
"""Метаинформация заявления на конвертацию паевого в биллинг-кошелёк"""
|
||||
meta: BillingConversionStatementSignedMetaDocumentInput!
|
||||
|
||||
"""Хэш мета-данных"""
|
||||
meta_hash: String!
|
||||
|
||||
"""Вектор подписей"""
|
||||
signatures: [SignatureInfoInput!]!
|
||||
|
||||
"""Версия стандарта документа"""
|
||||
version: String!
|
||||
}
|
||||
|
||||
input BillingConversionStatementSignedMetaDocumentInput {
|
||||
"""Номер блока, на котором был создан документ"""
|
||||
block_num: Int!
|
||||
|
||||
"""Сумма к конвертации паевого в членский на биллинг-кошелёк"""
|
||||
convert_amount: String!
|
||||
|
||||
"""Название кооператива, связанное с документом"""
|
||||
coopname: String!
|
||||
|
||||
"""Дата и время создания документа"""
|
||||
created_at: String!
|
||||
|
||||
"""Имя генератора, использованного для создания документа"""
|
||||
generator: String!
|
||||
|
||||
"""Язык документа"""
|
||||
lang: String!
|
||||
|
||||
"""Ссылки, связанные с документом"""
|
||||
links: [String!]!
|
||||
|
||||
"""ID документа в реестре"""
|
||||
registry_id: Int!
|
||||
|
||||
"""Часовой пояс, в котором был создан документ"""
|
||||
timezone: String!
|
||||
|
||||
"""Название документа"""
|
||||
title: String!
|
||||
|
||||
"""Имя пользователя, создавшего документ"""
|
||||
username: String!
|
||||
|
||||
"""Версия генератора, использованного для создания документа"""
|
||||
version: String!
|
||||
}
|
||||
|
||||
input BillingConvertInput {
|
||||
"""
|
||||
Сумма с символом, например "1500.0000 RUB"
|
||||
"""
|
||||
amount: String!
|
||||
|
||||
"""Имя аккаунта кооператива"""
|
||||
coopname: String!
|
||||
|
||||
"""
|
||||
Подписанное пайщиком заявление 1095.BillingConversionStatement (document2 с типизированной meta: convert_amount).
|
||||
"""
|
||||
document: BillingConversionStatementSignedDocumentInput!
|
||||
|
||||
"""Имя аккаунта пайщика — владельца биллинг-кошелька"""
|
||||
username: String!
|
||||
}
|
||||
|
||||
input BillingPayInput {
|
||||
"""
|
||||
Сумма с символом, например "1500.0000 RUB"
|
||||
"""
|
||||
amount: String!
|
||||
|
||||
"""Имя аккаунта кооператива"""
|
||||
coopname: String!
|
||||
|
||||
"""Назначение платежа"""
|
||||
memo: String!
|
||||
|
||||
"""
|
||||
Детерминированный идентификатор платежа (payment_hash из getBillingSummary провайдера)
|
||||
"""
|
||||
paymentHash: String!
|
||||
|
||||
"""Имя аккаунта пайщика — владельца биллинг-кошелька"""
|
||||
username: String!
|
||||
}
|
||||
|
||||
type BillingResult {
|
||||
paymentHash: String
|
||||
transactionId: String!
|
||||
}
|
||||
|
||||
type BillingSummary {
|
||||
coopname: String!
|
||||
currency: String!
|
||||
items: [BillingSummaryItem!]!
|
||||
nextPaymentDue: String
|
||||
paymentHash: String!
|
||||
periodDays: Int!
|
||||
totalAmount: Float!
|
||||
}
|
||||
|
||||
type BillingSummaryItem {
|
||||
amount: Float!
|
||||
isFree: Boolean!
|
||||
status: String!
|
||||
subscriptionId: Int!
|
||||
subscriptionTypeId: Int!
|
||||
subscriptionTypeName: String!
|
||||
}
|
||||
|
||||
type BlockchainAccount {
|
||||
"""Имя аккаунта"""
|
||||
account_name: String!
|
||||
@@ -1835,6 +1679,7 @@ type CallTranscription {
|
||||
createdAt: DateTime!
|
||||
endedAt: DateTime
|
||||
id: String!
|
||||
matrixRoomId: String!
|
||||
|
||||
"""Пользовательская заметка о содержании звонка"""
|
||||
memo: String!
|
||||
@@ -1843,8 +1688,6 @@ type CallTranscription {
|
||||
Отображаемые имена участников (Synapse displayname); в БД хранятся канонические Matrix user id
|
||||
"""
|
||||
participants: [String!]!
|
||||
|
||||
"""Внутреннее имя комнаты звонка (LiveKit room name), не Matrix room id"""
|
||||
roomId: String!
|
||||
roomName: String!
|
||||
startedAt: DateTime!
|
||||
@@ -3947,17 +3790,6 @@ type ChatCoopCalendarRoomOption {
|
||||
matrixRoomId: String!
|
||||
}
|
||||
|
||||
type ChatcoopNonProjectCommunicationRoom {
|
||||
"""Подпись для отображения комнаты"""
|
||||
displayLabel: String!
|
||||
|
||||
"""Тип комнаты (пайщики / совет / секретарь)"""
|
||||
kind: NonProjectRoomKind!
|
||||
|
||||
"""Идентификатор комнаты Matrix"""
|
||||
matrixRoomId: String!
|
||||
}
|
||||
|
||||
type ChatcoopProjectCommunicationRoom {
|
||||
"""Подпись для отображения (комната / проект Capital)"""
|
||||
displayLabel: String!
|
||||
@@ -3981,28 +3813,6 @@ type ChatcoopRoomMessageLine {
|
||||
originServerTs: Float!
|
||||
}
|
||||
|
||||
type ChatcoopSecretaryRoom {
|
||||
"""Название комнаты"""
|
||||
displayLabel: String!
|
||||
|
||||
"""Можно ли удалить комнату из интерфейса (только комнаты секретаря)"""
|
||||
editable: Boolean!
|
||||
|
||||
"""Комната зашифрована (E2EE) — секретарь не транскрибирует такие комнаты"""
|
||||
encrypted: Boolean!
|
||||
|
||||
"""
|
||||
Внутренний идентификатор комнаты в реестре (для операций; это НЕ Matrix room id)
|
||||
"""
|
||||
id: String!
|
||||
|
||||
"""Тип комнаты"""
|
||||
kind: ManagedRoomKind!
|
||||
|
||||
"""Секретарь присутствует в комнате"""
|
||||
secretaryInRoom: Boolean!
|
||||
}
|
||||
|
||||
input CheckMatrixUsernameInput {
|
||||
username: String!
|
||||
}
|
||||
@@ -4537,29 +4347,6 @@ type CooperativeProgram {
|
||||
program_type: String!
|
||||
}
|
||||
|
||||
type CooperativeRegistryItem {
|
||||
"""Анонсированный домен/сайт кооператива"""
|
||||
announce: String
|
||||
|
||||
"""Имя аккаунта кооператива (coopname)"""
|
||||
coopname: String!
|
||||
|
||||
"""Дата регистрации заявки кооператива (on-chain)"""
|
||||
created_at: String
|
||||
|
||||
"""Есть ли у кооператива данные провайдера (хотя бы одна подписка)"""
|
||||
has_provider_data: Boolean!
|
||||
|
||||
"""Наименование организации кооператива"""
|
||||
name: String
|
||||
|
||||
"""Статус кооператива в блокчейне: pending | active | blocked"""
|
||||
status: String!
|
||||
|
||||
"""Подписки кооператива у провайдера (с инстансом и биллингом)"""
|
||||
subscriptions: [ProviderSubscription!]!
|
||||
}
|
||||
|
||||
"""Страна регистрации пользователя"""
|
||||
enum Country {
|
||||
Russia
|
||||
@@ -5093,16 +4880,6 @@ input CreateProjectPropertyInput {
|
||||
username: String!
|
||||
}
|
||||
|
||||
input CreateSecretaryRoomInput {
|
||||
"""Название комнаты"""
|
||||
displayName: String!
|
||||
|
||||
"""
|
||||
Публичная комната (любой может войти) либо приватная (вход по приглашению создателя)
|
||||
"""
|
||||
isPublic: Boolean!
|
||||
}
|
||||
|
||||
input CreateSovietIndividualDataInput {
|
||||
"""Дата рождения"""
|
||||
birthdate: String!
|
||||
@@ -7129,6 +6906,9 @@ type Ledger2Wallet {
|
||||
"""Доступный баланс"""
|
||||
available: String!
|
||||
|
||||
"""Заблокированный баланс"""
|
||||
blocked: String!
|
||||
|
||||
"""eosio::name-идентификатор кошелька (w.<contract>.<waltype>)"""
|
||||
id: String!
|
||||
|
||||
@@ -7298,14 +7078,6 @@ input MakeClearanceInput {
|
||||
username: String!
|
||||
}
|
||||
|
||||
"""Тип комнаты: пайщики, совет, проект Capital, комната секретаря"""
|
||||
enum ManagedRoomKind {
|
||||
CAPITAL_PROJECT
|
||||
COUNCIL
|
||||
MEMBERS
|
||||
SECRETARY
|
||||
}
|
||||
|
||||
input MarkReportPeriodInput {
|
||||
mark: ReportSubmissionMark
|
||||
period: Int
|
||||
@@ -7631,16 +7403,6 @@ type Mutation {
|
||||
"""Добавить доверенное лицо кооперативного участка"""
|
||||
addTrustedAccount(data: AddTrustedAccountInput!): Branch!
|
||||
|
||||
"""
|
||||
Конвертация паевого взноса пайщика в членский на биллинг-кошелёк (operation o.bil.fund). Принимает подписанное пайщиком заявление 1095.BillingConversionStatement.
|
||||
"""
|
||||
billingConvert(input: BillingConvertInput!): BillingResult!
|
||||
|
||||
"""
|
||||
Списание стоимости подписок с биллинг-кошелька пайщика в инфраструктурный кошелёк кооператива (operation o.bil.pay). Идемпотентно по payment_hash.
|
||||
"""
|
||||
billingPay(input: BillingPayInput!): BillingResult!
|
||||
|
||||
"""Отменить заявку"""
|
||||
cancelRequest(data: CancelRequestInput!): Transaction!
|
||||
|
||||
@@ -7893,19 +7655,9 @@ type Mutation {
|
||||
"""Выдать или обновить персональный URL подписки ICS (секрет в query)"""
|
||||
chatcoopCreateCalendarIcsSubscription: ChatCoopCalendarIcsUrlResponse!
|
||||
|
||||
"""
|
||||
Создать комнату с секретарём (публичную или приватную); секретарь подключается сразу
|
||||
"""
|
||||
chatcoopCreateSecretaryRoom(data: CreateSecretaryRoomInput!): ChatcoopSecretaryRoom!
|
||||
|
||||
"""Удалить событие календаря"""
|
||||
chatcoopDeleteCalendarEvent(id: String!): Boolean!
|
||||
|
||||
"""
|
||||
Удалить комнату секретаря: вывести секретаря и снять комнату с синхронизации (возвращает идентификатор комнаты в реестре)
|
||||
"""
|
||||
chatcoopRemoveSecretaryRoom(data: RemoveSecretaryRoomInput!): String!
|
||||
|
||||
"""Обновить событие календаря"""
|
||||
chatcoopUpdateCalendarEvent(data: UpdateChatCoopCalendarEventInput!): ChatCoopCalendarEvent!
|
||||
|
||||
@@ -8032,11 +7784,6 @@ type Mutation {
|
||||
"""Сгенерировать бюллетень для голосования на общем собрании пайщиков"""
|
||||
generateBallotForAnnualGeneralMeetDocument(data: AnnualGeneralMeetingVotingBallotGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
|
||||
|
||||
"""
|
||||
Генерирует заявление 1095.BillingConversionStatement (перед подписью пайщиком) — аналог generateConvertToAxonStatement, канон documents-dto.
|
||||
"""
|
||||
generateBillingConversionStatement(data: BillingConversionStatementGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
|
||||
|
||||
"""Генерирует заявление на конвертацию паевого взноса в членский взнос"""
|
||||
generateConvertToAxonStatement(data: ConvertToAxonStatementGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
|
||||
|
||||
@@ -8262,13 +8009,6 @@ type Mutation {
|
||||
walmoveWallets(input: WalmoveInput!): Ledger2AdjustmentResult!
|
||||
}
|
||||
|
||||
"""Тип комнаты вне проекта: пайщики, совет, комната секретаря"""
|
||||
enum NonProjectRoomKind {
|
||||
COUNCIL
|
||||
MEMBERS
|
||||
SECRETARY
|
||||
}
|
||||
|
||||
input NotificationWorkflowRecipientInput {
|
||||
"""Username получателя"""
|
||||
username: String!
|
||||
@@ -9458,6 +9198,9 @@ type ProgramWallet {
|
||||
"""Номер блока последнего обновления"""
|
||||
blockNum: Float
|
||||
|
||||
"""Заблокированный баланс (формат: "100.0000 RUB")"""
|
||||
blocked: String!
|
||||
|
||||
"""Имя кооператива"""
|
||||
coopname: String!
|
||||
|
||||
@@ -9916,19 +9659,9 @@ type Query {
|
||||
"""
|
||||
chatcoopListCalendarRooms: [ChatCoopCalendarRoomOption!]!
|
||||
|
||||
"""
|
||||
Комнаты Matrix вне проектов Capital (пайщики/совет/секретарь) — для синхронизации в blago
|
||||
"""
|
||||
chatcoopListNonProjectCommunicationRooms: [ChatcoopNonProjectCommunicationRoom!]!
|
||||
|
||||
"""Комнаты Matrix, привязанные к проекту Capital (реестр ChatCoop)"""
|
||||
chatcoopListProjectCommunicationRooms(data: GetProjectCommunicationRoomsInput!): [ChatcoopProjectCommunicationRoom!]!
|
||||
|
||||
"""
|
||||
Все комнаты реестра ChatCoop (системные/проектные — read-only, комнаты секретаря — удаляемые)
|
||||
"""
|
||||
chatcoopListSecretaryRooms: [ChatcoopSecretaryRoom!]!
|
||||
|
||||
"""
|
||||
UTC-даты (YYYY-MM-DD), в которых есть сообщения новее afterOriginServerTsExclusive, для комнаты Matrix
|
||||
"""
|
||||
@@ -9964,11 +9697,6 @@ type Query {
|
||||
"""Получить список доступных типов отчётов"""
|
||||
getAvailableReports: [AvailableReport!]!
|
||||
|
||||
"""
|
||||
Сумма к оплате кооператива за период (стоимость платных подписок, разбивка, дата следующего платежа, payment_hash). Источник — provider backend оператора. Для реестра кооперативов Восхода.
|
||||
"""
|
||||
getBillingSummary(coopname: String!, period: Float): BillingSummary!
|
||||
|
||||
"""Получить список кооперативных участков"""
|
||||
getBranches(data: GetBranchesInput!): [Branch!]!
|
||||
|
||||
@@ -9984,11 +9712,6 @@ type Query {
|
||||
"""Получить состояние онбординга председателя"""
|
||||
getChairmanOnboardingState: ChairmanOnboardingState!
|
||||
|
||||
"""
|
||||
Реестр кооперативов оператора: список кооперативов из блокчейна с данными провайдера (подписки, инстанс, биллинг)
|
||||
"""
|
||||
getCooperativesRegistry: [CooperativeRegistryItem!]!
|
||||
|
||||
"""Получить текущий инстанс пользователя"""
|
||||
getCurrentInstance: CurrentInstanceDTO
|
||||
|
||||
@@ -10415,11 +10138,6 @@ type RegistrationProgram {
|
||||
title: String!
|
||||
}
|
||||
|
||||
input RemoveSecretaryRoomInput {
|
||||
"""Идентификатор комнаты в реестре, которую нужно удалить"""
|
||||
id: String!
|
||||
}
|
||||
|
||||
type ReportCalendarPeriodEntry {
|
||||
dueDate: String!
|
||||
dueMonth: Int!
|
||||
|
||||
@@ -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';
|
||||
@@ -69,7 +71,6 @@ import { WalletModule } from './application/wallet/wallet.module';
|
||||
import { NotificationModule } from './application/notification/notification.module';
|
||||
import { LedgerModule } from './application/ledger/ledger.module';
|
||||
import { Ledger2Module } from './application/ledger2/ledger2.module';
|
||||
import { BillingModule } from './application/billing/billing.module';
|
||||
import { ProcessRegistryModule } from './application/process-registry/process-registry.module';
|
||||
import { BlockchainExplorerModule } from './application/blockchain-explorer/blockchain-explorer.module';
|
||||
import { ProviderModule } from './application/provider/provider.module';
|
||||
@@ -86,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,
|
||||
@@ -96,6 +98,7 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
MongooseModule.forRoot(config.mongoose.url),
|
||||
DatabaseModule,
|
||||
GraphqlModule,
|
||||
ForkRegistryModule,
|
||||
BlockchainModule,
|
||||
GeneratorInfrastructureModule,
|
||||
RedisModule,
|
||||
@@ -162,10 +165,6 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
NotificationModule,
|
||||
LedgerModule,
|
||||
Ledger2Module,
|
||||
// Single-Hub v5: BillingModule подключается ТОЛЬКО на Восходе-хабе
|
||||
// (BILLING_HUB_MODE=true). На спицах модуль не регистрируется —
|
||||
// ни GraphQL Billing.*, ни крон-сервис, ни provider-client.
|
||||
...(process.env.BILLING_HUB_MODE === 'true' ? [BillingModule] : []),
|
||||
ProcessRegistryModule,
|
||||
BlockchainExplorerModule,
|
||||
ProviderModule,
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BillingService } from './services/billing.service';
|
||||
import { BillingResolver } from './resolvers/billing.resolver';
|
||||
import { BillingProviderClient } from '~/infrastructure/billing/billing-provider.client';
|
||||
import { BillingCronService } from '~/domain/billing/services/billing-cron.service';
|
||||
import { ProviderModule } from '~/application/provider/provider.module';
|
||||
import { DocumentDomainModule } from '~/domain/document/document.module';
|
||||
|
||||
/**
|
||||
* Модуль billing (Epic 12 / Single-Hub v5):
|
||||
* - GraphQL-мутации оплаты подписок (convert/pay) — BillingResolver/BillingService;
|
||||
* - периодическое списание — BillingCronService + HTTP-клиент провайдера;
|
||||
* - список коопов для тика берётся из on-chain `registrator.coops` через
|
||||
* ProviderService (никаких env-CSV).
|
||||
*
|
||||
* Blockchain-порт (`BILLING_BLOCKCHAIN_PORT`) предоставляется глобальным
|
||||
* BlockchainModule. Подключается в корневой AppModule только при
|
||||
* BILLING_HUB_MODE=true.
|
||||
*/
|
||||
@Module({
|
||||
imports: [ProviderModule, DocumentDomainModule],
|
||||
providers: [BillingService, BillingResolver, BillingProviderClient, BillingCronService],
|
||||
exports: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
import { IsString, Matches, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { BillingConversionStatementSignedDocumentInputDTO } from '~/application/document/documents-dto/billing-conversion-statement-document.dto';
|
||||
|
||||
/**
|
||||
* Input мутации `billingConvert` (действие `billing::convert`, operation `o.bil.fund`).
|
||||
*
|
||||
* Конвертирует паевой взнос пайщика в членский на персональный биллинг-кошелёк
|
||||
* (`w.wal.bill`). `amount` — строка с символом, как в apply: `"1500.0000 RUB"`.
|
||||
* `document` — подписанное пайщиком заявление (document2): согласие на конвертацию.
|
||||
*/
|
||||
@InputType('BillingConvertInput')
|
||||
export class BillingConvertInputDTO {
|
||||
@Field(() => String, { description: 'Имя аккаунта кооператива' })
|
||||
@IsString()
|
||||
coopname!: string;
|
||||
|
||||
@Field(() => String, { description: 'Имя аккаунта пайщика — владельца биллинг-кошелька' })
|
||||
@IsString()
|
||||
username!: string;
|
||||
|
||||
@Field(() => String, { description: 'Сумма с символом, например "1500.0000 RUB"' })
|
||||
@IsString()
|
||||
@Matches(/^\d+(\.\d+)?\s+[A-Z]{1,7}$/, { message: 'Формат "<amount> <SYMBOL>", например "1500.0000 RUB"' })
|
||||
amount!: string;
|
||||
|
||||
@Field(() => BillingConversionStatementSignedDocumentInputDTO, {
|
||||
description:
|
||||
'Подписанное пайщиком заявление 1095.BillingConversionStatement ' +
|
||||
'(document2 с типизированной meta: convert_amount).',
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => BillingConversionStatementSignedDocumentInputDTO)
|
||||
document!: BillingConversionStatementSignedDocumentInputDTO;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
import { IsString, Matches, MaxLength, Length } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Input мутации `billingPay` (действие `billing::pay`, operation `o.bil.pay`).
|
||||
*
|
||||
* Списывает с биллинг-кошелька пайщика суммарную стоимость подписок в
|
||||
* инфраструктурный кошелёк кооператива. Идемпотентно по `paymentHash`.
|
||||
* Состав и цены подписок on-chain не передаются — только сумма + payment_hash.
|
||||
*/
|
||||
@InputType('BillingPayInput')
|
||||
export class BillingPayInputDTO {
|
||||
@Field(() => String, { description: 'Имя аккаунта кооператива' })
|
||||
@IsString()
|
||||
coopname!: string;
|
||||
|
||||
@Field(() => String, { description: 'Имя аккаунта пайщика — владельца биллинг-кошелька' })
|
||||
@IsString()
|
||||
username!: string;
|
||||
|
||||
@Field(() => String, { description: 'Сумма с символом, например "1500.0000 RUB"' })
|
||||
@IsString()
|
||||
@Matches(/^\d+(\.\d+)?\s+[A-Z]{1,7}$/, { message: 'Формат "<amount> <SYMBOL>", например "1500.0000 RUB"' })
|
||||
amount!: string;
|
||||
|
||||
@Field(() => String, {
|
||||
description: 'Детерминированный идентификатор платежа (payment_hash из getBillingSummary провайдера)',
|
||||
})
|
||||
@IsString()
|
||||
@Length(1, 64)
|
||||
paymentHash!: string;
|
||||
|
||||
@Field(() => String, { description: 'Назначение платежа' })
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
memo!: string;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
/**
|
||||
* Результат мутаций `billingConvert` / `billingPay`.
|
||||
*
|
||||
* `transactionId` — id транзакции в чейне для трассировки.
|
||||
* `paymentHash` — для `billingPay` дублирует идентификатор платежа (для UI/сверки);
|
||||
* для `billingConvert` пуст.
|
||||
*/
|
||||
@ObjectType('BillingResult')
|
||||
export class BillingResultDTO {
|
||||
@Field(() => String)
|
||||
transactionId!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
paymentHash?: string;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Field, ObjectType, Int, Float } from '@nestjs/graphql';
|
||||
|
||||
/**
|
||||
* Позиция разбивки «суммы к оплате» по подписке (из provider getBillingSummary).
|
||||
*/
|
||||
@ObjectType('BillingSummaryItem')
|
||||
export class BillingSummaryItemDTO {
|
||||
@Field(() => Int)
|
||||
subscriptionId!: number;
|
||||
|
||||
@Field(() => Int)
|
||||
subscriptionTypeId!: number;
|
||||
|
||||
@Field(() => String)
|
||||
subscriptionTypeName!: string;
|
||||
|
||||
@Field(() => String)
|
||||
status!: string;
|
||||
|
||||
@Field(() => Float)
|
||||
amount!: number;
|
||||
|
||||
@Field(() => Boolean)
|
||||
isFree!: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сумма к оплате кооператива за период (проекция provider getBillingSummary в
|
||||
* GraphQL для реестра кооперативов Восхода, Epic 12 / Story 12.5).
|
||||
*/
|
||||
@ObjectType('BillingSummary')
|
||||
export class BillingSummaryDTO {
|
||||
@Field(() => String)
|
||||
coopname!: string;
|
||||
|
||||
@Field(() => Int)
|
||||
periodDays!: number;
|
||||
|
||||
@Field(() => Float)
|
||||
totalAmount!: number;
|
||||
|
||||
@Field(() => String)
|
||||
currency!: string;
|
||||
|
||||
@Field(() => [BillingSummaryItemDTO])
|
||||
items!: BillingSummaryItemDTO[];
|
||||
|
||||
@Field(() => String)
|
||||
paymentHash!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
nextPaymentDue?: string | null;
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
|
||||
import { RolesGuard } from '~/application/auth/guards/roles.guard';
|
||||
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
|
||||
import { BillingService } from '../services/billing.service';
|
||||
import { BillingConvertInputDTO } from '../dto/billing-convert-input.dto';
|
||||
import { BillingPayInputDTO } from '../dto/billing-pay-input.dto';
|
||||
import { BillingResultDTO } from '../dto/billing-result.dto';
|
||||
import { BillingSummaryDTO } from '../dto/billing-summary.dto';
|
||||
import { BillingConversionStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/billing-conversion-statement-document.dto';
|
||||
import { GenerateDocumentOptionsInputDTO } from '~/application/document/dto/generate-document-options-input.dto';
|
||||
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
|
||||
|
||||
/**
|
||||
* GraphQL фасад billing (Epic 12 — оплата инфраструктурных подписок).
|
||||
* - `billingConvert` — пайщик конвертирует паевой взнос в членский на личный
|
||||
* биллинг-кошелёк (`w.wal.bill`), приложив подписанное заявление (document2);
|
||||
* - `billingPay` — списание стоимости подписок с биллинг-кошелька в
|
||||
* инфраструктурный кошелёк кооператива (оператор/председатель), идемпотентно.
|
||||
*
|
||||
* Подпись `coopname@active` (backend ретранслирует после JWT). Состав и цены
|
||||
* подписок on-chain не хранятся — они живут на стороне оператора (provider).
|
||||
*/
|
||||
@Resolver()
|
||||
export class BillingResolver {
|
||||
constructor(private readonly service: BillingService) {}
|
||||
|
||||
@Query(() => BillingSummaryDTO, {
|
||||
name: 'getBillingSummary',
|
||||
description:
|
||||
'Сумма к оплате кооператива за период (стоимость платных подписок, разбивка, ' +
|
||||
'дата следующего платежа, payment_hash). Источник — provider backend оператора. ' +
|
||||
'Для реестра кооперативов Восхода.',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman', 'member'])
|
||||
getBillingSummary(
|
||||
@Args('coopname', { type: () => String }) coopname: string,
|
||||
@Args('period', { type: () => Number, nullable: true }) period?: number,
|
||||
): Promise<BillingSummaryDTO> {
|
||||
return this.service.getBillingSummary(coopname, period ?? 30);
|
||||
}
|
||||
|
||||
@Mutation(() => GeneratedDocumentDTO, {
|
||||
name: 'generateBillingConversionStatement',
|
||||
description:
|
||||
'Генерирует заявление 1095.BillingConversionStatement (перед подписью пайщиком) — ' +
|
||||
'аналог generateConvertToAxonStatement, канон documents-dto.',
|
||||
})
|
||||
@Throttle({ default: { limit: 3, ttl: 60000 } })
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['member', 'chairman'])
|
||||
generateBillingConversionStatement(
|
||||
@Args('data', { type: () => BillingConversionStatementGenerateDocumentInputDTO })
|
||||
data: BillingConversionStatementGenerateDocumentInputDTO,
|
||||
@Args('options', { type: () => GenerateDocumentOptionsInputDTO, nullable: true })
|
||||
options: GenerateDocumentOptionsInputDTO,
|
||||
): Promise<GeneratedDocumentDTO> {
|
||||
return this.service.generateConversionStatement(data, options);
|
||||
}
|
||||
|
||||
@Mutation(() => BillingResultDTO, {
|
||||
name: 'billingConvert',
|
||||
description:
|
||||
'Конвертация паевого взноса пайщика в членский на биллинг-кошелёк (operation o.bil.fund). ' +
|
||||
'Принимает подписанное пайщиком заявление 1095.BillingConversionStatement.',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['user', 'member', 'chairman'])
|
||||
billingConvert(
|
||||
@Args('input', { type: () => BillingConvertInputDTO }) input: BillingConvertInputDTO,
|
||||
): Promise<BillingResultDTO> {
|
||||
return this.service.convert(input);
|
||||
}
|
||||
|
||||
@Mutation(() => BillingResultDTO, {
|
||||
name: 'billingPay',
|
||||
description:
|
||||
'Списание стоимости подписок с биллинг-кошелька пайщика в инфраструктурный ' +
|
||||
'кошелёк кооператива (operation o.bil.pay). Идемпотентно по payment_hash.',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman'])
|
||||
billingPay(
|
||||
@Args('input', { type: () => BillingPayInputDTO }) input: BillingPayInputDTO,
|
||||
): Promise<BillingResultDTO> {
|
||||
return this.service.pay(input);
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
BILLING_BLOCKCHAIN_PORT,
|
||||
type BillingBlockchainPort,
|
||||
} from '~/domain/billing/ports/billing-blockchain.port';
|
||||
import { BillingProviderClient } from '~/infrastructure/billing/billing-provider.client';
|
||||
import { DocumentDomainService } from '~/domain/document/services/document-domain.service';
|
||||
import { AmountFormatterUtils } from '~/shared/utils/amount-formatter.utils';
|
||||
import { DomainToBlockchainUtils } from '~/shared/utils/domain-to-blockchain.utils';
|
||||
import { TransactionUtils } from '~/shared/utils/transaction.utils';
|
||||
import { Cooperative } from 'cooptypes';
|
||||
import { BillingConversionStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/billing-conversion-statement-document.dto';
|
||||
import { GenerateDocumentOptionsInputDTO } from '~/application/document/dto/generate-document-options-input.dto';
|
||||
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
|
||||
import type { BillingConvertInputDTO } from '../dto/billing-convert-input.dto';
|
||||
import type { BillingPayInputDTO } from '../dto/billing-pay-input.dto';
|
||||
import type { BillingResultDTO } from '../dto/billing-result.dto';
|
||||
import type { BillingSummaryDTO } from '../dto/billing-summary.dto';
|
||||
|
||||
/**
|
||||
* Application-сервис billing (Epic 12). Тонкий фасад: валидацию входа делают
|
||||
* class-validator-декораторы DTO, согласие пайщика (для convert) и presence-check
|
||||
* кооператива проверяет on-chain сам контракт `billing`. Сервис делегирует подпись
|
||||
* и отправку в blockchain-порт (`coopname@active`, WIF из vault).
|
||||
*/
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
constructor(
|
||||
@Inject(BILLING_BLOCKCHAIN_PORT) private readonly blockchainPort: BillingBlockchainPort,
|
||||
private readonly providerClient: BillingProviderClient,
|
||||
private readonly documentDomainService: DocumentDomainService,
|
||||
private readonly domainToBlockchainUtils: DomainToBlockchainUtils,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Генерирует заявление 1095.BillingConversionStatement — подписывает пайщик
|
||||
* на стороне desktop перед `billingConvert`. Канон: provider.service →
|
||||
* generateConvertToAxonStatement.
|
||||
*/
|
||||
async generateConversionStatement(
|
||||
data: BillingConversionStatementGenerateDocumentInputDTO,
|
||||
options: GenerateDocumentOptionsInputDTO,
|
||||
): Promise<GeneratedDocumentDTO> {
|
||||
data.registry_id = Cooperative.Registry.BillingConversionStatement.registry_id;
|
||||
data.convert_amount = AmountFormatterUtils.formatAmount(data.convert_amount);
|
||||
const document = await this.documentDomainService.generateDocument({ data, options });
|
||||
return document as unknown as GeneratedDocumentDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сумма к оплате кооператива за период — проекция provider getBillingSummary
|
||||
* для реестра кооперативов (сумма/дата след. платежа/free-метки/payment_hash).
|
||||
*/
|
||||
async getBillingSummary(coopname: string, periodDays = 30): Promise<BillingSummaryDTO> {
|
||||
const s = await this.providerClient.getBillingSummary(coopname, periodDays);
|
||||
return {
|
||||
coopname: s.coopname,
|
||||
periodDays: s.period_days,
|
||||
totalAmount: s.total_amount,
|
||||
currency: s.currency,
|
||||
paymentHash: s.payment_hash,
|
||||
nextPaymentDue: s.next_payment_due,
|
||||
items: (s.items ?? []).map((i) => ({
|
||||
subscriptionId: i.subscription_id,
|
||||
subscriptionTypeId: i.subscription_type_id,
|
||||
subscriptionTypeName: i.subscription_type_name,
|
||||
status: i.status,
|
||||
amount: i.amount,
|
||||
isFree: i.is_free,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async convert(input: BillingConvertInputDTO): Promise<BillingResultDTO> {
|
||||
const result = await this.blockchainPort.convert({
|
||||
coopname: input.coopname,
|
||||
username: input.username,
|
||||
quantity: input.amount,
|
||||
document: this.domainToBlockchainUtils.convertSignedDocumentToBlockchainFormat(input.document),
|
||||
});
|
||||
return { transactionId: TransactionUtils.extractTransactionId(result) };
|
||||
}
|
||||
|
||||
async pay(input: BillingPayInputDTO): Promise<BillingResultDTO> {
|
||||
const result = await this.blockchainPort.pay({
|
||||
coopname: input.coopname,
|
||||
username: input.username,
|
||||
quantity: input.amount,
|
||||
paymentHash: input.paymentHash,
|
||||
memo: input.memo,
|
||||
});
|
||||
return {
|
||||
transactionId: TransactionUtils.extractTransactionId(result),
|
||||
paymentHash: input.paymentHash,
|
||||
};
|
||||
}
|
||||
}
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
import { InputType, Field, IntersectionType, OmitType } from '@nestjs/graphql';
|
||||
import { IsString } 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';
|
||||
import type { ExcludeCommonProps } from '~/application/document/types';
|
||||
|
||||
// интерфейс параметров для генерации (registry_id = 1095)
|
||||
type action = Cooperative.Registry.BillingConversionStatement.Action;
|
||||
|
||||
@InputType(`BaseBillingConversionStatementMetaDocumentInput`)
|
||||
class BaseBillingConversionStatementMetaDocumentInputDTO implements ExcludeCommonProps<action> {
|
||||
@Field({ description: 'Сумма к конвертации паевого в членский на биллинг-кошелёк' })
|
||||
@IsString()
|
||||
convert_amount!: string;
|
||||
}
|
||||
|
||||
@InputType(`BillingConversionStatementGenerateDocumentInput`)
|
||||
export class BillingConversionStatementGenerateDocumentInputDTO
|
||||
extends IntersectionType(
|
||||
BaseBillingConversionStatementMetaDocumentInputDTO,
|
||||
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
|
||||
)
|
||||
implements action
|
||||
{
|
||||
registry_id!: number;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@InputType(`BillingConversionStatementSignedMetaDocumentInput`)
|
||||
export class BillingConversionStatementSignedMetaDocumentInputDTO
|
||||
extends IntersectionType(BaseBillingConversionStatementMetaDocumentInputDTO, MetaDocumentInputDTO)
|
||||
implements action {}
|
||||
|
||||
@InputType(`BillingConversionStatementSignedDocumentInput`)
|
||||
export class BillingConversionStatementSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
|
||||
@Field(() => BillingConversionStatementSignedMetaDocumentInputDTO, {
|
||||
description: 'Метаинформация заявления на конвертацию паевого в биллинг-кошелёк',
|
||||
})
|
||||
public readonly meta!: BillingConversionStatementSignedMetaDocumentInputDTO;
|
||||
}
|
||||
@@ -17,4 +17,7 @@ export class Ledger2WalletDTO {
|
||||
|
||||
@Field(() => String, { description: 'Доступный баланс' })
|
||||
available!: string;
|
||||
|
||||
@Field(() => String, { description: 'Заблокированный баланс' })
|
||||
blocked!: string;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { Ledger2 } from 'cooptypes';
|
||||
import { TransactionUtils } from '~/shared/utils/transaction.utils';
|
||||
import {
|
||||
LEDGER2_STATE_PORT,
|
||||
type Ledger2StatePort,
|
||||
@@ -137,7 +136,7 @@ export class Ledger2Service {
|
||||
|
||||
return {
|
||||
processHash,
|
||||
transactionId: TransactionUtils.extractTransactionId(result),
|
||||
transactionId: this.extractTransactionId(result),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,4 +185,16 @@ export class Ledger2Service {
|
||||
private generateProcessHash(): string {
|
||||
return createHash('sha256').update(randomBytes(32)).digest('hex');
|
||||
}
|
||||
|
||||
private extractTransactionId(result: unknown): string {
|
||||
if (result && typeof result === 'object' && 'transaction_id' in result) {
|
||||
const tx = (result as { transaction_id?: unknown }).transaction_id;
|
||||
if (typeof tx === 'string') return tx;
|
||||
}
|
||||
if (result && typeof result === 'object' && 'response' in result) {
|
||||
const resp = (result as { response?: { transaction_id?: unknown } }).response;
|
||||
if (resp && typeof resp.transaction_id === 'string') return resp.transaction_id;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { ProviderSubscriptionDTO } from './provider-subscription.dto';
|
||||
|
||||
/**
|
||||
* Элемент реестра кооперативов для оператора (Восход).
|
||||
*
|
||||
* Сводит в одну запись:
|
||||
* - on-chain данные кооператива из registrator.coops (статус pending|active|blocked, домен, дата);
|
||||
* - данные провайдера (подписки/инстанс/биллинг) из provider-backend, привязанные по coopname.
|
||||
*
|
||||
* Источник on-chain: BlockchainPort.getAllRows(registrator, coops).
|
||||
* Источник provider: ProviderService.getUserSubscriptions(coopname) (server-secret канал).
|
||||
*/
|
||||
@ObjectType('CooperativeRegistryItem')
|
||||
export class CooperativeRegistryItemDTO {
|
||||
@Field(() => String, { description: 'Имя аккаунта кооператива (coopname)' })
|
||||
coopname!: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Наименование организации кооператива' })
|
||||
name?: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Анонсированный домен/сайт кооператива' })
|
||||
announce?: string;
|
||||
|
||||
@Field(() => String, { description: 'Статус кооператива в блокчейне: pending | active | blocked' })
|
||||
status!: string;
|
||||
|
||||
@Field(() => String, { nullable: true, description: 'Дата регистрации заявки кооператива (on-chain)' })
|
||||
created_at?: string;
|
||||
|
||||
@Field(() => Boolean, { description: 'Есть ли у кооператива данные провайдера (хотя бы одна подписка)' })
|
||||
has_provider_data!: boolean;
|
||||
|
||||
@Field(() => [ProviderSubscriptionDTO], { description: 'Подписки кооператива у провайдера (с инстансом и биллингом)' })
|
||||
subscriptions!: ProviderSubscriptionDTO[];
|
||||
}
|
||||
@@ -3,12 +3,11 @@ import { ProviderService } from './services/provider.service';
|
||||
import { ProviderResolver } from './resolvers/provider.resolver';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { DocumentDomainModule } from '~/domain/document/document.module';
|
||||
import { BlockchainModule } from '~/infrastructure/blockchain/blockchain.module';
|
||||
// Импортируем для регистрации GraphQL enum
|
||||
import '~/domain/instance-status.enum';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule, DocumentDomainModule, BlockchainModule],
|
||||
imports: [ConfigModule, DocumentDomainModule],
|
||||
providers: [ProviderService, ProviderResolver],
|
||||
exports: [ProviderService],
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import { UseGuards } from '@nestjs/common';
|
||||
import { ProviderService } from '../services/provider.service';
|
||||
import { ProviderSubscriptionDTO } from '../dto/provider-subscription.dto';
|
||||
import { CurrentInstanceDTO } from '../dto/current-instance.dto';
|
||||
import { CooperativeRegistryItemDTO } from '../dto/cooperative-registry-item.dto';
|
||||
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';
|
||||
@@ -19,16 +18,6 @@ import { Throttle } from '@nestjs/throttler';
|
||||
export class ProviderResolver {
|
||||
constructor(private readonly providerService: ProviderService) {}
|
||||
|
||||
@Query(() => [CooperativeRegistryItemDTO], {
|
||||
name: 'getCooperativesRegistry',
|
||||
description: 'Реестр кооперативов оператора: список кооперативов из блокчейна с данными провайдера (подписки, инстанс, биллинг)',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['member', 'chairman'])
|
||||
async getCooperativesRegistry(): Promise<CooperativeRegistryItemDTO[]> {
|
||||
return this.providerService.getCooperativesRegistry();
|
||||
}
|
||||
|
||||
@Query(() => [ProviderSubscriptionDTO], {
|
||||
name: 'getProviderSubscriptions',
|
||||
description: 'Получить подписки пользователя у провайдера',
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { Injectable, Logger, BadRequestException, Inject } from '@nestjs/common';
|
||||
import { RegistratorContract } from 'cooptypes';
|
||||
import { ProviderSubscriptionDTO } from '../dto/provider-subscription.dto';
|
||||
import { CurrentInstanceDTO } from '../dto/current-instance.dto';
|
||||
import { CooperativeRegistryItemDTO } from '../dto/cooperative-registry-item.dto';
|
||||
import { InstanceStatus } from '~/domain/instance-status.enum';
|
||||
import { Client, configureClient } from '@coopenomics/provider-client';
|
||||
import { config } from '~/config';
|
||||
import { BLOCKCHAIN_PORT, type BlockchainPort } from '~/domain/common/ports/blockchain.port';
|
||||
import { ORGANIZATION_REPOSITORY, type OrganizationRepository } from '~/domain/common/repositories/organization.repository';
|
||||
import { DocumentDomainService } from '~/domain/document/services/document-domain.service';
|
||||
import { ConvertToAxonStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/convert-to-axon-statement-document.dto';
|
||||
import { GenerateDocumentOptionsInputDTO } from '~/application/document/dto/generate-document-options-input.dto';
|
||||
@@ -23,9 +19,7 @@ export class ProviderService {
|
||||
|
||||
constructor(
|
||||
private readonly documentDomainService: DocumentDomainService,
|
||||
@Inject(SYSTEM_BLOCKCHAIN_PORT) private readonly systemBlockchainPort: SystemBlockchainPort,
|
||||
@Inject(BLOCKCHAIN_PORT) private readonly blockchainPort: BlockchainPort,
|
||||
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository
|
||||
@Inject(SYSTEM_BLOCKCHAIN_PORT) private readonly systemBlockchainPort: SystemBlockchainPort
|
||||
) {
|
||||
// Проверяем наличие PROVIDER_BASE_URL
|
||||
const providerBaseUrl = config.provider_base_url;
|
||||
@@ -49,64 +43,6 @@ export class ProviderService {
|
||||
return config.provider_base_url !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Реестр кооперативов для оператора: сводит on-chain список кооперативов
|
||||
* (registrator.coops) с данными провайдера (подписки/инстанс/биллинг по coopname).
|
||||
*
|
||||
* On-chain — источник истины по статусу кооператива (pending|active|blocked).
|
||||
* Provider — источник данных подписок; если провайдер не настроен или у кооператива
|
||||
* нет подписки, поле subscriptions остаётся пустым (кооператив всё равно в списке).
|
||||
*/
|
||||
async getCooperativesRegistry(): Promise<CooperativeRegistryItemDTO[]> {
|
||||
const coops = (await this.blockchainPort.getAllRows(
|
||||
RegistratorContract.contractName.production,
|
||||
RegistratorContract.contractName.production,
|
||||
RegistratorContract.Tables.Cooperatives.tableName
|
||||
)) as RegistratorContract.Tables.Cooperatives.ICooperative[];
|
||||
|
||||
const providerAvailable = this.isProviderAvailable();
|
||||
|
||||
return Promise.all(
|
||||
coops.map(async (coop) => {
|
||||
const item = new CooperativeRegistryItemDTO();
|
||||
item.coopname = coop.username;
|
||||
item.name = await this.resolveOrganizationName(coop.username, coop.description);
|
||||
item.announce = coop.announce;
|
||||
item.status = coop.status;
|
||||
item.created_at = coop.created_at;
|
||||
item.subscriptions = [];
|
||||
|
||||
if (providerAvailable) {
|
||||
try {
|
||||
item.subscriptions = await this.getUserSubscriptions(coop.username);
|
||||
} catch (error: any) {
|
||||
// Провайдер недоступен или у кооператива нет подписок — не роняем весь реестр.
|
||||
this.logger.warn(`Не удалось получить подписки провайдера для ${coop.username}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
item.has_provider_data = item.subscriptions.length > 0;
|
||||
return item;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлечь человеко-читаемое наименование кооператива. Сначала пробуем
|
||||
* organization-запись в Mongo (short_name → full_name), потом on-chain
|
||||
* description; если и его нет — null, фронт сам отрисует coopname.
|
||||
*/
|
||||
private async resolveOrganizationName(username: string, onChainDescription: string): Promise<string | undefined> {
|
||||
try {
|
||||
const organization = await this.organizationRepository.findByUsername(username);
|
||||
const name = organization?.short_name || organization?.full_name;
|
||||
if (name) return name;
|
||||
} catch {
|
||||
// organization ещё не зарегистрирована — fallback на on-chain
|
||||
}
|
||||
return onChainDescription || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить подписки пользователя по username
|
||||
*/
|
||||
|
||||
@@ -28,6 +28,9 @@ export class ProgramWalletDTO {
|
||||
@Field(() => String, { description: 'Доступный баланс (формат: "100.0000 RUB")' })
|
||||
available!: string;
|
||||
|
||||
@Field(() => String, { description: 'Заблокированный баланс (формат: "100.0000 RUB")' })
|
||||
blocked!: string;
|
||||
|
||||
@Field(() => String, { description: 'Паевой взнос (формат: "100.0000 RUB")' })
|
||||
membership_contribution!: string;
|
||||
|
||||
@@ -46,6 +49,7 @@ export class ProgramWalletDTO {
|
||||
dto.agreement_id = entity.agreement_id;
|
||||
dto.username = entity.username;
|
||||
dto.available = entity.available;
|
||||
dto.blocked = entity.blocked;
|
||||
dto.membership_contribution = entity.membership_contribution;
|
||||
dto.blockNum = entity.blockNum;
|
||||
return dto;
|
||||
|
||||
@@ -49,12 +49,4 @@ export class ProgramWalletSyncService
|
||||
|
||||
this.logger.debug('Сервис синхронизации программных кошельков полностью инициализирован с подписками на паттерны');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для программных кошельков
|
||||
* Подписывается на все форки независимо от контракта
|
||||
*/
|
||||
async handleProgramWalletFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,24 +87,6 @@ const envVarsSchema = z.object({
|
||||
.describe('адрес сервиса GRAPHQL'),
|
||||
PROVIDER_BASE_URL: z.string().default('').describe('базовый URL сервиса провайдера'),
|
||||
|
||||
// Billing Single-Hub v5: BillingModule подключается только на Восходе-хабе.
|
||||
// На спицах флаг false → модуль вообще не регистрируется, endpoints/cron
|
||||
// не появляются. Antelope не поддерживает deferred_trx — тик крона
|
||||
// инициирует backend.
|
||||
BILLING_HUB_MODE: z
|
||||
.string()
|
||||
.default('false')
|
||||
.transform((v) => v === 'true' || v === '1')
|
||||
.describe('узел работает как биллинг-хаб (только Восход): включает BillingModule, крон и GraphQL Billing.*'),
|
||||
BILLING_CRON_EXPRESSION: z
|
||||
.string()
|
||||
.default('0 * * * *')
|
||||
.describe('cron-выражение тика биллинга (по умолчанию ежечасно)'),
|
||||
BILLING_CRON_PAYER: z
|
||||
.string()
|
||||
.default('')
|
||||
.describe('пайщик-плательщик, чей w.wal.bill дебетуется (пусто — списание пропускается)'),
|
||||
|
||||
// Параметры союза кооперативов
|
||||
UNION_LINK: z
|
||||
.string()
|
||||
@@ -141,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'),
|
||||
@@ -249,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' : ''),
|
||||
@@ -280,13 +300,6 @@ export default {
|
||||
coopname: envVars.data.COOPNAME,
|
||||
graphql_service: envVars.data.GRAPHQL_SERVICE,
|
||||
provider_base_url: envVars.data.PROVIDER_BASE_URL,
|
||||
billing: {
|
||||
hub_mode: envVars.data.BILLING_HUB_MODE,
|
||||
cron_expression: envVars.data.BILLING_CRON_EXPRESSION,
|
||||
// Список коопов — из on-chain `registrator.coops` через ProviderService
|
||||
// (см. BillingCronService.activeCoopnames). Env-override отсутствует.
|
||||
payer: envVars.data.BILLING_CRON_PAYER,
|
||||
},
|
||||
union: {
|
||||
link: envVars.data.UNION_LINK,
|
||||
is_unioned: envVars.data.IS_UNIONED,
|
||||
|
||||
@@ -26,29 +26,22 @@ const logger = winston.createLogger({
|
||||
})
|
||||
),
|
||||
transports: [
|
||||
// При генерации схемы (build-time codegen) файловые транспорты не подключаем:
|
||||
// процесс запускается из-под пользователя рядом с работающим контейнером, чей logs/
|
||||
// принадлежит root, — запись туда падает с EACCES. На рантайм не влияет (флаг ставится только в codegen).
|
||||
...(process.env.CONTROLLER_SCHEMA_GEN
|
||||
? []
|
||||
: [
|
||||
new DailyRotateFile({
|
||||
filename: 'logs/application-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
zippedArchive: true,
|
||||
maxSize: '20m',
|
||||
maxFiles: '30d',
|
||||
level: 'info',
|
||||
}),
|
||||
new DailyRotateFile({
|
||||
filename: 'logs/error-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
zippedArchive: true,
|
||||
maxSize: '20m',
|
||||
maxFiles: '30d',
|
||||
level: 'error',
|
||||
}),
|
||||
]),
|
||||
new DailyRotateFile({
|
||||
filename: 'logs/application-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
zippedArchive: true,
|
||||
maxSize: '20m',
|
||||
maxFiles: '30d',
|
||||
level: 'info',
|
||||
}),
|
||||
new DailyRotateFile({
|
||||
filename: 'logs/error-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
zippedArchive: true,
|
||||
maxSize: '20m',
|
||||
maxFiles: '30d',
|
||||
level: 'error',
|
||||
}),
|
||||
new winston.transports.Console({
|
||||
stderrLevels: ['error'],
|
||||
level: config.env === 'development' ? 'debug' : 'info',
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { TransactionResult } from '~/domain/blockchain/types/transaction-result.type';
|
||||
import type { Cooperative } from 'cooptypes';
|
||||
|
||||
/**
|
||||
* Параметры конвертации паевого взноса пайщика в членский на биллинг-кошелёк
|
||||
* (`w.wal.bill`) — действие `billing::convert` (operation `o.bil.fund`).
|
||||
* Несёт подписанное пайщиком заявление (`document`).
|
||||
*/
|
||||
export interface BillingConvertBlockchainDomainInterface {
|
||||
coopname: string;
|
||||
/** Пайщик-владелец биллинг-кошелька. */
|
||||
username: string;
|
||||
/** Сумма с символом, например `"1500.0000 RUB"`. */
|
||||
quantity: string;
|
||||
/** Подписанное пайщиком заявление (document2). */
|
||||
document: Cooperative.Document.IChainDocument2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Параметры списания стоимости подписок с биллинг-кошелька пайщика в
|
||||
* инфраструктурный кошелёк кооператива — действие `billing::pay`
|
||||
* (operation `o.bil.pay`). Идемпотентно по `paymentHash`.
|
||||
*/
|
||||
export interface BillingPayBlockchainDomainInterface {
|
||||
coopname: string;
|
||||
username: string;
|
||||
/** Сумма с символом, например `"1500.0000 RUB"`. */
|
||||
quantity: string;
|
||||
/** Детерминированный идентификатор платежа (из provider getBillingSummary). */
|
||||
paymentHash: string;
|
||||
memo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Порт billing для записи в блокчейн. Подпись `coopname@active` (WIF из vault);
|
||||
* согласие пайщика для convert несёт `document`.
|
||||
*/
|
||||
export interface BillingBlockchainPort {
|
||||
convert(data: BillingConvertBlockchainDomainInterface): Promise<TransactionResult>;
|
||||
pay(data: BillingPayBlockchainDomainInterface): Promise<TransactionResult>;
|
||||
}
|
||||
|
||||
export const BILLING_BLOCKCHAIN_PORT = Symbol('BILLING_BLOCKCHAIN_PORT');
|
||||
@@ -1,153 +0,0 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy, Logger, Inject } from '@nestjs/common';
|
||||
import cron from 'node-cron';
|
||||
import config from '~/config/config';
|
||||
import {
|
||||
BILLING_BLOCKCHAIN_PORT,
|
||||
type BillingBlockchainPort,
|
||||
} from '~/domain/billing/ports/billing-blockchain.port';
|
||||
import { BillingProviderClient } from '~/infrastructure/billing/billing-provider.client';
|
||||
import { ProviderService } from '~/application/provider/services/provider.service';
|
||||
|
||||
/**
|
||||
* Периодическое списание подписок (Epic 12, Story 12.6) — oracle-паттерн.
|
||||
*
|
||||
* Antelope не поддерживает deferred_trx, поэтому рекуррентность инициирует
|
||||
* backend: на каждый тик узел запрашивает у провайдера «сумму к оплате»
|
||||
* (источник истины по составу/ценам — provider, on-chain их нет), и если есть
|
||||
* что списывать и срок подошёл — проводит on-chain `billing::pay`, затем
|
||||
* подтверждает платёж провайдеру (тот продлевает подписки по `payment_hash`).
|
||||
*
|
||||
* Инварианты:
|
||||
* - сумма = 0 (все подписки free) → on-chain списание НЕ выполняется;
|
||||
* - идемпотентность по `payment_hash` (контракт no-op + провайдер идемпотентен);
|
||||
* - падение `pay` не зацикливает узел: ошибка логируется, тик продолжает
|
||||
* следующий кооператив (grace/уведомления — на стороне провайдера/Epic 9).
|
||||
*
|
||||
* Плательщик (`config.billing.payer`) — пайщик, чей USER_SHARED-кошелёк
|
||||
* `w.wal.bill` дебетуется. Списание per-пайщик, поэтому без указанного
|
||||
* плательщика тик пропускается (см. конфиг `BILLING_CRON_PAYER`).
|
||||
*/
|
||||
@Injectable()
|
||||
export class BillingCronService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(BillingCronService.name);
|
||||
private cronJob: cron.ScheduledTask | null = null;
|
||||
private running = false;
|
||||
|
||||
constructor(
|
||||
@Inject(BILLING_BLOCKCHAIN_PORT) private readonly blockchainPort: BillingBlockchainPort,
|
||||
private readonly providerClient: BillingProviderClient,
|
||||
private readonly providerService: ProviderService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
if (!this.providerClient.isConfigured()) {
|
||||
this.logger.warn('BillingCronService: PROVIDER_BASE_URL не задан — тик не будет запущен');
|
||||
return;
|
||||
}
|
||||
if (!cron.validate(config.billing.cron_expression)) {
|
||||
this.logger.error(`BillingCronService: некорректное cron-выражение "${config.billing.cron_expression}"`);
|
||||
return;
|
||||
}
|
||||
this.cronJob = cron.schedule(config.billing.cron_expression, () => {
|
||||
void this.tick();
|
||||
});
|
||||
this.logger.log(`BillingCronService запущен (cron="${config.billing.cron_expression}")`);
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.cronJob) {
|
||||
this.cronJob.stop();
|
||||
this.cronJob = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Список коопов для тика берётся ИЗ on-chain `registrator.coops`
|
||||
* (через ProviderService), отфильтрованный по `status === 'active'`.
|
||||
* Никаких env-CSV — кооперативы всегда есть в блокчейне.
|
||||
*/
|
||||
private async activeCoopnames(): Promise<string[]> {
|
||||
const registry = await this.providerService.getCooperativesRegistry();
|
||||
return registry.filter((c) => c.status === 'active').map((c) => c.coopname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Один тик: обходит активные коопы из on-chain реестра, списывает подошедшие
|
||||
* к оплате. Защита от наложения тиков (`running`) — если предыдущий ещё идёт,
|
||||
* пропускаем.
|
||||
*/
|
||||
async tick(): Promise<void> {
|
||||
if (this.running) {
|
||||
this.logger.warn('BillingCronService: предыдущий тик ещё выполняется — пропуск');
|
||||
return;
|
||||
}
|
||||
const payer = config.billing.payer;
|
||||
if (!payer) {
|
||||
this.logger.warn('BillingCronService: BILLING_CRON_PAYER не задан — нечего дебетовать, пропуск тика');
|
||||
return;
|
||||
}
|
||||
this.running = true;
|
||||
try {
|
||||
const coopnames = await this.activeCoopnames();
|
||||
for (const coopname of coopnames) {
|
||||
await this.processCoop(coopname, payer);
|
||||
}
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async processCoop(coopname: string, payer: string): Promise<void> {
|
||||
try {
|
||||
const summary = await this.providerClient.getBillingSummary(coopname);
|
||||
|
||||
if (!summary || summary.total_amount <= 0) {
|
||||
return; // всё free или нет подписок — пустые транзакции не гоняем
|
||||
}
|
||||
if (!this.isDue(summary.next_payment_due)) {
|
||||
return; // срок ещё не подошёл
|
||||
}
|
||||
|
||||
const quantity = `${summary.total_amount.toFixed(config.blockchain.root_govern_precision)} ${summary.currency || config.blockchain.root_govern_symbol}`;
|
||||
|
||||
const result = await this.blockchainPort.pay({
|
||||
coopname,
|
||||
username: payer,
|
||||
quantity,
|
||||
paymentHash: summary.payment_hash,
|
||||
memo: `Оплата подписок за ${summary.period_days} дн.`,
|
||||
});
|
||||
|
||||
const transactionId =
|
||||
result && typeof result === 'object' && 'transaction_id' in result
|
||||
? String((result as { transaction_id?: unknown }).transaction_id ?? '')
|
||||
: '';
|
||||
|
||||
await this.providerClient.confirmPayment({
|
||||
coopname,
|
||||
paymentHash: summary.payment_hash,
|
||||
amount: summary.total_amount,
|
||||
blockchainTransactionId: transactionId,
|
||||
periodDays: summary.period_days,
|
||||
});
|
||||
|
||||
this.logger.log(`Списано ${quantity} за подписки ${coopname} (payment_hash=${summary.payment_hash})`);
|
||||
} catch (error: any) {
|
||||
// Падение списания (например, недостаток средств на w.wal.bill) не должно
|
||||
// зацикливать узел: фиксируем и продолжаем. Перевод подписки в past_due/grace
|
||||
// и уведомления — на стороне провайдера (Epic 4/9).
|
||||
this.logger.error(`BillingCronService: списание для ${coopname} не выполнено: ${error?.message ?? error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Срок оплаты подошёл, если дата следующего платежа не задана (первое списание)
|
||||
* либо она в прошлом/сегодня.
|
||||
*/
|
||||
private isDue(nextPaymentDue: string | null): boolean {
|
||||
if (!nextPaymentDue) return true;
|
||||
const due = new Date(nextPaymentDue).getTime();
|
||||
if (Number.isNaN(due)) return true;
|
||||
return due <= Date.now();
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,12 @@ import type { ActionRepositoryPort } from '../ports/action-repository.port';
|
||||
import type { DeltaRepositoryPort } from '../ports/delta-repository.port';
|
||||
import type { ForkRepositoryPort } from '../ports/fork-repository.port';
|
||||
import type { SyncStateRepositoryPort } from '../ports/sync-state-repository.port';
|
||||
import type { ConsumerDedupRepositoryPort } from '../ports/consumer-dedup-repository.port';
|
||||
import { ACTION_REPOSITORY_PORT } from '../ports/action-repository.port';
|
||||
import { DELTA_REPOSITORY_PORT } from '../ports/delta-repository.port';
|
||||
import { FORK_REPOSITORY_PORT } from '../ports/fork-repository.port';
|
||||
import { SYNC_STATE_REPOSITORY_PORT } from '../ports/sync-state-repository.port';
|
||||
import { CONSUMER_DEDUP_REPOSITORY_PORT } from '../ports/consumer-dedup-repository.port';
|
||||
|
||||
/**
|
||||
* Интерактор парсера блокчейна
|
||||
@@ -30,9 +32,36 @@ export class ParserInteractor {
|
||||
@Inject(FORK_REPOSITORY_PORT)
|
||||
private readonly forkRepository: ForkRepositoryPort,
|
||||
@Inject(SYNC_STATE_REPOSITORY_PORT)
|
||||
private readonly syncStateRepository: SyncStateRepositoryPort
|
||||
private readonly syncStateRepository: SyncStateRepositoryPort,
|
||||
@Inject(CONSUMER_DEDUP_REPOSITORY_PORT)
|
||||
private readonly consumerDedupRepository: ConsumerDedupRepositoryPort
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Отмечено ли событие как уже применённое (Story 2.3, INV-09).
|
||||
*/
|
||||
async isEventApplied(eventId: string): Promise<boolean> {
|
||||
return await this.consumerDedupRepository.isApplied(eventId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Отметить событие применённым в consumer_dedup (Story 2.2 dual-write).
|
||||
* Идемпотентно: повтор после краха между save и mark не падает.
|
||||
* blockNum (Story 4.1) — для последующего deleteAfterBlock при форке;
|
||||
* опциональный, если вызов из мест без контекста блока.
|
||||
*/
|
||||
async markEventApplied(eventId: string, blockNum?: number): Promise<void> {
|
||||
await this.consumerDedupRepository.markApplied(eventId, blockNum);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить из consumer_dedup записи с block_num > forkBlockNum — очистка дедупа
|
||||
* на форке (Story 4.1, ADR-005). Возвращает число удалённых строк.
|
||||
*/
|
||||
async deleteDedupAfterBlock(forkBlockNum: number): Promise<number> {
|
||||
return await this.consumerDedupRepository.deleteAfterBlock(forkBlockNum);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранение действия блокчейна
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Порт списка применённых событий (consumer_dedup) — фундамент идемпотентности
|
||||
* (Story 2.1, INV-09).
|
||||
*/
|
||||
export interface ConsumerDedupRepositoryPort {
|
||||
/** Отмечено ли событие как уже применённое. Используется dedup-gate (Story 2.3). */
|
||||
isApplied(eventId: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Отметить событие применённым. Идемпотентно (ON CONFLICT DO NOTHING): повтор
|
||||
* после краха между save и mark не должен падать. blockNum — номер блока события
|
||||
* (Story 4.1, для последующего deleteAfterBlock на форке); опциональный для
|
||||
* backward-compat с местами, где блок неизвестен (например, ручные тесты).
|
||||
*/
|
||||
markApplied(eventId: string, blockNum?: number): Promise<void>;
|
||||
|
||||
/** Очистка меток старше cutoff (retention). Возвращает число удалённых строк. */
|
||||
deleteOlderThan(cutoff: Date): Promise<number>;
|
||||
|
||||
/**
|
||||
* Удалить метки событий с block_num > blockNum — для очистки дедупа на форке
|
||||
* (Story 4.1, ADR-005). Записи с NULL block_num (legacy до Epic 4) НЕ затрагиваются:
|
||||
* PG сравнение NULL > N даёт unknown и не попадает под WHERE. Возвращает число строк.
|
||||
*/
|
||||
deleteAfterBlock(blockNum: number): Promise<number>;
|
||||
}
|
||||
|
||||
export const CONSUMER_DEDUP_REPOSITORY_PORT = Symbol('ConsumerDedupRepositoryPort');
|
||||
@@ -94,27 +94,4 @@ export class BlockchainEventHandlerService implements OnModuleInit {
|
||||
throw error; // Перебрасываем ошибку для корректной обработки
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка события форка блокчейна
|
||||
* Сохраняет форк в базу данных
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleForkEvent(data: { block_num: number }): Promise<void> {
|
||||
try {
|
||||
this.logger.debug(`Handling fork event at block: ${data.block_num}`);
|
||||
|
||||
// Преобразование данных форка для сохранения
|
||||
const forkData = {
|
||||
chain_id: config.blockchain.id, // Используем chain_id из конфигурации
|
||||
block_num: data.block_num,
|
||||
};
|
||||
|
||||
await this.parserInteractor.saveFork(forkData);
|
||||
this.logger.debug(`Fork saved at block: ${data.block_num} for chain: ${config.blockchain.id}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка обработки события форка: ${error.message}`, error.stack);
|
||||
throw error; // Перебрасываем ошибку для корректной обработки
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-8
@@ -75,12 +75,4 @@ export class AppendixSyncService
|
||||
this.logger.error(`Ошибка при обработке отклонения приложения: ${error?.message}`, error?.stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик форков для приложений
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleAppendixFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
-9
@@ -104,13 +104,4 @@ export class CommitSyncService
|
||||
this.logger.error(`Ошибка при обработке отклонения коммита: ${error?.message}`, error?.stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для коммитов
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleCommitFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ContributorDomainEntity } from '../../domain/entities/contributor.entity';
|
||||
@@ -84,13 +84,4 @@ export class ContributorSyncService
|
||||
|
||||
return contributorEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для участников
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleContributorFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { DebtDomainEntity } from '../../domain/entities/debt.entity';
|
||||
@@ -49,13 +49,4 @@ export class DebtSyncService
|
||||
|
||||
this.logger.debug('Сервис синхронизации долгов полностью инициализирован с подписками на паттерны');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для долгов
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleDebtFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ExpenseDomainEntity } from '../../domain/entities/expense.entity';
|
||||
@@ -49,13 +49,4 @@ export class ExpenseSyncService
|
||||
|
||||
this.logger.debug('Сервис синхронизации расходов полностью инициализирован с подписками на паттерны');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для расходов
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleExpenseFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { InvestDomainEntity } from '../../domain/entities/invest.entity';
|
||||
@@ -49,13 +49,4 @@ export class InvestSyncService
|
||||
|
||||
this.logger.debug('Сервис синхронизации инвестиций полностью инициализирован с подписками на паттерны');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для инвестиций
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleInvestFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-9
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ProgramPropertyDomainEntity } from '../../domain/entities/program-property.entity';
|
||||
@@ -50,12 +50,4 @@ export class ProgramPropertySyncService
|
||||
this.eventEmitter.on(pattern, this.processDelta.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик форков для программных имущественных взносов
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleProgramPropertyFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-9
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ProgramWalletDomainEntity } from '../../domain/entities/program-wallet.entity';
|
||||
@@ -47,12 +47,4 @@ export class ProgramWalletSyncService
|
||||
this.eventEmitter.on(pattern, this.processDelta.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик форков для программных кошельков
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleProgramWalletFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-9
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ProgramWithdrawDomainEntity } from '../../domain/entities/program-withdraw.entity';
|
||||
@@ -50,12 +50,4 @@ export class ProgramWithdrawSyncService
|
||||
this.eventEmitter.on(pattern, this.processDelta.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик форков для возвратов из программы
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleProgramWithdrawFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-9
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ProjectPropertyDomainEntity } from '../../domain/entities/project-property.entity';
|
||||
@@ -50,12 +50,4 @@ export class ProjectPropertySyncService
|
||||
this.eventEmitter.on(pattern, this.processDelta.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик форков для проектных имущественных взносов
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleProjectPropertyFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ProjectDomainEntity } from '../../domain/entities/project.entity';
|
||||
@@ -139,13 +139,4 @@ export class ProjectSyncService
|
||||
|
||||
return projectEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для проектов
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleProjectFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ResultDomainEntity } from '../../domain/entities/result.entity';
|
||||
@@ -102,13 +102,4 @@ export class ResultSyncService
|
||||
|
||||
return resultEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для результатов
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleResultFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-11
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { SegmentDomainEntity } from '../../domain/entities/segment.entity';
|
||||
@@ -54,16 +54,6 @@ export class SegmentSyncService
|
||||
|
||||
this.logger.debug('Сервис синхронизации сегментов полностью инициализирован с подписками на паттерны');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для сегментов
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleSegmentFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронизация сегмента между блокчейном и базой данных
|
||||
*/
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { StateDomainEntity } from '../../domain/entities/state.entity';
|
||||
@@ -49,13 +49,4 @@ export class StateSyncService
|
||||
|
||||
this.logger.debug('Сервис синхронизации состояния полностью инициализирован с подписками на паттерны');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для состояния
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleStateFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { VoteDomainEntity } from '../../domain/entities/vote.entity';
|
||||
@@ -49,13 +49,4 @@ export class VoteSyncService
|
||||
|
||||
this.logger.debug('Сервис синхронизации голосов полностью инициализирован с подписками на паттерны');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для голосов
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleVoteFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,13 @@ import type {
|
||||
import type { IProjectDomainInterfaceBlockchainData } from '../interfaces/project-blockchain.interface';
|
||||
import type { IBlockchainSynchronizable } from '~/shared/interfaces/blockchain-sync.interface';
|
||||
import { BaseDomainEntity } from '~/shared/sync/entities/base-domain.entity';
|
||||
import { auditUnknownStatus } from '~/shared/sync/errors/audit-unknown-status';
|
||||
import { IssueIdGenerationService } from '../services/issue-id-generation.service';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
|
||||
const PROJECT_STATUS_AUDIT_LOGGER = new WinstonLoggerService();
|
||||
PROJECT_STATUS_AUDIT_LOGGER.setContext('ProjectDomainEntity');
|
||||
|
||||
/**
|
||||
* Доменная сущность проекта
|
||||
*
|
||||
@@ -210,7 +216,16 @@ export class ProjectDomainEntity
|
||||
case 'cancelled':
|
||||
return ProjectStatus.CANCELLED;
|
||||
default:
|
||||
// По умолчанию считаем статус неопределенным
|
||||
// Story 6.5: silent fallback на UNDEFINED заменён audit-trail'ом.
|
||||
// Если контракт ввёл новый статус — drift всплывёт в логе как error.
|
||||
auditUnknownStatus('ProjectDomainEntity', blockchainStatus, PROJECT_STATUS_AUDIT_LOGGER, [
|
||||
'pending',
|
||||
'active',
|
||||
'voting',
|
||||
'result',
|
||||
'finalized',
|
||||
'cancelled',
|
||||
]);
|
||||
return ProjectStatus.UNDEFINED;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -29,10 +29,9 @@ export class AppendixDeltaMapper extends AbstractBlockchainDeltaMapper<IAppendix
|
||||
return null;
|
||||
}
|
||||
|
||||
// 🔥 ВАЖНО: Парсим документы ПЕРЕД возвратом
|
||||
// Парсим документы ПЕРЕД возвратом
|
||||
const appendix = DomainToBlockchainUtils.convertChainDocumentToDomainFormat(value.appendix);
|
||||
|
||||
// Парсим документы
|
||||
return { ...value, appendix };
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error mapping delta to blockchain data: ${error.message}`, error.stack);
|
||||
|
||||
+3
-11
@@ -256,20 +256,12 @@ export class DecisionExpiredNotificationService implements OnModuleInit, OnModul
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем дату последней проверки. ВАЖНО: read-modify-write по СВЕЖЕМУ
|
||||
// config из БД, а не по захваченному при initialize() снимку `plugin`.
|
||||
// Cron-замыкание держит in-memory снимок с момента boot; за время между
|
||||
// boot и тиком онбординг-флаги (`onboarding_*_done/_hash`) и прочие поля
|
||||
// могли быть записаны в БД другими сервисами. Перезапись устаревшего
|
||||
// снимка стёрла бы их (lost update). Поэтому берём актуальный config и
|
||||
// трогаем только lastCheckDate.
|
||||
const fresh = await this.extensionRepository.findByName('chairman');
|
||||
const baseConfig = fresh?.config ?? plugin.config;
|
||||
// Обновляем дату последней проверки (только это поле, чтобы не перезаписать другие параметры конфига)
|
||||
const updatedConfig = {
|
||||
...baseConfig,
|
||||
...plugin.config,
|
||||
lastCheckDate: new Date().toISOString(),
|
||||
};
|
||||
await this.extensionRepository.update({ name: 'chairman', config: updatedConfig });
|
||||
await this.extensionRepository.update({ ...plugin, config: updatedConfig });
|
||||
|
||||
this.logger.debug('Проверка истекших решений завершена');
|
||||
} catch (error) {
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import type { IDelta } from '~/types/common';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../../shared/services/abstract-entity-sync.service';
|
||||
@@ -56,15 +56,6 @@ export class ApprovalSyncService
|
||||
async handleApprovalDelta(delta: IDelta): Promise<void> {
|
||||
await this.processDelta(delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик форков для одобрений
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleApprovalFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение поддерживаемых версий контрактов и таблиц
|
||||
*/
|
||||
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* Matrix: комната секретаря — создаётся вручную председателем/советом из desktop.
|
||||
* Всегда незашифрована (E2EE секретарь транскрибировать не может). Тип (public/private)
|
||||
* выбирается при создании, поэтому isPrivate здесь не фиксируем — задаёт сервис.
|
||||
*/
|
||||
|
||||
import type { MatrixChatRoomPreset } from './matrix-chat-room-preset.types';
|
||||
|
||||
/**
|
||||
* Те же права, что у комнаты проекта Capital: модераторы (50) почти по всем настройкам;
|
||||
* 100 только у Matrix-админа для критичных state.
|
||||
*/
|
||||
function buildPowerLevels(adminUserId: string): Record<string, unknown> {
|
||||
return {
|
||||
users_default: 0,
|
||||
invite: 50,
|
||||
kick: 50,
|
||||
ban: 50,
|
||||
redact: 50,
|
||||
state_default: 50,
|
||||
events_default: 0,
|
||||
users: {
|
||||
[adminUserId]: 100,
|
||||
},
|
||||
events: {
|
||||
'm.room.name': 50,
|
||||
'm.room.topic': 50,
|
||||
'm.room.avatar': 50,
|
||||
'm.room.pinned_events': 50,
|
||||
'm.room.canonical_alias': 50,
|
||||
'm.room.aliases': 50,
|
||||
'm.room.power_levels': 100,
|
||||
'm.room.history_visibility': 50,
|
||||
'm.room.encryption': 100,
|
||||
'm.room.tombstone': 100,
|
||||
'm.room.server_acl': 100,
|
||||
|
||||
'im.vector.modular.widgets': 50,
|
||||
'm.widget': 50,
|
||||
'org.matrix.msc2876.widgets': 50,
|
||||
'io.element.widgets.layout': 50,
|
||||
|
||||
'io.element.call': 50,
|
||||
'io.element.call.member': 0,
|
||||
'org.matrix.msc3401.call': 50,
|
||||
'org.matrix.msc3401.call.member': 0,
|
||||
'm.call': 50,
|
||||
'm.call.invite': 50,
|
||||
'm.call.answer': 0,
|
||||
'm.call.hangup': 0,
|
||||
'm.call.candidates': 0,
|
||||
'm.call.select_answer': 0,
|
||||
'm.call.reject': 0,
|
||||
'm.call.negotiate': 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Базовый пресет комнаты секретаря. `isPrivate` переопределяется сервисом по флагу публичности.
|
||||
* `encrypt` всегда false — иначе секретарь не сможет читать сообщения и транскрибировать.
|
||||
*/
|
||||
export const SECRETARY_ROOM_MATRIX: MatrixChatRoomPreset = {
|
||||
label: 'Комната секретаря',
|
||||
isPrivate: true,
|
||||
encrypt: false,
|
||||
roomType: undefined,
|
||||
initialState: [],
|
||||
buildPowerLevels,
|
||||
};
|
||||
-26
@@ -23,32 +23,6 @@ export class ChatcoopProjectCommunicationRoomDTO {
|
||||
displayLabel!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Тип непроектной комнаты: пайщики, совет или комната секретаря.
|
||||
*/
|
||||
export enum NonProjectRoomKindGql {
|
||||
MEMBERS = 'MEMBERS',
|
||||
COUNCIL = 'COUNCIL',
|
||||
SECRETARY = 'SECRETARY',
|
||||
}
|
||||
|
||||
registerEnumType(NonProjectRoomKindGql, {
|
||||
name: 'NonProjectRoomKind',
|
||||
description: 'Тип комнаты вне проекта: пайщики, совет, комната секретаря',
|
||||
});
|
||||
|
||||
@ObjectType('ChatcoopNonProjectCommunicationRoom')
|
||||
export class ChatcoopNonProjectCommunicationRoomDTO {
|
||||
@Field({ description: 'Идентификатор комнаты Matrix' })
|
||||
matrixRoomId!: string;
|
||||
|
||||
@Field({ description: 'Подпись для отображения комнаты' })
|
||||
displayLabel!: string;
|
||||
|
||||
@Field(() => NonProjectRoomKindGql, { description: 'Тип комнаты (пайщики / совет / секретарь)' })
|
||||
kind!: NonProjectRoomKindGql;
|
||||
}
|
||||
|
||||
@ObjectType('ChatcoopRoomMessageLine')
|
||||
export class ChatcoopRoomMessageLineDTO {
|
||||
@Field(() => Float, { description: 'origin_server_ts из Matrix (мс)' })
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Field, InputType, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
import { IsBoolean, IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Тип комнаты в реестре ChatCoop. Редактировать (удалять) можно только `SECRETARY`;
|
||||
* системные (`MEMBERS`/`COUNCIL`) и проектные (`CAPITAL_PROJECT`) защищены.
|
||||
*/
|
||||
export enum ManagedRoomKindGql {
|
||||
MEMBERS = 'MEMBERS',
|
||||
COUNCIL = 'COUNCIL',
|
||||
CAPITAL_PROJECT = 'CAPITAL_PROJECT',
|
||||
SECRETARY = 'SECRETARY',
|
||||
}
|
||||
|
||||
registerEnumType(ManagedRoomKindGql, {
|
||||
name: 'ManagedRoomKind',
|
||||
description: 'Тип комнаты: пайщики, совет, проект Capital, комната секретаря',
|
||||
});
|
||||
|
||||
@ObjectType('ChatcoopSecretaryRoom')
|
||||
export class ChatcoopSecretaryRoomDTO {
|
||||
@Field({ description: 'Внутренний идентификатор комнаты в реестре (для операций; это НЕ Matrix room id)' })
|
||||
id!: string;
|
||||
|
||||
@Field({ description: 'Название комнаты' })
|
||||
displayLabel!: string;
|
||||
|
||||
@Field(() => ManagedRoomKindGql, { description: 'Тип комнаты' })
|
||||
kind!: ManagedRoomKindGql;
|
||||
|
||||
@Field({ description: 'Комната зашифрована (E2EE) — секретарь не транскрибирует такие комнаты' })
|
||||
encrypted!: boolean;
|
||||
|
||||
@Field({ description: 'Секретарь присутствует в комнате' })
|
||||
secretaryInRoom!: boolean;
|
||||
|
||||
@Field({ description: 'Можно ли удалить комнату из интерфейса (только комнаты секретаря)' })
|
||||
editable!: boolean;
|
||||
}
|
||||
|
||||
@InputType('CreateSecretaryRoomInput')
|
||||
export class CreateSecretaryRoomInputDTO {
|
||||
@Field({ description: 'Название комнаты' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(240)
|
||||
displayName!: string;
|
||||
|
||||
@Field({ description: 'Публичная комната (любой может войти) либо приватная (вход по приглашению создателя)' })
|
||||
@IsBoolean()
|
||||
isPublic!: boolean;
|
||||
}
|
||||
|
||||
@InputType('RemoveSecretaryRoomInput')
|
||||
export class RemoveSecretaryRoomInputDTO {
|
||||
@Field({ description: 'Идентификатор комнаты в реестре, которую нужно удалить' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
id!: string;
|
||||
}
|
||||
@@ -39,9 +39,12 @@ export class CallTranscriptionResponseDTO {
|
||||
@Field()
|
||||
id!: string;
|
||||
|
||||
@Field({ description: 'Внутреннее имя комнаты звонка (LiveKit room name), не Matrix room id' })
|
||||
@Field()
|
||||
roomId!: string;
|
||||
|
||||
@Field()
|
||||
matrixRoomId!: string;
|
||||
|
||||
@Field()
|
||||
roomName!: string;
|
||||
|
||||
|
||||
-32
@@ -11,33 +11,19 @@ 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 {
|
||||
ChatcoopNonProjectCommunicationRoomDTO,
|
||||
ChatcoopProjectCommunicationRoomDTO,
|
||||
ChatcoopRoomMessageLineDTO,
|
||||
GetMaxOriginServerTsForRoomInputDTO,
|
||||
GetProjectCommunicationRoomsInputDTO,
|
||||
GetRoomMessagesForUtcDateInputDTO,
|
||||
ListUtcDatesWithNewRoomMessagesInputDTO,
|
||||
NonProjectRoomKindGql,
|
||||
RoomMessageKindGql,
|
||||
} from '../dto/project-communication.dto';
|
||||
import type { InterNonProjectRoomKind } from '@coopenomics/inter';
|
||||
|
||||
function mapKind(kind: 'text' | 'audio'): RoomMessageKindGql {
|
||||
return kind === 'text' ? RoomMessageKindGql.TEXT : RoomMessageKindGql.AUDIO;
|
||||
}
|
||||
|
||||
function mapNonProjectKind(kind: InterNonProjectRoomKind): NonProjectRoomKindGql {
|
||||
switch (kind) {
|
||||
case 'members':
|
||||
return NonProjectRoomKindGql.MEMBERS;
|
||||
case 'council':
|
||||
return NonProjectRoomKindGql.COUNCIL;
|
||||
case 'secretary':
|
||||
return NonProjectRoomKindGql.SECRETARY;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Доступ к данным переписки Capital↔Matrix для синхронизации (blago-cli, секретарь).
|
||||
* Источник — порт `INTER_PROJECT_COMMUNICATION_ARTIFACTS` (пакет inter).
|
||||
@@ -75,24 +61,6 @@ export class ProjectCommunicationResolver {
|
||||
}));
|
||||
}
|
||||
|
||||
@Query(() => [ChatcoopNonProjectCommunicationRoomDTO], {
|
||||
name: 'chatcoopListNonProjectCommunicationRooms',
|
||||
description: 'Комнаты Matrix вне проектов Capital (пайщики/совет/секретарь) — для синхронизации в blago',
|
||||
})
|
||||
@AuthRoles(['chairman', 'member', 'user'])
|
||||
async listNonProjectCommunicationRooms(
|
||||
@CurrentUser() user: MonoAccountDomainInterface
|
||||
): Promise<ChatcoopNonProjectCommunicationRoomDTO[]> {
|
||||
this.ensureComm();
|
||||
this.logger.debug(`chatcoopListNonProjectCommunicationRooms user=${user.username}`);
|
||||
const rooms = await this.comm!.listNonProjectCommunicationRooms();
|
||||
return rooms.map((r) => ({
|
||||
matrixRoomId: r.matrixRoomId,
|
||||
displayLabel: r.displayLabel,
|
||||
kind: mapNonProjectKind(r.kind),
|
||||
}));
|
||||
}
|
||||
|
||||
@Query(() => [String], {
|
||||
name: 'chatcoopListUtcDatesWithNewRoomMessages',
|
||||
description:
|
||||
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { Logger, UseGuards } from '@nestjs/common';
|
||||
import { ActiveUserStatusGuard } from '~/application/auth/guards/active-user-status.guard';
|
||||
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 { SecretaryRoomManagementService } from '../services/secretary-room-management.service';
|
||||
import {
|
||||
ChatcoopSecretaryRoomDTO,
|
||||
CreateSecretaryRoomInputDTO,
|
||||
ManagedRoomKindGql,
|
||||
RemoveSecretaryRoomInputDTO,
|
||||
} from '../dto/secretary-room.dto';
|
||||
import type { ChatcoopManagedMatrixRoomKind } from '../../domain/entities/managed-matrix-room.entity';
|
||||
import type { ManagedMatrixRoomDomainEntity } from '../../domain/entities/managed-matrix-room.entity';
|
||||
|
||||
function mapManagedKind(kind: ChatcoopManagedMatrixRoomKind): ManagedRoomKindGql {
|
||||
switch (kind) {
|
||||
case 'members':
|
||||
return ManagedRoomKindGql.MEMBERS;
|
||||
case 'council':
|
||||
return ManagedRoomKindGql.COUNCIL;
|
||||
case 'capital_project':
|
||||
return ManagedRoomKindGql.CAPITAL_PROJECT;
|
||||
case 'secretary':
|
||||
return ManagedRoomKindGql.SECRETARY;
|
||||
}
|
||||
}
|
||||
|
||||
function toDto(room: ManagedMatrixRoomDomainEntity): ChatcoopSecretaryRoomDTO {
|
||||
return {
|
||||
id: room.id,
|
||||
displayLabel: room.displayLabel || 'Без названия',
|
||||
kind: mapManagedKind(room.kind),
|
||||
encrypted: room.encrypted,
|
||||
secretaryInRoom: room.secretaryInRoom,
|
||||
editable: room.kind === 'secretary',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Управление комнатами секретаря (стол связи desktop).
|
||||
* Доступ — председатель и члены совета: они создают комнаты для звонков с секретарём и удаляют свои.
|
||||
*/
|
||||
@Resolver()
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard, ActiveUserStatusGuard)
|
||||
export class SecretaryRoomsResolver {
|
||||
private readonly logger = new Logger(SecretaryRoomsResolver.name);
|
||||
|
||||
constructor(private readonly service: SecretaryRoomManagementService) {}
|
||||
|
||||
@Query(() => [ChatcoopSecretaryRoomDTO], {
|
||||
name: 'chatcoopListSecretaryRooms',
|
||||
description: 'Все комнаты реестра ChatCoop (системные/проектные — read-only, комнаты секретаря — удаляемые)',
|
||||
})
|
||||
@AuthRoles(['chairman', 'member'])
|
||||
async listSecretaryRooms(
|
||||
@CurrentUser() user: MonoAccountDomainInterface
|
||||
): Promise<ChatcoopSecretaryRoomDTO[]> {
|
||||
this.logger.debug(`chatcoopListSecretaryRooms user=${user.username}`);
|
||||
const rooms = await this.service.listRooms();
|
||||
return rooms.map(toDto);
|
||||
}
|
||||
|
||||
@Mutation(() => ChatcoopSecretaryRoomDTO, {
|
||||
name: 'chatcoopCreateSecretaryRoom',
|
||||
description: 'Создать комнату с секретарём (публичную или приватную); секретарь подключается сразу',
|
||||
})
|
||||
@AuthRoles(['chairman', 'member'])
|
||||
async createSecretaryRoom(
|
||||
@CurrentUser() user: MonoAccountDomainInterface,
|
||||
@Args('data', { type: () => CreateSecretaryRoomInputDTO }) data: CreateSecretaryRoomInputDTO
|
||||
): Promise<ChatcoopSecretaryRoomDTO> {
|
||||
this.logger.log(`chatcoopCreateSecretaryRoom user=${user.username} isPublic=${data.isPublic}`);
|
||||
const room = await this.service.createSecretaryRoom({
|
||||
creatorUsername: user.username,
|
||||
creatorRole: user.role,
|
||||
displayName: data.displayName,
|
||||
isPublic: data.isPublic,
|
||||
});
|
||||
return toDto(room);
|
||||
}
|
||||
|
||||
@Mutation(() => String, {
|
||||
name: 'chatcoopRemoveSecretaryRoom',
|
||||
description: 'Удалить комнату секретаря: вывести секретаря и снять комнату с синхронизации (возвращает идентификатор комнаты в реестре)',
|
||||
})
|
||||
@AuthRoles(['chairman', 'member'])
|
||||
async removeSecretaryRoom(
|
||||
@CurrentUser() user: MonoAccountDomainInterface,
|
||||
@Args('data', { type: () => RemoveSecretaryRoomInputDTO }) data: RemoveSecretaryRoomInputDTO
|
||||
): Promise<string> {
|
||||
this.logger.log(`chatcoopRemoveSecretaryRoom user=${user.username} room=${data.id}`);
|
||||
return this.service.removeSecretaryRoom(data.id);
|
||||
}
|
||||
}
|
||||
+1
@@ -54,6 +54,7 @@ export class TranscriptionResolver {
|
||||
return {
|
||||
id: domain.id,
|
||||
roomId: domain.roomId,
|
||||
matrixRoomId: domain.matrixRoomId,
|
||||
roomName: domain.roomName,
|
||||
startedAt: domain.startedAt,
|
||||
endedAt: domain.endedAt,
|
||||
|
||||
-42
@@ -818,48 +818,6 @@ export class MatrixApiService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Приглашает пользователя в комнату (от имени Matrix-админа, который состоит в комнате).
|
||||
* Пользователь увидит приглашение и войдёт сам — в отличие от {@link joinRoom} (force-join).
|
||||
*/
|
||||
async inviteUser(userId: string, roomId: string): Promise<void> {
|
||||
try {
|
||||
const adminToken = await this.loginAdmin();
|
||||
await this.httpClient.post(
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/invite`,
|
||||
{ user_id: userId },
|
||||
{ headers: { Authorization: `Bearer ${adminToken}` } }
|
||||
);
|
||||
this.logger.log(`Пользователь ${userId} приглашён в комнату ${roomId}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(
|
||||
`Не удалось пригласить пользователя ${userId} в комнату ${roomId}: ${JSON.stringify(error?.response?.data)}`
|
||||
);
|
||||
throw new Error('Не удалось пригласить пользователя в комнату');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Исключает пользователя из комнаты (от имени Matrix-админа с power 100).
|
||||
* Используется для вывода секретаря из комнаты по требованию председателя/совета.
|
||||
*/
|
||||
async kickUser(userId: string, roomId: string, reason?: string): Promise<void> {
|
||||
try {
|
||||
const adminToken = await this.loginAdmin();
|
||||
await this.httpClient.post(
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/kick`,
|
||||
reason ? { user_id: userId, reason } : { user_id: userId },
|
||||
{ headers: { Authorization: `Bearer ${adminToken}` } }
|
||||
);
|
||||
this.logger.log(`Пользователь ${userId} исключён из комнаты ${roomId}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(
|
||||
`Не удалось исключить пользователя ${userId} из комнаты ${roomId}: ${JSON.stringify(error?.response?.data)}`
|
||||
);
|
||||
throw new Error('Не удалось исключить пользователя из комнаты');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает текущие права пользователей в комнате
|
||||
*/
|
||||
|
||||
-152
@@ -1,152 +0,0 @@
|
||||
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { MatrixApiService } from './matrix-api.service';
|
||||
import { ChatCoopApplicationService } from './chatcoop-application.service';
|
||||
import { MatrixUserManagementService } from '../../domain/services/matrix-user-management.service';
|
||||
import { SECRETARY_ROOM_MATRIX } from '../config/matrix-secretary-room.config';
|
||||
import {
|
||||
CHATCOOP_MANAGED_MATRIX_ROOM_REPOSITORY,
|
||||
type ChatcoopManagedMatrixRoomRepository,
|
||||
} from '../../domain/repositories/managed-matrix-room.repository';
|
||||
import {
|
||||
CHATCOOP_STATE_REPOSITORY,
|
||||
type ChatcoopStateRepository,
|
||||
} from '../../domain/repositories/chatcoop-state.repository';
|
||||
import type { ManagedMatrixRoomDomainEntity } from '../../domain/entities/managed-matrix-room.entity';
|
||||
|
||||
export interface CreateSecretaryRoomInput {
|
||||
/** Логин пайщика-создателя (председатель или член совета) */
|
||||
creatorUsername: string;
|
||||
/** Роль создателя в кооперативе — для прав в комнате (chairman / member) */
|
||||
creatorRole: string;
|
||||
/** Название комнаты */
|
||||
displayName: string;
|
||||
/** true — публичная (любой может войти), false — приватная (вход по приглашению) */
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Управление комнатами секретаря: создание/удаление и список всех комнат реестра.
|
||||
*
|
||||
* Принцип безопасности: секретарь присутствует ТОЛЬКО в комнатах, созданных нашим бэкендом
|
||||
* (kind `secretary`, а также системные members/council и проектные capital_project). Произвольные
|
||||
* «чужие» Matrix-комнаты сюда не подключаются — Synapse общий на все кооперативы, и force-join в
|
||||
* чужую комнату был бы дырой.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SecretaryRoomManagementService {
|
||||
private readonly logger = new Logger(SecretaryRoomManagementService.name);
|
||||
|
||||
constructor(
|
||||
private readonly matrixApi: MatrixApiService,
|
||||
private readonly chatCoopApplicationService: ChatCoopApplicationService,
|
||||
private readonly matrixUserManagement: MatrixUserManagementService,
|
||||
@Inject(CHATCOOP_MANAGED_MATRIX_ROOM_REPOSITORY)
|
||||
private readonly managedRooms: ChatcoopManagedMatrixRoomRepository,
|
||||
@Inject(CHATCOOP_STATE_REPOSITORY)
|
||||
private readonly chatcoopState: ChatcoopStateRepository
|
||||
) {}
|
||||
|
||||
async listRooms(): Promise<ManagedMatrixRoomDomainEntity[]> {
|
||||
return this.managedRooms.findAll();
|
||||
}
|
||||
|
||||
async createSecretaryRoom(input: CreateSecretaryRoomInput): Promise<ManagedMatrixRoomDomainEntity> {
|
||||
const st = await this.chatcoopState.getSingleton();
|
||||
if (!st.isInitialized || !st.spaceId || st.spaceId.trim().length === 0) {
|
||||
throw new BadRequestException('ChatCoop не инициализирован — нельзя создать комнату секретаря');
|
||||
}
|
||||
const secretaryId = st.secretaryMatrixUserId;
|
||||
if (typeof secretaryId !== 'string' || secretaryId.trim().length === 0) {
|
||||
throw new BadRequestException('Секретарь не инициализирован — нельзя создать комнату секретаря');
|
||||
}
|
||||
const displayName = input.displayName.trim();
|
||||
if (displayName.length === 0) {
|
||||
throw new BadRequestException('Название комнаты не может быть пустым');
|
||||
}
|
||||
|
||||
const adminUserId = this.matrixApi.getAdminUserId();
|
||||
const powerLevels = SECRETARY_ROOM_MATRIX.buildPowerLevels(adminUserId);
|
||||
const isPrivate = !input.isPublic;
|
||||
|
||||
const roomId = await this.matrixApi.createRoom(
|
||||
displayName.slice(0, 240),
|
||||
`Комната секретаря — создал ${input.creatorUsername}`,
|
||||
isPrivate,
|
||||
SECRETARY_ROOM_MATRIX.roomType,
|
||||
SECRETARY_ROOM_MATRIX.initialState.length > 0 ? SECRETARY_ROOM_MATRIX.initialState : undefined,
|
||||
// Комната секретаря всегда незашифрована — иначе секретарь не читает сообщения и не транскрибирует.
|
||||
false,
|
||||
powerLevels as Record<string, unknown>
|
||||
);
|
||||
|
||||
await this.matrixApi.addRoomToSpace(st.spaceId.trim(), roomId);
|
||||
|
||||
// Force-join только секретарь.
|
||||
await this.matrixApi.joinRoom(secretaryId.trim(), roomId);
|
||||
|
||||
// Создатель сразу входит в свою комнату с правами модератора.
|
||||
const creatorMatrix = await this.matrixUserManagement.getMatrixUserByCoopUsername(input.creatorUsername);
|
||||
if (creatorMatrix) {
|
||||
try {
|
||||
await this.matrixApi.joinRoom(creatorMatrix.matrixUserId, roomId);
|
||||
await this.chatCoopApplicationService.applyMembersRoomStylePowerForUser(
|
||||
creatorMatrix.matrixUserId,
|
||||
roomId,
|
||||
input.creatorRole
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Не удалось ввести создателя ${input.creatorUsername} в комнату ${roomId}: ${String(err)}`);
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(`Нет Matrix-аккаунта для создателя ${input.creatorUsername} — он войдёт позже сам`);
|
||||
}
|
||||
|
||||
const room = await this.managedRooms.upsertRoom({
|
||||
matrixRoomId: roomId,
|
||||
encrypted: false,
|
||||
kind: 'secretary',
|
||||
displayLabel: displayName,
|
||||
projectHash: null,
|
||||
secretaryInRoom: true,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Создана комната секретаря ${roomId} (${isPrivate ? 'приватная' : 'публичная'}) создателем ${input.creatorUsername}`
|
||||
);
|
||||
return room;
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет комнату секретаря: выводит секретаря из Matrix-комнаты и убирает запись из реестра ChatCoop.
|
||||
* Сама Matrix-комната не уничтожается — её участники сохраняют доступ и историю, но транскрипция/синхронизация прекращаются.
|
||||
* Разрешено только для kind `secretary` — системные и проектные комнаты так удалять нельзя.
|
||||
*
|
||||
* На вход — внутренний идентификатор реестра (`id`), а НЕ Matrix room id: интерфейс не оперирует
|
||||
* Matrix room id, чтобы не раздавать handle для входа в приватные комнаты.
|
||||
*/
|
||||
async removeSecretaryRoom(id: string): Promise<string> {
|
||||
const room = await this.managedRooms.findById(id);
|
||||
if (!room) {
|
||||
throw new NotFoundException('Комната не найдена в реестре ChatCoop');
|
||||
}
|
||||
if (room.kind !== 'secretary') {
|
||||
throw new BadRequestException('Удалять можно только комнаты секретаря (системные и проектные защищены)');
|
||||
}
|
||||
|
||||
const matrixRoomId = room.matrixRoomId;
|
||||
const st = await this.chatcoopState.getSingleton();
|
||||
const secretaryId = st.secretaryMatrixUserId;
|
||||
if (typeof secretaryId === 'string' && secretaryId.trim().length > 0) {
|
||||
try {
|
||||
await this.matrixApi.kickUser(secretaryId.trim(), matrixRoomId, 'Комната секретаря удалена');
|
||||
} catch (err) {
|
||||
this.logger.warn(`Не удалось вывести секретаря из ${matrixRoomId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.managedRooms.setSecretaryInRoom(matrixRoomId, false);
|
||||
await this.managedRooms.deleteByMatrixRoomId(matrixRoomId);
|
||||
this.logger.log(`Комната секретаря ${id} удалена из реестра`);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,6 @@ import { ChatCoopCalendarResolver } from './application/resolvers/chatcoop-calen
|
||||
import { ChatCoopCalendarFeedController } from './application/controllers/chatcoop-calendar-feed.controller';
|
||||
import { TranscriptionResolver } from './application/resolvers/transcription.resolver';
|
||||
import { ProjectCommunicationResolver } from './application/resolvers/project-communication.resolver';
|
||||
import { SecretaryRoomsResolver } from './application/resolvers/secretary-rooms.resolver';
|
||||
import { SecretaryRoomManagementService } from './application/services/secretary-room-management.service';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { z } from 'zod';
|
||||
@@ -529,7 +527,6 @@ export class ChatCoopPlugin extends BaseExtModule {
|
||||
// Application Services
|
||||
ChatCoopApplicationService,
|
||||
CapitalProjectMatrixSyncService,
|
||||
SecretaryRoomManagementService,
|
||||
MatrixApiService,
|
||||
ChatCoopSecretaryMatrixTokenService,
|
||||
SecretaryAgentService,
|
||||
@@ -607,7 +604,6 @@ export class ChatCoopPlugin extends BaseExtModule {
|
||||
TranscriptionResolver,
|
||||
ActiveUserStatusGuard,
|
||||
ProjectCommunicationResolver,
|
||||
SecretaryRoomsResolver,
|
||||
],
|
||||
exports: [
|
||||
ChatCoopPlugin,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user