Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de55c3aa3e | |||
| 32fc211006 | |||
| 24663bdaac | |||
| c3a433b591 | |||
| ca877ab294 | |||
| 4d6a03a142 | |||
| c185b0167f | |||
| 07f35ea084 | |||
| 4a13cd26c0 | |||
| 59a8b9b6ee | |||
| eb720a88ef | |||
| 58b8654989 | |||
| 138d7ab350 | |||
| b2adb56535 | |||
| 1f486ce48a | |||
| 7ca2695121 | |||
| bbfc592357 | |||
| 18646f8353 | |||
| e932260847 | |||
| b42394bfb4 | |||
| ff8f60e4b2 | |||
| abcd34f23d | |||
| 83cac58fca | |||
| dbf46d88d0 | |||
| a0cb930b77 | |||
| 4a4145c4ed | |||
| 00860b3b18 | |||
| 8fe9509e97 | |||
| 848566bd69 | |||
| aeb4efe2b6 | |||
| 7ef138ba19 |
@@ -27,6 +27,12 @@ PG_HOST_PORT=5532
|
||||
COOPBACK_HOST_PORT=2998
|
||||
DESKTOP_HOST_PORT=2999
|
||||
OPENSEARCH_HOST_PORT=9200
|
||||
MINIO_HOST_PORT=9000
|
||||
MINIO_CONSOLE_HOST_PORT=9001
|
||||
|
||||
# MinIO root credentials (только для локального dev; на prod подменяются плейбуком).
|
||||
MINIO_ROOT_USER=minioadmin
|
||||
MINIO_ROOT_PASSWORD=minioadmin
|
||||
|
||||
# URL для скриптов и кода, запускаемого с хоста (boot, networks.sh,
|
||||
# preactivate.sh, health.ts). Должны соответствовать host-портам выше.
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
name: Build Docker Images
|
||||
|
||||
# Триггерится по успешному завершению `Build contracts container`. Это
|
||||
# гарантирует, что к моменту webhook'а на тестнет/прод образ
|
||||
# `dicoop/contracts:<branch>` уже обновлён в DockerHub.
|
||||
#
|
||||
# Раньше workflow висел на `push: tags`, и при релизе (`chore(release):
|
||||
# publish` + git tag) запускался параллельно с build-contracts. Поскольку
|
||||
# build-containers (~8 мин) короче build-contracts (~11 мин), webhook
|
||||
# отлетал раньше окончания build-contracts на 2-3 минуты — ансибл на
|
||||
# тестнете подтягивал ПРЕДЫДУЩИЙ образ контрактов и перетирал чейн
|
||||
# старым wasm. Инцидент 2026-05-13 (ledger2 фикс walletop sweep'а
|
||||
# откатился ансиблом).
|
||||
#
|
||||
# Теперь: build-contracts триггерится и по push веток, и по push тэгов
|
||||
# (см. tags в build-contracts.yaml). После его success этот workflow
|
||||
# резолвит — есть ли тэг на коммите, и если есть — собирает контейнеры
|
||||
# и шлёт webhook. Если тэга нет (обычный push в ветку без релиза) —
|
||||
# делаем no-op.
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build contracts container"]
|
||||
types: [completed]
|
||||
# Ручной fallback: запустить сборку контейнеров и деплой для конкретного
|
||||
# тэга, если автоматический workflow_run не сработал (типичный кейс —
|
||||
# миграционное окно: на default-ветке (main) старая версия build-containers
|
||||
# с `push: tags`, а на dev/testnet уже новая с `workflow_run`. До мержа
|
||||
# в main новая логика не активна).
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Git-тэг для сборки и деплоя (например v2026.5.13-alpha-3)'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
# Job запускается, если: (а) workflow_run от build-contracts успешен,
|
||||
# либо (б) ручной запуск через workflow_dispatch.
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: Checkout repository at triggering SHA
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve tag for triggering commit
|
||||
id: resolve_tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
TAG_NAME="${{ github.event.inputs.tag }}"
|
||||
HEAD_SHA=$(git rev-parse "$TAG_NAME^{commit}")
|
||||
echo "Ручной запуск: tag=$TAG_NAME sha=$HEAD_SHA"
|
||||
else
|
||||
HEAD_SHA="${{ env.HEAD_SHA }}"
|
||||
# `git describe --exact-match` отдаёт имя тэга, лежащего ровно
|
||||
# на этом коммите; код 128 если тэга нет — тогда выходим без
|
||||
# сборки контейнеров (push в ветку без релизного тэга).
|
||||
TAG_NAME=$(git describe --tags --exact-match "$HEAD_SHA" 2>/dev/null || true)
|
||||
if [ -z "$TAG_NAME" ]; then
|
||||
echo "На коммите $HEAD_SHA тэга нет — релиза не было, контейнеры не собираем."
|
||||
echo "has_tag=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo "Найден тэг: $TAG_NAME"
|
||||
echo "has_tag=true" >> $GITHUB_OUTPUT
|
||||
echo "DOCKER_TAG=$TAG_NAME" >> $GITHUB_ENV
|
||||
echo "HEAD_SHA=$HEAD_SHA" >> $GITHUB_ENV
|
||||
|
||||
# Проверяем, является ли тег продакшн-тегом (не содержит alpha, beta, rc и т.д.)
|
||||
if [[ ! $TAG_NAME =~ -(alpha|beta|rc|test) ]]; then
|
||||
echo "IS_PRODUCTION_TAG=true" >> $GITHUB_ENV
|
||||
echo "Это продакшн тег, будем добавлять latest"
|
||||
else
|
||||
echo "IS_PRODUCTION_TAG=false" >> $GITHUB_ENV
|
||||
echo "Это не продакшн тег, latest не добавляем"
|
||||
fi
|
||||
|
||||
- name: Debug info
|
||||
if: steps.resolve_tag.outputs.has_tag == 'true'
|
||||
run: |
|
||||
echo "Триггер: ${{ github.event_name }}"
|
||||
echo "Триггерящий коммит: ${{ env.HEAD_SHA }}"
|
||||
echo "Резолвенный тэг: ${{ env.DOCKER_TAG }}"
|
||||
echo "Последние коммиты:"
|
||||
git log -n 3 --oneline
|
||||
echo "Проверяем файлы в директории components/desktop/src-ssr:"
|
||||
ls -la components/desktop/src-ssr/ || echo "Директория не найдена!"
|
||||
echo "Проверяем файлы в middlewares:"
|
||||
ls -la components/desktop/src-ssr/middlewares/ || echo "Директория middlewares не найдена!"
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: steps.resolve_tag.outputs.has_tag == 'true'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Сначала собираем базовый образ с runtime
|
||||
- name: Build base image
|
||||
if: steps.resolve_tag.outputs.has_tag == 'true'
|
||||
run: |
|
||||
docker build --target runtime -t dicoop/mono-base:${{ env.DOCKER_TAG }} .
|
||||
docker push dicoop/mono-base:${{ env.DOCKER_TAG }}
|
||||
|
||||
# Если это продакшн тег, добавляем latest
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/mono-base:${{ env.DOCKER_TAG }} dicoop/mono-base:latest
|
||||
docker push dicoop/mono-base:latest
|
||||
fi
|
||||
|
||||
# Создаем сервисные образы на основе базового
|
||||
- name: Build desktop image
|
||||
if: steps.resolve_tag.outputs.has_tag == 'true'
|
||||
run: |
|
||||
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.desktop
|
||||
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/desktop\", \"run\", \"start\"]" >> Dockerfile.desktop
|
||||
docker build -t dicoop/desktop:${{ env.DOCKER_TAG }} -f Dockerfile.desktop .
|
||||
docker push dicoop/desktop:${{ env.DOCKER_TAG }}
|
||||
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/desktop:${{ env.DOCKER_TAG }} dicoop/desktop:latest
|
||||
docker push dicoop/desktop:latest
|
||||
fi
|
||||
|
||||
- name: Build controller image
|
||||
if: steps.resolve_tag.outputs.has_tag == 'true'
|
||||
run: |
|
||||
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.coopback
|
||||
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/controller\", \"run\", \"start\"]" >> Dockerfile.coopback
|
||||
docker build -t dicoop/coopback:${{ env.DOCKER_TAG }} -f Dockerfile.coopback .
|
||||
docker push dicoop/coopback:${{ env.DOCKER_TAG }}
|
||||
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/coopback:${{ env.DOCKER_TAG }} dicoop/coopback:latest
|
||||
docker push dicoop/coopback:latest
|
||||
fi
|
||||
|
||||
# TEMP: парсер используется существующим деплоем как dicoop/cooparser,
|
||||
# пока не мигрировали на dicoop/parser из отдельного coopenomics/parser repo.
|
||||
# Оставляем сборку и пуш образа до завершения миграции потребителей.
|
||||
- name: Build parser image
|
||||
if: steps.resolve_tag.outputs.has_tag == 'true'
|
||||
run: |
|
||||
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.cooparser
|
||||
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/parser\", \"run\", \"start\"]" >> Dockerfile.cooparser
|
||||
docker build -t dicoop/cooparser:${{ env.DOCKER_TAG }} -f Dockerfile.cooparser .
|
||||
docker push dicoop/cooparser:${{ env.DOCKER_TAG }}
|
||||
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/cooparser:${{ env.DOCKER_TAG }} dicoop/cooparser:latest
|
||||
docker push dicoop/cooparser:latest
|
||||
fi
|
||||
|
||||
- name: Build notificator image
|
||||
if: steps.resolve_tag.outputs.has_tag == 'true'
|
||||
run: |
|
||||
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.notificator
|
||||
echo "CMD [\"pnpm\", \"-F\", \"coop-notificator\", \"run\", \"start\"]" >> Dockerfile.notificator
|
||||
docker build -t dicoop/notificator:${{ env.DOCKER_TAG }} -f Dockerfile.notificator .
|
||||
docker push dicoop/notificator:${{ env.DOCKER_TAG }}
|
||||
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/notificator:${{ env.DOCKER_TAG }} dicoop/notificator:latest
|
||||
docker push dicoop/notificator:latest
|
||||
fi
|
||||
|
||||
- name: Build notifications image
|
||||
if: steps.resolve_tag.outputs.has_tag == 'true'
|
||||
run: |
|
||||
echo "FROM dicoop/mono-base:${{ env.DOCKER_TAG }}" > Dockerfile.notifications
|
||||
echo "CMD [\"pnpm\", \"-F\", \"@coopenomics/notifications\", \"run\", \"sync\"]" >> Dockerfile.notifications
|
||||
docker build -t dicoop/notifications:${{ env.DOCKER_TAG }} -f Dockerfile.notifications .
|
||||
docker push dicoop/notifications:${{ env.DOCKER_TAG }}
|
||||
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
docker tag dicoop/notifications:${{ env.DOCKER_TAG }} dicoop/notifications:latest
|
||||
docker push dicoop/notifications:latest
|
||||
fi
|
||||
|
||||
# Отправка хука для деплоя. К этому моменту build-contracts.yaml
|
||||
# уже завершился успешно (мы и есть его workflow_run-зависимый
|
||||
# потребитель), значит образ `dicoop/contracts:<branch>` гарантированно
|
||||
# обновлён в DockerHub — гонки 2026-05-13 больше не воспроизвести.
|
||||
- name: Trigger deployment webhook
|
||||
if: success() && steps.resolve_tag.outputs.has_tag == 'true'
|
||||
run: |
|
||||
if [[ "${{ env.DOCKER_TAG }}" == *alpha* ]]; then
|
||||
# Хук для тестнета (alpha теги)
|
||||
curl -X POST ${{ vars.TESTNET_WEBHOOK_URL }} \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '${{ env.DOCKER_TAG }}'
|
||||
else
|
||||
# Хук для продакшена (остальные теги)
|
||||
curl -X POST ${{ vars.PRODUCTION_WEBHOOK_URL }} \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '${{ env.DOCKER_TAG }}'
|
||||
fi
|
||||
|
||||
# Уведомление в Telegram об успехе
|
||||
- name: Telegram notify success
|
||||
if: success() && steps.resolve_tag.outputs.has_tag == 'true'
|
||||
run: |
|
||||
if [[ "${{ env.IS_PRODUCTION_TAG }}" == "true" ]]; then
|
||||
ADDITIONAL_INFO=" (с тегом latest)"
|
||||
else
|
||||
ADDITIONAL_INFO=""
|
||||
fi
|
||||
|
||||
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
|
||||
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
|
||||
-d text="✅ [GITHUB MONO] Успешная сборка контейнеров: $GITHUB_REPOSITORY (sha=${{ env.HEAD_SHA }}) [${{ env.DOCKER_TAG }}]$ADDITIONAL_INFO"
|
||||
|
||||
# Уведомление в Telegram об ошибке (только если до этого был
|
||||
# резолвен тэг — иначе no-op запуск без сборки и без алертов).
|
||||
- name: Telegram notify failure
|
||||
if: failure() && steps.resolve_tag.outputs.has_tag == 'true'
|
||||
run: |
|
||||
curl -s -X POST https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage \
|
||||
-d chat_id=${{ secrets.TELEGRAM_CHAT_ID }} \
|
||||
-d text="❌ [GITHUB MONO] Ошибка при сборке контейнеров: $GITHUB_REPOSITORY (sha=${{ env.HEAD_SHA }}) [${{ env.DOCKER_TAG }}]"
|
||||
@@ -1,18 +1,48 @@
|
||||
# .github/workflows/trigger-coopenomics.yml
|
||||
name: Trigger Contracts Docs Deploy
|
||||
|
||||
# Триггерит rebuild docs в coopenomics/coopenomics только по продакшн-
|
||||
# тэгам на main. Раньше дёргался на каждый push в dev/testnet/main/capital,
|
||||
# теперь — только формальный релиз (без -alpha/-beta/-rc/-test).
|
||||
on:
|
||||
push:
|
||||
branches: [dev, testnet, main, capital] # или когда нужно триггерить
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_trigger: ${{ steps.check.outputs.should_trigger }}
|
||||
steps:
|
||||
- name: Checkout (для git branch --contains)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Gate — main + non-alpha
|
||||
id: check
|
||||
run: |
|
||||
TAG="${{ github.ref_name }}"
|
||||
if [[ "$TAG" =~ -(alpha|beta|rc|test) ]]; then
|
||||
echo "Тэг $TAG — pre-release, docs deploy не дёргаем"
|
||||
echo "should_trigger=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if ! git branch -r --contains "${{ github.sha }}" | grep -qE "^[[:space:]]*origin/main$"; then
|
||||
echo "Тэг $TAG не на main — docs deploy не дёргаем"
|
||||
echo "should_trigger=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "Тэг $TAG — продакшн релиз на main, дёргаем coopenomics"
|
||||
echo "should_trigger=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
trigger-coopenomics:
|
||||
needs: gate
|
||||
if: needs.gate.outputs.should_trigger == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Coopenomics deployment
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.COOPENOMICS_PAT }}
|
||||
repository: coopenomics/coopenomics # укажи правильный owner/repo
|
||||
repository: coopenomics/coopenomics
|
||||
event-type: deploy_from_mono
|
||||
client-payload: '{"repository": "${{ github.repository }}", "sha": "${{ github.sha }}", "ref": "${{ github.ref }}", "actor": "${{ github.actor }}"}'
|
||||
|
||||
@@ -7,12 +7,18 @@ name: Build contracts container
|
||||
# ke-bootstrap'ом (отдельный артефакт), который при старте ноды деплоит
|
||||
# контракты через `cleos set contract`.
|
||||
#
|
||||
# Триггер — push в одну из веток-окружений (dev / testnet / main).
|
||||
# Маппинг:
|
||||
# Триггер — push в одну из веток-окружений (dev / testnet / main) с
|
||||
# изменением .cpp/CMakeLists/build-скриптов. Маппинг:
|
||||
# dev → IS_TESTNET=ON, tag=dev
|
||||
# testnet → IS_TESTNET=ON, tag=testnet
|
||||
# main → IS_TESTNET=OFF, tag=main + latest
|
||||
#
|
||||
# ВАЖНО: тэги `v*` тут НЕ обрабатываются — они уезжают в `release.yaml`,
|
||||
# который делает релиз атомарно (контракты + контейнеры + webhook в
|
||||
# одном job'е). См. release.yaml для контекста — там в комментарии
|
||||
# описана причина (гонка build-contracts и build-containers по push'у
|
||||
# тэга, инцидент 2026-05-13 с ledger2 walletop sweep'ом).
|
||||
#
|
||||
# Сам compile идёт через CDT-образ `dicoop/blockchain_v5.1.1:dev`
|
||||
# (тот же, что использует `components/contracts/build.sh|build-all.sh`).
|
||||
# Артефакты копируются в slim alpine-образ.
|
||||
@@ -22,24 +28,12 @@ name: Build contracts container
|
||||
# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID — уведомления
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, testnet, main]
|
||||
paths:
|
||||
- 'components/contracts/cpp/**'
|
||||
- 'components/contracts/CMakeLists.txt'
|
||||
- 'components/contracts/build.sh'
|
||||
- 'components/contracts/build-all.sh'
|
||||
- 'components/contracts/docker/**'
|
||||
- '.github/workflows/build-contracts.yaml'
|
||||
# Дополнительно ловим все push'и тэгов: build-containers.yaml зависит от
|
||||
# успеха этой сборки через workflow_run, и без unconditional запуска по
|
||||
# тегу build-containers не получит триггер для коммитов, где .cpp не
|
||||
# менялся (например `chore(release): publish` правит только версии
|
||||
# пакетов). См. историю гонки 2026-05-13: webhook деплоя сработал на
|
||||
# 2:34 быстрее обновления `dicoop/contracts:testnet`, на чейн уехал
|
||||
# старый wasm.
|
||||
tags:
|
||||
- '*'
|
||||
# Только ручной запуск. Тэги (релизы) обрабатывает release.yaml,
|
||||
# который собирает контракты атомарно вместе с контейнерами и
|
||||
# webhook'ом — отдельная сборка по push'у в ветку только мешает
|
||||
# (две параллельные сборки на каждый release-bump). Здесь оставляем
|
||||
# workflow_dispatch для ручной пересборки `dicoop/contracts:<branch>`
|
||||
# (например, чтобы прокатить wasm на dev-ноду без релизного тэга).
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -48,55 +42,30 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Полная история нужна, чтобы при push'е тэга резолвить ветку
|
||||
# через `git branch -r --contains $SHA` (см. следующий step).
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Determine build mode and docker tag
|
||||
# Источник режима — ВЕТКА, не имя тэга. Сначала пытаемся взять
|
||||
# `github.ref_name` как ветку (push веток); если не подошло — это
|
||||
# push тэга, и мы ищем какая из dev/testnet/main содержит коммит.
|
||||
# Тэг такого окружения нести семантику не должен (например,
|
||||
# `v2026.5.13-alpha-2` → ветка testnet, BUILD_MODE=test).
|
||||
run: |
|
||||
REF="${{ github.ref_name }}"
|
||||
REF_TYPE="${{ github.ref_type }}"
|
||||
BRANCH=""
|
||||
if [ "$REF_TYPE" = "branch" ]; then
|
||||
BRANCH="$REF"
|
||||
else
|
||||
SHA="${{ github.sha }}"
|
||||
for candidate in main testnet dev; do
|
||||
if git branch -r --contains "$SHA" 2>/dev/null | grep -qE "^[[:space:]]*origin/${candidate}$"; then
|
||||
BRANCH="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
case "$BRANCH" in
|
||||
case "${{ github.ref_name }}" in
|
||||
main)
|
||||
echo "BUILD_MODE=prod" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=main" >> $GITHUB_ENV
|
||||
echo "EXTRA_TAG=latest" >> $GITHUB_ENV
|
||||
echo "BUILD_MODE=prod" >> "$GITHUB_ENV"
|
||||
echo "DOCKER_TAG=main" >> "$GITHUB_ENV"
|
||||
echo "EXTRA_TAG=latest" >> "$GITHUB_ENV"
|
||||
;;
|
||||
testnet)
|
||||
echo "BUILD_MODE=test" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=testnet" >> $GITHUB_ENV
|
||||
echo "EXTRA_TAG=" >> $GITHUB_ENV
|
||||
echo "BUILD_MODE=test" >> "$GITHUB_ENV"
|
||||
echo "DOCKER_TAG=testnet" >> "$GITHUB_ENV"
|
||||
echo "EXTRA_TAG=" >> "$GITHUB_ENV"
|
||||
;;
|
||||
dev)
|
||||
echo "BUILD_MODE=test" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=dev" >> $GITHUB_ENV
|
||||
echo "EXTRA_TAG=" >> $GITHUB_ENV
|
||||
echo "BUILD_MODE=test" >> "$GITHUB_ENV"
|
||||
echo "DOCKER_TAG=dev" >> "$GITHUB_ENV"
|
||||
echo "EXTRA_TAG=" >> "$GITHUB_ENV"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported ref: ref_name=$REF ref_type=$REF_TYPE resolved_branch='$BRANCH'" >&2
|
||||
echo "Unsupported branch ${{ github.ref_name }}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "Resolved BRANCH=$BRANCH (ref_name=$REF ref_type=$REF_TYPE)"
|
||||
|
||||
- name: Pull CDT toolchain image
|
||||
run: docker pull dicoop/blockchain_v5.1.1:dev
|
||||
|
||||
@@ -1,16 +1,42 @@
|
||||
name: Publish Docs
|
||||
|
||||
# Публикация только по продакшн-тэгам, лежащим на main (без -alpha/-beta/-rc/-test).
|
||||
# Раньше триггерилось на каждый push в main/testnet/dev/reports/marketplace2 →
|
||||
# docs пересобирались по 5+ раз в день впустую. Теперь — только релиз.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- testnet
|
||||
- dev
|
||||
- reports
|
||||
- marketplace2
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_publish: ${{ steps.check.outputs.should_publish }}
|
||||
steps:
|
||||
- name: Checkout (для git branch --contains)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Gate — main + non-alpha
|
||||
id: check
|
||||
run: |
|
||||
TAG="${{ github.ref_name }}"
|
||||
if [[ "$TAG" =~ -(alpha|beta|rc|test) ]]; then
|
||||
echo "Тэг $TAG — pre-release, docs не публикуем"
|
||||
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if ! git branch -r --contains "${{ github.sha }}" | grep -qE "^[[:space:]]*origin/main$"; then
|
||||
echo "Тэг $TAG не на main — docs не публикуем"
|
||||
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "Тэг $TAG — продакшн релиз на main, публикуем docs"
|
||||
echo "should_publish=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build-and-publish-docs:
|
||||
needs: gate
|
||||
if: needs.gate.outputs.should_publish == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
name: Release
|
||||
|
||||
# Атомарный релизный workflow. Триггерится исключительно push'ем тэга
|
||||
# `v*`. В одном job'е по порядку:
|
||||
# 1) собирает контракты под mode (prod/test) и пушит `dicoop/contracts:<branch>`,
|
||||
# 2) собирает базовый образ + сервисные контейнеры и пушит `dicoop/<svc>:<tag>`,
|
||||
# 3) шлёт webhook деплоя (testnet/production по типу тэга).
|
||||
#
|
||||
# Зачем атомарно: раньше `build-contracts` и `build-containers`
|
||||
# крутились параллельно по push'у тэга, и webhook от build-containers
|
||||
# (~8 мин) обгонял окончание build-contracts (~11 мин) → ансибл на
|
||||
# тестнете подхватывал ПРЕДЫДУЩИЙ образ контрактов и перетирал чейн
|
||||
# старым wasm. Попытка связать их через workflow_run упёрлась в
|
||||
# default-branch caveat и потерю head_sha в YAML-выражениях. Теперь
|
||||
# нет двух workflow'ов — нет гонки, нет fallback'ов.
|
||||
#
|
||||
# Триггер по веткам (dev/testnet/main без тэга) тут НЕ обрабатывается —
|
||||
# для CI-обновления `dicoop/contracts:<branch>` без релиза остаётся
|
||||
# `build-contracts.yaml`.
|
||||
#
|
||||
# Резолв ветки. Тэг семантики окружения не несёт (например
|
||||
# `v2026.5.13-alpha-2` может лежать на testnet или dev — определяет
|
||||
# именно ветка, на которую сделан merge перед тэгом). Поэтому ищем
|
||||
# первое совпадение SHA с remote-ветками main/testnet/dev.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve branch, build mode and tags
|
||||
run: |
|
||||
TAG_NAME="${{ github.ref_name }}"
|
||||
SHA="${{ github.sha }}"
|
||||
BRANCH=""
|
||||
for candidate in main testnet dev; do
|
||||
if git branch -r --contains "$SHA" 2>/dev/null | grep -qE "^[[:space:]]*origin/${candidate}$"; then
|
||||
BRANCH="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$BRANCH" ]; then
|
||||
echo "::error::Тэг $TAG_NAME ($SHA) не лежит ни на одной из dev/testnet/main"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$BRANCH" in
|
||||
main)
|
||||
BUILD_MODE=prod
|
||||
CONTRACTS_TAG=main
|
||||
CONTRACTS_EXTRA=latest
|
||||
;;
|
||||
testnet)
|
||||
BUILD_MODE=test
|
||||
CONTRACTS_TAG=testnet
|
||||
CONTRACTS_EXTRA=
|
||||
;;
|
||||
dev)
|
||||
BUILD_MODE=test
|
||||
CONTRACTS_TAG=dev
|
||||
CONTRACTS_EXTRA=
|
||||
;;
|
||||
esac
|
||||
|
||||
# Прод-тэги (без -alpha/-beta/-rc/-test) уезжают на боевой
|
||||
# webhook + получают тэг :latest у сервисных образов.
|
||||
if [[ "$TAG_NAME" =~ -(alpha|beta|rc|test) ]]; then
|
||||
IS_PROD=false
|
||||
WEBHOOK_URL='${{ vars.TESTNET_WEBHOOK_URL }}'
|
||||
else
|
||||
IS_PROD=true
|
||||
WEBHOOK_URL='${{ vars.PRODUCTION_WEBHOOK_URL }}'
|
||||
fi
|
||||
|
||||
{
|
||||
echo "TAG_NAME=$TAG_NAME"
|
||||
echo "SHA=$SHA"
|
||||
echo "BRANCH=$BRANCH"
|
||||
echo "BUILD_MODE=$BUILD_MODE"
|
||||
echo "CONTRACTS_TAG=$CONTRACTS_TAG"
|
||||
echo "CONTRACTS_EXTRA=$CONTRACTS_EXTRA"
|
||||
echo "IS_PROD=$IS_PROD"
|
||||
echo "WEBHOOK_URL=$WEBHOOK_URL"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
echo "Release $TAG_NAME → branch=$BRANCH, build_mode=$BUILD_MODE, contracts:$CONTRACTS_TAG, prod=$IS_PROD"
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# === Этап 1: контракты ===
|
||||
|
||||
- name: Pull CDT toolchain image
|
||||
run: docker pull dicoop/blockchain_v5.1.1:dev
|
||||
|
||||
- name: Compile contracts
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
./build-all.sh "$BUILD_MODE"
|
||||
echo "--- build/contracts ---"
|
||||
ls -la build/contracts/
|
||||
|
||||
- name: Stage contracts docker context
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
ROOT="docker/.context"
|
||||
rm -rf "$ROOT"
|
||||
mkdir -p "$ROOT/contracts"
|
||||
|
||||
# Имена контрактов = аргументы add_contract_build(...) только в
|
||||
# незакомменченных строках (CMake-комментарий начинается с `#`).
|
||||
NAMES=$(grep -E '^[[:space:]]*add_contract_build\(' CMakeLists.txt \
|
||||
| sed -E 's/^[[:space:]]*add_contract_build\(([^)]*)\).*/\1/')
|
||||
echo "Contracts to package:"
|
||||
echo "$NAMES"
|
||||
|
||||
MANIFEST="$ROOT/contracts/manifest.json"
|
||||
|
||||
pack_one() {
|
||||
local NAME="$1" WASM="$2" ABI="$3"
|
||||
if [ ! -f "$WASM" ] || [ ! -f "$ABI" ]; then
|
||||
echo "::warning::Skipping $NAME: artifacts missing ($WASM / $ABI)" >&2
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$ROOT/contracts/$NAME"
|
||||
cp "$WASM" "$ROOT/contracts/$NAME/$NAME.wasm"
|
||||
cp "$ABI" "$ROOT/contracts/$NAME/$NAME.abi"
|
||||
local SHA_WASM=$(sha256sum "$WASM" | awk '{print $1}')
|
||||
local SHA_ABI=$(sha256sum "$ABI" | awk '{print $1}')
|
||||
printf ' {"name":"%s","sha256_wasm":"%s","sha256_abi":"%s"}' \
|
||||
"$NAME" "$SHA_WASM" "$SHA_ABI"
|
||||
}
|
||||
|
||||
{
|
||||
printf '{\n'
|
||||
printf ' "build_mode": "%s",\n' "$BUILD_MODE"
|
||||
printf ' "git_sha": "%s",\n' "$SHA"
|
||||
printf ' "tag": "%s",\n' "$TAG_NAME"
|
||||
printf ' "branch": "%s",\n' "$BRANCH"
|
||||
printf ' "built_at": "%s",\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
printf ' "contracts": [\n'
|
||||
|
||||
FIRST=1
|
||||
emit() {
|
||||
entry=$(pack_one "$@") || return 0
|
||||
[ $FIRST -eq 0 ] && printf ',\n'
|
||||
FIRST=0
|
||||
printf '%s' "$entry"
|
||||
}
|
||||
|
||||
for c in $NAMES; do
|
||||
if [ "$c" = "system" ]; then
|
||||
# У system многоконтрактная сборка: разносим под-контракты
|
||||
# eosio.* как полноценные элементы каталога, сам "system"
|
||||
# как имя пропускаем.
|
||||
for sub in build/contracts/system/contracts/eosio.*/; do
|
||||
[ -d "$sub" ] || continue
|
||||
sub_name=$(basename "$sub")
|
||||
emit "$sub_name" "$sub/$sub_name.wasm" "$sub/$sub_name.abi"
|
||||
done
|
||||
else
|
||||
emit "$c" "build/contracts/$c/$c.wasm" "build/contracts/$c/$c.abi"
|
||||
fi
|
||||
done
|
||||
|
||||
printf '\n ]\n'
|
||||
printf '}\n'
|
||||
} > "$MANIFEST"
|
||||
|
||||
cp docker/Dockerfile "$ROOT/Dockerfile"
|
||||
cp docker/entrypoint.sh "$ROOT/entrypoint.sh"
|
||||
chmod +x "$ROOT/entrypoint.sh"
|
||||
|
||||
echo "--- manifest.json ---"
|
||||
cat "$MANIFEST"
|
||||
|
||||
- name: Build and push contracts image
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
IMAGE="dicoop/contracts"
|
||||
SHORT_SHA="${SHA::7}"
|
||||
docker build \
|
||||
--label "org.opencontainers.image.revision=$SHA" \
|
||||
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--label "build.mode=$BUILD_MODE" \
|
||||
-t "$IMAGE:$CONTRACTS_TAG" \
|
||||
./docker/.context
|
||||
docker push "$IMAGE:$CONTRACTS_TAG"
|
||||
|
||||
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
|
||||
docker push "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
|
||||
|
||||
if [ -n "$CONTRACTS_EXTRA" ]; then
|
||||
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_EXTRA"
|
||||
docker push "$IMAGE:$CONTRACTS_EXTRA"
|
||||
fi
|
||||
|
||||
- name: Verify pushed contracts image
|
||||
run: |
|
||||
docker run --rm "dicoop/contracts:$CONTRACTS_TAG" list
|
||||
echo "---"
|
||||
docker run --rm "dicoop/contracts:$CONTRACTS_TAG" sha256
|
||||
|
||||
# === Этап 2: контейнеры приложений ===
|
||||
|
||||
- name: Build and push base image
|
||||
run: |
|
||||
docker build --target runtime -t "dicoop/mono-base:$TAG_NAME" .
|
||||
docker push "dicoop/mono-base:$TAG_NAME"
|
||||
if [ "$IS_PROD" = "true" ]; then
|
||||
docker tag "dicoop/mono-base:$TAG_NAME" dicoop/mono-base:latest
|
||||
docker push dicoop/mono-base:latest
|
||||
fi
|
||||
|
||||
- name: Build and push service images
|
||||
run: |
|
||||
build_service() {
|
||||
local SVC="$1" PKG="$2" CMD="$3"
|
||||
local DOCKERFILE="Dockerfile.$SVC"
|
||||
{
|
||||
echo "FROM dicoop/mono-base:$TAG_NAME"
|
||||
echo "CMD [\"pnpm\", \"-F\", \"$PKG\", \"run\", \"$CMD\"]"
|
||||
} > "$DOCKERFILE"
|
||||
docker build -t "dicoop/$SVC:$TAG_NAME" -f "$DOCKERFILE" .
|
||||
docker push "dicoop/$SVC:$TAG_NAME"
|
||||
if [ "$IS_PROD" = "true" ]; then
|
||||
docker tag "dicoop/$SVC:$TAG_NAME" "dicoop/$SVC:latest"
|
||||
docker push "dicoop/$SVC:latest"
|
||||
fi
|
||||
}
|
||||
|
||||
build_service desktop '@coopenomics/desktop' start
|
||||
build_service coopback '@coopenomics/controller' start
|
||||
# TEMP: cooparser — пока не мигрировали потребителей на dicoop/parser
|
||||
# из отдельного coopenomics/parser repo.
|
||||
build_service cooparser '@coopenomics/parser' start
|
||||
build_service notificator 'coop-notificator' start
|
||||
build_service notifications '@coopenomics/notifications' sync
|
||||
|
||||
# === Этап 3: webhook деплоя ===
|
||||
|
||||
- name: Trigger deployment webhook
|
||||
run: |
|
||||
curl -X POST "$WEBHOOK_URL" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$TAG_NAME"
|
||||
|
||||
- name: Telegram notify success
|
||||
if: success()
|
||||
run: |
|
||||
EXTRA=""
|
||||
if [ "$IS_PROD" = "true" ]; then
|
||||
EXTRA=" (с тегом latest)"
|
||||
fi
|
||||
curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||||
-d "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
|
||||
-d "text=✅ [GITHUB MONO] Релиз $TAG_NAME ($BRANCH, contracts:$CONTRACTS_TAG, sha=${SHA::7})$EXTRA"
|
||||
|
||||
- name: Telegram notify failure
|
||||
if: failure()
|
||||
run: |
|
||||
curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||||
-d "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \
|
||||
-d "text=❌ [GITHUB MONO] Ошибка релиза $TAG_NAME ($BRANCH, sha=${SHA::7})"
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/blago-cli",
|
||||
"version": "2026.5.13-alpha-3",
|
||||
"version": "2026.5.14-2",
|
||||
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@coopenomics/boot",
|
||||
"type": "module",
|
||||
"version": "2026.5.13-alpha-3",
|
||||
"version": "2026.5.14-2",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
"description": "CLI-утилита инициализации блокчейна и кооператива",
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Расширение Capital Test под эпик «Благорост» (Story 1.11).
|
||||
*
|
||||
* Существующий capital.test.ts (~2650 строк) покрывает базовые happy-paths.
|
||||
* Новые ветви Story 1.1–1.10 описаны здесь отдельно, чтобы не раздувать
|
||||
* монолитный файл. it.todo() — каркас под последующее наполнение helpers'ами
|
||||
* по аналогии с processDebt / processFundProgram / etc.
|
||||
*
|
||||
* Helpers, которые потребуются (часть из них появится в Эпиках 2–5):
|
||||
* - processDebtDeclinePay (gateway → debtpaydcln)
|
||||
* - processSettleDebt (settledebt деньгами)
|
||||
* - processMarkOverdue (markdebtoverd batch)
|
||||
* - processTopupProgramExpense (chairman → topupprogexp)
|
||||
* - processProgramExpense (createpgexp → apprvpgexp → authpgexp → pgexppay)
|
||||
* - processDeclineProgramExpense (declpgexp на разных фазах)
|
||||
* - processRequestRole / processApproveRole / processDeclineRole
|
||||
* - processInviteRole / processAcceptInvite / processDeclineInvite
|
||||
* - processRequestRateUpdate
|
||||
*/
|
||||
|
||||
describe('CAPITAL — расширения «Благорост» (Эпик 1)', () => {
|
||||
|
||||
describe('A — Займы', () => {
|
||||
it.todo(
|
||||
'Story 1.1: debtpaydcln возвращает PAY_PENDING → AUTHORIZED с last_pay_error и не удаляет запись'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.1: debtpayretry повторно отправляет outpay из AUTHORIZED без новой авторизации совета'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.1: debtpayretry отвергается если last_pay_error пустой (платёж ещё не пробовали)'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.2: settledebt на полную сумму → SETTLED, проводка REPAY 30000, contributors.debt_amount уменьшается'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.2: settledebt доступен и для OVERDUE, не только PAID'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.2: settledebt отвергается при amount ≠ debt.amount'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.3: debtpaycnfrm проставляет due_at = now + 1 год (фиксированный срок, решение 2026-05-19)'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.13: closedebt — компонент cancelled → автоматическое закрытие долга: o.cap.crtnma (Дт 04 / Кт 08, TRANSFER GENERATOR_FUND → NMA, создание НМА из коммитов-обеспечения) + o.cap.dbtwrf (Дт 80 / Кт 58, BURN LOAN_ISSUED, write-off долга); сегмент уменьшен на сумму займа; долг → WRITEOFF; без подписи пайщика и без сбора совета (решение 2026-05-19)'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.13: closedebt отвергается, если родительский компонент не в статусе cancelled'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.13: closedebt отвергается, если долг не в статусе paid/overdue'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.13: closedebt — глобальный contributors.debt_amount пайщика уменьшен; запись в loan.debts удалена; loan.summaries.total опущен'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.3: markdebtoverd переводит PAID → OVERDUE для долгов с now > due_at; PAID с due_at в будущем не трогает'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.3: markdebtoverd обрабатывает не более batch_limit (25) записей за вызов'
|
||||
)
|
||||
})
|
||||
|
||||
describe('A2 — Централизованный учёт займов (loan-контракт + счётчик сегмента)', () => {
|
||||
it.todo(
|
||||
'Story 1.12: loan-контракт изолирован — loan.debts хранит только (coopname, username, debt_hash, amount, repaid_at) + source_contract; project_hash отсутствует'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.12: debtpaycnfrm создаёт запись в loan.debts через inline createdebt и поднимает loan.summaries.total пайщика'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.12: settledebt в capital удаляет запись из loan.debts через inline settledebt и опускает loan.summaries.total'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.12: debt в loan.debts помечается source_contract = вызвавший контракт (capital для capital→loan)'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.12: debtpaycnfrm увеличивает active_debts_count на сегменте (binary_extension), settledebt — уменьшает'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.12: signact2 с N≤10 займами на проекте — все погашаются inline-actions в loan; active_debts_count = 0'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.12: signact2 с >10 займами на проекте (active_debts_count > 10) — fail сразу по счётчику, без обхода byprojhash'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.12: loan.createdebt от аккаунта вне contracts_whitelist — fail (только capital/marketplace/...)'
|
||||
)
|
||||
})
|
||||
|
||||
describe('B — Расходы программы', () => {
|
||||
it.todo(
|
||||
'Story 1.4: exppaycnfrm после complete_expense делает Ledger2 PAY_EXPENSE (BLAGOROST_FUND → SOV_EXPENSES)'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.5/1.6: topupprogexp пополняет program_expense_pool из global_available_invest_pool'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.5: createpgexp резервирует amount; вторая createpgexp на превышение — fail'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.5: cycle createpgexp → apprvpgexp → authpgexp → pgexppay = paid + delete + Ledger2 PAY_EXPENSE'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.5: declpgexp на любом из {created, approved, authorized} возвращает reserved → pool, удаляет запись'
|
||||
)
|
||||
})
|
||||
|
||||
describe('D — Двухуровневые допуски и approved-ставка (бездокументарная схема)', () => {
|
||||
it.todo(
|
||||
'Story 1.7: requestrole без statement создаёт rolerequests со статусом PENDING; approverole без master_decision фиксирует approved_rate'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.7: requestrole с role вне Role::{CREATOR, AUTHOR, MASTER} — fail "Допустимые роли заявки..."'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.7: requestrateu создаёт запись с role = Role::NONE и request_type = RequestType::RATE_UPDATE'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.8: createcmmt с approved_rate=1200 и creator_hours=4 → generation_amounts на 4800, не на 6000 (глобальная)'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.7: editcontrib с новой глобальной ставкой не меняет approved_rate_per_hour на сегменте'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.7: inviterole без statement → acceptinvite применяет инвайт-ставку к сегменту; declinvite оставляет сегмент без approved-rate'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.7: declinerole оставляет запись со статусом DECLINED + reason, сегмент не трогает'
|
||||
)
|
||||
|
||||
it.todo(
|
||||
'Story 1.7: requestrateu + approverole с новой ставкой обновляет approved_rate в сегменте; role.apply_role_to_project не вызывается'
|
||||
)
|
||||
})
|
||||
|
||||
describe('E — Event ridges (Story 1.10)', () => {
|
||||
it.todo(
|
||||
'Все новые actions вызывают require_recipient для (автор события, обратная сторона, председатель)'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/cleos",
|
||||
"version": "2026.5.13-alpha-3",
|
||||
"version": "2026.5.14-2",
|
||||
"private": true,
|
||||
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @brief Закрывает долг созданием НМА кооператива из коммитов-обеспечения.
|
||||
*
|
||||
* Срабатывает, когда родительский компонент проекта явно перешёл в `cancelled`:
|
||||
* проект до акта-2 не дойдёт, возврат через `signact2` невозможен.
|
||||
* Коммиты-обеспечение, накопленные пайщиком на сегменте проекта-компонента
|
||||
* (`w.cap.gen`), формируют НМА кооператива на отдельном кошельке `w.cap.nma`,
|
||||
* а обязательство пайщика по займу списывается (write-off). Никаких санкций
|
||||
* к пайщику не применяется, совет не собирается, подпись пайщика не требуется.
|
||||
*
|
||||
* Двухоперационная свёртка ledger2:
|
||||
* - o.cap.crtnma — TRANSFER w.cap.gen → w.cap.nma, Дт 04 / Кт 08.
|
||||
* Зеркалит o.cap.accept, но без участия пайщика: НМА из
|
||||
* коммитов-обеспечения долга идут в имущество кооператива,
|
||||
* а не на share программы пайщика.
|
||||
* - o.cap.dbtwrf — BURN w.cap.loan, Дт 80 / Кт 58.
|
||||
* Списание долга (write-off). Зеркало o.cap.repay, но share
|
||||
* пайщика не пополняется — долг закрыт имущественно.
|
||||
*
|
||||
* Решение 2026-05-19 (Игорь Смуров / Алексей Муравьёв): срок займа фиксированный
|
||||
* (1 год от выплаты), но при явной отмене компонента закрытие происходит
|
||||
* досрочно — без ожидания due_at.
|
||||
*
|
||||
* @param coopname Наименование кооператива (scope)
|
||||
* @param debt_hash Хеш закрываемого займа
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
*
|
||||
* @note Авторизация только от @p coopname. Action идёт из backend кооператива,
|
||||
* отслеживающего переход компонента в `cancelled`.
|
||||
*/
|
||||
void capital::closedebt(name coopname, checksum256 debt_hash) {
|
||||
require_auth(coopname);
|
||||
|
||||
// 1. Получить долг
|
||||
auto exist_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
|
||||
|
||||
// 2. Долг должен быть активным (выплачен, не погашен)
|
||||
eosio::check(exist_debt.status == Capital::Debts::Status::PAID
|
||||
|| exist_debt.status == Capital::Debts::Status::OVERDUE,
|
||||
"Закрытие долга допустимо для активного (paid) или просроченного (overdue) займа");
|
||||
|
||||
// 3. Guard на статус родительского компонента: только `cancelled` (отменён).
|
||||
// Если компонент завершён нормально (`finalized`/`result`) — возврат идёт
|
||||
// через signact2 → o.cap.repay, не сюда.
|
||||
auto project = Capital::Projects::get_project_or_fail(coopname, exist_debt.project_hash);
|
||||
eosio::check(project.status == Capital::Projects::Status::CANCELLED,
|
||||
"Закрытие долга через НМА допустимо только при cancelled статусе компонента проекта");
|
||||
|
||||
// 4. Сегмент пайщика на компоненте
|
||||
auto exist_segment = Capital::Segments::get_segment_or_fail(coopname,
|
||||
exist_debt.project_hash, exist_debt.username, "Сегмент пайщика не найден");
|
||||
|
||||
// 5. Контрибьютор для обновления глобального долга
|
||||
auto contributor = Capital::Contributors::get_contributor(coopname, exist_debt.username);
|
||||
eosio::check(contributor.has_value(), "Контрибьютор не найден");
|
||||
|
||||
auto memo = Capital::Memo::get_debt_memo(exist_debt.username);
|
||||
|
||||
// 6. o.cap.crtnma: TRANSFER GENERATOR_FUND → NMA, Дт 04 / Кт 08.
|
||||
// Из коммитов-обеспечения долга создаётся НМА кооператива.
|
||||
Ledger2::apply(_capital, coopname, operations::capital::CREATE_NMA,
|
||||
exist_debt.amount, exist_debt.username, debt_hash, memo);
|
||||
|
||||
// 7. o.cap.dbtwrf: BURN LOAN_ISSUED, Дт 80 / Кт 58.
|
||||
// Финансовое вложение кооператива в пайщика списано (write-off); share пайщика
|
||||
// не пополняется — долг закрыт имущественно.
|
||||
Ledger2::apply(_capital, coopname, operations::capital::DEBT_WRITEOFF,
|
||||
exist_debt.amount, exist_debt.username, debt_hash, memo);
|
||||
|
||||
// 8. Обновить агрегаты сегмента: -debt_amount, -active_debts_count.
|
||||
Capital::Segments::decrease_debt_amount(coopname, exist_segment.id, exist_debt.amount);
|
||||
Capital::Segments::decrease_active_debts_count(coopname, exist_debt.project_hash, exist_debt.username);
|
||||
|
||||
// 9. Уменьшить used_for_compensation в проекте (зеркало createdebt).
|
||||
Capital::Projects::subtract_used_for_compensation(coopname, project.id, exist_debt.amount);
|
||||
|
||||
// 10. Глобальный долг контрибьютора уменьшаем (как в settledebt).
|
||||
Capital::Contributors::decrease_debt_amount(coopname, contributor->id, exist_debt.amount);
|
||||
|
||||
// 11. Запись долга → WRITEOFF (для аудита).
|
||||
Capital::Debts::mark_writeoff(coopname, exist_debt.id);
|
||||
|
||||
// 12. Централизованный реестр в loan-контракте: уменьшаем сводную, удаляем запись.
|
||||
Loan::settle_debt(_capital, coopname, exist_debt.username, debt_hash, exist_debt.amount);
|
||||
|
||||
// 13. Уведомить пайщика и кооператив.
|
||||
require_recipient(exist_debt.username);
|
||||
require_recipient(coopname);
|
||||
}
|
||||
@@ -19,11 +19,11 @@ void capital::debtauthcnfr(eosio::name coopname, checksum256 debt_hash, document
|
||||
|
||||
// Получаем долг
|
||||
auto exist_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
|
||||
|
||||
// Обновляем статус долга
|
||||
Capital::Debts::update_debt_status(coopname, exist_debt.id, Capital::Debts::Status::AUTHORIZED,
|
||||
_capital, decision);
|
||||
|
||||
|
||||
// Фиксируем решение совета и переводим долг в pay_pending —
|
||||
// outpay уходит в gateway, дальше debtpaycnfrm (успех) или debtpaydcln (отказ).
|
||||
Capital::Debts::start_pay(coopname, exist_debt.id, decision, _capital);
|
||||
|
||||
// создаём объект исходящего платежа в gateway с коллбэком после обработки
|
||||
::Gateway::create_outcome(
|
||||
_capital,
|
||||
|
||||
@@ -18,12 +18,11 @@ void capital::debtpaycnfrm(name coopname, checksum256 debt_hash) {
|
||||
auto contributor = Capital::Contributors::get_contributor(coopname, exist_debt.username);
|
||||
eosio::check(contributor.has_value(), "Контрибьютор не найден");
|
||||
|
||||
// Проверяем что долг в статусе 'authorized' (готов к выплате)
|
||||
eosio::check(exist_debt.status == Capital::Debts::Status::AUTHORIZED,
|
||||
"Долг должен быть в статусе 'authorized' для подтверждения оплаты");
|
||||
|
||||
// Обновляем статус долга на PAID
|
||||
Capital::Debts::update_debt_status(coopname, exist_debt.id, Capital::Debts::Status::PAID, _gateway);
|
||||
// Переводим долг в PAID + проставляем due_at = now + 1 год (срок погашения,
|
||||
// решение 2026-05-19 — срок зафиксирован, не привязан к календарному
|
||||
// сроку проекта).
|
||||
// confirm_paid внутри проверяет статус pay_pending и атомарно сбрасывает last_pay_error.
|
||||
Capital::Debts::confirm_paid(coopname, exist_debt.id, _gateway);
|
||||
|
||||
// Выдача пайщику беспроцентного займа: Dr 58 / Cr 51, ISSUE LOAN_ISSUED (4051).
|
||||
// Семантика момента — деньги ушли пайщику, у кооператива появилось финансовое
|
||||
@@ -34,5 +33,17 @@ void capital::debtpaycnfrm(name coopname, checksum256 debt_hash) {
|
||||
|
||||
// Увеличиваем долг contributor (теперь долг активен и должен быть погашен через внесение результата)
|
||||
Capital::Contributors::increase_debt_amount(coopname, contributor->id, exist_debt.amount);
|
||||
|
||||
};
|
||||
|
||||
// Локальный счётчик займов пайщика на проекте этого сегмента (+1).
|
||||
// Используется в signact2 как лимит «10 займов на проект» без обхода byprojhash.
|
||||
Capital::Segments::increase_active_debts_count(coopname, exist_debt.project_hash, exist_debt.username);
|
||||
|
||||
// Централизованный учёт займа в контракте loan — loan-контракт независим от capital,
|
||||
// знает только (coopname, username, debt_hash, amount, repaid_at) + source_contract
|
||||
// (заполняется самим loan::createdebt из payer-авторизации). Локальная привязка к
|
||||
// проекту/программе остаётся в Capital::Debts (поле project_hash).
|
||||
// due_at уже проставлен в confirm_paid, читаем обновлённое значение.
|
||||
auto fresh_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
|
||||
Loan::create_debt(_capital, coopname, exist_debt.username,
|
||||
debt_hash, fresh_debt.due_at, exist_debt.amount);
|
||||
};
|
||||
@@ -1,23 +1,27 @@
|
||||
/**
|
||||
* @brief Отклоняет оплату долга
|
||||
* Отклоняет оплату долга и удаляет его из базы данных:
|
||||
* - Получает долг
|
||||
* - Удаляет долг из базы данных с указанием причины
|
||||
* @param coopname Наименование кооператива
|
||||
* @param debt_hash Хеш долга для отклонения оплаты
|
||||
* @param reason Причина отклонения оплаты долга
|
||||
* @brief Отклоняет оплату долга по реквизитам (gateway возвращает ошибку перечисления).
|
||||
*
|
||||
* Решение совета уже принято в @ref capital::debtauthcnfr, долг НЕ удаляется и НЕ откатывается
|
||||
* в approved. Запись остаётся, статус возвращается из @c pay_pending в @c authorized
|
||||
* с заполненным полем @c last_pay_error = @p reason. Из @c authorized доступна повторная
|
||||
* отправка в gateway без новой авторизации совета.
|
||||
*
|
||||
* @param coopname Наименование кооператива
|
||||
* @param debt_hash Хеш долга, для которого пришёл отказ от gateway
|
||||
* @param reason Текстовая причина отказа (заполняется gateway-оператором)
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
|
||||
*
|
||||
* @note Авторизация требуется от аккаунта: @p _gateway
|
||||
*/
|
||||
void capital::debtpaydcln(name coopname, checksum256 debt_hash, std::string reason) {
|
||||
require_auth(_gateway);
|
||||
|
||||
//TODO: отказ в оплате по реквизитам не должен приводить к удалению и откату т.к. решение о займе уже принято
|
||||
// // Получаем долг
|
||||
// auto exist_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
|
||||
|
||||
// // Удаляем долг
|
||||
// Capital::Debts::delete_debt(coopname, debt_hash);
|
||||
};
|
||||
|
||||
auto exist_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
|
||||
|
||||
Capital::Debts::mark_pay_declined(coopname, exist_debt.id, reason, _capital);
|
||||
|
||||
// event ridge: должник и кооператив получают уведомление об отказе оплаты.
|
||||
require_recipient(exist_debt.username);
|
||||
require_recipient(coopname);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @brief Повторная отправка платежа по займу в gateway после предыдущего отказа.
|
||||
*
|
||||
* Допустимо только из AUTHORIZED с непустым last_pay_error (то есть после debtpaydcln).
|
||||
* Использует уже сохранённое решение совета — новой авторизации не требуется.
|
||||
* Переводит займ в PAY_PENDING, очищает last_pay_error и создаёт новый outpay.
|
||||
*
|
||||
* @param coopname Кооператив
|
||||
* @param debt_hash Хеш займа для повторной отправки
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
*
|
||||
* @note Авторизация требуется от @p coopname (председатель/backend). Пайщик подпись не ставит.
|
||||
*/
|
||||
void capital::debtpayretry(eosio::name coopname, checksum256 debt_hash) {
|
||||
require_auth(coopname);
|
||||
|
||||
auto exist_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
|
||||
eosio::check(exist_debt.status == Capital::Debts::Status::AUTHORIZED,
|
||||
"Повторная отправка платежа возможна только из статуса authorized");
|
||||
eosio::check(!exist_debt.last_pay_error.empty(),
|
||||
"Повторная отправка платежа возможна только после предыдущего отказа (debtpaydcln)");
|
||||
|
||||
Capital::Debts::start_pay(coopname, exist_debt.id, exist_debt.authorization, _capital);
|
||||
|
||||
::Gateway::create_outcome(
|
||||
_capital,
|
||||
coopname,
|
||||
exist_debt.username,
|
||||
exist_debt.debt_hash,
|
||||
exist_debt.amount,
|
||||
_capital,
|
||||
Names::Capital::CONFIRM_DEBT_PAYMENT,
|
||||
Names::Capital::DECLINE_DEBT
|
||||
);
|
||||
|
||||
// event ridge: должник и председатель видят повторную отправку.
|
||||
require_recipient(exist_debt.username);
|
||||
require_recipient(coopname);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @brief Помечает просроченные займы статусом OVERDUE.
|
||||
*
|
||||
* Долг считается просроченным, когда current_time_point > debt.due_at
|
||||
* (поле due_at проставляется в debtpaycnfrm на 1 год от даты выплаты;
|
||||
* срок зафиксирован — решение 2026-05-19).
|
||||
* Из OVERDUE долг можно так же закрыть settledebt или signact2 как из PAID.
|
||||
*
|
||||
* Action рассчитан на запуск из backend через @Cron (см. Эпик 4) — раз в сутки.
|
||||
* Один вызов помечает не более 25 записей чтобы уложиться в 0.5-секундный
|
||||
* лимит транзакции EOSIO; backend повторяет до тех пор, пока action не
|
||||
* помечает 0 — это сигнал, что в текущем scope больше нечего переводить.
|
||||
*
|
||||
* @param coopname Кооператив (scope таблицы debts)
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
*
|
||||
* @note Авторизация требуется от аккаунта: @p coopname (председатель / системный).
|
||||
*/
|
||||
void capital::markdebtoverd(name coopname) {
|
||||
require_auth(coopname);
|
||||
|
||||
constexpr uint32_t MAX_PER_BATCH = 25;
|
||||
Capital::Debts::sweep_overdue(coopname, MAX_PER_BATCH);
|
||||
}
|
||||
@@ -1,22 +1,57 @@
|
||||
/**
|
||||
* @brief Погашает долг участника
|
||||
* Погашает долг участника в системе кооператива:
|
||||
* - Проверяет подлинность заявления о погашении
|
||||
* - Обрабатывает погашение долга (TODO: реализация)
|
||||
* @param coopname Наименование кооператива
|
||||
* @param username Наименование пользователя-должника
|
||||
* @param amount Сумма погашения долга
|
||||
* @param statement Заявление о погашении долга
|
||||
* @brief Погашает активный заём пайщика.
|
||||
*
|
||||
* Прямой канал погашения (без сдачи результата): сумма, ровно равная остатку долга,
|
||||
* закрывает заём. В ledger2 фиксируется операция REPAY (Dr 80 / Cr 58),
|
||||
* глобальный долг пайщика в contributors.debt_amount уменьшается, запись долга
|
||||
* переходит в SETTLED.
|
||||
*
|
||||
* Альтернативный путь закрытия — через подписание акта-2 по результату (см. signact2.cpp),
|
||||
* там та же операция REPAY вызывается на сумму result->debt_amount.
|
||||
*
|
||||
* @param coopname Наименование кооператива
|
||||
* @param debt_hash Хеш погашаемого долга
|
||||
* @param amount Сумма погашения (должна совпасть с debt.amount — полное закрытие)
|
||||
* @param statement Документ-заявление на погашение (SettleDebtStatement)
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
|
||||
* @note Авторизация требуется от аккаунта: @p coopname
|
||||
*
|
||||
* @note Авторизация только от @p coopname. Пайщик транзакции не подписывает — backend кооператива
|
||||
* подставляет себя.
|
||||
*/
|
||||
void capital::settledebt(name coopname, name username, eosio::asset amount, document2 statement){
|
||||
void capital::settledebt(name coopname, checksum256 debt_hash, eosio::asset amount, document2 statement) {
|
||||
require_auth(coopname);
|
||||
//TODO: ?
|
||||
// // Получаем долг
|
||||
// auto exist_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
|
||||
|
||||
|
||||
}
|
||||
|
||||
auto exist_debt = Capital::Debts::get_debt_or_fail(coopname, debt_hash);
|
||||
|
||||
eosio::check(exist_debt.status == Capital::Debts::Status::PAID
|
||||
|| exist_debt.status == Capital::Debts::Status::OVERDUE,
|
||||
"Погашение допустимо для активного (paid) или просроченного (overdue) займа");
|
||||
|
||||
eosio::check(amount.is_valid(), "Сумма погашения некорректна");
|
||||
eosio::check(amount.symbol == exist_debt.amount.symbol,
|
||||
"Символ суммы погашения не совпадает с символом долга");
|
||||
eosio::check(amount == exist_debt.amount,
|
||||
"Сумма погашения должна полностью покрывать остаток долга");
|
||||
|
||||
auto contributor = Capital::Contributors::get_contributor(coopname, exist_debt.username);
|
||||
eosio::check(contributor.has_value(), "Контрибьютор не найден");
|
||||
|
||||
// Возврат займа пайщиком: Dr 80 / Cr 58 — закрываем финансовое вложение в участника.
|
||||
auto memo = Capital::Memo::get_settle_debt_memo(exist_debt.username, debt_hash);
|
||||
Ledger2::apply(_capital, coopname, operations::capital::REPAY, amount,
|
||||
exist_debt.username, debt_hash, memo);
|
||||
|
||||
Capital::Contributors::decrease_debt_amount(coopname, contributor->id, amount);
|
||||
Capital::Debts::mark_settled(coopname, exist_debt.id, memo, _capital);
|
||||
|
||||
// Локальный счётчик займов пайщика на сегменте этого проекта (-1).
|
||||
Capital::Segments::decrease_active_debts_count(coopname, exist_debt.project_hash, exist_debt.username);
|
||||
|
||||
// Централизованный учёт: уменьшаем сводную и удаляем запись из loan.debts через inline action.
|
||||
Loan::settle_debt(_capital, coopname, exist_debt.username, debt_hash, amount);
|
||||
|
||||
// event ridge: и должник, и председатель видят факт погашения.
|
||||
require_recipient(exist_debt.username);
|
||||
require_recipient(coopname);
|
||||
}
|
||||
|
||||
+10
-3
@@ -28,9 +28,16 @@ void capital::exppaycnfrm(eosio::name coopname, checksum256 expense_hash) {
|
||||
|
||||
// Обновляем used_expense_pool в проекте
|
||||
Capital::Projects::complete_expense(coopname, project.id, expense.amount);
|
||||
|
||||
|
||||
// Списываем сумму расхода с программного фонда в пул хозрасходов:
|
||||
// TRANSFER BLAGOROST_FUND → SOV_EXPENSES (без Dr/Cr — оба под счётом 80).
|
||||
// Бухгалтерская проводка Dr 26 / Cr 51 на расчётном фиксируется gateway отдельно.
|
||||
auto memo = Capital::Memo::get_expense_pay_memo(expense_hash);
|
||||
Ledger2::apply(_capital, coopname,
|
||||
operations::capital::PAY_EXPENSE,
|
||||
expense.amount, expense.username,
|
||||
expense_hash, memo);
|
||||
|
||||
// Удаляем запись расхода
|
||||
Capital::Expenses::delete_expense(coopname, expense.id);
|
||||
|
||||
//TODO: здесь должна быть проводка по фонду
|
||||
}
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @brief Одобряет программный расход председателем и отправляет в совет.
|
||||
*
|
||||
* @param coopname Кооператив
|
||||
* @param approver Председатель (или доверенный)
|
||||
* @param expense_hash Хеш расхода
|
||||
* @param approved_statement Принятая записка (с подписью председателя)
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
*
|
||||
* @note Авторизация требуется от @p coopname.
|
||||
*/
|
||||
void capital::apprvpgexp(name coopname, name approver, checksum256 expense_hash,
|
||||
document2 approved_statement) {
|
||||
require_auth(coopname);
|
||||
|
||||
verify_document_or_fail(approved_statement);
|
||||
|
||||
auto expense = Capital::Expenses::get_program_expense_or_fail(coopname, expense_hash);
|
||||
eosio::check(expense.status == Capital::Expenses::Status::CREATED,
|
||||
"Программный расход должен быть в статусе 'created'");
|
||||
|
||||
Capital::Expenses::set_program_approved(coopname, expense.id, approved_statement);
|
||||
|
||||
::Soviet::create_agenda(
|
||||
_capital,
|
||||
coopname,
|
||||
expense.username,
|
||||
Names::SovietActions::CAPITAL_RESOLVE_PROGRAM_EXPENSE,
|
||||
expense_hash,
|
||||
_capital,
|
||||
Names::Capital::AUTHORIZE_PROGRAM_EXPENSE,
|
||||
Names::Capital::DECLINE_PROGRAM_EXPENSE,
|
||||
expense.expense_statement,
|
||||
std::string("")
|
||||
);
|
||||
|
||||
// event ridge: автор расхода и approver (председатель/доверенный) уведомляются.
|
||||
require_recipient(expense.username);
|
||||
require_recipient(approver);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @brief Авторизует программный расход советом и создаёт исходящий платёж в gateway.
|
||||
*
|
||||
* @param coopname Кооператив
|
||||
* @param expense_hash Хеш расхода
|
||||
* @param authorization Документ-решение совета
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
*
|
||||
* @note Авторизация требуется от @p _soviet.
|
||||
*/
|
||||
void capital::authpgexp(name coopname, checksum256 expense_hash, document2 authorization) {
|
||||
require_auth(_soviet);
|
||||
|
||||
auto expense = Capital::Expenses::get_program_expense_or_fail(coopname, expense_hash);
|
||||
eosio::check(expense.status == Capital::Expenses::Status::APPROVED,
|
||||
"Программный расход должен быть в статусе 'approved'");
|
||||
|
||||
Capital::Expenses::set_program_authorized(coopname, expense.id, authorization);
|
||||
|
||||
Action::send<createoutpay_interface>(
|
||||
_gateway,
|
||||
Names::External::CREATE_OUTPAY,
|
||||
_capital,
|
||||
coopname,
|
||||
expense.username,
|
||||
expense.expense_hash,
|
||||
expense.amount,
|
||||
_capital,
|
||||
Names::Capital::CONFIRM_PROGRAM_EXPENSE_PAYMENT,
|
||||
Names::Capital::DECLINE_PROGRAM_EXPENSE
|
||||
);
|
||||
|
||||
// event ridge: автор расхода и председатель уведомляются о решении совета.
|
||||
require_recipient(expense.username);
|
||||
require_recipient(coopname);
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @brief Создаёт целевой программный расход и резервирует средства в state.
|
||||
*
|
||||
* Программный расход — целевое списание программы «Благорост», не привязанное к проекту.
|
||||
* Источник средств — пул @c program_expense_pool, наполняемый через @ref capital::topupprogexp.
|
||||
*
|
||||
* @param coopname Кооператив
|
||||
* @param expense_hash Хеш расхода (анкер процесса p.cap.expns)
|
||||
* @param creator Инициатор (председатель / казначей; авторизуется через coopname)
|
||||
* @param amount Сумма
|
||||
* @param description Описание (для отчётности)
|
||||
* @param statement Документ-заявление (ProgramExpenseStatement)
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
*
|
||||
* @note Авторизация требуется от @p coopname.
|
||||
*/
|
||||
void capital::createpgexp(name coopname, checksum256 expense_hash, name creator,
|
||||
eosio::asset amount, std::string description, document2 statement) {
|
||||
require_auth(coopname);
|
||||
|
||||
verify_document_or_fail(statement);
|
||||
Wallet::validate_asset(amount);
|
||||
eosio::check(amount.amount > 0, "Сумма программного расхода должна быть положительной");
|
||||
|
||||
auto exist = Capital::get_program_expense(coopname, expense_hash);
|
||||
eosio::check(!exist.has_value(), "Программный расход с указанным хэшем уже существует");
|
||||
|
||||
// Резервируем средства в пуле программных расходов сразу при создании,
|
||||
// чтобы две параллельные заявки не превысили доступный пул.
|
||||
Capital::State::reserve_program_expense(coopname, amount);
|
||||
|
||||
Capital::Expenses::create_program_expense(coopname, expense_hash, creator,
|
||||
amount, description, statement);
|
||||
|
||||
// event ridge: создатель расхода и председатель видят появление заявки.
|
||||
require_recipient(creator);
|
||||
require_recipient(coopname);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @brief Отклоняет программный расход на любом этапе до подтверждения оплаты.
|
||||
*
|
||||
* Допустимо из created (отказ председателя), approved (отказ совета через callback)
|
||||
* и authorized (отказ gateway по реквизитам). Возвращает зарезервированную сумму
|
||||
* обратно в program_expense_pool и удаляет запись.
|
||||
*
|
||||
* @param coopname Кооператив
|
||||
* @param expense_hash Хеш расхода
|
||||
* @param reason Причина отказа
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
*
|
||||
* @note Авторизация требуется от @p coopname (если отказ инициирован председателем),
|
||||
* @p _soviet (если совет отказал) или @p _gateway (если выпуск платежа провалился).
|
||||
*/
|
||||
void capital::declpgexp(name coopname, checksum256 expense_hash, std::string reason) {
|
||||
eosio::check(
|
||||
eosio::has_auth(coopname) || eosio::has_auth(_soviet) || eosio::has_auth(_gateway),
|
||||
"Отклонить программный расход может только председатель, совет или gateway"
|
||||
);
|
||||
|
||||
auto expense = Capital::Expenses::get_program_expense_or_fail(coopname, expense_hash);
|
||||
eosio::check(
|
||||
expense.status == Capital::Expenses::Status::CREATED ||
|
||||
expense.status == Capital::Expenses::Status::APPROVED ||
|
||||
expense.status == Capital::Expenses::Status::AUTHORIZED,
|
||||
"Программный расход уже завершён (paid/declined)"
|
||||
);
|
||||
|
||||
Capital::State::release_program_expense(coopname, expense.amount);
|
||||
Capital::Expenses::delete_program_expense(coopname, expense.id);
|
||||
|
||||
// event ridge: автор расхода и председатель уведомляются об отклонении.
|
||||
require_recipient(expense.username);
|
||||
require_recipient(coopname);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @brief Подтверждает оплату программного расхода (callback от gateway).
|
||||
*
|
||||
* Снимает резерв с @c program_expense_reserved, делает ledger2-проводку
|
||||
* @ref operations::capital::PAY_EXPENSE (TRANSFER BLAGOROST_FUND → SOV_EXPENSES)
|
||||
* и удаляет запись расхода — финал процесса @c p.cap.expns.
|
||||
*
|
||||
* @param coopname Кооператив
|
||||
* @param expense_hash Хеш расхода
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
*
|
||||
* @note Авторизация требуется от @p _gateway.
|
||||
*/
|
||||
void capital::pgexppay(name coopname, checksum256 expense_hash) {
|
||||
require_auth(_gateway);
|
||||
|
||||
auto expense = Capital::Expenses::get_program_expense_or_fail(coopname, expense_hash);
|
||||
eosio::check(expense.status == Capital::Expenses::Status::AUTHORIZED,
|
||||
"Программный расход должен быть в статусе 'authorized' для подтверждения оплаты");
|
||||
|
||||
// Окончательное списание из резерва (деньги ушли gateway).
|
||||
Capital::State::consume_program_expense(coopname, expense.amount);
|
||||
|
||||
// Бухгалтерская/кошельковая проводка.
|
||||
auto memo = Capital::Memo::get_expense_pay_memo(expense_hash);
|
||||
Ledger2::apply(_capital, coopname,
|
||||
operations::capital::PAY_EXPENSE,
|
||||
expense.amount, expense.username,
|
||||
expense_hash, memo);
|
||||
|
||||
// Запись больше не нужна как ожидающая — закрываем процесс.
|
||||
Capital::Expenses::delete_program_expense(coopname, expense.id);
|
||||
|
||||
// event ridge: автор расхода и председатель уведомляются о факте оплаты.
|
||||
require_recipient(expense.username);
|
||||
require_recipient(coopname);
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @brief Пополняет пул программных расходов из общего инвестиционного пула программы.
|
||||
*
|
||||
* Программные расходы — это целевые списания «Благорост», не аллоцированные ни в один проект
|
||||
* (хозяйственные расходы программы, оплата инфраструктуры программы и т.п.). Прежде чем
|
||||
* создавать запрос на программный расход, председатель пополняет дедикейтнутый пул
|
||||
* @c program_expense_pool из общего @c global_available_invest_pool — деньги остаются
|
||||
* на том же ledger2-кошельке @c BLAGOROST_FUND, меняется только разбивка по назначению.
|
||||
*
|
||||
* @param coopname Кооператив (scope state)
|
||||
* @param amount Сумма пополнения (символ строго совпадает с программой)
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
*
|
||||
* @note Авторизация требуется от аккаунта: @p coopname (председатель).
|
||||
*/
|
||||
void capital::topupprogexp(name coopname, eosio::asset amount) {
|
||||
require_auth(coopname);
|
||||
|
||||
Capital::State::topup_program_expense_pool(coopname, amount);
|
||||
}
|
||||
@@ -47,11 +47,22 @@ void capital::createcmmt(eosio::name coopname, eosio::name username, checksum256
|
||||
eosio::check(creator_hours > 0, "Только положительная сумма часов создателя");
|
||||
eosio::check(creator_hours <= 1000000, "Превышено максимальное количество часов (1 млн)");
|
||||
|
||||
// Если на сегменте уже утверждена мастером approved-ставка (Story 1.7/1.8) — берём её,
|
||||
// иначе fallback на глобальную rate_per_hour из contributors. Это позволяет мастеру
|
||||
// компонента дифференцировать ставку по проекту без изменения договора УХД.
|
||||
auto segment_opt = Capital::Segments::get_segment(coopname, project_hash, username);
|
||||
eosio::asset effective_rate = contributor->rate_per_hour;
|
||||
if (segment_opt.has_value()
|
||||
&& segment_opt->approved_rate_per_hour.has_value()
|
||||
&& segment_opt->approved_rate_per_hour->amount > 0) {
|
||||
effective_rate = *segment_opt->approved_rate_per_hour;
|
||||
}
|
||||
|
||||
// считаем сумму фактических затрат создателя на основе ставки в час и потраченного времени
|
||||
eosio::asset creator_base = contributor -> rate_per_hour * creator_hours;
|
||||
|
||||
eosio::asset creator_base = effective_rate * creator_hours;
|
||||
|
||||
// Вычисляем фактическое изменение сумм генерации
|
||||
auto delta_amounts = Capital::Core::Generation::calculate_fact_generation_amounts(contributor -> rate_per_hour, creator_hours);
|
||||
auto delta_amounts = Capital::Core::Generation::calculate_fact_generation_amounts(effective_rate, creator_hours);
|
||||
|
||||
// Создаем коммит без отправки на аппрув председателю
|
||||
// Одобрение осуществляется мастером проекта через approvecmmt
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @brief Кандидат принимает инвайт от мастера: ставка/часы из заявки становятся
|
||||
* approved-параметрами сегмента.
|
||||
*
|
||||
* @param coopname Кооператив
|
||||
* @param request_hash Хеш инвайт-заявки
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
*
|
||||
* @note Авторизация требуется от @p coopname (backend подставляет кандидата).
|
||||
*/
|
||||
void capital::acceptinvite(name coopname, checksum256 request_hash) {
|
||||
require_auth(coopname);
|
||||
|
||||
auto req = Capital::RoleRequests::get_role_request_or_fail(coopname, request_hash);
|
||||
eosio::check(req.direction == Capital::RoleRequests::Direction::INVITE,
|
||||
"Принять можно только инвайт");
|
||||
|
||||
Capital::RoleRequests::approve(coopname, req.id, req.rate_per_hour, req.hours_per_day);
|
||||
Capital::Segments::set_approved_rate(
|
||||
coopname, req.project_hash, req.username, req.rate_per_hour, req.hours_per_day
|
||||
);
|
||||
|
||||
// Подключаем кандидата к проекту по приглашённой роли.
|
||||
if (req.request_type == Capital::RoleRequests::RequestType::ROLE) {
|
||||
Capital::RoleRequests::apply_role_to_project(
|
||||
coopname, req.project_hash, req.username, req.role
|
||||
);
|
||||
}
|
||||
|
||||
// event ridge: кандидат и мастер видят принятие инвайта.
|
||||
require_recipient(req.username);
|
||||
require_recipient(req.master);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @brief Мастер компонента одобряет заявку на L2-допуск либо обновление approved-ставки.
|
||||
*
|
||||
* Фиксирует approved-ставку и норматив часов в сегменте через
|
||||
* @ref Capital::Segments::set_approved_rate — далее `createcmmt` берёт их.
|
||||
* Сегмент должен уже существовать (создаётся при подписании приложения договора УХД
|
||||
* на этот проект через apprvappndx — L1-допуск).
|
||||
*
|
||||
* Бездокументарная схема: решение фиксируется только подписью транзакции
|
||||
* (require_auth coopname), документ-решение мастера не передаётся.
|
||||
*
|
||||
* @param coopname Кооператив
|
||||
* @param request_hash Хеш заявки (requestrole либо requestrateu)
|
||||
* @param approved_rate Утверждённая мастером ставка часа (≠ запрошенной — допустимо)
|
||||
* @param approved_hours Утверждённая норма часов
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
*/
|
||||
void capital::approverole(name coopname, checksum256 request_hash,
|
||||
eosio::asset approved_rate, uint64_t approved_hours) {
|
||||
require_auth(coopname);
|
||||
|
||||
Wallet::validate_asset(approved_rate);
|
||||
eosio::check(approved_rate.amount > 0, "Утверждённая ставка должна быть положительной");
|
||||
eosio::check(approved_hours > 0 && approved_hours <= 8, "Норма часов — от 1 до 8");
|
||||
|
||||
auto req = Capital::RoleRequests::get_role_request_or_fail(coopname, request_hash);
|
||||
|
||||
Capital::RoleRequests::approve(coopname, req.id, approved_rate, approved_hours);
|
||||
|
||||
Capital::Segments::set_approved_rate(
|
||||
coopname, req.project_hash, req.username, approved_rate, approved_hours
|
||||
);
|
||||
|
||||
// Только для ROLE-заявок: подключаем участника к проекту по заявленной роли.
|
||||
// RATE_UPDATE роль не меняет, только ставку — inline action не нужен.
|
||||
if (req.request_type == Capital::RoleRequests::RequestType::ROLE) {
|
||||
Capital::RoleRequests::apply_role_to_project(
|
||||
coopname, req.project_hash, req.username, req.role
|
||||
);
|
||||
}
|
||||
|
||||
// event ridge: заявитель и мастер компонента видят решение.
|
||||
require_recipient(req.username);
|
||||
require_recipient(req.master);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @brief Мастер компонента отклоняет заявку на L2-допуск / на обновление approved-ставки.
|
||||
* Запись остаётся в таблице со статусом DECLINED + причиной — для аудита.
|
||||
*
|
||||
* @param coopname Кооператив
|
||||
* @param request_hash Хеш заявки
|
||||
* @param reason Причина отказа
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
*/
|
||||
void capital::declinerole(name coopname, checksum256 request_hash, std::string reason) {
|
||||
require_auth(coopname);
|
||||
|
||||
auto req = Capital::RoleRequests::get_role_request_or_fail(coopname, request_hash);
|
||||
Capital::RoleRequests::decline(coopname, req.id, reason);
|
||||
|
||||
// event ridge: заявитель и мастер компонента видят отказ.
|
||||
require_recipient(req.username);
|
||||
require_recipient(req.master);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @brief Кандидат отказывается от инвайта мастера.
|
||||
*
|
||||
* @param coopname Кооператив
|
||||
* @param request_hash Хеш инвайт-заявки
|
||||
* @param reason Причина отказа
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
*/
|
||||
void capital::declinvite(name coopname, checksum256 request_hash, std::string reason) {
|
||||
require_auth(coopname);
|
||||
|
||||
auto req = Capital::RoleRequests::get_role_request_or_fail(coopname, request_hash);
|
||||
eosio::check(req.direction == Capital::RoleRequests::Direction::INVITE,
|
||||
"Отказаться можно только от инвайта");
|
||||
|
||||
Capital::RoleRequests::decline(coopname, req.id, reason);
|
||||
|
||||
// event ridge: кандидат и мастер видят отказ от инвайта.
|
||||
require_recipient(req.username);
|
||||
require_recipient(req.master);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @brief Мастер компонента (или председатель — для роли master при пустом слоте мастера)
|
||||
* приглашает кандидата на роль. Обратный поток к requestrole.
|
||||
* Кандидат далее acceptinvite (с применением приглашённой ставки) либо declinvite.
|
||||
*
|
||||
* Бездокументарная схема: приглашение и принятие/отказ оформляются только
|
||||
* подписью транзакции (require_auth coopname). Юридический документ-инвайт не нужен.
|
||||
*
|
||||
* Ставка часа в приглашении — это ставка, на которую кандидат соглашается через
|
||||
* acceptinvite. Не путать с contributors.rate_per_hour (личная глобальная ставка).
|
||||
*
|
||||
* @param coopname Кооператив
|
||||
* @param request_hash Хеш заявки (анкер; уникален в scope coopname)
|
||||
* @param project_hash Проект / компонент
|
||||
* @param candidate Приглашаемый пайщик (потенциальный участник)
|
||||
* @param master Мастер-приглашающий (или координатор/председатель для роли master)
|
||||
* @param role Предлагаемая роль: Role::CREATOR | Role::AUTHOR | Role::MASTER
|
||||
* @param rate_per_hour Предлагаемая ставка часа
|
||||
* @param hours_per_day Предлагаемая норма часов в день
|
||||
* @param description Текст приглашения (может быть пустым)
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
*/
|
||||
void capital::inviterole(name coopname, checksum256 request_hash, checksum256 project_hash,
|
||||
name candidate, name master, name role,
|
||||
eosio::asset rate_per_hour, uint64_t hours_per_day,
|
||||
std::string description) {
|
||||
require_auth(coopname);
|
||||
|
||||
Capital::RoleRequests::validate_role_or_fail(role);
|
||||
Wallet::validate_asset(rate_per_hour);
|
||||
eosio::check(rate_per_hour.amount > 0, "Ставка часа должна быть положительной");
|
||||
eosio::check(hours_per_day > 0 && hours_per_day <= 8, "Норма часов — от 1 до 8");
|
||||
|
||||
Capital::RoleRequests::create(
|
||||
coopname, request_hash, project_hash, candidate, master, role,
|
||||
rate_per_hour, hours_per_day,
|
||||
Capital::RoleRequests::Direction::INVITE,
|
||||
Capital::RoleRequests::RequestType::ROLE,
|
||||
description
|
||||
);
|
||||
|
||||
// event ridge: кандидат и мастер видят инвайт.
|
||||
require_recipient(candidate);
|
||||
require_recipient(master);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @brief Заявка пайщика на обновление approved-ставки на сегменте.
|
||||
* Тот же flow что и requestrole, но через RequestType::RATE_UPDATE.
|
||||
* approverole с новой ставкой → обновление сегмента; declinerole — без изменений.
|
||||
*
|
||||
* Бездокументарная схема: заявление-документ не передаётся, утверждение
|
||||
* фиксируется только транзакцией. role в записи остаётся Role::NONE
|
||||
* (валидация роли для RATE_UPDATE не применяется).
|
||||
*
|
||||
* @param coopname Кооператив
|
||||
* @param request_hash Хеш заявки
|
||||
* @param project_hash Проект / компонент
|
||||
* @param username Заявитель
|
||||
* @param master Мастер компонента (ожидаемый одобряющий)
|
||||
* @param new_rate Желаемая новая ставка
|
||||
* @param new_hours Желаемая новая норма часов
|
||||
* @param description Текст заявки на обновление ставки (может быть пустым)
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
*/
|
||||
void capital::requestrateu(name coopname, checksum256 request_hash, checksum256 project_hash,
|
||||
name username, name master,
|
||||
eosio::asset new_rate, uint64_t new_hours,
|
||||
std::string description) {
|
||||
require_auth(coopname);
|
||||
|
||||
Wallet::validate_asset(new_rate);
|
||||
eosio::check(new_rate.amount > 0, "Новая ставка должна быть положительной");
|
||||
eosio::check(new_hours > 0 && new_hours <= 8, "Норма часов — от 1 до 8");
|
||||
|
||||
Capital::RoleRequests::create(
|
||||
coopname, request_hash, project_hash, username, master,
|
||||
Capital::RoleRequests::Role::NONE,
|
||||
new_rate, new_hours,
|
||||
Capital::RoleRequests::Direction::REQUEST,
|
||||
Capital::RoleRequests::RequestType::RATE_UPDATE,
|
||||
description
|
||||
);
|
||||
|
||||
// event ridge: заявитель и мастер компонента видят запрос на обновление ставки.
|
||||
require_recipient(username);
|
||||
require_recipient(master);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @brief Заявка пайщика на L2-допуск (роль creator/author/master) на компоненте.
|
||||
*
|
||||
* Создаёт запись в `rolerequests` со статусом PENDING. Дальше мастер компонента
|
||||
* (или председатель — для роли master, когда мастера в проекте ещё нет)
|
||||
* решает: approverole (с фиксацией approved-ставки в сегменте) или declinerole.
|
||||
*
|
||||
* Бездокументарная схема: подача и решение оформляются только подписью транзакции
|
||||
* (require_auth coopname). Юридическое заявление при приёме на роль не требуется —
|
||||
* допуск даётся подписью действия на стороне кооператива.
|
||||
*
|
||||
* Ставка часа подаётся явно — это ставка пайщика именно под этот проект.
|
||||
* По дефолту фронт подставляет contributors.rate_per_hour, но пайщик может изменить.
|
||||
*
|
||||
* @param coopname Кооператив
|
||||
* @param request_hash Хеш заявки (анкер для approverole/declinerole)
|
||||
* @param project_hash Проект (или компонент — на уровне хэша одно)
|
||||
* @param username Заявитель
|
||||
* @param master Мастер компонента (ожидаемый одобряющий)
|
||||
* @param role Запрашиваемая роль: Role::CREATOR | Role::AUTHOR | Role::MASTER
|
||||
* @param rate_per_hour Заявленная ставка часа под этот проект
|
||||
* @param hours_per_day Заявленная норма часов в день
|
||||
* @param description Текст заявки (может быть пустым)
|
||||
* @ingroup public_actions
|
||||
* @ingroup public_capital_actions
|
||||
*/
|
||||
void capital::requestrole(name coopname, checksum256 request_hash, checksum256 project_hash,
|
||||
name username, name master, name role,
|
||||
eosio::asset rate_per_hour, uint64_t hours_per_day,
|
||||
std::string description) {
|
||||
require_auth(coopname);
|
||||
|
||||
Capital::RoleRequests::validate_role_or_fail(role);
|
||||
Wallet::validate_asset(rate_per_hour);
|
||||
eosio::check(rate_per_hour.amount > 0, "Ставка часа должна быть положительной");
|
||||
eosio::check(hours_per_day > 0 && hours_per_day <= 8, "Норма часов в день — от 1 до 8");
|
||||
|
||||
Capital::RoleRequests::create(
|
||||
coopname, request_hash, project_hash, username, master, role,
|
||||
rate_per_hour, hours_per_day,
|
||||
Capital::RoleRequests::Direction::REQUEST,
|
||||
Capital::RoleRequests::RequestType::ROLE,
|
||||
description
|
||||
);
|
||||
|
||||
// event ridge: заявитель и мастер компонента видят новую заявку.
|
||||
require_recipient(username);
|
||||
require_recipient(master);
|
||||
}
|
||||
@@ -14,21 +14,20 @@
|
||||
*/
|
||||
void capital::startvoting(name coopname, checksum256 project_hash) {
|
||||
require_auth(coopname);
|
||||
print("z");
|
||||
|
||||
// Проверяем существование проекта и получаем его
|
||||
auto project = Capital::Projects::get_project_or_fail(coopname, project_hash);
|
||||
print("a");
|
||||
|
||||
// Проверяем что проект авторизован советом
|
||||
//eosio::check(project.is_authorized, "Проект не авторизован советом");
|
||||
|
||||
// Проверяем, что проект в статусе "active"
|
||||
eosio::check(project.status == Capital::Projects::Status::ACTIVE, "Проект должен быть в статусе 'active'");
|
||||
eosio::check(project.counts.total_commits > 0, "Проект без коммитов не может быть поставлен на голосование");
|
||||
print("b");
|
||||
|
||||
// Обновляем статус проекта на "voting"
|
||||
Capital::Projects::update_status(coopname, project.id, Capital::Projects::Status::VOTING);
|
||||
print("c");
|
||||
|
||||
// Инициализируем голосование по методу Водянова
|
||||
Capital::Core::Voting::initialize_project_voting(coopname, project.id);
|
||||
print("d");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,10 +61,41 @@ void capital::signact2(eosio::name coopname, eosio::name chairman, checksum256 r
|
||||
}
|
||||
|
||||
// Возврат беспроцентного займа пайщика: Dr 80 / Cr 58, TRANSFER LOAN_ISSUED → SHARE_FUND_PAY.
|
||||
// Семантика: пайщик погасил ссуду результатом (интеллектуальным взносом),
|
||||
// Семантика: пайщик погасил ссуду результатом (паевой взнос интеллектуальным результатом),
|
||||
// у кооперативa уменьшилось фин. вложение 58, деньги вернулись на расчётный.
|
||||
if (result -> debt_amount.amount > 0){
|
||||
Ledger2::apply(_capital, coopname, operations::capital::REPAY, result -> debt_amount, result -> username, result_hash, memo);
|
||||
|
||||
// Лимит «10 займов на проект в одном паевом взносе» проверяем по счётчику
|
||||
// active_debts_count на сегменте (binary_extension) — без обхода byprojhash.
|
||||
// Сам счётчик инкрементируется в debtpaycnfrm и декрементируется в mark_settled.
|
||||
uint32_t active_cnt = Capital::Segments::get_active_debts_count(coopname, result->project_hash, result->username);
|
||||
eosio::check(active_cnt <= 10,
|
||||
"Превышен лимит займов на проект для одного паевого взноса (10)");
|
||||
|
||||
// Перебираем долги пайщика по проекту, но не больше известного счётчика —
|
||||
// ранний выход сразу после нужного числа активных записей.
|
||||
Capital::Debts::debts_index debts(_capital, coopname.value);
|
||||
auto byproj = debts.get_index<"byprojhash"_n>();
|
||||
std::vector<std::tuple<uint64_t, checksum256, eosio::asset>> to_settle;
|
||||
to_settle.reserve(active_cnt);
|
||||
for (auto it = byproj.find(result->project_hash);
|
||||
it != byproj.end() && it->project_hash == result->project_hash
|
||||
&& to_settle.size() < active_cnt; ++it) {
|
||||
if (it->username == result->username
|
||||
&& (it->status == Capital::Debts::Status::PAID
|
||||
|| it->status == Capital::Debts::Status::OVERDUE)) {
|
||||
to_settle.emplace_back(it->id, it->debt_hash, it->amount);
|
||||
}
|
||||
}
|
||||
for (auto& tup : to_settle) {
|
||||
auto id = std::get<0>(tup);
|
||||
auto dh = std::get<1>(tup);
|
||||
auto amt = std::get<2>(tup);
|
||||
Loan::settle_debt(_capital, coopname, result->username, dh, amt);
|
||||
Capital::Debts::mark_settled(coopname, id, memo, _capital);
|
||||
Capital::Segments::decrease_active_debts_count(coopname, result->project_hash, result->username);
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем накопительные показатели контрибьютора на основе его ролей в сегменте
|
||||
|
||||
@@ -92,9 +92,29 @@
|
||||
#include "app/debt_managment/create_debt/debtauthcnfr.cpp"
|
||||
#include "app/debt_managment/create_debt/debtpaycnfrm.cpp"
|
||||
#include "app/debt_managment/create_debt/debtpaydcln.cpp"
|
||||
#include "app/debt_managment/create_debt/debtpayretry.cpp"
|
||||
#include "app/debt_managment/create_debt/declinedebt.cpp"
|
||||
#include "app/debt_managment/create_debt/markdebtoverd.cpp"
|
||||
#include "app/debt_managment/create_debt/closedebt.cpp"
|
||||
#include "app/debt_managment/settle_debt/settledebt.cpp"
|
||||
|
||||
// Управление программными расходами
|
||||
#include "app/expense_managment/program_expenses/topupprogexp.cpp"
|
||||
#include "app/expense_managment/program_expenses/createpgexp.cpp"
|
||||
#include "app/expense_managment/program_expenses/apprvpgexp.cpp"
|
||||
#include "app/expense_managment/program_expenses/authpgexp.cpp"
|
||||
#include "app/expense_managment/program_expenses/pgexppay.cpp"
|
||||
#include "app/expense_managment/program_expenses/declpgexp.cpp"
|
||||
|
||||
// L2-роли (Story 1.7)
|
||||
#include "app/participation_management/roles/requestrole.cpp"
|
||||
#include "app/participation_management/roles/approverole.cpp"
|
||||
#include "app/participation_management/roles/declinerole.cpp"
|
||||
#include "app/participation_management/roles/inviterole.cpp"
|
||||
#include "app/participation_management/roles/acceptinvite.cpp"
|
||||
#include "app/participation_management/roles/declinvite.cpp"
|
||||
#include "app/participation_management/roles/requestrateu.cpp"
|
||||
|
||||
// Управление расходами
|
||||
#include "app/expense_managment/create_expense/approveexpns.cpp"
|
||||
#include "app/expense_managment/create_expense/capauthexpns.cpp"
|
||||
|
||||
@@ -315,11 +315,73 @@ public:
|
||||
|
||||
[[eosio::action]]
|
||||
void debtpaydcln(name coopname, checksum256 debt_hash, std::string reason);
|
||||
|
||||
|
||||
[[eosio::action]]
|
||||
void debtpayretry(eosio::name coopname, checksum256 debt_hash);
|
||||
|
||||
[[eosio::action]]
|
||||
void declinedebt(name coopname, eosio::name username, checksum256 debt_hash, std::string reason);
|
||||
|
||||
[[eosio::action]]
|
||||
void settledebt(name coopname, name username, eosio::asset amount, document2 statement);
|
||||
void settledebt(name coopname, checksum256 debt_hash, eosio::asset amount, document2 statement);
|
||||
|
||||
[[eosio::action]]
|
||||
void markdebtoverd(name coopname);
|
||||
|
||||
[[eosio::action]]
|
||||
void closedebt(name coopname, checksum256 debt_hash);
|
||||
|
||||
[[eosio::action]]
|
||||
void topupprogexp(name coopname, eosio::asset amount);
|
||||
|
||||
[[eosio::action]]
|
||||
void createpgexp(name coopname, checksum256 expense_hash, name creator,
|
||||
eosio::asset amount, std::string description, document2 statement);
|
||||
|
||||
[[eosio::action]]
|
||||
void apprvpgexp(name coopname, name approver, checksum256 expense_hash,
|
||||
document2 approved_statement);
|
||||
|
||||
[[eosio::action]]
|
||||
void authpgexp(name coopname, checksum256 expense_hash, document2 authorization);
|
||||
|
||||
[[eosio::action]]
|
||||
void pgexppay(name coopname, checksum256 expense_hash);
|
||||
|
||||
[[eosio::action]]
|
||||
void declpgexp(name coopname, checksum256 expense_hash, std::string reason);
|
||||
|
||||
// L2-роли и approved-ставки сегмента (Story 1.7) — бездокументарная схема,
|
||||
// решения фиксируются только подписью транзакции (require_auth coopname).
|
||||
[[eosio::action]]
|
||||
void requestrole(name coopname, checksum256 request_hash, checksum256 project_hash,
|
||||
name username, name master, name role,
|
||||
eosio::asset rate_per_hour, uint64_t hours_per_day,
|
||||
std::string description);
|
||||
|
||||
[[eosio::action]]
|
||||
void approverole(name coopname, checksum256 request_hash,
|
||||
eosio::asset approved_rate, uint64_t approved_hours);
|
||||
|
||||
[[eosio::action]]
|
||||
void declinerole(name coopname, checksum256 request_hash, std::string reason);
|
||||
|
||||
[[eosio::action]]
|
||||
void inviterole(name coopname, checksum256 request_hash, checksum256 project_hash,
|
||||
name candidate, name master, name role,
|
||||
eosio::asset rate_per_hour, uint64_t hours_per_day,
|
||||
std::string description);
|
||||
|
||||
[[eosio::action]]
|
||||
void acceptinvite(name coopname, checksum256 request_hash);
|
||||
|
||||
[[eosio::action]]
|
||||
void declinvite(name coopname, checksum256 request_hash, std::string reason);
|
||||
|
||||
[[eosio::action]]
|
||||
void requestrateu(name coopname, checksum256 request_hash, checksum256 project_hash,
|
||||
name username, name master,
|
||||
eosio::asset new_rate, uint64_t new_hours,
|
||||
std::string description);
|
||||
|
||||
};
|
||||
|
||||
@@ -15,10 +15,14 @@ namespace Capital::Debts {
|
||||
|
||||
*/
|
||||
namespace Status {
|
||||
constexpr name CREATED = "created"_n; ///< Долг создан
|
||||
constexpr name APPROVED = "approved"_n; ///< Долг одобрен
|
||||
constexpr name AUTHORIZED = "authorized"_n; ///< Долг авторизован советом
|
||||
constexpr name PAID = "paid"_n; ///< Долг выплачен (готов к использованию)
|
||||
constexpr name CREATED = "created"_n; ///< Долг создан
|
||||
constexpr name APPROVED = "approved"_n; ///< Долг одобрен председателем
|
||||
constexpr name AUTHORIZED = "authorized"_n; ///< Долг авторизован советом, ожидает повторной отправки на оплату
|
||||
constexpr name PAY_PENDING = "paypending"_n; ///< Долг отправлен в gateway на оплату (исходящий платёж создан, ждём debtpaycnfrm/debtpaydcln)
|
||||
constexpr name PAID = "paid"_n; ///< Долг выплачен пайщику, активный (ждёт погашения)
|
||||
constexpr name OVERDUE = "overdue"_n; ///< Долг просрочен (now > due_at) — выставляется action markdebtoverd
|
||||
constexpr name SETTLED = "settled"_n; ///< Долг полностью погашен (деньгами через settledebt либо результатом через signact2)
|
||||
constexpr name WRITEOFF = "writeoff"_n; ///< Долг списан (write-off) за счёт созданного НМА из коммитов-обеспечения (через closedebt при отмене родительского компонента; решение 2026-05-19)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,7 +46,9 @@ struct [[eosio::table, eosio::contract(CAPITAL)]] debt {
|
||||
document2 approved_statement; ///< Одобренное заявление
|
||||
document2 authorization; ///< Авторизация совета
|
||||
std::string memo; ///< Примечание
|
||||
|
||||
std::string last_pay_error; ///< Последняя ошибка outpay от gateway (заполняется debtpaydcln; пусто = успех или ещё не отправлен)
|
||||
time_point_sec due_at; ///< Срок погашения (current_time_point + 1 год при переходе в paid; решение 2026-05-19); now > due_at → overdue
|
||||
|
||||
uint64_t primary_key() const { return id; } ///< Первичный ключ (1)
|
||||
|
||||
uint64_t by_username() const { return username.value; } ///< Индекс по имени пользователя (2)
|
||||
@@ -145,6 +151,179 @@ inline void update_debt_status(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Переводит долг в PAY_PENDING — фиксирует решение совета и факт отправки в gateway.
|
||||
* Допустимо из APPROVED (первая отправка после авторизации совета) или из AUTHORIZED
|
||||
* (повторная отправка после предыдущего отказа gateway). Очищает last_pay_error.
|
||||
*/
|
||||
inline void start_pay(
|
||||
eosio::name coopname,
|
||||
uint64_t debt_id,
|
||||
const document2 &authorization,
|
||||
eosio::name payer = name{}
|
||||
) {
|
||||
if (payer == name{}) payer = coopname;
|
||||
|
||||
debts_index debts(_capital, coopname.value);
|
||||
auto debt = debts.find(debt_id);
|
||||
eosio::check(debt != debts.end(), "Долг не найден");
|
||||
eosio::check(debt->status == Status::APPROVED || debt->status == Status::AUTHORIZED,
|
||||
"Старт оплаты допустим только из approved/authorized");
|
||||
|
||||
debts.modify(debt, payer, [&](auto &d) {
|
||||
d.status = Status::PAY_PENDING;
|
||||
d.authorization = authorization;
|
||||
d.last_pay_error = "";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Возвращает долг из PAY_PENDING в AUTHORIZED с записью причины ошибки оплаты.
|
||||
* Долг не удаляется — решение совета уже принято, доступна повторная отправка в gateway.
|
||||
*/
|
||||
inline void mark_pay_declined(
|
||||
eosio::name coopname,
|
||||
uint64_t debt_id,
|
||||
const std::string &reason,
|
||||
eosio::name payer = name{}
|
||||
) {
|
||||
if (payer == name{}) payer = coopname;
|
||||
|
||||
debts_index debts(_capital, coopname.value);
|
||||
auto debt = debts.find(debt_id);
|
||||
eosio::check(debt != debts.end(), "Долг не найден");
|
||||
eosio::check(debt->status == Status::PAY_PENDING,
|
||||
"Отказ оплаты допустим только из статуса pay_pending");
|
||||
|
||||
debts.modify(debt, payer, [&](auto &d) {
|
||||
d.status = Status::AUTHORIZED;
|
||||
d.last_pay_error = reason;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Кол-во секунд в стандартном сроке погашения займа (1 год = 365 дней).
|
||||
*
|
||||
* Решение 2026-05-19 (Игорь Смуров / Алексей Муравьёв): срок займа
|
||||
* фиксированный — год, не привязан к календарному сроку проекта. Если
|
||||
* родительский компонент явно перешёл в `cancelled` раньше — долг закрывается
|
||||
* досрочно через создание НМА кооператива из коммитов-обеспечения и списание
|
||||
* долга (см. action `capital::closedebt`, операции o.cap.crtnma + o.cap.dbtwrf).
|
||||
*/
|
||||
constexpr uint32_t DEFAULT_DEBT_TERM_SECONDS = 365 * 24 * 60 * 60;
|
||||
|
||||
/**
|
||||
* @brief Переводит долг в PAID, фиксирует дату выплаты и проставляет due_at.
|
||||
*
|
||||
* Используется в debtpaycnfrm после успешной outpay из gateway. due_at рассчитывается
|
||||
* от current_time_point на год вперёд; долги с now > due_at и status == PAID
|
||||
* далее перейдут в OVERDUE через markdebtoverd.
|
||||
*/
|
||||
inline void confirm_paid(
|
||||
eosio::name coopname,
|
||||
uint64_t debt_id,
|
||||
eosio::name payer = name{}
|
||||
) {
|
||||
if (payer == name{}) payer = coopname;
|
||||
|
||||
debts_index debts(_capital, coopname.value);
|
||||
auto debt = debts.find(debt_id);
|
||||
eosio::check(debt != debts.end(), "Долг не найден");
|
||||
eosio::check(debt->status == Status::PAY_PENDING,
|
||||
"Подтверждение оплаты допустимо только из pay_pending");
|
||||
|
||||
auto now = eosio::current_time_point();
|
||||
debts.modify(debt, payer, [&](auto &d) {
|
||||
d.status = Status::PAID;
|
||||
d.due_at = eosio::time_point_sec(now.sec_since_epoch() + DEFAULT_DEBT_TERM_SECONDS);
|
||||
d.last_pay_error = "";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Помечает долги PAID с now > due_at как OVERDUE в пределах одного batch.
|
||||
*
|
||||
* @param coopname Кооператив (scope)
|
||||
* @param batch_limit Максимальное число записей за вызов (защита от 30-сек лимита транзакции)
|
||||
* @return Сколько долгов помечено за этот вызов; backend-cron повторяет до 0.
|
||||
*/
|
||||
inline uint32_t sweep_overdue(
|
||||
eosio::name coopname,
|
||||
uint32_t batch_limit
|
||||
) {
|
||||
debts_index debts(_capital, coopname.value);
|
||||
auto now = eosio::current_time_point();
|
||||
auto now_sec = eosio::time_point_sec(now.sec_since_epoch());
|
||||
|
||||
uint32_t marked = 0;
|
||||
for (auto it = debts.begin(); it != debts.end() && marked < batch_limit; ++it) {
|
||||
if (it->status == Status::PAID && it->due_at != time_point_sec() && now_sec > it->due_at) {
|
||||
debts.modify(it, coopname, [&](auto &d) {
|
||||
d.status = Status::OVERDUE;
|
||||
});
|
||||
++marked;
|
||||
}
|
||||
}
|
||||
return marked;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Закрывает долг как погашенный — переводит в SETTLED, сохраняет документ-основание
|
||||
* в поле memo (если передан) и фиксирует дату погашения.
|
||||
*
|
||||
* Деньги и проводка REPAY к этому моменту уже должны быть применены в Ledger2,
|
||||
* а contributors.debt_amount — уменьшен. Функция отвечает только за состояние записи.
|
||||
*/
|
||||
inline void mark_settled(
|
||||
eosio::name coopname,
|
||||
uint64_t debt_id,
|
||||
const std::string &settle_memo = "",
|
||||
eosio::name payer = name{}
|
||||
) {
|
||||
if (payer == name{}) payer = coopname;
|
||||
|
||||
debts_index debts(_capital, coopname.value);
|
||||
auto debt = debts.find(debt_id);
|
||||
eosio::check(debt != debts.end(), "Долг не найден");
|
||||
eosio::check(debt->status == Status::PAID || debt->status == Status::OVERDUE,
|
||||
"Погашение допустимо только из статуса paid или overdue");
|
||||
|
||||
debts.modify(debt, payer, [&](auto &d) {
|
||||
d.status = Status::SETTLED;
|
||||
d.repaid_at = eosio::current_time_point();
|
||||
if (!settle_memo.empty()) {
|
||||
d.memo = settle_memo;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Закрывает долг как списанный — переводит в WRITEOFF.
|
||||
*
|
||||
* Применяется в closedebt после применения o.cap.crtnma + o.cap.dbtwrf и
|
||||
* декремента сегмента. Семантически зеркалит mark_settled, но не пишет
|
||||
* memo о погашении: долг закрыт имущественно через созданное НМА из
|
||||
* коммитов-обеспечения, без зачисления на share пайщика. Решение 2026-05-19.
|
||||
*/
|
||||
inline void mark_writeoff(
|
||||
eosio::name coopname,
|
||||
uint64_t debt_id,
|
||||
eosio::name payer = name{}
|
||||
) {
|
||||
if (payer == name{}) payer = coopname;
|
||||
|
||||
debts_index debts(_capital, coopname.value);
|
||||
auto debt = debts.find(debt_id);
|
||||
eosio::check(debt != debts.end(), "Долг не найден");
|
||||
eosio::check(debt->status == Status::PAID || debt->status == Status::OVERDUE,
|
||||
"Списание долга допустимо только из статуса paid или overdue");
|
||||
|
||||
debts.modify(debt, payer, [&](auto &d) {
|
||||
d.status = Status::WRITEOFF;
|
||||
d.repaid_at = eosio::current_time_point();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Удаляет долг
|
||||
*/
|
||||
|
||||
@@ -178,8 +178,116 @@ namespace Capital::Expenses {
|
||||
Capital::expense_index expenses(_capital, coopname.value);
|
||||
auto expense = expenses.find(expense_id);
|
||||
eosio::check(expense != expenses.end(), "Расход не найден");
|
||||
|
||||
|
||||
expenses.erase(expense);
|
||||
}
|
||||
|
||||
} // namespace Capital::Expenses
|
||||
|
||||
namespace Capital {
|
||||
|
||||
/**
|
||||
* @brief Таблица программных расходов — целевые списания «Благорост»,
|
||||
* не привязанные к конкретному проекту.
|
||||
* @par Область памяти (scope): coopname
|
||||
* @par Имя таблицы (table): progexpenses
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract(CAPITAL)]] program_expense {
|
||||
uint64_t id; ///< Внутренний ключ
|
||||
name coopname; ///< Имя кооператива
|
||||
name username; ///< Инициатор расхода (председатель / казначей)
|
||||
|
||||
name status = Expenses::Status::CREATED; ///< Статус (CREATED/APPROVED/AUTHORIZED/PAID/DECLINED — общий enum)
|
||||
checksum256 expense_hash; ///< Хеш расхода — анкер процесса p.cap.expns
|
||||
uint64_t fund_id; ///< Идентификатор фонда списания
|
||||
eosio::asset amount; ///< Сумма расхода
|
||||
std::string description; ///< Описание (для отчётности)
|
||||
|
||||
document2 expense_statement; ///< Служебная записка (ProgramExpenseStatement)
|
||||
document2 approved_statement; ///< Записка, принятая председателем
|
||||
document2 authorization; ///< Решение совета
|
||||
|
||||
time_point_sec spended_at = current_time_point();
|
||||
|
||||
uint64_t primary_key() const { return id; }
|
||||
uint64_t by_username() const { return username.value; }
|
||||
checksum256 by_expense_hash() const { return expense_hash; }
|
||||
};
|
||||
|
||||
typedef eosio::multi_index<
|
||||
"progexpenses"_n, program_expense,
|
||||
indexed_by<"byusername"_n, const_mem_fun<program_expense, uint64_t, &program_expense::by_username>>,
|
||||
indexed_by<"byhash"_n, const_mem_fun<program_expense, checksum256, &program_expense::by_expense_hash>>
|
||||
> program_expense_index;
|
||||
|
||||
inline std::optional<program_expense> get_program_expense(eosio::name coopname, const checksum256 &hash) {
|
||||
program_expense_index t(_capital, coopname.value);
|
||||
auto idx = t.get_index<"byhash"_n>();
|
||||
auto itr = idx.find(hash);
|
||||
if (itr == idx.end()) return std::nullopt;
|
||||
return program_expense(*itr);
|
||||
}
|
||||
|
||||
} // namespace Capital
|
||||
|
||||
namespace Capital::Expenses {
|
||||
|
||||
inline program_expense get_program_expense_or_fail(eosio::name coopname, const checksum256 &expense_hash) {
|
||||
auto v = Capital::get_program_expense(coopname, expense_hash);
|
||||
eosio::check(v.has_value(), "Программный расход с указанным хэшем не найден");
|
||||
return v.value();
|
||||
}
|
||||
|
||||
inline void create_program_expense(
|
||||
eosio::name coopname,
|
||||
const checksum256 &expense_hash,
|
||||
eosio::name username,
|
||||
const eosio::asset &amount,
|
||||
const std::string &description,
|
||||
const document2 &statement
|
||||
) {
|
||||
Capital::program_expense_index t(_capital, coopname.value);
|
||||
uint64_t id = get_global_id_in_scope(_capital, coopname, "progexpenses"_n);
|
||||
|
||||
t.emplace(coopname, [&](auto &e) {
|
||||
e.id = id;
|
||||
e.coopname = coopname;
|
||||
e.username = username;
|
||||
e.expense_hash = expense_hash;
|
||||
e.fund_id = Ledger::accounts::GENERAL_EXPENSES;
|
||||
e.status = Status::CREATED;
|
||||
e.spended_at = current_time_point();
|
||||
e.expense_statement = statement;
|
||||
e.amount = amount;
|
||||
e.description = description;
|
||||
});
|
||||
}
|
||||
|
||||
inline void set_program_approved(eosio::name coopname, uint64_t id, const document2 &approved_statement) {
|
||||
Capital::program_expense_index t(_capital, coopname.value);
|
||||
auto itr = t.find(id);
|
||||
eosio::check(itr != t.end(), "Программный расход не найден");
|
||||
t.modify(itr, coopname, [&](auto &e) {
|
||||
e.status = Status::APPROVED;
|
||||
e.approved_statement = approved_statement;
|
||||
});
|
||||
}
|
||||
|
||||
inline void set_program_authorized(eosio::name coopname, uint64_t id, const document2 &authorization) {
|
||||
Capital::program_expense_index t(_capital, coopname.value);
|
||||
auto itr = t.find(id);
|
||||
eosio::check(itr != t.end(), "Программный расход не найден");
|
||||
t.modify(itr, coopname, [&](auto &e) {
|
||||
e.status = Status::AUTHORIZED;
|
||||
e.authorization = authorization;
|
||||
});
|
||||
}
|
||||
|
||||
inline void delete_program_expense(eosio::name coopname, uint64_t id) {
|
||||
Capital::program_expense_index t(_capital, coopname.value);
|
||||
auto itr = t.find(id);
|
||||
eosio::check(itr != t.end(), "Программный расход не найден");
|
||||
t.erase(itr);
|
||||
}
|
||||
|
||||
} // namespace Capital::Expenses
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/binary_extension.hpp>
|
||||
|
||||
using namespace eosio;
|
||||
using std::string;
|
||||
|
||||
@@ -40,7 +42,11 @@ namespace Capital {
|
||||
double program_membership_cumulative_reward_per_share; ///< Накопительное вознаграждение на долю в членских взносах
|
||||
|
||||
config config; ///< Управляемая конфигурация контракта
|
||||
|
||||
|
||||
// binary_extension: таблица state уже в продакшене, новые поля только хвостом.
|
||||
eosio::binary_extension<asset> program_expense_pool; ///< Доступная сумма программы под целевые расходы (без аллокации в проекты)
|
||||
eosio::binary_extension<asset> program_expense_reserved; ///< Сумма, зарезервированная под approved/authorized программные расходы
|
||||
|
||||
uint64_t primary_key() const { return coopname.value; } ///< Первичный ключ (1)
|
||||
};
|
||||
|
||||
@@ -78,7 +84,7 @@ inline global_state get_global_state(name coopname) {
|
||||
|
||||
/**
|
||||
* @brief Проверяет что контракт инициализирован.
|
||||
*
|
||||
*
|
||||
* @param coopname Имя кооператива.
|
||||
*/
|
||||
inline void is_initialized(name coopname) {
|
||||
@@ -86,6 +92,83 @@ inline void is_initialized(name coopname) {
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
}
|
||||
|
||||
/// Lazy-default для binary_extension<asset>: пустое значение = 0 в _root_govern_symbol.
|
||||
inline asset _read_pool(const eosio::binary_extension<asset> &field) {
|
||||
return field.has_value() ? *field : asset(0, _root_govern_symbol);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Пополняет пул программных расходов на @p amount из общего пула инвестиций программы.
|
||||
* Вызывается из action topupprogexp (председатель).
|
||||
*/
|
||||
inline void topup_program_expense_pool(eosio::name coopname, const asset &amount) {
|
||||
global_state_table global_state_inst(_capital, _capital.value);
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма пополнения должна быть положительной");
|
||||
eosio::check(itr->global_available_invest_pool >= amount,
|
||||
"Недостаточно свободных инвестиций программы для пополнения пула расходов");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
s.global_available_invest_pool -= amount;
|
||||
s.program_expense_pool.emplace(_read_pool(s.program_expense_pool) + amount);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Резервирует средства под approved/authorized программный расход:
|
||||
* program_expense_pool -= amount, program_expense_reserved += amount.
|
||||
*/
|
||||
inline void reserve_program_expense(eosio::name coopname, const asset &amount) {
|
||||
global_state_table global_state_inst(_capital, _capital.value);
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма резерва должна быть положительной");
|
||||
eosio::check(_read_pool(itr->program_expense_pool) >= amount,
|
||||
"Недостаточно средств в пуле программных расходов");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
s.program_expense_pool.emplace(_read_pool(s.program_expense_pool) - amount);
|
||||
s.program_expense_reserved.emplace(_read_pool(s.program_expense_reserved) + amount);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Освобождает зарезервированное при отклонении программного расхода:
|
||||
* program_expense_reserved -= amount, program_expense_pool += amount.
|
||||
*/
|
||||
inline void release_program_expense(eosio::name coopname, const asset &amount) {
|
||||
global_state_table global_state_inst(_capital, _capital.value);
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма освобождения должна быть положительной");
|
||||
eosio::check(_read_pool(itr->program_expense_reserved) >= amount,
|
||||
"Зарезервированных программных расходов меньше указанной суммы");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
s.program_expense_reserved.emplace(_read_pool(s.program_expense_reserved) - amount);
|
||||
s.program_expense_pool.emplace(_read_pool(s.program_expense_pool) + amount);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Окончательное списание программного расхода после exppaycnfrm:
|
||||
* program_expense_reserved -= amount. Деньги уже ушли gateway,
|
||||
* ledger2-проводка PAY_EXPENSE сделана в action подтверждения.
|
||||
*/
|
||||
inline void consume_program_expense(eosio::name coopname, const asset &amount) {
|
||||
global_state_table global_state_inst(_capital, _capital.value);
|
||||
auto itr = global_state_inst.find(coopname.value);
|
||||
eosio::check(itr != global_state_inst.end(), "Контракт не инициализирован");
|
||||
eosio::check(amount.is_valid() && amount.amount > 0, "Сумма списания должна быть положительной");
|
||||
eosio::check(_read_pool(itr->program_expense_reserved) >= amount,
|
||||
"Зарезервированных программных расходов меньше указанной суммы");
|
||||
|
||||
global_state_inst.modify(itr, _capital, [&](auto &s) {
|
||||
s.program_expense_reserved.emplace(_read_pool(s.program_expense_reserved) - amount);
|
||||
});
|
||||
}
|
||||
}// namespace State
|
||||
|
||||
}// namespace Capital
|
||||
@@ -17,6 +17,7 @@
|
||||
#include "projects.hpp"
|
||||
#include "results.hpp"
|
||||
#include "debts.hpp"
|
||||
#include "role_requests.hpp"
|
||||
#include "wallets.hpp"
|
||||
#include "plan_pool.hpp"
|
||||
#include "fact_pool.hpp"
|
||||
|
||||
@@ -87,4 +87,12 @@ namespace Capital::Memo {
|
||||
return "Внесение имущества в программу Благорост: " + checksum256_to_hex(property_hash);
|
||||
}
|
||||
|
||||
inline std::string get_settle_debt_memo(eosio::name username, checksum256 debt_hash) {
|
||||
return "Погашение займа участником " + username.to_string() + " (заём " + checksum256_to_hex(debt_hash) + ")";
|
||||
}
|
||||
|
||||
inline std::string get_expense_pay_memo(checksum256 expense_hash) {
|
||||
return "Оплата целевого расхода программы «Благорост»: " + checksum256_to_hex(expense_hash);
|
||||
}
|
||||
|
||||
} // namespace Capital::Memo
|
||||
@@ -4,17 +4,23 @@ using namespace eosio;
|
||||
using std::string;
|
||||
|
||||
namespace Capital {
|
||||
//TODO: УДАЛИТЬ после 07.2026 т.к. не используем
|
||||
/**
|
||||
* @brief Таблица программных инвестиций (progrinvests): ранее использовалась для заявок на зачёт с этапом одобрения советом; при текущей логике createpinv строки не создаёт, таблица сохранена для совместимости и чтения исторических записей.
|
||||
* @deprecated description: не используется
|
||||
* @brief Таблица программных инвестиций (progrinvests): ранее использовалась
|
||||
* для заявок на зачёт с этапом одобрения советом. При текущей логике
|
||||
* @ref capital::createpinv строки не создаёт, таблица сохранена для
|
||||
* совместимости и чтения исторических записей.
|
||||
*
|
||||
* @deprecated Запланировано к удалению после 2026-07. Не использовать в новом
|
||||
* коде. Чтение через scope coopname допустимо до этой даты для
|
||||
* ретроспективных отчётов; писать через emplace/modify нельзя.
|
||||
*
|
||||
* @ingroup public_tables
|
||||
* @ingroup public_capital_tables
|
||||
|
||||
*
|
||||
* @par Область памяти (scope): coopname
|
||||
* @par Имя таблицы (table): progrinvests
|
||||
* @par Имя таблицы (table): progrinvests
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract(CAPITAL)]] program_invest {
|
||||
struct [[deprecated("remove after 2026-07"), eosio::table, eosio::contract(CAPITAL)]] program_invest {
|
||||
uint64_t id; ///< ID программной инвестиции (внутренний ключ)
|
||||
eosio::name coopname; ///< Имя кооператива
|
||||
eosio::name username; ///< Имя инвестора
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
#pragma once
|
||||
|
||||
#include <eosio/eosio.hpp>
|
||||
#include <eosio/asset.hpp>
|
||||
|
||||
using namespace eosio;
|
||||
using std::string;
|
||||
|
||||
namespace Capital::RoleRequests {
|
||||
|
||||
/**
|
||||
* @brief Статусы заявки на роль / обновление approved-ставки.
|
||||
*/
|
||||
namespace Status {
|
||||
constexpr name PENDING = "pending"_n;
|
||||
constexpr name APPROVED = "approved"_n;
|
||||
constexpr name DECLINED = "declined"_n;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Направление: пайщик просит сам (REQUEST) или мастер инвайтит кандидата (INVITE).
|
||||
*/
|
||||
namespace Direction {
|
||||
constexpr name REQUEST = "request"_n;
|
||||
constexpr name INVITE = "invite"_n;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Тип заявки: «дай мне роль/допуск L2» или «обнови мне approved-ставку».
|
||||
*/
|
||||
namespace RequestType {
|
||||
constexpr name ROLE = "role"_n;
|
||||
constexpr name RATE_UPDATE = "rateupdate"_n;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Допустимые роли заявки на L2-допуск.
|
||||
* Контрибьюторы и инвесторы — L1, через role_requests не идут.
|
||||
*/
|
||||
namespace Role {
|
||||
constexpr name CREATOR = "creator"_n;
|
||||
constexpr name AUTHOR = "author"_n;
|
||||
constexpr name MASTER = "master"_n;
|
||||
constexpr name NONE = name{}; ///< Для RATE_UPDATE: поле role не значимо
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Очередь заявок на L2-допуск/инвайт и на обновление approved-ставки.
|
||||
*
|
||||
* Бездокументарная схема: подача и решение оформляются только транзакцией
|
||||
* блокчейна (require_auth coopname). Юридическое заявление при приёме на роль
|
||||
* не требуется — допуск даётся через подпись действия на стороне кооператива.
|
||||
*
|
||||
* @par Область памяти (scope): coopname
|
||||
* @par Имя таблицы (table): rolerequests
|
||||
*/
|
||||
struct [[eosio::table, eosio::contract(CAPITAL)]] role_request {
|
||||
uint64_t id; ///< Внутренний ключ
|
||||
eosio::name coopname; ///< Кооператив
|
||||
checksum256 request_hash; ///< Хеш заявки (анкер)
|
||||
checksum256 project_hash; ///< Проект / компонент
|
||||
eosio::name username; ///< Заявитель (REQUEST) или кандидат (INVITE)
|
||||
eosio::name master; ///< Мастер компонента (одобряющий) — при invite на роль master это координатор/председатель
|
||||
eosio::name role = Role::NONE; ///< Role::CREATOR | Role::AUTHOR | Role::MASTER (для RequestType::RATE_UPDATE — Role::NONE)
|
||||
eosio::asset rate_per_hour = asset(0, _root_govern_symbol); ///< Заявленная пайщиком/мастером ставка часа (всегда явная, не из contributors)
|
||||
uint64_t hours_per_day = 0; ///< Заявленная норма часов
|
||||
eosio::asset approved_rate = asset(0, _root_govern_symbol); ///< Утверждённая ставка (≠ requested при approve)
|
||||
uint64_t approved_hours = 0; ///< Утверждённая норма часов
|
||||
eosio::name direction = Direction::REQUEST; ///< request | invite
|
||||
eosio::name request_type = RequestType::ROLE; ///< role | rateupdate
|
||||
eosio::name status = Status::PENDING; ///< pending | approved | declined
|
||||
std::string description; ///< Текст заявки / приглашения (может быть пустым)
|
||||
std::string decline_reason; ///< Причина отказа (если DECLINED)
|
||||
time_point_sec created_at = current_time_point();
|
||||
|
||||
uint64_t primary_key() const { return id; }
|
||||
checksum256 by_request_hash() const { return request_hash; }
|
||||
uint128_t by_project_user() const {
|
||||
return combine_checksum_ids(project_hash, username);
|
||||
}
|
||||
};
|
||||
|
||||
typedef eosio::multi_index<
|
||||
"rolerequests"_n, role_request,
|
||||
indexed_by<"byhash"_n, const_mem_fun<role_request, checksum256, &role_request::by_request_hash>>,
|
||||
indexed_by<"byprojuser"_n, const_mem_fun<role_request, uint128_t, &role_request::by_project_user>>
|
||||
> role_requests_index;
|
||||
|
||||
inline std::optional<role_request> get_role_request(eosio::name coopname, const checksum256 &request_hash) {
|
||||
role_requests_index t(_capital, coopname.value);
|
||||
auto idx = t.get_index<"byhash"_n>();
|
||||
auto itr = idx.find(request_hash);
|
||||
if (itr == idx.end()) return std::nullopt;
|
||||
return role_request(*itr);
|
||||
}
|
||||
|
||||
inline role_request get_role_request_or_fail(eosio::name coopname, const checksum256 &request_hash) {
|
||||
auto v = get_role_request(coopname, request_hash);
|
||||
eosio::check(v.has_value(), "Заявка на роль не найдена");
|
||||
return v.value();
|
||||
}
|
||||
|
||||
inline void create(
|
||||
eosio::name coopname,
|
||||
const checksum256 &request_hash,
|
||||
const checksum256 &project_hash,
|
||||
eosio::name username,
|
||||
eosio::name master,
|
||||
eosio::name role,
|
||||
const eosio::asset &rate_per_hour,
|
||||
uint64_t hours_per_day,
|
||||
eosio::name direction,
|
||||
eosio::name request_type,
|
||||
const std::string &description
|
||||
) {
|
||||
role_requests_index t(_capital, coopname.value);
|
||||
eosio::check(!get_role_request(coopname, request_hash).has_value(),
|
||||
"Заявка с указанным хэшем уже существует");
|
||||
|
||||
uint64_t id = get_global_id_in_scope(_capital, coopname, "rolerequests"_n);
|
||||
t.emplace(coopname, [&](auto &r) {
|
||||
r.id = id;
|
||||
r.coopname = coopname;
|
||||
r.request_hash = request_hash;
|
||||
r.project_hash = project_hash;
|
||||
r.username = username;
|
||||
r.master = master;
|
||||
r.role = role;
|
||||
r.rate_per_hour = rate_per_hour;
|
||||
r.hours_per_day = hours_per_day;
|
||||
r.direction = direction;
|
||||
r.request_type = request_type;
|
||||
r.status = Status::PENDING;
|
||||
r.description = description;
|
||||
r.created_at = current_time_point();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Проверяет, что role входит в допустимый набор Role::{CREATOR, AUTHOR, MASTER}.
|
||||
* Контрибьюторы и инвесторы — это L1-роли и через role_requests не идут.
|
||||
* Не применяется к заявкам RequestType::RATE_UPDATE (там role = Role::NONE).
|
||||
*/
|
||||
inline void validate_role_or_fail(eosio::name role) {
|
||||
eosio::check(
|
||||
role == Role::CREATOR || role == Role::AUTHOR || role == Role::MASTER,
|
||||
"Допустимые роли заявки: creator, author, master"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Применяет заявленную роль к проекту через inline action:
|
||||
* Role::AUTHOR → addauthor, Role::MASTER → setmaster, Role::CREATOR → no-op
|
||||
* (creator фиксируется фактически при первом createcmmt).
|
||||
*
|
||||
* Вызывается из approverole/acceptinvite после фиксации решения, чтобы
|
||||
* заявленная при подаче роль немедленно отражалась в реестре участников.
|
||||
* Inline action идёт под permission coopname — председатель/backend подписывает.
|
||||
*/
|
||||
inline void apply_role_to_project(
|
||||
eosio::name coopname,
|
||||
const checksum256 &project_hash,
|
||||
eosio::name username,
|
||||
eosio::name role
|
||||
) {
|
||||
if (role == Role::AUTHOR) {
|
||||
eosio::action(
|
||||
eosio::permission_level{coopname, "active"_n},
|
||||
_capital,
|
||||
"addauthor"_n,
|
||||
std::make_tuple(coopname, project_hash, username)
|
||||
).send();
|
||||
} else if (role == Role::MASTER) {
|
||||
eosio::action(
|
||||
eosio::permission_level{coopname, "active"_n},
|
||||
_capital,
|
||||
"setmaster"_n,
|
||||
std::make_tuple(coopname, project_hash, username)
|
||||
).send();
|
||||
}
|
||||
}
|
||||
|
||||
inline void approve(
|
||||
eosio::name coopname,
|
||||
uint64_t request_id,
|
||||
const eosio::asset &approved_rate,
|
||||
uint64_t approved_hours
|
||||
) {
|
||||
role_requests_index t(_capital, coopname.value);
|
||||
auto itr = t.find(request_id);
|
||||
eosio::check(itr != t.end(), "Заявка не найдена");
|
||||
eosio::check(itr->status == Status::PENDING, "Заявка уже обработана");
|
||||
t.modify(itr, coopname, [&](auto &r) {
|
||||
r.status = Status::APPROVED;
|
||||
r.approved_rate = approved_rate;
|
||||
r.approved_hours = approved_hours;
|
||||
});
|
||||
}
|
||||
|
||||
inline void decline(eosio::name coopname, uint64_t request_id, const std::string &reason) {
|
||||
role_requests_index t(_capital, coopname.value);
|
||||
auto itr = t.find(request_id);
|
||||
eosio::check(itr != t.end(), "Заявка не найдена");
|
||||
eosio::check(itr->status == Status::PENDING, "Заявка уже обработана");
|
||||
t.modify(itr, coopname, [&](auto &r) {
|
||||
r.status = Status::DECLINED;
|
||||
r.decline_reason = reason;
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace Capital::RoleRequests
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <eosio/eosio.hpp>
|
||||
#include <eosio/asset.hpp>
|
||||
#include <eosio/binary_extension.hpp>
|
||||
|
||||
using namespace eosio;
|
||||
|
||||
@@ -112,7 +113,19 @@ namespace Capital::Segments {
|
||||
eosio::asset available_for_program = asset(0, _root_govern_symbol); ///< Доступная сумма для конвертации в программу (intellectual_cost - debt_amount)
|
||||
eosio::asset available_for_wallet = asset(0, _root_govern_symbol); ///< Доступная сумма для конвертации в кошелек (provisional_amount - debt_amount)
|
||||
double share_percent = 0.0; ///< Доля участника в результате (intellectual_cost / fact.total * 100)
|
||||
|
||||
|
||||
// Допуски L2 — утверждаются мастером компонента/сегмента через approverole (Story 1.7).
|
||||
// Используются в createcmmt вместо глобального contributors.rate_per_hour, если значение установлено.
|
||||
// binary_extension: таблица segments уже в продакшене, расширение схемы только хвостом.
|
||||
eosio::binary_extension<eosio::asset> approved_rate_per_hour; ///< Утверждённая мастером ставка часа на этом сегменте
|
||||
eosio::binary_extension<uint64_t> approved_hours_per_day; ///< Утверждённое мастером количество часов в день на этом сегменте
|
||||
|
||||
// Счётчик активных (PAID/OVERDUE) займов пайщика на проекте этого сегмента.
|
||||
// Инкрементируется в Capital::Debts::confirm_paid, декрементируется в mark_settled.
|
||||
// Используется как лимит «10 займов на проект в одном паевом взносе» (signact2),
|
||||
// чтобы не перебирать byprojhash и не упираться в 30-сек лимит транзакции.
|
||||
eosio::binary_extension<uint32_t> active_debts_count;
|
||||
|
||||
uint64_t primary_key() const { return id; } ///< Первичный ключ (1)
|
||||
|
||||
checksum256 by_project_hash() const { return project_hash; } ///< Индекс по хэшу проекта (2)
|
||||
@@ -156,6 +169,30 @@ namespace Capital::Segments {
|
||||
return segment(*segment_itr);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Фиксирует утверждённую мастером ставку часа и норматив часов в день на сегменте.
|
||||
* Вызывается из action approverole (Story 1.7). createcmmt далее берёт эту
|
||||
* ставку для расчёта generation_amounts вместо глобальной из contributors.
|
||||
*/
|
||||
inline void set_approved_rate(
|
||||
eosio::name coopname,
|
||||
const checksum256 &project_hash,
|
||||
eosio::name username,
|
||||
const eosio::asset &rate_per_hour,
|
||||
uint64_t hours_per_day
|
||||
) {
|
||||
segments_index segments(_capital, coopname.value);
|
||||
auto idx = segments.get_index<"byprojuser"_n>();
|
||||
auto rkey = combine_checksum_ids(project_hash, username);
|
||||
auto it = idx.find(rkey);
|
||||
eosio::check(it != idx.end(), "Сегмент участника на этом проекте не найден");
|
||||
|
||||
idx.modify(it, coopname, [&](auto &s) {
|
||||
s.approved_rate_per_hour.emplace(rate_per_hour);
|
||||
s.approved_hours_per_day.emplace(hours_per_day);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @brief Подсчитывает количество авторов в проекте.
|
||||
@@ -418,14 +455,57 @@ inline void increase_debt_amount(eosio::name coopname, uint64_t segment_id, eosi
|
||||
inline void decrease_debt_amount(eosio::name coopname, uint64_t segment_id, eosio::asset amount) {
|
||||
Capital::Segments::segments_index segments(_capital, coopname.value);
|
||||
auto segment = segments.find(segment_id);
|
||||
|
||||
|
||||
eosio::check(segment->debt_amount >= amount, "Пайщик не может погасить долг больше, чем должен");
|
||||
|
||||
|
||||
segments.modify(segment, coopname, [&](auto &s) {
|
||||
s.debt_amount -= amount;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Увеличивает счётчик активных займов пайщика на сегменте на +1.
|
||||
* Вызывается в Capital::Debts::confirm_paid после перехода долга в PAID.
|
||||
*/
|
||||
inline void increase_active_debts_count(eosio::name coopname, const checksum256 &project_hash, eosio::name username) {
|
||||
segments_index segments(_capital, coopname.value);
|
||||
auto idx = segments.get_index<"byprojuser"_n>();
|
||||
auto rkey = combine_checksum_ids(project_hash, username);
|
||||
auto it = idx.find(rkey);
|
||||
eosio::check(it != idx.end(), "Сегмент участника на этом проекте не найден");
|
||||
idx.modify(it, coopname, [&](auto &s) {
|
||||
uint32_t cur = s.active_debts_count.has_value() ? *s.active_debts_count : 0;
|
||||
s.active_debts_count.emplace(cur + 1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Уменьшает счётчик активных займов пайщика на сегменте на -1.
|
||||
* Вызывается в Capital::Debts::mark_settled (settledebt и signact2-погашение).
|
||||
*/
|
||||
inline void decrease_active_debts_count(eosio::name coopname, const checksum256 &project_hash, eosio::name username) {
|
||||
segments_index segments(_capital, coopname.value);
|
||||
auto idx = segments.get_index<"byprojuser"_n>();
|
||||
auto rkey = combine_checksum_ids(project_hash, username);
|
||||
auto it = idx.find(rkey);
|
||||
eosio::check(it != idx.end(), "Сегмент участника на этом проекте не найден");
|
||||
idx.modify(it, coopname, [&](auto &s) {
|
||||
uint32_t cur = s.active_debts_count.has_value() ? *s.active_debts_count : 0;
|
||||
eosio::check(cur > 0, "Счётчик активных займов уже на нуле");
|
||||
s.active_debts_count.emplace(cur - 1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Текущее значение счётчика активных займов пайщика на сегменте.
|
||||
* Используется в signact2 как лимит обхода долгов проекта (≤10).
|
||||
*/
|
||||
inline uint32_t get_active_debts_count(eosio::name coopname, const checksum256 &project_hash, eosio::name username) {
|
||||
auto seg = get_segment(coopname, project_hash, username);
|
||||
if (!seg.has_value()) return 0;
|
||||
return seg->active_debts_count.has_value() ? *seg->active_debts_count : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Удаляет сегмент участника
|
||||
* @param coopname Имя кооператива
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Стандарт «Займ пайщику» — выдача беспроцентного целевого займа из паевого
|
||||
# фонда кооператива и его возврат через акт приёма-передачи проекта.
|
||||
#
|
||||
# Многоактовый процесс с авторизацией советом. Две операции:
|
||||
# • o.cap.lend — выдача займа на момент авторизации (ISSUE → w.cap.loan, Дт 58 / Кт 51)
|
||||
# • o.cap.repay — возврат займа из акта-2 (TRANSFER w.cap.loan → w.wal.share, Дт 80 / Кт 58)
|
||||
#
|
||||
# Источники правды в коде:
|
||||
# • cpp/capital/capital.hpp — actions
|
||||
# • cpp/capital/src/debt/{createdebt,approvedebt,debtauthcnfr,debtpaycnfrm,declinedebt,debtpaydcln}.cpp
|
||||
# • cpp/lib/core/ledger2/operations.hpp — o.cap.lend, o.cap.repay
|
||||
# • cpp/lib/core/ledger2/processes.hpp — processes::capital::DEBT
|
||||
# Стандарт «Займ пайщику»
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# ── Секция 1. Паспорт ───────────────────────────────────────────────────────
|
||||
@@ -20,103 +9,104 @@ title: Выдача займа пайщику
|
||||
slug: debt
|
||||
status: proposed
|
||||
contract: capital
|
||||
summary: >
|
||||
Пайщик получает из паевого фонда кооператива беспроцентный целевой заём
|
||||
на срок проекта; возврат происходит при сдаче акта-2 проекта.
|
||||
purpose: >
|
||||
«Заём пайщику» — кооператив выдаёт пайщику беспроцентный целевой заём
|
||||
из паевого фонда на срок проекта. Заявку последовательно одобряют
|
||||
председатель и совет, выплата уходит пайщику через кассира. Возврат
|
||||
происходит автоматически при сдаче акта приёма-передачи проекта —
|
||||
отдельная заявка на возврат не нужна.
|
||||
Пайщик получает из паевого фонда кооператива беспроцентный целевой заём
|
||||
сроком на один год. Решение о выдаче принимает совет кооператива. Заём
|
||||
возвращается автоматически при сдаче результата работ по проекту. Если же
|
||||
проект до сдачи не доведён и был отменён, долг закрывается за счёт
|
||||
имущества, которое пайщик ранее передал кооперативу в обеспечение по этому
|
||||
займу. Никаких санкций к пайщику в случае невозврата не применяется.
|
||||
roles:
|
||||
- contributor
|
||||
- chairman
|
||||
- soviet
|
||||
- gateway_operator
|
||||
- Пайщик
|
||||
- Председатель
|
||||
- Совет
|
||||
- Кассир
|
||||
|
||||
# ── Секция 2. Действия контракта ────────────────────────────────────────────
|
||||
actions:
|
||||
- name: capital::createdebt
|
||||
human: Подать заявление
|
||||
actor: contributor
|
||||
role: opener
|
||||
actor: Пайщик
|
||||
purpose: >
|
||||
Пайщик создаёт заявку на беспроцентный целевой заём. Контракт фиксирует
|
||||
запись долга в статусе `created`, обновляет сегмент CRPS пайщика на
|
||||
сумму займа.
|
||||
Пайщик подаёт заявление на беспроцентный целевой заём.
|
||||
- name: capital::approvedebt
|
||||
human: Одобрить решением председателя
|
||||
actor: chairman
|
||||
role: progress
|
||||
human: Одобрить
|
||||
actor: Председатель
|
||||
purpose: >
|
||||
Председатель добавляет к заявке документ-одобрение займа. Статус
|
||||
заявки → `approved`.
|
||||
Председатель одобряет заявление пайщика и выносит его на утверждение
|
||||
советом.
|
||||
- name: capital::debtauthcnfr
|
||||
human: Авторизовать выплату
|
||||
actor: soviet
|
||||
role: progress
|
||||
actor: Совет
|
||||
purpose: >
|
||||
Совет авторизует выплату займа. На этом этапе применяется ledger2-
|
||||
операция o.cap.lend и Gateway получает поручение на исходящий платёж.
|
||||
Статус → `authorized`.
|
||||
Совет утверждает выдачу займа и поручает кассиру выполнить выплату.
|
||||
- name: capital::debtpaycnfrm
|
||||
human: Подтвердить выплату
|
||||
actor: gateway_operator
|
||||
role: closer
|
||||
actor: Кассир
|
||||
purpose: >
|
||||
Gateway подтверждает зачисление займа на счёт пайщика. Статус → `paid`.
|
||||
Это закрывающее действие: запись долга сохраняется до возврата
|
||||
(операция o.cap.repay сработает уже на акте-2 в процессе РИД).
|
||||
Кассир подтверждает зачисление денежных средств пайщику. С этого
|
||||
момента отсчитывается срок возврата — один год.
|
||||
- name: capital::declinedebt
|
||||
human: Отклонить
|
||||
actor: chairman
|
||||
role: reject
|
||||
actor: Председатель
|
||||
purpose: >
|
||||
Председатель или совет отклоняет заявку на заём. Сегмент CRPS откатывается,
|
||||
запись долга удаляется, операции в ledger2 не создаются.
|
||||
Председатель или совет отклоняет заявление о выдаче займа.
|
||||
- name: capital::debtpaydcln
|
||||
human: Отклонить выплату
|
||||
actor: gateway_operator
|
||||
role: reject
|
||||
actor: Кассир
|
||||
purpose: >
|
||||
Gateway отклонил исходящий платёж по займу (тех. ошибка, возврат).
|
||||
Запись долга удаляется.
|
||||
Кассир сообщает, что выплата не прошла (технический сбой, возврат
|
||||
средств банком). Заявление снимается.
|
||||
- name: capital::closedebt
|
||||
human: Закрыть займ
|
||||
actor: Председатель совета
|
||||
purpose: >
|
||||
Если проект, под который был выдан заём, отменён до сдачи результата —
|
||||
долг закрывается за счёт имущества, ранее переданного пайщиком
|
||||
кооперативу в обеспечение по этому займу. Делается автоматически:
|
||||
без сбора совета и без подписи пайщика.
|
||||
|
||||
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
|
||||
entity: capital::debt
|
||||
entity_human: Долг пайщика
|
||||
entity_source: cpp/capital/src/debt/
|
||||
|
||||
states:
|
||||
- name: created
|
||||
human: Заявление подано
|
||||
description: >
|
||||
Заявка на заём создана. Сегмент CRPS пайщика обновлён на запрошенную сумму.
|
||||
Пайщик подал заявление на заём. Документ ждёт решения председателя.
|
||||
kind: normal
|
||||
- name: approved
|
||||
human: Председатель одобрил
|
||||
human: Одобрено председателем
|
||||
description: >
|
||||
К заявке добавлен документ-одобрение председателя. Заявка ждёт авторизации
|
||||
советом.
|
||||
Председатель одобрил заявление. Документ ушёл на утверждение совету.
|
||||
kind: normal
|
||||
- name: authorized
|
||||
human: Совет авторизовал
|
||||
human: Утверждено советом
|
||||
description: >
|
||||
Совет авторизовал заём, в ledger2 применена операция o.cap.lend
|
||||
(Дт 58 / Кт 51), Gateway получил поручение на исходящий платёж.
|
||||
Совет утвердил выдачу займа. Кассиру поручено выполнить выплату.
|
||||
kind: normal
|
||||
- name: paid
|
||||
human: Заём выплачен
|
||||
description: >
|
||||
Gateway подтвердил зачисление займа пайщику. Запись долга остаётся
|
||||
в реестре до момента возврата через акт приёма-передачи проекта.
|
||||
kind: final
|
||||
- name: removed
|
||||
human: Отклонено
|
||||
Кассир подтвердил зачисление средств пайщику. С этого дня
|
||||
отсчитывается срок возврата — один год. Возврат произойдёт
|
||||
автоматически вместе со сдачей результата работ по проекту.
|
||||
kind: normal
|
||||
- name: writeoff
|
||||
human: Списано через имущество-обеспечение
|
||||
description: >
|
||||
Заявка отклонена на одном из этапов; запись удалена, операции
|
||||
o.cap.lend / o.cap.repay не создавались.
|
||||
Проект, под который был выдан заём, отменён до сдачи результата.
|
||||
Долг закрыт автоматически за счёт имущества, переданного пайщиком
|
||||
кооперативу в обеспечение по этому займу. Запись о займе остаётся
|
||||
в реестре с этой пометкой для последующего аудита; никаких претензий
|
||||
к пайщику не предъявляется.
|
||||
kind: normal
|
||||
- name: removed
|
||||
human: Заявление отклонено
|
||||
description: >
|
||||
Заявление снято на одном из этапов согласования — председателем,
|
||||
советом или кассиром.
|
||||
kind: virtual
|
||||
virtual: true
|
||||
|
||||
@@ -124,129 +114,141 @@ transitions:
|
||||
- from: "∅"
|
||||
to: created
|
||||
action: capital::createdebt
|
||||
actor: contributor
|
||||
actor: Пайщик
|
||||
guards:
|
||||
- Пайщик имеет статус active и участвует в проекте.
|
||||
- Сегмент CRPS пайщика актуален.
|
||||
- provisional_amount ≥ запрошенная сумма займа.
|
||||
- Заявление подписано ЭЦП.
|
||||
- Пайщик — активный участник кооператива.
|
||||
- Пайщик участвует в проекте, под который запрашивается заём.
|
||||
- На сегменте пайщика хватает доступной суммы для запроса.
|
||||
- Заявление подписано пайщиком.
|
||||
|
||||
- from: created
|
||||
to: approved
|
||||
action: capital::approvedebt
|
||||
actor: chairman
|
||||
actor: Председатель
|
||||
guards:
|
||||
- Документ одобрения председателя подписан.
|
||||
- Председатель подписал решение об одобрении.
|
||||
|
||||
- from: approved
|
||||
to: authorized
|
||||
action: capital::debtauthcnfr
|
||||
actor: soviet
|
||||
ledger_code: o.cap.lend
|
||||
operations:
|
||||
- o.cap.lend
|
||||
actor: Совет
|
||||
guards:
|
||||
- Документ авторизации совета подписан.
|
||||
- Совет принял решение о выдаче займа.
|
||||
|
||||
- from: authorized
|
||||
to: paid
|
||||
action: capital::debtpaycnfrm
|
||||
actor: gateway_operator
|
||||
actor: Кассир
|
||||
guards:
|
||||
- Gateway подтвердил зачисление займа пайщику.
|
||||
- Кассир подтвердил зачисление средств пайщику.
|
||||
|
||||
- from: created
|
||||
to: removed
|
||||
action: capital::declinedebt
|
||||
actor: chairman
|
||||
actor: Председатель
|
||||
guards:
|
||||
- Председатель или совет отклонили заявку.
|
||||
- Председатель или совет отклонили заявление.
|
||||
|
||||
- from: approved
|
||||
to: removed
|
||||
action: capital::declinedebt
|
||||
actor: soviet
|
||||
actor: Совет
|
||||
guards:
|
||||
- Совет отклонил заявку.
|
||||
- Совет отклонил заявление.
|
||||
|
||||
- from: authorized
|
||||
to: removed
|
||||
action: capital::debtpaydcln
|
||||
actor: gateway_operator
|
||||
actor: Кассир
|
||||
guards:
|
||||
- Платёж не прошёл / отклонён Gateway.
|
||||
- Платёж не прошёл — банк отклонил или вернул перевод.
|
||||
|
||||
- from: paid
|
||||
to: writeoff
|
||||
action: capital::closedebt
|
||||
actor: Председатель совета
|
||||
guards:
|
||||
- Проект, под который был выдан заём, отменён.
|
||||
- У пайщика на сегменте есть имущество, переданное в обеспечение.
|
||||
|
||||
# ── Секция 4. Сценарий ──────────────────────────────────────────────────────
|
||||
scenario:
|
||||
steps:
|
||||
- step: 1
|
||||
title: Подача заявления на заём
|
||||
actor: contributor
|
||||
title: Подача заявления
|
||||
actor: Пайщик
|
||||
action: capital::createdebt
|
||||
description: >
|
||||
Пайщик подаёт заявление на беспроцентный целевой заём.
|
||||
Контракт создаёт запись долга со статусом `created` и обновляет
|
||||
сегмент CRPS на запрошенную сумму.
|
||||
|
||||
- step: 2
|
||||
title: Одобрение председателем
|
||||
actor: chairman
|
||||
actor: Председатель
|
||||
action: capital::approvedebt
|
||||
description: >
|
||||
Председатель добавляет к заявке документ-одобрение. Статус
|
||||
заявки → `approved`.
|
||||
Председатель рассматривает заявление и при согласии одобряет его,
|
||||
вынося на утверждение совету.
|
||||
|
||||
- step: 3
|
||||
title: Авторизация советом и выдача займа
|
||||
actor: soviet
|
||||
title: Утверждение советом и поручение на выплату
|
||||
actor: Совет
|
||||
action: capital::debtauthcnfr
|
||||
description: >
|
||||
Совет авторизует выплату. В ledger2 применяется операция o.cap.lend
|
||||
(ISSUE → w.cap.loan, Дт 58 / Кт 51), Gateway получает поручение на
|
||||
исходящий платёж. Статус → `authorized`.
|
||||
Совет утверждает выдачу займа. Кассиру передаётся поручение на
|
||||
перечисление средств пайщику.
|
||||
|
||||
- step: 4
|
||||
title: Подтверждение выплаты
|
||||
actor: gateway_operator
|
||||
actor: Кассир
|
||||
action: capital::debtpaycnfrm
|
||||
description: >
|
||||
Gateway подтверждает зачисление средств пайщику. Статус → `paid`.
|
||||
Запись долга остаётся в реестре до момента возврата через акт-2
|
||||
проекта (операция o.cap.repay сработает в процессе p.cap.rid).
|
||||
Кассир выполняет перевод и подтверждает зачисление средств пайщику.
|
||||
С этого дня отсчитывается срок возврата — один год.
|
||||
|
||||
alternatives:
|
||||
- branch: Отказ председателя или совета
|
||||
at_step: 2
|
||||
action: capital::declinedebt
|
||||
actor: chairman
|
||||
actor: Председатель
|
||||
description: >
|
||||
Заявка отклонена. Сегмент CRPS откатывается, запись долга удаляется,
|
||||
операции в ledger2 не создаются.
|
||||
- branch: Отказ Gateway
|
||||
Заявление отклонено на этапе согласования. Деньги пайщику не
|
||||
перечисляются.
|
||||
- branch: Не прошёл платёж
|
||||
at_step: 4
|
||||
action: capital::debtpaydcln
|
||||
actor: gateway_operator
|
||||
actor: Кассир
|
||||
description: >
|
||||
Платёж не прошёл / отклонён. Запись долга удаляется.
|
||||
Банк отклонил или вернул перевод. Заявление снимается; пайщик может
|
||||
подать его заново.
|
||||
- branch: Невозврат — закрытие через имущество-обеспечение
|
||||
at_step: 4
|
||||
action: capital::closedebt
|
||||
actor: Председатель совета
|
||||
description: >
|
||||
Если проект, под который был выдан заём, отменён до сдачи
|
||||
результата — долг закрывается автоматически за счёт имущества,
|
||||
переданного пайщиком кооперативу в обеспечение по этому займу.
|
||||
Сбора совета не требуется, подпись пайщика не требуется,
|
||||
претензий к пайщику не предъявляется.
|
||||
|
||||
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
|
||||
documents:
|
||||
- step: 1
|
||||
- action: capital::createdebt
|
||||
title: Заявление на получение займа
|
||||
registry_id: 1050
|
||||
signed_by: [contributor]
|
||||
signed_by: [Пайщик]
|
||||
stored_in: debts.statement
|
||||
|
||||
- step: 2
|
||||
- action: capital::approvedebt
|
||||
title: Решение совета о предоставлении займа (одобрение председателем)
|
||||
registry_id: 1051
|
||||
signed_by: [chairman]
|
||||
signed_by: [Председатель]
|
||||
stored_in: debts.approved_statement
|
||||
|
||||
- step: 3
|
||||
title: Решение совета о предоставлении займа (авторизация совета)
|
||||
- action: capital::debtauthcnfr
|
||||
title: Решение совета о предоставлении займа (утверждение советом)
|
||||
registry_id: 1051
|
||||
signed_by: [soviet]
|
||||
signed_by: [Совет]
|
||||
stored_in: debts.authorization
|
||||
|
||||
# ── Секция 6. Операции ──────────────────────────────────────────────────────
|
||||
@@ -254,32 +256,65 @@ operations:
|
||||
- ledger_code: o.cap.lend
|
||||
human_name: Выдача пайщику беспроцентного займа
|
||||
wallet_op: ISSUE
|
||||
wallet_from: ''
|
||||
wallet_to: w.cap.loan # Выданные пайщикам беспроцентные займы
|
||||
debit: 58 # Финансовые вложения
|
||||
credit: 51 # Расчётный счёт
|
||||
debit: 58
|
||||
credit: 51
|
||||
wallet_from: null
|
||||
wallet_to: w.cap.loan
|
||||
amount_ref: debt.amount
|
||||
triggered_by: capital::debtauthcnfr
|
||||
description: >
|
||||
Выдача беспроцентного займа пайщику. Деньги уходят с расчётного счёта
|
||||
кооператива (Кт 51) и появляются как финансовое вложение Дт 58.
|
||||
Кошелёк «Выданные пайщикам беспроцентные займы» (w.cap.loan) фиксирует
|
||||
обязательство пайщика перед кооперативом.
|
||||
Кооператив перечисляет пайщику беспроцентный целевой заём. На стороне
|
||||
кооператива деньги уходят с расчётного счёта и фиксируются как
|
||||
финансовое вложение в пайщика-должника.
|
||||
|
||||
- ledger_code: o.cap.repay
|
||||
human_name: Возврат беспроцентного займа пайщика по акту-2
|
||||
human_name: Возврат беспроцентного займа пайщика при сдаче результата
|
||||
wallet_op: TRANSFER
|
||||
wallet_from: w.cap.loan # Выданные пайщикам беспроцентные займы
|
||||
wallet_to: w.wal.share # ЦПП «Цифровой Кошелёк» — паевые взносы деньгами
|
||||
debit: 80 # Паевой фонд (складочный капитал)
|
||||
credit: 58 # Финансовые вложения
|
||||
debit: 80
|
||||
credit: 58
|
||||
wallet_from: w.cap.loan
|
||||
wallet_to: w.wal.share
|
||||
user_wallet: w.wal.share
|
||||
user_ref: debt.username
|
||||
available_delta: +debt.amount
|
||||
blocked_delta: null
|
||||
amount_ref: debt.amount
|
||||
triggered_by: capital::debtpaycnfrm
|
||||
description: >
|
||||
Возврат займа в момент сдачи акта-2 проекта (происходит как часть
|
||||
процесса «Приём РИД» — p.cap.rid). Финансовое вложение закрывается
|
||||
(Кт 58), сумма зачитывается в паевой фонд (Дт 80) и появляется как
|
||||
доступный остаток на SHARE_FUND_PAY пайщика.
|
||||
Происходит в момент сдачи результата работ по проекту
|
||||
(подписанный акт приёма-передачи). Долг пайщика гасится, а сумма
|
||||
займа зачитывается ему как паевой взнос — появляется как свободный
|
||||
остаток на личном паевом кошельке пайщика.
|
||||
|
||||
- ledger_code: o.cap.crtnma
|
||||
human_name: Создание имущества кооператива из обеспечения по займу
|
||||
wallet_op: TRANSFER
|
||||
debit: 4
|
||||
credit: 8
|
||||
wallet_from: w.cap.gen
|
||||
wallet_to: w.cap.nma
|
||||
amount_ref: debt.amount
|
||||
triggered_by: capital::closedebt
|
||||
description: >
|
||||
Срабатывает при невозврате: проект, под который был выдан заём,
|
||||
отменён до сдачи результата. Имущество, ранее переданное пайщиком
|
||||
кооперативу в обеспечение, переходит в собственность кооператива
|
||||
как нематериальный актив.
|
||||
|
||||
- ledger_code: o.cap.dbtwrf
|
||||
human_name: Списание долга за счёт имущества-обеспечения
|
||||
wallet_op: BURN
|
||||
debit: 80
|
||||
credit: 58
|
||||
wallet_from: w.cap.loan
|
||||
wallet_to: null
|
||||
amount_ref: debt.amount
|
||||
triggered_by: capital::closedebt
|
||||
description: >
|
||||
Парная операция к созданию имущества из обеспечения: обязательство
|
||||
пайщика по займу закрывается. На паевой кошелёк пайщика ничего не
|
||||
зачисляется — долг закрыт имущественно, через переданное в
|
||||
обеспечение имущество.
|
||||
|
||||
# ── Секция 7. Связи ─────────────────────────────────────────────────────────
|
||||
related:
|
||||
@@ -287,11 +322,20 @@ related:
|
||||
id: public_capital_rid_process
|
||||
relation: triggers
|
||||
note: >
|
||||
Возврат займа (o.cap.repay) технически происходит в процессе
|
||||
«Приём РИД» при подписании акта-2 проекта.
|
||||
Возврат займа физически происходит при сдаче результата работ по
|
||||
проекту (подписанный акт приёма-передачи) — в процессе «Приём
|
||||
результата интеллектуальной деятельности».
|
||||
|
||||
- process_type: p.reg.accept
|
||||
id: public_registrator_accept_process
|
||||
relation: provides
|
||||
note: >
|
||||
Заём может получить только активный пайщик кооператива.
|
||||
|
||||
- process_type: p.cap.rid
|
||||
id: public_capital_rid_process
|
||||
relation: affects
|
||||
note: >
|
||||
Закрытие долга через имущество-обеспечение уменьшает на сегменте
|
||||
пайщика ту часть, которая раньше была передана в обеспечение и
|
||||
теперь стала собственностью кооператива.
|
||||
|
||||
@@ -77,9 +77,12 @@ namespace operations {
|
||||
inline constexpr eosio::name DROP_PREIMP = "o.cap.drppre"_n; ///< Закрытие пред-импорт-учёта при переходе на электронный учёт (Dr 80 / Cr 04, BURN PREIMP_FUND). Вызывается из capital::importcontr перед o.cap.import.
|
||||
inline constexpr eosio::name LEND = "o.cap.lend"_n; ///< Выдача беспроцентного займа пайщику (Dr 58 / Cr 51, ISSUE LOAN_ISSUED).
|
||||
inline constexpr eosio::name REPAY = "o.cap.repay"_n; ///< Возврат займа пайщика по акту-2 (Dr 80 / Cr 58, TRANSFER LOAN_ISSUED → SHARE_FUND_PAY).
|
||||
inline constexpr eosio::name CREATE_NMA = "o.cap.crtnma"_n; ///< Создать НМА кооператива из коммитов-обеспечения долга при невозврате займа (Dr 04 / Cr 08, TRANSFER GENERATOR_FUND → NMA). Решение 2026-05-19.
|
||||
inline constexpr eosio::name DEBT_WRITEOFF = "o.cap.dbtwrf"_n; ///< Списание долга (write-off) за счёт созданного НМА (Dr 80 / Cr 58, BURN LOAN_ISSUED). Зеркало REPAY без зачисления на share пайщика — долг закрыт имущественно. Решение 2026-05-19.
|
||||
inline constexpr eosio::name WITHDRAW_FROM_CAPITAL = "o.cap.wthcap"_n; ///< Возврат паевого из ЦПП «Благорост» в кошелёк пайщика (TRANSFER BLAGOROST_FUND → SHARE_FUND_PAY, без Dr/Cr).
|
||||
inline constexpr eosio::name CONVERT_TO_SHARE = "o.cap.cnvshr"_n; ///< Конвертация сегмента: РИД → главный кошелёк (TRANSFER GENERATOR_FUND → SHARE_FUND_PAY, без Dr/Cr — бухпроводка уже была сделана в ACCEPT_RID).
|
||||
inline constexpr eosio::name CONVERT_TO_BLAGO = "o.cap.cnvbl"_n; ///< Конвертация сегмента: РИД → ЦПП «Благорост» (TRANSFER GENERATOR_FUND → BLAGOROST_FUND, без Dr/Cr — бухпроводка уже была сделана в ACCEPT_RID).
|
||||
inline constexpr eosio::name PAY_EXPENSE = "o.cap.expns"_n; ///< Оплата целевого расхода программы (Dr 86 / Cr 51, TRANSFER BLAGOROST_FUND → SOV_EXPENSES). Списание целевого финансирования на расчётный счёт.
|
||||
}
|
||||
|
||||
// marketplace
|
||||
@@ -252,6 +255,28 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
|
||||
ledger2_accounts::SHARE_FUND, ledger2_accounts::FINANCIAL_INVESTMENTS,
|
||||
"Возврат беспроцентного займа пайщика по акту-2" },
|
||||
|
||||
// 11a. Создать НМА кооператива из коммитов-обеспечения долга при невозврате:
|
||||
// Dr 04 / Cr 08, TRANSFER GENERATOR_FUND → NMA.
|
||||
// Срабатывает автоматически из `capital::closedebt`, когда родительский
|
||||
// компонент перешёл в `cancelled` (решение 2026-05-19 — без сбора совета,
|
||||
// без подписи пайщика). РИД-коммиты, накопленные на GENERATOR_FUND пайщика
|
||||
// как обеспечение долга, формируют НМА кооператива на отдельном кошельке
|
||||
// `w.cap.nma`. Пара по 04 зеркалит `o.cap.accept`, но без участия пайщика —
|
||||
// сегмент пайщика уменьшается на сумму операции.
|
||||
{ operations::capital::CREATE_NMA, processes::capital::DEBT, WalletOp::TRANSFER,
|
||||
ledger2_wallets::GENERATOR_FUND, ledger2_wallets::NMA,
|
||||
ledger2_accounts::INTANGIBLE_ASSETS, ledger2_accounts::NON_CURRENT_INVESTMENTS,
|
||||
"Создание НМА кооператива из коммитов-обеспечения долга" },
|
||||
|
||||
// 11b. Списание долга (write-off) за счёт созданного НМА: Dr 80 / Cr 58, BURN LOAN_ISSUED.
|
||||
// Парная к `o.cap.crtnma` операция, закрывает обязательство пайщика по займу.
|
||||
// Зеркало `o.cap.repay`, но без зачисления на share пайщика (долг закрыт
|
||||
// имущественно — через НМА из коммитов-обеспечения). Решение 2026-05-19.
|
||||
{ operations::capital::DEBT_WRITEOFF, processes::capital::DEBT, WalletOp::BURN,
|
||||
ledger2_wallets::LOAN_ISSUED, eosio::name{},
|
||||
ledger2_accounts::SHARE_FUND, ledger2_accounts::FINANCIAL_INVESTMENTS,
|
||||
"Списание долга (write-off) за счёт созданного НМА" },
|
||||
|
||||
// 12. Подтверждение поставки: Dr 51 / Cr 80, ISSUE SHARE_FUND_PAY
|
||||
{ operations::marketplace::CONFIRM_SUPPLY, processes::marketplace::REQUEST, WalletOp::ISSUE, eosio::name{}, ledger2_wallets::SHARE_FUND_PAY,
|
||||
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::SHARE_FUND,
|
||||
@@ -305,6 +330,16 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
|
||||
0, 0,
|
||||
"Конвертация сегмента: РИД → ЦПП «Благорост»" },
|
||||
|
||||
// 20. Оплата целевого расхода программы: Dr 86 / Cr 51, TRANSFER BLAGOROST_FUND → SOV_EXPENSES.
|
||||
// Закрывает TODO в exppaycnfrm.cpp: списывает сумму расхода из программного фонда
|
||||
// «Благорост» в пул «Хозяйственные расходы из числа целевого финансирования»;
|
||||
// одновременно фиксируется бухпроводка по уменьшению целевого финансирования (86)
|
||||
// и списанию с расчётного счёта (51).
|
||||
{ operations::capital::PAY_EXPENSE, processes::capital::EXPENSE, WalletOp::TRANSFER,
|
||||
ledger2_wallets::BLAGOROST_FUND, ledger2_wallets::SOV_EXPENSES,
|
||||
ledger2_accounts::TARGET_RECEIPTS, ledger2_accounts::BANK_ACCOUNT,
|
||||
"Оплата целевого расхода по программе «Благорост»" },
|
||||
|
||||
// ----- Миграционные (o.mig.*) — вызываются только из migrate.cpp -----
|
||||
|
||||
// 15. Миграция: минимальный паевой: Dr 51 / Cr 80, ISSUE MIN_SHARE_FUND
|
||||
|
||||
@@ -48,11 +48,12 @@ namespace processes {
|
||||
namespace capital {
|
||||
inline constexpr eosio::name IMPORT = "p.cap.import"_n; ///< Оффлайн-импорт пайщика Благорост (o.cap.drppre (опц.) + o.cap.import).
|
||||
inline constexpr eosio::name INVEST = "p.cap.invest"_n; ///< Инвестиция в ЦПП Благорост (wallet-only 2001→9001).
|
||||
inline constexpr eosio::name DEBT = "p.cap.debt"_n; ///< Займ пайщику (o.cap.lend + o.cap.repay).
|
||||
inline constexpr eosio::name DEBT = "p.cap.debt"_n; ///< Займ пайщику (o.cap.lend + o.cap.repay; альтернативная ветка невозврата — o.cap.crtnma + o.cap.dbtwrf, решение 2026-05-19).
|
||||
inline constexpr eosio::name RID = "p.cap.rid"_n; ///< Приём РИД в паевой фонд: o.cap.commit (коммиты) + o.cap.accept (акт-2) + опц. o.cap.repay + o.cap.cnvshr/o.cap.cnvbl (финальная конвертация сегмента). Анкер процесса — result_hash.
|
||||
inline constexpr eosio::name PROPERTY = "p.cap.prop"_n; ///< Приём имущественного паевого взноса (одноактовый).
|
||||
inline constexpr eosio::name PREIMP = "p.cap.preimp"_n; ///< Первичный учёт РИД-взноса до перехода на электронный учёт (одноактовый, anchor = preimp register hash).
|
||||
inline constexpr eosio::name WTHCAP = "p.cap.wthcap"_n; ///< Возврат паевого из ЦПП «Благорост» в кошелёк пайщика (одноактовый).
|
||||
inline constexpr eosio::name EXPENSE = "p.cap.expns"_n; ///< Целевой расход программы (создание → одобрение председателя → авторизация совета → outpay → подтверждение оплаты). Anchor — expense_hash.
|
||||
}
|
||||
|
||||
// marketplace
|
||||
|
||||
@@ -63,6 +63,7 @@ struct ledger2_wallets {
|
||||
static constexpr eosio::name BLAGOROST_FUND = "w.cap.blago"_n; ///< Благорост — единый агрегированный кошелёк программы (USER_SHARED; ADR-009)
|
||||
static constexpr eosio::name GENERATOR_FUND = "w.cap.gen"_n; ///< Генератор — единый агрегированный кошелёк программы (USER_SHARED; ADR-009)
|
||||
static constexpr eosio::name PREIMP_FUND = "w.cap.preimp"_n; ///< Первичный учёт РИД-взносов до перехода на электронный учёт (USER_SHARED; o.cap.preimp / o.cap.drppre)
|
||||
static constexpr eosio::name NMA = "w.cap.nma"_n; ///< НМА кооператива — собственное имущество, перешедшее из коммитов-обеспечения при невозврате займа (COOPERATIVE; решение 2026-05-19)
|
||||
|
||||
// marketplace — выплаты
|
||||
static constexpr eosio::name SUPPLIER_PAYMENTS = "w.mkt.payout"_n; ///< Выплаты поставщикам (sink RECEIVE_CONFIRM, COOPERATIVE)
|
||||
@@ -94,7 +95,7 @@ struct Ledger2WalletMeta {
|
||||
WalletKind kind;
|
||||
};
|
||||
|
||||
inline constexpr std::array<Ledger2WalletMeta, 14> LEDGER2_WALLET_REGISTRY = {{
|
||||
inline constexpr std::array<Ledger2WalletMeta, 15> LEDGER2_WALLET_REGISTRY = {{
|
||||
// USER_SHARED (6) — L3-разрез по пайщику
|
||||
{ ledger2_wallets::MIN_SHARE_FUND, "Минимальный паевой взнос", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::SHARE_FUND_PAY, "Паевой взнос пайщика", WalletKind::USER_SHARED },
|
||||
@@ -103,7 +104,7 @@ inline constexpr std::array<Ledger2WalletMeta, 14> LEDGER2_WALLET_REGISTRY = {{
|
||||
{ ledger2_wallets::GENERATOR_FUND, "ЦПП «Генератор» — единый кошелёк программы у пайщика", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::PREIMP_FUND, "Первичный учёт РИД-взносов до перехода на электронный учёт", WalletKind::USER_SHARED },
|
||||
|
||||
// COOPERATIVE (8) — единый кооперативный баланс, без L3
|
||||
// COOPERATIVE (9) — единый кооперативный баланс, без L3
|
||||
{ ledger2_wallets::ENTRANCE_FEES, "Вступительные взносы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::WITHDRAWALS_SINK, "Возвраты паевых взносов пайщикам", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::INFRA_FEES, "Членские взносы за инфраструктуру кооп. платформы", WalletKind::COOPERATIVE },
|
||||
@@ -112,6 +113,7 @@ inline constexpr std::array<Ledger2WalletMeta, 14> LEDGER2_WALLET_REGISTRY = {{
|
||||
{ ledger2_wallets::MIN_SHARE_USED, "Использованные минимальные паевые взносы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::LOAN_ISSUED, "Выданные пайщикам беспроцентные займы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::SUPPLIER_PAYMENTS, "Выплаты поставщикам", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::NMA, "НМА кооператива (из коммитов-обеспечения по займам)", WalletKind::COOPERATIVE },
|
||||
}};
|
||||
|
||||
static constexpr size_t LEDGER2_WALLET_REGISTRY_SIZE = LEDGER2_WALLET_REGISTRY.size();
|
||||
|
||||
@@ -50,6 +50,12 @@ namespace Names {
|
||||
// Коллбэки для расходов
|
||||
constexpr eosio::name AUTHORIZE_EXPENSE = "capauthexpns"_n;
|
||||
constexpr eosio::name DECLINE_EXPENSE = "capdeclexpns"_n;
|
||||
|
||||
// Коллбэки для программных расходов
|
||||
// (CREATE_PROGRAM_EXPENSE объявлен ниже в блоке "акцептов" — общий для обоих контекстов)
|
||||
constexpr eosio::name AUTHORIZE_PROGRAM_EXPENSE = "authpgexp"_n;
|
||||
constexpr eosio::name DECLINE_PROGRAM_EXPENSE = "declpgexp"_n;
|
||||
constexpr eosio::name CONFIRM_PROGRAM_EXPENSE_PAYMENT = "pgexppay"_n;
|
||||
|
||||
// Коллбэки для долгов
|
||||
constexpr eosio::name AUTHORIZE_DEBT = "debtauthcnfr"_n;
|
||||
@@ -91,6 +97,7 @@ namespace Names {
|
||||
constexpr eosio::name CAPITAL_WITHDRAW_FROM_PROGRAM = "capwthdrprog"_n;
|
||||
constexpr eosio::name CAPITAL_WITHDRAW_FROM_PROJECT = "capwthdrproj"_n;
|
||||
constexpr eosio::name CAPITAL_RESOLVE_EXPENSE = "capresexpns"_n;
|
||||
constexpr eosio::name CAPITAL_RESOLVE_PROGRAM_EXPENSE = "caprespgexp"_n;
|
||||
constexpr eosio::name CREATE_RESULT = "createresult"_n;
|
||||
constexpr eosio::name CREATE_DEBT = "createdebt"_n;
|
||||
constexpr eosio::name CREATE_PROJECT = "createprj"_n;
|
||||
@@ -120,6 +127,7 @@ namespace Names {
|
||||
constexpr eosio::name CREATE_INVESTMENT = "createinvest"_n; // акцепт инвестиции
|
||||
constexpr eosio::name CREATE_PROGRAM_INVESTMENT = "createpinv"_n; // акцепт программной инвестиции
|
||||
constexpr eosio::name CREATE_EXPENSE = "createexpnse"_n; // акцепт расхода
|
||||
constexpr eosio::name CREATE_PROGRAM_EXPENSE = "createpgexp"_n; // акцепт программного расхода
|
||||
constexpr eosio::name CREATE_WITHDRAW_1 = "createwthd1"_n; // акцепт возврата из задания
|
||||
constexpr eosio::name CREATE_WITHDRAW_2 = "createwthd2"_n; // акцепт возврата из проекта
|
||||
constexpr eosio::name CREATE_WITHDRAW_3 = "createwthd3"_n; // акцепт возврата из программы
|
||||
|
||||
@@ -19,6 +19,11 @@ struct [[eosio::table, eosio::contract(LOAN)]] debt {
|
||||
time_point_sec created_at;
|
||||
time_point_sec repaid_at;
|
||||
|
||||
// Контракт-источник, инициировавший выдачу займа (capital / marketplace / ...).
|
||||
// loan не знает доменного контекста (проекта, программы) — это ответственность
|
||||
// вызывающего контракта; ему же принадлежит и локальная привязка debt_hash.
|
||||
name source_contract;
|
||||
|
||||
uint64_t primary_key() const { return id; }
|
||||
uint64_t by_username() const { return username.value; }
|
||||
checksum256 by_debt_hash() const { return debt_hash; }
|
||||
|
||||
@@ -2,10 +2,13 @@ using namespace Loan;
|
||||
|
||||
/**
|
||||
* @brief Создание долгового обязательства.
|
||||
* Создает новое долговое обязательство для пользователя с указанным сроком погашения
|
||||
* Создает новое долговое обязательство пайщика. loan-контракт независим от
|
||||
* прикладных контрактов — здесь нет привязки к проекту/программе/компоненту.
|
||||
* Локальный контекст (project_hash и т.п.) хранится на стороне вызывающего
|
||||
* контракта в его собственной таблице долгов.
|
||||
* @param coopname Наименование кооператива
|
||||
* @param username Имя пользователя-должника
|
||||
* @param debt_hash Хэш долгового обязательства
|
||||
* @param debt_hash Хэш долгового обязательства (идентификатор)
|
||||
* @param repaid_at Срок погашения долга
|
||||
* @param quantity Сумма долга
|
||||
* @ingroup public_actions
|
||||
@@ -33,6 +36,7 @@ void loan::createdebt(name coopname, name username, checksum256 debt_hash, time_
|
||||
d.amount = quantity;
|
||||
d.created_at = time_point_sec(current_time_point());
|
||||
d.repaid_at = repaid_at;
|
||||
d.source_contract = payer;
|
||||
});
|
||||
|
||||
Loan::summaries_index summaries(_loan, coopname.value);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/contracts",
|
||||
"version": "2026.5.13-alpha-3",
|
||||
"version": "2026.5.14-2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -84,3 +84,16 @@ OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=
|
||||
WHISPER_MODEL=whisper-1
|
||||
WHISPER_LANGUAGE=ru
|
||||
|
||||
# FILE STORAGE (MinIO в dev compose; на prod подменяется плейбуком).
|
||||
# Endpoint S3-совместимого бэкенда. В dev compose service name = minio.
|
||||
# Для запуска контроллера на хосте (вне docker) — http://127.0.0.1:9000.
|
||||
MINIO_ENDPOINT=http://minio:9000
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
# Имя физического бакета. По умолчанию `coop-${COOPNAME}`, можно перекрыть.
|
||||
MINIO_BUCKET=
|
||||
# HMAC-секрет для подписи URL отдачи. Если пусто — берётся SERVER_SECRET.
|
||||
FILE_STORAGE_SIGNING_SECRET=
|
||||
# База публичного URL контроллера для read-URL. Если пусто — берётся BACKEND_URL.
|
||||
FILE_STORAGE_PUBLIC_BASE_URL=
|
||||
|
||||
@@ -8,6 +8,9 @@ module.exports = {
|
||||
NODE_ENV: 'test',
|
||||
},
|
||||
restoreMocks: true,
|
||||
// Интеграционные тесты против внешних сервисов (MinIO и т.п.) — не часть штатного `jest`-прогона.
|
||||
// Запускаются явно через `npm run test:integration:file-storage`.
|
||||
testPathIgnorePatterns: ['/node_modules/', '\\.integration\\.spec\\.ts$'],
|
||||
coveragePathIgnorePatterns: ['node_modules', 'src/config', 'src/app.ts', 'tests'],
|
||||
coverageReporters: ['text', 'lcov', 'clover', 'html'],
|
||||
moduleNameMapper: {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "create-nodejs-express-app",
|
||||
"version": "2026.5.13-alpha-3",
|
||||
"version": "2026.5.14-2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "create-nodejs-express-app",
|
||||
"version": "2026.5.13-alpha-3",
|
||||
"version": "2026.5.14-2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@a2seven/yoo-checkout": "^1.1.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/controller",
|
||||
"version": "2026.5.13-alpha-3",
|
||||
"version": "2026.5.14-2",
|
||||
"description": "Бэкенд GraphQL API кооператива на NestJS",
|
||||
"private": true,
|
||||
"bin": "bin/createNodejsApp.js",
|
||||
@@ -22,6 +22,7 @@
|
||||
"dev": "nodemon --watch src --watch migrations --ext ts,js,env --exec 'ts-node -r tsconfig-paths/register' src/index.ts",
|
||||
"test": "jest -i --testPathPattern=tests/unit",
|
||||
"test:integration": "echo 'Integration tests run via @coopenomics/boot' && exit 0",
|
||||
"test:integration:file-storage": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --testRegex 'tests/file-storage/.*\\.integration\\.spec\\.ts$' --testPathIgnorePatterns='/node_modules/' --testTimeout=60000 -i",
|
||||
"test:watch": "jest -i --watchAll",
|
||||
"coverage": "jest -i --coverage",
|
||||
"coverage:coveralls": "jest -i --coverage --coverageReporters=text-lcov | coveralls",
|
||||
@@ -71,6 +72,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@a2seven/yoo-checkout": "^1.1.4",
|
||||
"@aws-sdk/client-s3": "^3.1035.0",
|
||||
"@coopenomics/factory": "workspace:*",
|
||||
"@coopenomics/inter": "workspace:*",
|
||||
"@coopenomics/notifications": "workspace:*",
|
||||
@@ -193,6 +195,7 @@
|
||||
"@graphql-codegen/typescript": "^4.1.1",
|
||||
"@graphql-codegen/typescript-apollo-client-helpers": "^3.0.0",
|
||||
"@graphql-codegen/typescript-operations": "^4.3.1",
|
||||
"@nestjs/testing": "^11.1.19",
|
||||
"@redocly/cli": "^1.18.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.26",
|
||||
|
||||
@@ -1805,7 +1805,7 @@ type CapitalCommit {
|
||||
data: JSON
|
||||
|
||||
"""Описание коммита"""
|
||||
description: String!
|
||||
description: String
|
||||
|
||||
"""Отображаемое имя пользователя"""
|
||||
display_name: String
|
||||
@@ -1814,7 +1814,7 @@ type CapitalCommit {
|
||||
id: Int
|
||||
|
||||
"""Метаданные коммита"""
|
||||
meta: String!
|
||||
meta: String
|
||||
|
||||
"""Флаг присутствия записи в блокчейне"""
|
||||
present: Boolean!
|
||||
@@ -6161,6 +6161,53 @@ input GenerationContractSignedMetaDocumentInput {
|
||||
version: String!
|
||||
}
|
||||
|
||||
input GenerationConvertStatementGenerateDocumentInput {
|
||||
"""Сумма для перевода в программу «Благорост»"""
|
||||
blagorost_wallet_amount: String!
|
||||
|
||||
"""Номер блока, на котором был создан документ"""
|
||||
block_num: Int
|
||||
|
||||
"""Название кооператива, связанное с документом"""
|
||||
coopname: String!
|
||||
|
||||
"""Дата и время создания документа"""
|
||||
created_at: String
|
||||
|
||||
"""Имя генератора, использованного для создания документа"""
|
||||
generator: String
|
||||
|
||||
"""Язык документа"""
|
||||
lang: String
|
||||
|
||||
"""Ссылки, связанные с документом"""
|
||||
links: [String!]
|
||||
|
||||
"""Сумма для перевода в Цифровой Кошелёк"""
|
||||
main_wallet_amount: String!
|
||||
|
||||
"""Хэш проекта"""
|
||||
project_hash: String!
|
||||
|
||||
"""Часовой пояс, в котором был создан документ"""
|
||||
timezone: String
|
||||
|
||||
"""Название документа"""
|
||||
title: String
|
||||
|
||||
"""Признак перевода в программу «Благорост»"""
|
||||
to_blagorost: Boolean!
|
||||
|
||||
"""Признак перевода в Цифровой Кошелёк"""
|
||||
to_wallet: Boolean!
|
||||
|
||||
"""Имя пользователя, создавшего документ"""
|
||||
username: String!
|
||||
|
||||
"""Версия генератора, использованного для создания документа"""
|
||||
version: String
|
||||
}
|
||||
|
||||
input GenerationMoneyInvestStatementGenerateDocumentInput {
|
||||
"""Сумма инвестирования"""
|
||||
amount: String!
|
||||
@@ -6272,56 +6319,6 @@ input GenerationMoneyInvestStatementSignedMetaDocumentInput {
|
||||
version: String!
|
||||
}
|
||||
|
||||
input GenerationToMainWalletConvertStatementGenerateDocumentInput {
|
||||
"""Хэш приложения"""
|
||||
appendix_hash: String!
|
||||
|
||||
"""Сумма для перевода на благорост кошелек"""
|
||||
blagorost_wallet_amount: String!
|
||||
|
||||
"""Номер блока, на котором был создан документ"""
|
||||
block_num: Int
|
||||
|
||||
"""Название кооператива, связанное с документом"""
|
||||
coopname: String!
|
||||
|
||||
"""Дата и время создания документа"""
|
||||
created_at: String
|
||||
|
||||
"""Имя генератора, использованного для создания документа"""
|
||||
generator: String
|
||||
|
||||
"""Язык документа"""
|
||||
lang: String
|
||||
|
||||
"""Ссылки, связанные с документом"""
|
||||
links: [String!]
|
||||
|
||||
"""Сумма для перевода на основной кошелек"""
|
||||
main_wallet_amount: String!
|
||||
|
||||
"""Хэш проекта"""
|
||||
project_hash: String!
|
||||
|
||||
"""Часовой пояс, в котором был создан документ"""
|
||||
timezone: String
|
||||
|
||||
"""Название документа"""
|
||||
title: String
|
||||
|
||||
"""Перевод на благорост кошелек"""
|
||||
to_blagorost: Boolean!
|
||||
|
||||
"""Перевод на основной кошелек"""
|
||||
to_wallet: Boolean!
|
||||
|
||||
"""Имя пользователя, создавшего документ"""
|
||||
username: String!
|
||||
|
||||
"""Версия генератора, использованного для создания документа"""
|
||||
version: String
|
||||
}
|
||||
|
||||
input GetAccountInput {
|
||||
"""Имя аккаунта пользователя"""
|
||||
username: String!
|
||||
@@ -7685,6 +7682,13 @@ type Mutation {
|
||||
"""
|
||||
capitalGenerateGenerationContract(data: GenerationContractGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
|
||||
|
||||
"""
|
||||
Сгенерировать заявление о конвертации целевого паевого взноса (в Цифровой Кошелёк и/или в программу «Благорост»)
|
||||
|
||||
Требуемые роли: chairman, member.
|
||||
"""
|
||||
capitalGenerateGenerationConvertStatement(data: GenerationConvertStatementGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
|
||||
|
||||
"""
|
||||
Сгенерировать заявление об инвестировании в генерацию
|
||||
|
||||
@@ -7713,27 +7717,6 @@ type Mutation {
|
||||
"""
|
||||
capitalGenerateGenerationPropertyInvestStatement(data: GenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
|
||||
|
||||
"""
|
||||
Сгенерировать заявление о конвертации из генерации в благорост
|
||||
|
||||
Требуемые роли: chairman, member.
|
||||
"""
|
||||
capitalGenerateGenerationToCapitalizationConvertStatement(data: GenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
|
||||
|
||||
"""
|
||||
Сгенерировать заявление о конвертации из генерации в основной кошелек
|
||||
|
||||
Требуемые роли: chairman, member.
|
||||
"""
|
||||
capitalGenerateGenerationToMainWalletConvertStatement(data: GenerationToMainWalletConvertStatementGenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
|
||||
|
||||
"""
|
||||
Сгенерировать заявление о конвертации из генерации в проектный кошелек
|
||||
|
||||
Требуемые роли: chairman, member.
|
||||
"""
|
||||
capitalGenerateGenerationToProjectConvertStatement(data: GenerateDocumentInput!, options: GenerateDocumentOptionsInput): GeneratedDocument!
|
||||
|
||||
"""
|
||||
Сгенерировать решение о получении займа
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { EventsInfrastructureModule } from './infrastructure/events/events.modul
|
||||
import { FreeDecisionInfrastructureModule } from './infrastructure/free-decision/free-decision-infrastructure.module';
|
||||
import { DecisionTrackingInfrastructureModule } from './infrastructure/decision-tracking/decision-tracking-infrastructure.module';
|
||||
import { SearchInfrastructureModule } from './infrastructure/search/search-infrastructure.module';
|
||||
import { FileStorageInfrastructureModule } from './infrastructure/file-storage';
|
||||
|
||||
// Domain modules
|
||||
import { AccountDomainModule } from './domain/account/account-domain.module';
|
||||
@@ -102,6 +103,14 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
EventsInfrastructureModule,
|
||||
FreeDecisionInfrastructureModule,
|
||||
DecisionTrackingInfrastructureModule,
|
||||
FileStorageInfrastructureModule.forRoot({
|
||||
endpoint: config.file_storage.endpoint,
|
||||
accessKey: config.file_storage.access_key,
|
||||
secretKey: config.file_storage.secret_key,
|
||||
bucket: config.file_storage.bucket,
|
||||
signingSecret: config.file_storage.signing_secret,
|
||||
publicBaseUrl: config.file_storage.public_base_url,
|
||||
}),
|
||||
// Domain modules
|
||||
AuthDomainModule,
|
||||
RegistrationDomainModule,
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { InputType, Field, IntersectionType, OmitType } from '@nestjs/graphql';
|
||||
import { IsString, IsBoolean } from 'class-validator';
|
||||
import { Cooperative } from 'cooptypes';
|
||||
import { GenerateMetaDocumentInputDTO } from '~/application/document/dto/generate-meta-document-input.dto';
|
||||
import { MetaDocumentInputDTO } from '~/application/document/dto/meta-document-input.dto';
|
||||
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
|
||||
|
||||
// утилита для выборки повторяющихся параметров из базовых интерфейсов
|
||||
type ExcludeCommonProps<T> = Omit<T, 'coopname' | 'username' | 'registry_id' | 'appendix_hash'>;
|
||||
|
||||
// интерфейс параметров для генерации
|
||||
type action = Cooperative.Registry.GenerationConvertStatement.Action;
|
||||
|
||||
@InputType(`BaseGenerationConvertStatementMetaDocumentInput`)
|
||||
class BaseGenerationConvertStatementMetaDocumentInputDTO implements ExcludeCommonProps<action> {
|
||||
@Field({ description: 'Хэш проекта' })
|
||||
@IsString()
|
||||
project_hash!: string;
|
||||
|
||||
@Field({ description: 'Сумма для перевода в Цифровой Кошелёк' })
|
||||
@IsString()
|
||||
main_wallet_amount!: string;
|
||||
|
||||
@Field({ description: 'Сумма для перевода в программу «Благорост»' })
|
||||
@IsString()
|
||||
blagorost_wallet_amount!: string;
|
||||
|
||||
@Field({ description: 'Признак перевода в Цифровой Кошелёк' })
|
||||
@IsBoolean()
|
||||
to_wallet!: boolean;
|
||||
|
||||
@Field({ description: 'Признак перевода в программу «Благорост»' })
|
||||
@IsBoolean()
|
||||
to_blagorost!: boolean;
|
||||
}
|
||||
|
||||
@InputType(`GenerationConvertStatementGenerateDocumentInput`)
|
||||
export class GenerationConvertStatementGenerateDocumentInputDTO extends IntersectionType(
|
||||
BaseGenerationConvertStatementMetaDocumentInputDTO,
|
||||
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
|
||||
) {
|
||||
registry_id!: number;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@InputType(`GenerationConvertStatementSignedMetaDocumentInput`)
|
||||
export class GenerationConvertStatementSignedMetaDocumentInputDTO
|
||||
extends IntersectionType(BaseGenerationConvertStatementMetaDocumentInputDTO, MetaDocumentInputDTO)
|
||||
implements action {
|
||||
@Field({ description: 'Хэш приложения к проекту' })
|
||||
@IsString()
|
||||
appendix_hash!: string;
|
||||
}
|
||||
|
||||
@InputType(`GenerationConvertStatementSignedDocumentInput`)
|
||||
export class GenerationConvertStatementSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
|
||||
@Field(() => GenerationConvertStatementSignedMetaDocumentInputDTO, {
|
||||
description: 'Метаинформация для документа заявления о конвертации целевого паевого взноса',
|
||||
})
|
||||
public readonly meta!: GenerationConvertStatementSignedMetaDocumentInputDTO;
|
||||
}
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
import { InputType, Field, IntersectionType, OmitType } from '@nestjs/graphql';
|
||||
import { IsString, IsBoolean } from 'class-validator';
|
||||
import { Cooperative } from 'cooptypes';
|
||||
import { GenerateMetaDocumentInputDTO } from '~/application/document/dto/generate-meta-document-input.dto';
|
||||
import { MetaDocumentInputDTO } from '~/application/document/dto/meta-document-input.dto';
|
||||
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
|
||||
|
||||
// утилита для выборки повторяющихся параметров из базовых интерфейсов
|
||||
type ExcludeCommonProps<T> = Omit<T, 'coopname' | 'username' | 'registry_id'>;
|
||||
|
||||
// интерфейс параметров для генерации
|
||||
type action = Cooperative.Registry.GenerationToMainWalletConvertStatement.Action;
|
||||
|
||||
@InputType(`BaseGenerationToMainWalletConvertStatementMetaDocumentInput`)
|
||||
class BaseGenerationToMainWalletConvertStatementMetaDocumentInputDTO implements ExcludeCommonProps<action> {
|
||||
@Field({ description: 'Хэш приложения' })
|
||||
@IsString()
|
||||
appendix_hash!: string;
|
||||
|
||||
@Field({ description: 'Хэш проекта' })
|
||||
@IsString()
|
||||
project_hash!: string;
|
||||
|
||||
@Field({ description: 'Сумма для перевода на основной кошелек' })
|
||||
@IsString()
|
||||
main_wallet_amount!: string;
|
||||
|
||||
@Field({ description: 'Сумма для перевода на благорост кошелек' })
|
||||
@IsString()
|
||||
blagorost_wallet_amount!: string;
|
||||
|
||||
@Field({ description: 'Перевод на основной кошелек' })
|
||||
@IsBoolean()
|
||||
to_wallet!: boolean;
|
||||
|
||||
@Field({ description: 'Перевод на благорост кошелек' })
|
||||
@IsBoolean()
|
||||
to_blagorost!: boolean;
|
||||
}
|
||||
|
||||
@InputType(`GenerationToMainWalletConvertStatementGenerateDocumentInput`)
|
||||
export class GenerationToMainWalletConvertStatementGenerateDocumentInputDTO extends IntersectionType(
|
||||
BaseGenerationToMainWalletConvertStatementMetaDocumentInputDTO,
|
||||
OmitType(GenerateMetaDocumentInputDTO, ['registry_id'] as const)
|
||||
) {
|
||||
registry_id!: number;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@InputType(`GenerationToMainWalletConvertStatementSignedMetaDocumentInput`)
|
||||
export class GenerationToMainWalletConvertStatementSignedMetaDocumentInputDTO
|
||||
extends IntersectionType(BaseGenerationToMainWalletConvertStatementMetaDocumentInputDTO, MetaDocumentInputDTO)
|
||||
implements action {}
|
||||
|
||||
@InputType(`GenerationToMainWalletConvertStatementSignedDocumentInput`)
|
||||
export class GenerationToMainWalletConvertStatementSignedDocumentInputDTO extends SignedDigitalDocumentInputDTO {
|
||||
@Field(() => GenerationToMainWalletConvertStatementSignedMetaDocumentInputDTO, {
|
||||
description: 'Метаинформация для документа заявления о переводе части целевого паевого взноса',
|
||||
})
|
||||
public readonly meta!: GenerationToMainWalletConvertStatementSignedMetaDocumentInputDTO;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
import { ValidateNested } from 'class-validator';
|
||||
import { IsBoolean, IsOptional, ValidateNested } from 'class-validator';
|
||||
import { CreateInitOrganizationDataInputDTO } from '~/application/account/dto/create-organization-data-input.dto';
|
||||
|
||||
@InputType('Init')
|
||||
@@ -9,4 +9,13 @@ export class InitDTO {
|
||||
})
|
||||
@ValidateNested()
|
||||
organization_data!: CreateInitOrganizationDataInputDTO;
|
||||
|
||||
@Field(() => Boolean, {
|
||||
nullable: true,
|
||||
description:
|
||||
'Признак того, что инициализация выполняется со стороны провайдера. При true coopback ставит init_by_server=true (org_data становится readonly для пользовательского визарда). Поле передаёт provider в callInitSystemMutation.',
|
||||
})
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
is_server_init?: boolean;
|
||||
}
|
||||
|
||||
@@ -27,10 +27,14 @@ export class InitInteractor {
|
||||
}
|
||||
|
||||
// Определяем тип инициализации:
|
||||
// - Если mono не существует - это ПЕРВАЯ инициализация (может быть как серверная так и пользовательская)
|
||||
// - Если existingMono.init_by_server уже установлен - сохраняем его (не меняем источник)
|
||||
// - Если mono существует но init_by_server не установлен - значит это пользовательская инициализация
|
||||
const isServerInit = !existingMono ? true : existingMono.init_by_server === true;
|
||||
// - Если data.is_server_init === true (вызов от провайдера через server-secret) — ВСЕГДА серверная.
|
||||
// Это разблокирует перезапись user-init данных провайдером при следующем initSystem.
|
||||
// - Иначе сохраняем прежнюю логику: первая инициализация = серверная, повторная — наследует флаг.
|
||||
const isServerInit = data.is_server_init === true
|
||||
? true
|
||||
: !existingMono
|
||||
? true
|
||||
: existingMono.init_by_server === true;
|
||||
|
||||
// Проверяем права на обновление:
|
||||
// Пользователь не может обновлять данные, установленные сервером
|
||||
@@ -58,11 +62,11 @@ export class InitInteractor {
|
||||
await this.monoStatusRepository.setStatus(SystemStatus.initialized);
|
||||
}
|
||||
|
||||
// Устанавливаем флаг источника инициализации только если это первая инициализация
|
||||
// - Для серверной инициализации (первый раз): устанавливаем true
|
||||
// - Для пользовательской инициализации (первый раз): устанавливаем false
|
||||
// - При повторной инициализации: НЕ меняем флаг (сохраняем изначальный источник)
|
||||
if (!existingMono) {
|
||||
// Устанавливаем флаг источника инициализации:
|
||||
// - Первая инициализация: ставим isServerInit как есть (true для server-side, true для user-side при первом init — историческое поведение).
|
||||
// - Повторная: если is_server_init=true пришёл от провайдера — поднимаем флаг в true (даже если до этого было user-init).
|
||||
// Это нужно чтобы провайдер мог перезаписать данные после того как пользователь успел заполнить форму первым.
|
||||
if (!existingMono || data.is_server_init === true) {
|
||||
await this.monoStatusRepository.setInitByServer(isServerInit);
|
||||
}
|
||||
|
||||
|
||||
@@ -93,8 +93,9 @@ const envVarsSchema = z.object({
|
||||
.default('https://союз-русь.рф/anketa')
|
||||
.describe('ссылка на анкету для получения членства в союзе кооперативов'),
|
||||
IS_UNIONED: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.string()
|
||||
.default('true')
|
||||
.transform((v) => v === 'true')
|
||||
.describe('флаг, указывающий что требуется членство в союзе для подключения к кооперативной экономике'),
|
||||
MATRIX_UNION_PERSON_ID: z.string().optional().describe('Matrix userId представителя союза для связи с кооперативами'),
|
||||
MATRIX_UNION_NAME: z.string().default('СПО РУСЬ').describe('Название союза для подписания комнат связи'),
|
||||
@@ -170,6 +171,28 @@ const envVarsSchema = z.object({
|
||||
OPENAI_BASE_URL: z.string().optional().describe('Базовый URL для Whisper API (через chatcoop-proxy nginx)'),
|
||||
WHISPER_MODEL: z.string().default('whisper-1').describe('Модель Whisper для STT'),
|
||||
WHISPER_LANGUAGE: z.string().default('ru').describe('Язык для Whisper STT'),
|
||||
|
||||
// Файловое хранилище (MinIO в dev/контуре кооператива; S3 в проде по плану E59-N).
|
||||
// MINIO_ENDPOINT без default: если не задан — file storage стартует в no-op,
|
||||
// HeadBucket не делается, операции get/put отдают понятную ошибку.
|
||||
MINIO_ENDPOINT: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Endpoint S3-совместимого бэкенда; в compose — service name. Пусто = file storage отключён.'),
|
||||
MINIO_ACCESS_KEY: z.string().default('minioadmin').describe('Access-key для MinIO/S3'),
|
||||
MINIO_SECRET_KEY: z.string().default('minioadmin').describe('Secret-key для MinIO/S3'),
|
||||
MINIO_BUCKET: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Имя физического бакета; по умолчанию `coop-${COOPNAME}`'),
|
||||
FILE_STORAGE_SIGNING_SECRET: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('HMAC-секрет для подписи read-URL; пусто — берётся SERVER_SECRET'),
|
||||
FILE_STORAGE_PUBLIC_BASE_URL: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('База публичного URL контроллера для read-URL; пусто — берётся BACKEND_URL'),
|
||||
});
|
||||
|
||||
const envInput = isSchemaGeneration ? { ...SCHEMA_GEN_ENV_DEFAULTS, ...process.env } : process.env;
|
||||
@@ -293,4 +316,12 @@ export default {
|
||||
whisper_model: envVars.data.WHISPER_MODEL,
|
||||
whisper_language: envVars.data.WHISPER_LANGUAGE,
|
||||
},
|
||||
file_storage: {
|
||||
endpoint: envVars.data.MINIO_ENDPOINT,
|
||||
access_key: envVars.data.MINIO_ACCESS_KEY,
|
||||
secret_key: envVars.data.MINIO_SECRET_KEY,
|
||||
bucket: envVars.data.MINIO_BUCKET || `coop-${envVars.data.COOPNAME}`,
|
||||
signing_secret: envVars.data.FILE_STORAGE_SIGNING_SECRET || envVars.data.SERVER_SECRET,
|
||||
public_base_url: envVars.data.FILE_STORAGE_PUBLIC_BASE_URL || envVars.data.BACKEND_URL,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,4 +2,8 @@ import type { CreateOrganizationDataInputDomainInterface } from '~/domain/accoun
|
||||
|
||||
export interface InitInputDomainInterface {
|
||||
organization_data: CreateOrganizationDataInputDomainInterface;
|
||||
// Если true — инициализация инициирована провайдером (через server-secret),
|
||||
// флаг init_by_server проставляется в true безусловно. Если undefined/false —
|
||||
// сохраняется текущая логика: setInitByServer(true) только при первой init.
|
||||
is_server_init?: boolean;
|
||||
}
|
||||
|
||||
+4
-2
@@ -90,14 +90,16 @@ export class CommitOutputDTO extends BaseOutputDTO {
|
||||
commit_hash!: string;
|
||||
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Описание коммита',
|
||||
})
|
||||
description!: string;
|
||||
description?: string;
|
||||
|
||||
@Field(() => String, {
|
||||
nullable: true,
|
||||
description: 'Метаданные коммита',
|
||||
})
|
||||
meta!: string;
|
||||
meta?: string;
|
||||
|
||||
@Field(() => GraphQLJSON, {
|
||||
nullable: true,
|
||||
|
||||
+13
-47
@@ -7,9 +7,11 @@ import { RolesGuard } from '~/application/auth/guards/roles.guard';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
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 { TransactionDTO } from '~/application/common/dto/transaction-result-response.dto';
|
||||
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
|
||||
import { GenerationToMainWalletConvertStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/generation-to-main-wallet-convert-statement-document.dto';
|
||||
import { GenerationConvertStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/generation-convert-statement-document.dto';
|
||||
import { GenerateDocumentOptionsInputDTO } from '~/application/document/dto/generate-document-options-input.dto';
|
||||
import { GenerateDocumentInputDTO } from '~/application/document/dto/generate-document-input.dto';
|
||||
|
||||
@@ -57,60 +59,24 @@ export class DistributionManagementResolver {
|
||||
// ============ ГЕНЕРАЦИЯ ДОКУМЕНТОВ ============
|
||||
|
||||
/**
|
||||
* Мутация для генерации заявления о конвертации из генерации в основной кошелек
|
||||
* Мутация для генерации заявления о конвертации целевого паевого взноса
|
||||
* (универсальный шаблон: в Цифровой Кошелёк и/или в программу «Благорост»)
|
||||
*/
|
||||
@Mutation(() => GeneratedDocumentDTO, {
|
||||
name: 'capitalGenerateGenerationToMainWalletConvertStatement',
|
||||
description: 'Сгенерировать заявление о конвертации из генерации в основной кошелек',
|
||||
name: 'capitalGenerateGenerationConvertStatement',
|
||||
description: 'Сгенерировать заявление о конвертации целевого паевого взноса (в Цифровой Кошелёк и/или в программу «Благорост»)',
|
||||
})
|
||||
@Throttle({ default: { limit: 3, ttl: 60000 } })
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman', 'member'])
|
||||
async generateGenerationToMainWalletConvertStatement(
|
||||
@Args('data', { type: () => GenerationToMainWalletConvertStatementGenerateDocumentInputDTO })
|
||||
data: GenerationToMainWalletConvertStatementGenerateDocumentInputDTO,
|
||||
async generateGenerationConvertStatement(
|
||||
@Args('data', { type: () => GenerationConvertStatementGenerateDocumentInputDTO })
|
||||
data: GenerationConvertStatementGenerateDocumentInputDTO,
|
||||
@Args('options', { type: () => GenerateDocumentOptionsInputDTO, nullable: true })
|
||||
options: GenerateDocumentOptionsInputDTO
|
||||
options: GenerateDocumentOptionsInputDTO,
|
||||
@CurrentUser() currentUser: MonoAccountDomainInterface
|
||||
): Promise<GeneratedDocumentDTO> {
|
||||
return this.distributionManagementService.generateGenerationToMainWalletConvertStatement(data, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Мутация для генерации заявления о конвертации из генерации в проектный кошелек
|
||||
*/
|
||||
@Mutation(() => GeneratedDocumentDTO, {
|
||||
name: 'capitalGenerateGenerationToProjectConvertStatement',
|
||||
description: 'Сгенерировать заявление о конвертации из генерации в проектный кошелек',
|
||||
})
|
||||
@Throttle({ default: { limit: 3, ttl: 60000 } })
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman', 'member'])
|
||||
async generateGenerationToProjectConvertStatement(
|
||||
@Args('data', { type: () => GenerateDocumentInputDTO })
|
||||
data: GenerateDocumentInputDTO,
|
||||
@Args('options', { type: () => GenerateDocumentOptionsInputDTO, nullable: true })
|
||||
options: GenerateDocumentOptionsInputDTO
|
||||
): Promise<GeneratedDocumentDTO> {
|
||||
return this.distributionManagementService.generateGenerationToProjectConvertStatement(data, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Мутация для генерации заявления о конвертации из генерации в благорост
|
||||
*/
|
||||
@Mutation(() => GeneratedDocumentDTO, {
|
||||
name: 'capitalGenerateGenerationToCapitalizationConvertStatement',
|
||||
description: 'Сгенерировать заявление о конвертации из генерации в благорост',
|
||||
})
|
||||
@Throttle({ default: { limit: 3, ttl: 60000 } })
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman', 'member'])
|
||||
async generateGenerationToCapitalizationConvertStatement(
|
||||
@Args('data', { type: () => GenerateDocumentInputDTO })
|
||||
data: GenerateDocumentInputDTO,
|
||||
@Args('options', { type: () => GenerateDocumentOptionsInputDTO, nullable: true })
|
||||
options: GenerateDocumentOptionsInputDTO
|
||||
): Promise<GeneratedDocumentDTO> {
|
||||
return this.distributionManagementService.generateGenerationToCapitalizationConvertStatement(data, options);
|
||||
return this.distributionManagementService.generateGenerationConvertStatement(data, options, currentUser);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+11
-41
@@ -5,10 +5,11 @@ import type { RefreshProgramInputDTO } from '../dto/distribution_management/refr
|
||||
import type { TransactResult } from '@wharfkit/session';
|
||||
import { GenerateDocumentOptionsInputDTO } from '~/application/document/dto/generate-document-options-input.dto';
|
||||
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
|
||||
import { GenerationToMainWalletConvertStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/generation-to-main-wallet-convert-statement-document.dto';
|
||||
import { GenerationConvertStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/generation-convert-statement-document.dto';
|
||||
import { DocumentInteractor } from '~/application/document/interactors/document.interactor';
|
||||
import { Cooperative } from 'cooptypes';
|
||||
import type { GenerateDocumentInputDTO } from '~/application/document/dto/generate-document-input.dto';
|
||||
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
|
||||
|
||||
/**
|
||||
* Сервис уровня приложения для управления распределением в CAPITAL
|
||||
@@ -40,50 +41,19 @@ export class DistributionManagementService {
|
||||
// ============ МЕТОДЫ ГЕНЕРАЦИИ ДОКУМЕНТОВ ============
|
||||
|
||||
/**
|
||||
* Генерация заявления о конвертации из генерации в основной кошелек
|
||||
* Генерация заявления о конвертации целевого паевого взноса
|
||||
* (универсальный шаблон: в Цифровой Кошелёк и/или в программу «Благорост»)
|
||||
*/
|
||||
async generateGenerationToMainWalletConvertStatement(
|
||||
data: GenerationToMainWalletConvertStatementGenerateDocumentInputDTO,
|
||||
options: GenerateDocumentOptionsInputDTO
|
||||
async generateGenerationConvertStatement(
|
||||
data: GenerationConvertStatementGenerateDocumentInputDTO,
|
||||
options: GenerateDocumentOptionsInputDTO,
|
||||
currentUser: MonoAccountDomainInterface
|
||||
): Promise<GeneratedDocumentDTO> {
|
||||
const enrichedData = await this.distributionManagementInteractor.prepareGenerationConvertStatementData(data, currentUser);
|
||||
const document = await this.documentInteractor.generateDocument({
|
||||
data: {
|
||||
...data,
|
||||
registry_id: Cooperative.Registry.GenerationToMainWalletConvertStatement.registry_id,
|
||||
},
|
||||
options,
|
||||
});
|
||||
return document as GeneratedDocumentDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерация заявления о конвертации из генерации в проектный кошелек
|
||||
*/
|
||||
async generateGenerationToProjectConvertStatement(
|
||||
data: GenerateDocumentInputDTO,
|
||||
options: GenerateDocumentOptionsInputDTO
|
||||
): Promise<GeneratedDocumentDTO> {
|
||||
const document = await this.documentInteractor.generateDocument({
|
||||
data: {
|
||||
...data,
|
||||
registry_id: Cooperative.Registry.GenerationToProjectConvertStatement.registry_id,
|
||||
},
|
||||
options,
|
||||
});
|
||||
return document as GeneratedDocumentDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерация заявления о конвертации из генерации в благорост
|
||||
*/
|
||||
async generateGenerationToCapitalizationConvertStatement(
|
||||
data: GenerateDocumentInputDTO,
|
||||
options: GenerateDocumentOptionsInputDTO
|
||||
): Promise<GeneratedDocumentDTO> {
|
||||
const document = await this.documentInteractor.generateDocument({
|
||||
data: {
|
||||
...data,
|
||||
registry_id: Cooperative.Registry.GenerationToCapitalizationConvertStatement.registry_id,
|
||||
...enrichedData,
|
||||
registry_id: Cooperative.Registry.GenerationConvertStatement.registry_id,
|
||||
},
|
||||
options,
|
||||
});
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@ export class MutationLogMapperService {
|
||||
// Результаты и конвертация
|
||||
capitalPushResult: LogEventType.RESULT_PUSHED,
|
||||
capitalConvertSegment: LogEventType.SEGMENT_CONVERTED,
|
||||
capitalGenerateGenerationToMainWalletConvertStatement: LogEventType.PROJECT_WITHDRAWAL,
|
||||
capitalGenerateGenerationConvertStatement: LogEventType.PROJECT_WITHDRAWAL,
|
||||
capitalGenerateCapitalizationToMainWalletConvertStatement: LogEventType.PROGRAM_WITHDRAWAL,
|
||||
|
||||
// Генерация - Stories, Issues, Cycles
|
||||
|
||||
+8
@@ -41,6 +41,14 @@ export class TimeTrackingService {
|
||||
await this.timeTrackingInteractor.recalcDoneEstimatesForContributorProject(contributorHash, projectHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Откатить time-entries отклонённого коммита обратно в uncommitted и нормализовать
|
||||
* раскладку estimate-долей для затронутых задач.
|
||||
*/
|
||||
async revertEntriesForDeclinedCommit(commitHash: string): Promise<void> {
|
||||
await this.timeTrackingInteractor.revertEntriesForDeclinedCommit(commitHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить статистику времени для участника по проекту (DTO версия)
|
||||
*/
|
||||
|
||||
+34
-1
@@ -3,6 +3,10 @@ import { CapitalBlockchainPort, CAPITAL_BLOCKCHAIN_PORT } from '../../domain/int
|
||||
import type { TransactResult } from '@wharfkit/session';
|
||||
import type { FundProgramDomainInput } from '../../domain/actions/fund-program-domain-input.interface';
|
||||
import type { RefreshProgramDomainInput } from '../../domain/actions/refresh-program-domain-input.interface';
|
||||
import { APPENDIX_REPOSITORY, AppendixRepository } from '../../domain/repositories/appendix.repository';
|
||||
import { GenerationConvertStatementGenerateDocumentInputDTO } from '~/application/document/documents-dto/generation-convert-statement-document.dto';
|
||||
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
|
||||
import { Cooperative } from 'cooptypes';
|
||||
|
||||
/**
|
||||
* Интерактор домена для распределения средств в CAPITAL контракте
|
||||
@@ -12,7 +16,9 @@ import type { RefreshProgramDomainInput } from '../../domain/actions/refresh-pro
|
||||
export class DistributionManagementInteractor {
|
||||
constructor(
|
||||
@Inject(CAPITAL_BLOCKCHAIN_PORT)
|
||||
private readonly capitalBlockchainPort: CapitalBlockchainPort
|
||||
private readonly capitalBlockchainPort: CapitalBlockchainPort,
|
||||
@Inject(APPENDIX_REPOSITORY)
|
||||
private readonly appendixRepository: AppendixRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -32,4 +38,31 @@ export class DistributionManagementInteractor {
|
||||
return await this.capitalBlockchainPort.refreshProgram(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Подготавливает данные для генерации заявления о конвертации целевого паевого взноса.
|
||||
* appendix_hash подтягивается по (username, project_hash) из подтверждённого приложения к проекту.
|
||||
*/
|
||||
async prepareGenerationConvertStatementData(
|
||||
data: GenerationConvertStatementGenerateDocumentInputDTO,
|
||||
currentUser: MonoAccountDomainInterface
|
||||
): Promise<Cooperative.Registry.GenerationConvertStatement.Action> {
|
||||
const projectHash = data.project_hash;
|
||||
if (!projectHash) {
|
||||
throw new Error('project_hash обязателен для генерации заявления о конвертации');
|
||||
}
|
||||
|
||||
const userAppendix = await this.appendixRepository.findConfirmedByUsernameAndProjectHash(
|
||||
currentUser.username,
|
||||
projectHash
|
||||
);
|
||||
|
||||
if (!userAppendix) {
|
||||
throw new Error(`Не найдено подтверждённое приложение пользователя ${currentUser.username} для проекта ${projectHash}`);
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
appendix_hash: userAppendix.appendix_hash,
|
||||
} as Cooperative.Registry.GenerationConvertStatement.Action;
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -358,6 +358,11 @@ export class GenerationInteractor {
|
||||
commit.status = CommitStatus.DECLINED;
|
||||
await this.commitRepository.save(commit);
|
||||
|
||||
// Откатываем time-entries обратно в uncommitted и нормализуем раскладку долей
|
||||
// по затронутым DONE-задачам. Иначе часы остаются в total_committed_hours и
|
||||
// не возвращаются в доступный пул.
|
||||
await this.timeTrackingService.revertEntriesForDeclinedCommit(data.commit_hash);
|
||||
|
||||
// Создаём данные для блокчейна
|
||||
const blockchainData: CapitalContract.Actions.CommitDecline.ICommitDecline = {
|
||||
coopname: data.coopname,
|
||||
@@ -429,6 +434,10 @@ export class GenerationInteractor {
|
||||
// Сохранить изменения
|
||||
await this.commitRepository.save(commit);
|
||||
|
||||
// Откатить time-entries и нормализовать раскладку (идемпотентно: если decline
|
||||
// уже был обработан через локальный путь, revert вернёт 0 затронутых).
|
||||
await this.timeTrackingService.revertEntriesForDeclinedCommit(actionPayload.commit_hash);
|
||||
|
||||
this.logger.debug(`Коммит ${actionPayload.commit_hash} отклонен`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка при обработке отклонения коммита: ${error?.message}`, error?.stack);
|
||||
|
||||
+536
@@ -0,0 +1,536 @@
|
||||
import { TimeTrackingInteractor } from './time-tracking.interactor';
|
||||
import type { TimeEntryRepository } from '../../domain/repositories/time-entry.repository';
|
||||
import type { ProjectRepository } from '../../domain/repositories/project.repository';
|
||||
import type { ContributorRepository } from '../../domain/repositories/contributor.repository';
|
||||
import type { IssueRepository } from '../../domain/repositories/issue.repository';
|
||||
import { TimeEntryDomainEntity } from '../../domain/entities/time-entry.entity';
|
||||
import { IssueStatus } from '../../domain/enums/issue-status.enum';
|
||||
import { IssuePriority } from '../../domain/enums/issue-priority.enum';
|
||||
import { IssueDomainEntity } from '../../domain/entities/issue.entity';
|
||||
import type { ContributorDomainEntity } from '../../domain/entities/contributor.entity';
|
||||
import { ContributorStatus } from '../../domain/enums/contributor-status.enum';
|
||||
|
||||
// Сценарии калиброваны по проду voskhod (issue CC7-1, estimate=15, 3 creators)
|
||||
// чтобы локально воспроизводить найденные баги и предотвращать регрессию.
|
||||
|
||||
type Mocked<T> = { [K in keyof T]: jest.Mock };
|
||||
|
||||
function makeContributor(username: string, hash: string, coopname = 'voskhod'): ContributorDomainEntity {
|
||||
return {
|
||||
contributor_hash: hash,
|
||||
username,
|
||||
coopname,
|
||||
display_name: username,
|
||||
status: ContributorStatus.ACTIVE,
|
||||
hours_per_day: 8,
|
||||
} as unknown as ContributorDomainEntity;
|
||||
}
|
||||
|
||||
function makeIssue(opts: {
|
||||
issue_hash: string;
|
||||
project_hash?: string;
|
||||
coopname?: string;
|
||||
estimate: number;
|
||||
status: IssueStatus;
|
||||
creators: string[];
|
||||
id?: string;
|
||||
}): IssueDomainEntity {
|
||||
return {
|
||||
id: opts.id ?? 'TST-1',
|
||||
issue_hash: opts.issue_hash,
|
||||
project_hash: opts.project_hash ?? 'project-hash-1',
|
||||
coopname: opts.coopname ?? 'voskhod',
|
||||
title: 'Test issue',
|
||||
priority: IssuePriority.MEDIUM,
|
||||
status: opts.status,
|
||||
estimate: opts.estimate,
|
||||
creators: opts.creators,
|
||||
created_by: opts.creators[0] ?? 'ant',
|
||||
sort_order: 0,
|
||||
metadata: { labels: [], attachments: [] },
|
||||
} as unknown as IssueDomainEntity;
|
||||
}
|
||||
|
||||
function makeEntry(opts: {
|
||||
contributor_hash: string;
|
||||
issue_hash: string;
|
||||
hours: number;
|
||||
is_committed: boolean;
|
||||
entry_type?: 'hourly' | 'estimate';
|
||||
estimate_snapshot?: number;
|
||||
commit_hash?: string;
|
||||
_id?: string;
|
||||
date?: string;
|
||||
}): TimeEntryDomainEntity {
|
||||
return new TimeEntryDomainEntity({
|
||||
_id: opts._id ?? `e-${Math.random()}`,
|
||||
contributor_hash: opts.contributor_hash,
|
||||
issue_hash: opts.issue_hash,
|
||||
project_hash: 'project-hash-1',
|
||||
coopname: 'voskhod',
|
||||
date: opts.date ?? '2026-05-15',
|
||||
hours: opts.hours,
|
||||
is_committed: opts.is_committed,
|
||||
entry_type: opts.entry_type ?? 'estimate',
|
||||
estimate_snapshot: opts.estimate_snapshot,
|
||||
commit_hash: opts.commit_hash,
|
||||
block_num: 0,
|
||||
present: false,
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
|
||||
function buildInteractor() {
|
||||
const timeEntryRepository: Mocked<TimeEntryRepository> = {
|
||||
create: jest.fn().mockImplementation(async (e: TimeEntryDomainEntity) => e),
|
||||
findByContributorAndDate: jest.fn().mockResolvedValue([]),
|
||||
findUncommittedByContributor: jest.fn().mockResolvedValue([]),
|
||||
findUncommittedByProjectAndContributor: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockImplementation(async (e: TimeEntryDomainEntity) => e),
|
||||
updateMany: jest.fn().mockResolvedValue(undefined),
|
||||
getTotalUncommittedHours: jest.fn().mockResolvedValue(0),
|
||||
getContributorProjectStats: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ total_committed_hours: 0, total_uncommitted_hours: 0 }),
|
||||
commitTimeEntries: jest.fn().mockResolvedValue(undefined),
|
||||
revertCommittedEntriesByCommitHash: jest.fn().mockResolvedValue(0),
|
||||
findCommittedByCommitHash: jest.fn().mockResolvedValue([]),
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
deleteUncommittedByIssueHash: jest.fn().mockResolvedValue(undefined),
|
||||
findProjectsByContributor: jest.fn().mockResolvedValue([]),
|
||||
findContributorsByProject: jest.fn().mockResolvedValue([]),
|
||||
findByProjectWithPagination: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ items: [], totalCount: 0, currentPage: 1, totalPages: 0 }),
|
||||
getAggregatedTimeEntriesByIssues: jest.fn().mockResolvedValue([]),
|
||||
getAggregatedTimeEntriesCount: jest.fn().mockResolvedValue(0),
|
||||
findByIssueAndType: jest.fn().mockResolvedValue([]),
|
||||
getTotalEstimateHoursByIssue: jest.fn().mockResolvedValue({ total: 0, estimate_snapshot: 0 }),
|
||||
hasCommittedTimeByIssueHash: jest.fn().mockResolvedValue(false),
|
||||
updateProjectHashByIssueHash: jest.fn().mockResolvedValue(undefined),
|
||||
getFactByIssues: jest.fn().mockResolvedValue(new Map()),
|
||||
};
|
||||
|
||||
const contributorRepository: Partial<Mocked<ContributorRepository>> = {
|
||||
findByUsernameAndCoopname: jest.fn().mockResolvedValue(null),
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
findByStatusAndCoopname: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const issueRepository: Partial<Mocked<IssueRepository>> = {
|
||||
findByIssueHash: jest.fn().mockResolvedValue(null),
|
||||
findByStatus: jest.fn().mockResolvedValue([]),
|
||||
findByStatusAndCreators: jest.fn().mockResolvedValue([]),
|
||||
findCompletedByProjectAndCreators: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const projectRepository: Partial<Mocked<ProjectRepository>> = {
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
findByHash: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
const logger = {
|
||||
setContext: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
log: jest.fn(),
|
||||
verbose: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const interactor = new TimeTrackingInteractor(
|
||||
timeEntryRepository as unknown as TimeEntryRepository,
|
||||
projectRepository as unknown as ProjectRepository,
|
||||
contributorRepository as unknown as ContributorRepository,
|
||||
issueRepository as unknown as IssueRepository,
|
||||
logger
|
||||
);
|
||||
|
||||
return { interactor, timeEntryRepository, contributorRepository, issueRepository, projectRepository };
|
||||
}
|
||||
|
||||
function expectEntryCreate(
|
||||
mock: jest.Mock,
|
||||
expected: Partial<{ contributor_hash: string; hours: number; entry_type: string; is_committed: boolean }>
|
||||
): void {
|
||||
const calls = mock.mock.calls.map((c) => c[0] as TimeEntryDomainEntity);
|
||||
const match = calls.find((entry) => {
|
||||
if (expected.contributor_hash && entry.contributor_hash !== expected.contributor_hash) return false;
|
||||
if (expected.hours !== undefined && Math.abs(entry.hours - expected.hours) > 1e-6) return false;
|
||||
if (expected.entry_type !== undefined && entry.entry_type !== expected.entry_type) return false;
|
||||
if (expected.is_committed !== undefined && entry.is_committed !== expected.is_committed) return false;
|
||||
return true;
|
||||
});
|
||||
if (!match) {
|
||||
const summary = calls
|
||||
.map((e) => `{contributor=${e.contributor_hash}, hours=${e.hours}, type=${e.entry_type}, committed=${e.is_committed}}`)
|
||||
.join('\n ');
|
||||
throw new Error(`No create() call matched ${JSON.stringify(expected)}.\nActual calls:\n ${summary}`);
|
||||
}
|
||||
}
|
||||
|
||||
describe('TimeTrackingInteractor.applyExplicitEstimateToTimeEntries', () => {
|
||||
it('делит estimate поровну между creators при первой установке', async () => {
|
||||
const { interactor, timeEntryRepository, contributorRepository } = buildInteractor();
|
||||
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
|
||||
makeContributor(u, `${u}-hash`)
|
||||
);
|
||||
timeEntryRepository.findByIssueAndType.mockResolvedValue([]);
|
||||
|
||||
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
|
||||
await interactor.applyExplicitEstimateToTimeEntries(issue);
|
||||
|
||||
expect(timeEntryRepository.deleteUncommittedByIssueHash).toHaveBeenCalledWith('i1');
|
||||
expect(timeEntryRepository.create).toHaveBeenCalledTimes(3);
|
||||
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'ant-hash', hours: 5, entry_type: 'estimate', is_committed: false });
|
||||
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'smr-hash', hours: 5, entry_type: 'estimate', is_committed: false });
|
||||
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'dvl-hash', hours: 5, entry_type: 'estimate', is_committed: false });
|
||||
});
|
||||
|
||||
it('при estimate=0 только удаляет незакоммиченные, новых не создаёт', async () => {
|
||||
const { interactor, timeEntryRepository } = buildInteractor();
|
||||
const issue = makeIssue({ issue_hash: 'i1', estimate: 0, status: IssueStatus.DONE, creators: ['ant'] });
|
||||
await interactor.applyExplicitEstimateToTimeEntries(issue);
|
||||
expect(timeEntryRepository.deleteUncommittedByIssueHash).toHaveBeenCalledWith('i1');
|
||||
expect(timeEntryRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('БАГ #1: не даёт «двойную долю» creator-у, который уже закоммитил свою часть', async () => {
|
||||
// Прод-сценарий CC7-1: ant закоммитил полную долю (5 ч). При recalc/applyExplicit
|
||||
// его доля = 5 − 5 = 0 (а не 10/3 = 3.33 как было в баговой версии). Остальные
|
||||
// получают свои 5 ч, без «бонусной перераздачи остатка».
|
||||
const { interactor, timeEntryRepository, contributorRepository } = buildInteractor();
|
||||
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
|
||||
makeContributor(u, `${u}-hash`)
|
||||
);
|
||||
timeEntryRepository.findByIssueAndType.mockResolvedValue([
|
||||
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: true, entry_type: 'estimate', estimate_snapshot: 15 }),
|
||||
]);
|
||||
|
||||
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
|
||||
await interactor.applyExplicitEstimateToTimeEntries(issue);
|
||||
|
||||
// ant не должен получить uncommitted estimate (он уже закоммитил полную долю)
|
||||
const createCalls = timeEntryRepository.create.mock.calls.map((c) => c[0] as TimeEntryDomainEntity);
|
||||
const antEntries = createCalls.filter((e) => e.contributor_hash === 'ant-hash');
|
||||
expect(antEntries).toHaveLength(0);
|
||||
|
||||
// Остальные двое получают по 5 ч каждый, а не 10/3
|
||||
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'smr-hash', hours: 5 });
|
||||
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'dvl-hash', hours: 5 });
|
||||
expect(timeEntryRepository.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('частичный committed уменьшает долю только этого creator', async () => {
|
||||
const { interactor, timeEntryRepository, contributorRepository } = buildInteractor();
|
||||
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
|
||||
makeContributor(u, `${u}-hash`)
|
||||
);
|
||||
// Смуров закоммитил 3 из 5 → его остаток 2, у остальных по 5
|
||||
timeEntryRepository.findByIssueAndType.mockResolvedValue([
|
||||
makeEntry({ contributor_hash: 'smr-hash', issue_hash: 'i1', hours: 3, is_committed: true, entry_type: 'estimate', estimate_snapshot: 15 }),
|
||||
]);
|
||||
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
|
||||
await interactor.applyExplicitEstimateToTimeEntries(issue);
|
||||
|
||||
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'ant-hash', hours: 5 });
|
||||
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'smr-hash', hours: 2 });
|
||||
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'dvl-hash', hours: 5 });
|
||||
expect(timeEntryRepository.create).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('overcommitted (закоммичено больше доли) даёт 0, не отрицательное значение', async () => {
|
||||
const { interactor, timeEntryRepository, contributorRepository } = buildInteractor();
|
||||
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
|
||||
makeContributor(u, `${u}-hash`)
|
||||
);
|
||||
timeEntryRepository.findByIssueAndType.mockResolvedValue([
|
||||
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 8, is_committed: true, entry_type: 'estimate', estimate_snapshot: 15 }),
|
||||
]);
|
||||
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
|
||||
await interactor.applyExplicitEstimateToTimeEntries(issue);
|
||||
|
||||
const antCreates = timeEntryRepository.create.mock.calls
|
||||
.map((c) => c[0] as TimeEntryDomainEntity)
|
||||
.filter((e) => e.contributor_hash === 'ant-hash');
|
||||
expect(antCreates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('пустой список creators — удаляет uncommitted, не создаёт новых', async () => {
|
||||
const { interactor, timeEntryRepository } = buildInteractor();
|
||||
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: [] });
|
||||
await interactor.applyExplicitEstimateToTimeEntries(issue);
|
||||
expect(timeEntryRepository.deleteUncommittedByIssueHash).toHaveBeenCalledWith('i1');
|
||||
expect(timeEntryRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimeTrackingInteractor.recalcDoneEstimatesForContributorProject', () => {
|
||||
it('no-op если раскладка уже совпадает с планом', async () => {
|
||||
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
|
||||
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
|
||||
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
|
||||
makeContributor(u, `${u}-hash`)
|
||||
);
|
||||
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
|
||||
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([issue]);
|
||||
timeEntryRepository.findByIssueAndType.mockResolvedValue([
|
||||
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
|
||||
makeEntry({ contributor_hash: 'smr-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
|
||||
makeEntry({ contributor_hash: 'dvl-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
|
||||
]);
|
||||
await interactor.recalcDoneEstimatesForContributorProject('ant-hash', 'project-hash-1');
|
||||
expect(timeEntryRepository.deleteUncommittedByIssueHash).not.toHaveBeenCalled();
|
||||
expect(timeEntryRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('лечит раскладку 10/3 (баговую) обратно к 5/5/5 когда есть закоммитившие', async () => {
|
||||
// Воспроизведение прод-сценария CC7-1 после фикса: ant закоммитил 5 ч (committed),
|
||||
// в БД остались баговые 3.333 uncommitted у всех. Recalc должен снести их и
|
||||
// оставить ant=0, остальным по 5.
|
||||
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
|
||||
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
|
||||
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
|
||||
makeContributor(u, `${u}-hash`)
|
||||
);
|
||||
const issue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
|
||||
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([issue]);
|
||||
timeEntryRepository.findByIssueAndType.mockResolvedValue([
|
||||
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: true, entry_type: 'estimate', estimate_snapshot: 15 }),
|
||||
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 3.3333333, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
|
||||
makeEntry({ contributor_hash: 'smr-hash', issue_hash: 'i1', hours: 3.3333333, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
|
||||
makeEntry({ contributor_hash: 'dvl-hash', issue_hash: 'i1', hours: 3.3333333, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15 }),
|
||||
]);
|
||||
|
||||
await interactor.recalcDoneEstimatesForContributorProject('ant-hash', 'project-hash-1');
|
||||
|
||||
expect(timeEntryRepository.deleteUncommittedByIssueHash).toHaveBeenCalledWith('i1');
|
||||
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'smr-hash', hours: 5 });
|
||||
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'dvl-hash', hours: 5 });
|
||||
// ant не получает uncommitted (он уже закоммитил свою долю)
|
||||
const antCreates = timeEntryRepository.create.mock.calls
|
||||
.map((c) => c[0] as TimeEntryDomainEntity)
|
||||
.filter((e) => e.contributor_hash === 'ant-hash');
|
||||
expect(antCreates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('пропускает задачи без estimate', async () => {
|
||||
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
|
||||
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
|
||||
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([
|
||||
makeIssue({ issue_hash: 'i1', estimate: 0, status: IssueStatus.DONE, creators: ['ant'] }),
|
||||
]);
|
||||
await interactor.recalcDoneEstimatesForContributorProject('ant-hash', 'project-hash-1');
|
||||
expect(timeEntryRepository.deleteUncommittedByIssueHash).not.toHaveBeenCalled();
|
||||
expect(timeEntryRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimeTrackingInteractor.commitTime', () => {
|
||||
it('БАГ #2: при partial split сохраняет entry_type=estimate и estimate_snapshot', async () => {
|
||||
// Прод-сценарий: у Смурова 3.333 ч estimate uncommitted, коммит 3 ч → split.
|
||||
// До фикса новая committed-запись создавалась с entry_type='hourly' (default).
|
||||
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
|
||||
contributorRepository.findOne!.mockResolvedValue(makeContributor('smr', 'smr-hash'));
|
||||
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([
|
||||
makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] }),
|
||||
]);
|
||||
const entry = makeEntry({
|
||||
contributor_hash: 'smr-hash',
|
||||
issue_hash: 'i1',
|
||||
hours: 5,
|
||||
is_committed: false,
|
||||
entry_type: 'estimate',
|
||||
estimate_snapshot: 15,
|
||||
_id: 'orig',
|
||||
});
|
||||
timeEntryRepository.findUncommittedByProjectAndContributor.mockResolvedValue([entry]);
|
||||
|
||||
await interactor.commitTime('smr-hash', 'project-hash-1', 3, 'commit-hash-1');
|
||||
|
||||
expect(timeEntryRepository.create).toHaveBeenCalledTimes(1);
|
||||
const created = timeEntryRepository.create.mock.calls[0][0] as TimeEntryDomainEntity;
|
||||
expect(created.is_committed).toBe(true);
|
||||
expect(created.hours).toBe(3);
|
||||
expect(created.entry_type).toBe('estimate');
|
||||
expect(created.estimate_snapshot).toBe(15);
|
||||
expect(created.commit_hash).toBe('commit-hash-1');
|
||||
|
||||
expect(timeEntryRepository.update).toHaveBeenCalledTimes(1);
|
||||
const updated = timeEntryRepository.update.mock.calls[0][0] as TimeEntryDomainEntity;
|
||||
expect(updated._id).toBe('orig');
|
||||
expect(updated.hours).toBeCloseTo(2, 6);
|
||||
});
|
||||
|
||||
it('full commit (entry.hours == requested) помечает оригинал, не создаёт split', async () => {
|
||||
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
|
||||
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
|
||||
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([
|
||||
makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] }),
|
||||
]);
|
||||
timeEntryRepository.findUncommittedByProjectAndContributor.mockResolvedValue([
|
||||
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15, _id: 'orig' }),
|
||||
]);
|
||||
|
||||
await interactor.commitTime('ant-hash', 'project-hash-1', 5, 'commit-hash-2');
|
||||
|
||||
expect(timeEntryRepository.create).not.toHaveBeenCalled();
|
||||
expect(timeEntryRepository.commitTimeEntries).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('коммит только по DONE задачам — отфильтровывает entries по активным задачам', async () => {
|
||||
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
|
||||
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
|
||||
// Только i1 в DONE, i2 в IN_PROGRESS
|
||||
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([
|
||||
makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant'] }),
|
||||
]);
|
||||
timeEntryRepository.findUncommittedByProjectAndContributor.mockResolvedValue([
|
||||
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate' }),
|
||||
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i2', hours: 3, is_committed: false, entry_type: 'hourly' }),
|
||||
]);
|
||||
|
||||
await interactor.commitTime('ant-hash', 'project-hash-1', 3, 'commit-hash-3');
|
||||
|
||||
// Должен коммитить из i1 (DONE), не из i2
|
||||
const committedArgs = timeEntryRepository.commitTimeEntries.mock.calls[0]?.[0] as TimeEntryDomainEntity[] | undefined;
|
||||
const splitCreate = timeEntryRepository.create.mock.calls[0]?.[0] as TimeEntryDomainEntity | undefined;
|
||||
const touchedIssues = new Set<string>();
|
||||
committedArgs?.forEach((e) => touchedIssues.add(e.issue_hash));
|
||||
if (splitCreate) touchedIssues.add(splitCreate.issue_hash);
|
||||
expect(touchedIssues.has('i2')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TimeTrackingInteractor.revertEntriesForDeclinedCommit', () => {
|
||||
it('БАГ #3: вызывает revert + нормализует раскладку по затронутой DONE-задаче', async () => {
|
||||
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
|
||||
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
|
||||
makeContributor(u, `${u}-hash`)
|
||||
);
|
||||
const reverted = makeEntry({
|
||||
contributor_hash: 'ant-hash',
|
||||
issue_hash: 'i1',
|
||||
hours: 5,
|
||||
is_committed: true,
|
||||
entry_type: 'estimate',
|
||||
estimate_snapshot: 15,
|
||||
commit_hash: 'commit-bad',
|
||||
});
|
||||
timeEntryRepository.findCommittedByCommitHash.mockResolvedValue([reverted]);
|
||||
timeEntryRepository.revertCommittedEntriesByCommitHash.mockResolvedValue(1);
|
||||
issueRepository.findByIssueHash!.mockResolvedValue(
|
||||
makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] })
|
||||
);
|
||||
// После revert у ant'a 0 committed (revert убрал) — поэтому новая раскладка 5/5/5
|
||||
timeEntryRepository.findByIssueAndType.mockResolvedValue([]);
|
||||
|
||||
await interactor.revertEntriesForDeclinedCommit('commit-bad');
|
||||
|
||||
expect(timeEntryRepository.revertCommittedEntriesByCommitHash).toHaveBeenCalledWith('commit-bad');
|
||||
expect(timeEntryRepository.deleteUncommittedByIssueHash).toHaveBeenCalledWith('i1');
|
||||
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'ant-hash', hours: 5, entry_type: 'estimate' });
|
||||
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'smr-hash', hours: 5, entry_type: 'estimate' });
|
||||
expectEntryCreate(timeEntryRepository.create, { contributor_hash: 'dvl-hash', hours: 5, entry_type: 'estimate' });
|
||||
});
|
||||
|
||||
it('идемпотентно: повторный вызов на коммит без записей — no-op', async () => {
|
||||
const { interactor, timeEntryRepository } = buildInteractor();
|
||||
timeEntryRepository.findCommittedByCommitHash.mockResolvedValue([]);
|
||||
await interactor.revertEntriesForDeclinedCommit('commit-already-reverted');
|
||||
expect(timeEntryRepository.revertCommittedEntriesByCommitHash).not.toHaveBeenCalled();
|
||||
expect(timeEntryRepository.deleteUncommittedByIssueHash).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('не пересчитывает задачи которые больше не DONE', async () => {
|
||||
const { interactor, timeEntryRepository, issueRepository } = buildInteractor();
|
||||
timeEntryRepository.findCommittedByCommitHash.mockResolvedValue([
|
||||
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 3, is_committed: true, entry_type: 'estimate', estimate_snapshot: 15, commit_hash: 'c1' }),
|
||||
]);
|
||||
timeEntryRepository.revertCommittedEntriesByCommitHash.mockResolvedValue(1);
|
||||
issueRepository.findByIssueHash!.mockResolvedValue(
|
||||
makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.IN_PROGRESS, creators: ['ant'] })
|
||||
);
|
||||
await interactor.revertEntriesForDeclinedCommit('c1');
|
||||
expect(timeEntryRepository.revertCommittedEntriesByCommitHash).toHaveBeenCalled();
|
||||
expect(timeEntryRepository.deleteUncommittedByIssueHash).not.toHaveBeenCalled();
|
||||
expect(timeEntryRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration scenario: CC7-1 прод-инцидент', () => {
|
||||
it('после декларации estimate=15 на 3 creators даёт по 5 ч каждому, total uncommitted=15', async () => {
|
||||
const { interactor, timeEntryRepository, contributorRepository } = buildInteractor();
|
||||
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
|
||||
makeContributor(u, `${u}-hash`)
|
||||
);
|
||||
const issue = makeIssue({
|
||||
issue_hash: 'cc7-1-hash',
|
||||
estimate: 15,
|
||||
status: IssueStatus.DONE,
|
||||
creators: ['ant', 'zxfevlujlica', 'ipesgnlxmnwx'],
|
||||
});
|
||||
timeEntryRepository.findByIssueAndType.mockResolvedValue([]);
|
||||
|
||||
await interactor.applyExplicitEstimateToTimeEntries(issue);
|
||||
|
||||
const creates = timeEntryRepository.create.mock.calls.map((c) => c[0] as TimeEntryDomainEntity);
|
||||
expect(creates).toHaveLength(3);
|
||||
const totalHours = creates.reduce((s, e) => s + e.hours, 0);
|
||||
expect(totalHours).toBeCloseTo(15, 6);
|
||||
creates.forEach((e) => {
|
||||
expect(e.hours).toBeCloseTo(5, 6);
|
||||
expect(e.entry_type).toBe('estimate');
|
||||
expect(e.is_committed).toBe(false);
|
||||
expect(e.estimate_snapshot).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
it('сценарий full lifecycle: коммит → decline → revert восстанавливает доступное время', async () => {
|
||||
const { interactor, timeEntryRepository, contributorRepository, issueRepository } = buildInteractor();
|
||||
contributorRepository.findOne!.mockResolvedValue(makeContributor('ant', 'ant-hash'));
|
||||
contributorRepository.findByUsernameAndCoopname!.mockImplementation(async (u: string) =>
|
||||
makeContributor(u, `${u}-hash`)
|
||||
);
|
||||
const doneIssue = makeIssue({ issue_hash: 'i1', estimate: 15, status: IssueStatus.DONE, creators: ['ant', 'smr', 'dvl'] });
|
||||
issueRepository.findCompletedByProjectAndCreators!.mockResolvedValue([doneIssue]);
|
||||
issueRepository.findByIssueHash!.mockResolvedValue(doneIssue);
|
||||
|
||||
// 1. Стартовое состояние: 5/5/5 uncommitted
|
||||
const initial = [
|
||||
makeEntry({ contributor_hash: 'ant-hash', issue_hash: 'i1', hours: 5, is_committed: false, entry_type: 'estimate', estimate_snapshot: 15, _id: 'a1' }),
|
||||
];
|
||||
timeEntryRepository.findUncommittedByProjectAndContributor.mockResolvedValue(initial);
|
||||
|
||||
// 2. ant коммитит свои 5 ч
|
||||
await interactor.commitTime('ant-hash', 'project-hash-1', 5, 'commit-ant');
|
||||
expect(timeEntryRepository.commitTimeEntries).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 3. Симулируем что в БД теперь есть committed estimate 5 ч от ant'a
|
||||
const committedEntry = makeEntry({
|
||||
contributor_hash: 'ant-hash',
|
||||
issue_hash: 'i1',
|
||||
hours: 5,
|
||||
is_committed: true,
|
||||
entry_type: 'estimate',
|
||||
estimate_snapshot: 15,
|
||||
commit_hash: 'commit-ant',
|
||||
});
|
||||
timeEntryRepository.findCommittedByCommitHash.mockResolvedValue([committedEntry]);
|
||||
timeEntryRepository.revertCommittedEntriesByCommitHash.mockResolvedValue(1);
|
||||
// После revert у ant'а 0 committed estimate → новая раскладка 5/5/5
|
||||
timeEntryRepository.findByIssueAndType.mockResolvedValue([]);
|
||||
|
||||
// 4. Decline → revert
|
||||
timeEntryRepository.create.mockClear();
|
||||
timeEntryRepository.deleteUncommittedByIssueHash.mockClear();
|
||||
await interactor.revertEntriesForDeclinedCommit('commit-ant');
|
||||
|
||||
// 5. После revert: 5/5/5 uncommitted
|
||||
const creates = timeEntryRepository.create.mock.calls.map((c) => c[0] as TimeEntryDomainEntity);
|
||||
const total = creates.reduce((s, e) => s + e.hours, 0);
|
||||
expect(total).toBeCloseTo(15, 6);
|
||||
expect(creates.every((e) => e.entry_type === 'estimate' && !e.is_committed)).toBe(true);
|
||||
});
|
||||
});
|
||||
+115
-102
@@ -49,64 +49,116 @@ export class TimeTrackingInteractor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Реакция на фактическое изменение оценки (вызывать только если сохранённое и новое значение
|
||||
* различаются): снимаем все незакоммиченные билеты по задаче, затем при ненулевой оценке создаём
|
||||
* новые записи estimate (поровну между исполнителями). При оценке 0 — только очистка.
|
||||
* Реакция на фактическое изменение оценки: пересобирает незакоммиченные estimate-билеты
|
||||
* задачи под текущий состав creators и новую оценку, учитывая личный committed-баланс
|
||||
* каждого исполнителя. При оценке 0 — только очистка незакоммиченных.
|
||||
*/
|
||||
async applyExplicitEstimateToTimeEntries(issue: IssueDomainEntity): Promise<void> {
|
||||
const newEstimate = issue.estimate ?? 0;
|
||||
|
||||
await this.timeEntryRepository.deleteUncommittedByIssueHash(issue.issue_hash);
|
||||
|
||||
if (isNegligibleHours(newEstimate)) {
|
||||
await this.timeEntryRepository.deleteUncommittedByIssueHash(issue.issue_hash);
|
||||
this.logger.debug(
|
||||
`applyExplicitEstimateToTimeEntries: задача ${issue.id} (${issue.issue_hash}), оценка снята или обнулена — незакоммиченные билеты по задаче удалены`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.redistributeIssueEstimateEntries(issue, newEstimate, { force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Пересобрать незакоммиченные estimate-билеты задачи: для каждого creator личная
|
||||
* доля = estimate / N, минус его уже закоммиченные estimate-часы по этой же задаче.
|
||||
* Закоммиченные записи не трогаются. «Остаток» одного creator не перераспределяется
|
||||
* между другими — каждый учитывается изолированно.
|
||||
*
|
||||
* @param opts.force true — пересоздаёт всегда; false — пропускает no-op когда раскладка
|
||||
* уже совпадает с планом.
|
||||
* @returns true если что-то пересоздано/удалено, false если no-op
|
||||
*/
|
||||
private async redistributeIssueEstimateEntries(
|
||||
issue: IssueDomainEntity,
|
||||
estimate: number,
|
||||
opts: { force: boolean }
|
||||
): Promise<boolean> {
|
||||
const creators = issue.creators || [];
|
||||
if (creators.length === 0) {
|
||||
await this.timeEntryRepository.deleteUncommittedByIssueHash(issue.issue_hash);
|
||||
this.logger.warn(
|
||||
`applyExplicitEstimateToTimeEntries: задача ${issue.id} (${issue.issue_hash}) без исполнителей — билеты очищены, начисление estimate пропущено`
|
||||
`redistributeIssueEstimateEntries: задача ${issue.id} (${issue.issue_hash}) без исполнителей — билеты очищены`
|
||||
);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const hoursPerCreator = newEstimate / creators.length;
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
const sharePerCreator = estimate / creators.length;
|
||||
|
||||
const estimateEntries = await this.timeEntryRepository.findByIssueAndType(issue.issue_hash, 'estimate');
|
||||
const committedByContributor = new Map<string, number>();
|
||||
for (const entry of estimateEntries) {
|
||||
if (!entry.is_committed) continue;
|
||||
committedByContributor.set(
|
||||
entry.contributor_hash,
|
||||
(committedByContributor.get(entry.contributor_hash) || 0) + entry.hours
|
||||
);
|
||||
}
|
||||
|
||||
type Plan = { contributor_hash: string; uncommittedShare: number };
|
||||
const plan: Plan[] = [];
|
||||
for (const creatorUsername of creators) {
|
||||
const contributor = await this.contributorRepository.findByUsernameAndCoopname(creatorUsername, issue.coopname);
|
||||
if (!contributor) {
|
||||
this.logger.warn(
|
||||
`applyExplicitEstimateToTimeEntries: исполнитель ${creatorUsername} не найден в ${issue.coopname}, пропуск`
|
||||
`redistributeIssueEstimateEntries: исполнитель ${creatorUsername} не найден в ${issue.coopname}, пропуск`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const myCommitted = committedByContributor.get(contributor.contributor_hash) || 0;
|
||||
const uncommittedShare = Math.max(0, sharePerCreator - myCommitted);
|
||||
plan.push({ contributor_hash: contributor.contributor_hash, uncommittedShare });
|
||||
}
|
||||
|
||||
const estimateEntry = new TimeEntryDomainEntity({
|
||||
_id: '',
|
||||
contributor_hash: contributor.contributor_hash,
|
||||
issue_hash: issue.issue_hash,
|
||||
project_hash: issue.project_hash,
|
||||
coopname: issue.coopname,
|
||||
date,
|
||||
hours: hoursPerCreator,
|
||||
is_committed: false,
|
||||
block_num: 0,
|
||||
present: false,
|
||||
status: 'active',
|
||||
entry_type: 'estimate',
|
||||
estimate_snapshot: newEstimate,
|
||||
});
|
||||
if (!opts.force) {
|
||||
const currentUncommitted = estimateEntries.filter((e) => !e.is_committed);
|
||||
const planNonZero = plan.filter((p) => p.uncommittedShare > HOURS_FLOAT_EPSILON);
|
||||
const planByHash = new Map(planNonZero.map((p) => [p.contributor_hash, p.uncommittedShare]));
|
||||
const isValid =
|
||||
currentUncommitted.length === planNonZero.length &&
|
||||
currentUncommitted.every((entry) => {
|
||||
const expected = planByHash.get(entry.contributor_hash);
|
||||
return expected !== undefined && hoursAlmostEqual(entry.hours, expected);
|
||||
});
|
||||
if (isValid) return false;
|
||||
}
|
||||
|
||||
await this.timeEntryRepository.create(estimateEntry);
|
||||
await this.timeEntryRepository.deleteUncommittedByIssueHash(issue.issue_hash);
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
for (const item of plan) {
|
||||
if (item.uncommittedShare <= HOURS_FLOAT_EPSILON) continue;
|
||||
await this.timeEntryRepository.create(
|
||||
new TimeEntryDomainEntity({
|
||||
_id: '',
|
||||
contributor_hash: item.contributor_hash,
|
||||
issue_hash: issue.issue_hash,
|
||||
project_hash: issue.project_hash,
|
||||
coopname: issue.coopname,
|
||||
date,
|
||||
hours: item.uncommittedShare,
|
||||
is_committed: false,
|
||||
block_num: 0,
|
||||
present: false,
|
||||
status: 'active',
|
||||
entry_type: 'estimate',
|
||||
estimate_snapshot: estimate,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`applyExplicitEstimateToTimeEntries: задача ${issue.id} (${issue.issue_hash}), выставлена оценка ${newEstimate} ч — незакоммиченные билеты заменены`
|
||||
`redistributeIssueEstimateEntries: задача ${issue.id} (${issue.issue_hash}), estimate=${estimate} ч, ` +
|
||||
`share=${sharePerCreator} ч × ${creators.length} creators, незакоммиченные пересозданы (${plan.length} участников)`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,12 +169,38 @@ export class TimeTrackingInteractor {
|
||||
await this.timeEntryRepository.deleteUncommittedByIssueHash(issueHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Откатить time-entries отклонённого коммита обратно в uncommitted и нормализовать
|
||||
* раскладку estimate-долей для затронутых DONE-задач (если их состав creators
|
||||
* актуален). Идемпотентно: вызов на коммит без time-entries — no-op.
|
||||
*/
|
||||
async revertEntriesForDeclinedCommit(commitHash: string): Promise<void> {
|
||||
const reverted = await this.timeEntryRepository.findCommittedByCommitHash(commitHash);
|
||||
if (reverted.length === 0) return;
|
||||
|
||||
await this.timeEntryRepository.revertCommittedEntriesByCommitHash(commitHash);
|
||||
|
||||
const issueHashes = [...new Set(reverted.map((e) => e.issue_hash))];
|
||||
for (const issueHash of issueHashes) {
|
||||
const issue = await this.issueRepository.findByIssueHash(issueHash);
|
||||
if (!issue) continue;
|
||||
if (issue.status !== IssueStatus.DONE) continue;
|
||||
const estimate = issue.estimate ?? 0;
|
||||
if (isNegligibleHours(estimate)) continue;
|
||||
await this.redistributeIssueEstimateEntries(issue, estimate, { force: true });
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`revertEntriesForDeclinedCommit: коммит ${commitHash}, откачено ${reverted.length} записей, ` +
|
||||
`пересчитано ${issueHashes.length} задач`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Лечебный пересчёт: для всех DONE-задач проекта, где участник сейчас в creators,
|
||||
* сносит незакоммиченные estimate-билеты и распределяет оставшуюся часть estimate
|
||||
* (estimate − уже закоммиченные часы по задаче) поровну между текущими creators.
|
||||
* Идемпотентно. Вызывается, например, перед createCommit, чтобы вылечить расхождения,
|
||||
* накопившиеся после смен creators/статусов, не прошедших через applyExplicitEstimate.
|
||||
* пересобирает незакоммиченные estimate-билеты по личным долям creators
|
||||
* (estimate / N − собственный committed). Идемпотентно. Вызывается перед createCommit
|
||||
* и из миграций, чтобы вылечить расхождения после смен creators/статусов.
|
||||
*/
|
||||
async recalcDoneEstimatesForContributorProject(contributorHash: string, projectHash: string): Promise<void> {
|
||||
const contributor = await this.contributorRepository.findOne({ contributor_hash: contributorHash });
|
||||
@@ -135,76 +213,7 @@ export class TimeTrackingInteractor {
|
||||
for (const issue of completedIssues) {
|
||||
const estimate = issue.estimate ?? 0;
|
||||
if (isNegligibleHours(estimate)) continue;
|
||||
|
||||
const creators = issue.creators || [];
|
||||
if (creators.length === 0) continue;
|
||||
|
||||
const estimateEntries = await this.timeEntryRepository.findByIssueAndType(issue.issue_hash, 'estimate');
|
||||
const committedTotal = estimateEntries
|
||||
.filter((entry) => entry.is_committed)
|
||||
.reduce((sum, entry) => sum + entry.hours, 0);
|
||||
const uncommittedTotal = estimateEntries
|
||||
.filter((entry) => !entry.is_committed)
|
||||
.reduce((sum, entry) => sum + entry.hours, 0);
|
||||
|
||||
const remaining = estimate - committedTotal;
|
||||
|
||||
const expectedHoursPerCreator = remaining > HOURS_FLOAT_EPSILON ? remaining / creators.length : 0;
|
||||
const currentCreatorHashes = new Set<string>();
|
||||
for (const creatorUsername of creators) {
|
||||
const creatorContributor = await this.contributorRepository.findByUsernameAndCoopname(
|
||||
creatorUsername,
|
||||
issue.coopname
|
||||
);
|
||||
if (creatorContributor) currentCreatorHashes.add(creatorContributor.contributor_hash);
|
||||
}
|
||||
|
||||
const uncommittedEntries = estimateEntries.filter((entry) => !entry.is_committed);
|
||||
const distributionLooksValid =
|
||||
uncommittedEntries.length === currentCreatorHashes.size &&
|
||||
uncommittedEntries.every(
|
||||
(entry) =>
|
||||
currentCreatorHashes.has(entry.contributor_hash) &&
|
||||
hoursAlmostEqual(entry.hours, expectedHoursPerCreator)
|
||||
) &&
|
||||
hoursAlmostEqual(uncommittedTotal, remaining > HOURS_FLOAT_EPSILON ? remaining : 0);
|
||||
|
||||
if (distributionLooksValid) continue;
|
||||
|
||||
await this.timeEntryRepository.deleteUncommittedByIssueHash(issue.issue_hash);
|
||||
if (remaining <= HOURS_FLOAT_EPSILON) continue;
|
||||
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
for (const creatorUsername of creators) {
|
||||
const creatorContributor = await this.contributorRepository.findByUsernameAndCoopname(
|
||||
creatorUsername,
|
||||
issue.coopname
|
||||
);
|
||||
if (!creatorContributor) continue;
|
||||
|
||||
await this.timeEntryRepository.create(
|
||||
new TimeEntryDomainEntity({
|
||||
_id: '',
|
||||
contributor_hash: creatorContributor.contributor_hash,
|
||||
issue_hash: issue.issue_hash,
|
||||
project_hash: issue.project_hash,
|
||||
coopname: issue.coopname,
|
||||
date,
|
||||
hours: expectedHoursPerCreator,
|
||||
is_committed: false,
|
||||
block_num: 0,
|
||||
present: false,
|
||||
status: 'active',
|
||||
entry_type: 'estimate',
|
||||
estimate_snapshot: estimate,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`recalcDoneEstimatesForContributorProject: задача ${issue.id} (${issue.issue_hash}) — пересчитаны estimate-билеты: ` +
|
||||
`estimate=${estimate} ч, committed=${committedTotal} ч, distributed=${expectedHoursPerCreator} ч × ${creators.length}`
|
||||
);
|
||||
await this.redistributeIssueEstimateEntries(issue, estimate, { force: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -801,7 +810,9 @@ export class TimeTrackingInteractor {
|
||||
entriesToCommit.push(entry);
|
||||
remainingHours -= entry.hours;
|
||||
} else {
|
||||
// Коммитим часть записи - создаём новую запись с оставшимся временем
|
||||
// Коммитим часть записи - создаём новую запись с оставшимся временем.
|
||||
// entry_type и estimate_snapshot копируем из оригинала — иначе recalc estimate
|
||||
// не увидит закоммиченную долю (он фильтрует только entry_type='estimate').
|
||||
const committedEntry = new TimeEntryDomainEntity({
|
||||
_id: '',
|
||||
contributor_hash: entry.contributor_hash,
|
||||
@@ -812,6 +823,8 @@ export class TimeTrackingInteractor {
|
||||
hours: remainingHours,
|
||||
commit_hash: commitHash,
|
||||
is_committed: true,
|
||||
entry_type: entry.entry_type,
|
||||
estimate_snapshot: entry.estimate_snapshot,
|
||||
block_num: entry.block_num,
|
||||
present: entry.present,
|
||||
status: entry.status,
|
||||
|
||||
+15
@@ -59,6 +59,21 @@ export interface TimeEntryRepository {
|
||||
*/
|
||||
commitTimeEntries(entries: TimeEntryDomainEntity[], commitHash: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Найти закоммиченные time-entries по commit_hash. Используется при decline,
|
||||
* чтобы понять какие задачи затронуты до отката.
|
||||
*/
|
||||
findCommittedByCommitHash(commitHash: string): Promise<TimeEntryDomainEntity[]>;
|
||||
|
||||
/**
|
||||
* Откатить закоммиченные time-entries по commit_hash обратно в uncommitted
|
||||
* (is_committed=false, commit_hash=NULL). Используется при decline коммита, чтобы
|
||||
* часы вернулись в доступный пул. Идемпотентно.
|
||||
*
|
||||
* @returns количество затронутых записей
|
||||
*/
|
||||
revertCommittedEntriesByCommitHash(commitHash: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* Удалить запись времени
|
||||
*/
|
||||
|
||||
+18
@@ -121,6 +121,24 @@ export class TimeEntryTypeormRepository implements TimeEntryRepository {
|
||||
await this.repository.update({ _id: In(ids) }, { commit_hash: commitHash, is_committed: true, _updated_at: new Date() });
|
||||
}
|
||||
|
||||
async findCommittedByCommitHash(commitHash: string): Promise<TimeEntryDomainEntity[]> {
|
||||
const entities = await this.repository.find({
|
||||
where: { commit_hash: commitHash, is_committed: true },
|
||||
});
|
||||
return entities.map((entity) => this.toDomain(entity));
|
||||
}
|
||||
|
||||
async revertCommittedEntriesByCommitHash(commitHash: string): Promise<number> {
|
||||
const result = await this.repository
|
||||
.createQueryBuilder()
|
||||
.update(TimeEntryEntity)
|
||||
.set({ is_committed: false, commit_hash: null as unknown as undefined, _updated_at: new Date() })
|
||||
.where('commit_hash = :commitHash', { commitHash })
|
||||
.andWhere('is_committed = true')
|
||||
.execute();
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.repository.delete(id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
# FileStorage
|
||||
|
||||
Универсальное файловое хранилище контура кооператива (MinIO в v1, готовность к нативному S3).
|
||||
Подробности и архитектура — req **«SPEC: файловое хранилище v1»** в blago.
|
||||
|
||||
## Когда использовать
|
||||
|
||||
Расширению нужно положить и потом отдать пайщику бинарный файл — изображение, PDF, в перспективе
|
||||
любой другой blob. Не катить свой S3-клиент и не плодить ad-hoc эндпоинты.
|
||||
|
||||
## Минимальный пример
|
||||
|
||||
### 1. Сервис расширения декларирует свой бакет
|
||||
|
||||
```ts
|
||||
// extensions/stol-zakazov/order-images.service.ts
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
InjectBucket,
|
||||
UseBucket,
|
||||
type InterFileStorageBucket,
|
||||
} from '~/infrastructure/file-storage';
|
||||
import type { FileUpload } from 'graphql-upload';
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
@UseBucket({
|
||||
name: 'stol-zakazov:images',
|
||||
maxBytes: 10 * MB,
|
||||
allowedMime: ['image/jpeg', 'image/png', 'image/webp'],
|
||||
metadataSchema: { ownerId: 'required', orderId: 'required' },
|
||||
defaultUrlTtlSeconds: 600,
|
||||
})
|
||||
@Injectable()
|
||||
export class OrderImagesService {
|
||||
constructor(@InjectBucket() private readonly bucket: InterFileStorageBucket) {}
|
||||
|
||||
async attach(orderId: string, ownerId: string, upload: FileUpload): Promise<string> {
|
||||
const key = `orders/${orderId}/main.${ext(upload.mimetype)}`;
|
||||
await this.bucket.put(key, upload.createReadStream(), {
|
||||
contentType: upload.mimetype,
|
||||
metadata: { ownerId, orderId },
|
||||
});
|
||||
return key;
|
||||
}
|
||||
|
||||
async displayUrl(key: string): Promise<string> {
|
||||
return this.bucket.getReadUrl(key);
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
await this.bucket.delete(key); // идемпотентно
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Конвенция:** один бакет — один сервис. Нужен второй бакет — сделай второй сервис.
|
||||
|
||||
### 2. Модуль расширения регистрирует сервис в `forFeature`
|
||||
|
||||
```ts
|
||||
// extensions/stol-zakazov/stol-zakazov.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FileStorageInfrastructureModule } from '~/infrastructure/file-storage';
|
||||
import { OrderImagesService } from './order-images.service';
|
||||
|
||||
@Module({
|
||||
imports: [FileStorageInfrastructureModule.forFeature([OrderImagesService])],
|
||||
providers: [OrderImagesService],
|
||||
exports: [OrderImagesService],
|
||||
})
|
||||
export class StolZakazovModule {}
|
||||
```
|
||||
|
||||
`forFeature` читает `@UseBucket` метадату каждого класса и провайдит ему конкретный
|
||||
`InterFileStorageBucket` (с уже подмешанным префиксом). Корневой `forRoot(...)` уже подключён
|
||||
в `app.module.ts`.
|
||||
|
||||
### 3. GraphQL — обычная мутация с Upload и поле URL в ответе
|
||||
|
||||
```graphql
|
||||
type Mutation {
|
||||
createOrderItem(input: CreateOrderItemInput!): OrderItem!
|
||||
}
|
||||
|
||||
input CreateOrderItemInput {
|
||||
title: String!
|
||||
image: Upload!
|
||||
}
|
||||
|
||||
type OrderItem {
|
||||
id: ID!
|
||||
title: String!
|
||||
imageUrl: String! # резолвер: bucket.getReadUrl(key) после ACL-проверки
|
||||
}
|
||||
```
|
||||
|
||||
UI ходит обычным `<img src={imageUrl}>`. Никакого нового REST-слоя для GraphQL-клиента —
|
||||
служебная ручка `/api/storage/...` живёт сама и видит только подписанные URL.
|
||||
|
||||
## Операции
|
||||
|
||||
| Метод | Что делает | Гарантии |
|
||||
|---|---|---|
|
||||
| `put(key, body, opts)` | Загружает объект | Перезапись по тому же ключу, валидация `maxBytes`/`allowedMime`/`metadataSchema` |
|
||||
| `getReadUrl(key, opts?)` | Короткоживущий URL чтения | HMAC-signed URL контроллера; TTL по умолчанию из `defaultUrlTtlSeconds` спеки |
|
||||
| `delete(key)` | Удалить | Идемпотентно (нет объекта = успех) |
|
||||
| `head(key)` | Метаданные без скачивания | Бросает `InterFileStorageObjectNotFoundError` если нет |
|
||||
|
||||
`body` принимает `Uint8Array` или `ReadableStream<Uint8Array>` (graphql-upload `createReadStream()`
|
||||
также подходит — duck-typed, проверяется наличие `getReader()` или конвертируется адаптером).
|
||||
|
||||
## Ошибки
|
||||
|
||||
Всё — наследники `InterFileStorageError`. Доменный код мапит на свои коды:
|
||||
|
||||
- `InterFileStorageObjectNotFoundError` — нет объекта (`head`)
|
||||
- `InterFileStorageObjectTooLargeError` — превышен `maxBytes`
|
||||
- `InterFileStorageMimeRejectedError` — `contentType` не в `allowedMime`
|
||||
- `InterFileStorageMetadataValidationError` — нет required-метаданного
|
||||
- `InterFileStorageBackendUnavailableError` — недоступен MinIO/S3
|
||||
- `InterFileStorageBucketNotConfiguredError` — невалидная спека бакета
|
||||
|
||||
## Конфигурация (env)
|
||||
|
||||
| Переменная | Default | Заметка |
|
||||
|---|---|---|
|
||||
| `MINIO_ENDPOINT` | `http://minio:9000` | Compose service name; для запуска контроллера на хосте — `http://127.0.0.1:9000` |
|
||||
| `MINIO_ACCESS_KEY` | `minioadmin` | Только dev; на prod подменяется плейбуком |
|
||||
| `MINIO_SECRET_KEY` | `minioadmin` | То же |
|
||||
| `MINIO_BUCKET` | `coop-${COOPNAME}` | Физический бакет, идемпотентно создаётся на bootstrap |
|
||||
| `FILE_STORAGE_SIGNING_SECRET` | `SERVER_SECRET` | HMAC-секрет; на prod — отдельный |
|
||||
| `FILE_STORAGE_PUBLIC_BASE_URL` | `BACKEND_URL` | База URL для read-ссылок |
|
||||
|
||||
## Тесты
|
||||
|
||||
```bash
|
||||
# Юнит (моки)
|
||||
cd components/controller && npx jest src/infrastructure/file-storage/ -i
|
||||
|
||||
# Интеграционные против реального MinIO
|
||||
docker compose up -d minio # из mono-ai-3 root
|
||||
cd components/controller
|
||||
npm run test:integration:file-storage
|
||||
```
|
||||
|
||||
Тесты пропускаются автоматически (с диагностическим warning), если MinIO недоступен на
|
||||
`FILE_STORAGE_TEST_MINIO_ENDPOINT` (default `http://localhost:9000`).
|
||||
|
||||
## Что в коробке (файлы модуля)
|
||||
|
||||
- `file-storage.config.ts` — `FileStorageInfrastructureOptions` + токен `FILE_STORAGE_OPTIONS`
|
||||
- `bucket-registry.ts` — singleton, накапливает `BucketSpec` от декораторов
|
||||
- `use-bucket.decorator.ts` — `@UseBucket`/`@InjectBucket` + `bucketTokenFor`
|
||||
- `minio-file-storage.adapter.ts` — реализация `InterFileStoragePort` поверх `@aws-sdk/client-s3`,
|
||||
валидация спеки, `OnApplicationBootstrap` с `ensureBucketExists`
|
||||
- `signing.ts` — `signReadUrl` / `verifyReadUrl` (HMAC-SHA256, constant-time)
|
||||
- `file-storage-http.controller.ts` — `GET /api/storage/:bucket/:key` с проверкой подписи
|
||||
- `file-storage-infrastructure.module.ts` — `FileStorageModule.forRoot(...) / forFeature(consumers)`
|
||||
|
||||
## Не входит в v1
|
||||
|
||||
- `getWriteUrl()` (presigned PUT)
|
||||
- `list(prefix)`, `copy(src, dst)`
|
||||
- Lifecycle/retention на уровне порта
|
||||
- Привязка URL к userId (отдельный режим, если появится особо приватный контент)
|
||||
- Антивирусное сканирование
|
||||
|
||||
См. §11 спеки.
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { InterFileStorageBucketSpec } from '@coopenomics/inter';
|
||||
import { BucketRegistry } from './bucket-registry';
|
||||
|
||||
describe('BucketRegistry', () => {
|
||||
beforeEach(() => {
|
||||
BucketRegistry._resetForTests();
|
||||
});
|
||||
|
||||
const spec = (name: string): InterFileStorageBucketSpec => ({
|
||||
name,
|
||||
maxBytes: 1024,
|
||||
allowedMime: ['image/jpeg'],
|
||||
});
|
||||
|
||||
it('add + get возвращает зарегистрированную спеку', () => {
|
||||
class A {}
|
||||
BucketRegistry.add(A, spec('ext-a:images'));
|
||||
expect(BucketRegistry.get(A)?.name).toBe('ext-a:images');
|
||||
});
|
||||
|
||||
it('повторная регистрация того же класса под тем же именем — no-op (идемпотентно)', () => {
|
||||
class A {}
|
||||
BucketRegistry.add(A, spec('ext-a:images'));
|
||||
expect(() => BucketRegistry.add(A, spec('ext-a:images'))).not.toThrow();
|
||||
});
|
||||
|
||||
it('повторная регистрация того же класса под другим именем — выбрасывает', () => {
|
||||
class A {}
|
||||
BucketRegistry.add(A, spec('ext-a:images'));
|
||||
expect(() => BucketRegistry.add(A, spec('ext-a:other'))).toThrow(/уже зарегистрирован/);
|
||||
});
|
||||
|
||||
it('list возвращает все зарегистрированные пары', () => {
|
||||
class A {}
|
||||
class B {}
|
||||
BucketRegistry.add(A, spec('ext-a:images'));
|
||||
BucketRegistry.add(B, spec('ext-b:receipts'));
|
||||
const list = BucketRegistry.list();
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list.map((r) => r.spec.name).sort()).toEqual(['ext-a:images', 'ext-b:receipts']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { InterFileStorageBucketSpec } from '@coopenomics/inter';
|
||||
|
||||
/**
|
||||
* Глобальный реестр бакетов, наполняется декоратором `@UseBucket` на этапе загрузки модулей.
|
||||
* Используется `FileStorageInfrastructureModule.forFeature(...)` для wire-up per-class providers
|
||||
* и `MinioFileStorageAdapter.onApplicationBootstrap` для логирования / диагностики.
|
||||
*/
|
||||
export interface RegisteredBucket {
|
||||
readonly cls: { readonly name: string };
|
||||
readonly spec: InterFileStorageBucketSpec;
|
||||
}
|
||||
|
||||
const REGISTERED = new Map<{ name: string }, InterFileStorageBucketSpec>();
|
||||
|
||||
export const BucketRegistry = {
|
||||
add(cls: { name: string }, spec: InterFileStorageBucketSpec): void {
|
||||
const existing = REGISTERED.get(cls);
|
||||
if (existing && existing.name !== spec.name) {
|
||||
throw new Error(
|
||||
`BucketRegistry: класс ${cls.name} уже зарегистрирован под бакетом '${existing.name}', ` +
|
||||
`повторная регистрация под '${spec.name}' запрещена`,
|
||||
);
|
||||
}
|
||||
REGISTERED.set(cls, spec);
|
||||
},
|
||||
|
||||
get(cls: { name: string }): InterFileStorageBucketSpec | undefined {
|
||||
return REGISTERED.get(cls);
|
||||
},
|
||||
|
||||
list(): readonly RegisteredBucket[] {
|
||||
return Array.from(REGISTERED.entries()).map(([cls, spec]) => ({ cls, spec }));
|
||||
},
|
||||
|
||||
/** Только для тестов: очистка реестра между сценариями. */
|
||||
_resetForTests(): void {
|
||||
REGISTERED.clear();
|
||||
},
|
||||
};
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
import 'reflect-metadata';
|
||||
import { Readable } from 'stream';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import {
|
||||
InterFileStorageBackendUnavailableError,
|
||||
InterFileStorageObjectNotFoundError,
|
||||
} from '@coopenomics/inter';
|
||||
import {
|
||||
FILE_STORAGE_OPTIONS,
|
||||
type FileStorageInfrastructureOptions,
|
||||
} from './file-storage.config';
|
||||
import { FileStorageHttpController } from './file-storage-http.controller';
|
||||
import { MinioFileStorageAdapter } from './minio-file-storage.adapter';
|
||||
import { signReadUrl } from './signing';
|
||||
|
||||
const OPTS: FileStorageInfrastructureOptions = {
|
||||
endpoint: 'http://minio:9000',
|
||||
accessKey: 'AKIA-test',
|
||||
secretKey: 'sk-test',
|
||||
bucket: 'coop-test',
|
||||
signingSecret: 'signing-secret-32-bytes-aaaaaaaa',
|
||||
publicBaseUrl: 'https://test.example.org',
|
||||
};
|
||||
|
||||
interface AdapterStub {
|
||||
fetchObjectForReadProxy: jest.Mock;
|
||||
}
|
||||
|
||||
async function makeApp(stub: AdapterStub): Promise<INestApplication> {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [FileStorageHttpController],
|
||||
providers: [
|
||||
{ provide: FILE_STORAGE_OPTIONS, useValue: OPTS },
|
||||
{ provide: MinioFileStorageAdapter, useValue: stub },
|
||||
],
|
||||
}).compile();
|
||||
const app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
return app;
|
||||
}
|
||||
|
||||
function makeStream(bytes: Buffer): Readable {
|
||||
return Readable.from([bytes]);
|
||||
}
|
||||
|
||||
function makeStub(): AdapterStub {
|
||||
return { fetchObjectForReadProxy: jest.fn() };
|
||||
}
|
||||
|
||||
function urlFor(bucket: string, key: string, ttlSeconds: number): { path: string; exp: number; sig: string } {
|
||||
const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
|
||||
const sig = signReadUrl({ bucket, key, expUnix: exp, secret: OPTS.signingSecret });
|
||||
const encodedKey = key.split('/').map(encodeURIComponent).join('/');
|
||||
return { path: `/api/storage/${encodeURIComponent(bucket)}/${encodedKey}`, exp, sig };
|
||||
}
|
||||
|
||||
describe('FileStorageHttpController', () => {
|
||||
let app: INestApplication;
|
||||
let stub: AdapterStub;
|
||||
|
||||
beforeEach(() => {
|
||||
stub = makeStub();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
it('200 + байты при валидной подписи', async () => {
|
||||
app = await makeApp(stub);
|
||||
const payload = Buffer.from('hello world');
|
||||
stub.fetchObjectForReadProxy.mockResolvedValueOnce({
|
||||
stream: makeStream(payload),
|
||||
size: payload.length,
|
||||
contentType: 'image/jpeg',
|
||||
lastModified: new Date(),
|
||||
});
|
||||
|
||||
const u = urlFor('orders-images', 'orders/42/main.jpg', 60);
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(u.path)
|
||||
.query({ exp: u.exp, sig: u.sig })
|
||||
.buffer(true)
|
||||
.parse((response, cb) => {
|
||||
const chunks: Buffer[] = [];
|
||||
response.on('data', (c) => chunks.push(c));
|
||||
response.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/jpeg');
|
||||
expect(res.headers['content-length']).toBe(String(payload.length));
|
||||
expect(res.headers['cache-control']).toMatch(/^private, max-age=\d+$/);
|
||||
expect((res.body as Buffer).equals(payload)).toBe(true);
|
||||
|
||||
expect(stub.fetchObjectForReadProxy).toHaveBeenCalledWith('orders-images/orders/42/main.jpg');
|
||||
});
|
||||
|
||||
it('403 при неверной подписи', async () => {
|
||||
app = await makeApp(stub);
|
||||
const u = urlFor('orders-images', 'a.jpg', 60);
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(u.path)
|
||||
.query({ exp: u.exp, sig: 'a'.repeat(64) });
|
||||
expect(res.status).toBe(403);
|
||||
expect(stub.fetchObjectForReadProxy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('403 при истёкшем exp', async () => {
|
||||
app = await makeApp(stub);
|
||||
const exp = Math.floor(Date.now() / 1000) - 1;
|
||||
const sig = signReadUrl({ bucket: 'orders-images', key: 'a.jpg', expUnix: exp, secret: OPTS.signingSecret });
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/storage/orders-images/a.jpg')
|
||||
.query({ exp, sig });
|
||||
expect(res.status).toBe(403);
|
||||
expect(stub.fetchObjectForReadProxy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('403 при отсутствии exp/sig', async () => {
|
||||
app = await makeApp(stub);
|
||||
const res = await request(app.getHttpServer()).get('/api/storage/orders-images/a.jpg');
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('403 при подмене bucket в URL (HMAC связан с bucket)', async () => {
|
||||
app = await makeApp(stub);
|
||||
const exp = Math.floor(Date.now() / 1000) + 60;
|
||||
const sig = signReadUrl({
|
||||
bucket: 'orders-images',
|
||||
key: 'a.jpg',
|
||||
expUnix: exp,
|
||||
secret: OPTS.signingSecret,
|
||||
});
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/storage/expenses-receipts/a.jpg')
|
||||
.query({ exp, sig });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('403 при подмене key в URL (HMAC связан с key)', async () => {
|
||||
app = await makeApp(stub);
|
||||
const exp = Math.floor(Date.now() / 1000) + 60;
|
||||
const sig = signReadUrl({
|
||||
bucket: 'orders-images',
|
||||
key: 'a.jpg',
|
||||
expUnix: exp,
|
||||
secret: OPTS.signingSecret,
|
||||
});
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/storage/orders-images/b.jpg')
|
||||
.query({ exp, sig });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('404 при ObjectNotFoundError от адаптера', async () => {
|
||||
app = await makeApp(stub);
|
||||
stub.fetchObjectForReadProxy.mockRejectedValueOnce(
|
||||
new InterFileStorageObjectNotFoundError('missing'),
|
||||
);
|
||||
const u = urlFor('orders-images', 'missing.jpg', 60);
|
||||
const res = await request(app.getHttpServer()).get(u.path).query({ exp: u.exp, sig: u.sig });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('502 при BackendUnavailableError от адаптера', async () => {
|
||||
app = await makeApp(stub);
|
||||
stub.fetchObjectForReadProxy.mockRejectedValueOnce(
|
||||
new InterFileStorageBackendUnavailableError('backend down'),
|
||||
);
|
||||
const u = urlFor('orders-images', 'x.jpg', 60);
|
||||
const res = await request(app.getHttpServer()).get(u.path).query({ exp: u.exp, sig: u.sig });
|
||||
expect(res.status).toBe(502);
|
||||
});
|
||||
|
||||
it('правильно парсит составной ключ с / и спецсимволами', async () => {
|
||||
app = await makeApp(stub);
|
||||
const payload = Buffer.from('xy');
|
||||
stub.fetchObjectForReadProxy.mockResolvedValueOnce({
|
||||
stream: makeStream(payload),
|
||||
size: payload.length,
|
||||
contentType: 'application/pdf',
|
||||
lastModified: new Date(),
|
||||
});
|
||||
|
||||
const key = 'reports/q1 2026/r 1.pdf';
|
||||
const u = urlFor('expenses-receipts', key, 60);
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(u.path)
|
||||
.query({ exp: u.exp, sig: u.sig });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(stub.fetchObjectForReadProxy).toHaveBeenCalledWith(`expenses-receipts/${key}`);
|
||||
});
|
||||
});
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Logger,
|
||||
Param,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
InterFileStorageBackendUnavailableError,
|
||||
InterFileStorageObjectNotFoundError,
|
||||
} from '@coopenomics/inter';
|
||||
import {
|
||||
FILE_STORAGE_OPTIONS,
|
||||
type FileStorageInfrastructureOptions,
|
||||
} from './file-storage.config';
|
||||
import { MinioFileStorageAdapter } from './minio-file-storage.adapter';
|
||||
import { verifyReadUrl } from './signing';
|
||||
|
||||
/**
|
||||
* Служебная ручка отдачи объектов по HMAC-signed URL.
|
||||
*
|
||||
* Формат URL формирует `MinioFileStorageAdapter.getReadUrl`:
|
||||
* `<publicBaseUrl>/api/storage/:bucket/:key?exp=<unix-ts>&sig=<hmac-hex>`
|
||||
*
|
||||
* - `:bucket` — публичная форма имени бакета (`<extension>-<purpose>`).
|
||||
* - `:key` — caller-defined ключ; может содержать `/`, кодируется посегментно.
|
||||
* - `exp` — unix-timestamp истечения; `now < exp` обязательно.
|
||||
* - `sig` — `HMAC-SHA256(secret, "<bucket>\n<key>\n<exp>")`, hex; constant-time сверка.
|
||||
*
|
||||
* ACL принимается ДО получения URL — на стороне доменного резолвера. Любой держатель URL до
|
||||
* истечения TTL может скачать. Утечка → утечка контента до `exp`.
|
||||
*/
|
||||
@Controller('api/storage')
|
||||
export class FileStorageHttpController {
|
||||
private readonly logger = new Logger(FileStorageHttpController.name);
|
||||
|
||||
constructor(
|
||||
@Inject(FILE_STORAGE_OPTIONS)
|
||||
private readonly opts: FileStorageInfrastructureOptions,
|
||||
private readonly adapter: MinioFileStorageAdapter,
|
||||
) {}
|
||||
|
||||
@Get(':bucket/*')
|
||||
async serve(
|
||||
@Param('bucket') bucketRaw: string,
|
||||
@Query('exp') expRaw: string | undefined,
|
||||
@Query('sig') sig: string | undefined,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const bucket = safeDecodeURIComponent(bucketRaw);
|
||||
const restRaw = (req.params as Record<string, string>)['0'] ?? '';
|
||||
const key = decodeKeyFromPath(restRaw);
|
||||
const exp = Number(expRaw);
|
||||
|
||||
if (!Number.isFinite(exp) || !sig || !bucket || !key) {
|
||||
res.status(HttpStatus.FORBIDDEN).end();
|
||||
return;
|
||||
}
|
||||
const nowUnix = Math.floor(Date.now() / 1000);
|
||||
if (nowUnix >= exp) {
|
||||
res.status(HttpStatus.FORBIDDEN).end();
|
||||
return;
|
||||
}
|
||||
const ok = verifyReadUrl({
|
||||
bucket,
|
||||
key,
|
||||
expUnix: exp,
|
||||
sig,
|
||||
secret: this.opts.signingSecret,
|
||||
});
|
||||
if (!ok) {
|
||||
res.status(HttpStatus.FORBIDDEN).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const physicalKey = `${bucket}/${key}`;
|
||||
|
||||
let obj;
|
||||
try {
|
||||
obj = await this.adapter.fetchObjectForReadProxy(physicalKey);
|
||||
} catch (e) {
|
||||
if (e instanceof InterFileStorageObjectNotFoundError) {
|
||||
res.status(HttpStatus.NOT_FOUND).end();
|
||||
return;
|
||||
}
|
||||
if (e instanceof InterFileStorageBackendUnavailableError) {
|
||||
this.logger.warn(`backend недоступен на ${physicalKey}: ${(e as Error).message}`);
|
||||
res.status(HttpStatus.BAD_GATEWAY).end();
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const remaining = Math.max(0, exp - nowUnix);
|
||||
res.setHeader('Content-Type', obj.contentType);
|
||||
if (obj.size > 0) {
|
||||
res.setHeader('Content-Length', String(obj.size));
|
||||
}
|
||||
res.setHeader('Cache-Control', `private, max-age=${remaining}`);
|
||||
res.status(HttpStatus.OK);
|
||||
|
||||
obj.stream.on('error', (err) => {
|
||||
this.logger.warn(`stream error на ${physicalKey}: ${err.message}`);
|
||||
if (!res.headersSent) {
|
||||
res.status(HttpStatus.BAD_GATEWAY).end();
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
obj.stream.pipe(res);
|
||||
}
|
||||
}
|
||||
|
||||
function safeDecodeURIComponent(s: string): string {
|
||||
try {
|
||||
return decodeURIComponent(s);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function decodeKeyFromPath(rest: string): string {
|
||||
if (!rest) return '';
|
||||
return rest.split('/').map(safeDecodeURIComponent).join('/');
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { DynamicModule, Global, Module, type Type } from '@nestjs/common';
|
||||
import { INTER_FILE_STORAGE, type InterFileStoragePort } from '@coopenomics/inter';
|
||||
import { BucketRegistry } from './bucket-registry';
|
||||
import {
|
||||
FILE_STORAGE_OPTIONS,
|
||||
type FileStorageInfrastructureOptions,
|
||||
} from './file-storage.config';
|
||||
import { FileStorageHttpController } from './file-storage-http.controller';
|
||||
import { MinioFileStorageAdapter } from './minio-file-storage.adapter';
|
||||
import { bucketTokenFor } from './use-bucket.decorator';
|
||||
|
||||
/**
|
||||
* Динамический модуль файлового хранилища.
|
||||
*
|
||||
* - `forRoot(options)` — провайдит адаптер `InterFileStoragePort` (токен `INTER_FILE_STORAGE`)
|
||||
* и стартует `OnApplicationBootstrap` хук с `ensureBucketExists`. Глобальный — токен
|
||||
* доступен `forFeature`-ам без явного импорта.
|
||||
* - `forFeature(consumers)` — для каждого `@UseBucket`-класса регистрирует фабрику
|
||||
* `bucketTokenFor(class)`, которая отдаёт `InterFileStorageBucket`. Импортируется в модуле
|
||||
* расширения, где живут эти сервисы.
|
||||
*/
|
||||
@Global()
|
||||
@Module({})
|
||||
export class FileStorageInfrastructureModule {
|
||||
static forRoot(options: FileStorageInfrastructureOptions): DynamicModule {
|
||||
return {
|
||||
module: FileStorageInfrastructureModule,
|
||||
controllers: [FileStorageHttpController],
|
||||
providers: [
|
||||
{ provide: FILE_STORAGE_OPTIONS, useValue: options },
|
||||
MinioFileStorageAdapter,
|
||||
{ provide: INTER_FILE_STORAGE, useExisting: MinioFileStorageAdapter },
|
||||
],
|
||||
exports: [INTER_FILE_STORAGE, MinioFileStorageAdapter],
|
||||
};
|
||||
}
|
||||
|
||||
static forFeature(consumers: ReadonlyArray<Type<unknown>>): DynamicModule {
|
||||
const providers = consumers.map((cls) => {
|
||||
const spec = BucketRegistry.get(cls);
|
||||
if (!spec) {
|
||||
throw new Error(
|
||||
`FileStorageInfrastructureModule.forFeature: класс ${cls.name} не помечен @UseBucket`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
provide: bucketTokenFor(cls),
|
||||
useFactory: (port: InterFileStoragePort) => port.getBucket(spec),
|
||||
inject: [INTER_FILE_STORAGE],
|
||||
};
|
||||
});
|
||||
return {
|
||||
module: FileStorageInfrastructureModule,
|
||||
providers,
|
||||
exports: providers.map((p) => p.provide),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Конфигурация подключения к MinIO/S3 и подписания read-URL.
|
||||
* Передаётся в `FileStorageInfrastructureModule.forRoot(options)`.
|
||||
*/
|
||||
export interface FileStorageInfrastructureOptions {
|
||||
/** URL S3-совместимого бэкенда. Для MinIO — `http://minio:9000`. Пусто/undefined = file storage отключён (no-op). */
|
||||
endpoint?: string;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
/** Имя физического бакета на сервере (например, `coop-voskhod`). */
|
||||
bucket: string;
|
||||
/** Секрет для HMAC-подписи read-URL служебной ручки контроллера. */
|
||||
signingSecret: string;
|
||||
/** База публичного URL контроллера (например, `https://voskhod.example.org`). */
|
||||
publicBaseUrl: string;
|
||||
/** AWS region. Для MinIO любое непустое значение. По умолчанию `us-east-1`. */
|
||||
region?: string;
|
||||
/** Path-style addressing (true для MinIO, false для AWS S3). По умолчанию true. */
|
||||
forcePathStyle?: boolean;
|
||||
/** TTL по умолчанию для read-URL (сек). Перекрывается `BucketSpec.defaultUrlTtlSeconds`. По умолчанию 600. */
|
||||
defaultUrlTtlSeconds?: number;
|
||||
}
|
||||
|
||||
export const FILE_STORAGE_OPTIONS = Symbol.for('FileStorage.Options');
|
||||
|
||||
export const FILE_STORAGE_DEFAULT_URL_TTL_SECONDS = 600;
|
||||
@@ -0,0 +1,14 @@
|
||||
export {
|
||||
FILE_STORAGE_DEFAULT_URL_TTL_SECONDS,
|
||||
FILE_STORAGE_OPTIONS,
|
||||
type FileStorageInfrastructureOptions,
|
||||
} from './file-storage.config';
|
||||
export { BucketRegistry, type RegisteredBucket } from './bucket-registry';
|
||||
export { InjectBucket, UseBucket, bucketTokenFor } from './use-bucket.decorator';
|
||||
export {
|
||||
MinioFileStorageAdapter,
|
||||
type FileStorageObjectStream,
|
||||
} from './minio-file-storage.adapter';
|
||||
export { FileStorageHttpController } from './file-storage-http.controller';
|
||||
export { FileStorageInfrastructureModule } from './file-storage-infrastructure.module';
|
||||
export { signReadUrl, verifyReadUrl } from './signing';
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
import 'reflect-metadata';
|
||||
import {
|
||||
CreateBucketCommand,
|
||||
DeleteObjectCommand,
|
||||
HeadBucketCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import {
|
||||
InterFileStorageBackendUnavailableError,
|
||||
InterFileStorageBucketNotConfiguredError,
|
||||
InterFileStorageMetadataValidationError,
|
||||
InterFileStorageMimeRejectedError,
|
||||
InterFileStorageObjectNotFoundError,
|
||||
InterFileStorageObjectTooLargeError,
|
||||
type InterFileStorageBucketSpec,
|
||||
} from '@coopenomics/inter';
|
||||
import type { FileStorageInfrastructureOptions } from './file-storage.config';
|
||||
import { MinioFileStorageAdapter } from './minio-file-storage.adapter';
|
||||
import { verifyReadUrl } from './signing';
|
||||
|
||||
const BASE_OPTS: FileStorageInfrastructureOptions = {
|
||||
endpoint: 'http://minio:9000',
|
||||
accessKey: 'AKIA-test',
|
||||
secretKey: 'sk-test',
|
||||
bucket: 'coop-test',
|
||||
signingSecret: 'signing-secret-32-bytes-aaaaaaaa',
|
||||
publicBaseUrl: 'https://test.example.org',
|
||||
};
|
||||
|
||||
const SPEC: InterFileStorageBucketSpec = {
|
||||
name: 'orders:images',
|
||||
maxBytes: 1024,
|
||||
allowedMime: ['image/jpeg', 'image/png'],
|
||||
metadataSchema: {
|
||||
ownerId: 'required',
|
||||
altText: 'optional',
|
||||
},
|
||||
};
|
||||
|
||||
function makeAdapter(opts: FileStorageInfrastructureOptions = BASE_OPTS): {
|
||||
adapter: MinioFileStorageAdapter;
|
||||
send: jest.Mock;
|
||||
} {
|
||||
const adapter = new MinioFileStorageAdapter(opts);
|
||||
const send = jest.fn();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(adapter as any).s3 = { send };
|
||||
return { adapter, send };
|
||||
}
|
||||
|
||||
describe('MinioFileStorageAdapter — getBucket валидация спеки', () => {
|
||||
it('отклоняет name без двоеточия', () => {
|
||||
const { adapter } = makeAdapter();
|
||||
expect(() => adapter.getBucket({ ...SPEC, name: 'no-colon' })).toThrow(
|
||||
InterFileStorageBucketNotConfiguredError,
|
||||
);
|
||||
});
|
||||
|
||||
it('отклоняет неположительный maxBytes', () => {
|
||||
const { adapter } = makeAdapter();
|
||||
expect(() => adapter.getBucket({ ...SPEC, maxBytes: 0 })).toThrow(
|
||||
InterFileStorageBucketNotConfiguredError,
|
||||
);
|
||||
});
|
||||
|
||||
it('отклоняет пустой allowedMime', () => {
|
||||
const { adapter } = makeAdapter();
|
||||
expect(() => adapter.getBucket({ ...SPEC, allowedMime: [] })).toThrow(
|
||||
InterFileStorageBucketNotConfiguredError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MinioFileStorageAdapter — put', () => {
|
||||
it('успешный put передаёт верные Bucket/Key/ContentType/Metadata в PutObjectCommand', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
send.mockResolvedValueOnce({ ETag: '"abc123"' });
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
|
||||
const body = new Uint8Array([1, 2, 3, 4]);
|
||||
const result = await bucket.put('orders/42/main.jpg', body, {
|
||||
contentType: 'image/jpeg',
|
||||
metadata: { ownerId: 'u-1' },
|
||||
});
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
const cmd = send.mock.calls[0][0] as PutObjectCommand;
|
||||
expect(cmd).toBeInstanceOf(PutObjectCommand);
|
||||
expect(cmd.input.Bucket).toBe('coop-test');
|
||||
expect(cmd.input.Key).toBe('orders-images/orders/42/main.jpg');
|
||||
expect(cmd.input.ContentType).toBe('image/jpeg');
|
||||
expect(cmd.input.ContentLength).toBe(4);
|
||||
expect(cmd.input.Metadata).toEqual({ ownerId: 'u-1' });
|
||||
|
||||
expect(result.key).toBe('orders/42/main.jpg');
|
||||
expect(result.etag).toBe('abc123');
|
||||
expect(result.size).toBe(4);
|
||||
});
|
||||
|
||||
it('отклоняет put с неподходящим MIME без обращения к S3', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
await expect(
|
||||
bucket.put('orders/x/main.exe', new Uint8Array([0]), {
|
||||
contentType: 'application/octet-stream',
|
||||
metadata: { ownerId: 'u-1' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(InterFileStorageMimeRejectedError);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('отклоняет put сверх maxBytes (Uint8Array) без обращения к S3', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
const body = new Uint8Array(SPEC.maxBytes + 1);
|
||||
await expect(
|
||||
bucket.put('big.jpg', body, { contentType: 'image/jpeg', metadata: { ownerId: 'u-1' } }),
|
||||
).rejects.toBeInstanceOf(InterFileStorageObjectTooLargeError);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('отклоняет put сверх maxBytes (ReadableStream) до отправки', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
|
||||
const chunks: Uint8Array[] = [
|
||||
new Uint8Array(SPEC.maxBytes - 100),
|
||||
new Uint8Array(200), // overshoot at second chunk
|
||||
];
|
||||
const stream = makeWebStream(chunks) as unknown as ReadableStream<Uint8Array>;
|
||||
|
||||
await expect(
|
||||
bucket.put('big-stream.jpg', stream, {
|
||||
contentType: 'image/jpeg',
|
||||
metadata: { ownerId: 'u-1' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(InterFileStorageObjectTooLargeError);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('отклоняет put без обязательного metadata-поля', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
await expect(
|
||||
bucket.put('x.jpg', new Uint8Array([1]), { contentType: 'image/jpeg', metadata: {} }),
|
||||
).rejects.toBeInstanceOf(InterFileStorageMetadataValidationError);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('путь успешного потока — стрим читается, размер контролируется, S3 получает Buffer', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
send.mockResolvedValueOnce({ ETag: '"e1"' });
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
|
||||
const stream = makeWebStream([
|
||||
new Uint8Array([10, 20]),
|
||||
new Uint8Array([30]),
|
||||
]) as unknown as ReadableStream<Uint8Array>;
|
||||
const r = await bucket.put('s.jpg', stream, {
|
||||
contentType: 'image/jpeg',
|
||||
metadata: { ownerId: 'u' },
|
||||
});
|
||||
expect(r.size).toBe(3);
|
||||
const cmd = send.mock.calls[0][0] as PutObjectCommand;
|
||||
const body = cmd.input.Body as Buffer;
|
||||
expect(body.length).toBe(3);
|
||||
expect(body[0]).toBe(10);
|
||||
expect(body[2]).toBe(30);
|
||||
});
|
||||
|
||||
it('оборачивает backend-ошибку put в BackendUnavailableError', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
send.mockRejectedValueOnce(new Error('connection refused'));
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
|
||||
await expect(
|
||||
bucket.put('x.jpg', new Uint8Array([1]), {
|
||||
contentType: 'image/jpeg',
|
||||
metadata: { ownerId: 'u' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(InterFileStorageBackendUnavailableError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MinioFileStorageAdapter — head', () => {
|
||||
it('возвращает метаданные при успешном HEAD', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
const lastModified = new Date('2026-04-23T10:00:00Z');
|
||||
send.mockResolvedValueOnce({
|
||||
ContentLength: 42,
|
||||
ETag: '"deadbeef"',
|
||||
ContentType: 'image/png',
|
||||
LastModified: lastModified,
|
||||
Metadata: { ownerid: 'u-1' },
|
||||
});
|
||||
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
const r = await bucket.head('a/b.png');
|
||||
|
||||
expect((send.mock.calls[0][0] as HeadObjectCommand).input.Key).toBe('orders-images/a/b.png');
|
||||
expect(r.size).toBe(42);
|
||||
expect(r.etag).toBe('deadbeef');
|
||||
expect(r.contentType).toBe('image/png');
|
||||
expect(r.lastModified).toEqual(lastModified);
|
||||
expect(r.metadata.ownerid).toBe('u-1');
|
||||
});
|
||||
|
||||
it('бросает ObjectNotFoundError при S3-ошибке NotFound', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
const err = Object.assign(new Error('not found'), {
|
||||
name: 'NotFound',
|
||||
$metadata: { httpStatusCode: 404 },
|
||||
});
|
||||
send.mockRejectedValueOnce(err);
|
||||
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
await expect(bucket.head('missing.jpg')).rejects.toBeInstanceOf(
|
||||
InterFileStorageObjectNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
it('бросает ObjectNotFoundError при ошибке NoSuchKey', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
const err = Object.assign(new Error('no such key'), { name: 'NoSuchKey' });
|
||||
send.mockRejectedValueOnce(err);
|
||||
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
await expect(bucket.head('missing.jpg')).rejects.toBeInstanceOf(
|
||||
InterFileStorageObjectNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
it('оборачивает прочие backend-ошибки в BackendUnavailableError', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
send.mockRejectedValueOnce(new Error('timeout'));
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
await expect(bucket.head('x.jpg')).rejects.toBeInstanceOf(
|
||||
InterFileStorageBackendUnavailableError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MinioFileStorageAdapter — delete', () => {
|
||||
it('успешный delete вызывает DeleteObjectCommand с правильным Key', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
send.mockResolvedValueOnce({});
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
await bucket.delete('a/b.jpg');
|
||||
const cmd = send.mock.calls[0][0] as DeleteObjectCommand;
|
||||
expect(cmd).toBeInstanceOf(DeleteObjectCommand);
|
||||
expect(cmd.input.Bucket).toBe('coop-test');
|
||||
expect(cmd.input.Key).toBe('orders-images/a/b.jpg');
|
||||
});
|
||||
|
||||
it('оборачивает backend-ошибку delete в BackendUnavailableError', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
send.mockRejectedValueOnce(new Error('500'));
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
await expect(bucket.delete('x.jpg')).rejects.toBeInstanceOf(
|
||||
InterFileStorageBackendUnavailableError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MinioFileStorageAdapter — getReadUrl', () => {
|
||||
it('возвращает URL верной структуры с подписью, проходящей verifyReadUrl', async () => {
|
||||
const { adapter } = makeAdapter();
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
|
||||
const url = await bucket.getReadUrl('orders/42/main.jpg', { ttlSeconds: 60 });
|
||||
const parsed = new URL(url);
|
||||
expect(parsed.origin).toBe('https://test.example.org');
|
||||
expect(parsed.pathname).toBe('/api/storage/orders-images/orders/42/main.jpg');
|
||||
|
||||
const exp = Number(parsed.searchParams.get('exp'));
|
||||
const sig = parsed.searchParams.get('sig')!;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expect(exp).toBeGreaterThan(now);
|
||||
expect(exp).toBeLessThanOrEqual(now + 61);
|
||||
expect(sig).toMatch(/^[0-9a-f]{64}$/);
|
||||
|
||||
const ok = verifyReadUrl({
|
||||
bucket: 'orders-images',
|
||||
key: 'orders/42/main.jpg',
|
||||
expUnix: exp,
|
||||
sig,
|
||||
secret: BASE_OPTS.signingSecret,
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
it('берёт TTL из defaultUrlTtlSeconds бакета, если не передан', async () => {
|
||||
const { adapter } = makeAdapter();
|
||||
const bucket = adapter.getBucket({ ...SPEC, defaultUrlTtlSeconds: 30 });
|
||||
const url = await bucket.getReadUrl('x.jpg');
|
||||
const exp = Number(new URL(url).searchParams.get('exp'));
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
expect(exp - now).toBeGreaterThanOrEqual(28);
|
||||
expect(exp - now).toBeLessThanOrEqual(31);
|
||||
});
|
||||
|
||||
it('экранирует ключ посегментно, сохраняя слэши как разделители', async () => {
|
||||
const { adapter } = makeAdapter();
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
const url = await bucket.getReadUrl('a b/c?d=e/f.jpg');
|
||||
const parsed = new URL(url);
|
||||
expect(parsed.pathname).toBe('/api/storage/orders-images/a%20b/c%3Fd%3De/f.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MinioFileStorageAdapter — onApplicationBootstrap', () => {
|
||||
it('пропускает CreateBucket, если HeadBucket прошёл', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
send.mockResolvedValueOnce({}); // HeadBucket OK
|
||||
await adapter.onApplicationBootstrap();
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(send.mock.calls[0][0]).toBeInstanceOf(HeadBucketCommand);
|
||||
});
|
||||
|
||||
it('создаёт бакет, если HeadBucket вернул 404', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
const notFound = Object.assign(new Error('not found'), {
|
||||
name: 'NotFound',
|
||||
$metadata: { httpStatusCode: 404 },
|
||||
});
|
||||
send.mockRejectedValueOnce(notFound).mockResolvedValueOnce({});
|
||||
|
||||
await adapter.onApplicationBootstrap();
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect(send.mock.calls[0][0]).toBeInstanceOf(HeadBucketCommand);
|
||||
expect(send.mock.calls[1][0]).toBeInstanceOf(CreateBucketCommand);
|
||||
});
|
||||
|
||||
it('бросает BackendUnavailableError, если HeadBucket упал не на 404', async () => {
|
||||
const { adapter, send } = makeAdapter();
|
||||
send.mockRejectedValueOnce(new Error('connection refused'));
|
||||
await expect(adapter.onApplicationBootstrap()).rejects.toBeInstanceOf(
|
||||
InterFileStorageBackendUnavailableError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeWebStream(chunks: Uint8Array[]): {
|
||||
getReader: () => { read: () => Promise<{ value?: Uint8Array; done: boolean }>; cancel: () => Promise<void> };
|
||||
} {
|
||||
let i = 0;
|
||||
return {
|
||||
getReader() {
|
||||
return {
|
||||
async read() {
|
||||
if (i >= chunks.length) return { value: undefined, done: true };
|
||||
const value = chunks[i++];
|
||||
return { value, done: false };
|
||||
},
|
||||
async cancel() {
|
||||
/* noop */
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import {
|
||||
CreateBucketCommand,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import {
|
||||
InterFileStorageBackendUnavailableError,
|
||||
InterFileStorageBucketNotConfiguredError,
|
||||
InterFileStorageMetadataValidationError,
|
||||
InterFileStorageMimeRejectedError,
|
||||
InterFileStorageObjectNotFoundError,
|
||||
InterFileStorageObjectTooLargeError,
|
||||
type InterFileStorageBody,
|
||||
type InterFileStorageBucket,
|
||||
type InterFileStorageBucketSpec,
|
||||
type InterFileStorageGetReadUrlOptions,
|
||||
type InterFileStorageObjectMetadata,
|
||||
type InterFileStoragePort,
|
||||
type InterFileStoragePutOptions,
|
||||
type InterFileStoragePutResult,
|
||||
} from '@coopenomics/inter';
|
||||
import { BucketRegistry } from './bucket-registry';
|
||||
import {
|
||||
FILE_STORAGE_DEFAULT_URL_TTL_SECONDS,
|
||||
FILE_STORAGE_OPTIONS,
|
||||
type FileStorageInfrastructureOptions,
|
||||
} from './file-storage.config';
|
||||
import { signReadUrl } from './signing';
|
||||
|
||||
/**
|
||||
* Адаптер `InterFileStoragePort` поверх MinIO/S3. Реализация одна и та же для обоих —
|
||||
* различается только `endpoint` и `forcePathStyle`. На bootstrap идемпотентно создаёт
|
||||
* физический бакет, дальше отдаёт `InterFileStorageBucket`-хэндлы по спекам.
|
||||
*
|
||||
* URL чтения формируется на собственный домен контроллера (служебная ручка `/api/storage/...`),
|
||||
* подписывается HMAC-SHA256 секретом из конфига. При переходе на нативный S3 здесь же
|
||||
* можно начать возвращать настоящие S3 presigned URL — контракт строки не меняется.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MinioFileStorageAdapter implements InterFileStoragePort, OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(MinioFileStorageAdapter.name);
|
||||
private readonly s3: S3Client | null;
|
||||
private readonly enabled: boolean;
|
||||
|
||||
constructor(
|
||||
@Inject(FILE_STORAGE_OPTIONS)
|
||||
private readonly opts: FileStorageInfrastructureOptions,
|
||||
) {
|
||||
this.enabled = typeof opts.endpoint === 'string' && opts.endpoint.length > 0;
|
||||
this.s3 = this.enabled
|
||||
? new S3Client({
|
||||
endpoint: opts.endpoint,
|
||||
region: opts.region ?? 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: opts.accessKey,
|
||||
secretAccessKey: opts.secretKey,
|
||||
},
|
||||
forcePathStyle: opts.forcePathStyle ?? true,
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
if (!this.enabled || !this.s3) {
|
||||
this.logger.warn(
|
||||
'File storage не сконфигурирован (MINIO_ENDPOINT пуст) — модуль стартует в no-op режиме. ' +
|
||||
'Загрузка/чтение файлов будут отклонены до настройки бэкенда.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.ensureBucketExists();
|
||||
const registered = BucketRegistry.list();
|
||||
this.logger.log(
|
||||
`File storage готов. Физический бакет '${this.opts.bucket}', эндпоинт '${this.opts.endpoint}'. ` +
|
||||
`Зарегистрировано бакетов через @UseBucket: ${registered.length}` +
|
||||
(registered.length > 0
|
||||
? ` (${registered.map((r) => `${r.cls.name}→${r.spec.name}`).join(', ')})`
|
||||
: ''),
|
||||
);
|
||||
}
|
||||
|
||||
private assertEnabled(): S3Client {
|
||||
if (!this.enabled || !this.s3) {
|
||||
throw new InterFileStorageBackendUnavailableError(
|
||||
'File storage не сконфигурирован: MINIO_ENDPOINT пуст. Задайте переменную окружения, чтобы включить загрузку/чтение файлов.',
|
||||
);
|
||||
}
|
||||
return this.s3;
|
||||
}
|
||||
|
||||
private async ensureBucketExists(): Promise<void> {
|
||||
const s3 = this.assertEnabled();
|
||||
try {
|
||||
await s3.send(new HeadBucketCommand({ Bucket: this.opts.bucket }));
|
||||
return;
|
||||
} catch (e) {
|
||||
if (!isNotFound(e)) {
|
||||
throw new InterFileStorageBackendUnavailableError(
|
||||
`HeadBucket(${this.opts.bucket}) не удался: ${getMessage(e)}`,
|
||||
{ cause: asError(e) },
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await s3.send(new CreateBucketCommand({ Bucket: this.opts.bucket }));
|
||||
this.logger.log(`Создан физический бакет '${this.opts.bucket}'`);
|
||||
} catch (e) {
|
||||
throw new InterFileStorageBackendUnavailableError(
|
||||
`CreateBucket(${this.opts.bucket}) не удался: ${getMessage(e)}`,
|
||||
{ cause: asError(e) },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getBucket(spec: InterFileStorageBucketSpec): InterFileStorageBucket {
|
||||
const s3 = this.assertEnabled();
|
||||
validateSpec(spec);
|
||||
return new MinioBucketHandle(s3, this.opts, spec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Внутренний fetch для HTTP-ручки `/api/storage/:bucket/:key` (E59-4).
|
||||
* `physicalKey` — это уже собранный ключ S3-объекта вида `<extension>-<purpose>/<caller-key>`.
|
||||
* @throws InterFileStorageObjectNotFoundError, InterFileStorageBackendUnavailableError
|
||||
*/
|
||||
async fetchObjectForReadProxy(physicalKey: string): Promise<FileStorageObjectStream> {
|
||||
const s3 = this.assertEnabled();
|
||||
try {
|
||||
const r = await s3.send(
|
||||
new GetObjectCommand({ Bucket: this.opts.bucket, Key: physicalKey }),
|
||||
);
|
||||
return {
|
||||
stream: r.Body as NodeJS.ReadableStream,
|
||||
size: r.ContentLength ?? 0,
|
||||
contentType: r.ContentType ?? 'application/octet-stream',
|
||||
lastModified: r.LastModified ?? new Date(0),
|
||||
};
|
||||
} catch (e) {
|
||||
if (isNotFound(e)) {
|
||||
throw new InterFileStorageObjectNotFoundError(`Объект '${physicalKey}' не найден`);
|
||||
}
|
||||
throw wrapBackendError(e, `getObject '${physicalKey}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MinioBucketHandle implements InterFileStorageBucket {
|
||||
private readonly publicBucketName: string;
|
||||
private readonly defaultTtlSeconds: number;
|
||||
|
||||
constructor(
|
||||
private readonly s3: S3Client,
|
||||
private readonly opts: FileStorageInfrastructureOptions,
|
||||
private readonly spec: InterFileStorageBucketSpec,
|
||||
) {
|
||||
this.publicBucketName = spec.name.replace(/:/g, '-');
|
||||
this.defaultTtlSeconds =
|
||||
spec.defaultUrlTtlSeconds ?? opts.defaultUrlTtlSeconds ?? FILE_STORAGE_DEFAULT_URL_TTL_SECONDS;
|
||||
}
|
||||
|
||||
private physicalKey(key: string): string {
|
||||
return `${this.publicBucketName}/${key}`;
|
||||
}
|
||||
|
||||
async put(
|
||||
key: string,
|
||||
body: InterFileStorageBody,
|
||||
opts: InterFileStoragePutOptions,
|
||||
): Promise<InterFileStoragePutResult> {
|
||||
if (!this.spec.allowedMime.includes(opts.contentType)) {
|
||||
throw new InterFileStorageMimeRejectedError(
|
||||
`MIME '${opts.contentType}' не входит в allowedMime бакета '${this.spec.name}'`,
|
||||
);
|
||||
}
|
||||
this.validateMetadata(opts.metadata);
|
||||
const buffer = await materializeAndCheckSize(body, this.spec.maxBytes, this.spec.name);
|
||||
|
||||
try {
|
||||
const r = await this.s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.opts.bucket,
|
||||
Key: this.physicalKey(key),
|
||||
Body: buffer,
|
||||
ContentType: opts.contentType,
|
||||
ContentLength: buffer.byteLength,
|
||||
Metadata: opts.metadata as Record<string, string> | undefined,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
key,
|
||||
etag: stripQuotes(r.ETag) ?? '',
|
||||
size: buffer.byteLength,
|
||||
};
|
||||
} catch (e) {
|
||||
throw wrapBackendError(e, `put '${this.spec.name}/${key}'`);
|
||||
}
|
||||
}
|
||||
|
||||
async getReadUrl(key: string, opts?: InterFileStorageGetReadUrlOptions): Promise<string> {
|
||||
const ttl = opts?.ttlSeconds ?? this.defaultTtlSeconds;
|
||||
const exp = Math.floor(Date.now() / 1000) + ttl;
|
||||
const sig = signReadUrl({
|
||||
bucket: this.publicBucketName,
|
||||
key,
|
||||
expUnix: exp,
|
||||
secret: this.opts.signingSecret,
|
||||
});
|
||||
const base = this.opts.publicBaseUrl.replace(/\/+$/, '');
|
||||
return `${base}/api/storage/${encodeURIComponent(this.publicBucketName)}/${encodeKeyForPath(key)}?exp=${exp}&sig=${sig}`;
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
try {
|
||||
await this.s3.send(
|
||||
new DeleteObjectCommand({ Bucket: this.opts.bucket, Key: this.physicalKey(key) }),
|
||||
);
|
||||
} catch (e) {
|
||||
// S3 DeleteObject идемпотентен — отсутствие объекта не ошибка. Сюда попадают только
|
||||
// реальные backend-фейлы.
|
||||
throw wrapBackendError(e, `delete '${this.spec.name}/${key}'`);
|
||||
}
|
||||
}
|
||||
|
||||
async head(key: string): Promise<InterFileStorageObjectMetadata> {
|
||||
try {
|
||||
const r = await this.s3.send(
|
||||
new HeadObjectCommand({ Bucket: this.opts.bucket, Key: this.physicalKey(key) }),
|
||||
);
|
||||
return {
|
||||
size: r.ContentLength ?? 0,
|
||||
etag: stripQuotes(r.ETag) ?? '',
|
||||
contentType: r.ContentType ?? 'application/octet-stream',
|
||||
lastModified: r.LastModified ?? new Date(0),
|
||||
metadata: r.Metadata ?? {},
|
||||
};
|
||||
} catch (e) {
|
||||
if (isNotFound(e)) {
|
||||
throw new InterFileStorageObjectNotFoundError(
|
||||
`Объект '${this.spec.name}/${key}' не найден`,
|
||||
);
|
||||
}
|
||||
throw wrapBackendError(e, `head '${this.spec.name}/${key}'`);
|
||||
}
|
||||
}
|
||||
|
||||
private validateMetadata(metadata?: Readonly<Record<string, string>>): void {
|
||||
if (!this.spec.metadataSchema) return;
|
||||
for (const [field, kind] of Object.entries(this.spec.metadataSchema)) {
|
||||
if (kind !== 'required') continue;
|
||||
const value = metadata?.[field];
|
||||
if (!value) {
|
||||
throw new InterFileStorageMetadataValidationError(
|
||||
`Метаданное '${field}' обязательно для бакета '${this.spec.name}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Результат внутреннего fetch для HTTP-ручки `/api/storage/...`.
|
||||
* Не часть публичного `InterFileStoragePort` — только для proxy-стрима внутри контроллера.
|
||||
*/
|
||||
export interface FileStorageObjectStream {
|
||||
stream: NodeJS.ReadableStream;
|
||||
size: number;
|
||||
contentType: string;
|
||||
lastModified: Date;
|
||||
}
|
||||
|
||||
function validateSpec(spec: InterFileStorageBucketSpec): void {
|
||||
if (!spec.name || !spec.name.includes(':')) {
|
||||
throw new InterFileStorageBucketNotConfiguredError(
|
||||
`BucketSpec.name должен иметь формат '<extension>:<purpose>', получено '${spec.name}'`,
|
||||
);
|
||||
}
|
||||
if (!Number.isFinite(spec.maxBytes) || spec.maxBytes <= 0) {
|
||||
throw new InterFileStorageBucketNotConfiguredError(
|
||||
`BucketSpec.maxBytes должно быть положительным конечным числом, получено ${spec.maxBytes}`,
|
||||
);
|
||||
}
|
||||
if (!spec.allowedMime || spec.allowedMime.length === 0) {
|
||||
throw new InterFileStorageBucketNotConfiguredError(
|
||||
`BucketSpec.allowedMime должен содержать хотя бы один MIME-тип`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function materializeAndCheckSize(
|
||||
body: InterFileStorageBody,
|
||||
maxBytes: number,
|
||||
specName: string,
|
||||
): Promise<Buffer> {
|
||||
if (body instanceof Uint8Array) {
|
||||
if (body.byteLength > maxBytes) {
|
||||
throw new InterFileStorageObjectTooLargeError(
|
||||
`Размер ${body.byteLength} байт превышает лимит ${maxBytes} бакета '${specName}'`,
|
||||
);
|
||||
}
|
||||
return Buffer.from(body);
|
||||
}
|
||||
// Web ReadableStream<Uint8Array> — duck-typed, чтобы не зависеть от глобального типа `ReadableStream`.
|
||||
if (
|
||||
typeof body === 'object' &&
|
||||
body !== null &&
|
||||
'getReader' in body &&
|
||||
typeof (body as { getReader: unknown }).getReader === 'function'
|
||||
) {
|
||||
const reader = (body as { getReader: () => StreamReader }).getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
if (value && value.byteLength > 0) {
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
// Освобождаем reader, чтобы не оставлять стрим висящим.
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new InterFileStorageObjectTooLargeError(
|
||||
`Размер тела превышает лимит ${maxBytes} бакета '${specName}'`,
|
||||
);
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
throw new InterFileStorageBackendUnavailableError(
|
||||
`Неподдерживаемый тип тела для бакета '${specName}'`,
|
||||
);
|
||||
}
|
||||
|
||||
interface StreamReader {
|
||||
read(): Promise<{ value?: Uint8Array; done: boolean }>;
|
||||
cancel(): Promise<void>;
|
||||
}
|
||||
|
||||
function isNotFound(e: unknown): boolean {
|
||||
if (typeof e !== 'object' || e === null) return false;
|
||||
const ex = e as {
|
||||
name?: string;
|
||||
Code?: string;
|
||||
$metadata?: { httpStatusCode?: number };
|
||||
};
|
||||
if (ex.name === 'NotFound' || ex.name === 'NoSuchKey' || ex.name === 'NoSuchBucket') return true;
|
||||
if (ex.Code === 'NotFound' || ex.Code === 'NoSuchKey' || ex.Code === 'NoSuchBucket') return true;
|
||||
if (ex.$metadata?.httpStatusCode === 404) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function wrapBackendError(e: unknown, op: string): InterFileStorageBackendUnavailableError {
|
||||
return new InterFileStorageBackendUnavailableError(`${op}: ${getMessage(e)}`, {
|
||||
cause: asError(e),
|
||||
});
|
||||
}
|
||||
|
||||
function getMessage(e: unknown): string {
|
||||
if (e instanceof Error) return e.message;
|
||||
if (typeof e === 'string') return e;
|
||||
return 'unknown error';
|
||||
}
|
||||
|
||||
function asError(e: unknown): Error | undefined {
|
||||
return e instanceof Error ? e : undefined;
|
||||
}
|
||||
|
||||
function stripQuotes(s: string | undefined): string | undefined {
|
||||
if (!s) return s;
|
||||
return s.replace(/^"|"$/g, '');
|
||||
}
|
||||
|
||||
function encodeKeyForPath(key: string): string {
|
||||
// '/' — разделитель сегментов URL, остальное per-segment encodeURIComponent.
|
||||
return key.split('/').map(encodeURIComponent).join('/');
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { signReadUrl, verifyReadUrl } from './signing';
|
||||
|
||||
describe('signing', () => {
|
||||
const secret = 'test-secret-32-bytes-aaaaaaaaaaaa';
|
||||
|
||||
it('подпись детерминирована для одинакового набора параметров', () => {
|
||||
const a = signReadUrl({ bucket: 'b1', key: 'k1', expUnix: 1000, secret });
|
||||
const b = signReadUrl({ bucket: 'b1', key: 'k1', expUnix: 1000, secret });
|
||||
expect(a).toBe(b);
|
||||
expect(a).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('verify проходит на корректную подпись', () => {
|
||||
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
|
||||
expect(verifyReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, sig, secret })).toBe(true);
|
||||
});
|
||||
|
||||
it('verify отклоняет подмену bucket', () => {
|
||||
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
|
||||
expect(verifyReadUrl({ bucket: 'expenses-receipts', key: 'a/b.jpg', expUnix: 9999, sig, secret })).toBe(false);
|
||||
});
|
||||
|
||||
it('verify отклоняет подмену key', () => {
|
||||
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
|
||||
expect(verifyReadUrl({ bucket: 'orders-images', key: 'a/c.jpg', expUnix: 9999, sig, secret })).toBe(false);
|
||||
});
|
||||
|
||||
it('verify отклоняет подмену exp', () => {
|
||||
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
|
||||
expect(verifyReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 10000, sig, secret })).toBe(false);
|
||||
});
|
||||
|
||||
it('verify отклоняет подмену секрета', () => {
|
||||
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
|
||||
expect(
|
||||
verifyReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, sig, secret: 'other' }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('verify отклоняет подпись неверной длины без выброса исключения', () => {
|
||||
expect(
|
||||
verifyReadUrl({ bucket: 'b', key: 'k', expUnix: 1, sig: 'abcd', secret }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('verify отклоняет не-hex без выброса исключения', () => {
|
||||
const fake = 'z'.repeat(64);
|
||||
expect(
|
||||
verifyReadUrl({ bucket: 'b', key: 'k', expUnix: 1, sig: fake, secret }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
|
||||
/**
|
||||
* HMAC-SHA256(secret, "<bucket>\n<key>\n<exp>") в hex.
|
||||
* Используется и адаптером (выдача URL), и HTTP-ручкой (валидация).
|
||||
*/
|
||||
export function signReadUrl(args: {
|
||||
bucket: string;
|
||||
key: string;
|
||||
expUnix: number;
|
||||
secret: string;
|
||||
}): string {
|
||||
const h = createHmac('sha256', args.secret);
|
||||
h.update(`${args.bucket}\n${args.key}\n${args.expUnix}`);
|
||||
return h.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time сверка подписи. Возвращает false на любую структурную ошибку
|
||||
* (несовпадение длин, не-hex), а не выбрасывает — чтобы не различать каналы side-channel-ом.
|
||||
*/
|
||||
export function verifyReadUrl(args: {
|
||||
bucket: string;
|
||||
key: string;
|
||||
expUnix: number;
|
||||
sig: string;
|
||||
secret: string;
|
||||
}): boolean {
|
||||
const expected = signReadUrl(args);
|
||||
if (expected.length !== args.sig.length) return false;
|
||||
let expectedBuf: Buffer;
|
||||
let actualBuf: Buffer;
|
||||
try {
|
||||
expectedBuf = Buffer.from(expected, 'hex');
|
||||
actualBuf = Buffer.from(args.sig, 'hex');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (expectedBuf.length !== actualBuf.length) return false;
|
||||
return timingSafeEqual(expectedBuf, actualBuf);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'reflect-metadata';
|
||||
import { BucketRegistry } from './bucket-registry';
|
||||
import { UseBucket, bucketTokenFor } from './use-bucket.decorator';
|
||||
|
||||
describe('UseBucket decorator', () => {
|
||||
beforeEach(() => {
|
||||
BucketRegistry._resetForTests();
|
||||
});
|
||||
|
||||
it('регистрирует спеку класса в BucketRegistry', () => {
|
||||
@UseBucket({ name: 'demo:images', maxBytes: 1024, allowedMime: ['image/png'] })
|
||||
class DemoService {}
|
||||
|
||||
const got = BucketRegistry.get(DemoService);
|
||||
expect(got).toBeDefined();
|
||||
expect(got?.name).toBe('demo:images');
|
||||
expect(got?.maxBytes).toBe(1024);
|
||||
expect(got?.allowedMime).toEqual(['image/png']);
|
||||
});
|
||||
|
||||
it('bucketTokenFor стабилен и зависит только от имени класса', () => {
|
||||
class FooService {}
|
||||
expect(bucketTokenFor(FooService)).toBe('__InterFileStorageBucket:FooService');
|
||||
expect(bucketTokenFor(FooService)).toBe(bucketTokenFor(FooService));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import type { InterFileStorageBucketSpec } from '@coopenomics/inter';
|
||||
import { BucketRegistry } from './bucket-registry';
|
||||
|
||||
/**
|
||||
* DI-токен per-class бакета, под которым `forFeature` регистрирует фабрику `BucketHandle`.
|
||||
* Конструктор `@InjectBucket()` инжектится по этому токену.
|
||||
*/
|
||||
export function bucketTokenFor(cls: { name: string }): string {
|
||||
return `__InterFileStorageBucket:${cls.name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Класс-декоратор: декларирует бакет, в который пишет данный сервис.
|
||||
* Конвенция — один бакет на один сервис; повторная регистрация под другим именем запрещена.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* @UseBucket({ name: 'stol-zakazov:images', maxBytes: 10*MB, allowedMime: ['image/jpeg'] })
|
||||
* @Injectable()
|
||||
* export class OrderImagesService {
|
||||
* constructor(@InjectBucket() private readonly bucket: InterFileStorageBucket) {}
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function UseBucket(spec: InterFileStorageBucketSpec): ClassDecorator {
|
||||
return (target) => {
|
||||
BucketRegistry.add(target as unknown as { name: string }, spec);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Параметр-декоратор: инжектит `InterFileStorageBucket` для текущего класса.
|
||||
* Резолвится в DI-токен `bucketTokenFor(<ClassName>)`, который провайдится через
|
||||
* `FileStorageInfrastructureModule.forFeature([ThisClass])`.
|
||||
*/
|
||||
export function InjectBucket(): ParameterDecorator {
|
||||
return (target, propertyKey, parameterIndex) => {
|
||||
const cls = target as unknown as { name: string };
|
||||
Inject(bucketTokenFor(cls))(target, propertyKey, parameterIndex);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# File-storage integration tests
|
||||
|
||||
Тесты против реального MinIO. По умолчанию ожидают MinIO на `http://localhost:9100` с
|
||||
кредами `testuser` / `testpassword` (см. `docker-compose.yml` рядом).
|
||||
|
||||
## Как запустить
|
||||
|
||||
```bash
|
||||
# 1. Поднять MinIO
|
||||
docker compose -f components/controller/tests/file-storage/docker-compose.yml up -d
|
||||
# дождаться healthy
|
||||
|
||||
# 2. Запустить интеграционные тесты
|
||||
cd components/controller
|
||||
npm run test:integration:file-storage
|
||||
|
||||
# 3. После работы
|
||||
docker compose -f components/controller/tests/file-storage/docker-compose.yml down -v
|
||||
```
|
||||
|
||||
## Переопределение через env
|
||||
|
||||
| Переменная | Default | Что делает |
|
||||
|---|---|---|
|
||||
| `FILE_STORAGE_TEST_MINIO_ENDPOINT` | `http://localhost:9100` | URL MinIO |
|
||||
| `FILE_STORAGE_TEST_MINIO_ACCESS_KEY` | `testuser` | Access key |
|
||||
| `FILE_STORAGE_TEST_MINIO_SECRET_KEY` | `testpassword` | Secret key |
|
||||
| `FILE_STORAGE_TEST_BUCKET` | `coop-test-{timestamp}` | Имя физического бакета (уникально per-run) |
|
||||
|
||||
Если MinIO недоступен на `endpoint`, suite пропускает тесты (`it.skip`) с диагностическим сообщением.
|
||||
@@ -0,0 +1,19 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
|
||||
container_name: file-storage-test-minio
|
||||
command: server /data --console-address ":9101"
|
||||
environment:
|
||||
MINIO_ROOT_USER: testuser
|
||||
MINIO_ROOT_PASSWORD: testpassword
|
||||
ports:
|
||||
# Уникальные порты, чтобы не конфликтовать с prod-MinIO (9000) и другими инстансами.
|
||||
- '9100:9000'
|
||||
- '9101:9101'
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
@@ -0,0 +1,280 @@
|
||||
import 'reflect-metadata';
|
||||
import { execSync } from 'child_process';
|
||||
import { Readable } from 'stream';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import {
|
||||
InterFileStorageMetadataValidationError,
|
||||
InterFileStorageMimeRejectedError,
|
||||
InterFileStorageObjectNotFoundError,
|
||||
InterFileStorageObjectTooLargeError,
|
||||
type InterFileStorageBucketSpec,
|
||||
} from '@coopenomics/inter';
|
||||
import {
|
||||
FileStorageHttpController,
|
||||
FILE_STORAGE_OPTIONS,
|
||||
type FileStorageInfrastructureOptions,
|
||||
MinioFileStorageAdapter,
|
||||
signReadUrl,
|
||||
} from '../../src/infrastructure/file-storage';
|
||||
|
||||
const ENDPOINT = process.env.FILE_STORAGE_TEST_MINIO_ENDPOINT ?? 'http://localhost:9100';
|
||||
const ACCESS_KEY = process.env.FILE_STORAGE_TEST_MINIO_ACCESS_KEY ?? 'testuser';
|
||||
const SECRET_KEY = process.env.FILE_STORAGE_TEST_MINIO_SECRET_KEY ?? 'testpassword';
|
||||
const BUCKET = process.env.FILE_STORAGE_TEST_BUCKET ?? `coop-test-${Date.now()}`;
|
||||
const SIGNING_SECRET = 'integration-signing-secret-aaaaaaaaaaaaaaaa';
|
||||
const PUBLIC_BASE_URL = 'https://test.example.org';
|
||||
|
||||
const SPEC: InterFileStorageBucketSpec = {
|
||||
name: 'orders:images',
|
||||
maxBytes: 10 * 1024,
|
||||
allowedMime: ['image/jpeg', 'image/png', 'application/pdf'],
|
||||
metadataSchema: {
|
||||
ownerId: 'required',
|
||||
altText: 'optional',
|
||||
},
|
||||
defaultUrlTtlSeconds: 60,
|
||||
};
|
||||
|
||||
/** Sync-проверка доступности MinIO. Решение `it` vs `it.skip` принимается до describe(). */
|
||||
function probeMinioSync(): boolean {
|
||||
try {
|
||||
execSync(`curl -fsS --max-time 2 ${ENDPOINT}/minio/health/live`, { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const minioReachable = probeMinioSync();
|
||||
if (!minioReachable) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[integration] MinIO недоступен на ${ENDPOINT} — тесты пропущены. ` +
|
||||
`Подними: docker compose -f tests/file-storage/docker-compose.yml up -d`,
|
||||
);
|
||||
}
|
||||
|
||||
const itLive = (name: string, fn: jest.ProvidesCallback, timeout?: number) =>
|
||||
(minioReachable ? it : it.skip)(name, fn, timeout);
|
||||
|
||||
describe('file-storage integration (real MinIO)', () => {
|
||||
let adapter: MinioFileStorageAdapter;
|
||||
let app: INestApplication;
|
||||
const opts: FileStorageInfrastructureOptions = {
|
||||
endpoint: ENDPOINT,
|
||||
accessKey: ACCESS_KEY,
|
||||
secretKey: SECRET_KEY,
|
||||
bucket: BUCKET,
|
||||
signingSecret: SIGNING_SECRET,
|
||||
publicBaseUrl: PUBLIC_BASE_URL,
|
||||
forcePathStyle: true,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!minioReachable) return;
|
||||
adapter = new MinioFileStorageAdapter(opts);
|
||||
await adapter.onApplicationBootstrap();
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [FileStorageHttpController],
|
||||
providers: [
|
||||
{ provide: FILE_STORAGE_OPTIONS, useValue: opts },
|
||||
{ provide: MinioFileStorageAdapter, useValue: adapter },
|
||||
],
|
||||
}).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
itLive('put → head → getReadUrl → GET → bytes равны (image)', async () => {
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
const payload = Buffer.from(jpegFixture());
|
||||
await bucket.put('orders/42/main.jpg', payload, {
|
||||
contentType: 'image/jpeg',
|
||||
metadata: { ownerId: 'u-1', altText: 'demo' },
|
||||
});
|
||||
|
||||
const meta = await bucket.head('orders/42/main.jpg');
|
||||
expect(meta.size).toBe(payload.length);
|
||||
expect(meta.contentType).toBe('image/jpeg');
|
||||
expect(meta.metadata.ownerid ?? meta.metadata.ownerId).toBe('u-1');
|
||||
|
||||
const url = await bucket.getReadUrl('orders/42/main.jpg', { ttlSeconds: 30 });
|
||||
const parsed = new URL(url);
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(parsed.pathname)
|
||||
.query({ exp: parsed.searchParams.get('exp')!, sig: parsed.searchParams.get('sig')! })
|
||||
.buffer(true)
|
||||
.parse((response, cb) => {
|
||||
const chunks: Buffer[] = [];
|
||||
response.on('data', (c) => chunks.push(c));
|
||||
response.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/jpeg');
|
||||
expect((res.body as Buffer).equals(payload)).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
itLive('put PDF среднего размера через стрим, читается обратно идентично', async () => {
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
const payload = Buffer.alloc(8 * 1024); // 8 KiB
|
||||
for (let i = 0; i < payload.length; i++) payload[i] = i % 251;
|
||||
const stream = bufferToWebStream(payload);
|
||||
await bucket.put('reports/r-1/r1.pdf', stream, {
|
||||
contentType: 'application/pdf',
|
||||
metadata: { ownerId: 'u-2' },
|
||||
});
|
||||
|
||||
const meta = await bucket.head('reports/r-1/r1.pdf');
|
||||
expect(meta.size).toBe(payload.length);
|
||||
|
||||
const url = await bucket.getReadUrl('reports/r-1/r1.pdf', { ttlSeconds: 30 });
|
||||
const parsed = new URL(url);
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(parsed.pathname)
|
||||
.query({ exp: parsed.searchParams.get('exp')!, sig: parsed.searchParams.get('sig')! })
|
||||
.buffer(true)
|
||||
.parse((response, cb) => {
|
||||
const chunks: Buffer[] = [];
|
||||
response.on('data', (c) => chunks.push(c));
|
||||
response.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as Buffer).equals(payload)).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
itLive('delete идемпотентен (повтор на отсутствующий — успех)', async () => {
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
await bucket.put('to-delete.jpg', Buffer.from([1, 2, 3]), {
|
||||
contentType: 'image/jpeg',
|
||||
metadata: { ownerId: 'u' },
|
||||
});
|
||||
await bucket.delete('to-delete.jpg');
|
||||
await expect(bucket.delete('to-delete.jpg')).resolves.toBeUndefined();
|
||||
await expect(bucket.delete('never-existed.jpg')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
itLive('head отсутствующего → ObjectNotFoundError', async () => {
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
await expect(bucket.head('does-not-exist.jpg')).rejects.toBeInstanceOf(
|
||||
InterFileStorageObjectNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
itLive('превышение maxBytes → ObjectTooLargeError, объект не появился', async () => {
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
const tooBig = Buffer.alloc(SPEC.maxBytes + 1);
|
||||
await expect(
|
||||
bucket.put('too-big.jpg', tooBig, { contentType: 'image/jpeg', metadata: { ownerId: 'u' } }),
|
||||
).rejects.toBeInstanceOf(InterFileStorageObjectTooLargeError);
|
||||
await expect(bucket.head('too-big.jpg')).rejects.toBeInstanceOf(
|
||||
InterFileStorageObjectNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
itLive('запрещённый MIME → MimeRejectedError, объект не появился', async () => {
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
await expect(
|
||||
bucket.put('bad.exe', Buffer.from([0]), {
|
||||
contentType: 'application/octet-stream',
|
||||
metadata: { ownerId: 'u' },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(InterFileStorageMimeRejectedError);
|
||||
await expect(bucket.head('bad.exe')).rejects.toBeInstanceOf(
|
||||
InterFileStorageObjectNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
itLive('отсутствие required-метаданного → MetadataValidationError', async () => {
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
await expect(
|
||||
bucket.put('no-meta.jpg', Buffer.from([0]), { contentType: 'image/jpeg', metadata: {} }),
|
||||
).rejects.toBeInstanceOf(InterFileStorageMetadataValidationError);
|
||||
});
|
||||
|
||||
itLive('HMAC-роут: 200 → 403 (exp) → 403 (sig) → 404', async () => {
|
||||
const bucket = adapter.getBucket(SPEC);
|
||||
const payload = Buffer.from('end-to-end');
|
||||
await bucket.put('hmac-test/x.jpg', payload, {
|
||||
contentType: 'image/jpeg',
|
||||
metadata: { ownerId: 'u' },
|
||||
});
|
||||
|
||||
// 200
|
||||
const okUrl = await bucket.getReadUrl('hmac-test/x.jpg', { ttlSeconds: 30 });
|
||||
const okParsed = new URL(okUrl);
|
||||
const ok = await request(app.getHttpServer())
|
||||
.get(okParsed.pathname)
|
||||
.query({ exp: okParsed.searchParams.get('exp')!, sig: okParsed.searchParams.get('sig')! });
|
||||
expect(ok.status).toBe(200);
|
||||
|
||||
// 403 — истёкший exp
|
||||
const pastExp = Math.floor(Date.now() / 1000) - 1;
|
||||
const pastSig = signReadUrl({
|
||||
bucket: 'orders-images',
|
||||
key: 'hmac-test/x.jpg',
|
||||
expUnix: pastExp,
|
||||
secret: SIGNING_SECRET,
|
||||
});
|
||||
const expired = await request(app.getHttpServer())
|
||||
.get('/api/storage/orders-images/hmac-test/x.jpg')
|
||||
.query({ exp: pastExp, sig: pastSig });
|
||||
expect(expired.status).toBe(403);
|
||||
|
||||
// 403 — чужой sig
|
||||
const futureExp = Math.floor(Date.now() / 1000) + 60;
|
||||
const wrongSig = await request(app.getHttpServer())
|
||||
.get('/api/storage/orders-images/hmac-test/x.jpg')
|
||||
.query({ exp: futureExp, sig: 'a'.repeat(64) });
|
||||
expect(wrongSig.status).toBe(403);
|
||||
|
||||
// 404 — несуществующий ключ, но валидная подпись
|
||||
const missingKey = 'hmac-test/missing.jpg';
|
||||
const missingSig = signReadUrl({
|
||||
bucket: 'orders-images',
|
||||
key: missingKey,
|
||||
expUnix: futureExp,
|
||||
secret: SIGNING_SECRET,
|
||||
});
|
||||
const notFound = await request(app.getHttpServer())
|
||||
.get(`/api/storage/orders-images/${missingKey}`)
|
||||
.query({ exp: futureExp, sig: missingSig });
|
||||
expect(notFound.status).toBe(404);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function jpegFixture(): Uint8Array {
|
||||
// Минимальный валидный JPEG header + EOI; для проверки трактовать как опаковые байты.
|
||||
return new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0xff, 0xd9]);
|
||||
}
|
||||
|
||||
function bufferToWebStream(buf: Buffer): ReadableStream<Uint8Array> {
|
||||
// Простая реализация без зависимости от node:stream/web.
|
||||
let sent = false;
|
||||
return {
|
||||
getReader() {
|
||||
return {
|
||||
async read() {
|
||||
if (sent) return { value: undefined, done: true };
|
||||
sent = true;
|
||||
return { value: new Uint8Array(buf), done: false };
|
||||
},
|
||||
async cancel() {
|
||||
/* noop */
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as ReadableStream<Uint8Array>;
|
||||
}
|
||||
|
||||
// suppress unused-import warning for Readable in case parser flags
|
||||
void Readable;
|
||||
@@ -460,6 +460,9 @@ export const AllTypesProps: Record<string,any> = {
|
||||
},
|
||||
GenerationContractSignedMetaDocumentInput:{
|
||||
|
||||
},
|
||||
GenerationConvertStatementGenerateDocumentInput:{
|
||||
|
||||
},
|
||||
GenerationMoneyInvestStatementGenerateDocumentInput:{
|
||||
|
||||
@@ -470,9 +473,6 @@ export const AllTypesProps: Record<string,any> = {
|
||||
},
|
||||
GenerationMoneyInvestStatementSignedMetaDocumentInput:{
|
||||
|
||||
},
|
||||
GenerationToMainWalletConvertStatementGenerateDocumentInput:{
|
||||
|
||||
},
|
||||
GetAccountInput:{
|
||||
|
||||
@@ -770,6 +770,10 @@ export const AllTypesProps: Record<string,any> = {
|
||||
data:"GenerationContractGenerateDocumentInput",
|
||||
options:"GenerateDocumentOptionsInput"
|
||||
},
|
||||
capitalGenerateGenerationConvertStatement:{
|
||||
data:"GenerationConvertStatementGenerateDocumentInput",
|
||||
options:"GenerateDocumentOptionsInput"
|
||||
},
|
||||
capitalGenerateGenerationMoneyInvestStatement:{
|
||||
data:"GenerationMoneyInvestStatementGenerateDocumentInput",
|
||||
options:"GenerateDocumentOptionsInput"
|
||||
@@ -786,18 +790,6 @@ export const AllTypesProps: Record<string,any> = {
|
||||
data:"GenerateDocumentInput",
|
||||
options:"GenerateDocumentOptionsInput"
|
||||
},
|
||||
capitalGenerateGenerationToCapitalizationConvertStatement:{
|
||||
data:"GenerateDocumentInput",
|
||||
options:"GenerateDocumentOptionsInput"
|
||||
},
|
||||
capitalGenerateGenerationToMainWalletConvertStatement:{
|
||||
data:"GenerationToMainWalletConvertStatementGenerateDocumentInput",
|
||||
options:"GenerateDocumentOptionsInput"
|
||||
},
|
||||
capitalGenerateGenerationToProjectConvertStatement:{
|
||||
data:"GenerateDocumentInput",
|
||||
options:"GenerateDocumentOptionsInput"
|
||||
},
|
||||
capitalGenerateGetLoanDecision:{
|
||||
data:"GenerateDocumentInput",
|
||||
options:"GenerateDocumentOptionsInput"
|
||||
@@ -3526,13 +3518,11 @@ export const ReturnTypes: Record<string,any> = {
|
||||
capitalGenerateExpenseDecision:"GeneratedDocument",
|
||||
capitalGenerateExpenseStatement:"GeneratedDocument",
|
||||
capitalGenerateGenerationContract:"GeneratedDocument",
|
||||
capitalGenerateGenerationConvertStatement:"GeneratedDocument",
|
||||
capitalGenerateGenerationMoneyInvestStatement:"GeneratedDocument",
|
||||
capitalGenerateGenerationPropertyInvestAct:"GeneratedDocument",
|
||||
capitalGenerateGenerationPropertyInvestDecision:"GeneratedDocument",
|
||||
capitalGenerateGenerationPropertyInvestStatement:"GeneratedDocument",
|
||||
capitalGenerateGenerationToCapitalizationConvertStatement:"GeneratedDocument",
|
||||
capitalGenerateGenerationToMainWalletConvertStatement:"GeneratedDocument",
|
||||
capitalGenerateGenerationToProjectConvertStatement:"GeneratedDocument",
|
||||
capitalGenerateGetLoanDecision:"GeneratedDocument",
|
||||
capitalGenerateGetLoanStatement:"GeneratedDocument",
|
||||
capitalGenerateProgramMoneyInvestStatement:"GeneratedDocument",
|
||||
|
||||
+143
-171
@@ -5521,6 +5521,38 @@ export type ValueTypes = {
|
||||
username: string | Variable<any, string>,
|
||||
/** Версия генератора, использованного для создания документа */
|
||||
version: string | Variable<any, string>
|
||||
};
|
||||
["GenerationConvertStatementGenerateDocumentInput"]: {
|
||||
/** Сумма для перевода в программу «Благорост» */
|
||||
blagorost_wallet_amount: string | Variable<any, string>,
|
||||
/** Номер блока, на котором был создан документ */
|
||||
block_num?: number | undefined | null | Variable<any, string>,
|
||||
/** Название кооператива, связанное с документом */
|
||||
coopname: string | Variable<any, string>,
|
||||
/** Дата и время создания документа */
|
||||
created_at?: string | undefined | null | Variable<any, string>,
|
||||
/** Имя генератора, использованного для создания документа */
|
||||
generator?: string | undefined | null | Variable<any, string>,
|
||||
/** Язык документа */
|
||||
lang?: string | undefined | null | Variable<any, string>,
|
||||
/** Ссылки, связанные с документом */
|
||||
links?: Array<string> | undefined | null | Variable<any, string>,
|
||||
/** Сумма для перевода в Цифровой Кошелёк */
|
||||
main_wallet_amount: string | Variable<any, string>,
|
||||
/** Хэш проекта */
|
||||
project_hash: string | Variable<any, string>,
|
||||
/** Часовой пояс, в котором был создан документ */
|
||||
timezone?: string | undefined | null | Variable<any, string>,
|
||||
/** Название документа */
|
||||
title?: string | undefined | null | Variable<any, string>,
|
||||
/** Признак перевода в программу «Благорост» */
|
||||
to_blagorost: boolean | Variable<any, string>,
|
||||
/** Признак перевода в Цифровой Кошелёк */
|
||||
to_wallet: boolean | Variable<any, string>,
|
||||
/** Имя пользователя, создавшего документ */
|
||||
username: string | Variable<any, string>,
|
||||
/** Версия генератора, использованного для создания документа */
|
||||
version?: string | undefined | null | Variable<any, string>
|
||||
};
|
||||
["GenerationMoneyInvestStatementGenerateDocumentInput"]: {
|
||||
/** Сумма инвестирования */
|
||||
@@ -5597,40 +5629,6 @@ export type ValueTypes = {
|
||||
username: string | Variable<any, string>,
|
||||
/** Версия генератора, использованного для создания документа */
|
||||
version: string | Variable<any, string>
|
||||
};
|
||||
["GenerationToMainWalletConvertStatementGenerateDocumentInput"]: {
|
||||
/** Хэш приложения */
|
||||
appendix_hash: string | Variable<any, string>,
|
||||
/** Сумма для перевода на благорост кошелек */
|
||||
blagorost_wallet_amount: string | Variable<any, string>,
|
||||
/** Номер блока, на котором был создан документ */
|
||||
block_num?: number | undefined | null | Variable<any, string>,
|
||||
/** Название кооператива, связанное с документом */
|
||||
coopname: string | Variable<any, string>,
|
||||
/** Дата и время создания документа */
|
||||
created_at?: string | undefined | null | Variable<any, string>,
|
||||
/** Имя генератора, использованного для создания документа */
|
||||
generator?: string | undefined | null | Variable<any, string>,
|
||||
/** Язык документа */
|
||||
lang?: string | undefined | null | Variable<any, string>,
|
||||
/** Ссылки, связанные с документом */
|
||||
links?: Array<string> | undefined | null | Variable<any, string>,
|
||||
/** Сумма для перевода на основной кошелек */
|
||||
main_wallet_amount: string | Variable<any, string>,
|
||||
/** Хэш проекта */
|
||||
project_hash: string | Variable<any, string>,
|
||||
/** Часовой пояс, в котором был создан документ */
|
||||
timezone?: string | undefined | null | Variable<any, string>,
|
||||
/** Название документа */
|
||||
title?: string | undefined | null | Variable<any, string>,
|
||||
/** Перевод на благорост кошелек */
|
||||
to_blagorost: boolean | Variable<any, string>,
|
||||
/** Перевод на основной кошелек */
|
||||
to_wallet: boolean | Variable<any, string>,
|
||||
/** Имя пользователя, создавшего документ */
|
||||
username: string | Variable<any, string>,
|
||||
/** Версия генератора, использованного для создания документа */
|
||||
version?: string | undefined | null | Variable<any, string>
|
||||
};
|
||||
["GetAccountInput"]: {
|
||||
/** Имя аккаунта пользователя */
|
||||
@@ -6421,13 +6419,11 @@ capitalGenerateComponentGenerationContract?: [{ data: ValueTypes["ComponentGener
|
||||
capitalGenerateExpenseDecision?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
|
||||
capitalGenerateExpenseStatement?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
|
||||
capitalGenerateGenerationContract?: [{ data: ValueTypes["GenerationContractGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
|
||||
capitalGenerateGenerationConvertStatement?: [{ data: ValueTypes["GenerationConvertStatementGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
|
||||
capitalGenerateGenerationMoneyInvestStatement?: [{ data: ValueTypes["GenerationMoneyInvestStatementGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
|
||||
capitalGenerateGenerationPropertyInvestAct?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
|
||||
capitalGenerateGenerationPropertyInvestDecision?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
|
||||
capitalGenerateGenerationPropertyInvestStatement?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
|
||||
capitalGenerateGenerationToCapitalizationConvertStatement?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
|
||||
capitalGenerateGenerationToMainWalletConvertStatement?: [{ data: ValueTypes["GenerationToMainWalletConvertStatementGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
|
||||
capitalGenerateGenerationToProjectConvertStatement?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
|
||||
capitalGenerateGetLoanDecision?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
|
||||
capitalGenerateGetLoanStatement?: [{ data: ValueTypes["GenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
|
||||
capitalGenerateProgramMoneyInvestStatement?: [{ data: ValueTypes["ProgramCapitalizationMoneyInvestStatementGenerateDocumentInput"] | Variable<any, string>, options?: ValueTypes["GenerateDocumentOptionsInput"] | undefined | null | Variable<any, string>},ValueTypes["GeneratedDocument"]],
|
||||
@@ -13829,6 +13825,38 @@ export type ResolverInputTypes = {
|
||||
username: string,
|
||||
/** Версия генератора, использованного для создания документа */
|
||||
version: string
|
||||
};
|
||||
["GenerationConvertStatementGenerateDocumentInput"]: {
|
||||
/** Сумма для перевода в программу «Благорост» */
|
||||
blagorost_wallet_amount: string,
|
||||
/** Номер блока, на котором был создан документ */
|
||||
block_num?: number | undefined | null,
|
||||
/** Название кооператива, связанное с документом */
|
||||
coopname: string,
|
||||
/** Дата и время создания документа */
|
||||
created_at?: string | undefined | null,
|
||||
/** Имя генератора, использованного для создания документа */
|
||||
generator?: string | undefined | null,
|
||||
/** Язык документа */
|
||||
lang?: string | undefined | null,
|
||||
/** Ссылки, связанные с документом */
|
||||
links?: Array<string> | undefined | null,
|
||||
/** Сумма для перевода в Цифровой Кошелёк */
|
||||
main_wallet_amount: string,
|
||||
/** Хэш проекта */
|
||||
project_hash: string,
|
||||
/** Часовой пояс, в котором был создан документ */
|
||||
timezone?: string | undefined | null,
|
||||
/** Название документа */
|
||||
title?: string | undefined | null,
|
||||
/** Признак перевода в программу «Благорост» */
|
||||
to_blagorost: boolean,
|
||||
/** Признак перевода в Цифровой Кошелёк */
|
||||
to_wallet: boolean,
|
||||
/** Имя пользователя, создавшего документ */
|
||||
username: string,
|
||||
/** Версия генератора, использованного для создания документа */
|
||||
version?: string | undefined | null
|
||||
};
|
||||
["GenerationMoneyInvestStatementGenerateDocumentInput"]: {
|
||||
/** Сумма инвестирования */
|
||||
@@ -13905,40 +13933,6 @@ export type ResolverInputTypes = {
|
||||
username: string,
|
||||
/** Версия генератора, использованного для создания документа */
|
||||
version: string
|
||||
};
|
||||
["GenerationToMainWalletConvertStatementGenerateDocumentInput"]: {
|
||||
/** Хэш приложения */
|
||||
appendix_hash: string,
|
||||
/** Сумма для перевода на благорост кошелек */
|
||||
blagorost_wallet_amount: string,
|
||||
/** Номер блока, на котором был создан документ */
|
||||
block_num?: number | undefined | null,
|
||||
/** Название кооператива, связанное с документом */
|
||||
coopname: string,
|
||||
/** Дата и время создания документа */
|
||||
created_at?: string | undefined | null,
|
||||
/** Имя генератора, использованного для создания документа */
|
||||
generator?: string | undefined | null,
|
||||
/** Язык документа */
|
||||
lang?: string | undefined | null,
|
||||
/** Ссылки, связанные с документом */
|
||||
links?: Array<string> | undefined | null,
|
||||
/** Сумма для перевода на основной кошелек */
|
||||
main_wallet_amount: string,
|
||||
/** Хэш проекта */
|
||||
project_hash: string,
|
||||
/** Часовой пояс, в котором был создан документ */
|
||||
timezone?: string | undefined | null,
|
||||
/** Название документа */
|
||||
title?: string | undefined | null,
|
||||
/** Перевод на благорост кошелек */
|
||||
to_blagorost: boolean,
|
||||
/** Перевод на основной кошелек */
|
||||
to_wallet: boolean,
|
||||
/** Имя пользователя, создавшего документ */
|
||||
username: string,
|
||||
/** Версия генератора, использованного для создания документа */
|
||||
version?: string | undefined | null
|
||||
};
|
||||
["GetAccountInput"]: {
|
||||
/** Имя аккаунта пользователя */
|
||||
@@ -14706,13 +14700,11 @@ capitalGenerateComponentGenerationContract?: [{ data: ResolverInputTypes["Compon
|
||||
capitalGenerateExpenseDecision?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
|
||||
capitalGenerateExpenseStatement?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
|
||||
capitalGenerateGenerationContract?: [{ data: ResolverInputTypes["GenerationContractGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
|
||||
capitalGenerateGenerationConvertStatement?: [{ data: ResolverInputTypes["GenerationConvertStatementGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
|
||||
capitalGenerateGenerationMoneyInvestStatement?: [{ data: ResolverInputTypes["GenerationMoneyInvestStatementGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
|
||||
capitalGenerateGenerationPropertyInvestAct?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
|
||||
capitalGenerateGenerationPropertyInvestDecision?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
|
||||
capitalGenerateGenerationPropertyInvestStatement?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
|
||||
capitalGenerateGenerationToCapitalizationConvertStatement?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
|
||||
capitalGenerateGenerationToMainWalletConvertStatement?: [{ data: ResolverInputTypes["GenerationToMainWalletConvertStatementGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
|
||||
capitalGenerateGenerationToProjectConvertStatement?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
|
||||
capitalGenerateGetLoanDecision?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
|
||||
capitalGenerateGetLoanStatement?: [{ data: ResolverInputTypes["GenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
|
||||
capitalGenerateProgramMoneyInvestStatement?: [{ data: ResolverInputTypes["ProgramCapitalizationMoneyInvestStatementGenerateDocumentInput"], options?: ResolverInputTypes["GenerateDocumentOptionsInput"] | undefined | null},ResolverInputTypes["GeneratedDocument"]],
|
||||
@@ -18965,13 +18957,13 @@ export type ModelTypes = {
|
||||
/** Обогащенные данные коммита (diff-патч, исходная ссылка и т.д.) */
|
||||
data?: ModelTypes["JSON"] | undefined | null,
|
||||
/** Описание коммита */
|
||||
description: string,
|
||||
description?: string | undefined | null,
|
||||
/** Отображаемое имя пользователя */
|
||||
display_name?: string | undefined | null,
|
||||
/** ID в блокчейне */
|
||||
id?: number | undefined | null,
|
||||
/** Метаданные коммита */
|
||||
meta: string,
|
||||
meta?: string | undefined | null,
|
||||
/** Флаг присутствия записи в блокчейне */
|
||||
present: boolean,
|
||||
/** Проект, к которому относится коммит */
|
||||
@@ -21895,6 +21887,38 @@ export type ModelTypes = {
|
||||
username: string,
|
||||
/** Версия генератора, использованного для создания документа */
|
||||
version: string
|
||||
};
|
||||
["GenerationConvertStatementGenerateDocumentInput"]: {
|
||||
/** Сумма для перевода в программу «Благорост» */
|
||||
blagorost_wallet_amount: string,
|
||||
/** Номер блока, на котором был создан документ */
|
||||
block_num?: number | undefined | null,
|
||||
/** Название кооператива, связанное с документом */
|
||||
coopname: string,
|
||||
/** Дата и время создания документа */
|
||||
created_at?: string | undefined | null,
|
||||
/** Имя генератора, использованного для создания документа */
|
||||
generator?: string | undefined | null,
|
||||
/** Язык документа */
|
||||
lang?: string | undefined | null,
|
||||
/** Ссылки, связанные с документом */
|
||||
links?: Array<string> | undefined | null,
|
||||
/** Сумма для перевода в Цифровой Кошелёк */
|
||||
main_wallet_amount: string,
|
||||
/** Хэш проекта */
|
||||
project_hash: string,
|
||||
/** Часовой пояс, в котором был создан документ */
|
||||
timezone?: string | undefined | null,
|
||||
/** Название документа */
|
||||
title?: string | undefined | null,
|
||||
/** Признак перевода в программу «Благорост» */
|
||||
to_blagorost: boolean,
|
||||
/** Признак перевода в Цифровой Кошелёк */
|
||||
to_wallet: boolean,
|
||||
/** Имя пользователя, создавшего документ */
|
||||
username: string,
|
||||
/** Версия генератора, использованного для создания документа */
|
||||
version?: string | undefined | null
|
||||
};
|
||||
["GenerationMoneyInvestStatementGenerateDocumentInput"]: {
|
||||
/** Сумма инвестирования */
|
||||
@@ -21971,40 +21995,6 @@ export type ModelTypes = {
|
||||
username: string,
|
||||
/** Версия генератора, использованного для создания документа */
|
||||
version: string
|
||||
};
|
||||
["GenerationToMainWalletConvertStatementGenerateDocumentInput"]: {
|
||||
/** Хэш приложения */
|
||||
appendix_hash: string,
|
||||
/** Сумма для перевода на благорост кошелек */
|
||||
blagorost_wallet_amount: string,
|
||||
/** Номер блока, на котором был создан документ */
|
||||
block_num?: number | undefined | null,
|
||||
/** Название кооператива, связанное с документом */
|
||||
coopname: string,
|
||||
/** Дата и время создания документа */
|
||||
created_at?: string | undefined | null,
|
||||
/** Имя генератора, использованного для создания документа */
|
||||
generator?: string | undefined | null,
|
||||
/** Язык документа */
|
||||
lang?: string | undefined | null,
|
||||
/** Ссылки, связанные с документом */
|
||||
links?: Array<string> | undefined | null,
|
||||
/** Сумма для перевода на основной кошелек */
|
||||
main_wallet_amount: string,
|
||||
/** Хэш проекта */
|
||||
project_hash: string,
|
||||
/** Часовой пояс, в котором был создан документ */
|
||||
timezone?: string | undefined | null,
|
||||
/** Название документа */
|
||||
title?: string | undefined | null,
|
||||
/** Перевод на благорост кошелек */
|
||||
to_blagorost: boolean,
|
||||
/** Перевод на основной кошелек */
|
||||
to_wallet: boolean,
|
||||
/** Имя пользователя, создавшего документ */
|
||||
username: string,
|
||||
/** Версия генератора, использованного для создания документа */
|
||||
version?: string | undefined | null
|
||||
};
|
||||
["GetAccountInput"]: {
|
||||
/** Имя аккаунта пользователя */
|
||||
@@ -22869,6 +22859,10 @@ export type ModelTypes = {
|
||||
|
||||
Требуемые роли: chairman, member. */
|
||||
capitalGenerateGenerationContract: ModelTypes["GeneratedDocument"],
|
||||
/** Сгенерировать заявление о конвертации целевого паевого взноса (в Цифровой Кошелёк и/или в программу «Благорост»)
|
||||
|
||||
Требуемые роли: chairman, member. */
|
||||
capitalGenerateGenerationConvertStatement: ModelTypes["GeneratedDocument"],
|
||||
/** Сгенерировать заявление об инвестировании в генерацию
|
||||
|
||||
Требуемые роли: chairman, member. */
|
||||
@@ -22885,18 +22879,6 @@ export type ModelTypes = {
|
||||
|
||||
Требуемые роли: chairman, member. */
|
||||
capitalGenerateGenerationPropertyInvestStatement: ModelTypes["GeneratedDocument"],
|
||||
/** Сгенерировать заявление о конвертации из генерации в благорост
|
||||
|
||||
Требуемые роли: chairman, member. */
|
||||
capitalGenerateGenerationToCapitalizationConvertStatement: ModelTypes["GeneratedDocument"],
|
||||
/** Сгенерировать заявление о конвертации из генерации в основной кошелек
|
||||
|
||||
Требуемые роли: chairman, member. */
|
||||
capitalGenerateGenerationToMainWalletConvertStatement: ModelTypes["GeneratedDocument"],
|
||||
/** Сгенерировать заявление о конвертации из генерации в проектный кошелек
|
||||
|
||||
Требуемые роли: chairman, member. */
|
||||
capitalGenerateGenerationToProjectConvertStatement: ModelTypes["GeneratedDocument"],
|
||||
/** Сгенерировать решение о получении займа
|
||||
|
||||
Требуемые роли: chairman, member. */
|
||||
@@ -27626,13 +27608,13 @@ export type GraphQLTypes = {
|
||||
/** Обогащенные данные коммита (diff-патч, исходная ссылка и т.д.) */
|
||||
data?: GraphQLTypes["JSON"] | undefined | null,
|
||||
/** Описание коммита */
|
||||
description: string,
|
||||
description?: string | undefined | null,
|
||||
/** Отображаемое имя пользователя */
|
||||
display_name?: string | undefined | null,
|
||||
/** ID в блокчейне */
|
||||
id?: number | undefined | null,
|
||||
/** Метаданные коммита */
|
||||
meta: string,
|
||||
meta?: string | undefined | null,
|
||||
/** Флаг присутствия записи в блокчейне */
|
||||
present: boolean,
|
||||
/** Проект, к которому относится коммит */
|
||||
@@ -30712,6 +30694,38 @@ export type GraphQLTypes = {
|
||||
username: string,
|
||||
/** Версия генератора, использованного для создания документа */
|
||||
version: string
|
||||
};
|
||||
["GenerationConvertStatementGenerateDocumentInput"]: {
|
||||
/** Сумма для перевода в программу «Благорост» */
|
||||
blagorost_wallet_amount: string,
|
||||
/** Номер блока, на котором был создан документ */
|
||||
block_num?: number | undefined | null,
|
||||
/** Название кооператива, связанное с документом */
|
||||
coopname: string,
|
||||
/** Дата и время создания документа */
|
||||
created_at?: string | undefined | null,
|
||||
/** Имя генератора, использованного для создания документа */
|
||||
generator?: string | undefined | null,
|
||||
/** Язык документа */
|
||||
lang?: string | undefined | null,
|
||||
/** Ссылки, связанные с документом */
|
||||
links?: Array<string> | undefined | null,
|
||||
/** Сумма для перевода в Цифровой Кошелёк */
|
||||
main_wallet_amount: string,
|
||||
/** Хэш проекта */
|
||||
project_hash: string,
|
||||
/** Часовой пояс, в котором был создан документ */
|
||||
timezone?: string | undefined | null,
|
||||
/** Название документа */
|
||||
title?: string | undefined | null,
|
||||
/** Признак перевода в программу «Благорост» */
|
||||
to_blagorost: boolean,
|
||||
/** Признак перевода в Цифровой Кошелёк */
|
||||
to_wallet: boolean,
|
||||
/** Имя пользователя, создавшего документ */
|
||||
username: string,
|
||||
/** Версия генератора, использованного для создания документа */
|
||||
version?: string | undefined | null
|
||||
};
|
||||
["GenerationMoneyInvestStatementGenerateDocumentInput"]: {
|
||||
/** Сумма инвестирования */
|
||||
@@ -30788,40 +30802,6 @@ export type GraphQLTypes = {
|
||||
username: string,
|
||||
/** Версия генератора, использованного для создания документа */
|
||||
version: string
|
||||
};
|
||||
["GenerationToMainWalletConvertStatementGenerateDocumentInput"]: {
|
||||
/** Хэш приложения */
|
||||
appendix_hash: string,
|
||||
/** Сумма для перевода на благорост кошелек */
|
||||
blagorost_wallet_amount: string,
|
||||
/** Номер блока, на котором был создан документ */
|
||||
block_num?: number | undefined | null,
|
||||
/** Название кооператива, связанное с документом */
|
||||
coopname: string,
|
||||
/** Дата и время создания документа */
|
||||
created_at?: string | undefined | null,
|
||||
/** Имя генератора, использованного для создания документа */
|
||||
generator?: string | undefined | null,
|
||||
/** Язык документа */
|
||||
lang?: string | undefined | null,
|
||||
/** Ссылки, связанные с документом */
|
||||
links?: Array<string> | undefined | null,
|
||||
/** Сумма для перевода на основной кошелек */
|
||||
main_wallet_amount: string,
|
||||
/** Хэш проекта */
|
||||
project_hash: string,
|
||||
/** Часовой пояс, в котором был создан документ */
|
||||
timezone?: string | undefined | null,
|
||||
/** Название документа */
|
||||
title?: string | undefined | null,
|
||||
/** Перевод на благорост кошелек */
|
||||
to_blagorost: boolean,
|
||||
/** Перевод на основной кошелек */
|
||||
to_wallet: boolean,
|
||||
/** Имя пользователя, создавшего документ */
|
||||
username: string,
|
||||
/** Версия генератора, использованного для создания документа */
|
||||
version?: string | undefined | null
|
||||
};
|
||||
["GetAccountInput"]: {
|
||||
/** Имя аккаунта пользователя */
|
||||
@@ -31739,6 +31719,10 @@ export type GraphQLTypes = {
|
||||
|
||||
Требуемые роли: chairman, member. */
|
||||
capitalGenerateGenerationContract: GraphQLTypes["GeneratedDocument"],
|
||||
/** Сгенерировать заявление о конвертации целевого паевого взноса (в Цифровой Кошелёк и/или в программу «Благорост»)
|
||||
|
||||
Требуемые роли: chairman, member. */
|
||||
capitalGenerateGenerationConvertStatement: GraphQLTypes["GeneratedDocument"],
|
||||
/** Сгенерировать заявление об инвестировании в генерацию
|
||||
|
||||
Требуемые роли: chairman, member. */
|
||||
@@ -31755,18 +31739,6 @@ export type GraphQLTypes = {
|
||||
|
||||
Требуемые роли: chairman, member. */
|
||||
capitalGenerateGenerationPropertyInvestStatement: GraphQLTypes["GeneratedDocument"],
|
||||
/** Сгенерировать заявление о конвертации из генерации в благорост
|
||||
|
||||
Требуемые роли: chairman, member. */
|
||||
capitalGenerateGenerationToCapitalizationConvertStatement: GraphQLTypes["GeneratedDocument"],
|
||||
/** Сгенерировать заявление о конвертации из генерации в основной кошелек
|
||||
|
||||
Требуемые роли: chairman, member. */
|
||||
capitalGenerateGenerationToMainWalletConvertStatement: GraphQLTypes["GeneratedDocument"],
|
||||
/** Сгенерировать заявление о конвертации из генерации в проектный кошелек
|
||||
|
||||
Требуемые роли: chairman, member. */
|
||||
capitalGenerateGenerationToProjectConvertStatement: GraphQLTypes["GeneratedDocument"],
|
||||
/** Сгенерировать решение о получении займа
|
||||
|
||||
Требуемые роли: chairman, member. */
|
||||
@@ -35917,10 +35889,10 @@ type ZEUS_VARIABLES = {
|
||||
["GenerationContractGenerateDocumentInput"]: ValueTypes["GenerationContractGenerateDocumentInput"];
|
||||
["GenerationContractSignedDocumentInput"]: ValueTypes["GenerationContractSignedDocumentInput"];
|
||||
["GenerationContractSignedMetaDocumentInput"]: ValueTypes["GenerationContractSignedMetaDocumentInput"];
|
||||
["GenerationConvertStatementGenerateDocumentInput"]: ValueTypes["GenerationConvertStatementGenerateDocumentInput"];
|
||||
["GenerationMoneyInvestStatementGenerateDocumentInput"]: ValueTypes["GenerationMoneyInvestStatementGenerateDocumentInput"];
|
||||
["GenerationMoneyInvestStatementSignedDocumentInput"]: ValueTypes["GenerationMoneyInvestStatementSignedDocumentInput"];
|
||||
["GenerationMoneyInvestStatementSignedMetaDocumentInput"]: ValueTypes["GenerationMoneyInvestStatementSignedMetaDocumentInput"];
|
||||
["GenerationToMainWalletConvertStatementGenerateDocumentInput"]: ValueTypes["GenerationToMainWalletConvertStatementGenerateDocumentInput"];
|
||||
["GetAccountInput"]: ValueTypes["GetAccountInput"];
|
||||
["GetAccountsInput"]: ValueTypes["GetAccountsInput"];
|
||||
["GetBranchesInput"]: ValueTypes["GetBranchesInput"];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cooptypes",
|
||||
"type": "module",
|
||||
"version": "2026.5.13-alpha-3",
|
||||
"version": "2026.5.14-2",
|
||||
"description": "Shared TypeScript типы и интерфейсы экосистемы кооперативов",
|
||||
"author": "Alex Ant <dacom.dark.sun@gmail.com>",
|
||||
"license": "MIT",
|
||||
|
||||
+2
-2
@@ -34,8 +34,8 @@ export interface Model {
|
||||
to_blagorost: boolean
|
||||
}
|
||||
|
||||
export const title = 'Заявление о переводе части целевого паевого взноса'
|
||||
export const description = 'Форма заявления о переводе части целевого паевого взноса'
|
||||
export const title = 'Заявление о трансляции паевого взноса из программы "Генерация"'
|
||||
export const description = 'Форма заявления о трансляции паевого взноса из программы "Генерация" в Цифровой Кошелёк и/или в программу «Благорост»'
|
||||
|
||||
export const context = `<div class="digital-document"><div style="text-align: right"><p>{% trans 'to_council' %} {{ vars.full_abbr_genitive }} "{{ vars.name }}"</p><p>{% trans 'from_shareholder' %} {{ common_user.full_name_or_short_name }}</p></div><div style="text-align: center"><h2>{% trans 'statement_title' %}</h2><h3>{% trans 'statement_subtitle' %}</h3></div><p>{% trans 'statement_text' %} № {{ contributor_contract_number }} {% trans 'from_date' %} {{ contributor_contract_created_at }} {% trans 'and_appendix' %} № {{ appendix_short_hash }}, {% trans 'request_text' %} №{{ project_short_hash }} {% trans 'following_way' %}:</p><ol>{% if to_wallet %}<li>{% trans 'wallet_conversion_text', main_wallet_amount %}</li>{% endif %}{% if to_blagorost %}<li>{% trans 'blagorost_conversion_text', blagorost_wallet_amount %}</li>{% endif %}</ol><div style="margin-top: 40px;"><p>{% trans 'signed_by_digital_signature' %}</p><p style="text-align: right">{{ meta.created_at }}</p></div></div><style>.digital-document {padding: 20px;white-space: pre-wrap;}</style>`
|
||||
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
import type { IGenerate, IMetaDocument } from '../../document'
|
||||
|
||||
export const registry_id = 1081
|
||||
|
||||
// Модель действия для генерации
|
||||
export interface Action extends IGenerate {
|
||||
registry_id: number
|
||||
}
|
||||
|
||||
export type Meta = IMetaDocument & Action
|
||||
|
||||
// Модель данных документа
|
||||
export interface Model {
|
||||
meta: IMetaDocument
|
||||
}
|
||||
|
||||
export const title = 'Заявление о конвертации средств генерации в проект'
|
||||
export const description = 'Форма заявления о конвертации средств из генерации в конкретный проект'
|
||||
export const context = '<div class="digital-document"><div style="text-align: center"><h2>ЗАЯВЛЕНИЕ О КОНВЕРТАЦИИ СРЕДСТВ ГЕНЕРАЦИИ В ПРОЕКТ</h2></div><p>Прошу конвертировать средства из генерации для инвестирования в проект.</p><p>Подпись: Иван Иванович</p></div>'
|
||||
|
||||
export const translations = {}
|
||||
export const exampleData = {}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
import type { IGenerate, IMetaDocument } from '../../document'
|
||||
|
||||
export const registry_id = 1082
|
||||
|
||||
// Модель действия для генерации
|
||||
export interface Action extends IGenerate {
|
||||
registry_id: number
|
||||
}
|
||||
|
||||
export type Meta = IMetaDocument & Action
|
||||
|
||||
// Модель данных документа
|
||||
export interface Model {
|
||||
meta: IMetaDocument
|
||||
}
|
||||
|
||||
export const title = 'Заявление о конвертации средств генерации в Благорост'
|
||||
export const description = 'Форма заявления о конвертации средств из генерации в программу Благорост'
|
||||
export const context = '<div class="digital-document"><div style="text-align: center"><h2>ЗАЯВЛЕНИЕ О КОНВЕРТАЦИИ СРЕДСТВ ГЕНЕРАЦИИ В благорост</h2></div><p>Прошу конвертировать средства из генерации для участия в программе благороста.</p><p>Подпись: Иван Иванович</p></div>'
|
||||
|
||||
export const translations = {}
|
||||
export const exampleData = {}
|
||||
@@ -61,9 +61,7 @@ export * as CapitalizationPropertyInvestStatement from './1070.CapitalizationPro
|
||||
export * as CapitalizationPropertyInvestDecision from './1071.CapitalizationPropertyInvestDecision'
|
||||
export * as CapitalizationPropertyInvestAct from './1072.CapitalizationPropertyInvestAct'
|
||||
|
||||
export * as GenerationToMainWalletConvertStatement from './1080.GenerationToMainWalletConvertStatement'
|
||||
export * as GenerationToProjectConvertStatement from './1081.GenerationToProjectConvertStatement'
|
||||
export * as GenerationToCapitalizationConvertStatement from './1082.GenerationToCapitalizationConvertStatement'
|
||||
export * as GenerationConvertStatement from './1080.GenerationConvertStatement'
|
||||
|
||||
export * as CapitalizationToMainWalletConvertStatement from './1090.CapitalizationToMainWalletConvertStatement'
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user