Эпик 8 «Списание скоропорта по решению совета» MVP Стола заказов (598-11) (#399)

* [598-11][@ant] feat: Эпик 8 «Списание скоропорта по решению совета» — перевод процесса на канонический паттерн soviet::createagenda с двумя документами

ОСНОВНОЕ ИЗМЕНЕНИЕ ОТ ПРЕДЫДУЩЕЙ ИТЕРАЦИИ:
- Предыдущая реализация Эпика 8 (PR #399 — reverted) шла «мимо совета»: backend
  напрямую дергал propwroff/execwroff/declwroff, в UI председатель «исполнял»
  и «отклонял» сам. Это не совпадало с каноническим паттерном принятия решений
  совета (приём в пайщики 100→501, паевой взнос имуществом 700→701, свободное
  решение 600, ResultContribution 1040→1041 — и т.д.).
- Текущая ветка переделана с нуля под канон: документ-Предложение (1106)
  подписывается председателем → soviet::createagenda(type=mktwroff) ставит
  повестку → совет голосует и авторизует Протокол (1105) → callback от soviet
  (onmktwoauth / onmktwodecl) фиксирует решение на цепи → backend per-item
  проводит execwroff.

C++ КОНТРАКТЫ:
- contracts/cpp/marketplace: добавлены actions onmktwoauth(coopname, hash,
  authorization) и onmktwodecl(coopname, hash, reason) с require_auth(_soviet).
  Сигнатуры совпадают с AUTHORIZE_CALLBACK_SIGNATURE и DECLINE_CALLBACK_SIGNATURE
  в soviet.hpp.
- contracts/cpp/marketplace: добавлен статус AUTHORIZED в WroffStatus; execwroff
  упрощён — больше не принимает протокол (он лежит в proposal.protocol после
  callback'а onmktwoauth), проверяет статус == AUTHORIZED.
- contracts/cpp/marketplace: удалён declwroff (его роль исполняет
  soviet::cancelexprd → callback onmktwodecl).
- contracts/cpp/lib: добавлен type "mktwroff"_n в consts; dispatch в
  soviet::exec.cpp идёт по default-branch на authorize_action_effect.
- p.mkt.wroff.standard.yaml: states/transitions/documents переведены под
  новый граф.

COOPTYPES + FACTORY:
- registry 1106.MarketplaceWriteoffStatement (Заявление председателя о списании)
  — подписывается перед propwroff + createagenda.
- registry 1105.MarketplaceWriteoffProtocol (Протокол совета о списании) —
  подписывается chairman'ом в стандартном sov.decision flow, ложится в
  wroffprops.protocol через callback onmktwoauth.
- factory/Actions + Templates для обоих registry; registry.ts обновлён.
- contracts/marketplace/actions: declWroff удалён, добавлены onMktWoAuth и
  onMktWoDecl; IExecWroff в interfaces/marketplace.ts больше не несёт protocol.

CONTROLLER:
- domain/infrastructure MarketplaceWriteoffProposal: статусы DRAFT → ON_AGENDA
  → AUTHORIZED → EXECUTING → EXECUTED | REJECTED; trigger cron/manual;
  decision_id для связки с soviet.decisions; items с inventory_id для
  крон-пометки записей WRITTEN_OFF после executed.
- partial-unique индексы запрещают параллельные DRAFT/ON_AGENDA per coopname.
- application/services/marketplace-writeoff.service: createDraft / updateDraft /
  cancelDraft / submitToCouncil (propwroff + createagenda) / onCouncilAuthorized
  (callback → AUTHORIZED → executeAuthorizedProposal) / executeAuthorizedProposal
  (цикл execwroff per-item) / onCouncilDeclined.
- detected proposal_hash = sha256('writeoff|coopname|cycle_started_at|draft_id|items').
- application/services/marketplace-writeoff-cron: ежемесячный сканер
  inventory.expiry_date; если config.writeoff.auto_proposal_enabled — формирует
  DRAFT-предложение по позициям LABELED с приближающимся сроком (грейс
  заданный конфигом), иначе только reminder председателю.
- infrastructure/inventory: новая колонка expiry_date (TypeORM synchronize),
  partial-индекс под крон-сканер; проставление при labeling
  (`labeled_at + Offer.warranty_days * 86400`).
- types.ts: IConfig.writeoff{auto_proposal_enabled, expiry_grace_days}.
- migrations/marketplace-bootstrap-v6: bump schema_version + merge writeoff
  defaults.
- domain/ports/marketplace-canonical-blockchain: propWroff / execWroff /
  createWriteoffAgenda. Adapter использует BlockchainService + VaultDomainService.
- application/dto/marketplace-writeoff.dto: enum'ы (StatusEnum / TriggerEnum)
  через registerEnumType; PaginatedMarketplaceWriteoffProposals на
  createPaginationResult; PaginationInputDTO в resolver.
- application/resolvers/marketplace-writeoff.resolver: marketplaceOpenWriteoffDraft,
  marketplaceListWriteoffProposals, marketplaceWriteoffProposal,
  marketplaceCreateWriteoffDraft (admin/manage_draft),
  marketplaceUpdateWriteoffDraft, marketplaceCancelWriteoffDraft,
  marketplaceWriteoffStatementSignablePayload (admin/propose, генерирует 1106),
  marketplaceSubmitWriteoffDraft (admin/propose, подписанное 1106 → propwroff +
  createagenda). marketplaceExecuteWriteoffProposal / marketplaceDeclineWriteoffProposal
  больше нет — это теперь делает совет.
- access-matrix: admin.Writeoff = ['manage_draft','propose','read:all'],
  board.Writeoff = ['decide','read:all'], board_readonly.Writeoff = ['read:all'].

SDK (@coopenomics/sdk):
- selectors/marketplace/writeoffSelector (item, decision_log, proposal,
  paginated) с MakeAllFieldsRequired-валидаторами.
- queries.marketplace: openWriteoffDraft, listWriteoffProposals,
  getWriteoffProposal, writeoffStatementSignablePayload.
- mutations.marketplace: createWriteoffDraft, updateWriteoffDraft,
  cancelWriteoffDraft, submitWriteoffDraft.

NOTIFICATIONS:
- 5 Novu workflows: marketplace-writeoff-draft-built (адресат:
  председатель/админ), -proposed (адресат: совет), -authorized, -executed,
  -rejected (адресат: админ/председатель). Liquid-синтаксис.
- Эмиты событий marketplace.writeoff.* добавлены в
  marketplace-notification.events.ts; listener-bridge wiring оставлен Phase 2
  (отдельный коммит, паттерн 598-20).

DESKTOP:
- pages/Marketplace/AdminWriteoffs/api: типизированные обёртки над
  @coopenomics/sdk.
- AdminWriteoffsPage.vue: open-draft + лента «в работе совета» +
  архив (EXECUTED/REJECTED).
- DraftEditorDialog: добавление/удаление/правка позиций с live-total.
- SubmitToCouncilDialog: превью Заявления 1106 → подпись DigitalDocument →
  submitWriteoffDraft (запускает propwroff + createagenda).
- WriteoffProposalDetailsDialog: детали, items.executed, decision_log.
- extensions/market/install.ts: маршрут /<coopname>/market/writeoffs для
  ролей chairman+member; mp-role-admin / --mp-font-body токены по канону.

ИЗВЕСТНЫЕ ОГРАНИЧЕНИЯ / PHASE 2:
- Listener-bridge `MarketplaceNotificationService` для каналов
  marketplace.writeoff.* остаётся на отдельный коммит (паттерн 598-20).
- Sync writeoff_proposals из chain delta (двусторонняя сверка decision_id ↔
  soviet.decisions) — Phase 2, если будет нужен reconciler.
- Крон в auto-proposal-mode проставляет amount=0 placeholder (нет
  attached unit-cost при labeling) — председатель руками поправит при ревью.
- Переуступка по сниженной цене — out of MVP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [598-11][@ant] fix: ревью PR #399 — asset из config, стандарт на бизнес-языке, spec и docs-фабрика

- ASSET_DECIMALS/SYMBOL в writeoff.service / writeoff-cron.service берутся из
  MARKETPLACE_ASSET_CONFIG (DI), не хардкод (как в order-create.service)
- p.mkt.wroff.standard.yaml переписан без техжаргона (callback/soviet::exec/
  AUTHORIZE_CALLBACK_SIGNATURE убраны, эталон p.mkt.return)
- Заявление 1106 и Протокол 1105 включены в documents-1000-plus тест фабрики
  с моком getDecision для 1105
- spec для MarketplaceWriteoffService: createDraft/updateDraft/cancelDraft,
  computeProposalHash детерминистичность, submitToCouncil happy + 4 reject-кейса,
  onCouncilAuthorized/Declined, executeAuthorizedProposal per-item
- coopname в writeoff.resolver — из config.coopname (IMarketplaceCurrentMember
  не несёт coopname); статичный сигнатурный долг toDocument убран
- CreateAgenda путь — SovietContract.Actions.Decisions.CreateAgenda

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: ant <ant@noreply.local>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Ant
2026-05-18 20:10:32 +05:00
committed by GitHub
parent f0311afaa7
commit 80ebdb4a73
81 changed files with 5172 additions and 289 deletions
+1
View File
@@ -67,6 +67,7 @@ static constexpr eosio::name _free_decision_action = "freedecision"_n;
static constexpr eosio::name _change_action = "change"_n;
static constexpr eosio::name _product_contribution_action = "productcntr"_n;
static constexpr eosio::name _product_return_action = "productrtrn"_n;
static constexpr eosio::name _marketplace_writeoff_action = "mktwroff"_n; ///< Списание скоропорта по решению совета (p.mkt.wroff)
// capitalization linked actions
@@ -17,18 +17,26 @@ using namespace eosio;
/**
* @brief Статусы проекта решения совета о списании скоропорта (процесс p.mkt.wroff).
*
* Граф: ∅ → draft → executed (final, ledger2-операции применены)
* → rejected (final, без ledger2-операций)
* Граф (выровнен под канонический паттерн «решение совета»
* `soviet::createagenda` + callback'и):
*
* ∅
* ├─ propwroff (admin) ─────────────► proposed
* │ └─ soviet::createagenda(type=mktwroff, callback=onmktwoauth/onmktwodecl)
* ├─ onmktwoauth (callback от soviet) ► authorized (хранит protocol2)
* │ └─ execwroff per-item (backend цикл) ► executed (final)
* └─ onmktwodecl (callback от soviet) ► rejected (final, без ledger2-операций)
*
* Источник правды — `p.mkt.wroff.standard.yaml` секция `states:`.
*/
namespace WroffStatus {
// Имя константы PROPOSED (а не DRAFT) — конфликт с макросом
// DRAFT="draft" из lib/consts.hpp; on-chain строка осталась "draft"_n,
// как в p.mkt.wroff.standard.yaml.
inline constexpr eosio::name PROPOSED = "draft"_n;
inline constexpr eosio::name EXECUTED = "executed"_n;
inline constexpr eosio::name REJECTED = "rejected"_n;
// on-chain имена 1:1 совпадают с YAML; PROPOSED/AUTHORIZED/EXECUTED/REJECTED —
// C++-константы (имя PROPOSED вместо DRAFT — чтобы не конфликтовать с макросом
// DRAFT из lib/consts.hpp).
inline constexpr eosio::name PROPOSED = "proposed"_n;
inline constexpr eosio::name AUTHORIZED = "authorized"_n;
inline constexpr eosio::name EXECUTED = "executed"_n;
inline constexpr eosio::name REJECTED = "rejected"_n;
}
/**
@@ -92,10 +100,12 @@ struct [[eosio::table, eosio::contract(MARKETPLACE)]] writeoff_proposal {
eosio::asset total_amount = asset(0, _root_govern_symbol); ///< Σ items.amount (для UI / отчёта)
eosio::name status = WroffStatus::PROPOSED;
document2 protocol; ///< протокол решения совета (для execwroff)
std::string reject_reason; ///< причина отклонения (для declwroff)
document2 protocol; ///< Подписанный советом протокол решения; кладётся в callback onmktwoauth/onmktwodecl
std::string reject_reason; ///< Причина отклонения (можно достать из meta protocol в onmktwodecl)
// Timestamp'ы propwroff/execwroff/declwroff — на бэкенде из blockchain_actions[at].
// Связка с soviet.decisions — через decisions.hash == proposal.hash; backend стыкует обе таблицы по hash.
// Timestamp'ы propwroff / onmktwoauth / onmktwodecl / execwroff фиксируются
// backend'ом из блокчейн-дельт (поле blockchain_actions[at]).
uint64_t primary_key() const { return id; }
checksum256 by_hash() const { return hash; }
@@ -26,10 +26,14 @@
#include "src/p.mkt.return/accretrn.cpp"
#include "src/p.mkt.return/rejretrn.cpp"
// ── p.mkt.wroff (3 actions) ───── Stories Эпика 8 ──────────────────────
// ── p.mkt.wroff (4 actions) ───── Stories Эпика 8 ──────────────────────
// Канонический паттерн «решение совета»: propwroff (admin) → soviet::createagenda
// → onmktwoauth / onmktwodecl (callback от soviet после голосования) → execwroff
// per-item (backend цикл).
#include "src/p.mkt.wroff/propwroff.cpp"
#include "src/p.mkt.wroff/onmktwoauth.cpp"
#include "src/p.mkt.wroff/onmktwodecl.cpp"
#include "src/p.mkt.wroff/execwroff.cpp"
#include "src/p.mkt.wroff/declwroff.cpp"
[[eosio::action]] void marketplace::migrate() {
// Donor-таблиц нет (AR30 — donor-actions удалены вместе с requests/segments/
@@ -31,7 +31,16 @@ using namespace Marketplace;
* acceptorder, declineorder, signsupp, signchair, signiss1, signiss2.
* - **p.mkt.return** (5 actions): submretrn, aprretrem, rejretrem, accretrn,
* rejretrn.
* - **p.mkt.wroff** (3 actions): propwroff, execwroff, declwroff.
* - **p.mkt.wroff** (4 actions): propwroff, execwroff, onmktwoauth, onmktwodecl.
* Cписание скоропорта идёт через канонический паттерн «решение совета»:
* backend подписывает Заявление о списании (registry 1106) ключом
* кооператива, вызывает `propwroff` (запись wroffprops::proposed) +
* `soviet::createagenda(type=mktwroff, callback=onmktwoauth/onmktwodecl)`.
* После голосования совета и подписи Протокола (registry 1105) chairman'ом
* soviet::exec автоматически вызывает callback `onmktwoauth` (PROPOSED →
* AUTHORIZED, сохраняется protocol2) или `onmktwodecl` (PROPOSED → REJECTED).
* Только после AUTHORIZED backend циклом по items вызывает `execwroff`
* per-item (atomic пара o.mkt.wroff + o.mkt.wroff2 на каждой позиции).
*
* Все per-batch операции на on-chain выполняются per-Order (бэкенд
* проходит циклом по Order'ам соответствующего batch'а, объединяя их по
@@ -283,8 +292,11 @@ public:
// ── p.mkt.wroff ──────────────────────────────────────────────────────
/**
* @brief Backend / админ выносит проект списания на повестку совета (Story 8.1).
* Без ledger2-операций — только создание proposal с N позициями.
* @brief Backend выносит проект списания на повестку совета (Story 8.1).
* Без ledger2-операций — только создание proposal с N позициями (статус
* proposed). Сразу после этого backend вызывает `soviet::createagenda` с
* `callback_contract=marketplace`, `confirm_callback=onmktwoauth`,
* `decline_callback=onmktwodecl`, `type=mktwroff`, `hash=proposal_hash`.
* @ingroup public_marketplace_actions
*/
[[eosio::action]] void propwroff(eosio::name coopname,
@@ -293,27 +305,54 @@ public:
std::vector<wroff_item> items);
/**
* @brief Совет исполняет одну позицию проекта списания (Story 8.3).
* Per-item: o.mkt.wroff + o.mkt.wroff2 (атомарно в той же транзакции),
* `items[item_index].executed = true`. Когда все items.executed → proposal
* status переходит в EXECUTED. Авторизация: подписант ∈ branches[items[item_index].braname].
* Backend проходит циклом по неисполненным items, вызывая `execwroff` per item.
* @brief Callback от `soviet::exec` после авторизации Протокола совета
* (registry 1105) председателем (Story 8.4). PROPOSED → AUTHORIZED;
* сохраняется `authorization` в `wroffprops.protocol`. Цикл per-item
* списания запускает backend через `execwroff` после получения этой дельты.
*
* Авторизация: контракт `_soviet` (`require_auth(_soviet)`); сигнатура
* соответствует `authorize_action_effect` в soviet — `(coopname, hash,
* authorization)`.
* @ingroup public_marketplace_actions
*/
[[eosio::action]] void onmktwoauth(eosio::name coopname,
checksum256 hash,
document2 authorization);
/**
* @brief Callback от `soviet::cancelexprd` (или от любого decline-эффекта в
* soviet) — повестка отклонена или просрочена (Story 8.4). PROPOSED →
* REJECTED; `reason` сохраняется в `wroffprops.reject_reason`. Без
* ledger2-движений.
*
* Сигнатура `(coopname, hash, reason)` соответствует
* `DECLINE_CALLBACK_SIGNATURE` в `lib/core/soviet/soviet.hpp:19`.
* Авторизация: `_soviet`.
* @ingroup public_marketplace_actions
*/
[[eosio::action]] void onmktwodecl(eosio::name coopname,
checksum256 hash,
std::string reason);
/**
* @brief Backend исполняет одну позицию авторизованного проекта списания
* (Story 8.4). Per-item: `o.mkt.wroff + o.mkt.wroff2` (атомарно в одной
* транзакции), `items[item_index].executed = true`. Когда все items
* исполнены, статус AUTHORIZED → EXECUTED.
*
* Защита от газового лимита (тысячи позиций в одной транзакции Antelope
* не помещаются) — backend проходит цикл и вызывает `execwroff` per item.
*
* Guards:
* - proposal.status == AUTHORIZED (callback `onmktwoauth` уже отработал);
* - подписант (`signer`) авторизован для КУ-источника
* (`branches[items[item_index].braname]`).
* @ingroup public_marketplace_actions
*/
[[eosio::action]] void execwroff(eosio::name coopname,
eosio::name signer,
checksum256 proposal_hash,
uint64_t item_index,
document2 protocol);
/**
* @brief Совет отклоняет проект списания целиком (Story 8.3).
* @ingroup public_marketplace_actions
*/
[[eosio::action]] void declwroff(eosio::name coopname,
eosio::name decided_by,
checksum256 proposal_hash,
std::string reason);
uint64_t item_index);
// ── service ──────────────────────────────────────────────────────────
@@ -1,43 +1,41 @@
# ─────────────────────────────────────────────────────────────────────────────
# Стандарт «Утилизация скоропорта» — кооперативный процесс периодического
# списания имущества со склада КУ, не выданного первичному заказчику и
# подлежащего безвозвратному изъятию из кооператива (просроченный скоропорт,
# малооценочные позиции и т.п.).
# списания со склада участка имущества, которое физически пропало или стало
# непригодным к выдаче заказчику (просрочка, повреждения, малоценные позиции).
#
# Процесс инициируется кроном на стороне Backend (раз в месяц по умолчанию,
# точная дата согласовывается с бухгалтером), формирует проект решения
# совета, совет принимает или отклоняет проект, при принятии срабатывает
# контрактное действие списания. Списание идёт **через транзит счёта 91
# «Прочие доходы и расходы»** — это фиксирует, что имущество безвозвратно
# исчезло из кооператива (а не передано пайщику).
# Процесс инициируется кооперативом по расписанию (по умолчанию раз в месяц;
# точную дату согласовывает бухгалтер). По итогам опроса складов председатель
# подписывает заявление о списании и вносит его на рассмотрение совета.
# Совет рассматривает заявление через типовой процесс решения совета
# (стандарт sov.decision) и подписывает протокол. По принятому протоколу
# кооператив списывает каждую позицию проекта со склада участка.
#
# **Модель кошельков (упрощённая, refinement 2026-05-04):** процесс не
# двигает кошельки — это чисто бухгалтерское событие через проводки
# счёта 10 (учёт имущества), 91 (транзит) и 86 (ЦФ программы).
# Имущество отслеживается аналитикой по счёту 10 (per-КУ субсчета),
# без отдельного кошелька.
# Списание идёт **через транзит счёта 91 «Прочие доходы и расходы»** — это
# фиксирует, что имущество безвозвратно исчезло из кооператива (а не передано
# пайщику). По кошелькам заказчиков движений не происходит — это чисто
# кооперативный учётный расход.
#
# Идентификация кошельков (для будущих расширений процесса) — eosio::name
# с префиксом w.<contract>.<waltype> (рефакторинг 2026-04-27 на ветке
# reports). Sentinel '' (пустая строка) — «кошелёк вне системы» для
# операций ACCOUNT_ONLY.
# **Модель кошельков (упрощённая, refinement 2026-05-04):** процесс не двигает
# кошельки — это бухгалтерское событие через проводки счёта 10 (учёт
# имущества), 91 (транзит) и 86 (ЦФ программы). Имущество отслеживается
# аналитикой по счёту 10 (per-КУ субсчета), без отдельного кошелька.
#
# В процессе участвуют 2 ledger2-операции (композитная проводка через
# транзит счёта 91 разбита на пару, срабатывают в одной транзакции execwroff):
# • o.mkt.wroff — часть 1: NONE Дт 91 / Кт 10 (выбытие со склада на «прочие»)
# • o.mkt.wroff2 — часть 2: NONE Дт 86 / Кт 91 (закрытие транзита на ЦФ программы)
# В процессе участвуют 2 ledger2-операции (композитная проводка через транзит
# счёта 91, срабатывают в одной транзакции списания позиции):
# • o.mkt.wroff — часть 1: Дт 91 / Кт 10 (выбытие со склада на «прочие»)
# • o.mkt.wroff2 — часть 2: Дт 86 / Кт 91 (закрытие транзита на ЦФ программы)
#
# Канон формата:
# coopenomics-docs/docs/standards/_spec/canon.md
# Источники правды в коде:
# • cpp/marketplace/marketplace.hpp — actions (status: proposed)
# • cpp/marketplace/src/writeoff/ — реализация (предстоит)
# • cpp/marketplace/src/p.mkt.wroff/ — реализация
# • cpp/lib/core/ledger2/operations.hpp — OPERATION_REGISTRY
# расширение o.mkt.wroff
# • cpp/lib/core/ledger2/processes.hpp — processes::marketplace::WRITEOFF
# • cpp/lib/core/ledger2/accounts.hpp — Целевое финансирование (86),
# Материалы (10),
# Прочие доходы и расходы (91 — NEW)
# Прочие доходы и расходы (91)
# ─────────────────────────────────────────────────────────────────────────────
# ── Секция 1. Паспорт ───────────────────────────────────────────────────────
@@ -50,208 +48,243 @@ contract: marketplace
purpose: >
Штатный путь корректно отразить в учёте имущество, которое физически
пропало или стало непригодным к выдаче. Применяется, когда товар на
складе участка просрочился, испортился или иначе не может быть
передан заказчику. Без этого процесса такие позиции висели бы на
складе бесконечно — здесь кооператив честно фиксирует свои потери
через решение совета.
складе участка просрочился, испортился или иначе не может быть передан
заказчику. Без этого процесса такие позиции висели бы на складе
бесконечно — здесь кооператив честно фиксирует свои потери через
решение совета.
Процесс состоит из двух шагов. На первом шаге кооператив по расписанию
(по умолчанию раз в месяц; точную дату согласовывает бухгалтер)
собирает со складов участков позиции, удовлетворяющие критериям
списания, формирует проект решения и выносит его на повестку совета.
На втором шаге совет рассматривает проект и принимает решение:
при положительном решении позиции списываются со складов как
безвозвратные потери; при отрицательном — проект отклоняется или
откладывается, а позиции остаются на складах. По кошелькам заказчиков
движений не происходит — это чисто кооперативный учётный расход.
опрашивает склады участков и собирает позиции, удовлетворяющие
критериям списания. Председатель оформляет проект списания, подписывает
заявление и вносит его на рассмотрение совета. На втором шаге совет
по типовому процессу решения совета рассматривает заявление и
подписывает протокол: при положительном решении каждая позиция
списывается со склада как безвозвратные потери; при отрицательном
проект отклоняется и позиции остаются на складе.
По кошелькам заказчиков движений не происходит — это чисто кооперативный
учётный расход через транзит счёта 91 «Прочие доходы и расходы» с
закрытием на счёт целевого финансирования программы.
roles:
- backend # Backend Marketplace (крон-триггер, формирование проекта)
- chairman # председатель кооператива (вносит проект на повестку совета)
- chairman # председатель кооператива (подписывает заявление, вносит на совет)
- council # совет кооператива (принимает решение по протоколу)
# ── Секция 2. Действия контракта (блокчейн-уровень) ─────────────────────────
# Имена actions ≤12 символов eosio::name.
# Имена actions ≤12 символов eosio::name. Заявление председателя на
# рассмотрение совета вносится по типовому процессу «Решение совета»
# (стандарт sov.decision), поэтому здесь перечислены только специфические
# для списания действия — внесение проекта и исполнение позиций по
# принятому протоколу совета.
actions:
- name: marketplace::propwroff
human: Внести проект списания
actor: backend
human: Внести проект списания на рассмотрение совета
actor: chairman
role: opener
purpose: >
Кооператив по расписанию (по умолчанию раз в месяц) собирает на
складах участков позиции, которые невозможно выдать заказчику
просроченные и не востребованные, повреждённые, малооценные — и
формирует проект решения о списании. Проект выносится на повестку
Председатель подписывает заявление о списании скоропорта — список
позиций, которые невозможно выдать заказчику (просроченные и не
востребованные, повреждённые, малоценные), — и вносит проект на
рассмотрение совета. Дальнейшее движение проекта (повестка совета,
голосование, подписание протокола) — по типовому процессу решения
совета.
- name: marketplace::execwroff
human: Исполнить списание
actor: council
human: Исполнить позицию списания по протоколу совета
actor: chairman
role: closer
purpose: >
Совет принял положительное решение по проекту. Кооператив
списывает позиции со складов участков — имущество уходит из учёта
как безвозвратные потери. По кошелькам заказчиков движений нет.
- name: marketplace::declwroff
human: Отклонить проект списания
actor: council
role: reject
purpose: >
Совет отклонил проект списания либо отложил его рассмотрение.
Позиции остаются на складах участков и попадут в следующий цикл
списания.
После принятия советом положительного решения по каждой позиции
проекта выполняется списание: имущество выбывает со склада участка,
сумма закрывается на счёт целевого финансирования программы через
транзит счёта 91. На каждую позицию — атомарная пара операций
o.mkt.wroff + o.mkt.wroff2 в одной транзакции. Когда все позиции
проекта исполнены, проект считается завершённым.
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
# Сущность: marketplace::writeoff_proposal (таблица writeoff_proposals,
# scope=coopname). Запись агрегирует список позиций, попавших в один цикл
# утилизации, и проходит свой жизненный цикл (draft → executed | rejected).
# Сущность: marketplace::writeoff_proposal (таблица wroffprops, scope=coopname).
# Запись агрегирует список позиций, попавших в один цикл списания, и проходит
# свой жизненный цикл (proposed → authorized → executed | rejected).
entity: marketplace::writeoff_proposal
entity_human: Проект списания скоропорта
entity_source: cpp/marketplace/src/writeoff/
entity_source: cpp/marketplace/src/p.mkt.wroff/
states:
- name: draft
human: Проект сформирован
- name: proposed
human: На рассмотрении совета
description: >
Кооператив сформировал список позиций к списанию и вынес проект
решения на повестку совета. Ждёт рассмотрения советом.
Председатель подписал заявление о списании и внёс проект на
рассмотрение совета. Ожидается решение совета по типовому процессу
решения совета.
kind: normal
- name: authorized
human: Решение совета принято
description: >
Совет принял положительное решение по проекту и подписал протокол.
По каждой позиции проекта последовательно выполняется списание со
склада участка.
kind: normal
- name: executed
human: Списание исполнено
description: >
Совет принял положительное решение, кооператив списал позиции со
складов участков. Имущество ушло из учёта как безвозвратные
потери. По кошелькам заказчиков движений не было.
Кооператив списал все позиции проекта со складов участков.
Имущество ушло из учёта как безвозвратные потери. По кошелькам
заказчиков движений не было.
kind: final
- name: rejected
human: Проект отклонён
human: Совет отклонил проект
description: >
Совет отклонил проект списания либо отложил его рассмотрение.
Позиции остаются на складах участков и попадут в следующий цикл
списания.
Совет отклонил проект списания. Позиции остаются на складах
участков и могут попасть в следующий цикл списания.
kind: final
transitions:
- from: "∅"
to: draft
to: proposed
action: marketplace::propwroff
actor: backend
actor: chairman
guards:
- Наступил регламентный срок очередного цикла списания.
- На складах участков найдены позиции, удовлетворяющие критериям списания (просроченные, не выданные первичному заказчику, повреждённые, малооценные).
- Наступил регламентный срок очередного цикла списания (или ручной запуск председателем).
- Председатель подписал Заявление о списании скоропорта.
- На складах участков найдены позиции, удовлетворяющие критериям списания.
- from: draft
- from: proposed
to: authorized
actor: council
guards:
- Совет рассмотрел проект и принял положительное решение по типовому процессу решения совета.
- Председатель подписал Протокол совета о списании скоропорта.
- from: authorized
to: executed
action: marketplace::execwroff
actor: council
actor: chairman
ledger_code: p.mkt.wroff
operations:
- o.mkt.wroff
- o.mkt.wroff2
guards:
- Совет принял положительное решение по проекту и оформил его протоколом.
- Решение совета по проекту принято и протокол подписан.
- Все позиции проекта последовательно исполнены.
- from: draft
- from: proposed
to: rejected
action: marketplace::declwroff
actor: council
guards:
- Совет принял отрицательное решение по проекту либо не принял решение в установленный регламентом срок.
- Совет рассмотрел проект и отклонил его по типовому процессу решения совета.
# ── Секция 4. Сценарий ──────────────────────────────────────────────────────
scenario:
steps:
- step: 1
title: Формирование проекта списания
actor: backend
title: Формирование и внесение проекта на рассмотрение совета
actor: chairman
action: marketplace::propwroff
description: >
Кооператив по расписанию (по умолчанию раз в месяц, точная дата
согласовывается с бухгалтером) опрашивает склады участков и
собирает позиции, которые невозможно выдать заказчику:
просроченные и не востребованные, повреждённые, малооценные.
Сформированный список становится проектом решения и выносится
на повестку совета.
Кооператив по расписанию (по умолчанию раз в месяц; точную дату
согласовывает бухгалтер) опрашивает склады участков и собирает
позиции, которые невозможно выдать заказчику: просроченные и не
востребованные, повреждённые, малоценные. Председатель оформляет
проект списания со списком позиций, подписывает заявление и
вносит проект на рассмотрение совета.
pre:
- Наступил регламентный срок очередного цикла списания.
- На складах участков найдены позиции, удовлетворяющие критериям списания.
- Председатель подписал Заявление о списании скоропорта.
post:
- Проект списания сформирован.
- Проект вынесен на повестку совета.
- Проект списания сформирован и подписан председателем.
- Проект внесён на рассмотрение совета.
- step: 2
title: Рассмотрение проекта советом
actor: council
action: marketplace::execwroff
action: null
description: >
Совет рассматривает проект — на очном заседании или в форме
заочного голосования — и принимает решение, оформленное
протоколом. При положительном решении кооператив списывает
перечисленные в проекте позиции со складов участков. Имущество
уходит из учёта как безвозвратные потери. По кошелькам
заказчиков движений не происходит.
Совет рассматривает заявление председателя по типовому процессу
решения совета — на очном заседании или в форме заочного
голосования — и принимает решение. При положительном решении
председатель подписывает протокол о списании. При отрицательном —
проект отклоняется.
pre:
- Проект внесён на рассмотрение совета.
- Совет принял положительное решение по проекту и оформил его протоколом.
post:
- Списание исполнено.
- Списанные позиции ушли со складов участков.
- Совет принял решение по проекту и подписал протокол.
- step: 3
title: Исполнение списания
actor: chairman
action: marketplace::execwroff
description: >
По принятому советом протоколу кооператив последовательно
списывает каждую позицию проекта со склада участка. Имущество
безвозвратно выбывает из учёта; сумма закрывается на счёт
целевого финансирования программы через транзит счёта 91. По
кошелькам заказчиков движений нет.
pre:
- Совет принял положительное решение по проекту.
- Протокол совета о списании подписан.
post:
- Все позиции проекта списаны со складов участков.
- Имущество ушло из учёта как безвозвратные потери.
alternatives:
- branch: Совет отклонил проект
at_step: 2
action: marketplace::declwroff
action: null
actor: council
description: >
Совет отклонил проект списания (например, требуется
дополнительная экспертиза) либо отложил его рассмотрение.
Позиции остаются на складах участков и попадут в следующий цикл
списания.
Совет отклонил проект списания например, требуется
дополнительная экспертиза либо отложил рассмотрение. Позиции
остаются на складах участков и могут попасть в следующий цикл
списания. Движений по имуществу и средствам не происходит.
- branch: Альтернатива — переуступка имущества по сниженной цене
at_step: 1
action: null
actor: backend
actor: chairman
description: >
Альтернативный путь для имущества, ещё пригодного к выдаче
(например, накануне истечения срока годности): кооператив сам
Альтернативный путь для имущества, ещё пригодного к выдаче
например, накануне истечения срока годности: кооператив сам
выступает поставщиком в новом предложении по сниженной цене и
ставит имущество на следующий цикл отсечки заявок. В MVP только
помечен; реализуется отдельным процессом в более поздней фазе.
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
# В процессе подписывается протокол решения совета (authorization), который
# является типовым документом совета и обслуживается через процесс
# sov.decision (повестка → голосование → протокол). Этот процесс
# напрямую не оформляет своих документов — только пункт повестки.
# Заявление председателя — собственный документ процесса (вносится советом
# на рассмотрение). Протокол совета — документ типового процесса решения
# совета (по стандарту sov.decision), к которому привязан проект списания.
documents:
- action: marketplace::execwroff
title: Протокол решения совета о списании скоропорта
registry_id: 0
signed_by: [council_members]
stored_in: "(authorization — параметр действия execwroff, обслуживается через sov.decision)"
note: "TODO: использовать существующий шаблон протокола совета (sov.decision-протокол) с прикреплённым перечнем позиций; либо создать специализированный шаблон в cooptypes/cooperative/registry/<id>.MarketplaceWriteoffProtocol при необходимости отдельной формы."
- action: marketplace::propwroff
title: Заявление о списании скоропорта
registry_id: 1106
signed_by: [chairman]
stored_in: writeoff_proposals.statement
note: "Подписывается председателем перед внесением проекта на рассмотрение совета. Содержит список позиций к списанию (участок, наименование, количество, сумма, причина). Источник правды — components/cooptypes/src/cooperative/registry/1106.MarketplaceWriteoffStatement"
- action: null
title: Протокол совета о списании скоропорта
registry_id: 1105
signed_by: [chairman]
stored_in: writeoff_proposals.protocol
note: "Протокол по типовому процессу решения совета (стандарт sov.decision). Подписывается председателем после принятия советом положительного решения. Источник правды — components/cooptypes/src/cooperative/registry/1105.MarketplaceWriteoffProtocol"
# ── Секция 6. Операции (Ledger2) ────────────────────────────────────────────
# Одна ledger2-операция со СОСТАВНОЙ проводкой: транзит через счёт 91.
# В журнале это одна именованная операция; в бухгалтерии — две проводки
# (или одна составная). Реализация: в .cpp execwroff вызывает ledger2::apply
# дважды с одинаковым operation_id (или один раз с composite-record), так
# чтобы транзит через 91 был атомарным и не нарушал инвариантов
# Дт/Кт-баланса по 91. WalletOp ACCOUNT_ONLY — proposed расширение enum'а
# ledger2 для операций без движения по кошелькам.
# Композитная бухгалтерская проводка через транзит счёта 91 «Прочие доходы
# и расходы» — разбита на пару операций, срабатывающих атомарно по каждой
# позиции проекта в одной транзакции исполнения списания.
operations:
- ledger_code: o.mkt.wroff
human_name: Утилизация скоропорта — выбытие со склада
wallet_op: NONE # NONE = только бухпроводка без движения по кошелькам
wallet_op: NONE # только бухпроводка без движения по кошелькам
# L1 — часть 1 композитной проводки: Дт 91 / Кт 10 (выбытие имущества
# со склада на «прочие»). Закрытие транзита выполняется отдельной
# операцией o.mkt.wroff2 (Дт 86 / Кт 91), атомарно с o.mkt.wroff
# в той же транзакции execwroff (контракт вызывает обе последовательно).
debit: 91 # Прочие доходы и расходы (NEW)
credit: 10 # Материалы (NEW в LEDGER2_ACCOUNT_REGISTRY)
# в той же транзакции исполнения списания.
debit: 91 # Прочие доходы и расходы
credit: 10 # Материалы
# L2 — без перевода между кошельками кооператива
wallet_from: null
wallet_to: null
@@ -263,22 +296,22 @@ operations:
amount_ref: writeoff_item.cost
triggered_by: marketplace::execwroff
description: >
Списание имущества со склада КУ — часть 1 композитной бухгалтерской
проводки через транзит счёта 91 «Прочие доходы и расходы». NONE —
только бухпроводка Дт 91 / Кт 10, кошельки не двигаются. Сразу
после этой операции в той же транзакции execwroff контракт вызывает
o.mkt.wroff2 (Дт 86 / Кт 91, NONE) — закрытие транзита на счёт ЦФ
программы. Применяется по каждой позиции из списка writeoff_proposal
может быть несколько последовательных пар в одной транзакции
execwroff. Согласовано с Ангелиной 2026-04-27.
Списание имущества со склада участка — часть 1 композитной
бухгалтерской проводки через транзит счёта 91 «Прочие доходы и
расходы». Только бухпроводка Дт 91 / Кт 10, кошельки не двигаются.
Сразу после этой операции в той же транзакции вызывается o.mkt.wroff2
(Дт 86 / Кт 91) — закрытие транзита на счёт целевого финансирования
программы. Применяется по каждой позиции из списка проекта
несколько последовательных пар в одной транзакции исполнения.
Согласовано с Ангелиной 2026-04-27.
- ledger_code: o.mkt.wroff2
human_name: Утилизация скоропорта — закрытие транзита
wallet_op: NONE
# L1 — часть 2 композитной проводки: Дт 86 / Кт 91 (закрытие «прочих»
# на счёт ЦФ программы).
# на счёт целевого финансирования программы).
debit: 86 # Целевое финансирование
credit: 91 # Прочие доходы и расходы (NEW)
credit: 91 # Прочие доходы и расходы
# L2 / L3 — без движения по кошелькам
wallet_from: null
wallet_to: null
@@ -289,9 +322,9 @@ operations:
amount_ref: writeoff_item.cost
triggered_by: marketplace::execwroff
description: >
Закрытие транзита счёта 91 после выбытия имущества со склада. NONE —
только бухпроводка Дт 86 / Кт 91, кошельки не двигаются. Срабатывает
атомарно с o.mkt.wroff в той же транзакции execwroff. По итогу пары
operations нетто-эффект — Дт 86 / Кт 10 (закрытие ЦФ программы +
выбытие имущества со склада), 91 проходит нулевым транзитом.
Закрытие транзита счёта 91 после выбытия имущества со склада. Только
бухпроводка Дт 86 / Кт 91, кошельки не двигаются. Срабатывает
атомарно с o.mkt.wroff в той же транзакции исполнения списания.
По итогу пары operations нетто-эффект — Дт 86 / Кт 10 (закрытие
целевого финансирования программы + выбытие имущества со склада),
91 проходит нулевым транзитом.
@@ -1,39 +0,0 @@
/**
* @brief Совет отклоняет проект списания скоропорта целиком (Story 8.3, p.mkt.wroff).
*
* Без ledger2-операций. Status: PROPOSED → REJECTED (final).
* reason сохраняется в proposal.reject_reason. Позиции остаются на складах
* участков и попадут в следующий цикл списания. Отклонить можно только
* непочатый proposal — если хотя бы одна позиция уже исполнена через
* execwroff, отклонить целиком запрещено.
*
* Guards:
* - proposal.status == PROPOSED.
* - Ни одна из items не была исполнена (executed == false для всех).
* - reason.size() > 0.
*
* @ingroup public_marketplace_actions
*/
void marketplace::declwroff(eosio::name coopname,
eosio::name decided_by,
checksum256 proposal_hash,
std::string reason) {
require_auth(coopname);
eosio::check(reason.size() > 0 && reason.size() <= 500,
"Укажите причину отклонения проекта списания (от 1 до 500 символов)");
auto p = Marketplace::get_writeoff_proposal_by_hash_or_fail(coopname, proposal_hash);
eosio::check(p.status == WroffStatus::PROPOSED,
"Проект списания не находится на рассмотрении");
for (const auto& it : p.items) {
eosio::check(!it.executed,
"Нельзя отклонить проект — часть позиций уже исполнена");
}
Marketplace::update_writeoff_proposal(coopname, p.id, [&](auto& upd) {
upd.status = WroffStatus::REJECTED;
upd.decided_by = decided_by;
upd.reject_reason = reason;
});
}
@@ -1,44 +1,33 @@
/**
* @brief Совет исполняет одну позицию проекта списания скоропорта (Story 8.3,
* p.mkt.wroff).
* @brief Backend исполняет одну позицию авторизованного советом проекта
* списания скоропорта (Story 8.4, p.mkt.wroff).
*
* Per-item композитная транзакция (атомарно):
* - Ledger2::apply(o.mkt.wroff, item.amount, …, hash=proposal.hash) — Дт 91 / Кт 10.
* - Ledger2::apply(o.mkt.wroff2, item.amount, …, hash=proposal.hash) — Дт 86 / Кт 91.
*
* Backend проходит циклом по неисполненным позициям protocols proposal'а,
* вызывая `execwroff(proposal_hash, item_index, protocol)` per item — это
* снимает ограничение на максимальный размер протокола (тысячи позиций
* не помещаются в одну транзакцию Antelope).
* Вызывается ТОЛЬКО после того, как совет авторизовал проект (status =
* AUTHORIZED через callback `onmktwoauth`). Backend проходит циклом по
* неисполненным позициям, вызывая `execwroff(proposal_hash, item_index)`
* per item — это снимает ограничение на максимальный размер протокола.
*
* Все операции с одним process_hash = proposal.hash — backend через
* `getProcess(proposal.hash)` соберёт полную трассировку процесса.
*
* Status: PROPOSED → EXECUTED наступает автоматически после исполнения
* Status: AUTHORIZED → EXECUTED наступает автоматически после исполнения
* последней позиции (когда все items[i].executed становятся true).
*
* Guards:
* - proposal.status == PROPOSED.
* - item_index в пределах proposal.items.
* - items[item_index].executed == false (idempotency).
* - Подписант (`signer`) авторизован для КУ-источника списания
* (`items[item_index].braname`) — председатель / trustee / trusted.
* - verify_document_or_fail(protocol, {signer}) — sanity check на подпись;
* реальная сверка с протоколом совета — backend pre-validation через
* soviet::decisions.
* Подписанный советом protocol уже лежит в `proposal.protocol` (положен
* callback'ом `onmktwoauth`), отдельно его передавать не нужно.
*
* @ingroup public_marketplace_actions
*/
void marketplace::execwroff(eosio::name coopname,
eosio::name signer,
checksum256 proposal_hash,
uint64_t item_index,
document2 protocol) {
uint64_t item_index) {
require_auth(coopname);
auto p = Marketplace::get_writeoff_proposal_by_hash_or_fail(coopname, proposal_hash);
eosio::check(p.status == WroffStatus::PROPOSED,
"Проект списания не находится на исполнении");
eosio::check(p.status == WroffStatus::AUTHORIZED,
"Проект списания не авторизован советом");
eosio::check(item_index < p.items.size(),
"Указана несуществующая позиция в проекте списания");
eosio::check(!p.items[item_index].executed,
@@ -50,8 +39,6 @@ void marketplace::execwroff(eosio::name coopname,
eosio::check(branch.is_user_authorized(signer),
"Подписант не уполномочен исполнять списание данного кооперативного участка");
verify_document_or_fail(protocol, { signer });
// Композитная пара wroff + wroff2 для одной позиции
Ledger2::apply(_marketplace, coopname,
operations::marketplace::WRITE_OFF_PERISHABLE,
@@ -65,7 +52,6 @@ void marketplace::execwroff(eosio::name coopname,
// Помечаем позицию исполненной + финализируем proposal если все позиции готовы
Marketplace::update_writeoff_proposal(coopname, p.id, [&](auto& upd) {
upd.items[item_index].executed = true;
upd.protocol = protocol;
upd.decided_by = signer;
bool all_done = true;
@@ -0,0 +1,34 @@
/**
* @brief Callback от `soviet::exec` после авторизации Протокола совета о
* списании скоропорта (registry 1105) председателем (Story 8.4, p.mkt.wroff).
*
* Соглашение о сигнатуре — `(coopname, hash, authorization)` — задано в
* `soviet::createagenda::authorize_action_effect`. Контракт `soviet` зовёт
* `marketplace::onmktwoauth` от своего имени, поэтому единственно допустимая
* авторизация — `_soviet`.
*
* Эффект:
* - Находит wroffprops по proposal_hash == `hash`.
* - Проверяет, что текущий статус == PROPOSED (запрет повторного callback'а).
* - Записывает подписанный советом protocol2 в `proposal.protocol`.
* - Переводит статус PROPOSED → AUTHORIZED.
*
* Дальнейший шаг — backend через дельту/мониторинг видит AUTHORIZED и
* проходит циклом execwroff per-item (см. `execwroff.cpp`).
*
* @ingroup public_marketplace_actions
*/
void marketplace::onmktwoauth(eosio::name coopname,
checksum256 hash,
document2 authorization) {
require_auth(_soviet);
auto p = Marketplace::get_writeoff_proposal_by_hash_or_fail(coopname, hash);
eosio::check(p.status == WroffStatus::PROPOSED,
"Проект списания не находится на повестке (callback повторный или поздний)");
Marketplace::update_writeoff_proposal(coopname, p.id, [&](auto& upd) {
upd.status = WroffStatus::AUTHORIZED;
upd.protocol = authorization;
});
}
@@ -0,0 +1,34 @@
/**
* @brief Callback от `soviet` после отказа в Протоколе совета о списании
* скоропорта или истечения срока повестки (Story 8.4, p.mkt.wroff).
*
* Сигнатура `(coopname, hash, reason)` соответствует
* `DECLINE_CALLBACK_SIGNATURE` (см. `cpp/lib/core/soviet/soviet.hpp:19`).
* Контракт `soviet` вызывает action из `cancelexprd` (повестка просрочена)
* или вручную через `decline*` actions от своего имени, поэтому единственно
* допустимая авторизация — `_soviet`.
*
* Эффект:
* - Находит wroffprops по proposal_hash == `hash`.
* - Проверяет, что текущий статус == PROPOSED.
* - Записывает `reason` в `proposal.reject_reason`.
* - Переводит статус PROPOSED → REJECTED.
*
* Без ledger2-движений.
*
* @ingroup public_marketplace_actions
*/
void marketplace::onmktwodecl(eosio::name coopname,
checksum256 hash,
std::string reason) {
require_auth(_soviet);
auto p = Marketplace::get_writeoff_proposal_by_hash_or_fail(coopname, hash);
eosio::check(p.status == WroffStatus::PROPOSED,
"Проект списания не находится на повестке (callback повторный или поздний)");
Marketplace::update_writeoff_proposal(coopname, p.id, [&](auto& upd) {
upd.status = WroffStatus::REJECTED;
upd.reject_reason = reason;
});
}
@@ -1,12 +1,15 @@
/**
* @brief Backend / админ выносит проект списания скоропорта на повестку
* совета (Story 8.1, p.mkt.wroff).
* @brief Backend выносит проект списания скоропорта на повестку совета
* (Story 8.1, p.mkt.wroff).
*
* Без ledger2-операций. Создаётся writeoff_proposal в статусе proposed
* (= "draft" в YAML); total_amount = Σ items.amount; все items создаются
* с executed=false. Списание выполняется отдельно — `execwroff(proposal_hash,
* item_index, protocol)` исполняет одну позицию за вызов; backend проходит
* циклом по items, что снимает ограничение на размер протокола.
* Без ledger2-операций. Создаётся writeoff_proposal в статусе proposed;
* total_amount = Σ items.amount; все items создаются с executed=false.
* Сразу после этого action'а backend в той же транзакции вызывает
* `soviet::createagenda(type=mktwroff, callback_contract=_marketplace,
* confirm_callback=onmktwoauth, decline_callback=onmktwodecl, hash=
* proposal_hash, statement=signed_writeoff_statement)`. Списание
* выполняется per-item через `execwroff` только после callback'а
* `onmktwoauth` (status proposed → authorized).
*
* Guards:
* - actor backend (auth coopname).
@@ -16,6 +16,7 @@ import { marketplaceBootstrapV2Migration } from '~/extensions/marketplace/migrat
import { marketplaceBootstrapV3Migration } from '~/extensions/marketplace/migrations/marketplace-bootstrap-v3.migration';
import { marketplaceBootstrapV4Migration } from '~/extensions/marketplace/migrations/marketplace-bootstrap-v4.migration';
import { marketplaceBootstrapV5Migration } from '~/extensions/marketplace/migrations/marketplace-bootstrap-v5.migration';
import { marketplaceBootstrapV6Migration } from '~/extensions/marketplace/migrations/marketplace-bootstrap-v6.migration';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { ExtensionsModule } from '~/extensions/extensions.module';
@@ -64,6 +65,7 @@ export class ExtensionDomainModule {
this.migrationService.registerMigration(marketplaceBootstrapV3Migration);
this.migrationService.registerMigration(marketplaceBootstrapV4Migration);
this.migrationService.registerMigration(marketplaceBootstrapV5Migration);
this.migrationService.registerMigration(marketplaceBootstrapV6Migration);
// Устанавливаем расширения по умолчанию
await this.extensionInteractor.installDefaultApps();
@@ -75,16 +75,23 @@ export const marketplaceAccessMatrix: Record<MarketplaceRole, Record<string, str
Vitrine: ['manage', 'read'],
Warehouse: ['read:all'],
Extension: ['configure'],
// Эпик 8: общий администратор формирует и редактирует DRAFT-проект
// списания, подписывает Заявление 1106 и отправляет проект в совет.
Writeoff: ['manage_draft', 'propose', 'read:all'],
},
board_readonly: {
Warehouse: ['read:all'],
Order: ['read:all'],
Offer: ['read:all'],
Agenda: ['read'],
Writeoff: ['read:all'],
},
board: {
Agenda: ['manage'],
Writeoff: ['decide'],
// Эпик 8: совет — авторизация Протокола 1105 идёт через стандартный
// sov.decision flow (votefor / authorize), `decide` остаётся как
// capability-маркер для UI «может ли роль видеть и принимать решение».
Writeoff: ['decide', 'read:all'],
Decision: ['create', 'sign'],
},
};
@@ -0,0 +1,166 @@
import { Field, InputType, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
import { createPaginationResult } from '~/application/common/dto/pagination.dto';
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
import {
MarketplaceWriteoffProposalStatuses,
MarketplaceWriteoffProposalTriggers,
} from '../../domain/entities/marketplace-writeoff-proposal.types';
export enum MarketplaceWriteoffProposalStatusEnum {
DRAFT = 'DRAFT',
ON_AGENDA = 'ON_AGENDA',
AUTHORIZED = 'AUTHORIZED',
EXECUTING = 'EXECUTING',
EXECUTED = 'EXECUTED',
REJECTED = 'REJECTED',
}
registerEnumType(MarketplaceWriteoffProposalStatusEnum, {
name: 'MarketplaceWriteoffProposalStatus',
description:
'Состояние проекта решения совета о списании скоропорта на пути от черновика до итогового списания.',
});
export enum MarketplaceWriteoffProposalTriggerEnum {
CRON = 'cron',
MANUAL = 'manual',
}
registerEnumType(MarketplaceWriteoffProposalTriggerEnum, {
name: 'MarketplaceWriteoffProposalTrigger',
description: 'Источник проекта списания: автоматический ежемесячный крон или ручное создание.',
});
@ObjectType('MarketplaceWriteoffProposalItem')
export class MarketplaceWriteoffProposalItemDTO {
@Field({ description: 'Кооперативный участок (склад) — источник позиции к списанию.' })
braname!: string;
@Field({ description: 'Наименование позиции или артикул из карточки имущества.' })
asset_title!: string;
@Field({ description: 'Количество единиц к списанию.' })
quantity!: string;
@Field({ description: 'Сумма списания (4 знака после запятой, валюта кооператива).' })
amount!: string;
@Field({ description: 'Причина списания (срок годности, повреждение и т.п.).' })
reason!: string;
@Field({ nullable: true, description: 'Идентификатор инвентарной позиции, если известна.' })
inventory_id?: string | null;
@Field({ description: 'Признак того, что позиция уже исполнена через execwroff.' })
executed!: boolean;
}
@ObjectType('MarketplaceWriteoffDecisionEntry')
export class MarketplaceWriteoffDecisionEntryDTO {
@Field()
at!: string;
@Field()
actor!: string;
@Field()
action!: string;
}
@ObjectType('MarketplaceWriteoffProposal')
export class MarketplaceWriteoffProposalDTO {
@Field({ description: 'Идентификатор проекта в системе кооператива.' })
id!: string;
@Field()
coopname!: string;
@Field(() => MarketplaceWriteoffProposalTriggerEnum)
trigger!: MarketplaceWriteoffProposalTriggerEnum;
@Field(() => MarketplaceWriteoffProposalStatusEnum)
status!: MarketplaceWriteoffProposalStatusEnum;
@Field()
cycle_started_at!: string;
@Field({
description:
'Канонический хеш проекта, используется как process_hash on-chain (wroffprops, soviet.decisions).',
})
proposal_hash!: string;
@Field(() => Int, { nullable: true })
decision_id?: number | null;
@Field({ nullable: true, description: 'Аккаунт инициатора проекта (председатель / админ).' })
proposed_by_account?: string | null;
@Field({ nullable: true, description: 'Аккаунт, принявший финальное решение.' })
decided_by_account?: string | null;
@Field(() => [MarketplaceWriteoffProposalItemDTO])
items!: MarketplaceWriteoffProposalItemDTO[];
@Field({ description: 'Σ сумм всех позиций (форматированный asset, 4 знака).' })
total_amount!: string;
@Field({ nullable: true, description: 'Причина отказа совета (если REJECTED).' })
reject_reason?: string | null;
@Field(() => [MarketplaceWriteoffDecisionEntryDTO])
decision_log!: MarketplaceWriteoffDecisionEntryDTO[];
@Field({ nullable: true })
submitted_at?: string | null;
@Field({ nullable: true })
authorized_at?: string | null;
@Field({ nullable: true })
executed_at?: string | null;
@Field({ nullable: true })
rejected_at?: string | null;
@Field()
created_at!: string;
@Field()
updated_at!: string;
}
@ObjectType('PaginatedMarketplaceWriteoffProposals')
export class PaginatedMarketplaceWriteoffProposalsDTO extends createPaginationResult(
MarketplaceWriteoffProposalDTO,
'PaginatedMarketplaceWriteoffProposals'
) {}
@InputType('MarketplaceWriteoffItemInput')
export class MarketplaceWriteoffItemInputDTO {
@Field()
braname!: string;
@Field()
asset_title!: string;
@Field()
quantity!: string;
@Field({ description: 'Сумма списания в формате числа с 4 знаками после запятой.' })
amount!: string;
@Field()
reason!: string;
@Field({ nullable: true })
inventory_id?: string;
}
@InputType('MarketplaceCreateWriteoffDraftInput')
export class MarketplaceCreateWriteoffDraftInputDTO {
@Field(() => [MarketplaceWriteoffItemInputDTO])
items!: MarketplaceWriteoffItemInputDTO[];
@Field({ nullable: true, description: 'Начало расчётного цикла, если отличается от текущего момента.' })
cycle_started_at?: string;
}
@InputType('MarketplaceUpdateWriteoffDraftInput')
export class MarketplaceUpdateWriteoffDraftInputDTO {
@Field()
id!: string;
@Field(() => [MarketplaceWriteoffItemInputDTO])
items!: MarketplaceWriteoffItemInputDTO[];
}
@InputType('MarketplaceWriteoffStatementSignablePayloadInput')
export class MarketplaceWriteoffStatementSignablePayloadInputDTO {
@Field()
draft_id!: string;
}
@InputType('MarketplaceSubmitWriteoffDraftInput')
export class MarketplaceSubmitWriteoffDraftInputDTO {
@Field()
draft_id!: string;
@Field(() => SignedDigitalDocumentInputDTO, {
description: 'Подписанное председателем Заявление о списании скоропорта (registry_id=1106).',
})
signed_statement!: SignedDigitalDocumentInputDTO;
}
@InputType('MarketplaceListWriteoffProposalsInput')
export class MarketplaceListWriteoffProposalsInputDTO {
@Field(() => [MarketplaceWriteoffProposalStatusEnum], { nullable: true })
statuses?: MarketplaceWriteoffProposalStatusEnum[];
}
export const MarketplaceWriteoffProposalStatusMap = MarketplaceWriteoffProposalStatuses;
export const MarketplaceWriteoffProposalTriggerMap = MarketplaceWriteoffProposalTriggers;
@@ -125,3 +125,75 @@ export interface MarketplaceReturnClaimFinalizedEvent {
tx_hash: string;
} | null;
}
// ── Эпик 8: списание скоропорта ─────────────────────────────────────
/**
* Cron / админ сформировал DRAFT-проект списания — председатель приглашён
* проверить корзину и подписать Заявление о списании (1106).
*/
export const MARKETPLACE_WRITEOFF_DRAFT_BUILT_EVENT = 'marketplace.writeoff.chairman.draftBuilt';
/**
* Председатель подписал Заявление и отправил проект в совет —
* `propwroff` + `soviet::createagenda(mktwroff)` отработали. Совет
* получает приглашение проголосовать.
*/
export const MARKETPLACE_WRITEOFF_PROPOSED_EVENT = 'marketplace.writeoff.council.proposed';
/**
* Совет авторизовал Протокол списания (1105) — председатель и общий
* админ узнают, что backend запускает списание per-item.
*/
export const MARKETPLACE_WRITEOFF_AUTHORIZED_EVENT = 'marketplace.writeoff.admin.authorized';
/**
* Все позиции проекта успешно списаны через atomic execwroff per-item —
* проект финализирован.
*/
export const MARKETPLACE_WRITEOFF_EXECUTED_EVENT = 'marketplace.writeoff.admin.executed';
/**
* Совет отказал в проекте списания (или срок повестки истёк) — общий
* админ получает причину отказа.
*/
export const MARKETPLACE_WRITEOFF_REJECTED_EVENT = 'marketplace.writeoff.admin.rejected';
export interface MarketplaceWriteoffDraftBuiltPayload {
coopname: string;
proposal_id: string;
trigger: 'cron' | 'manual';
items_count: number;
total_amount: string;
}
export interface MarketplaceWriteoffProposedPayload {
coopname: string;
proposal_id: string;
proposal_hash: string;
items_count: number;
total_amount: string;
}
export interface MarketplaceWriteoffAuthorizedPayload {
coopname: string;
proposal_id: string;
proposal_hash: string;
items_count: number;
total_amount: string;
}
export interface MarketplaceWriteoffExecutedPayload {
coopname: string;
proposal_id: string;
proposal_hash: string;
items_count: number;
total_amount: string;
}
export interface MarketplaceWriteoffRejectedPayload {
coopname: string;
proposal_id: string;
proposal_hash: string;
reason: string;
}
@@ -106,6 +106,13 @@ import {
import { MarketplaceReturnClaimImagesService } from './services/marketplace-return-claim-images.service';
import { MarketplaceReturnClaimResolver } from './resolvers/marketplace-return-claim.resolver';
import { FileStorageInfrastructureModule } from '~/infrastructure/file-storage';
// Эпик 8 — списание скоропорта через решение совета
import { MarketplaceWriteoffService } from './services/marketplace-writeoff.service';
import { MarketplaceWriteoffCronService } from './services/marketplace-writeoff-cron.service';
import { MarketplaceWriteoffResolver } from './resolvers/marketplace-writeoff.resolver';
import { ExtensionDomainModule } from '~/domain/extension/extension-domain.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MarketplaceInventoryEntity } from '../infrastructure/entities/marketplace-inventory.entity';
/**
* Модуль приложения marketplace
@@ -131,6 +138,10 @@ import { FileStorageInfrastructureModule } from '~/infrastructure/file-storage';
// на MarketplaceReturnClaimImagesService — модуль `forFeature` читает
// метадату и провайдит ему `InterFileStorageBucket`.
FileStorageInfrastructureModule.forFeature([MarketplaceReturnClaimImagesService]),
// Эпик 8: writeoff cron сканер должен видеть marketplace_inventory
TypeOrmModule.forFeature([MarketplaceInventoryEntity], 'marketplace'),
// Доступ к ExtensionDomainService для чтения writeoff-конфига расширения
ExtensionDomainModule,
],
providers: [
// GraphQL резолверы
@@ -282,6 +293,10 @@ import { FileStorageInfrastructureModule } from '~/infrastructure/file-storage';
},
MarketplaceReturnClaimService,
MarketplaceReturnClaimImagesService,
// Эпик 8 — списание скоропорта
MarketplaceWriteoffService,
MarketplaceWriteoffCronService,
MarketplaceWriteoffResolver,
],
exports: [
// Экспортируем сервисы для использования в других модулях
@@ -362,6 +377,9 @@ import { FileStorageInfrastructureModule } from '~/infrastructure/file-storage';
// Эпик 7
MARKETPLACE_RETURN_CLAIM_SERVICE,
MarketplaceReturnClaimService,
// Эпик 8
MarketplaceWriteoffService,
MarketplaceWriteoffResolver,
],
})
export class MarketplaceExtensionApplicationModule {}
@@ -0,0 +1,55 @@
import type { MarketplaceWriteoffProposalDomainEntity } from '../../domain/entities/marketplace-writeoff-proposal.entity';
import type { MarketplaceWriteoffProposalDecisionEntry } from '../../domain/entities/marketplace-writeoff-proposal.types';
import {
MarketplaceWriteoffDecisionEntryDTO,
MarketplaceWriteoffProposalDTO,
MarketplaceWriteoffProposalItemDTO,
MarketplaceWriteoffProposalStatusEnum,
MarketplaceWriteoffProposalTriggerEnum,
} from '../dto/marketplace-writeoff.dto';
export function toMarketplaceWriteoffProposalDTO(
entity: MarketplaceWriteoffProposalDomainEntity
): MarketplaceWriteoffProposalDTO {
const dto = new MarketplaceWriteoffProposalDTO();
dto.id = entity.id;
dto.coopname = entity.coopname;
dto.trigger = entity.trigger as unknown as MarketplaceWriteoffProposalTriggerEnum;
dto.status = entity.status as unknown as MarketplaceWriteoffProposalStatusEnum;
dto.cycle_started_at = entity.cycle_started_at.toISOString();
dto.proposal_hash = entity.proposal_hash;
dto.decision_id = entity.decision_id;
dto.proposed_by_account = entity.proposed_by_account;
dto.decided_by_account = entity.decided_by_account;
dto.items = (entity.items ?? []).map((it) => {
const d = new MarketplaceWriteoffProposalItemDTO();
d.braname = it.braname;
d.asset_title = it.asset_title;
d.quantity = it.quantity;
d.amount = it.amount;
d.reason = it.reason;
d.inventory_id = it.inventory_id;
d.executed = it.executed;
return d;
});
dto.total_amount = entity.total_amount;
dto.reject_reason = entity.reject_reason;
dto.decision_log = (entity.decision_log ?? []).map(toMarketplaceWriteoffDecisionEntryDTO);
dto.submitted_at = entity.submitted_at?.toISOString() ?? null;
dto.authorized_at = entity.authorized_at?.toISOString() ?? null;
dto.executed_at = entity.executed_at?.toISOString() ?? null;
dto.rejected_at = entity.rejected_at?.toISOString() ?? null;
dto.created_at = entity.created_at.toISOString();
dto.updated_at = entity.updated_at.toISOString();
return dto;
}
function toMarketplaceWriteoffDecisionEntryDTO(
entry: MarketplaceWriteoffProposalDecisionEntry
): MarketplaceWriteoffDecisionEntryDTO {
const dto = new MarketplaceWriteoffDecisionEntryDTO();
dto.at = entry.at;
dto.actor = entry.actor;
dto.action = entry.action;
return dto;
}
@@ -0,0 +1,229 @@
import { Injectable, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Cooperative } from 'cooptypes';
import config from '~/config/config';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { PaginationInputDTO } from '~/application/common/dto/pagination.dto';
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
import type { DocumentDomainEntity } from '~/domain/document/entity/document-domain.entity';
import { DocumentDomainService } from '~/domain/document/services/document-domain.service';
import { CurrentMarketplaceMember } from '../decorators/current-marketplace-member.decorator';
import { RequireMarketplaceAccess } from '../decorators/marketplace-access.decorator';
import { MarketplaceMembershipGuard } from '../guards/marketplace-membership.guard';
import { MarketplaceRoleGuard } from '../guards/marketplace-role.guard';
import type { IMarketplaceCurrentMember } from '../dto/marketplace-current-member.dto';
import {
MarketplaceCreateWriteoffDraftInputDTO,
MarketplaceListWriteoffProposalsInputDTO,
MarketplaceSubmitWriteoffDraftInputDTO,
MarketplaceUpdateWriteoffDraftInputDTO,
MarketplaceWriteoffProposalDTO,
MarketplaceWriteoffProposalStatusEnum,
MarketplaceWriteoffStatementSignablePayloadInputDTO,
PaginatedMarketplaceWriteoffProposalsDTO,
} from '../dto/marketplace-writeoff.dto';
import { MarketplaceWriteoffService } from '../services/marketplace-writeoff.service';
import { toMarketplaceWriteoffProposalDTO } from './marketplace-writeoff.mapper';
function toGeneratedDocumentDTO(e: DocumentDomainEntity): GeneratedDocumentDTO {
const dto = new GeneratedDocumentDTO();
dto.full_title = e.full_title;
dto.html = e.html;
dto.hash = e.hash;
dto.meta = e.meta;
dto.binary = e.binary;
return dto;
}
/**
* Эпик 8: GraphQL-фасад для процесса списания скоропорта через решение
* совета.
*
* Пайплайн пользователя (общий администратор / председатель):
* 1. `marketplaceCreateWriteoffDraft` — собрать корзину (или подобрать
* автоматически выставленную крон-сервисом).
* 2. `marketplaceUpdateWriteoffDraft` — поправить состав.
* 3. `marketplaceWriteoffStatementSignablePayload` — получить Заявление
* 1106 для подписания председателем.
* 4. `marketplaceSubmitWriteoffDraft` — отправить подписанное Заявление,
* запускается `propwroff` + `soviet::createagenda(mktwroff)`.
*
* Дальше пайплайн ведёт стандартный sov.decision-flow: совет голосует и
* подписывает Протокол 1105 — callback `marketplace::onmktwoauth` через
* листенер дельты ставит проект в AUTHORIZED, и backend сам циклом
* проводит per-item списания. UI здесь только наблюдает.
*/
@Resolver()
@Injectable()
export class MarketplaceWriteoffResolver {
constructor(
private readonly service: MarketplaceWriteoffService,
private readonly documentDomainService: DocumentDomainService
) {}
@Query(() => MarketplaceWriteoffProposalDTO, {
nullable: true,
name: 'marketplaceOpenWriteoffDraft',
description: 'Открытый черновик проекта списания (если есть). Один на кооператив.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Writeoff', 'read:all')
async marketplaceOpenWriteoffDraft(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember
): Promise<MarketplaceWriteoffProposalDTO | null> {
const draft = await this.service.getOpenDraft(config.coopname);
return draft ? toMarketplaceWriteoffProposalDTO(draft) : null;
}
@Query(() => PaginatedMarketplaceWriteoffProposalsDTO, {
name: 'marketplaceListWriteoffProposals',
description: 'Лента всех проектов списания кооператива с фильтром по статусу.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Writeoff', 'read:all')
async marketplaceListWriteoffProposals(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceListWriteoffProposalsInputDTO,
@Args('options', { nullable: true }) options?: PaginationInputDTO
): Promise<PaginatedMarketplaceWriteoffProposalsDTO> {
const result = await this.service.listProposals({
coopname: config.coopname,
statuses: data.statuses as unknown as MarketplaceWriteoffProposalStatusEnum[] | undefined,
pagination: options,
});
const page = options?.page ?? 1;
const limit = options?.limit ?? 50;
return {
items: result.items.map(toMarketplaceWriteoffProposalDTO),
totalCount: result.total,
totalPages: Math.max(1, Math.ceil(result.total / limit)),
currentPage: page,
} as PaginatedMarketplaceWriteoffProposalsDTO;
}
@Query(() => MarketplaceWriteoffProposalDTO, {
name: 'marketplaceWriteoffProposal',
description: 'Детали одного проекта списания: items, decision_log, протокол.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Writeoff', 'read:all')
async marketplaceWriteoffProposal(
@Args('id') id: string
): Promise<MarketplaceWriteoffProposalDTO> {
return toMarketplaceWriteoffProposalDTO(await this.service.getProposal(id));
}
@Mutation(() => MarketplaceWriteoffProposalDTO, {
name: 'marketplaceCreateWriteoffDraft',
description:
'Создаёт ручной черновик проекта списания. На кооператив может быть только один открытый черновик и один проект, отправленный в совет.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Writeoff', 'manage_draft')
async marketplaceCreateWriteoffDraft(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceCreateWriteoffDraftInputDTO
): Promise<MarketplaceWriteoffProposalDTO> {
const draft = await this.service.createDraft({
coopname: config.coopname,
trigger: 'manual',
proposed_by_account: member.username,
cycle_started_at: data.cycle_started_at ? new Date(data.cycle_started_at) : undefined,
items: data.items,
});
return toMarketplaceWriteoffProposalDTO(draft);
}
@Mutation(() => MarketplaceWriteoffProposalDTO, {
name: 'marketplaceUpdateWriteoffDraft',
description: 'Изменить состав черновика — добавить, удалить или поправить позиции.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Writeoff', 'manage_draft')
async marketplaceUpdateWriteoffDraft(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceUpdateWriteoffDraftInputDTO
): Promise<MarketplaceWriteoffProposalDTO> {
const updated = await this.service.updateDraft({
id: data.id,
actor: member.username,
items: data.items,
});
return toMarketplaceWriteoffProposalDTO(updated);
}
@Mutation(() => Boolean, {
name: 'marketplaceCancelWriteoffDraft',
description: 'Удалить черновик. Доступно только пока проект в статусе DRAFT.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Writeoff', 'manage_draft')
async marketplaceCancelWriteoffDraft(
@Args('id') id: string
): Promise<boolean> {
await this.service.cancelDraft(id);
return true;
}
@Query(() => GeneratedDocumentDTO, {
name: 'marketplaceWriteoffStatementSignablePayload',
description:
'Превью Заявления о списании скоропорта (registry 1106) для подписания председателем.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Writeoff', 'propose')
async marketplaceWriteoffStatementSignablePayload(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceWriteoffStatementSignablePayloadInputDTO
): Promise<GeneratedDocumentDTO> {
const draft = await this.service.getProposal(data.draft_id);
if (!draft.is_draft) {
throw new Error('Подписать Заявление можно только для черновика (DRAFT)');
}
const proposalHash = this.service.computeProposalHash({
coopname: draft.coopname,
cycle_started_at: draft.cycle_started_at.toISOString(),
draft_id: draft.id,
items: draft.items,
});
const action: Cooperative.Registry.MarketplaceWriteoffStatement.Action = {
registry_id: Cooperative.Registry.MarketplaceWriteoffStatement.registry_id,
coopname: draft.coopname,
username: member.username,
lang: 'ru',
proposal_hash: proposalHash,
cycle_started_at: draft.cycle_started_at.toISOString(),
items: draft.items.map((it) => ({
braname: it.braname,
asset_title: it.asset_title,
quantity: it.quantity,
amount: it.amount,
reason: it.reason,
})),
total_amount: draft.total_amount,
};
const document = await this.documentDomainService.generateDocument({
data: action,
});
return toGeneratedDocumentDTO(document);
}
@Mutation(() => MarketplaceWriteoffProposalDTO, {
name: 'marketplaceSubmitWriteoffDraft',
description:
'Отправить черновик в совет. Принимает подписанное председателем Заявление 1106. После успешного приёма выполняются propwroff и soviet::createagenda(mktwroff).',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Writeoff', 'propose')
async marketplaceSubmitWriteoffDraft(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceSubmitWriteoffDraftInputDTO
): Promise<MarketplaceWriteoffProposalDTO> {
const submitted = await this.service.submitToCouncil({
id: data.draft_id,
chairman_account: member.username,
signed_statement: data.signed_statement,
});
return toMarketplaceWriteoffProposalDTO(submitted);
}
}
@@ -166,6 +166,12 @@ export class MarketplaceInventoryLabelService {
const inventory: MarketplaceInventoryDomainEntity[] = [];
const labeledAt = new Date();
// Story 8.3 (Эпик 8): срок годности позиции. Берётся из Offer.warranty_days
// (если задан) — это становится опорой для крон-сканера, который раз в
// месяц подбирает кандидатов для проекта списания.
const warrantyDays = offer.warranty_days ?? 0;
const expiryDate: Date | null =
warrantyDays > 0 ? new Date(labeledAt.getTime() + warrantyDays * 86_400_000) : null;
for (let i = 0; i < labelsPlan.length; i++) {
const quantity_per_label = labelsPlan[i];
const barcode_value = await this.generateUniqueBarcode(
@@ -187,6 +193,7 @@ export class MarketplaceInventoryLabelService {
orderer_account_snapshot: order.orderer_account,
labeled_at: labeledAt,
labeled_by_operator_account: input.operator_account,
expiry_date: expiryDate,
});
inventory.push(row);
}
@@ -0,0 +1,142 @@
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectRepository } from '@nestjs/typeorm';
import { LessThanOrEqual, Repository } from 'typeorm';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import config from '~/config/config';
import { ExtensionDomainService } from '~/domain/extension/services/extension-domain.service';
import { MarketplaceInventoryEntity } from '../../infrastructure/entities/marketplace-inventory.entity';
import { MarketplaceWriteoffProposalTriggers } from '../../domain/entities/marketplace-writeoff-proposal.types';
import { MarketplaceWriteoffService } from './marketplace-writeoff.service';
import {
MARKETPLACE_ASSET_CONFIG,
type MarketplaceAssetConfig,
} from './marketplace-asset.config';
import type { IConfig } from '../../types';
import { MARKETPLACE_WRITEOFF_DRAFT_BUILT_EVENT } from '../events/marketplace-notification.events';
/**
* Story 8.3 (Эпик 8): крон-сканер скоропорта.
*
* Раз в месяц проходит по `marketplace_inventory.expiry_date`. Если в
* настройках расширения `writeoff.auto_proposal_enabled = true` —
* формирует DRAFT-проект списания со всеми позициями, попавшими в окно
* `expiry_date <= now + expiry_grace_days`. Иначе — только эмитит
* напоминание председателю, чтобы тот собрал корзину вручную.
*/
@Injectable()
export class MarketplaceWriteoffCronService implements OnModuleInit {
constructor(
@InjectRepository(MarketplaceInventoryEntity, 'marketplace')
private readonly inventoryRepo: Repository<MarketplaceInventoryEntity>,
private readonly writeoffService: MarketplaceWriteoffService,
private readonly extensionDomainService: ExtensionDomainService,
@Inject(MARKETPLACE_ASSET_CONFIG)
private readonly assetConfig: MarketplaceAssetConfig,
private readonly eventBus: EventEmitter2,
private readonly logger: WinstonLoggerService
) {
this.logger.setContext(MarketplaceWriteoffCronService.name);
}
onModuleInit(): void {
this.logger.info(
`[WRITEOFF_CRON] планировщик ежемесячных списаний активирован для coopname=${config.coopname}`
);
}
/**
* Раз в месяц — на первое число в полдень UTC. CronExpression выбран
* из стандартных значений `@nestjs/schedule`, точную дату согласовывает
* бухгалтер через настройки кооператива (Phase 2).
*/
@Cron(CronExpression.EVERY_1ST_DAY_OF_MONTH_AT_NOON)
async triggerMonthlyCycle(): Promise<void> {
const coopname = config.coopname;
if (!coopname) return;
const existingDraft = await this.writeoffService.getOpenDraft(coopname);
if (existingDraft) {
this.logger.info(
`[WRITEOFF_CRON] DRAFT id=${existingDraft.id} уже существует — пропускаю цикл`
);
return;
}
const extension = await this.extensionDomainService.getAppByName('market');
const cfg = extension?.config as IConfig | undefined;
const auto = cfg?.writeoff?.auto_proposal_enabled ?? false;
const graceDays = cfg?.writeoff?.expiry_grace_days ?? 7;
if (!auto) {
this.logger.info(
`[WRITEOFF_CRON] auto-proposal=OFF — председателю отправлено только напоминание (coopname=${coopname})`
);
this.eventBus.emit('marketplace.writeoff.cron.reminder', {
coopname,
fired_at: new Date().toISOString(),
});
return;
}
const horizon = new Date(Date.now() + graceDays * 86_400_000);
const candidates = await this.inventoryRepo.find({
where: {
coopname,
status: 'LABELED',
expiry_date: LessThanOrEqual(horizon),
},
take: 200,
order: { expiry_date: 'ASC' },
});
if (candidates.length === 0) {
this.logger.info(
`[WRITEOFF_CRON] не найдено позиций для списания в окне +${graceDays} дней (coopname=${coopname})`
);
return;
}
const items: Parameters<typeof this.writeoffService.createDraft>[0]['items'] = candidates.map(
(inv) => ({
braname: inv.braname,
asset_title: inv.product_name_snapshot,
quantity: String(inv.quantity_per_label),
// Stub: до Phase 2 нет attached unit cost — крон ставит 0 placeholder,
// председатель руками поправит при ревью drafts'а. Это даёт UX —
// позиции уже видны, остаётся только проставить суммы.
amount: (0).toFixed(this.assetConfig.decimals),
reason: this.deriveReason(inv.expiry_date, horizon),
inventory_id: inv.id,
})
);
const created = await this.writeoffService.createDraft({
coopname,
trigger: MarketplaceWriteoffProposalTriggers.CRON,
proposed_by_account: null,
items,
});
this.logger.info(
`[WRITEOFF_CRON] сгенерирован DRAFT id=${created.id} c ${items.length} позициями (coopname=${coopname})`
);
this.eventBus.emit(MARKETPLACE_WRITEOFF_DRAFT_BUILT_EVENT, {
coopname,
proposal_id: created.id,
trigger: 'cron',
items_count: items.length,
total_amount: created.total_amount,
});
}
private deriveReason(expiry: Date | null, horizon: Date): string {
if (!expiry) return 'Срок годности не задан';
if (expiry.getTime() <= Date.now()) return 'Истёк срок годности';
if (expiry.getTime() <= horizon.getTime())
return 'Срок годности истекает в ближайшее время';
return 'Скоропорт';
}
}
@@ -0,0 +1,651 @@
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
import { Cooperative } from 'cooptypes';
import { MarketplaceWriteoffService } from './marketplace-writeoff.service';
import type {
MarketplaceWriteoffItemInput,
MarketplaceCreateWriteoffDraftInput,
} from './marketplace-writeoff.service';
import type { MarketplaceWriteoffProposalDomainRepository } from '../../domain/repositories/marketplace-writeoff-proposal.repository';
import type { MarketplaceInventoryDomainRepository } from '../../domain/repositories/marketplace-inventory.repository';
import type { MarketplaceCanonicalBlockchainPort } from '../../domain/ports/marketplace-canonical-blockchain.port';
import type { MarketplaceAssetConfig } from './marketplace-asset.config';
import { MarketplaceWriteoffProposalDomainEntity } from '../../domain/entities/marketplace-writeoff-proposal.entity';
import {
MarketplaceWriteoffProposalStatuses,
MarketplaceWriteoffProposalTriggers,
} from '../../domain/entities/marketplace-writeoff-proposal.types';
function buildProposal(
overrides: Partial<MarketplaceWriteoffProposalDomainEntity> = {}
): MarketplaceWriteoffProposalDomainEntity {
return new MarketplaceWriteoffProposalDomainEntity({
id: 'p-1',
coopname: 'voskhod',
trigger: MarketplaceWriteoffProposalTriggers.MANUAL,
status: MarketplaceWriteoffProposalStatuses.DRAFT,
cycle_started_at: new Date('2026-06-01T00:00:00Z'),
proposal_hash: '',
decision_id: null,
proposed_by_account: 'chairman1',
decided_by_account: null,
items: [
{
braname: 'voskhod1',
asset_title: 'Молоко «Доброе»',
quantity: '5',
amount: '485.0000',
reason: 'Истёк срок годности',
inventory_id: null,
executed: false,
},
],
total_amount: '485.0000 RUB',
protocol_doc: null,
statement_doc: null,
reject_reason: null,
decision_log: [],
submitted_at: null,
authorized_at: null,
executed_at: null,
rejected_at: null,
created_at: new Date('2026-06-01T00:00:00Z'),
updated_at: new Date('2026-06-01T00:00:00Z'),
...(overrides as any),
});
}
function buildItem(over: Partial<MarketplaceWriteoffItemInput> = {}): MarketplaceWriteoffItemInput {
return {
braname: 'voskhod1',
asset_title: 'Молоко «Доброе»',
quantity: '5',
amount: '485.0000',
reason: 'Истёк срок годности',
...over,
};
}
function buildMocks() {
const repo: jest.Mocked<MarketplaceWriteoffProposalDomainRepository> = {
create: jest.fn(),
findById: jest.fn(),
findByHash: jest.fn(),
findOpenDraft: jest.fn(),
findOpenInCouncil: jest.fn(),
list: jest.fn(),
updateDraftItems: jest.fn(),
submitToCouncil: jest.fn(),
markAuthorized: jest.fn(),
markExecuting: jest.fn(),
markItemExecuted: jest.fn(),
markFullyExecuted: jest.fn(),
markRejected: jest.fn(),
cancelDraft: jest.fn(),
} as unknown as jest.Mocked<MarketplaceWriteoffProposalDomainRepository>;
const inventoryRepo: jest.Mocked<MarketplaceInventoryDomainRepository> = {
applyStatusTransition: jest.fn(),
} as unknown as jest.Mocked<MarketplaceInventoryDomainRepository>;
const chainPort: jest.Mocked<MarketplaceCanonicalBlockchainPort> = {
propWroff: jest.fn().mockResolvedValue({ transaction: { id: 'tx-prop' } } as any),
execWroff: jest.fn().mockResolvedValue({ transaction: { id: 'tx-exec' } } as any),
createWriteoffAgenda: jest.fn().mockResolvedValue({ transaction: { id: 'tx-agenda' } } as any),
} as unknown as jest.Mocked<MarketplaceCanonicalBlockchainPort>;
const assetConfig: MarketplaceAssetConfig = { symbol: 'RUB', decimals: 4 };
const documentDomainService = {} as any;
const eventBus = { emit: jest.fn() } as any;
const logger = {
setContext: jest.fn(),
info: jest.fn(),
log: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
} as any;
return { repo, inventoryRepo, chainPort, assetConfig, documentDomainService, eventBus, logger };
}
function buildService(mocks: ReturnType<typeof buildMocks>): MarketplaceWriteoffService {
return new MarketplaceWriteoffService(
mocks.repo,
mocks.inventoryRepo,
mocks.chainPort,
mocks.assetConfig,
mocks.documentDomainService,
mocks.eventBus,
mocks.logger
);
}
function buildSignedStatement(proposalHash: string) {
const doc = {
hash: 'doc-hash',
meta: {
registry_id: Cooperative.Registry.MarketplaceWriteoffStatement.registry_id,
proposal_hash: proposalHash,
},
signatures: [
{
public_key: 'PUB_K1_test',
signature: 'SIG_K1_test',
signed_hash: 'signed-hash',
},
],
toDocument: jest.fn().mockReturnValue({ hash: 'doc-hash' }),
} as any;
return doc;
}
describe('MarketplaceWriteoffService', () => {
let mocks: ReturnType<typeof buildMocks>;
let service: MarketplaceWriteoffService;
beforeEach(() => {
mocks = buildMocks();
service = buildService(mocks);
});
// ── createDraft ────────────────────────────────────────────────────────
describe('createDraft', () => {
const baseInput: MarketplaceCreateWriteoffDraftInput = {
coopname: 'voskhod',
trigger: MarketplaceWriteoffProposalTriggers.MANUAL,
proposed_by_account: 'chairman1',
items: [buildItem()],
};
it('happy path — формирует DRAFT и эмитит draft_built событие', async () => {
mocks.repo.findOpenDraft.mockResolvedValue(null);
mocks.repo.findOpenInCouncil.mockResolvedValue(null);
mocks.repo.create.mockResolvedValue(buildProposal());
const result = await service.createDraft(baseInput);
expect(mocks.repo.create).toHaveBeenCalledWith(
expect.objectContaining({
coopname: 'voskhod',
trigger: MarketplaceWriteoffProposalTriggers.MANUAL,
total_amount: '485.0000 RUB',
items: [expect.objectContaining({ executed: false, amount: '485.0000' })],
})
);
expect(mocks.eventBus.emit).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ coopname: 'voskhod', items_count: 1 })
);
expect(result.id).toBe('p-1');
});
it('ConflictException — уже есть открытый DRAFT', async () => {
mocks.repo.findOpenDraft.mockResolvedValue(buildProposal({ id: 'old-draft' } as any));
await expect(service.createDraft(baseInput)).rejects.toThrow(ConflictException);
expect(mocks.repo.create).not.toHaveBeenCalled();
});
it('ConflictException — есть проект на повестке совета', async () => {
mocks.repo.findOpenDraft.mockResolvedValue(null);
mocks.repo.findOpenInCouncil.mockResolvedValue(
buildProposal({ status: 'ON_AGENDA' } as any)
);
await expect(service.createDraft(baseInput)).rejects.toThrow(ConflictException);
expect(mocks.repo.create).not.toHaveBeenCalled();
});
it('BadRequest — пустой список позиций', async () => {
mocks.repo.findOpenDraft.mockResolvedValue(null);
mocks.repo.findOpenInCouncil.mockResolvedValue(null);
await expect(
service.createDraft({ ...baseInput, items: [] })
).rejects.toThrow(BadRequestException);
});
it('BadRequest — более 200 позиций', async () => {
mocks.repo.findOpenDraft.mockResolvedValue(null);
mocks.repo.findOpenInCouncil.mockResolvedValue(null);
const items = Array.from({ length: 201 }, () => buildItem());
await expect(service.createDraft({ ...baseInput, items })).rejects.toThrow(
BadRequestException
);
});
it('BadRequest — некорректная сумма позиции', async () => {
mocks.repo.findOpenDraft.mockResolvedValue(null);
mocks.repo.findOpenInCouncil.mockResolvedValue(null);
await expect(
service.createDraft({ ...baseInput, items: [buildItem({ amount: '-5' })] })
).rejects.toThrow(BadRequestException);
});
});
// ── computeProposalHash ────────────────────────────────────────────────
describe('computeProposalHash', () => {
it('детерминистичен — одинаковые items дают одинаковый hash', () => {
const itemsA = [
{
braname: 'voskhod1',
asset_title: 'A',
quantity: '1',
amount: '10.0000',
reason: 'r',
inventory_id: null,
executed: false,
},
{
braname: 'voskhod2',
asset_title: 'B',
quantity: '2',
amount: '20.0000',
reason: 'r',
inventory_id: null,
executed: false,
},
];
const itemsB = [...itemsA].reverse();
const h1 = service.computeProposalHash({
coopname: 'voskhod',
cycle_started_at: '2026-06-01',
draft_id: 'p-1',
items: itemsA,
});
const h2 = service.computeProposalHash({
coopname: 'voskhod',
cycle_started_at: '2026-06-01',
draft_id: 'p-1',
items: itemsB,
});
expect(h1).toBe(h2);
expect(h1).toHaveLength(64);
});
it('различает разные cycle_started_at', () => {
const items = [
{
braname: 'voskhod1',
asset_title: 'A',
quantity: '1',
amount: '10.0000',
reason: 'r',
inventory_id: null,
executed: false,
},
];
const h1 = service.computeProposalHash({
coopname: 'voskhod',
cycle_started_at: '2026-06-01',
draft_id: 'p-1',
items,
});
const h2 = service.computeProposalHash({
coopname: 'voskhod',
cycle_started_at: '2026-07-01',
draft_id: 'p-1',
items,
});
expect(h1).not.toBe(h2);
});
});
// ── updateDraft / cancelDraft ──────────────────────────────────────────
describe('updateDraft', () => {
it('NotFound когда DRAFT не существует', async () => {
mocks.repo.findById.mockResolvedValue(null);
await expect(
service.updateDraft({ id: 'p-1', actor: 'chairman1', items: [buildItem()] })
).rejects.toThrow(NotFoundException);
});
it('BadRequest когда статус не DRAFT', async () => {
mocks.repo.findById.mockResolvedValue(buildProposal({ status: 'AUTHORIZED' } as any));
await expect(
service.updateDraft({ id: 'p-1', actor: 'chairman1', items: [buildItem()] })
).rejects.toThrow(BadRequestException);
});
it('happy — пересобирает items с пересчётом total_amount', async () => {
mocks.repo.findById.mockResolvedValue(buildProposal());
mocks.repo.updateDraftItems.mockResolvedValue(buildProposal());
await service.updateDraft({
id: 'p-1',
actor: 'chairman1',
items: [buildItem({ amount: '100.0000' }), buildItem({ amount: '200.0000' })],
});
expect(mocks.repo.updateDraftItems).toHaveBeenCalledWith(
'p-1',
expect.any(Array),
'300.0000 RUB',
expect.objectContaining({ action: 'draft_updated', actor: 'chairman1' })
);
});
});
describe('cancelDraft', () => {
it('NotFound', async () => {
mocks.repo.findById.mockResolvedValue(null);
await expect(service.cancelDraft('p-1')).rejects.toThrow(NotFoundException);
});
it('BadRequest когда не DRAFT', async () => {
mocks.repo.findById.mockResolvedValue(buildProposal({ status: 'EXECUTED' } as any));
await expect(service.cancelDraft('p-1')).rejects.toThrow(BadRequestException);
});
it('happy', async () => {
mocks.repo.findById.mockResolvedValue(buildProposal());
await service.cancelDraft('p-1');
expect(mocks.repo.cancelDraft).toHaveBeenCalledWith('p-1');
});
});
// ── submitToCouncil ───────────────────────────────────────────────────
describe('submitToCouncil', () => {
beforeEach(() => {
jest
.spyOn(service as any, 'verifyDocumentSignature')
.mockImplementation(() => undefined);
});
function setupDraft() {
const draft = buildProposal();
mocks.repo.findById.mockResolvedValue(draft);
const proposalHash = service.computeProposalHash({
coopname: draft.coopname,
cycle_started_at: draft.cycle_started_at.toISOString(),
draft_id: draft.id,
items: draft.items,
});
mocks.repo.submitToCouncil.mockResolvedValue(
buildProposal({ status: 'ON_AGENDA', proposal_hash: proposalHash } as any)
);
return { draft, proposalHash };
}
it('happy — propWroff + createWriteoffAgenda + submitToCouncil', async () => {
const { proposalHash } = setupDraft();
const result = await service.submitToCouncil({
id: 'p-1',
chairman_account: 'chairman1',
signed_statement: buildSignedStatement(proposalHash),
});
expect(mocks.chainPort.propWroff).toHaveBeenCalledWith(
expect.objectContaining({
coopname: 'voskhod',
proposed_by: 'chairman1',
proposal_hash: proposalHash,
})
);
expect(mocks.chainPort.createWriteoffAgenda).toHaveBeenCalledWith(
expect.objectContaining({
coopname: 'voskhod',
username: 'chairman1',
proposal_hash: proposalHash,
})
);
expect(mocks.repo.submitToCouncil).toHaveBeenCalledWith(
'p-1',
expect.objectContaining({ proposal_hash: proposalHash, proposed_by_account: 'chairman1' })
);
expect(result.status).toBe('ON_AGENDA');
});
it('NotFound когда DRAFT не существует', async () => {
mocks.repo.findById.mockResolvedValue(null);
await expect(
service.submitToCouncil({
id: 'p-1',
chairman_account: 'chairman1',
signed_statement: buildSignedStatement('any-hash'),
})
).rejects.toThrow(NotFoundException);
});
it('BadRequest когда статус не DRAFT', async () => {
mocks.repo.findById.mockResolvedValue(buildProposal({ status: 'AUTHORIZED' } as any));
await expect(
service.submitToCouncil({
id: 'p-1',
chairman_account: 'chairman1',
signed_statement: buildSignedStatement('any-hash'),
})
).rejects.toThrow(BadRequestException);
expect(mocks.chainPort.propWroff).not.toHaveBeenCalled();
});
it('BadRequest когда корзина пуста', async () => {
mocks.repo.findById.mockResolvedValue(buildProposal({ items: [] } as any));
await expect(
service.submitToCouncil({
id: 'p-1',
chairman_account: 'chairman1',
signed_statement: buildSignedStatement('any-hash'),
})
).rejects.toThrow(BadRequestException);
});
it('BadRequest когда registry_id не 1106', async () => {
const draft = buildProposal();
mocks.repo.findById.mockResolvedValue(draft);
const proposalHash = service.computeProposalHash({
coopname: draft.coopname,
cycle_started_at: draft.cycle_started_at.toISOString(),
draft_id: draft.id,
items: draft.items,
});
const wrongDoc = buildSignedStatement(proposalHash);
wrongDoc.meta.registry_id = 9999;
await expect(
service.submitToCouncil({
id: 'p-1',
chairman_account: 'chairman1',
signed_statement: wrongDoc,
})
).rejects.toThrow(BadRequestException);
});
it('BadRequest когда proposal_hash в подписанном Заявлении не сошёлся', async () => {
const draft = buildProposal();
mocks.repo.findById.mockResolvedValue(draft);
const wrongDoc = buildSignedStatement('wrong-hash-zzz');
await expect(
service.submitToCouncil({
id: 'p-1',
chairman_account: 'chairman1',
signed_statement: wrongDoc,
})
).rejects.toThrow(/пересоберите Заявление/);
expect(mocks.chainPort.propWroff).not.toHaveBeenCalled();
});
});
// ── onCouncilAuthorized ────────────────────────────────────────────────
describe('onCouncilAuthorized', () => {
it('happy — markAuthorized + сразу запускает executeAuthorizedProposal', async () => {
const onAgenda = buildProposal({ status: 'ON_AGENDA' } as any);
const authorized = buildProposal({ status: 'AUTHORIZED' } as any);
mocks.repo.findByHash.mockResolvedValue(onAgenda);
mocks.repo.markAuthorized.mockResolvedValue(authorized);
const executingProp = buildProposal({ status: 'EXECUTING' } as any);
mocks.repo.findById.mockResolvedValue(authorized);
mocks.repo.markExecuting.mockResolvedValue(executingProp);
const executedItem = buildProposal({
status: 'EXECUTING',
items: [{ ...buildProposal().items[0], executed: true }],
} as any);
mocks.repo.markItemExecuted.mockResolvedValue(executedItem);
mocks.repo.markFullyExecuted.mockResolvedValue(
buildProposal({ status: 'EXECUTED' } as any)
);
await service.onCouncilAuthorized({
coopname: 'voskhod',
proposal_hash: 'h1',
authorized_by: 'chairman1',
protocol_doc: { hash: 'protocol-doc' },
});
expect(mocks.repo.markAuthorized).toHaveBeenCalled();
expect(mocks.chainPort.execWroff).toHaveBeenCalled();
});
it('пропускает — proposal не найден по hash', async () => {
mocks.repo.findByHash.mockResolvedValue(null);
await service.onCouncilAuthorized({
coopname: 'voskhod',
proposal_hash: 'h1',
authorized_by: 'chairman1',
protocol_doc: null,
});
expect(mocks.repo.markAuthorized).not.toHaveBeenCalled();
expect(mocks.logger.warn).toHaveBeenCalled();
});
it('пропускает — статус не ON_AGENDA', async () => {
mocks.repo.findByHash.mockResolvedValue(
buildProposal({ status: 'EXECUTED' } as any)
);
await service.onCouncilAuthorized({
coopname: 'voskhod',
proposal_hash: 'h1',
authorized_by: 'chairman1',
protocol_doc: null,
});
expect(mocks.repo.markAuthorized).not.toHaveBeenCalled();
});
});
// ── onCouncilDeclined ─────────────────────────────────────────────────
describe('onCouncilDeclined', () => {
it('happy — markRejected + REJECTED event', async () => {
const onAgenda = buildProposal({ status: 'ON_AGENDA' } as any);
mocks.repo.findByHash.mockResolvedValue(onAgenda);
mocks.repo.markRejected.mockResolvedValue(
buildProposal({ status: 'REJECTED', reject_reason: 'не одобрено' } as any)
);
await service.onCouncilDeclined({
coopname: 'voskhod',
proposal_hash: 'h1',
reason: 'не одобрено',
});
expect(mocks.repo.markRejected).toHaveBeenCalledWith(
'p-1',
expect.objectContaining({ reject_reason: 'не одобрено' })
);
expect(mocks.eventBus.emit).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ reason: 'не одобрено' })
);
});
it('пропускает — proposal не найден', async () => {
mocks.repo.findByHash.mockResolvedValue(null);
await service.onCouncilDeclined({ coopname: 'voskhod', proposal_hash: 'h1', reason: 'x' });
expect(mocks.repo.markRejected).not.toHaveBeenCalled();
});
it('пропускает — статус не ON_AGENDA', async () => {
mocks.repo.findByHash.mockResolvedValue(buildProposal({ status: 'EXECUTED' } as any));
await service.onCouncilDeclined({ coopname: 'voskhod', proposal_hash: 'h1', reason: 'x' });
expect(mocks.repo.markRejected).not.toHaveBeenCalled();
});
});
// ── executeAuthorizedProposal ─────────────────────────────────────────
describe('executeAuthorizedProposal', () => {
it('per-item execWroff + markItemExecuted на каждой позиции + markFullyExecuted в конце', async () => {
const authorized = buildProposal({
status: 'AUTHORIZED',
items: [
{ ...buildProposal().items[0], braname: 'voskhod1', amount: '10.0000' },
{ ...buildProposal().items[0], braname: 'voskhod2', amount: '20.0000' },
],
} as any);
mocks.repo.findById.mockResolvedValue(authorized);
mocks.repo.markExecuting.mockResolvedValue(
buildProposal({ status: 'EXECUTING', items: authorized.items } as any)
);
// После каждого markItemExecuted возвращаем working с актуальным executed
let executed = 0;
mocks.repo.markItemExecuted.mockImplementation(async () => {
executed++;
return buildProposal({
status: 'EXECUTING',
items: authorized.items.map((it, idx) => ({ ...it, executed: idx < executed })),
} as any);
});
mocks.repo.markFullyExecuted.mockResolvedValue(
buildProposal({ status: 'EXECUTED' } as any)
);
await service.executeAuthorizedProposal('p-1', 'chairman1');
expect(mocks.chainPort.execWroff).toHaveBeenCalledTimes(2);
expect(mocks.repo.markItemExecuted).toHaveBeenCalledTimes(2);
expect(mocks.repo.markFullyExecuted).toHaveBeenCalled();
});
it('NotFound', async () => {
mocks.repo.findById.mockResolvedValue(null);
await expect(service.executeAuthorizedProposal('p-1', 'chairman1')).rejects.toThrow(
NotFoundException
);
});
it('BadRequest когда статус не AUTHORIZED/EXECUTING', async () => {
mocks.repo.findById.mockResolvedValue(buildProposal({ status: 'DRAFT' } as any));
await expect(service.executeAuthorizedProposal('p-1', 'chairman1')).rejects.toThrow(
BadRequestException
);
});
it('пропускает уже исполненные item-ы (idempotent)', async () => {
const items = [
{ ...buildProposal().items[0], braname: 'voskhod1', executed: true },
{ ...buildProposal().items[0], braname: 'voskhod2', executed: false },
];
const executing = buildProposal({ status: 'EXECUTING', items } as any);
mocks.repo.findById.mockResolvedValue(executing);
mocks.repo.markItemExecuted.mockResolvedValue(
buildProposal({
status: 'EXECUTING',
items: items.map((it) => ({ ...it, executed: true })),
} as any)
);
mocks.repo.markFullyExecuted.mockResolvedValue(
buildProposal({ status: 'EXECUTED' } as any)
);
await service.executeAuthorizedProposal('p-1', 'chairman1');
expect(mocks.chainPort.execWroff).toHaveBeenCalledTimes(1);
});
});
// ── formatAsset / formatAssetNumber через assetConfig ─────────────────
describe('formatAsset', () => {
it('берёт symbol/decimals из DI assetConfig (не хардкод)', () => {
const customMocks = buildMocks();
customMocks.assetConfig.symbol = 'XYZ';
customMocks.assetConfig.decimals = 2;
const customService = buildService(customMocks);
expect(customService.formatAsset(123.456)).toBe('123.46 XYZ');
expect(customService.formatAssetNumber(7)).toBe('7.00');
});
});
});
@@ -0,0 +1,519 @@
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { createHash } from 'crypto';
import { Cooperative, MarketContract } from 'cooptypes';
import { PublicKey, Signature } from '@wharfkit/antelope';
import http from 'http-status';
import { HttpApiError } from '~/utils/httpApiError';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { DocumentDomainService } from '~/domain/document/services/document-domain.service';
import type { PaginationInputDTO } from '~/application/common/dto/pagination.dto';
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
import {
MARKETPLACE_WRITEOFF_PROPOSAL_REPOSITORY,
type MarketplaceWriteoffProposalDomainRepository,
} from '../../domain/repositories/marketplace-writeoff-proposal.repository';
import {
MARKETPLACE_INVENTORY_REPOSITORY,
type MarketplaceInventoryDomainRepository,
} from '../../domain/repositories/marketplace-inventory.repository';
import {
MARKETPLACE_CANONICAL_BLOCKCHAIN_PORT,
type MarketplaceCanonicalBlockchainPort,
} from '../../domain/ports/marketplace-canonical-blockchain.port';
import {
MARKETPLACE_ASSET_CONFIG,
type MarketplaceAssetConfig,
} from './marketplace-asset.config';
import { MarketplaceWriteoffProposalDomainEntity } from '../../domain/entities/marketplace-writeoff-proposal.entity';
import {
MarketplaceWriteoffProposalStatuses,
type MarketplaceWriteoffProposalItem,
type MarketplaceWriteoffProposalStatus,
type MarketplaceWriteoffProposalTrigger,
} from '../../domain/entities/marketplace-writeoff-proposal.types';
import {
MARKETPLACE_WRITEOFF_PROPOSED_EVENT,
MARKETPLACE_WRITEOFF_DRAFT_BUILT_EVENT,
MARKETPLACE_WRITEOFF_AUTHORIZED_EVENT,
MARKETPLACE_WRITEOFF_EXECUTED_EVENT,
MARKETPLACE_WRITEOFF_REJECTED_EVENT,
} from '../events/marketplace-notification.events';
export interface MarketplaceWriteoffItemInput {
braname: string;
asset_title: string;
quantity: string;
amount: string;
reason: string;
inventory_id?: string | null;
}
export interface MarketplaceCreateWriteoffDraftInput {
coopname: string;
trigger: MarketplaceWriteoffProposalTrigger;
proposed_by_account: string | null;
cycle_started_at?: Date;
items: MarketplaceWriteoffItemInput[];
}
export interface MarketplaceUpdateWriteoffDraftInput {
id: string;
actor: string;
items: MarketplaceWriteoffItemInput[];
}
export interface MarketplaceSubmitWriteoffDraftInput {
id: string;
chairman_account: string;
signed_statement: ISignedDocumentDomainInterface;
}
export interface MarketplaceListWriteoffProposalsInput {
coopname: string;
statuses?: MarketplaceWriteoffProposalStatus[];
pagination?: PaginationInputDTO;
}
@Injectable()
export class MarketplaceWriteoffService {
constructor(
@Inject(MARKETPLACE_WRITEOFF_PROPOSAL_REPOSITORY)
private readonly repo: MarketplaceWriteoffProposalDomainRepository,
@Inject(MARKETPLACE_INVENTORY_REPOSITORY)
private readonly inventoryRepo: MarketplaceInventoryDomainRepository,
@Inject(MARKETPLACE_CANONICAL_BLOCKCHAIN_PORT)
private readonly chainPort: MarketplaceCanonicalBlockchainPort,
@Inject(MARKETPLACE_ASSET_CONFIG)
private readonly assetConfig: MarketplaceAssetConfig,
private readonly documentDomainService: DocumentDomainService,
private readonly eventBus: EventEmitter2,
private readonly logger: WinstonLoggerService
) {
this.logger.setContext(MarketplaceWriteoffService.name);
}
private get assetSymbol(): string {
return this.assetConfig.symbol;
}
private get assetDecimals(): number {
return this.assetConfig.decimals;
}
// ── Чтение ─────────────────────────────────────────────────────────
async listProposals(input: MarketplaceListWriteoffProposalsInput) {
return this.repo.list(
{ coopname: input.coopname, statuses: input.statuses },
input.pagination
);
}
async getProposal(id: string): Promise<MarketplaceWriteoffProposalDomainEntity> {
const p = await this.repo.findById(id);
if (!p) throw new NotFoundException('Проект списания не найден');
return p;
}
async getOpenDraft(coopname: string): Promise<MarketplaceWriteoffProposalDomainEntity | null> {
return this.repo.findOpenDraft(coopname);
}
// ── DRAFT pipeline ─────────────────────────────────────────────────
async createDraft(
input: MarketplaceCreateWriteoffDraftInput
): Promise<MarketplaceWriteoffProposalDomainEntity> {
const existingDraft = await this.repo.findOpenDraft(input.coopname);
if (existingDraft) {
throw new ConflictException(
`У кооператива уже есть открытый черновик списания (id=${existingDraft.id}). Удалите его перед созданием нового.`
);
}
const existingActive = await this.repo.findOpenInCouncil(input.coopname);
if (existingActive) {
throw new ConflictException(
`Проект списания id=${existingActive.id} уже отправлен в совет (статус=${existingActive.status}). Дождитесь решения совета.`
);
}
const normalizedItems = this.validateAndNormalizeItems(input.items);
const total = this.sumItems(normalizedItems);
const cycleStartedAt = input.cycle_started_at ?? new Date();
const created = await this.repo.create({
coopname: input.coopname,
trigger: input.trigger,
cycle_started_at: cycleStartedAt,
proposed_by_account: input.proposed_by_account,
items: normalizedItems,
total_amount: this.formatAsset(total),
});
this.eventBus.emit(MARKETPLACE_WRITEOFF_DRAFT_BUILT_EVENT, {
coopname: input.coopname,
proposal_id: created.id,
trigger: input.trigger,
items_count: normalizedItems.length,
total_amount: created.total_amount,
});
return created;
}
async updateDraft(
input: MarketplaceUpdateWriteoffDraftInput
): Promise<MarketplaceWriteoffProposalDomainEntity> {
const draft = await this.repo.findById(input.id);
if (!draft) throw new NotFoundException('Проект списания не найден');
if (!draft.is_draft) {
throw new BadRequestException('Редактировать можно только проект в статусе DRAFT');
}
const normalizedItems = this.validateAndNormalizeItems(input.items);
const total = this.sumItems(normalizedItems);
return this.repo.updateDraftItems(input.id, normalizedItems, this.formatAsset(total), {
at: new Date().toISOString(),
actor: input.actor,
action: 'draft_updated',
payload: { items_count: normalizedItems.length },
});
}
async cancelDraft(id: string): Promise<void> {
const draft = await this.repo.findById(id);
if (!draft) throw new NotFoundException('Проект списания не найден');
if (!draft.is_draft) {
throw new BadRequestException('Удалить можно только проект в статусе DRAFT');
}
await this.repo.cancelDraft(id);
}
// ── Submit в совет ─────────────────────────────────────────────────
async submitToCouncil(
input: MarketplaceSubmitWriteoffDraftInput
): Promise<MarketplaceWriteoffProposalDomainEntity> {
const draft = await this.repo.findById(input.id);
if (!draft) throw new NotFoundException('Проект списания не найден');
if (!draft.is_draft) {
throw new BadRequestException(
`Передать в совет можно только проект в статусе DRAFT (текущий: ${draft.status})`
);
}
if (!Array.isArray(draft.items) || draft.items.length === 0) {
throw new BadRequestException('Черновик пуст — добавьте позиции перед отправкой в совет');
}
this.verifyDocumentSignature(input.signed_statement);
const statementMeta = input.signed_statement.meta as {
registry_id?: number;
proposal_hash?: string;
} | undefined;
if (
!statementMeta ||
statementMeta.registry_id !== Cooperative.Registry.MarketplaceWriteoffStatement.registry_id
) {
throw new BadRequestException(
`Заявление должно быть зарегистрировано с registry_id=${Cooperative.Registry.MarketplaceWriteoffStatement.registry_id}`
);
}
const proposalHash = this.computeProposalHash({
coopname: draft.coopname,
cycle_started_at: draft.cycle_started_at.toISOString(),
draft_id: draft.id,
items: draft.items,
});
if (statementMeta.proposal_hash && statementMeta.proposal_hash !== proposalHash) {
throw new BadRequestException(
'Заявление подписано для другого расчёта позиций — пересоберите Заявление перед отправкой'
);
}
// 1. on-chain propwroff: фиксируем wroffprops::proposed
await this.chainPort.propWroff({
coopname: draft.coopname,
proposed_by: input.chairman_account,
proposal_hash: proposalHash,
items: draft.items.map((it) => ({
source_order_id: '0',
braname: it.braname,
amount: it.amount,
meta: it.reason,
executed: false,
})) as MarketContract.Actions.PropWroff.IPropWroff['items'],
});
// 2. createagenda: ставим повестку совета с callback'ами marketplace
await this.chainPort.createWriteoffAgenda({
coopname: draft.coopname,
username: input.chairman_account,
proposal_hash: proposalHash,
statement: input.signed_statement,
meta: JSON.stringify({
registry_id: Cooperative.Registry.MarketplaceWriteoffStatement.registry_id,
proposal_hash: proposalHash,
items_count: draft.items.length,
total_amount: draft.total_amount,
}),
});
// 3. PG: DRAFT → ON_AGENDA (decision_id заполнит реактор-наблюдатель за soviet.decisions)
const submitted = await this.repo.submitToCouncil(draft.id, {
proposal_hash: proposalHash,
statement_doc: input.signed_statement,
decision_id: null,
submitted_at: new Date(),
proposed_by_account: input.chairman_account,
log: {
at: new Date().toISOString(),
actor: input.chairman_account,
action: 'submitted_to_council',
payload: { proposal_hash: proposalHash, items_count: draft.items.length },
},
});
this.eventBus.emit(MARKETPLACE_WRITEOFF_PROPOSED_EVENT, {
coopname: submitted.coopname,
proposal_id: submitted.id,
proposal_hash: proposalHash,
items_count: submitted.items.length,
total_amount: submitted.total_amount,
});
return submitted;
}
// ── Callback-стороны (вызываются из delta-листенера) ────────────────
async onCouncilAuthorized(input: {
coopname: string;
proposal_hash: string;
authorized_by: string | null;
protocol_doc: unknown;
}): Promise<void> {
const proposal = await this.repo.findByHash(input.coopname, input.proposal_hash);
if (!proposal) {
this.logger.warn(
`[WRITEOFF] получен callback onmktwoauth, но proposal hash=${input.proposal_hash} не найден в PG`
);
return;
}
if (!proposal.is_on_agenda) {
this.logger.warn(
`[WRITEOFF] callback onmktwoauth для proposal ${proposal.id} в статусе ${proposal.status} — пропускаю`
);
return;
}
const updated = await this.repo.markAuthorized(proposal.id, {
protocol_doc: input.protocol_doc,
authorized_at: new Date(),
decided_by_account: input.authorized_by,
log: {
at: new Date().toISOString(),
actor: input.authorized_by ?? 'soviet',
action: 'authorized_by_council',
},
});
this.eventBus.emit(MARKETPLACE_WRITEOFF_AUTHORIZED_EVENT, {
coopname: updated.coopname,
proposal_id: updated.id,
proposal_hash: updated.proposal_hash,
items_count: updated.items.length,
total_amount: updated.total_amount,
});
// Сразу запускаем исполнение
await this.executeAuthorizedProposal(updated.id, input.authorized_by ?? proposal.proposed_by_account ?? input.coopname);
}
async onCouncilDeclined(input: {
coopname: string;
proposal_hash: string;
reason: string;
}): Promise<void> {
const proposal = await this.repo.findByHash(input.coopname, input.proposal_hash);
if (!proposal) {
this.logger.warn(
`[WRITEOFF] получен callback onmktwodecl, но proposal hash=${input.proposal_hash} не найден в PG`
);
return;
}
if (!proposal.is_on_agenda) {
this.logger.warn(
`[WRITEOFF] callback onmktwodecl для proposal ${proposal.id} в статусе ${proposal.status} — пропускаю`
);
return;
}
const updated = await this.repo.markRejected(proposal.id, {
reject_reason: input.reason,
rejected_at: new Date(),
decided_by_account: null,
log: {
at: new Date().toISOString(),
actor: 'soviet',
action: 'declined_by_council',
payload: { reason: input.reason },
},
});
this.eventBus.emit(MARKETPLACE_WRITEOFF_REJECTED_EVENT, {
coopname: updated.coopname,
proposal_id: updated.id,
proposal_hash: updated.proposal_hash,
reason: input.reason,
});
}
// ── Per-item execute loop ──────────────────────────────────────────
async executeAuthorizedProposal(id: string, signer: string): Promise<void> {
const proposal = await this.repo.findById(id);
if (!proposal) throw new NotFoundException('Проект списания не найден');
if (!proposal.is_authorized && !proposal.is_executing) {
throw new BadRequestException(
`Запустить исполнение можно только из AUTHORIZED/EXECUTING (текущий: ${proposal.status})`
);
}
let working = proposal;
if (working.is_authorized) {
working = await this.repo.markExecuting(id, {
at: new Date().toISOString(),
actor: signer,
action: 'execution_started',
});
}
for (let i = 0; i < working.items.length; i++) {
const item = working.items[i];
if (item.executed) continue;
try {
await this.chainPort.execWroff({
coopname: working.coopname,
signer,
proposal_hash: working.proposal_hash,
item_index: String(i) as MarketContract.Actions.ExecWroff.IExecWroff['item_index'],
});
working = await this.repo.markItemExecuted(id, i, {
at: new Date().toISOString(),
actor: signer,
action: 'item_executed',
payload: { item_index: i, braname: item.braname, amount: item.amount },
});
if (item.inventory_id) {
try {
await this.inventoryRepo.applyStatusTransition(item.inventory_id, 'WRITTEN_OFF');
} catch (e) {
this.logger.warn(
`[WRITEOFF] не удалось перевести inventory ${item.inventory_id} в WRITTEN_OFF: ${(e as Error).message}`
);
}
}
} catch (e) {
this.logger.error(
`[WRITEOFF] не удалось исполнить позицию ${i} проекта ${id}: ${(e as Error).message}`
);
throw e;
}
}
const finalized = await this.repo.markFullyExecuted(id, {
at: new Date().toISOString(),
actor: signer,
action: 'execution_completed',
});
this.eventBus.emit(MARKETPLACE_WRITEOFF_EXECUTED_EVENT, {
coopname: finalized.coopname,
proposal_id: finalized.id,
proposal_hash: finalized.proposal_hash,
items_count: finalized.items.length,
total_amount: finalized.total_amount,
});
}
// ── Утилиты ────────────────────────────────────────────────────────
computeProposalHash(input: {
coopname: string;
cycle_started_at: string;
draft_id: string;
items: MarketplaceWriteoffProposalItem[];
}): string {
const sorted = [...input.items].sort((a, b) =>
`${a.braname}|${a.asset_title}|${a.amount}`.localeCompare(
`${b.braname}|${b.asset_title}|${b.amount}`
)
);
const payload = [
'writeoff',
input.coopname,
input.cycle_started_at,
input.draft_id,
...sorted.map((it) => `${it.braname}|${it.asset_title}|${it.quantity}|${it.amount}`),
].join('|');
return createHash('sha256').update(payload).digest('hex');
}
validateAndNormalizeItems(items: MarketplaceWriteoffItemInput[]): MarketplaceWriteoffProposalItem[] {
if (!Array.isArray(items) || items.length === 0) {
throw new BadRequestException('Список позиций к списанию пуст');
}
if (items.length > 200) {
throw new BadRequestException('Максимум 200 позиций в одном проекте списания');
}
return items.map((it) => {
if (!it.braname) throw new BadRequestException('Не указан КУ позиции');
if (!it.asset_title) throw new BadRequestException('Не указано наименование позиции');
const amount = Number(it.amount);
if (!Number.isFinite(amount) || amount <= 0) {
throw new BadRequestException(
`Некорректная сумма позиции "${it.asset_title}": ${it.amount}`
);
}
return {
braname: it.braname,
asset_title: it.asset_title,
quantity: it.quantity,
amount: this.formatAssetNumber(amount),
reason: it.reason ?? '',
inventory_id: it.inventory_id ?? null,
executed: false,
};
});
}
sumItems(items: MarketplaceWriteoffProposalItem[]): number {
return items.reduce((acc, it) => acc + Number(it.amount), 0);
}
formatAsset(value: number): string {
return `${this.formatAssetNumber(value)} ${this.assetSymbol}`;
}
formatAssetNumber(value: number): string {
return value.toFixed(this.assetDecimals);
}
private verifyDocumentSignature(document: ISignedDocumentDomainInterface): void {
const sig = document.signatures?.[0];
if (!sig) throw new HttpApiError(http.BAD_REQUEST, 'Заявление не подписано');
const publicKey = PublicKey.from(sig.public_key);
const signature = Signature.from(sig.signature);
const verified = signature.verifyDigest(sig.signed_hash, publicKey);
if (!verified) {
throw new HttpApiError(http.BAD_REQUEST, 'Недействительная подпись Заявления о списании');
}
}
}
export { MarketplaceWriteoffProposalStatuses };
@@ -26,6 +26,7 @@ export class MarketplaceInventoryDomainEntity {
public readonly orderer_account_snapshot: string;
public readonly labeled_at: Date;
public readonly labeled_by_operator_account: string;
public readonly expiry_date: Date | null;
public readonly created_at: Date;
public updated_at: Date;
@@ -54,6 +55,7 @@ export class MarketplaceInventoryDomainEntity {
this.orderer_account_snapshot = props.orderer_account_snapshot;
this.labeled_at = props.labeled_at;
this.labeled_by_operator_account = props.labeled_by_operator_account;
this.expiry_date = props.expiry_date ?? null;
this.created_at = props.created_at;
this.updated_at = props.updated_at;
}
@@ -66,6 +66,16 @@ export interface MarketplaceInventoryProps {
orderer_account_snapshot: string;
labeled_at: Date;
labeled_by_operator_account: string;
/**
* Story 8.3 (Эпик 8): срок годности позиции. Проставляется при
* маркировке как `labeled_at + Offer.warranty_days * 86400`. По нему
* крон Эпика 8 формирует DRAFT-проект списания, когда `expiry_date`
* приближается к `now + writeoff.expiry_grace_days`.
*
* Nullable: для исторических записей без warranty_days и для позиций
* с бессрочными офферами (`warranty_days = 0`).
*/
expiry_date: Date | null;
created_at: Date;
updated_at: Date;
}
@@ -0,0 +1,96 @@
import type {
MarketplaceWriteoffProposalDecisionEntry,
MarketplaceWriteoffProposalItem,
MarketplaceWriteoffProposalProps,
MarketplaceWriteoffProposalStatus,
MarketplaceWriteoffProposalTrigger,
} from './marketplace-writeoff-proposal.types';
import { MarketplaceWriteoffProposalStatuses } from './marketplace-writeoff-proposal.types';
/**
* Story 8.1 (Эпик 8): доменная сущность проекта решения совета о списании
* скоропорта. Связка с on-chain `wroffprops` идёт по `proposal_hash`;
* связка с `soviet.decisions` — по тому же hash и опциональному
* `decision_id`.
*/
export class MarketplaceWriteoffProposalDomainEntity {
public readonly id: string;
public readonly coopname: string;
public readonly trigger: MarketplaceWriteoffProposalTrigger;
public status: MarketplaceWriteoffProposalStatus;
public readonly cycle_started_at: Date;
public readonly proposal_hash: string;
public decision_id: number | null;
public proposed_by_account: string | null;
public decided_by_account: string | null;
public items: MarketplaceWriteoffProposalItem[];
public total_amount: string;
public protocol_doc: unknown | null;
public statement_doc: unknown | null;
public reject_reason: string | null;
public decision_log: MarketplaceWriteoffProposalDecisionEntry[];
public submitted_at: Date | null;
public authorized_at: Date | null;
public executed_at: Date | null;
public rejected_at: Date | null;
public readonly created_at: Date;
public updated_at: Date;
constructor(props: MarketplaceWriteoffProposalProps) {
this.id = props.id;
this.coopname = props.coopname;
this.trigger = props.trigger;
this.status = props.status;
this.cycle_started_at = props.cycle_started_at;
this.proposal_hash = props.proposal_hash;
this.decision_id = props.decision_id;
this.proposed_by_account = props.proposed_by_account;
this.decided_by_account = props.decided_by_account;
this.items = props.items;
this.total_amount = props.total_amount;
this.protocol_doc = props.protocol_doc;
this.statement_doc = props.statement_doc;
this.reject_reason = props.reject_reason;
this.decision_log = props.decision_log;
this.submitted_at = props.submitted_at;
this.authorized_at = props.authorized_at;
this.executed_at = props.executed_at;
this.rejected_at = props.rejected_at;
this.created_at = props.created_at;
this.updated_at = props.updated_at;
}
public get is_draft(): boolean {
return this.status === MarketplaceWriteoffProposalStatuses.DRAFT;
}
public get is_on_agenda(): boolean {
return this.status === MarketplaceWriteoffProposalStatuses.ON_AGENDA;
}
public get is_authorized(): boolean {
return this.status === MarketplaceWriteoffProposalStatuses.AUTHORIZED;
}
public get is_executing(): boolean {
return this.status === MarketplaceWriteoffProposalStatuses.EXECUTING;
}
public get is_executed(): boolean {
return this.status === MarketplaceWriteoffProposalStatuses.EXECUTED;
}
public get is_rejected(): boolean {
return this.status === MarketplaceWriteoffProposalStatuses.REJECTED;
}
public get is_final(): boolean {
return this.is_executed || this.is_rejected;
}
public get items_executed_count(): number {
return this.items.filter((it) => it.executed).length;
}
public get items_pending_count(): number {
return this.items.filter((it) => !it.executed).length;
}
}
@@ -0,0 +1,99 @@
/**
* Story 8.1 (Эпик 8): жизненный цикл проекта списания скоропорта.
*
* Соответствие on-chain wroffprops:
* DRAFT — приватный PG-черновик; on-chain ещё не существует.
* ON_AGENDA — propwroff отправлен; есть soviet.decisions[decision_id];
* wroffprops.status = proposed.
* AUTHORIZED — пришёл callback marketplace::onmktwoauth от soviet'а;
* wroffprops.status = authorized; protocol2 сохранён.
* EXECUTING — backend в процессе цикла execwroff per-item (на цепи всё
* ещё authorized; PG-маркер «работа идёт»).
* EXECUTED — все items списаны; wroffprops.status = executed.
* REJECTED — callback marketplace::onmktwodecl; wroffprops.status =
* rejected.
*/
export type MarketplaceWriteoffProposalStatus =
| 'DRAFT'
| 'ON_AGENDA'
| 'AUTHORIZED'
| 'EXECUTING'
| 'EXECUTED'
| 'REJECTED';
export const MarketplaceWriteoffProposalStatuses = {
DRAFT: 'DRAFT',
ON_AGENDA: 'ON_AGENDA',
AUTHORIZED: 'AUTHORIZED',
EXECUTING: 'EXECUTING',
EXECUTED: 'EXECUTED',
REJECTED: 'REJECTED',
} as const satisfies Record<string, MarketplaceWriteoffProposalStatus>;
/**
* Откуда взялся DRAFT: ежемесячный крон-сканер или ручное действие
* председателя.
*/
export type MarketplaceWriteoffProposalTrigger = 'cron' | 'manual';
export const MarketplaceWriteoffProposalTriggers = {
CRON: 'cron',
MANUAL: 'manual',
} as const satisfies Record<string, MarketplaceWriteoffProposalTrigger>;
/**
* Позиция к списанию. Опциональный `inventory_id` — пометка, какой
* штрих-код из `marketplace_inventory` попал в корзину (нужно крону для
* последующего пометить статус позиции на WRITTEN_OFF после executed).
*/
export interface MarketplaceWriteoffProposalItem {
braname: string;
asset_title: string;
quantity: string;
amount: string;
reason: string;
inventory_id: string | null;
executed: boolean;
}
/**
* Запись журнала решений по проекту — для отчётов и UI таймлайна.
*/
export interface MarketplaceWriteoffProposalDecisionEntry {
at: string;
actor: string;
action: string;
payload?: Record<string, unknown>;
}
export interface MarketplaceWriteoffProposalProps {
id: string;
coopname: string;
trigger: MarketplaceWriteoffProposalTrigger;
status: MarketplaceWriteoffProposalStatus;
cycle_started_at: Date;
proposal_hash: string;
decision_id: number | null;
proposed_by_account: string | null;
decided_by_account: string | null;
items: MarketplaceWriteoffProposalItem[];
total_amount: string;
/** Протокол совета (registry 1105) — JSON `IDocument2`. */
protocol_doc: unknown | null;
/** Заявление председателя (registry 1106) — JSON `IDocument2`. */
statement_doc: unknown | null;
reject_reason: string | null;
decision_log: MarketplaceWriteoffProposalDecisionEntry[];
submitted_at: Date | null;
authorized_at: Date | null;
executed_at: Date | null;
rejected_at: Date | null;
created_at: Date;
updated_at: Date;
}
@@ -212,6 +212,49 @@ export interface MarketplaceCanonicalBlockchainPort {
* авторизацию `signer` для `braname` + `reason.size() ∈ (0, 500]`.
*/
rejRetrn(data: MarketContract.Actions.RejRetrn.IRejRetrn): Promise<TransactResult>;
/**
* Story 8.1 / FR37: backend вносит проект решения совета о списании
* скоропорта. Без ledger2-операций — создаётся on-chain wroffprops в
* статусе `proposed`. Сразу следующим действием backend вызывает
* `soviet::createagenda(type=mktwroff, callback_contract=marketplace,
* confirm_callback=onmktwoauth, decline_callback=onmktwodecl)` —
* проект становится повесткой совета.
*
* Авторизация — кооператив (`require_auth(coopname)`).
*/
propWroff(data: MarketContract.Actions.PropWroff.IPropWroff): Promise<TransactResult>;
/**
* Story 8.4: backend исполняет одну позицию авторизованного проекта
* списания. Per-item atomic-пара `o.mkt.wroff` (Дт 91 / Кт 10) +
* `o.mkt.wroff2` (Дт 86 / Кт 91); когда последняя позиция исполнена,
* статус AUTHORIZED → EXECUTED. Backend проходит цикл по
* `items[*].executed === false`.
*
* Авторизация — кооператив (`require_auth(coopname)`); C++ проверяет
* статус проекта (AUTHORIZED) и авторизацию `signer` для
* `items[item_index].braname`.
*/
execWroff(data: MarketContract.Actions.ExecWroff.IExecWroff): Promise<TransactResult>;
/**
* Story 8.4: backend выносит проект списания на повестку совета через
* canonical `soviet::createagenda`. Statement — подписанное Заявление
* председателя (registry 1106); type = `mktwroff` (см. consts
* `_marketplace_writeoff_action`); callback_contract = `marketplace`;
* confirm_callback = `onmktwoauth`; decline_callback = `onmktwodecl`.
*
* Авторизация — кооператив (`require_auth(coopname)`); soviet::create
* проверяет, что `marketplace` ∈ contracts_whitelist.
*/
createWriteoffAgenda(input: {
coopname: string;
username: string;
proposal_hash: string;
statement: unknown;
meta: string;
}): Promise<TransactResult>;
}
export const MARKETPLACE_CANONICAL_BLOCKCHAIN_PORT = Symbol('MARKETPLACE_CANONICAL_BLOCKCHAIN_PORT');
@@ -19,6 +19,7 @@ export interface MarketplaceInventoryCreateInput {
orderer_account_snapshot: string;
labeled_at: Date;
labeled_by_operator_account: string;
expiry_date?: Date | null;
}
export interface MarketplaceInventoryListFilter {
@@ -0,0 +1,113 @@
import type { PaginationInputDTO } from '~/application/common/dto/pagination.dto';
import type { MarketplaceWriteoffProposalDomainEntity } from '../entities/marketplace-writeoff-proposal.entity';
import type {
MarketplaceWriteoffProposalDecisionEntry,
MarketplaceWriteoffProposalItem,
MarketplaceWriteoffProposalStatus,
MarketplaceWriteoffProposalTrigger,
} from '../entities/marketplace-writeoff-proposal.types';
export const MARKETPLACE_WRITEOFF_PROPOSAL_REPOSITORY = Symbol(
'MARKETPLACE_WRITEOFF_PROPOSAL_REPOSITORY'
);
export interface MarketplaceWriteoffProposalCreateInput {
coopname: string;
trigger: MarketplaceWriteoffProposalTrigger;
cycle_started_at: Date;
proposed_by_account: string | null;
items: MarketplaceWriteoffProposalItem[];
total_amount: string;
}
export interface MarketplaceWriteoffProposalListFilter {
coopname: string;
statuses?: MarketplaceWriteoffProposalStatus[];
}
export interface MarketplaceWriteoffProposalDomainRepository {
create(
input: MarketplaceWriteoffProposalCreateInput
): Promise<MarketplaceWriteoffProposalDomainEntity>;
findById(id: string): Promise<MarketplaceWriteoffProposalDomainEntity | null>;
findByHash(
coopname: string,
proposal_hash: string
): Promise<MarketplaceWriteoffProposalDomainEntity | null>;
/** Один черновик в работе у coopname (DRAFT). */
findOpenDraft(coopname: string): Promise<MarketplaceWriteoffProposalDomainEntity | null>;
/**
* Активный (не финальный) проект, отправленный в совет: статусы
* ON_AGENDA, AUTHORIZED или EXECUTING. Гарантирует невозможность вынести
* второй проект пока предыдущий не закрыт.
*/
findOpenInCouncil(
coopname: string
): Promise<MarketplaceWriteoffProposalDomainEntity | null>;
list(
filter: MarketplaceWriteoffProposalListFilter,
pagination?: PaginationInputDTO
): Promise<{ items: MarketplaceWriteoffProposalDomainEntity[]; total: number }>;
updateDraftItems(
id: string,
items: MarketplaceWriteoffProposalItem[],
total_amount: string,
log: MarketplaceWriteoffProposalDecisionEntry
): Promise<MarketplaceWriteoffProposalDomainEntity>;
submitToCouncil(
id: string,
patch: {
proposal_hash: string;
statement_doc: unknown;
decision_id: number | null;
submitted_at: Date;
proposed_by_account: string;
log: MarketplaceWriteoffProposalDecisionEntry;
}
): Promise<MarketplaceWriteoffProposalDomainEntity>;
markAuthorized(
id: string,
patch: {
protocol_doc: unknown;
authorized_at: Date;
decided_by_account: string | null;
log: MarketplaceWriteoffProposalDecisionEntry;
}
): Promise<MarketplaceWriteoffProposalDomainEntity>;
markExecuting(
id: string,
log: MarketplaceWriteoffProposalDecisionEntry
): Promise<MarketplaceWriteoffProposalDomainEntity>;
markItemExecuted(
id: string,
item_index: number,
log: MarketplaceWriteoffProposalDecisionEntry
): Promise<MarketplaceWriteoffProposalDomainEntity>;
markFullyExecuted(
id: string,
log: MarketplaceWriteoffProposalDecisionEntry
): Promise<MarketplaceWriteoffProposalDomainEntity>;
markRejected(
id: string,
patch: {
reject_reason: string;
rejected_at: Date;
decided_by_account: string | null;
log: MarketplaceWriteoffProposalDecisionEntry;
}
): Promise<MarketplaceWriteoffProposalDomainEntity>;
cancelDraft(id: string): Promise<void>;
}
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { MarketContract } from 'cooptypes';
import { MarketContract, SovietContract } from 'cooptypes';
import type { TransactResult } from '@wharfkit/session';
import { BlockchainService } from '~/infrastructure/blockchain/blockchain.service';
import {
@@ -329,4 +329,73 @@ export class MarketplaceCanonicalBlockchainAdapter implements MarketplaceCanonic
data,
});
}
// ── Эпик 8 / p.mkt.wroff ───────────────────────────────────────────
async propWroff(data: MarketContract.Actions.PropWroff.IPropWroff): Promise<TransactResult> {
const wif = await this.vaultDomainService.getWif(data.coopname);
if (!wif) {
throw new HttpApiError(
httpStatus.BAD_GATEWAY,
'Не найден приватный ключ кооператива для submit propwroff'
);
}
this.blockchainService.initialize(data.coopname, wif);
return await this.blockchainService.transact({
account: MarketContract.contractName.production,
name: MarketContract.Actions.PropWroff.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data,
});
}
async execWroff(data: MarketContract.Actions.ExecWroff.IExecWroff): Promise<TransactResult> {
const wif = await this.vaultDomainService.getWif(data.coopname);
if (!wif) {
throw new HttpApiError(
httpStatus.BAD_GATEWAY,
'Не найден приватный ключ кооператива для submit execwroff'
);
}
this.blockchainService.initialize(data.coopname, wif);
return await this.blockchainService.transact({
account: MarketContract.contractName.production,
name: MarketContract.Actions.ExecWroff.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data,
});
}
async createWriteoffAgenda(input: {
coopname: string;
username: string;
proposal_hash: string;
statement: unknown;
meta: string;
}): Promise<TransactResult> {
const wif = await this.vaultDomainService.getWif(input.coopname);
if (!wif) {
throw new HttpApiError(
httpStatus.BAD_GATEWAY,
'Не найден приватный ключ кооператива для submit createagenda(mktwroff)'
);
}
this.blockchainService.initialize(input.coopname, wif);
return await this.blockchainService.transact({
account: SovietContract.contractName.production,
name: SovietContract.Actions.Decisions.CreateAgenda.actionName,
authorization: [{ actor: input.coopname, permission: 'active' }],
data: {
coopname: input.coopname,
username: input.username,
type: 'mktwroff',
hash: input.proposal_hash,
callback_contract: MarketContract.contractName.production,
confirm_callback: 'onmktwoauth',
decline_callback: 'onmktwodecl',
statement: input.statement,
meta: input.meta,
},
});
}
}
@@ -33,6 +33,7 @@ export class MarketplaceInventoryRepositoryAdapter implements MarketplaceInvento
orderer_account_snapshot: input.orderer_account_snapshot,
labeled_at: input.labeled_at,
labeled_by_operator_account: input.labeled_by_operator_account,
expiry_date: input.expiry_date ?? null,
});
const saved = await this.repo.save(row);
return this.mapper.toDomain(saved);
@@ -0,0 +1,256 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import type { PaginationInputDTO } from '~/application/common/dto/pagination.dto';
import { MarketplaceWriteoffProposalDomainEntity } from '../../domain/entities/marketplace-writeoff-proposal.entity';
import {
MarketplaceWriteoffProposalStatuses,
type MarketplaceWriteoffProposalDecisionEntry,
type MarketplaceWriteoffProposalItem,
} from '../../domain/entities/marketplace-writeoff-proposal.types';
import type {
MarketplaceWriteoffProposalCreateInput,
MarketplaceWriteoffProposalDomainRepository,
MarketplaceWriteoffProposalListFilter,
} from '../../domain/repositories/marketplace-writeoff-proposal.repository';
import { MarketplaceWriteoffProposalEntity } from '../entities/marketplace-writeoff-proposal.entity';
import { MarketplaceWriteoffProposalMapper } from '../mappers/marketplace-writeoff-proposal.mapper';
@Injectable()
export class MarketplaceWriteoffProposalRepositoryAdapter
implements MarketplaceWriteoffProposalDomainRepository
{
constructor(
@InjectRepository(MarketplaceWriteoffProposalEntity, 'marketplace')
private readonly repo: Repository<MarketplaceWriteoffProposalEntity>,
private readonly mapper: MarketplaceWriteoffProposalMapper
) {}
async create(
input: MarketplaceWriteoffProposalCreateInput
): Promise<MarketplaceWriteoffProposalDomainEntity> {
const row = this.repo.create({
coopname: input.coopname,
trigger: input.trigger,
status: MarketplaceWriteoffProposalStatuses.DRAFT,
cycle_started_at: input.cycle_started_at,
proposal_hash: '',
decision_id: null,
proposed_by_account: input.proposed_by_account,
decided_by_account: null,
items: input.items,
total_amount: input.total_amount,
protocol_doc: null,
statement_doc: null,
reject_reason: null,
decision_log: [
{
at: new Date().toISOString(),
actor: input.proposed_by_account ?? 'system',
action: 'draft_created',
payload: { trigger: input.trigger, items_count: input.items.length },
},
],
submitted_at: null,
authorized_at: null,
executed_at: null,
rejected_at: null,
});
const saved = await this.repo.save(row);
return this.mapper.toDomain(saved);
}
async findById(id: string): Promise<MarketplaceWriteoffProposalDomainEntity | null> {
const row = await this.repo.findOne({ where: { id } });
return row ? this.mapper.toDomain(row) : null;
}
async findByHash(
coopname: string,
proposal_hash: string
): Promise<MarketplaceWriteoffProposalDomainEntity | null> {
if (!proposal_hash) return null;
const row = await this.repo.findOne({ where: { coopname, proposal_hash } });
return row ? this.mapper.toDomain(row) : null;
}
async findOpenDraft(
coopname: string
): Promise<MarketplaceWriteoffProposalDomainEntity | null> {
const row = await this.repo.findOne({
where: { coopname, status: MarketplaceWriteoffProposalStatuses.DRAFT },
});
return row ? this.mapper.toDomain(row) : null;
}
async findOpenInCouncil(
coopname: string
): Promise<MarketplaceWriteoffProposalDomainEntity | null> {
const row = await this.repo.findOne({
where: {
coopname,
status: In([
MarketplaceWriteoffProposalStatuses.ON_AGENDA,
MarketplaceWriteoffProposalStatuses.AUTHORIZED,
MarketplaceWriteoffProposalStatuses.EXECUTING,
]),
},
});
return row ? this.mapper.toDomain(row) : null;
}
async list(
filter: MarketplaceWriteoffProposalListFilter,
pagination?: PaginationInputDTO
): Promise<{ items: MarketplaceWriteoffProposalDomainEntity[]; total: number }> {
const qb = this.repo
.createQueryBuilder('p')
.where('p.coopname = :coopname', { coopname: filter.coopname });
if (filter.statuses && filter.statuses.length > 0) {
qb.andWhere('p.status IN (:...statuses)', { statuses: filter.statuses });
}
const page = pagination?.page ?? 1;
const limit = pagination?.limit ?? 50;
const skip = (page - 1) * limit;
qb.orderBy('p.created_at', 'DESC').skip(skip).take(limit);
const [rows, total] = await qb.getManyAndCount();
return { items: rows.map((r) => this.mapper.toDomain(r)), total };
}
async updateDraftItems(
id: string,
items: MarketplaceWriteoffProposalItem[],
total_amount: string,
log: MarketplaceWriteoffProposalDecisionEntry
): Promise<MarketplaceWriteoffProposalDomainEntity> {
const row = await this.repo.findOneOrFail({ where: { id } });
if (row.status !== MarketplaceWriteoffProposalStatuses.DRAFT) {
throw new Error('Редактировать можно только проект в статусе DRAFT');
}
row.items = items;
row.total_amount = total_amount;
row.decision_log = [...(row.decision_log ?? []), log];
const saved = await this.repo.save(row);
return this.mapper.toDomain(saved);
}
async submitToCouncil(
id: string,
patch: {
proposal_hash: string;
statement_doc: unknown;
decision_id: number | null;
submitted_at: Date;
proposed_by_account: string;
log: MarketplaceWriteoffProposalDecisionEntry;
}
): Promise<MarketplaceWriteoffProposalDomainEntity> {
const row = await this.repo.findOneOrFail({ where: { id } });
if (row.status !== MarketplaceWriteoffProposalStatuses.DRAFT) {
throw new Error('Передать в совет можно только проект в статусе DRAFT');
}
row.status = MarketplaceWriteoffProposalStatuses.ON_AGENDA;
row.proposal_hash = patch.proposal_hash;
row.statement_doc = patch.statement_doc;
row.decision_id = patch.decision_id !== null ? String(patch.decision_id) : null;
row.submitted_at = patch.submitted_at;
row.proposed_by_account = patch.proposed_by_account;
row.decision_log = [...(row.decision_log ?? []), patch.log];
const saved = await this.repo.save(row);
return this.mapper.toDomain(saved);
}
async markAuthorized(
id: string,
patch: {
protocol_doc: unknown;
authorized_at: Date;
decided_by_account: string | null;
log: MarketplaceWriteoffProposalDecisionEntry;
}
): Promise<MarketplaceWriteoffProposalDomainEntity> {
const row = await this.repo.findOneOrFail({ where: { id } });
if (row.status !== MarketplaceWriteoffProposalStatuses.ON_AGENDA) {
throw new Error('Авторизовать можно только проект, отправленный в совет (ON_AGENDA)');
}
row.status = MarketplaceWriteoffProposalStatuses.AUTHORIZED;
row.protocol_doc = patch.protocol_doc;
row.authorized_at = patch.authorized_at;
row.decided_by_account = patch.decided_by_account;
row.decision_log = [...(row.decision_log ?? []), patch.log];
const saved = await this.repo.save(row);
return this.mapper.toDomain(saved);
}
async markExecuting(
id: string,
log: MarketplaceWriteoffProposalDecisionEntry
): Promise<MarketplaceWriteoffProposalDomainEntity> {
const row = await this.repo.findOneOrFail({ where: { id } });
if (row.status !== MarketplaceWriteoffProposalStatuses.AUTHORIZED) {
throw new Error('Запустить исполнение можно только из статуса AUTHORIZED');
}
row.status = MarketplaceWriteoffProposalStatuses.EXECUTING;
row.decision_log = [...(row.decision_log ?? []), log];
const saved = await this.repo.save(row);
return this.mapper.toDomain(saved);
}
async markItemExecuted(
id: string,
item_index: number,
log: MarketplaceWriteoffProposalDecisionEntry
): Promise<MarketplaceWriteoffProposalDomainEntity> {
const row = await this.repo.findOneOrFail({ where: { id } });
if (item_index < 0 || item_index >= row.items.length) {
throw new Error('Указана несуществующая позиция в проекте списания');
}
const items = row.items.map((it, idx) =>
idx === item_index ? { ...it, executed: true } : it
);
row.items = items;
row.decision_log = [...(row.decision_log ?? []), log];
const saved = await this.repo.save(row);
return this.mapper.toDomain(saved);
}
async markFullyExecuted(
id: string,
log: MarketplaceWriteoffProposalDecisionEntry
): Promise<MarketplaceWriteoffProposalDomainEntity> {
const row = await this.repo.findOneOrFail({ where: { id } });
row.status = MarketplaceWriteoffProposalStatuses.EXECUTED;
row.executed_at = new Date();
row.decision_log = [...(row.decision_log ?? []), log];
const saved = await this.repo.save(row);
return this.mapper.toDomain(saved);
}
async markRejected(
id: string,
patch: {
reject_reason: string;
rejected_at: Date;
decided_by_account: string | null;
log: MarketplaceWriteoffProposalDecisionEntry;
}
): Promise<MarketplaceWriteoffProposalDomainEntity> {
const row = await this.repo.findOneOrFail({ where: { id } });
row.status = MarketplaceWriteoffProposalStatuses.REJECTED;
row.reject_reason = patch.reject_reason;
row.rejected_at = patch.rejected_at;
row.decided_by_account = patch.decided_by_account;
row.decision_log = [...(row.decision_log ?? []), patch.log];
const saved = await this.repo.save(row);
return this.mapper.toDomain(saved);
}
async cancelDraft(id: string): Promise<void> {
const row = await this.repo.findOne({ where: { id } });
if (!row) throw new NotFoundException('Проект списания не найден');
if (row.status !== MarketplaceWriteoffProposalStatuses.DRAFT) {
throw new Error('Удалить можно только проект в статусе DRAFT');
}
await this.repo.delete({ id });
}
}
@@ -26,6 +26,8 @@ import type {
@Index(['coopname', 'order_id', 'status'])
@Index(['coopname', 'braname', 'status'])
@Index(['coopname', 'shipment_id'])
// Story 8.3: cron сканирует позиции LABELED с приближающимся expiry_date.
@Index('IDX_marketplace_inventory_expiry_scan', ['coopname', 'status', 'expiry_date'])
export class MarketplaceInventoryEntity {
@PrimaryGeneratedColumn('uuid')
public id!: string;
@@ -66,6 +68,13 @@ export class MarketplaceInventoryEntity {
@Column({ type: 'varchar', length: 13 })
public labeled_by_operator_account!: string;
/**
* Story 8.3: срок годности (labeled_at + Offer.warranty_days * 86400);
* nullable для бессрочных Offer'ов и legacy-записей без warranty_days.
*/
@Column({ type: 'timestamptz', nullable: true })
public expiry_date!: Date | null;
@CreateDateColumn({ type: 'timestamptz' })
public created_at!: Date;
@@ -0,0 +1,105 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import type {
MarketplaceWriteoffProposalDecisionEntry,
MarketplaceWriteoffProposalItem,
MarketplaceWriteoffProposalStatus,
MarketplaceWriteoffProposalTrigger,
} from '../../domain/entities/marketplace-writeoff-proposal.types';
/**
* Story 8.1 (Эпик 8): TypeORM-сущность проекта решения совета о списании
* скоропорта. Связка с on-chain wroffprops — по `proposal_hash` (заполняется
* при переводе DRAFT → ON_AGENDA). Связка с soviet.decisions — по
* `decision_id`.
*
* Партиционно-уникальный индекс по DRAFT-статусу на (coopname) запрещает
* одновременно держать два черновика; аналогично для активного состояния
* в совете — серия (ON_AGENDA / AUTHORIZED / EXECUTING) ограничена одним
* проектом per кооператива (защита от параллельного крон+manual).
*/
@Entity({ name: 'marketplace_writeoff_proposal' })
@Index(['coopname', 'status'])
@Index('UQ_marketplace_writeoff_proposal_hash', ['coopname', 'proposal_hash'], {
unique: true,
where: "proposal_hash <> ''",
})
@Index('UQ_marketplace_writeoff_proposal_draft', ['coopname'], {
unique: true,
where: "status = 'DRAFT'",
})
@Index('UQ_marketplace_writeoff_proposal_active', ['coopname'], {
unique: true,
where: "status IN ('ON_AGENDA','AUTHORIZED','EXECUTING')",
})
export class MarketplaceWriteoffProposalEntity {
@PrimaryGeneratedColumn('uuid')
public id!: string;
@Column({ type: 'varchar', length: 13 })
public coopname!: string;
@Column({ type: 'varchar', length: 16 })
public trigger!: MarketplaceWriteoffProposalTrigger;
@Column({ type: 'varchar', length: 16 })
public status!: MarketplaceWriteoffProposalStatus;
@Column({ type: 'timestamptz' })
public cycle_started_at!: Date;
/** Заполняется при `submitToCouncil`; до этого — пустая строка. */
@Column({ type: 'varchar', length: 64, default: '' })
public proposal_hash!: string;
@Column({ type: 'bigint', nullable: true })
public decision_id!: string | null;
@Column({ type: 'varchar', length: 13, nullable: true })
public proposed_by_account!: string | null;
@Column({ type: 'varchar', length: 13, nullable: true })
public decided_by_account!: string | null;
@Column({ type: 'jsonb' })
public items!: MarketplaceWriteoffProposalItem[];
@Column({ type: 'varchar', length: 48 })
public total_amount!: string;
@Column({ type: 'jsonb', nullable: true })
public protocol_doc!: unknown | null;
@Column({ type: 'jsonb', nullable: true })
public statement_doc!: unknown | null;
@Column({ type: 'text', nullable: true })
public reject_reason!: string | null;
@Column({ type: 'jsonb', default: () => "'[]'::jsonb" })
public decision_log!: MarketplaceWriteoffProposalDecisionEntry[];
@Column({ type: 'timestamptz', nullable: true })
public submitted_at!: Date | null;
@Column({ type: 'timestamptz', nullable: true })
public authorized_at!: Date | null;
@Column({ type: 'timestamptz', nullable: true })
public executed_at!: Date | null;
@Column({ type: 'timestamptz', nullable: true })
public rejected_at!: Date | null;
@CreateDateColumn({ type: 'timestamptz' })
public created_at!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
public updated_at!: Date;
}
@@ -19,6 +19,7 @@ export class MarketplaceInventoryMapper {
orderer_account_snapshot: row.orderer_account_snapshot,
labeled_at: row.labeled_at,
labeled_by_operator_account: row.labeled_by_operator_account,
expiry_date: row.expiry_date,
created_at: row.created_at,
updated_at: row.updated_at,
});
@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { MarketplaceWriteoffProposalDomainEntity } from '../../domain/entities/marketplace-writeoff-proposal.entity';
import { MarketplaceWriteoffProposalEntity } from '../entities/marketplace-writeoff-proposal.entity';
@Injectable()
export class MarketplaceWriteoffProposalMapper {
toDomain(row: MarketplaceWriteoffProposalEntity): MarketplaceWriteoffProposalDomainEntity {
return new MarketplaceWriteoffProposalDomainEntity({
id: row.id,
coopname: row.coopname,
trigger: row.trigger,
status: row.status,
cycle_started_at: row.cycle_started_at,
proposal_hash: row.proposal_hash ?? '',
decision_id: row.decision_id !== null ? Number(row.decision_id) : null,
proposed_by_account: row.proposed_by_account,
decided_by_account: row.decided_by_account,
items: row.items ?? [],
total_amount: row.total_amount,
protocol_doc: row.protocol_doc ?? null,
statement_doc: row.statement_doc ?? null,
reject_reason: row.reject_reason,
decision_log: row.decision_log ?? [],
submitted_at: row.submitted_at,
authorized_at: row.authorized_at,
executed_at: row.executed_at,
rejected_at: row.rejected_at,
created_at: row.created_at,
updated_at: row.updated_at,
});
}
}
@@ -28,6 +28,7 @@ import { MarketplaceAplReceptionEntity } from './entities/marketplace-apl-recept
import { MarketplaceOutgoingPaymentRequestEntity } from './entities/marketplace-outgoing-payment-request.entity';
import { MarketplaceTtnDocumentEntity } from './entities/marketplace-ttn-document.entity';
import { MarketplaceReturnClaimEntity } from './entities/marketplace-return-claim.entity';
import { MarketplaceWriteoffProposalEntity } from './entities/marketplace-writeoff-proposal.entity';
// Repository adapters
import { CategoryRepositoryAdapter } from './adapters/category-repository.adapter';
@@ -54,6 +55,7 @@ import { MarketplaceAplReceptionRepositoryAdapter } from './adapters/marketplace
import { MarketplaceOutgoingPaymentRequestRepositoryAdapter } from './adapters/marketplace-outgoing-payment-request-repository.adapter';
import { MarketplaceTtnDocumentRepositoryAdapter } from './adapters/marketplace-ttn-document-repository.adapter';
import { MarketplaceReturnClaimRepositoryAdapter } from './adapters/marketplace-return-claim-repository.adapter';
import { MarketplaceWriteoffProposalRepositoryAdapter } from './adapters/marketplace-writeoff-proposal-repository.adapter';
// Mappers
import { MarketplaceVitrineMapper } from './mappers/marketplace-vitrine.mapper';
@@ -71,6 +73,7 @@ import { MarketplaceAplReceptionMapper } from './mappers/marketplace-apl-recepti
import { MarketplaceOutgoingPaymentRequestMapper } from './mappers/marketplace-outgoing-payment-request.mapper';
import { MarketplaceTtnDocumentMapper } from './mappers/marketplace-ttn-document.mapper';
import { MarketplaceReturnClaimMapper } from './mappers/marketplace-return-claim.mapper';
import { MarketplaceWriteoffProposalMapper } from './mappers/marketplace-writeoff-proposal.mapper';
// Repository tokens
import { CATEGORY_DOMAIN_REPOSITORY } from '../domain/repositories/category-domain.repository';
@@ -97,6 +100,7 @@ import { MARKETPLACE_APL_RECEPTION_REPOSITORY } from '../domain/repositories/mar
import { MARKETPLACE_OUTGOING_PAYMENT_REQUEST_REPOSITORY } from '../domain/repositories/marketplace-outgoing-payment-request.repository';
import { MARKETPLACE_TTN_DOCUMENT_REPOSITORY } from '../domain/repositories/marketplace-ttn-document.repository';
import { MARKETPLACE_RETURN_CLAIM_REPOSITORY } from '../domain/repositories/marketplace-return-claim.repository';
import { MARKETPLACE_WRITEOFF_PROPOSAL_REPOSITORY } from '../domain/repositories/marketplace-writeoff-proposal.repository';
@Module({
imports: [
@@ -135,6 +139,7 @@ import { MARKETPLACE_RETURN_CLAIM_REPOSITORY } from '../domain/repositories/mark
MarketplaceOutgoingPaymentRequestEntity,
MarketplaceTtnDocumentEntity,
MarketplaceReturnClaimEntity,
MarketplaceWriteoffProposalEntity,
],
synchronize: true,
logging: false,
@@ -167,6 +172,7 @@ import { MARKETPLACE_RETURN_CLAIM_REPOSITORY } from '../domain/repositories/mark
MarketplaceOutgoingPaymentRequestEntity,
MarketplaceTtnDocumentEntity,
MarketplaceReturnClaimEntity,
MarketplaceWriteoffProposalEntity,
],
'marketplace'
), // Указываем имя подключения
@@ -294,6 +300,12 @@ import { MARKETPLACE_RETURN_CLAIM_REPOSITORY } from '../domain/repositories/mark
provide: MARKETPLACE_RETURN_CLAIM_REPOSITORY,
useClass: MarketplaceReturnClaimRepositoryAdapter,
},
// Эпик 8 — проекты решения совета о списании скоропорта
MarketplaceWriteoffProposalMapper,
{
provide: MARKETPLACE_WRITEOFF_PROPOSAL_REPOSITORY,
useClass: MarketplaceWriteoffProposalRepositoryAdapter,
},
],
exports: [
CATEGORY_DOMAIN_REPOSITORY,
@@ -327,6 +339,8 @@ import { MARKETPLACE_RETURN_CLAIM_REPOSITORY } from '../domain/repositories/mark
MARKETPLACE_TTN_DOCUMENT_REPOSITORY,
// Эпик 7 — гарантийный возврат
MARKETPLACE_RETURN_CLAIM_REPOSITORY,
// Эпик 8 — списание скоропорта
MARKETPLACE_WRITEOFF_PROPOSAL_REPOSITORY,
],
})
export class MarketplaceInfrastructureModule {}
@@ -0,0 +1,33 @@
import type { IExtensionSchemaMigration } from '~/domain/extension/services/extension-schema-migration.service';
import { defaultConfig, IConfig } from '../types';
/**
* Bootstrap-миграция v6 расширения `market` — Эпик 8 (списание скоропорта).
*
* Что добавляется:
* - таблица `marketplace_writeoff_proposal` (DRAFT → ON_AGENDA → AUTHORIZED →
* EXECUTING → EXECUTED / REJECTED) — Story 8.1;
* - колонка `marketplace_inventory.expiry_date` (Story 8.3) — TypeORM
* `synchronize:true` создаёт её ADD COLUMN nullable, backfill не нужен;
* - блок настроек `IConfig.writeoff` (`auto_proposal_enabled`,
* `expiry_grace_days`) — bootstrap-merge со значениями по умолчанию.
*
* DDL выполняется `synchronize:true` по декларации сущностей; миграция нужна
* для bump schema_version и для гарантии, что текущая инсталляция понимает
* новый блок конфигурации.
*/
export const marketplaceBootstrapV6Migration: IExtensionSchemaMigration<Partial<IConfig>, IConfig> = {
extensionName: 'market',
version: 6,
migrate(oldConfig, def) {
const baseline = { ...def, ...oldConfig };
return {
...baseline,
writeoff: {
...defaultConfig.writeoff,
...(baseline.writeoff ?? {}),
},
};
},
};
@@ -18,6 +18,23 @@ export interface ICoopAcceptanceConfig {
accepted_by_board_decision_id: string;
}
/**
* Story 8.3 (Эпик 8): настройки авто-проекта списания скоропорта.
*
* `auto_proposal_enabled` — если true, ежемесячный крон собирает позиции
* `MarketplaceInventory.expiry_date <= now + expiry_grace_days` в DRAFT-
* проект и шлёт нотификацию председателю на ревью. Если false, крон
* только пушит напоминание; председатель формирует корзину вручную.
*
* `expiry_grace_days` — окно опережения: за сколько дней до фактического
* `expiry_date` позиция должна попадать в кандидаты на списание (например,
* 7 — отбирать позиции, у которых до истечения срока меньше недели).
*/
export interface IWriteoffConfig {
auto_proposal_enabled: boolean;
expiry_grace_days: number;
}
// Конфигурация для расширения marketplace
export interface IConfig {
enabled: boolean;
@@ -25,6 +42,8 @@ export interface IConfig {
debug: boolean;
// Story 1.9: статус принятия положения ЦПП Советом кооператива.
coopAcceptance: ICoopAcceptanceConfig;
// Story 8.3 (Эпик 8): настройки крона списания скоропорта.
writeoff: IWriteoffConfig;
}
// Дефолтные параметры конфигурации
@@ -38,6 +57,10 @@ export const defaultConfig: IConfig = {
accepted_at: '',
accepted_by_board_decision_id: '',
},
writeoff: {
auto_proposal_enabled: false,
expiry_grace_days: 7,
},
};
// Схема валидации конфигурации
@@ -58,4 +81,10 @@ export const Schema = z.object({
accepted_at: '',
accepted_by_board_decision_id: '',
}),
writeoff: z
.object({
auto_proposal_enabled: z.boolean().default(false),
expiry_grace_days: z.number().int().min(0).default(7),
})
.default({ auto_proposal_enabled: false, expiry_grace_days: 7 }),
});
@@ -1,17 +0,0 @@
import * as Permissions from '../../../common/permissions'
import type * as Marketplace from '../../../interfaces/marketplace'
import { Actors } from '../../../common'
/**
* Совет отклоняет проект списания скоропорта целиком (Story 8.3, p.mkt.wroff).
* Без ledger2-операций. Status PROPOSED → REJECTED. Отклонить можно только если
* ни одна позиция ещё не исполнена через execwroff.
*/
export const authorizations = [{ permissions: [Permissions.active], actor: Actors._chairman }] as const
export const actionName = 'declwroff'
/**
* @interface
*/
export type IDeclWroff = Marketplace.IDeclWroff
@@ -3,13 +3,14 @@ import type * as Marketplace from '../../../interfaces/marketplace'
import { Actors } from '../../../common'
/**
* Совет исполняет одну позицию проекта списания (Story 8.3, p.mkt.wroff).
* Per-item композитная пара o.mkt.wroff + o.mkt.wroff2 (атомарно). Backend
* проходит циклом по неисполненным items. Когда все items.executed = true —
* статус автоматически PROPOSED → EXECUTED. Подписант авторизован для
* items[item_index].braname через Branch::is_user_authorized.
* Backend исполняет одну позицию авторизованного советом проекта списания
* (Story 8.4, p.mkt.wroff). Per-item композитная пара o.mkt.wroff +
* o.mkt.wroff2 (атомарно). Backend проходит циклом по неисполненным items;
* последняя позиция переводит статус AUTHORIZED → EXECUTED. Авторизация —
* `coopname`; signer должен быть авторизован для items[item_index].braname
* через Branch::is_user_authorized.
*/
export const authorizations = [{ permissions: [Permissions.active], actor: Actors._chairman }] as const
export const authorizations = [{ permissions: [Permissions.active], actor: Actors._coopname }] as const
export const actionName = 'execwroff'
@@ -104,24 +104,34 @@ export * as AccRetrn from './accRetrn'
*/
export * as RejRetrn from './rejRetrn'
// ── p.mkt.wroff (3 actions) — Stories Эпика 8 ──────────────────────────
// ── p.mkt.wroff (4 actions) — Stories Эпика 8 ──────────────────────────
// Канонический паттерн «решение совета»: propWroff (admin) +
// soviet::createagenda(type=mktwroff) → onMktWoAuth / onMktWoDecl (callback
// от soviet) → execWroff per-item (backend).
/**
* Backend / админ выносит проект списания на повестку совета (Story 8.1).
* Backend выносит проект списания на повестку совета (Story 8.1).
*/
export * as PropWroff from './propWroff'
/**
* Совет исполняет одну позицию проекта списания (Story 8.3).
* Callback от `soviet::exec` после авторизации Протокола (Story 8.4).
* PROPOSED → AUTHORIZED, кладёт authorization2 в wroffprops.protocol.
*/
export * as OnMktWoAuth from './onMktWoAuth'
/**
* Callback от `soviet::cancelexprd` или decline-эффекта (Story 8.4).
* PROPOSED → REJECTED, сохраняет reason в wroffprops.reject_reason.
*/
export * as OnMktWoDecl from './onMktWoDecl'
/**
* Backend исполняет одну позицию авторизованного проекта (Story 8.4).
* Per-item: o.mkt.wroff + o.mkt.wroff2 (атомарно).
*/
export * as ExecWroff from './execWroff'
/**
* Совет отклоняет проект списания целиком (Story 8.3).
*/
export * as DeclWroff from './declWroff'
// ── service ────────────────────────────────────────────────────────────
/**
@@ -0,0 +1,17 @@
import * as Permissions from '../../../common/permissions'
import type * as Marketplace from '../../../interfaces/marketplace'
import { Actors } from '../../../common'
/**
* Callback от `soviet::exec` после авторизации Протокола Совета о списании
* скоропорта (Story 8.4, p.mkt.wroff). Сигнатура совпадает с
* AUTHORIZE_CALLBACK_SIGNATURE из soviet.hpp. require_auth(_soviet).
*/
export const authorizations = [{ permissions: [Permissions.active], actor: Actors._contract }] as const
export const actionName = 'onmktwoauth'
/**
* @interface
*/
export type IOnMktWoAuth = Marketplace.IOnMktWoAuth
@@ -0,0 +1,17 @@
import * as Permissions from '../../../common/permissions'
import type * as Marketplace from '../../../interfaces/marketplace'
import { Actors } from '../../../common'
/**
* Callback от `soviet::cancelexprd` или decline-эффекта в soviet (Story 8.4).
* Переводит проект в REJECTED, сохраняет reason. Сигнатура совпадает с
* DECLINE_CALLBACK_SIGNATURE из soviet.hpp. require_auth(_soviet).
*/
export const authorizations = [{ permissions: [Permissions.active], actor: Actors._contract }] as const
export const actionName = 'onmktwodecl'
/**
* @interface
*/
export type IOnMktWoDecl = Marketplace.IOnMktWoDecl
@@ -0,0 +1,220 @@
import type { IDecisionData, IGenerate, IMetaDocument } from '../../document'
import type { ICooperativeData, IVars } from '../../model'
export const registry_id = 1105
/**
* Протокол совета о списании скоропорта со складов кооперативных
* участков — Эпик 8 ЦПП «Стол заказов» (членские взносы).
*
* Документ-Решение совета по Заявлению о списании (registry 1106).
* Подписывается председателем после голосования через стандартный
* sov.decision-flow (`soviet::authorize` с этим документом в качестве
* `authorization`). `soviet::exec` запускает callback
* `marketplace::onmktwoauth(coopname, hash, authorization)`, который
* сохраняет этот Протокол в `wroffprops.protocol` и переводит проект в
* AUTHORIZED. Backend циклом по items[] вызывает `marketplace::execwroff`
* per-item.
*/
export interface Action extends IGenerate {
registry_id: number
/** ID решения в soviet.decisions (auto-increment на цепи). */
decision_id: number
/** Канонический proposal_hash on-chain. */
proposal_hash: string
/** Идентификатор расчётного цикла списания (ISO-дата начала цикла). */
cycle_started_at: string
/** Σ items.amount — для шапки протокола. */
total_amount: string
/** Число позиций к списанию. */
items_count: number
}
export interface Model {
meta: IMetaDocument
coop: ICooperativeData
vars: IVars
decision: IDecisionData
proposal_hash: string
cycle_started_at: string
total_amount: string
items_count: number
}
export const title = 'Протокол совета о списании скоропорта'
export const description = 'Форма протокола решения совета о списании имущества со складов кооперативных участков по ЦПП «Стол заказов»'
export const context = `<style>
h1 { margin: 0px; text-align: center; }
h3 { margin: 0px; padding-top: 15px; }
.digital-document { padding: 20px; white-space: pre-wrap; }
.subheader { padding-bottom: 20px; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; word-wrap: break-word; overflow-wrap: break-word; }
th { background-color: #f4f4f4; width: 30%; }
</style>
<div class="digital-document">
<h1>{% trans 'protocol_number', decision.id %}</h1>
<p style="text-align:center" class="subheader">{% trans 'council_meeting_name' %} {{ vars.full_abbr_genitive }} "{{ vars.name }}"</p>
<p style="text-align: right">{{ meta.created_at }}, {{ coop.city }}</p>
<table>
<tbody>
<tr>
<th>{% trans 'meeting_format' %}</th>
<td>{% trans 'meeting_format_value' %}</td>
</tr>
<tr>
<th>{% trans 'meeting_place' %}</th>
<td>{{ coop.full_address }}</td>
</tr>
<tr>
<th>{% trans 'meeting_date' %}</th>
<td>{{ decision.date }}</td>
</tr>
<tr>
<th>{% trans 'opening_time' %}</th>
<td>{{ decision.time }}</td>
</tr>
</tbody>
</table>
<h3>{% trans 'council_members' %}</h3>
<table>
<tbody>
{% for member in coop.members %}
<tr>
<th>{% if member.is_chairman %}{% trans 'chairman_of_the_council' %}{% else %}{% trans 'member_of_the_council' %}{% endif %}</th>
<td>{{ member.last_name }} {{ member.first_name }} {{ member.middle_name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h3>{% trans 'meeting_legality' %}</h3>
<p>{% trans 'voting_results', decision.voters_percent %} {% trans 'quorum' %} {% trans 'chairman_of_the_meeting', coop.chairman.last_name, coop.chairman.first_name, coop.chairman.middle_name %}.</p>
<h3>{% trans 'agenda' %}</h3>
<table>
<tbody>
<tr>
<th>№</th>
<td>{% trans 'question' %}</td>
</tr>
<tr>
<th>{% trans 'agenda_item' %}</th>
<td>{% trans 'agenda_subject', items_count, total_amount, cycle_started_at %}</td>
</tr>
</tbody>
</table>
<h3>{% trans 'voting' %}</h3>
<p>{% trans 'vote_results' %}</p>
<table>
<tbody>
<tr>
<th>{% trans 'votes_for' %}</th>
<td>{{ decision.votes_for }}</td>
</tr>
<tr>
<th>{% trans 'votes_against' %}</th>
<td>{{ decision.votes_against }}</td>
</tr>
<tr>
<th>{% trans 'votes_abstained' %}</th>
<td>{{ decision.votes_abstained }}</td>
</tr>
</tbody>
</table>
<h3>{% trans 'decision_made' %}</h3>
<table>
<tbody>
<tr>
<th>№</th>
<td>{% trans 'question' %}</td>
</tr>
<tr>
<th>{% trans 'decision' %}</th>
<td>{% trans 'decision_writeoff_text', items_count, total_amount %}</td>
</tr>
</tbody>
</table>
<p>{% trans 'proposal_ref' %} {{ proposal_hash }}</p>
<hr />
<p>{% trans 'closing_time', decision.time %}</p>
<div class="signature">
<p>{% trans 'signature' %}</p>
<p>{% trans 'chairman' %} {{ coop.chairman.last_name }} {{ coop.chairman.first_name }} {{ coop.chairman.middle_name }}</p>
</div>
</div>
`
export const translations = {
ru: {
meeting_format: 'Форма',
meeting_date: 'Дата',
meeting_place: 'Место',
opening_time: 'Время открытия',
council_members: 'ЧЛЕНЫ СОВЕТА',
voting_results: 'Количество голосов составляет {0}% от общего числа членов Совета.',
meeting_legality: 'СОБРАНИЕ ПРАВОМОЧНО',
chairman_of_the_meeting: 'Председатель собрания совета: {0} {1} {2}',
agenda: 'ПОВЕСТКА ДНЯ',
decision_made: 'РЕШИЛИ',
closing_time: 'Время закрытия собрания совета: {0}.',
protocol_number: 'ПРОТОКОЛ № {0}',
council_meeting_name: 'Собрания Совета',
chairman_of_the_council: 'Председатель совета',
member_of_the_council: 'Член совета',
signature: 'Документ подписан электронной подписью.',
chairman: 'Председатель',
quorum: 'Кворум для решения поставленных на повестку дня вопросов имеется.',
voting: 'ГОЛОСОВАНИЕ',
decision: '1',
question: 'Вопрос',
vote_results: 'По первому вопросу повестки дня проголосовали:',
agenda_item: '1',
votes_for: 'ЗА',
votes_against: 'ПРОТИВ',
votes_abstained: 'ВОЗДЕРЖАЛСЯ',
meeting_format_value: 'заочная',
agenda_subject: 'Утвердить списание {0} позиций имущества со складов кооперативных участков на общую сумму {1} (цикл от {2}) по ЦПП «Стол заказов» — поданное Заявление председателя о списании скоропорта.',
decision_writeoff_text: 'Списать {0} позиций имущества на общую сумму {1} с балансов кооперативных участков через транзит счёта 91 «Прочие доходы и расходы» с закрытием на счёт 86 Целевого финансирования программы. Движений по кошелькам пайщиков не производить.',
proposal_ref: 'Идентификатор проекта списания:',
},
}
export const exampleData = {
meta: { created_at: '07.06.2026 12:00' },
coop: {
city: 'Москва',
full_address: 'Смольная 3-84',
chairman: {
last_name: 'Муравьев',
first_name: 'Алексей',
middle_name: 'Николаевич',
},
short_name: 'ПК "Восход"',
},
vars: {
full_abbr_genitive: 'Потребительского Кооператива',
name: 'ВОСХОД',
},
decision: {
id: '42',
date: '07.06.2026',
time: '12:00',
votes_for: '3',
votes_against: '0',
votes_abstained: '0',
voters_percent: '100',
},
proposal_hash: '0000abcd...',
cycle_started_at: '2026-06-01',
total_amount: '1505.0000 RUB',
items_count: 2,
}
@@ -0,0 +1,182 @@
import type { IGenerate, IMetaDocument } from '../../document'
import type { ICommonProgram, ICommonUser, ICooperativeData, IVars } from '../../model'
export const registry_id = 1106
/**
* Заявление председателя о списании скоропорта со складов кооперативных
* участков — Эпик 8 ЦПП «Стол заказов» (членские взносы).
*
* Подписывается председателем (initiator) и сразу после `marketplace::
* propwroff` отправляется в `soviet::createagenda(type=mktwroff, callback=
* marketplace::onmktwoauth/onmktwodecl, statement=signed_1106)` —
* становится повесткой совета. После голосования совета chairman
* подписывает Протокол списания (registry 1105), `soviet::exec` зовёт
* callback `marketplace::onmktwoauth` и backend циклом запускает
* `marketplace::execwroff` per-item (atomic-пара o.mkt.wroff + o.mkt.wroff2
* через транзит 91).
*/
export interface WriteoffItemAction {
/** Кооперативный участок (склад) источник позиции. */
braname: string
/** Краткое название позиции (название товара или артикул). */
asset_title: string
/** Количество единиц к списанию. */
quantity: string
/** Стоимость списания (4 знака после запятой, валюта `_root_govern_symbol`). */
amount: string
/** Причина списания (просрочка / порча / малооценность). */
reason: string
}
export interface Action extends IGenerate {
registry_id: number
/** Канонический proposal_hash on-chain (якорь к wroffprops.hash). */
proposal_hash: string
/** Идентификатор расчётного цикла списания (ISO-дата начала цикла). */
cycle_started_at: string
/** Позиции к списанию (полная корзина проекта). */
items: WriteoffItemAction[]
/** Σ items.amount — для UI и шапки документа. */
total_amount: string
}
export type Meta = IMetaDocument & Action
export interface WriteoffItemModel {
braname: string
asset_title: string
quantity: string
amount: string
reason: string
}
/**
* Модель данных для PDF-рендера Заявления о списании скоропорта.
* Подписант — председатель (от имени совета).
*/
export interface Model {
meta: IMetaDocument
coop: ICooperativeData
vars: IVars
chairman: ICommonUser
program: ICommonProgram
proposal_hash: string
cycle_started_at: string
items: WriteoffItemModel[]
total_amount: string
}
export const title = 'Заявление о списании скоропорта'
export const description = 'Заявление председателя в совет кооператива о признании списания со складов кооперативных участков скоропортящегося, повреждённого или малооценного имущества по ЦПП «Стол заказов»'
export const context = `<style>
h1 { margin: 0px; text-align: center; }
h3 { margin: 0px; padding-top: 15px; }
.digital-document { padding: 20px; white-space: pre-wrap; }
.subheader { padding-bottom: 20px; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; word-wrap: break-word; overflow-wrap: break-word; }
th { background-color: #f4f4f4; font-weight: bold; }
</style>
<div class="digital-document">
<div style="text-align: right">
<p style="margin: 0">{% trans 'v_soviet' %} {{ vars.full_abbr_genitive }} "{{ vars.name }}"</p>
<p style="margin: 0">{% trans 'from_chairman' %} {{ chairman.full_name_or_short_name }}</p>
</div>
<div style="text-align: center">
<h1>{% trans 'statement_title' %}</h1>
<p class="subheader">{% trans 'statement_subheader', program.name %}</p>
</div>
<p>{% trans 'preamble', program.name, cycle_started_at %}</p>
<table>
<thead>
<tr>
<th>№</th>
<th>{% trans 'col_branch' %}</th>
<th>{% trans 'col_asset' %}</th>
<th>{% trans 'col_quantity' %}</th>
<th>{% trans 'col_amount' %}</th>
<th>{% trans 'col_reason' %}</th>
</tr>
</thead>
<tbody>
{% for it in items %}
<tr>
<td>{{ forloop.counter }}</td>
<td>{{ it.braname }}</td>
<td>{{ it.asset_title }}</td>
<td>{{ it.quantity }}</td>
<td>{{ it.amount }}</td>
<td>{{ it.reason }}</td>
</tr>
{% endfor %}
<tr style="font-weight: bold">
<td colspan="4">{% trans 'total' %}</td>
<td>{{ total_amount }}</td>
<td></td>
</tr>
</tbody>
</table>
<p>{% trans 'ledger_note' %}</p>
<p>{% trans 'signature' %}</p>
<p>{{ chairman.full_name_or_short_name }}</p>
<p>{{ meta.created_at }}</p>
</div>
`
export const translations = {
ru: {
v_soviet: 'В Совет',
from_chairman: 'от Председателя',
statement_title: 'ЗАЯВЛЕНИЕ',
statement_subheader: 'о списании имущества со складов кооперативных участков по Целевой Потребительской Программе «{0}»',
preamble: 'Прошу совет принять решение о списании с балансов кооперативных участков следующих позиций имущества, признанных по итогам цикла «{0}» от {1} непригодными к выдаче пайщикам:',
col_branch: 'КУ',
col_asset: 'Наименование/Артикул',
col_quantity: 'Количество',
col_amount: 'Сумма списания',
col_reason: 'Причина',
total: 'ИТОГО',
ledger_note: 'Списание оформляется через транзит счёта 91 «Прочие доходы и расходы» атомарной парой ledger2-операций o.mkt.wroff (Дт 91 / Кт 10) и o.mkt.wroff2 (Дт 86 / Кт 91), движений по кошелькам пайщиков не происходит.',
signature: 'Подписано электронной подписью.',
},
}
export const exampleData = {
meta: { created_at: '01.06.2026 12:00' },
coop: {
short_name: 'ПК ВОСХОД',
city: 'Москва',
},
vars: {
name: 'ВОСХОД',
full_abbr_genitive: 'Потребительского Кооператива',
},
chairman: { full_name_or_short_name: 'Муравьев Алексей Николаевич' },
program: { name: 'СТОЛ ЗАКАЗОВ' },
proposal_hash: '0000abcd...',
cycle_started_at: '2026-06-01',
items: [
{
braname: 'KU-MOSKVA-1',
asset_title: 'Сахар-песок «Сладкий», 1 кг',
quantity: '12',
amount: '1020.0000',
reason: 'Истёк срок годности',
},
{
braname: 'KU-MOSKVA-2',
asset_title: 'Молоко «Доброе», 1 л',
quantity: '5',
amount: '485.0000',
reason: 'Повреждена упаковка',
},
],
total_amount: '1505.0000',
}
@@ -73,6 +73,8 @@ export * as MarketplaceOffer from './1101.MarketplaceOffer'
export * as MarketplaceAplReception from './1102.MarketplaceAplReception'
export * as MarketplaceTransportNote from './1103.MarketplaceTransportNote'
export * as MarketplaceReturnStatement from './1104.MarketplaceReturnStatement'
export * as MarketplaceWriteoffProtocol from './1105.MarketplaceWriteoffProtocol'
export * as MarketplaceWriteoffStatement from './1106.MarketplaceWriteoffStatement'
export * as SosediAgreement from './699.SosediAgreement'
@@ -186,13 +186,28 @@ export interface IExecWroff {
signer: IName
proposal_hash: IChecksum256
item_index: IUint64
protocol: IDocument2
}
export interface IDeclWroff {
/**
* Callback от `soviet::exec` после авторизации Протокола Совета о
* списании скоропорта (Story 8.4). Сигнатура `(coopname, hash, authorization)`
* совпадает с AUTHORIZE_CALLBACK_SIGNATURE в soviet.hpp. Контракт `soviet`
* вызывает action от своего имени (require_auth(_soviet)).
*/
export interface IOnMktWoAuth {
coopname: IName
decided_by: IName
proposal_hash: IChecksum256
hash: IChecksum256
authorization: IDocument2
}
/**
* Callback от `soviet::cancelexprd` (повестка просрочена) или decline-
* эффекта в soviet. Сигнатура `(coopname, hash, reason)` совпадает с
* DECLINE_CALLBACK_SIGNATURE в soviet.hpp.
*/
export interface IOnMktWoDecl {
coopname: IName
hash: IChecksum256
reason: string
}
@@ -8,6 +8,7 @@ import { OperatorIssuancePage } from 'src/pages/Marketplace/OperatorIssuance'
import { OrdererReadyToReceivePage } from 'src/pages/Marketplace/OrdererReadyToReceive'
import { OrdererReturnClaimsPage } from 'src/pages/Marketplace/OrdererReturnClaims'
import { OperatorReturnClaimsPage } from 'src/pages/Marketplace/OperatorReturnClaims'
import { AdminWriteoffsPage } from 'src/pages/Marketplace/AdminWriteoffs'
import type { IWorkspaceConfig } from 'src/shared/lib/types/workspace'
import { agreementsBase } from 'src/shared/lib/consts/workspaces'
@@ -96,6 +97,25 @@ export default async function (): Promise<IWorkspaceConfig[]> {
agreements: agreementsBase,
},
},
{
// Эпик 8 / Story 8.7: admin-стол списания скоропорта. Председатель
// или общий администратор собирает черновик проекта списания
// (вручную или из крон-предложения), подписывает Заявление 1106
// и отправляет проект в совет — `propwroff` + `soviet::createagenda
// (type=mktwroff)`. Совет голосует через стандартный sov.decision
// flow и подписывает Протокол 1105; backend сам циклом проводит
// per-item списания через `execwroff` (пары o.mkt.wroff + o.mkt.wroff2).
path: 'writeoffs',
name: 'marketplace-writeoffs',
component: markRaw(AdminWriteoffsPage),
meta: {
title: 'Списания скоропорта',
icon: 'fa-solid fa-trash-can-arrow-up',
roles: ['chairman', 'member'],
requiresAuth: true,
agreements: agreementsBase,
},
},
{
// Витрина дизайн-системы (Эпик 10): открыта председателю и членам совета
// для утверждения 13 custom-компонентов до их применения в эпиках 1-9.
@@ -0,0 +1,100 @@
import { Mutations, Queries, type Types } from '@coopenomics/sdk';
import { client } from 'src/shared/api/client';
export type MarketplaceWriteoffProposalView =
Queries.Marketplace.GetWriteoffProposal.IOutput['marketplaceWriteoffProposal'];
export type MarketplaceWriteoffProposalsPageView =
Queries.Marketplace.ListWriteoffProposals.IOutput['marketplaceListWriteoffProposals'];
export type MarketplaceWriteoffStatementDocumentView = Types.Document.IGeneratedDocument;
export type IListWriteoffProposalsInput =
Queries.Marketplace.ListWriteoffProposals.IInput['data'];
export type ICreateWriteoffDraftInput =
Mutations.Marketplace.CreateWriteoffDraft.IInput['data'];
export type IUpdateWriteoffDraftInput =
Mutations.Marketplace.UpdateWriteoffDraft.IInput['data'];
export type ISubmitWriteoffDraftInput =
Mutations.Marketplace.SubmitWriteoffDraft.IInput['data'];
export type IWriteoffStatementSignablePayloadInput =
Queries.Marketplace.WriteoffStatementSignablePayload.IInput['data'];
export async function getOpenWriteoffDraft(): Promise<MarketplaceWriteoffProposalView | null> {
const { [Queries.Marketplace.OpenWriteoffDraft.name]: result } = await client.Query(
Queries.Marketplace.OpenWriteoffDraft.query,
{},
);
return result as MarketplaceWriteoffProposalView | null;
}
export async function listWriteoffProposals(
data: IListWriteoffProposalsInput,
options?: { page?: number; limit?: number },
): Promise<MarketplaceWriteoffProposalsPageView> {
const { [Queries.Marketplace.ListWriteoffProposals.name]: result } = await client.Query(
Queries.Marketplace.ListWriteoffProposals.query,
{ variables: { data, options } },
);
return result;
}
export async function getWriteoffProposal(id: string): Promise<MarketplaceWriteoffProposalView> {
const { [Queries.Marketplace.GetWriteoffProposal.name]: result } = await client.Query(
Queries.Marketplace.GetWriteoffProposal.query,
{ variables: { id } },
);
return result;
}
export async function getWriteoffStatementSignablePayload(
data: IWriteoffStatementSignablePayloadInput,
): Promise<MarketplaceWriteoffStatementDocumentView> {
const { [Queries.Marketplace.WriteoffStatementSignablePayload.name]: result } = await client.Query(
Queries.Marketplace.WriteoffStatementSignablePayload.query,
{ variables: { data } },
);
return result;
}
export async function createWriteoffDraft(
data: ICreateWriteoffDraftInput,
): Promise<MarketplaceWriteoffProposalView> {
const { [Mutations.Marketplace.CreateWriteoffDraft.name]: result } = await client.Mutation(
Mutations.Marketplace.CreateWriteoffDraft.mutation,
{ variables: { data } },
);
return result;
}
export async function updateWriteoffDraft(
data: IUpdateWriteoffDraftInput,
): Promise<MarketplaceWriteoffProposalView> {
const { [Mutations.Marketplace.UpdateWriteoffDraft.name]: result } = await client.Mutation(
Mutations.Marketplace.UpdateWriteoffDraft.mutation,
{ variables: { data } },
);
return result;
}
export async function cancelWriteoffDraft(id: string): Promise<boolean> {
const { [Mutations.Marketplace.CancelWriteoffDraft.name]: result } = await client.Mutation(
Mutations.Marketplace.CancelWriteoffDraft.mutation,
{ variables: { id } },
);
return Boolean(result);
}
export async function submitWriteoffDraft(
data: ISubmitWriteoffDraftInput,
): Promise<MarketplaceWriteoffProposalView> {
const { [Mutations.Marketplace.SubmitWriteoffDraft.name]: result } = await client.Mutation(
Mutations.Marketplace.SubmitWriteoffDraft.mutation,
{ variables: { data } },
);
return result;
}
@@ -0,0 +1,2 @@
export { AdminWriteoffsPage } from './ui';
export * from './api';
@@ -0,0 +1,219 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { FailAlert } from 'src/shared/api';
import {
getOpenWriteoffDraft,
listWriteoffProposals,
type MarketplaceWriteoffProposalView,
} from '../api';
import DraftEditorDialog from './DraftEditorDialog.vue';
import SubmitToCouncilDialog from './SubmitToCouncilDialog.vue';
import WriteoffProposalDetailsDialog from './WriteoffProposalDetailsDialog.vue';
/**
* Эпик 8: admin-стол «Списания на повестке». Лента проектов решения совета
* о списании скоропорта.
*
* Раздел 1 — открытый черновик (DRAFT). Кран сервиса или ручное действие
* предлагают председателю / общему администратору набор позиций к
* списанию. Здесь же можно отредактировать состав и подписать Заявление
* 1106 для отправки в совет.
*
* Раздел 2 — активные проекты на повестке совета (ON_AGENDA /
* AUTHORIZED / EXECUTING). Здесь только наблюдение — действующие
* процедуры голосования и подписания Протокола 1105 идут через
* стандартный sov.decision-flow повестки совета.
*
* Раздел 3 — архив (EXECUTED / REJECTED).
*/
const draft = ref<MarketplaceWriteoffProposalView | null>(null);
const inCouncil = ref<MarketplaceWriteoffProposalView[]>([]);
const archive = ref<MarketplaceWriteoffProposalView[]>([]);
const loading = ref(false);
const draftEditorOpen = ref(false);
const submitDialogOpen = ref(false);
const detailsOpen = ref(false);
const selected = ref<MarketplaceWriteoffProposalView | null>(null);
async function load(): Promise<void> {
loading.value = true;
try {
const [openDraft, councilPage, archivePage] = await Promise.all([
getOpenWriteoffDraft(),
listWriteoffProposals({ statuses: ['ON_AGENDA', 'AUTHORIZED', 'EXECUTING'] }),
listWriteoffProposals({ statuses: ['EXECUTED', 'REJECTED'] }),
]);
draft.value = openDraft;
inCouncil.value = councilPage.items;
archive.value = archivePage.items;
} catch (e) {
FailAlert(e, 'Не удалось загрузить проекты списания');
} finally {
loading.value = false;
}
}
function openDetails(proposal: MarketplaceWriteoffProposalView): void {
selected.value = proposal;
detailsOpen.value = true;
}
function statusColor(status: MarketplaceWriteoffProposalView['status']): string {
switch (status) {
case 'DRAFT':
return 'grey';
case 'ON_AGENDA':
return 'primary';
case 'AUTHORIZED':
case 'EXECUTING':
return 'info';
case 'EXECUTED':
return 'positive';
case 'REJECTED':
return 'negative';
default:
return 'grey';
}
}
function humanStatus(status: MarketplaceWriteoffProposalView['status']): string {
switch (status) {
case 'DRAFT':
return 'Черновик';
case 'ON_AGENDA':
return 'На повестке';
case 'AUTHORIZED':
return 'Утверждено';
case 'EXECUTING':
return 'Идёт списание';
case 'EXECUTED':
return 'Исполнено';
case 'REJECTED':
return 'Отклонено';
default:
return String(status);
}
}
function formatDate(value: string | null | undefined): string {
if (!value) return '—';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '—';
return parsed.toLocaleDateString('ru-RU');
}
const totalActiveAmount = computed(() =>
inCouncil.value
.reduce((acc, p) => acc + (Number.parseFloat(p.total_amount) || 0), 0)
.toFixed(2),
);
function onDraftCreatedOrUpdated(updated: MarketplaceWriteoffProposalView): void {
draft.value = updated;
}
function onDraftSubmitted(): void {
draft.value = null;
void load();
}
onMounted(() => {
void load();
});
</script>
<template lang="pug">
q-page.mp-role-admin.mp-writeoffs.q-pa-md
.row.items-center.q-mb-sm
.text-h6 Списания скоропорта
q-space
q-btn(
v-if="!draft"
unelevated no-caps color="primary"
icon="fa-solid fa-plus"
label="Новый черновик"
@click="draftEditorOpen = true"
)
.text-body2.text-grey.q-mb-md
| Сначала собирается черновик списания (вручную или автоматически крон-сервисом). Затем председатель подписывает Заявление 1106 и отправляет проект в совет. Совет голосует через свою повестку и подписывает Протокол 1105 backend сам атомарно списывает позиции через пары o.mkt.wroff + o.mkt.wroff2.
q-card.q-mb-md(v-if="draft" flat bordered)
q-card-section
.row.items-center
.col
.text-subtitle1 Открытый черновик
.text-caption.text-grey {{ draft.items.length }} позиций · {{ draft.total_amount }}
.text-caption.text-grey Источник: {{ draft.trigger === 'cron' ? 'крон-сервис' : 'ручное создание' }} · {{ formatDate(draft.created_at) }}
q-btn(
flat color="primary" no-caps
icon="fa-solid fa-pen"
label="Изменить состав"
@click="draftEditorOpen = true"
)
q-btn.q-ml-sm(
unelevated color="primary" no-caps
icon="fa-solid fa-paper-plane"
label="Подписать и отправить в совет"
@click="submitDialogOpen = true"
)
q-card.q-mb-md(flat bordered)
q-card-section.row.items-center
q-spinner-tabs(v-if="loading" indeterminate)
.col
.text-subtitle1 В работе совета
.text-caption.text-grey {{ inCouncil.length }} проект(ов) на сумму {{ totalActiveAmount }}
q-card(v-if="inCouncil.length === 0" flat bordered).q-pa-md.q-mb-md
.text-grey Нет проектов на повестке. Откройте новый черновик или дождитесь, пока крон-сервис подберёт скоропорт.
q-list(v-else bordered separator).q-mb-md
q-item(
v-for="p in inCouncil" :key="p.id" clickable @click="openDetails(p)"
)
q-item-section
q-item-label.text-weight-medium {{ p.items.length }} позиций · {{ p.total_amount }}
q-item-label(caption) Подал {{ p.proposed_by_account ?? '—' }} · {{ formatDate(p.submitted_at) }}
q-item-section(side)
q-chip(:color="statusColor(p.status)" text-color="white" dense) {{ humanStatus(p.status) }}
.text-subtitle1.q-mb-sm Архив
q-card(v-if="archive.length === 0" flat bordered).q-pa-md
.text-grey Архив пуст.
q-list(v-else bordered separator)
q-item(
v-for="p in archive" :key="p.id" clickable @click="openDetails(p)"
)
q-item-section
q-item-label.text-weight-medium {{ p.items.length }} позиций · {{ p.total_amount }}
q-item-label(caption) {{ humanStatus(p.status) }} · {{ formatDate(p.executed_at ?? p.rejected_at ?? p.updated_at) }}
q-item-label(caption v-if="p.reject_reason") Причина отказа: {{ p.reject_reason }}
q-item-section(side)
q-chip(:color="statusColor(p.status)" text-color="white" dense) {{ humanStatus(p.status) }}
DraftEditorDialog(
v-model="draftEditorOpen"
:existing-draft="draft"
@saved="onDraftCreatedOrUpdated"
)
SubmitToCouncilDialog(
v-if="draft"
v-model="submitDialogOpen"
:draft="draft"
@submitted="onDraftSubmitted"
)
WriteoffProposalDetailsDialog(
v-if="selected"
v-model="detailsOpen"
:proposal="selected"
)
</template>
<style lang="scss" scoped>
.mp-writeoffs {
font-size: var(--mp-font-body, 16px);
}
</style>
@@ -0,0 +1,193 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import {
cancelWriteoffDraft,
createWriteoffDraft,
updateWriteoffDraft,
type ICreateWriteoffDraftInput,
type IUpdateWriteoffDraftInput,
type MarketplaceWriteoffProposalView,
} from '../api';
/**
* Эпик 8: диалог редактирования черновика проекта списания. Если
* `existingDraft` передан — диалог редактирует существующий черновик
* (`marketplaceUpdateWriteoffDraft`); если null — создаёт новый
* (`marketplaceCreateWriteoffDraft`). Также позволяет полностью отменить
* существующий черновик кнопкой «Удалить черновик».
*/
const props = defineProps<{
modelValue: boolean;
existingDraft: MarketplaceWriteoffProposalView | null;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'saved', proposal: MarketplaceWriteoffProposalView): void;
(e: 'cancelled'): void;
}>();
interface EditableItem {
braname: string;
asset_title: string;
quantity: string;
amount: string;
reason: string;
inventory_id: string | null;
}
const items = ref<EditableItem[]>([]);
const saving = ref(false);
watch(
() => props.modelValue,
(open) => {
if (open) {
items.value = (props.existingDraft?.items ?? []).map((it) => ({
braname: it.braname,
asset_title: it.asset_title,
quantity: it.quantity,
amount: it.amount,
reason: it.reason,
inventory_id: it.inventory_id ?? null,
}));
if (items.value.length === 0) addItem();
}
},
);
function addItem(): void {
items.value.push({
braname: '',
asset_title: '',
quantity: '1',
amount: '0.0000',
reason: '',
inventory_id: null,
});
}
function removeItem(idx: number): void {
items.value.splice(idx, 1);
}
const totalAmount = computed(() =>
items.value.reduce((acc, it) => acc + (Number.parseFloat(it.amount) || 0), 0).toFixed(4),
);
async function save(): Promise<void> {
if (items.value.length === 0) {
FailAlert(null, 'Добавьте хотя бы одну позицию');
return;
}
for (const it of items.value) {
if (!it.braname || !it.asset_title) {
FailAlert(null, 'У каждой позиции должны быть указаны КУ и наименование');
return;
}
const amount = Number(it.amount);
if (!Number.isFinite(amount) || amount <= 0) {
FailAlert(null, 'Сумма позиции должна быть положительным числом');
return;
}
}
saving.value = true;
try {
if (props.existingDraft) {
const payload: IUpdateWriteoffDraftInput = {
id: props.existingDraft.id,
items: items.value.map((it) => ({
braname: it.braname,
asset_title: it.asset_title,
quantity: it.quantity,
amount: it.amount,
reason: it.reason,
inventory_id: it.inventory_id ?? undefined,
})),
};
const updated = await updateWriteoffDraft(payload);
SuccessAlert(`Сохранено ${updated.items.length} позиций`);
emit('saved', updated);
} else {
const payload: ICreateWriteoffDraftInput = {
items: items.value.map((it) => ({
braname: it.braname,
asset_title: it.asset_title,
quantity: it.quantity,
amount: it.amount,
reason: it.reason,
inventory_id: it.inventory_id ?? undefined,
})),
};
const created = await createWriteoffDraft(payload);
SuccessAlert(`Черновик создан: ${created.items.length} позиций`);
emit('saved', created);
}
emit('update:modelValue', false);
} catch (e) {
FailAlert(e, 'Не удалось сохранить черновик');
} finally {
saving.value = false;
}
}
async function cancelDraftAndClose(): Promise<void> {
if (!props.existingDraft) return;
saving.value = true;
try {
await cancelWriteoffDraft(props.existingDraft.id);
SuccessAlert('Черновик удалён');
emit('cancelled');
emit('update:modelValue', false);
} catch (e) {
FailAlert(e, 'Не удалось удалить черновик');
} finally {
saving.value = false;
}
}
</script>
<template lang="pug">
q-dialog(
:model-value="modelValue"
@update:model-value="(v) => emit('update:modelValue', v)"
full-width persistent
)
q-card
q-card-section.row.items-center
.text-h6 {{ existingDraft ? 'Редактирование черновика' : 'Новый черновик списания' }}
q-space
q-btn(flat round icon="close" @click="emit('update:modelValue', false)")
q-card-section
q-list(bordered separator)
q-item(v-for="(it, idx) in items" :key="idx")
q-item-section
.row.q-col-gutter-sm
q-input.col-2(v-model="it.braname" label="КУ" dense outlined)
q-input.col-3(v-model="it.asset_title" label="Наименование" dense outlined)
q-input.col-1(v-model="it.quantity" label="Кол-во" dense outlined)
q-input.col-2(v-model="it.amount" label="Сумма" dense outlined)
q-input.col-4(v-model="it.reason" label="Причина" dense outlined)
q-item-section(side)
q-btn(flat round dense color="negative" icon="delete" @click="removeItem(idx)")
q-btn.q-mt-sm(flat no-caps icon="add" label="Добавить позицию" @click="addItem")
q-card-section.row.items-center
.text-body1 ИТОГО: {{ totalAmount }}
q-space
q-btn.q-mr-sm(
v-if="existingDraft"
flat no-caps color="negative"
label="Удалить черновик"
:loading="saving"
@click="cancelDraftAndClose"
)
q-btn(
unelevated no-caps color="primary"
label="Сохранить"
:loading="saving"
@click="save"
)
</template>
@@ -0,0 +1,107 @@
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { useStore } from 'src/shared/store';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { DigitalDocument } from 'src/shared/lib/document';
import {
getWriteoffStatementSignablePayload,
submitWriteoffDraft,
type MarketplaceWriteoffProposalView,
type MarketplaceWriteoffStatementDocumentView,
} from '../api';
/**
* Эпик 8: диалог подписания Заявления о списании скоропорта (registry
* 1106). Председатель смотрит preview-документ, подписывает приватным
* ключом, и отправляет в backend. Backend сам выполняет propwroff +
* soviet::createagenda(mktwroff), переводит проект из DRAFT в ON_AGENDA.
*/
const props = defineProps<{
modelValue: boolean;
draft: MarketplaceWriteoffProposalView;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'submitted'): void;
}>();
const store = useStore() as { account: { username: string } };
const previewDoc = ref<MarketplaceWriteoffStatementDocumentView | null>(null);
const loading = ref(false);
const submitting = ref(false);
watch(
() => props.modelValue,
async (open) => {
if (!open) {
previewDoc.value = null;
return;
}
loading.value = true;
try {
previewDoc.value = await getWriteoffStatementSignablePayload({ draft_id: props.draft.id });
} catch (e) {
FailAlert(e, 'Не удалось сформировать Заявление о списании');
emit('update:modelValue', false);
} finally {
loading.value = false;
}
},
);
async function signAndSubmit(): Promise<void> {
if (!previewDoc.value) return;
submitting.value = true;
try {
const digital = new DigitalDocument(previewDoc.value);
const signed = await digital.sign(store.account.username);
await submitWriteoffDraft({
draft_id: props.draft.id,
signed_statement: signed,
});
SuccessAlert('Проект отправлен в совет');
emit('submitted');
emit('update:modelValue', false);
} catch (e) {
FailAlert(e, 'Не удалось отправить проект в совет');
} finally {
submitting.value = false;
}
}
</script>
<template lang="pug">
q-dialog(
:model-value="modelValue"
@update:model-value="(v) => emit('update:modelValue', v)"
full-width persistent
)
q-card
q-card-section.row.items-center
.text-h6 Подписание Заявления о списании скоропорта
q-space
q-btn(flat round icon="close" @click="emit('update:modelValue', false)")
q-card-section(v-if="loading")
q-spinner-tabs(indeterminate)
.text-grey.q-ml-md Формируем Заявление
q-card-section(v-else-if="previewDoc")
.text-body2.text-grey.q-mb-md
| Подписав это Заявление, вы инициируете повестку совета о списании имущества со складов кооперативных участков. Совет рассматривает проект и подписывает Протокол списания через стандартный sov.decision flow.
q-card(flat bordered).q-pa-md(style="max-height: 50vh; overflow: auto")
div(v-html="previewDoc.html")
q-card-section.row.items-center.q-gutter-sm(v-if="previewDoc")
q-space
q-btn(flat no-caps label="Отмена" @click="emit('update:modelValue', false)")
q-btn(
unelevated no-caps color="primary"
icon="fa-solid fa-signature"
label="Подписать и отправить в совет"
:loading="submitting"
@click="signAndSubmit"
)
</template>
@@ -0,0 +1,99 @@
<script lang="ts" setup>
import type { MarketplaceWriteoffProposalView } from '../api';
const props = defineProps<{
modelValue: boolean;
proposal: MarketplaceWriteoffProposalView;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
}>();
function fmt(value: string | null | undefined): string {
if (!value) return '—';
const d = new Date(value);
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString('ru-RU');
}
function humanStatus(status: MarketplaceWriteoffProposalView['status']): string {
switch (status) {
case 'DRAFT':
return 'Черновик';
case 'ON_AGENDA':
return 'На повестке совета';
case 'AUTHORIZED':
return 'Авторизовано';
case 'EXECUTING':
return 'Идёт списание';
case 'EXECUTED':
return 'Исполнено';
case 'REJECTED':
return 'Отклонено';
default:
return String(status);
}
}
</script>
<template lang="pug">
q-dialog(
:model-value="modelValue"
@update:model-value="(v) => emit('update:modelValue', v)"
full-width
)
q-card
q-card-section.row.items-center
.text-h6 Проект списания · {{ humanStatus(proposal.status) }}
q-space
q-btn(flat round icon="close" @click="emit('update:modelValue', false)")
q-card-section
.text-subtitle2 Основные параметры
.row.q-col-gutter-md
.col
.text-caption.text-grey Цикл
div {{ fmt(proposal.cycle_started_at) }}
.col
.text-caption.text-grey Сумма
div {{ proposal.total_amount }}
.col
.text-caption.text-grey Идентификатор on-chain
div(style="font-family: monospace; word-break: break-all") {{ proposal.proposal_hash || '—' }}
.col
.text-caption.text-grey Решение совета
div {{ proposal.decision_id ?? '—' }}
q-card-section
.text-subtitle2.q-mb-sm Позиции
q-markup-table(dense flat bordered)
thead
tr
th
th КУ
th Наименование
th Кол-во
th Сумма
th Причина
th Статус
tbody
tr(v-for="(it, idx) in proposal.items" :key="idx")
td {{ idx + 1 }}
td {{ it.braname }}
td {{ it.asset_title }}
td {{ it.quantity }}
td {{ it.amount }}
td {{ it.reason }}
td(:class="it.executed ? 'text-positive' : 'text-grey-7'") {{ it.executed ? 'Исполнено' : 'Ожидает' }}
q-card-section(v-if="proposal.reject_reason")
.text-subtitle2 Причина отказа совета
.text-body2 {{ proposal.reject_reason }}
q-card-section(v-if="proposal.decision_log && proposal.decision_log.length")
.text-subtitle2.q-mb-sm Журнал решений
q-list(dense bordered separator)
q-item(v-for="(entry, idx) in proposal.decision_log" :key="idx")
q-item-section
q-item-label {{ entry.action }} {{ entry.actor }}
q-item-label(caption) {{ fmt(entry.at) }}
</template>
@@ -0,0 +1 @@
export { default as AdminWriteoffsPage } from './AdminWriteoffsPage.vue';
@@ -0,0 +1,72 @@
import { Cooperative, DraftContract } from 'cooptypes'
import { MarketplaceWriteoffProtocol } from '../Templates'
import { DocFactory } from '../Factory'
import type { IGeneratedDocument, IGenerationOptions, IMetaDocument, ITemplate } from '../Interfaces'
import type { MongoDBConnector } from '../Services/Databazor'
export { MarketplaceWriteoffProtocol as Template } from '../Templates'
/**
* Factory для Протокола совета о списании скоропорта (Эпик 8 / Story 8.4).
* Генерируется на этапе авторизации Решения совета — chairman подписывает
* этот документ через стандартный sov.decision flow (`soviet::authorize`),
* `soviet::exec` зовёт callback `marketplace::onmktwoauth(coopname, hash,
* authorization)` с этим Протоколом в качестве `authorization`.
*/
export class Factory extends DocFactory<MarketplaceWriteoffProtocol.Action> {
constructor(storage: MongoDBConnector) {
super(storage)
}
async generateDocument(
data: MarketplaceWriteoffProtocol.Action,
options?: IGenerationOptions,
): Promise<IGeneratedDocument> {
let template: ITemplate<MarketplaceWriteoffProtocol.Model>
if (process.env.SOURCE === 'local') {
template = MarketplaceWriteoffProtocol.Template
}
else {
template = await this.getTemplate(
DraftContract.contractName.production,
MarketplaceWriteoffProtocol.registry_id,
data.block_num,
)
}
const meta: IMetaDocument = await this.getMeta({ title: template.title, ...data })
const coop = await super.getCooperative(data.coopname, data.block_num)
const vars = await super.getVars(data.coopname, data.block_num)
const decision: Cooperative.Document.IDecisionData = await this.getDecision(
coop,
data.coopname,
data.decision_id,
meta.created_at,
)
const combinedData: MarketplaceWriteoffProtocol.Model = {
meta,
coop,
vars,
decision,
proposal_hash: data.proposal_hash,
cycle_started_at: data.cycle_started_at,
total_amount: data.total_amount,
items_count: data.items_count,
}
await this.validate(combinedData, template.model)
const translation = template.translations[meta.lang]
const document: IGeneratedDocument = await this.generatePDF(
'',
template.context,
combinedData,
translation,
meta,
options?.skip_save,
)
return document
}
}
@@ -0,0 +1,80 @@
import { Cooperative, DraftContract } from 'cooptypes'
import { MarketplaceWriteoffStatement } from '../Templates'
import { DocFactory } from '../Factory'
import type { IGeneratedDocument, IGenerationOptions, IMetaDocument, ITemplate } from '../Interfaces'
import type { MongoDBConnector } from '../Services/Databazor'
export { MarketplaceWriteoffStatement as Template } from '../Templates'
/**
* Factory для Заявления о списании скоропорта (Эпик 8 / Story 8.4).
* Подписывается председателем перед `marketplace::propwroff` +
* `soviet::createagenda(type=mktwroff)`. Источник пользователя —
* `data.username` (=председатель).
*/
export class Factory extends DocFactory<MarketplaceWriteoffStatement.Action> {
constructor(storage: MongoDBConnector) {
super(storage)
}
async generateDocument(
data: MarketplaceWriteoffStatement.Action,
options?: IGenerationOptions,
): Promise<IGeneratedDocument> {
let template: ITemplate<MarketplaceWriteoffStatement.Model>
if (process.env.SOURCE === 'local') {
template = MarketplaceWriteoffStatement.Template
}
else {
template = await this.getTemplate(
DraftContract.contractName.production,
MarketplaceWriteoffStatement.registry_id,
data.block_num,
)
}
const meta: IMetaDocument = await this.getMeta({ title: template.title, ...data })
const coop = await this.getCooperative(data.coopname, data.block_num)
const vars = await this.getVars(data.coopname, data.block_num)
const user = await this.getUser(data.username, data.block_num)
const chairman = this.getCommonUser(user)
// Marketplace = ЦПП «Стол заказов» (program_id=2)
const program = await this.getProgram(2)
const items: Cooperative.Registry.MarketplaceWriteoffStatement.WriteoffItemModel[] =
data.items.map(it => ({
braname: it.braname,
asset_title: it.asset_title,
quantity: it.quantity,
amount: it.amount,
reason: it.reason,
}))
const combinedData: MarketplaceWriteoffStatement.Model = {
meta,
coop,
vars,
chairman,
program,
proposal_hash: data.proposal_hash,
cycle_started_at: data.cycle_started_at,
items,
total_amount: data.total_amount,
}
await this.validate(combinedData, template.model)
const translation = template.translations[meta.lang]
const document: IGeneratedDocument = await this.generatePDF(
chairman.full_name_or_short_name,
template.context,
combinedData,
translation,
meta,
options?.skip_save,
)
return document
}
}
+4
View File
@@ -81,3 +81,7 @@ export * as MarketplaceTransportNote from './1103.MarketplaceTransportNote'
// Marketplace (Стол заказов) — Эпик 7: гарантийный возврат
export * as MarketplaceReturnStatement from './1104.MarketplaceReturnStatement'
// Marketplace (Стол заказов) — Эпик 8: списание скоропорта
export * as MarketplaceWriteoffStatement from './1106.MarketplaceWriteoffStatement'
export * as MarketplaceWriteoffProtocol from './1105.MarketplaceWriteoffProtocol'
@@ -0,0 +1,37 @@
import type { JSONSchemaType } from 'ajv'
import { Cooperative } from 'cooptypes'
import type { ITemplate } from '../Interfaces'
import { IMetaJSONSchema } from '../Schema/MetaSchema'
import { CooperativeSchema } from '../Schema'
import { VarsSchema } from '../Schema/VarsSchema'
import { decisionSchema } from '../Schema/DecisionSchema'
export const registry_id = Cooperative.Registry.MarketplaceWriteoffProtocol.registry_id
export type Action = Cooperative.Registry.MarketplaceWriteoffProtocol.Action
export type Model = Cooperative.Registry.MarketplaceWriteoffProtocol.Model
export const Schema: JSONSchemaType<Model> = {
type: 'object',
properties: {
meta: IMetaJSONSchema,
coop: CooperativeSchema,
vars: VarsSchema,
decision: decisionSchema,
proposal_hash: { type: 'string' },
cycle_started_at: { type: 'string' },
total_amount: { type: 'string' },
items_count: { type: 'number' },
},
required: ['meta', 'coop', 'vars', 'decision', 'proposal_hash', 'cycle_started_at', 'total_amount', 'items_count'],
additionalProperties: true,
}
export const Template: ITemplate<Model> = {
title: Cooperative.Registry.MarketplaceWriteoffProtocol.title,
description: Cooperative.Registry.MarketplaceWriteoffProtocol.description,
model: Schema,
context: Cooperative.Registry.MarketplaceWriteoffProtocol.context,
translations: Cooperative.Registry.MarketplaceWriteoffProtocol.translations,
}
@@ -0,0 +1,52 @@
import type { JSONSchemaType } from 'ajv'
import { Cooperative } from 'cooptypes'
import type { ITemplate } from '../Interfaces'
import { IMetaJSONSchema } from '../Schema/MetaSchema'
import { CooperativeSchema } from '../Schema'
import { VarsSchema } from '../Schema/VarsSchema'
import { CommonUserSchema } from '../Schema/CommonUserSchema'
import { CommonProgramSchema } from '../Schema/CommonProgramSchema'
export const registry_id = Cooperative.Registry.MarketplaceWriteoffStatement.registry_id
export type Action = Cooperative.Registry.MarketplaceWriteoffStatement.Action
export type Model = Cooperative.Registry.MarketplaceWriteoffStatement.Model
const WriteoffItemSchema: JSONSchemaType<Cooperative.Registry.MarketplaceWriteoffStatement.WriteoffItemModel> = {
type: 'object',
properties: {
braname: { type: 'string' },
asset_title: { type: 'string' },
quantity: { type: 'string' },
amount: { type: 'string' },
reason: { type: 'string' },
},
required: ['braname', 'asset_title', 'quantity', 'amount', 'reason'],
additionalProperties: true,
}
export const Schema: JSONSchemaType<Model> = {
type: 'object',
properties: {
meta: IMetaJSONSchema,
coop: CooperativeSchema,
vars: VarsSchema,
chairman: CommonUserSchema,
program: CommonProgramSchema,
proposal_hash: { type: 'string' },
cycle_started_at: { type: 'string' },
items: { type: 'array', items: WriteoffItemSchema, minItems: 1 },
total_amount: { type: 'string' },
},
required: ['meta', 'coop', 'vars', 'chairman', 'program', 'proposal_hash', 'cycle_started_at', 'items', 'total_amount'],
additionalProperties: true,
}
export const Template: ITemplate<Model> = {
title: Cooperative.Registry.MarketplaceWriteoffStatement.title,
description: Cooperative.Registry.MarketplaceWriteoffStatement.description,
model: Schema,
context: Cooperative.Registry.MarketplaceWriteoffStatement.context,
translations: Cooperative.Registry.MarketplaceWriteoffStatement.translations,
}
@@ -75,6 +75,10 @@ export * as MarketplaceTransportNote from './1103.MarketplaceTransportNote'
// Marketplace (Стол заказов) — Эпик 7: гарантийный возврат
export * as MarketplaceReturnStatement from './1104.MarketplaceReturnStatement'
// Marketplace (Стол заказов) — Эпик 8: списание скоропорта (заявление + протокол)
export * as MarketplaceWriteoffStatement from './1106.MarketplaceWriteoffStatement'
export * as MarketplaceWriteoffProtocol from './1105.MarketplaceWriteoffProtocol'
export * as AnnualGeneralMeetingAgenda from './300.AnnualGeneralMeetingAgenda'
export * as AnnualGeneralMeetingSovietDecision from './301.AnnualGeneralMeetingSovietDecision'
export * as AnnualGeneralMeetingNotification from './302.AnnualGeneralMeetingNotification'
@@ -73,6 +73,8 @@ import * as BlagorostOffer from './1000.BlagorostOffer'
import * as MarketplaceAplReception from './1102.MarketplaceAplReception'
import * as MarketplaceTransportNote from './1103.MarketplaceTransportNote'
import * as MarketplaceReturnStatement from './1104.MarketplaceReturnStatement'
import * as MarketplaceWriteoffProtocol from './1105.MarketplaceWriteoffProtocol'
import * as MarketplaceWriteoffStatement from './1106.MarketplaceWriteoffStatement'
export const Registry = {
1: WalletAgreement,
@@ -137,4 +139,6 @@ export const Registry = {
1102: MarketplaceAplReception,
1103: MarketplaceTransportNote,
1104: MarketplaceReturnStatement,
1105: MarketplaceWriteoffProtocol,
1106: MarketplaceWriteoffStatement,
}
+4
View File
@@ -174,6 +174,10 @@ export class Generator implements IGenerator {
// Marketplace (Стол заказов) — Эпик 7: гарантийный возврат
[Actions.MarketplaceReturnStatement.Template.registry_id]: new Actions.MarketplaceReturnStatement.Factory(this.storage), // 1104
// Marketplace (Стол заказов) — Эпик 8: списание скоропорта
[Actions.MarketplaceWriteoffStatement.Template.registry_id]: new Actions.MarketplaceWriteoffStatement.Factory(this.storage), // 1106
[Actions.MarketplaceWriteoffProtocol.Template.registry_id]: new Actions.MarketplaceWriteoffProtocol.Factory(this.storage), // 1105
}
await this.storage.connect()
}
@@ -24,6 +24,24 @@ beforeAll(async () => {
})
}
// Подменяем метод getDecision для фабрики Протокола списания скоропорта
// (1105) — голоса совета приходят из SIMPLE_EXPLORER_API, в тесте
// обращений к нему нет.
const factory1105 = (generator as any).factories['1105']
if (factory1105) {
vi.spyOn(factory1105, 'getDecision').mockImplementation(async () => {
return {
id: 1,
date: '01.06.2026',
time: '12:00',
votes_for: 3,
votes_against: 0,
votes_abstained: 0,
voters_percent: 100,
}
})
}
const commonUdata = {
coopname: 'voskhod',
username: 'ant',
@@ -416,4 +434,47 @@ describe('тест генератора документов с registry_id >= 1
lang: 'ru',
})
})
// Документы списания скоропорта по решению совета (ЦПП «Стол заказов»)
it('генерируем заявление председателя о списании скоропорта (1106)', async () => {
await testDocumentGeneration({
registry_id: 1106,
coopname: 'voskhod',
username: 'ant',
lang: 'ru',
proposal_hash: '0000abcd1234567890abcdef0000abcd1234567890abcdef00001234567890ab',
cycle_started_at: '2026-06-01',
items: [
{
braname: 'voskhod1',
asset_title: 'Сахар-песок «Сладкий», 1 кг',
quantity: '12',
amount: '1020.0000 RUB',
reason: 'Истёк срок годности',
},
{
braname: 'voskhod2',
asset_title: 'Молоко «Доброе», 1 л',
quantity: '5',
amount: '485.0000 RUB',
reason: 'Повреждена упаковка',
},
],
total_amount: '1505.0000 RUB',
})
})
it('генерируем протокол совета о списании скоропорта (1105)', async () => {
await testDocumentGeneration({
registry_id: 1105,
coopname: 'voskhod',
username: 'ant',
lang: 'ru',
decision_id: 1,
proposal_hash: '0000abcd1234567890abcdef0000abcd1234567890abcdef00001234567890ab',
cycle_started_at: '2026-06-01',
total_amount: '1505.0000 RUB',
items_count: 2,
})
})
})
@@ -31,6 +31,11 @@ import { workflow as marketplaceOrderReadyWorkflow } from './marketplace-order-r
import { workflow as marketplaceReturnClaimSubmittedWorkflow } from './marketplace-return-claim-submitted';
import { workflow as marketplaceReturnClaimDecidedWorkflow } from './marketplace-return-claim-decided';
import { workflow as marketplaceReturnClaimFinalizedWorkflow } from './marketplace-return-claim-finalized';
import { workflow as marketplaceWriteoffDraftBuiltWorkflow } from './marketplace-writeoff-draft-built';
import { workflow as marketplaceWriteoffProposedWorkflow } from './marketplace-writeoff-proposed';
import { workflow as marketplaceWriteoffAuthorizedWorkflow } from './marketplace-writeoff-authorized';
import { workflow as marketplaceWriteoffExecutedWorkflow } from './marketplace-writeoff-executed';
import { workflow as marketplaceWriteoffRejectedWorkflow } from './marketplace-writeoff-rejected';
// Импортируем все воркфлоу
export * as Welcome from './welcome';
@@ -64,6 +69,11 @@ export * as MarketplaceOrderReady from './marketplace-order-ready';
export * as MarketplaceReturnClaimSubmitted from './marketplace-return-claim-submitted';
export * as MarketplaceReturnClaimDecided from './marketplace-return-claim-decided';
export * as MarketplaceReturnClaimFinalized from './marketplace-return-claim-finalized';
export * as MarketplaceWriteoffDraftBuilt from './marketplace-writeoff-draft-built';
export * as MarketplaceWriteoffProposed from './marketplace-writeoff-proposed';
export * as MarketplaceWriteoffAuthorized from './marketplace-writeoff-authorized';
export * as MarketplaceWriteoffExecuted from './marketplace-writeoff-executed';
export * as MarketplaceWriteoffRejected from './marketplace-writeoff-rejected';
// Массив всех воркфлоу для автоматической регистрации
export const allWorkflows: WorkflowDefinition[] = [
@@ -98,6 +108,11 @@ export const allWorkflows: WorkflowDefinition[] = [
marketplaceReturnClaimSubmittedWorkflow,
marketplaceReturnClaimDecidedWorkflow,
marketplaceReturnClaimFinalizedWorkflow,
marketplaceWriteoffDraftBuiltWorkflow,
marketplaceWriteoffProposedWorkflow,
marketplaceWriteoffAuthorizedWorkflow,
marketplaceWriteoffExecutedWorkflow,
marketplaceWriteoffRejectedWorkflow,
];
// Экспортируем воркфлоу по ID для удобного доступа
@@ -0,0 +1,47 @@
import { WorkflowDefinition } from '../../types';
import { WorkflowBuilder } from '../../base/workflow-builder';
import { z } from 'zod';
import { BaseWorkflowPayload } from '../../types';
import { createEmailStep, createInAppStep, createPushStep } from '../../base/defaults';
import { slugify } from '../../utils';
export const marketplaceWriteoffAuthorizedPayloadSchema = z.object({
recipientName: z.string(),
coopname: z.string(),
proposal_id: z.string(),
proposal_hash: z.string(),
itemsCount: z.number(),
totalAmount: z.string(),
deepLinkUrl: z.string().optional(),
});
export type IPayload = z.infer<typeof marketplaceWriteoffAuthorizedPayloadSchema>;
export interface IWorkflow extends BaseWorkflowPayload, IPayload {}
export const name = 'Совет авторизовал проект списания скоропорта';
export const id = slugify(name);
export const workflow: WorkflowDefinition<IWorkflow> = WorkflowBuilder.create<IWorkflow>()
.name(name)
.workflowId(id)
.description('Уведомление администратору и председателю: совет одобрил Протокол списания, начинается per-item исполнение.')
.payloadSchema(marketplaceWriteoffAuthorizedPayloadSchema)
.tags(['marketplace', 'admin', 'writeoff'])
.addSteps([
createEmailStep(
'marketplace-writeoff-authorized-email',
'Списание скоропорта авторизовано советом',
'Здравствуйте, {{payload.recipientName}}!<br><br>Совет одобрил Протокол о списании <strong>{{payload.itemsCount}}</strong> позиций на сумму <strong>{{payload.totalAmount}}</strong>. Backend запустил per-item списания через execwroff.<br><br>Открыть проект: {{payload.deepLinkUrl}}'
),
createInAppStep(
'marketplace-writeoff-authorized-notification',
'Совет авторизовал списание',
'{{payload.itemsCount}} позиций — {{payload.totalAmount}}'
),
createPushStep(
'marketplace-writeoff-authorized-push',
'Совет авторизовал списание',
'{{payload.itemsCount}} позиций — {{payload.totalAmount}}'
),
])
.build();
@@ -0,0 +1,50 @@
import { WorkflowDefinition } from '../../types';
import { WorkflowBuilder } from '../../base/workflow-builder';
import { z } from 'zod';
import { BaseWorkflowPayload } from '../../types';
import { createEmailStep, createInAppStep, createPushStep } from '../../base/defaults';
import { slugify } from '../../utils';
export const marketplaceWriteoffDraftBuiltPayloadSchema = z.object({
chairmanName: z.string(),
coopname: z.string(),
proposal_id: z.string(),
trigger: z.string(),
itemsCount: z.number(),
totalAmount: z.string(),
deepLinkUrl: z.string().optional(),
});
export type IPayload = z.infer<typeof marketplaceWriteoffDraftBuiltPayloadSchema>;
export interface IWorkflow extends BaseWorkflowPayload, IPayload {}
export const name = 'Сформирован черновик проекта списания скоропорта';
export const id = slugify(name);
export const workflow: WorkflowDefinition<IWorkflow> = WorkflowBuilder.create<IWorkflow>()
.name(name)
.workflowId(id)
.description(
'Уведомление председателю / общему администратору о том, что сформирован новый черновик проекта списания скоропорта — нужно проверить состав и отправить в совет.'
)
.payloadSchema(marketplaceWriteoffDraftBuiltPayloadSchema)
.tags(['marketplace', 'admin', 'writeoff'])
.addSteps([
createEmailStep(
'marketplace-writeoff-draft-built-email',
'Готов черновик списания на {{payload.itemsCount}} позиций',
'Здравствуйте, {{payload.chairmanName}}!<br><br>Сформирован черновик проекта списания скоропорта: <strong>{{payload.itemsCount}}</strong> позиций на сумму <strong>{{payload.totalAmount}}</strong>. Источник — {{payload.trigger}}.<br><br>Проверьте состав и подпишите Заявление о списании, чтобы отправить проект в совет: {{payload.deepLinkUrl}}'
),
createInAppStep(
'marketplace-writeoff-draft-built-notification',
'Новый черновик проекта списания',
'{{payload.itemsCount}} позиций — {{payload.totalAmount}}'
),
createPushStep(
'marketplace-writeoff-draft-built-push',
'Готов черновик списания',
'{{payload.itemsCount}} позиций — {{payload.totalAmount}}'
),
])
.build();
@@ -0,0 +1,47 @@
import { WorkflowDefinition } from '../../types';
import { WorkflowBuilder } from '../../base/workflow-builder';
import { z } from 'zod';
import { BaseWorkflowPayload } from '../../types';
import { createEmailStep, createInAppStep, createPushStep } from '../../base/defaults';
import { slugify } from '../../utils';
export const marketplaceWriteoffExecutedPayloadSchema = z.object({
recipientName: z.string(),
coopname: z.string(),
proposal_id: z.string(),
proposal_hash: z.string(),
itemsCount: z.number(),
totalAmount: z.string(),
deepLinkUrl: z.string().optional(),
});
export type IPayload = z.infer<typeof marketplaceWriteoffExecutedPayloadSchema>;
export interface IWorkflow extends BaseWorkflowPayload, IPayload {}
export const name = 'Списание скоропорта исполнено';
export const id = slugify(name);
export const workflow: WorkflowDefinition<IWorkflow> = WorkflowBuilder.create<IWorkflow>()
.name(name)
.workflowId(id)
.description('Уведомление администратору о том, что все позиции проекта списания исполнены on-chain (executed).')
.payloadSchema(marketplaceWriteoffExecutedPayloadSchema)
.tags(['marketplace', 'admin', 'writeoff'])
.addSteps([
createEmailStep(
'marketplace-writeoff-executed-email',
'Списание скоропорта завершено',
'Здравствуйте, {{payload.recipientName}}!<br><br>Backend завершил списание <strong>{{payload.itemsCount}}</strong> позиций на общую сумму <strong>{{payload.totalAmount}}</strong>. Проект переведён в финальный статус EXECUTED.<br><br>Открыть протокол: {{payload.deepLinkUrl}}'
),
createInAppStep(
'marketplace-writeoff-executed-notification',
'Списание завершено',
'{{payload.itemsCount}} позиций — {{payload.totalAmount}}'
),
createPushStep(
'marketplace-writeoff-executed-push',
'Списание завершено',
'{{payload.itemsCount}} позиций — {{payload.totalAmount}}'
),
])
.build();
@@ -0,0 +1,49 @@
import { WorkflowDefinition } from '../../types';
import { WorkflowBuilder } from '../../base/workflow-builder';
import { z } from 'zod';
import { BaseWorkflowPayload } from '../../types';
import { createEmailStep, createInAppStep, createPushStep } from '../../base/defaults';
import { slugify } from '../../utils';
export const marketplaceWriteoffProposedPayloadSchema = z.object({
recipientName: z.string(),
coopname: z.string(),
proposal_id: z.string(),
proposal_hash: z.string(),
itemsCount: z.number(),
totalAmount: z.string(),
deepLinkUrl: z.string().optional(),
});
export type IPayload = z.infer<typeof marketplaceWriteoffProposedPayloadSchema>;
export interface IWorkflow extends BaseWorkflowPayload, IPayload {}
export const name = 'Проект списания скоропорта — на повестке совета';
export const id = slugify(name);
export const workflow: WorkflowDefinition<IWorkflow> = WorkflowBuilder.create<IWorkflow>()
.name(name)
.workflowId(id)
.description(
'Уведомление членам совета и общему администратору, что председатель подписал Заявление о списании и проект встал на повестку совета.'
)
.payloadSchema(marketplaceWriteoffProposedPayloadSchema)
.tags(['marketplace', 'council', 'writeoff'])
.addSteps([
createEmailStep(
'marketplace-writeoff-proposed-email',
'На повестке: списание {{payload.itemsCount}} позиций',
'Здравствуйте, {{payload.recipientName}}!<br><br>Председатель подписал Заявление о списании скоропорта. Проект встал на повестку совета: <strong>{{payload.itemsCount}}</strong> позиций на сумму <strong>{{payload.totalAmount}}</strong>.<br><br>Открыть проект: {{payload.deepLinkUrl}}'
),
createInAppStep(
'marketplace-writeoff-proposed-notification',
'Проект списания на повестке',
'{{payload.itemsCount}} позиций — {{payload.totalAmount}}'
),
createPushStep(
'marketplace-writeoff-proposed-push',
'Проект списания на повестке',
'{{payload.itemsCount}} позиций — {{payload.totalAmount}}'
),
])
.build();
@@ -0,0 +1,46 @@
import { WorkflowDefinition } from '../../types';
import { WorkflowBuilder } from '../../base/workflow-builder';
import { z } from 'zod';
import { BaseWorkflowPayload } from '../../types';
import { createEmailStep, createInAppStep, createPushStep } from '../../base/defaults';
import { slugify } from '../../utils';
export const marketplaceWriteoffRejectedPayloadSchema = z.object({
recipientName: z.string(),
coopname: z.string(),
proposal_id: z.string(),
proposal_hash: z.string(),
reason: z.string(),
deepLinkUrl: z.string().optional(),
});
export type IPayload = z.infer<typeof marketplaceWriteoffRejectedPayloadSchema>;
export interface IWorkflow extends BaseWorkflowPayload, IPayload {}
export const name = 'Совет отклонил проект списания скоропорта';
export const id = slugify(name);
export const workflow: WorkflowDefinition<IWorkflow> = WorkflowBuilder.create<IWorkflow>()
.name(name)
.workflowId(id)
.description('Уведомление администратору об отказе совета в проекте списания (или истёкшем сроке повестки).')
.payloadSchema(marketplaceWriteoffRejectedPayloadSchema)
.tags(['marketplace', 'admin', 'writeoff'])
.addSteps([
createEmailStep(
'marketplace-writeoff-rejected-email',
'Совет отклонил проект списания',
'Здравствуйте, {{payload.recipientName}}!<br><br>Совет отклонил проект списания скоропорта. Причина: <strong>{{payload.reason}}</strong>. Позиции остаются на складах участков.<br><br>Открыть проект: {{payload.deepLinkUrl}}'
),
createInAppStep(
'marketplace-writeoff-rejected-notification',
'Проект списания отклонён',
'Причина: {{payload.reason}}'
),
createPushStep(
'marketplace-writeoff-rejected-push',
'Проект списания отклонён',
'{{payload.reason}}'
),
])
.build();
@@ -0,0 +1,15 @@
import { $, type GraphQLTypes, type InputType, Selector } from '../../zeus/index'
export const name = 'marketplaceCancelWriteoffDraft'
export const mutation = Selector('Mutation')({
[name]: [{ id: $('id', 'String!') }, true],
})
export interface IInput {
/** @private */
[key: string]: unknown
id: string
}
export type IOutput = InputType<GraphQLTypes['Mutation'], typeof mutation>
@@ -0,0 +1,19 @@
import { marketplaceWriteoffProposalSelector } from '../../selectors/marketplace/writeoffSelector'
import { $, type GraphQLTypes, type InputType, type ModelTypes, Selector } from '../../zeus/index'
export const name = 'marketplaceCreateWriteoffDraft'
export const mutation = Selector('Mutation')({
[name]: [
{ data: $('data', 'MarketplaceCreateWriteoffDraftInput!') },
marketplaceWriteoffProposalSelector,
],
})
export interface IInput {
/** @private */
[key: string]: unknown
data: ModelTypes['MarketplaceCreateWriteoffDraftInput']
}
export type IOutput = InputType<GraphQLTypes['Mutation'], typeof mutation>
@@ -34,3 +34,11 @@ export * as RejectReturnRemote from './rejectReturnRemote'
export * as AcceptReturnAtVisit from './acceptReturnAtVisit'
/** Эпик 7: председатель КУ отказывает в гарантийном возврате на очном осмотре */
export * as RejectReturnAtVisit from './rejectReturnAtVisit'
/** Эпик 8: общий администратор создаёт черновик проекта списания скоропорта */
export * as CreateWriteoffDraft from './createWriteoffDraft'
/** Эпик 8: общий администратор изменяет состав черновика проекта списания */
export * as UpdateWriteoffDraft from './updateWriteoffDraft'
/** Эпик 8: удаление черновика проекта списания до отправки в совет */
export * as CancelWriteoffDraft from './cancelWriteoffDraft'
/** Эпик 8: председатель отправляет черновик с подписанным Заявлением 1106 в совет */
export * as SubmitWriteoffDraft from './submitWriteoffDraft'
@@ -0,0 +1,19 @@
import { marketplaceWriteoffProposalSelector } from '../../selectors/marketplace/writeoffSelector'
import { $, type GraphQLTypes, type InputType, type ModelTypes, Selector } from '../../zeus/index'
export const name = 'marketplaceSubmitWriteoffDraft'
export const mutation = Selector('Mutation')({
[name]: [
{ data: $('data', 'MarketplaceSubmitWriteoffDraftInput!') },
marketplaceWriteoffProposalSelector,
],
})
export interface IInput {
/** @private */
[key: string]: unknown
data: ModelTypes['MarketplaceSubmitWriteoffDraftInput']
}
export type IOutput = InputType<GraphQLTypes['Mutation'], typeof mutation>
@@ -0,0 +1,19 @@
import { marketplaceWriteoffProposalSelector } from '../../selectors/marketplace/writeoffSelector'
import { $, type GraphQLTypes, type InputType, type ModelTypes, Selector } from '../../zeus/index'
export const name = 'marketplaceUpdateWriteoffDraft'
export const mutation = Selector('Mutation')({
[name]: [
{ data: $('data', 'MarketplaceUpdateWriteoffDraftInput!') },
marketplaceWriteoffProposalSelector,
],
})
export interface IInput {
/** @private */
[key: string]: unknown
data: ModelTypes['MarketplaceUpdateWriteoffDraftInput']
}
export type IOutput = InputType<GraphQLTypes['Mutation'], typeof mutation>
@@ -0,0 +1,16 @@
import { marketplaceWriteoffProposalSelector } from '../../selectors/marketplace/writeoffSelector'
import { $, type GraphQLTypes, type InputType, Selector } from '../../zeus/index'
export const name = 'marketplaceWriteoffProposal'
export const query = Selector('Query')({
[name]: [{ id: $('id', 'String!') }, marketplaceWriteoffProposalSelector],
})
export interface IInput {
/** @private */
[key: string]: unknown
id: string
}
export type IOutput = InputType<GraphQLTypes['Query'], typeof query>
@@ -36,3 +36,11 @@ export * as ListMyReturnClaims from './listMyReturnClaims'
export * as ListReturnClaimsByBraname from './listReturnClaimsByBraname'
/** Эпик 7: превью заявления на гарантийный возврат для подписи заказчиком */
export * as ReturnClaimSignablePayload from './returnClaimSignablePayload'
/** Эпик 8: текущий открытый черновик проекта списания (если есть) */
export * as OpenWriteoffDraft from './openWriteoffDraft'
/** Эпик 8: лента проектов списания скоропорта с фильтром по статусу */
export * as ListWriteoffProposals from './listWriteoffProposals'
/** Эпик 8: один проект списания со всеми позициями и журналом решений */
export * as GetWriteoffProposal from './getWriteoffProposal'
/** Эпик 8: превью Заявления 1106 для подписания председателем */
export * as WriteoffStatementSignablePayload from './writeoffStatementSignablePayload'
@@ -0,0 +1,23 @@
import { paginatedMarketplaceWriteoffProposalsSelector } from '../../selectors/marketplace/writeoffSelector'
import { $, type GraphQLTypes, type InputType, type ModelTypes, Selector } from '../../zeus/index'
export const name = 'marketplaceListWriteoffProposals'
export const query = Selector('Query')({
[name]: [
{
data: $('data', 'MarketplaceListWriteoffProposalsInput!'),
options: $('options', 'PaginationInput'),
},
paginatedMarketplaceWriteoffProposalsSelector,
],
})
export interface IInput {
/** @private */
[key: string]: unknown
data: ModelTypes['MarketplaceListWriteoffProposalsInput']
options?: ModelTypes['PaginationInput']
}
export type IOutput = InputType<GraphQLTypes['Query'], typeof query>
@@ -0,0 +1,15 @@
import { marketplaceWriteoffProposalSelector } from '../../selectors/marketplace/writeoffSelector'
import { type GraphQLTypes, type InputType, Selector } from '../../zeus/index'
export const name = 'marketplaceOpenWriteoffDraft'
export const query = Selector('Query')({
[name]: marketplaceWriteoffProposalSelector,
})
export interface IInput {
/** @private */
[key: string]: unknown
}
export type IOutput = InputType<GraphQLTypes['Query'], typeof query>
@@ -0,0 +1,19 @@
import { documentSelector } from '../../selectors/common/documentSelector'
import { $, type GraphQLTypes, type InputType, type ModelTypes, Selector } from '../../zeus/index'
export const name = 'marketplaceWriteoffStatementSignablePayload'
export const query = Selector('Query')({
[name]: [
{ data: $('data', 'MarketplaceWriteoffStatementSignablePayloadInput!') },
documentSelector,
],
})
export interface IInput {
/** @private */
[key: string]: unknown
data: ModelTypes['MarketplaceWriteoffStatementSignablePayloadInput']
}
export type IOutput = InputType<GraphQLTypes['Query'], typeof query>
@@ -0,0 +1,76 @@
import { Selector, type ValueTypes } from '../../zeus/index'
import type { MakeAllFieldsRequired } from '../../utils/MakeAllFieldsRequired'
const rawItemSelector = {
braname: true,
asset_title: true,
quantity: true,
amount: true,
reason: true,
inventory_id: true,
executed: true,
}
const _validateItem: MakeAllFieldsRequired<ValueTypes['MarketplaceWriteoffProposalItem']> =
rawItemSelector
export const marketplaceWriteoffProposalItemSelector = Selector('MarketplaceWriteoffProposalItem')(
rawItemSelector,
)
const rawDecisionEntrySelector = {
at: true,
actor: true,
action: true,
}
const _validateEntry: MakeAllFieldsRequired<ValueTypes['MarketplaceWriteoffDecisionEntry']> =
rawDecisionEntrySelector
export const marketplaceWriteoffDecisionEntrySelector = Selector('MarketplaceWriteoffDecisionEntry')(
rawDecisionEntrySelector,
)
const rawProposalSelector = {
id: true,
coopname: true,
trigger: true,
status: true,
cycle_started_at: true,
proposal_hash: true,
decision_id: true,
proposed_by_account: true,
decided_by_account: true,
items: rawItemSelector,
total_amount: true,
reject_reason: true,
decision_log: rawDecisionEntrySelector,
submitted_at: true,
authorized_at: true,
executed_at: true,
rejected_at: true,
created_at: true,
updated_at: true,
}
const _validateProposal: MakeAllFieldsRequired<ValueTypes['MarketplaceWriteoffProposal']> =
rawProposalSelector
export const marketplaceWriteoffProposalSelector = Selector('MarketplaceWriteoffProposal')(
rawProposalSelector,
)
const rawPaginatedSelector = {
items: rawProposalSelector,
totalCount: true,
totalPages: true,
currentPage: true,
}
const _validatePaginated: MakeAllFieldsRequired<
ValueTypes['PaginatedMarketplaceWriteoffProposals']
> = rawPaginatedSelector
export const paginatedMarketplaceWriteoffProposalsSelector = Selector(
'PaginatedMarketplaceWriteoffProposals',
)(rawPaginatedSelector)