Эпик 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:
@@ -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();
|
||||
|
||||
+8
-1
@@ -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'],
|
||||
},
|
||||
};
|
||||
|
||||
+166
@@ -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;
|
||||
+72
@@ -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;
|
||||
}
|
||||
|
||||
+18
@@ -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 {}
|
||||
|
||||
+55
@@ -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;
|
||||
}
|
||||
+229
@@ -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);
|
||||
}
|
||||
}
|
||||
+7
@@ -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);
|
||||
}
|
||||
|
||||
+142
@@ -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 'Скоропорт';
|
||||
}
|
||||
}
|
||||
+651
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+519
@@ -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 };
|
||||
+2
@@ -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;
|
||||
}
|
||||
|
||||
+10
@@ -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;
|
||||
}
|
||||
|
||||
+96
@@ -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;
|
||||
}
|
||||
}
|
||||
+99
@@ -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;
|
||||
}
|
||||
+43
@@ -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');
|
||||
|
||||
+1
@@ -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 {
|
||||
|
||||
+113
@@ -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>;
|
||||
}
|
||||
+70
-1
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+256
@@ -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 });
|
||||
}
|
||||
}
|
||||
+9
@@ -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;
|
||||
|
||||
|
||||
+105
@@ -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;
|
||||
}
|
||||
+1
@@ -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,
|
||||
});
|
||||
|
||||
+32
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+14
@@ -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 {}
|
||||
|
||||
+33
@@ -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
|
||||
+220
@@ -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,
|
||||
}
|
||||
+182
@@ -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>
|
||||
+99
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user