Реестр заказов админа + докладка со склада в окне выдачи + фикс паевого на выдаче (598-50) #136

Closed
claude wants to merge 66 commits from feat/marketplace-admin-orders into feat/marketplace-process-registry
181 changed files with 10035 additions and 3239 deletions
@@ -86,7 +86,7 @@ namespace operations {
// marketplace — членская модель «Стола заказов».
namespace marketplace {
inline constexpr eosio::name LOCK_ORDER = "o.mkt.lock"_n; ///< Резервирование средств заказчика под конкретный Order (TRANSFER w.wal.share → w.mkt.order, Dr 80 / Cr 86 — паевой переходит в целевое финансирование на резерв-кошелёк). Единственный обязательный шаг ledger2 при createorder.
inline constexpr eosio::name CONVERT_TO_MKT_MEMBER = "o.mkt.conv"_n; ///< Конвертация паевого взноса в членский кошелёк «Стола заказов» (TRANSFER w.wal.share → w.mkt.member, Dr 80 / Cr 86). Доплата по факту (signiss2, actual > ordered) идёт ИМЕННО с членского программы — паевой сперва конвертируется сюда, напрямую с паевого не списываем.
inline constexpr eosio::name CONVERT_TO_MKT_MEMBER = "o.mkt.conv"_n; ///< Конвертация паевого взноса в членский кошелёк «Стола заказов» (TRANSFER w.wal.share → w.mkt.member, Dr 80 / Cr 86). ЕДИНСТВЕННЫЙ путь паевой→членский — только по Заявлению о конвертации (action convert). Автоматической конвертации на выдаче (signiss2) НЕТ: доплата/довзнос по факту берутся с уже внесённого членского (o.mkt.lockm/o.mkt.lockmf), при нехватке — отказ.
inline constexpr eosio::name LOCK_FROM_MEMBER = "o.mkt.lockm"_n; ///< Добор резерва заказа с членского «Стола заказов» (TRANSFER w.mkt.member → w.mkt.order, без Dr/Cr — оба кошелька на 86). Парный к o.mkt.conv шаг доплаты по факту: после конвертации добирает резерв под этот же Order.
inline constexpr eosio::name UNLOCK_ORDER = "o.mkt.unlock"_n; ///< Снятие резерва при отмене Order'а или недовыдаче (TRANSFER w.mkt.order → w.mkt.member, без Dr/Cr — оба кошелька на 86). Средства возвращаются на членский «Стола заказов» (не на универсальный членский) — остаются в программе и могут быть потрачены на следующие заказы.
inline constexpr eosio::name PURCHASE_FROM_SUPPLIER = "o.mkt.purch"_n; ///< Приёмка имущества кооперативом по АПП приёмки от поставщика (Dr 10 / Cr 86, NONE — только бухпроводка, кошельки не двигаются; имущество — аналитика по 10). Атомарно с PAY_SUPPLIER на закрывающей подписи председателя.
@@ -97,6 +97,8 @@ namespace operations {
inline constexpr eosio::name MARKDOWN_LOSS = "o.mkt.loss"_n; ///< Уценка при выдаче из остатка кооператива (NONE Dr 91 / Cr 10): разница между ценой прибытия и фактической ценой выдачи выбывает со склада в прочие расходы. Вместе с o.mkt.consum даёт выбытие по полной стоимости прибытия — на счёте 10 ничего не зависает. Накопленный расход на 91 погашается позже отдельным процессом (Dr 86 / Cr 91, аналогично списанию скоропорта через совет — пока не реализован, requirement 76 вопрос 4).
inline constexpr eosio::name MEMBERSHIP_FEE_LOCK = "o.mkt.fee"_n; ///< Блокировка членского взноса при создании заказа (TRANSFER w.wal.share → w.mkt.fee, Dr 80 / Cr 86 — как o.mkt.lock). Взнос считается от единой ставки кооператива и фиксируется явным полем Order.membership_fee; на signiss2 при факте больше заказа — дособирается этой же операцией.
inline constexpr eosio::name MEMBERSHIP_FEE_REFUND = "o.mkt.refund"_n; ///< Возврат неиспользованной части членского взноса (TRANSFER w.mkt.fee → w.mkt.member, без Dr/Cr — оба кошелька на 86). Срабатывает при отмене заказа (полностью) и при недовыдаче (пропорционально факту); симметричен o.mkt.unlock для резерва стоимости.
inline constexpr eosio::name LOCK_FEE_FROM_MEMBER = "o.mkt.lockmf"_n; ///< Блокировка членского взноса под заказ из уже внесённых членских средств пайщика (TRANSFER w.mkt.member → w.mkt.fee, без Dr/Cr — оба кошелька на 86). Парный по взносу к o.mkt.lockm (тот добирает тело заказа): stockorder фондируется из членского кошелька «Стола заказов» начисто, паевой сперва конвертируется отдельным действием (o.mkt.conv). Инверсия o.mkt.refund.
inline constexpr eosio::name REFUSAL_PENALTY = "o.mkt.penal"_n; ///< Удержание 50% при отказе пайщика от получения после акцепта поставщиком (TRANSFER w.mkt.order → w.mkt.fee, без Dr/Cr — оба кошелька на 86). Транзит: удержанная половина тела заказа кладётся в пул членских взносов, откуда вместе с удержанной половиной взноса единым o.brn.common (Branch::accrue) зачисляется в общий кошелёк КУ. Прямой TRANSFER w.mkt.order[пайщик] → w.brn.common[braname] невозможен — walletop держит один username на обе стороны. Имущество остаётся на складе КУ; вторая половина возвращается пайщику (o.mkt.unlock + o.mkt.refund).
}
// branch — экономика кооперативного участка (requirement b6).
@@ -322,6 +324,18 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
0, 0,
"Снятие резерва при отмене заказа" },
// 12b². p.mkt.supply: Удержание 50% при отказе пайщика от получения после
// акцепта поставщиком (TRANSFER w.mkt.order → w.mkt.fee, без Dr/Cr —
// оба кошелька на 86). Транзит: удержанная половина тела заказа кладётся
// в пул членских взносов, откуда вместе с удержанной половиной взноса
// единым Branch::accrue (o.brn.common) уходит в общий кошелёк КУ.
// Прямой перевод на w.brn.common невозможен — walletop держит один
// username на обе стороны (USER_SHARED[пайщик] ↛ USER_SHARED[braname]).
{ operations::marketplace::REFUSAL_PENALTY, processes::marketplace::SUPPLY, WalletOp::TRANSFER,
ledger2_wallets::MARKETPLACE_ORDER_LOCK, ledger2_wallets::MARKETPLACE_FEE_POOL,
0, 0,
"Удержание при отказе пайщика от получения после акцепта поставщиком" },
// 12c. p.mkt.supply: Приёмка имущества кооперативом по АПП приёмки
// (Dr 10 / Cr 86, NONE — только бухпроводка, кошельки не двигаются).
// Имущество — аналитикой по счёту 10 (per-КУ субсчета), без отдельного кошелька.
@@ -389,6 +403,16 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
0, 0,
"Возврат членского взноса по заказу" },
// 12j². p.mkt.supply: Блокировка членского взноса под заказ из остатка из уже
// внесённых членских средств (TRANSFER w.mkt.member → w.mkt.fee, без Dr/Cr —
// оба кошелька на 86). Парный по взносу к LOCK_FROM_MEMBER (тело): stockorder
// фондируется из членского начисто; паевой конвертируется заранее отдельным
// действием (CONVERT_TO_MKT_MEMBER). Инверсия MEMBERSHIP_FEE_REFUND.
{ operations::marketplace::LOCK_FEE_FROM_MEMBER, processes::marketplace::SUPPLY, WalletOp::TRANSFER,
ledger2_wallets::MARKETPLACE_MEMBER_FUND, ledger2_wallets::MARKETPLACE_FEE_POOL,
0, 0,
"Членский взнос «Стола заказов» из внесённых средств" },
// 13a. p.brn.fees: Зачисление 100% членского взноса в общий кошелёк КУ
// (TRANSFER w.mkt.fee → w.brn.common, без Dr/Cr — внутри 86; username = braname).
// Вызывается branch::accrue инлайн от контракта-источника при финализации заказа.
@@ -12,6 +12,7 @@
#include "../../domain/table_ledger2_userwallets.hpp"
#include "../../domain/table_marketplace_fee_config.hpp"
#include "../ledger2/ledger2.hpp"
#include "../branch/branch.hpp"
#include "memo.hpp"
#include "../../domain/table_marketplace_orders.hpp"
#include "../../domain/table_marketplace_return_requests.hpp"
@@ -203,4 +204,72 @@ inline void refund_membership_fee_if_any(eosio::name coopname, const order& o) {
Marketplace::Memo::get_membership_fee_refund_memo(o.id));
}
/// Полный возврат резерва заказа и членского взноса на членский «Стола
/// заказов» (бесплатная отмена: до акцепта поставщиком либо заказ из остатка
/// кооператива — поставщика и его риска нет).
inline void refund_order_full(eosio::name coopname, const order& o) {
Ledger2::apply(_marketplace, coopname,
operations::marketplace::UNLOCK_ORDER,
o.total_cost, o.orderer, o.hash,
Marketplace::Memo::get_cancel_order_memo(o.id));
refund_membership_fee_if_any(coopname, o);
}
/// Доля удержания при отказе пайщика от получения после акцепта поставщиком.
/// Hard-code (определяется Положением целевой потребительской программы).
inline constexpr uint64_t REFUSAL_PENALTY_PERCENT = 50;
/// Удерживаемая часть суммы (округление вниз — остаток в пользу пайщика).
inline eosio::asset refusal_penalty_share(const eosio::asset& base) {
return calc_membership_fee(base, REFUSAL_PENALTY_PERCENT);
}
/// Отказ пайщика от получения позиции после акцепта поставщиком: удержание 50%.
/// Тело заказа и членский взнос делятся пополам — удержанная половина уходит
/// в общий кошелёк КУ выдачи (тело — транзитом через пул взносов o.mkt.penalty,
/// затем единым Branch::accrue вместе с удержанной половиной взноса), вторая
/// половина возвращается пайщику на членский «Стола заказов». Имущество
/// остаётся на складе КУ (без движения по счёту 10) — кооператив несёт риск
/// уже оплаченной поставки, под который и держится удержание.
inline void retain_refusal_penalty(eosio::name coopname, const order& o) {
// ── Тело заказа 50/50 ──
const eosio::asset penalty_body = refusal_penalty_share(o.total_cost);
const eosio::asset refund_body = o.total_cost - penalty_body;
if (refund_body.amount > 0) {
Ledger2::apply(_marketplace, coopname,
operations::marketplace::UNLOCK_ORDER,
refund_body, o.orderer, o.hash,
Marketplace::Memo::get_cancel_order_memo(o.id));
}
if (penalty_body.amount > 0) {
// Транзит: удержанная половина тела → пул членских взносов, откуда уйдёт в КУ.
Ledger2::apply(_marketplace, coopname,
operations::marketplace::REFUSAL_PENALTY,
penalty_body, o.orderer, o.hash,
Marketplace::Memo::get_refusal_penalty_transit_memo(o.id));
}
// ── Членский взнос 50/50 ──
const eosio::asset fee = get_order_membership_fee(o);
const eosio::asset penalty_fee = refusal_penalty_share(fee);
const eosio::asset refund_fee = fee - penalty_fee;
if (refund_fee.amount > 0) {
Ledger2::apply(_marketplace, coopname,
operations::marketplace::MEMBERSHIP_FEE_REFUND,
refund_fee, o.orderer, o.hash,
Marketplace::Memo::get_membership_fee_refund_memo(o.id));
}
// ── Удержанное (тело + взнос) — в общий кошелёк КУ выдачи ──
// Обе удержанные половины сейчас в пуле членских взносов: тело — транзитом
// выше, взнос — ещё с createorder; единым accrue зачисляются в w.brn.common.
const eosio::asset to_common = penalty_body + penalty_fee;
if (to_common.amount > 0) {
Branch::accrue(_marketplace, coopname, o.delivery_braname,
to_common, o.hash,
Marketplace::Memo::get_refusal_penalty_distribute_memo(o.id));
}
}
} // namespace Marketplace
@@ -39,6 +39,10 @@ namespace Marketplace::Memo {
return "Резерв средств под заказ имущества № " + std::to_string(order_id) + " со склада кооператива";
}
inline std::string get_convert_to_member_memo() {
return "Конвертация паевого взноса в членский кошелёк Стола заказов по заявлению пайщика";
}
inline std::string get_membership_fee_lock_memo(uint64_t order_id) {
return "Членский взнос по заказу имущества № " + std::to_string(order_id) + " в Столе заказов";
}
@@ -63,6 +67,14 @@ namespace Marketplace::Memo {
return "Возврат резерва по отменённому заказу имущества № " + std::to_string(order_id) + " в Столе заказов";
}
inline std::string get_refusal_penalty_transit_memo(uint64_t order_id) {
return "Удержание при отказе от получения по заказу имущества № " + std::to_string(order_id) + " в Столе заказов";
}
inline std::string get_refusal_penalty_distribute_memo(uint64_t order_id) {
return "Удержание при отказе от получения по заказу имущества № " + std::to_string(order_id) + " в общий кошелёк кооперативного участка";
}
inline std::string get_decline_order_memo(uint64_t order_id) {
return "Возврат резерва по отклонённому поставщиком заказу имущества № " + std::to_string(order_id) + " в Столе заказов";
}
@@ -8,6 +8,7 @@
// ── p.mkt.supply (11 actions) ─── Stories Эпиков 4-5-6 ─────────────────
#include "src/p.mkt.supply/createorder.cpp"
#include "src/p.mkt.supply/stockorder.cpp"
#include "src/p.mkt.supply/convert.cpp"
#include "src/p.mkt.supply/markdown.cpp"
#include "src/p.mkt.supply/setfee.cpp"
#include "src/p.mkt.supply/cancelorder.cpp"
@@ -38,6 +39,7 @@
#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/confirmwroff.cpp"
[[eosio::action]] void marketplace::migrate() {
// Donor-таблиц нет (AR30 — donor-actions удалены вместе с requests/segments/
@@ -104,9 +104,15 @@ public:
* @brief Заказ из обезличенного остатка склада кооператива (requirement 76).
* Продавец — сам кооператив (`offerer == coopname`), имущество уже на
* счёте 10 после ранее закрытых приёмок, поэтому Order создаётся сразу в
* `acceptcoop` и идёт только через выдачу signiss1/signiss2. Один шаг
* ledger2: o.mkt.lock (TRANSFER w.wal.share → w.mkt.order). Этапы поставки
* `acceptcoop` и идёт только через выдачу signiss1/signiss2. Этапы поставки
* и выплата поставщику для такого заказа не существуют.
*
* Заказ из остатка ВСЕГДА фондируется из членского кошелька «Стола заказов»
* пайщика начисто (без паевого): o.mkt.lockm (тело, w.mkt.member → w.mkt.order)
* + o.mkt.lockmf (взнос, w.mkt.member → w.mkt.fee). Паевой пополняет членский
* кошелёк заранее отдельным действием `convert` (Заявление о конвертации). При
* замене непоставленного на свободный остаток высвобожденные отменой средства
* уже лежат в w.mkt.member — заказ создаётся без конвертации и доплаты.
* @ingroup public_marketplace_actions
*/
[[eosio::action]] void stockorder(eosio::name coopname,
@@ -117,8 +123,23 @@ public:
uint64_t quantity,
eosio::asset unit_price,
uint32_t warranty_period_secs,
checksum256 batch_hash,
document2 convert_statement);
checksum256 batch_hash);
/**
* @brief Конвертация паевого взноса пайщика в членский кошелёк «Стола
* заказов» (requirement 76, заказ из остатка из членских средств).
* Один шаг ledger2: o.mkt.conv (TRANSFER w.wal.share → w.mkt.member,
* Дт 80 / Кт 86). `convert_statement` — подписанное заказчиком Заявление о
* конвертации (шаблон 1110), публикуется в реестр документов отдельным
* пакетом (package = hash заявления). Выполняется перед `stockorder`, когда
* членских средств пайщика не хватает на заказ из остатка (на полную сумму
* или только на дельту превышения над высвобождённым при замене).
* @ingroup public_marketplace_actions
*/
[[eosio::action]] void convert(eosio::name coopname,
eosio::name orderer,
eosio::asset amount,
document2 convert_statement);
/**
* @brief Заказчик отменяет заказ до акцепта (Story 4.4). Триггерит o.mkt.unlock.
@@ -422,6 +443,41 @@ public:
checksum256 proposal_hash,
uint64_t item_index);
/**
* @brief Председатель кооперативного участка подтверждает фактическое
* списание со склада своего КУ по авторизованному советом проекту
* (ручной шаг стола ПВЗ).
*
* Совет лишь принимает решение о допустимости списания (proposed →
* authorized); фактическое выбытие имущества со склада инициирует
* председатель КУ, подписывая Служебную записку о списании (registry
* 1111). Один вызов закрывает все неисполненные позиции одного КУ
* (`braname`) за одну транзакцию: per-КУ гранулярность разбивает большой
* проект так, чтобы протокол поместился в лимит транзакции, и привязывает
* выбытие к подписи ответственного за склад.
*
* Эффект:
* - Ledger2::apply(o.mkt.wroff) по каждой неисполненной позиции с этим
* `braname` (Дт 86 / Кт 10), как в `execwroff`.
* - Служебная записка `memo` публикуется в реестр документов
* (`make_complete_document`, package = proposal_hash).
* - Позиции КУ помечаются executed; когда исполнены все позиции проекта,
* запись proposal стирается из RAM (терминал жизненного цикла).
*
* Guards:
* - proposal.status == AUTHORIZED (совет уже одобрил);
* - `signer` уполномочен для КУ `braname`
* (`branches[braname].is_user_authorized`) — председатель КУ или его
* доверенное лицо;
* - у проекта есть хотя бы одна неисполненная позиция этого КУ.
* @ingroup public_marketplace_actions
*/
[[eosio::action]] void confirmwroff(eosio::name coopname,
eosio::name signer,
checksum256 proposal_hash,
eosio::name braname,
document2 memo);
// ── service ──────────────────────────────────────────────────────────
/**
@@ -117,11 +117,27 @@ actions:
прибытия или с уценкой. Поставщик здесь не участвует: имущество
уже принято и оплачено кооперативом при первичной приёмке, поэтому
заказ сразу считается принятым кооперативом и идёт напрямую к
выдаче на том же участке. Кооператив резервирует средства
заказчика под заказ в момент его согласия; членский взнос по единой
ставке кооператива начисляется так же, как на обычный заказ. Вместе
с заказом заказчик подписывает заявление о конвертации паевого взноса
в членский взнос по программе — оно публикуется в реестре документов
выдаче на том же участке. Заказ со склада всегда оплачивается из
членских средств заказчика по программе «Стол заказов» — и тело
заказа, и членский взнос по единой ставке резервируются из них.
Пайщик пополняет членские средства паевым взносом заранее отдельным
действием «Конвертация» (с подписанным заявлением). Когда заказом со
склада замещают непоставленное по заказу заказчика, возвращённые ему
за непоставку средства уже лежат в членских — замена в пределах их
суммы проходит без конвертации и без доплаты.
- name: marketplace::convert
human: Перевести паевой взнос в членский «Стола заказов»
actor: orderer
role: opener
purpose: >
Заказчик подаёт заявление с просьбой перевести часть своего паевого
взноса с программы «Цифровой кошелёк» в членский взнос по программе
«Стол заказов». Переведённые средства становятся доступны для заказа
имущества со склада кооператива. Это пополнение нужно, когда членских
средств не хватает на такой заказ — на всю его сумму или только на
ту часть, что превышает уже возвращённое заказчику за непоставку при
замене. Заявление о конвертации публикуется в реестре документов
кооператива самостоятельным пакетом.
- name: marketplace::cancelorder
@@ -293,12 +309,14 @@ actions:
выдачи забрал свой заказ. Если фактически выданное расходится с
заказом — сумма заранее корректируется до фактического: при
меньшем количестве остаток возвращается на программный членский
кошелёк «Стола заказов», при большем — недостающая сумма вносится
дополнительным паевым взносом, который конвертируется в членский
«Стола заказов» и им добирается доплата (напрямую с паевого не
списываем). Вместе с фактом выдачи окончательно принимается членский
взнос: он пересчитывается пропорционально факту (излишек возвращается
заказчику на членский кошелёк программы) и целиком зачисляется в
кошелёк «Стола заказов», при большем — доплата берётся из членских
средств «Стола заказов» пайщика. На выдаче паевой взнос не
списывается и не конвертируется: если членских средств не хватает,
пайщик заранее переводит паевой взнос в членский отдельным
Заявлением о конвертации. Вместе с фактом выдачи окончательно
принимается членский взнос: он пересчитывается пропорционально
факту (излишек возвращается заказчику на членский кошелёк программы,
недостающая часть добирается тоже из членских средств) и целиком зачисляется в
общий кошелёк кооперативного участка выдачи — сначала средства
работают на участок (плановые расходы), и только потом председатель
распределяет остаток между собой и доверенными отдельной командой
@@ -406,15 +424,35 @@ transitions:
actor: orderer
ledger_code: p.mkt.supply
operations:
- o.mkt.lock
- o.mkt.fee
- o.mkt.lockm
- o.mkt.lockmf
guards:
- Заказчик — активный пайщик кооператива.
- Заказчик подписал Соглашение ЦПП «Стол заказов».
- Имущество есть в свободном остатке склада участка и предложено к приобретению оператором участка.
- На паевом взносе заказчика достаточно средств для полной стоимости заказа вместе с членским взносом.
- На членских средствах «Стола заказов» заказчика достаточно для полной стоимости заказа вместе с членским взносом (паевой пополняет их заранее действием «Конвертация»).
- Пункт выдачи — тот же участок, на складе которого лежит остаток.
# Пополнение членских средств паевым взносом под заказ со склада: отдельное
# действие по заявлению пайщика. Выполняется перед stockorder, когда членских
# средств не хватает (на всю сумму или на дельту превышения при замене).
- from: "∅"
to: "∅"
action: marketplace::convert
actor: orderer
ledger_code: p.mkt.supply
operations:
- o.mkt.conv
guards:
- Заказчик — активный пайщик кооператива.
- На паевом взносе заказчика достаточно средств на сумму конвертации.
- Подписано заявление о конвертации паевого взноса в членский «Стола заказов».
human: >
Пайщик переводит часть паевого взноса в членский взнос Стола заказов,
чтобы оплатить им заказ имущества со склада кооператива без доплаты
на месте. Конвертируется либо вся требуемая сумма, либо только та
часть, что превышает уже возвращённое заказчику за непоставку при замене.
- from: active
to: "∅"
action: marketplace::cancelorder
@@ -424,7 +462,11 @@ transitions:
- o.mkt.unlock
- o.mkt.refund
guards:
- Заказ ещё не вошёл в акцептованную поставщиком консолидированную заявку.
- Поставщик ещё не принял заявку в работу.
human: >
Бесплатная отмена. Пока поставщик не принял заявку, он не взял на себя
обязательство и ничего не везёт — кооператив полностью возвращает
заказчику зарезервированные средства и членский взнос.
- from: accepted_to_coop
to: "∅"
@@ -435,8 +477,31 @@ transitions:
- o.mkt.unlock
- o.mkt.refund
guards:
- Заказ сделан из свободного остатка склада кооператива (обычная поставка в этом состоянии не отменяется).
- Заказ сделан из свободного остатка склада кооператива (по нему поставщика нет).
- Выдача по заказу ещё не открыта — оператор участка не поставил свою подпись на акте выдачи.
human: >
Бесплатная отмена заказа из остатка склада: поставщика и риска перед ним
нет, поэтому средства и членский взнос возвращаются заказчику полностью.
- from: accepted | supply_prepared | accepted_to_coop
to: "∅"
action: marketplace::cancelorder
actor: orderer
ledger_code: p.mkt.supply
operations:
- o.mkt.unlock
- o.mkt.penal
- o.mkt.refund
guards:
- Поставщик уже принял заявку в работу (для заказа из обычной поставки, не из остатка).
- Акт выдачи по заказу ещё не открыт.
human: >
Отказ от получения после того, как поставщик принял заявку. Поставщик уже
обязался доставить имущество, а кооператив несёт риск его оплаты, поэтому
кооператив удерживает половину стоимости заказа и половину членского
взноса в общий кошелёк кооперативного участка; имущество остаётся на складе
участка. Вторую половину кооператив возвращает заказчику на его членский
счёт в программе.
- from: active
to: "∅"
@@ -469,6 +534,24 @@ transitions:
guards:
- Поставщик ещё не акцептовал консолидированную заявку.
- from: accepted | supply_prepared
to: "∅"
action: marketplace::declinebatch
actor: offerer
ledger_code: p.mkt.supply
operations:
- o.mkt.unlock
- o.mkt.refund
guards:
- Имущество ещё не принято кооперативом на склад (закрывающая подпись приёмки не поставлена).
human: >
Отказ в приёмке: поставщик привёз позицию, которую кооператив не
принимает (например, некондицию). Кооператив снимает позицию с приёмки —
она не попадает в акт, — а поставщик подтверждает отмену поставки этой
позиции и забирает имущество обратно. Штрафа поставщику нет (на текущем
этапе), заказчику полностью возвращаются стоимость и членский взнос:
непринятие — не его вина.
- from: accepted
to: ship_ready
action: marketplace::prepship
@@ -934,6 +1017,36 @@ operations:
По бухгалтерии — без проводки (оба кошелька на счёте 86 — перевод
внутри одной аналитики).
- ledger_code: o.mkt.penal
human_name: Удержание при отказе от получения
wallet_op: TRANSFER
# L1 — без проводки (оба кошелька на 86, фиктивная Дт=Кт игнорируется)
debit: null
credit: null
# L2 — удержанная половина тела заказа кладётся в пул членских взносов как
# транзит, откуда тем же зачислением, что и взнос, уходит в общий кошелёк
# кооперативного участка. Прямой перевод из резерва пайщика в кошелёк участка
# невозможен — у движения одна сторона-пайщик, другая — участок.
wallet_from: w.mkt.order # Резерв под Order
wallet_to: w.mkt.fee # Резерв членских взносов (транзит к кошельку участка)
# L3 — списание у пайщика; у пула разреза по пайщику нет.
l3:
- user_wallet: w.mkt.order
user_ref: order.orderer
available_delta: -order.total_cost / 2
blocked_delta: null
amount_ref: order.total_cost / 2
triggered_by: marketplace::cancelorder
description: >
Если заказчик отказывается получить заказ после того, как поставщик
принял заявку в работу, кооператив удерживает половину стоимости заказа.
Поставщик уже обязался доставить имущество, и кооператив несёт риск его
оплаты, поэтому удержанная половина остаётся в кооперативе — переводится
в общий кошелёк кооперативного участка (вместе с удержанной половиной
членского взноса), а имущество остаётся на складе участка. Вторая половина
возвращается заказчику. По бухгалтерии — без проводки (перевод внутри
счёта 86).
- ledger_code: o.mkt.fee
human_name: Членский взнос по заказу
wallet_op: TRANSFER
@@ -951,7 +1064,7 @@ operations:
available_delta: -order.membership_fee
blocked_delta: null
amount_ref: order.membership_fee
triggered_by: marketplace::createorder | marketplace::stockorder | marketplace::signiss2
triggered_by: marketplace::createorder | marketplace::signiss2
description: >
Членский взнос по единой ставке кооператива резервируется вместе со
стоимостью имущества при создании заказа; его сумма явно фиксируется
@@ -1003,15 +1116,17 @@ operations:
user_ref: order.orderer
available_delta: +diff
blocked_delta: null
amount_ref: fact_cost - order.total_cost
triggered_by: marketplace::signiss2
amount_ref: fact_cost - order.total_cost | сумма заявления о конвертации
triggered_by: marketplace::convert | marketplace::signiss2
description: >
Доплата по факту при выдаче большего количества/по большей цене НЕ
списывается с паевого напрямую: дополнительный паевой взнос заказчика
переводится в членский взнос Стола заказов, которым и добирается
резерв заказа. Двойная запись Дт 80 / Кт 86 — переход из складочного
капитала в целевое финансирование. Если на паевом не хватает средств
на разницу — выдача с доплатой не проводится.
Паевой взнос заказчика переводится в членский взнос Стола заказов.
Применяется в двух случаях: (1) пайщик заранее пополняет членские
средства под заказ имущества со склада (действие «Конвертация» по
заявлению — на всю сумму либо на дельту превышения над возвращённым
при замене); (2) доплата по факту при выдаче большего количества/по
большей цене не списывается с паевого напрямую, а сперва конвертируется
сюда. Двойная запись Дт 80 / Кт 86 — переход из складочного капитала в
целевое финансирование. Если на паевом не хватает средств — не проводится.
- ledger_code: o.mkt.lockm
human_name: Добор резерва заказа с членского «Стола заказов»
@@ -1032,12 +1147,40 @@ operations:
user_ref: order.orderer
available_delta: +diff
blocked_delta: null
amount_ref: fact_cost - order.total_cost
triggered_by: marketplace::signiss2
amount_ref: fact_cost - order.total_cost | order.total_cost
triggered_by: marketplace::stockorder | marketplace::signiss2
description: >
Шаг доплаты по факту: членским взносом Стола заказов добирается
резерв этого же заказа на разницу. Списание под заказ идёт именно с
членского кошелька программы, не с паевого напрямую. По бухгалтерии —
Резерв под заказ набирается из членского кошелька «Стола заказов»
пайщика. Заказ имущества со склада кооператива (stockorder) фондируется
так целиком — тело заказа берётся из членских средств начисто. Тем же
шагом добирается резерв при доплате по факту, когда выдано больше
заказанного. Списание под заказ идёт именно с членского кошелька
программы, не с паевого напрямую. По бухгалтерии — без проводки (оба
кошелька на счёте 86 — перевод внутри одной аналитики).
- ledger_code: o.mkt.lockmf
human_name: Членский взнос «Стола заказов» из внесённых средств
wallet_op: TRANSFER
# L1 — без проводки (оба кошелька на 86, фиктивная Дт=Кт игнорируется)
debit: null
credit: null
# L2 — членский взнос под заказ резервируется из членского кошелька пайщика
wallet_from: w.mkt.member # Членский кошелёк «Стола заказов» пайщика
wallet_to: w.mkt.fee # Резерв членских взносов под заказы
# L3 — у пула резервов взносов разреза по пайщику нет (см. o.mkt.fee)
l3:
- user_wallet: w.mkt.member
user_ref: order.orderer
available_delta: -order.membership_fee
blocked_delta: null
amount_ref: order.membership_fee
triggered_by: marketplace::stockorder
description: >
Членский взнос по заказу имущества со склада кооператива резервируется
из уже внесённых членских средств пайщика, а не с паевого. Парный по
взносу к набору тела заказа из членского кошелька: заказ со склада
оплачивается из членских средств начисто. Паевой пополняет членский
кошелёк заранее отдельным действием «Конвертация». По бухгалтерии —
без проводки (оба кошелька на счёте 86 — перевод внутри одной аналитики).
- ledger_code: o.mkt.purch
@@ -3,12 +3,19 @@
# списания со склада участка имущества, которое физически пропало или стало
# непригодным к выдаче заказчику (просрочка, повреждения, малоценные позиции).
#
# Процесс инициируется кооперативом по расписанию (по умолчанию раз в месяц;
# точную дату согласовывает бухгалтер). По итогам опроса складов председатель
# подписывает заявление о списании и вносит его на рассмотрение совета.
# Совет рассматривает заявление через типовой процесс решения совета
# (стандарт sov.decision) и подписывает протокол. По принятому протоколу
# кооператив списывает каждую позицию проекта со склада участка.
# Процесс инициируется кооперативом (вручную председателем или по расписанию;
# точную дату согласовывает бухгалтер). По итогам опроса складов оформляется
# проект списания, который вносится на рассмотрение совета через типовой
# процесс решения совета (стандарт sov.decision). Совет рассматривает проект
# и принимает решение. По принятому решению фактическое списание со склада
# каждого кооперативного участка подтверждает председатель этого участка,
# подписывая Служебную записку о списании, — только после этой подписи
# имущество выбывает со склада.
#
# Совет лишь признаёт списание допустимым; физическое выбытие имущества
# инициирует ответственный за склад председатель кооперативного участка.
# Подтверждение — пер-КУ: проект может охватывать несколько участков, и
# председатель каждого участка подтверждает только свою часть.
#
# Списание фиксирует, что имущество безвозвратно исчезло из кооператива
# (а не передано пайщику). По кошелькам заказчиков движений не происходит —
@@ -50,58 +57,71 @@ purpose: >
бесконечно — здесь кооператив честно фиксирует свои потери через
решение совета.
Процесс состоит из двух шагов. На первом шаге кооператив по расписанию
(по умолчанию раз в месяц; точную дату согласовывает бухгалтер)
Процесс состоит из трёх шагов. На первом шаге кооператив (вручную
председателем или по расписанию; точную дату согласовывает бухгалтер)
опрашивает склады участков и собирает позиции, удовлетворяющие
критериям списания. Председатель оформляет проект списания, подписывает
заявление и вносит его на рассмотрение совета. На втором шаге совет
по типовому процессу решения совета рассматривает заявление и
подписывает протокол: при положительном решении каждая позиция
списывается со склада как безвозвратные потери; при отрицательном
проект отклоняется и позиции остаются на складе.
критериям списания. Председатель оформляет проект списания и вносит его
на рассмотрение совета. На втором шаге совет по типовому процессу
решения совета рассматривает проект и принимает решение. На третьем шаге
фактическое списание со склада каждого участка подтверждает председатель
этого участка, подписывая Служебную записку о списании: только после
подписи имущество выбывает со склада как безвозвратные потери. При
отрицательном решении совета проект отклоняется и позиции остаются на
складе.
По кошелькам заказчиков движений не происходит — это чисто кооперативный
учётный расход на счёт целевого финансирования программы.
roles:
- chairman # председатель кооператива (подписывает заявление, вносит на совет)
- council # совет кооператива (принимает решение по протоколу)
- chairman # председатель кооператива (вносит проект на рассмотрение совета)
- council # совет кооператива (принимает решение по проекту)
- branch_chairman # председатель кооперативного участка (подтверждает фактическое списание со своего склада)
# ── Секция 2. Действия контракта (блокчейн-уровень) ─────────────────────────
# Имена actions ≤12 символов eosio::name. Заявление председателя на
# рассмотрение совета вносится по типовому процессу «Решение совета»
# (стандарт sov.decision), поэтому здесь перечислены только специфические
# для списания действия — внесение проекта и исполнение позиций по
# принятому протоколу совета.
# Имена actions ≤12 символов eosio::name. Внесение проекта на рассмотрение
# совета идёт по типовому процессу «Решение совета» (стандарт sov.decision),
# поэтому здесь перечислены только специфические для списания действия —
# внесение проекта и подтверждение фактического списания по участкам.
actions:
- name: marketplace::propwroff
human: Внести проект списания на рассмотрение совета
actor: chairman
role: opener
purpose: >
Председатель подписывает заявление о списании скоропорта — список
позиций, которые невозможно выдать заказчику (просроченные и не
востребованные, повреждённые, малоценные), — и вносит проект на
рассмотрение совета. Дальнейшее движение проекта (повестка совета,
голосование, подписание протокола) — по типовому процессу решения
совета.
Председатель оформляет проект списания скоропорта — список позиций,
которые невозможно выдать заказчику (просроченные и не востребованные,
повреждённые, малоценные), — и вносит его на рассмотрение совета.
Дальнейшее движение проекта (повестка совета, голосование, подписание
протокола) — по типовому процессу решения совета.
- name: marketplace::execwroff
human: Исполнить позицию списания по протоколу совета
actor: chairman
- name: marketplace::confirmwroff
human: Подтвердить фактическое списание со склада участка
actor: branch_chairman
role: closer
purpose: >
После принятия советом положительного решения по каждой позиции
проекта выполняется списание: имущество выбывает со склада участка,
а его стоимость относится на расход целевых средств программы.
Списание оформляется отдельно по каждой позиции проекта; когда
списаны все позиции, проект считается завершённым.
После принятия советом положительного решения председатель
кооперативного участка подтверждает фактическое выбытие имущества со
склада своего участка, подписывая Служебную записку о списании. Один
вызов закрывает все позиции данного участка в проекте (гранулярность —
пер-КУ): их стоимость относится на расход целевых средств программы.
Когда подтверждены все участки проекта, проект считается завершённым.
- name: marketplace::execwroff
human: Исполнить отдельную позицию списания (низкоуровневый примитив)
actor: chairman
role: aux
purpose: >
Низкоуровневый per-item примитив списания одной позиции авторизованного
проекта. В штатном пути не используется: фактическое списание идёт через
подтверждение председателя участка (confirmwroff). Оставлен для
совместимости и сервисных сценариев.
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
# Сущность: marketplace::writeoff_proposal (таблица wroffprops, scope=coopname).
# Запись агрегирует список позиций, попавших в один цикл списания, и живёт
# только пока проект на повестке совета или исполняется. Исполнение последней
# позиции либо отказ совета завершают жизненный цикл — запись удаляется,
# история проекта сохраняется в журнале действий и решении совета.
# только пока проект на повестке совета или ожидает подтверждения складов.
# Подтверждение последнего участка либо отказ совета завершают жизненный цикл —
# запись удаляется, история проекта сохраняется в журнале действий и решении
# совета.
entity: marketplace::writeoff_proposal
entity_human: Проект списания скоропорта
entity_source: cpp/marketplace/src/p.mkt.wroff/
@@ -110,17 +130,18 @@ states:
- name: proposed
human: На рассмотрении совета
description: >
Председатель подписал заявление о списании и внёс проект на
рассмотрение совета. Ожидается решение совета по типовому процессу
решения совета.
Проект списания внесён на рассмотрение совета. Ожидается решение
совета по типовому процессу решения совета.
kind: normal
- name: authorized
human: Решение совета принято
human: Решение совета принято, ожидает подтверждения складов
description: >
Совет принял положительное решение по проекту и подписал протокол.
По каждой позиции проекта последовательно выполняется списание со
склада участка.
Фактическое списание ожидает подтверждения председателя каждого
кооперативного участка, чьё имущество входит в проект. Позиции
участка списываются, когда его председатель подписывает Служебную
записку о списании.
kind: normal
transitions:
@@ -130,7 +151,6 @@ transitions:
actor: chairman
guards:
- Наступил регламентный срок очередного цикла списания (или ручной запуск председателем).
- Председатель подписал Заявление о списании скоропорта.
- На складах участков найдены позиции, удовлетворяющие критериям списания.
- from: proposed
@@ -142,16 +162,17 @@ transitions:
- from: authorized
to: "∅"
action: marketplace::execwroff
actor: chairman
action: marketplace::confirmwroff
actor: branch_chairman
ledger_code: p.mkt.wroff
operations:
- o.mkt.wroff
guards:
- Решение совета по проекту принято и протокол подписан.
- Все позиции проекта последовательно исполнены — имущество ушло из
учёта как безвозвратные потери, по кошелькам заказчиков движений
не было.
- Председатель каждого участка подтвердил фактическое списание со своего
склада, подписав Служебную записку о списании.
- Когда подтверждены все участки проекта — имущество ушло из учёта как
безвозвратные потери, по кошелькам заказчиков движений не было.
- from: proposed
to: "∅"
@@ -169,18 +190,17 @@ scenario:
actor: chairman
action: marketplace::propwroff
description: >
Кооператив по расписанию (по умолчанию раз в месяц; точную дату
Кооператив (вручную председателем или по расписанию; точную дату
согласовывает бухгалтер) опрашивает склады участков и собирает
позиции, которые невозможно выдать заказчику: просроченные и не
востребованные, повреждённые, малоценные. Председатель оформляет
проект списания со списком позиций, подписывает заявление и
вносит проект на рассмотрение совета.
проект списания со списком позиций и вносит проект на рассмотрение
совета.
pre:
- Наступил регламентный срок очередного цикла списания.
- Наступил регламентный срок очередного цикла списания (или ручной запуск).
- На складах участков найдены позиции, удовлетворяющие критериям списания.
- Председатель подписал Заявление о списании скоропорта.
post:
- Проект списания сформирован и подписан председателем.
- Проект списания сформирован.
- Проект внесён на рассмотрение совета.
- step: 2
@@ -188,32 +208,35 @@ scenario:
actor: council
action: null
description: >
Совет рассматривает заявление председателя по типовому процессу
решения совета — на очном заседании или в форме заочного
голосования — и принимает решение. При положительном решении
председатель подписывает протокол о списании. При отрицательном —
проект отклоняется.
Совет рассматривает проект по типовому процессу решения совета —
на очном заседании или в форме заочного голосования — и принимает
решение. При положительном решении председатель подписывает протокол
о списании, и проект переходит в ожидание подтверждения складов. При
отрицательном — проект отклоняется.
pre:
- Проект внесён на рассмотрение совета.
post:
- Совет принял решение по проекту и подписал протокол.
- step: 3
title: Исполнение списания
actor: chairman
action: marketplace::execwroff
title: Подтверждение фактического списания председателем участка
actor: branch_chairman
action: marketplace::confirmwroff
description: >
По принятому советом протоколу кооператив последовательно
списывает каждую позицию проекта со склада участка. Имущество
безвозвратно выбывает из учёта; сумма закрывается на счёт
целевого финансирования программы. По кошелькам заказчиков
движений нет.
По принятому советом решению председатель каждого кооперативного
участка, чьё имущество входит в проект, подтверждает фактическое
списание со своего склада, подписывая Служебную записку о списании.
После подписи позиции этого участка выбывают со склада; их стоимость
закрывается на счёт целевого финансирования программы. По кошелькам
заказчиков движений нет. Когда подтверждены все участки проекта,
проект завершён.
pre:
- Совет принял положительное решение по проекту.
- Протокол совета о списании подписан.
- Председатель участка подписал Служебную записку о списании.
post:
- Все позиции проекта списаны со складов участков.
- Имущество ушло из учёта как безвозвратные потери.
- Позиции участка списаны со склада.
- Когда подтверждены все участки — имущество ушло из учёта как безвозвратные потери.
alternatives:
- branch: Совет отклонил проект
@@ -240,26 +263,35 @@ scenario:
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
# Заявление председателя — собственный документ процесса (вносится советом
# на рассмотрение). Протокол совета — документ типового процесса решения
# совета (по стандарту sov.decision), к которому привязан проект списания.
# совета (по стандарту sov.decision). Служебная записка о списании —
# собственный документ процесса, подписывается председателем участка при
# подтверждении фактического списания.
documents:
- action: marketplace::propwroff
title: Заявление о списании скоропорта
registry_id: 1106
registry_id: 1108
signed_by: [chairman]
stored_in: writeoff_proposals.statement
note: "Подписывается председателем перед внесением проекта на рассмотрение совета. Содержит список позиций к списанию (участок, наименование, количество, сумма, причина). Источник правды — components/cooptypes/src/cooperative/registry/1106.MarketplaceWriteoffStatement"
note: "Подписывается председателем перед внесением проекта на рассмотрение совета. Содержит список позиций к списанию (участок, наименование, количество, сумма, причина). Источник правды — components/cooptypes/src/cooperative/registry/1108.MarketplaceWriteoffStatement"
- action: null
title: Протокол совета о списании скоропорта
registry_id: 1105
registry_id: 1107
signed_by: [chairman]
stored_in: writeoff_proposals.protocol
note: "Протокол по типовому процессу решения совета (стандарт sov.decision). Подписывается председателем после принятия советом положительного решения. Источник правды — components/cooptypes/src/cooperative/registry/1105.MarketplaceWriteoffProtocol"
note: "Протокол по типовому процессу решения совета (стандарт sov.decision). Подписывается председателем после принятия советом положительного решения. Источник правды — components/cooptypes/src/cooperative/registry/1107.MarketplaceWriteoffProtocol"
- action: marketplace::confirmwroff
title: Служебная записка о списании
registry_id: 1111
signed_by: [branch_chairman]
stored_in: "реестр документов (package = proposal_hash)"
note: "Подписывается председателем кооперативного участка при подтверждении фактического списания со своего склада. Завершающий документ пути; одна записка на один участок. Источник правды — components/cooptypes/src/cooperative/registry/1111.MarketplaceWriteoffServiceMemo"
# ── Секция 6. Операции (Ledger2) ────────────────────────────────────────────
# Одна операция — списание имущества со склада через целевое
# финансирование программы. Срабатывает по каждой позиции проекта в
# транзакции исполнения списания.
# финансирование программы. Срабатывает по каждой позиции участка в
# транзакции подтверждения списания (confirmwroff).
operations:
- ledger_code: o.mkt.wroff
human_name: Утилизация скоропорта
@@ -277,10 +309,10 @@ operations:
available_delta: null
blocked_delta: null
amount_ref: wroff_item.amount
triggered_by: marketplace::execwroff
triggered_by: marketplace::confirmwroff
description: >
Списание имущества со склада участка по протоколу совета. Двойная
запись Дт 86 / Кт 10 — расход целевых средств программы на выбытие
имущества со склада. Кошельки не двигаются — расход чисто кооперативный.
Применяется по каждой позиции из списка проекта — несколько
последовательных операций в одной транзакции исполнения.
Списание имущества со склада участка по решению совета, подтверждённому
председателем участка. Двойная запись Дт 86 / Кт 10 — расход целевых
средств программы на выбытие имущества со склада. Кошельки не двигаются —
расход чисто кооперативный. Применяется по каждой позиции участка —
несколько последовательных операций в одной транзакции подтверждения.
@@ -1,20 +1,22 @@
/**
* @brief Заказчик отменяет заказ до акцепта поставщиком (Story 4.4, p.mkt.supply).
* @brief Заказчик отменяет заказ / отказывается от получения позиции
* (Story 4.4 + удержание при отказе, p.mkt.supply).
*
* Триггерит `o.mkt.unlock` на full `order.total_cost` — TRANSFER w.mkt.order →
* w.mkt.member (снятие резерва). Сумма возвращается на членский «Стола заказов»
* `w.mkt.member.available` заказчика — остаётся в программе под следующие заказы.
* Граница удержания — акцепт поставщиком (acceptorder). До акцепта поставщик
* ещё не взял обязательство и ничего не везёт — отмена бесплатна (полный
* возврат резерва и членского взноса). После акцепта поставщик уже принял
* заявку и доставляет имущество, кооператив несёт риск его оплаты — отказ
* пайщика удерживает 50% (тела заказа и взноса) в общий кошелёк КУ; имущество
* остаётся на складе КУ, вторая половина возвращается пайщику.
*
* Guards:
* - Order существует.
* - actor == order.orderer.
* - Order в статусе active (до acceptorder). После acceptorder отмена
* запрещена — поставщик уже взял обязательство.
* - Исключение — заказ из остатка кооператива (offerer == coopname,
* см. stockorder): он рождается сразу в acceptcoop и отменяется в этом
* статусе — до первой подписи акта выдачи оператор может откатить и
* переформировать докладку (requirement 76, решение 11). После signiss1
* статус уходит в ready_to_receive и отмена закрыта.
* - Order существует, actor == order.orderer.
* - active → бесплатно (полный возврат).
* - accepted / supplyprep / acceptcoop → удержание 50% (после акцепта).
* - readyrecv / received → закрыто (акт выдачи уже открыт).
* - Заказ из остатка кооператива (offerer == coopname, см. stockorder):
* поставщика и его риска нет — отмена бесплатна в acceptcoop до первой
* подписи акта выдачи (requirement 76, решение 11); после signiss1 закрыта.
*
* @ingroup public_marketplace_actions
*/
@@ -26,23 +28,25 @@ void marketplace::cancelorder(eosio::name coopname,
auto o = Marketplace::get_order_by_hash_or_fail(coopname, order_hash);
eosio::check(o.orderer == orderer, "Вы не заказчик этого заказа");
const bool is_stock_order = (o.offerer == coopname);
if (is_stock_order) {
// Остаток кооператива: нет поставщика — отмена бесплатна до выдачи.
eosio::check(o.status == OrderStatus::ACCEPTED_TO_COOP,
"Нельзя отменить заказ из остатка: выдача уже открыта");
Marketplace::refund_order_full(coopname, o);
} else if (o.status == OrderStatus::ACTIVE) {
// До акцепта поставщиком — бесплатно.
Marketplace::refund_order_full(coopname, o);
} else if (o.status == OrderStatus::ACCEPTED ||
o.status == OrderStatus::SUPPLY_PREPARED ||
o.status == OrderStatus::ACCEPTED_TO_COOP) {
// После акцепта поставщиком — удержание 50%.
Marketplace::retain_refusal_penalty(coopname, o);
} else {
eosio::check(o.status == OrderStatus::ACTIVE,
"Нельзя отменить заказ: он уже акцептован поставщиком");
eosio::check(false, "Нельзя отменить заказ: акт выдачи уже открыт");
}
Ledger2::apply(_marketplace, coopname,
operations::marketplace::UNLOCK_ORDER,
o.total_cost, orderer, o.hash,
Marketplace::Memo::get_cancel_order_memo(o.id));
// Членский взнос возвращается полностью (o.mkt.refund, requirement b6).
Marketplace::refund_membership_fee_if_any(coopname, o);
// Отмена — терминал жизненного цикла заказа: запись стирается из RAM,
// Отмена / отказ — терминал жизненного цикла заказа: запись стирается из RAM,
// история остаётся в журнале действий.
Marketplace::erase_order(coopname, o.id);
}
@@ -0,0 +1,65 @@
/**
* @brief Конвертация паевого взноса пайщика в членский кошелёк «Стола заказов»
* (requirement 76, заказ из остатка из членских средств).
*
* Заказ из остатка (`stockorder`) фондируется ВСЕГДА из членского кошелька
* пайщика начисто. Этот action — отдельный шаг пополнения членского кошелька
* с паевого: пайщик подаёт Заявление о конвертации (шаблон 1110) с просьбой
* транслировать паевой взнос с программы «Цифровой кошелёк» в программу
* «Членский стол заказов». Зеркало `capital::convertsegm` в Благорост.
*
* Одна ledger2-операция:
* - `o.mkt.conv` (TRANSFER w.wal.share → w.mkt.member, Дт 80 / Кт 86) —
* паевой переходит в целевое финансирование на членский кошелёк программы.
*
* Когда вызывается (оркестрация на backend, всё в одной транзакции с `stockorder`):
* - докладка у стойки / обычный заказ из остатка, когда членских средств не
* хватает: конвертируется вся требуемая сумма;
* - замена непоставленного на свободный остаток, когда замена дороже
* высвобожденного отменой бюджета: конвертируется только дельта превышения
* (в рамках высвобожденного доплаты с паевого нет).
*
* Guards:
* - amount > 0 в _root_govern_symbol; подпись Заявления валидна (orderer).
* - Заказчик — активный пайщик кооператива.
* - w.wal.share.available заказчика >= amount.
*
* @ingroup public_marketplace_actions
*/
void marketplace::convert(eosio::name coopname,
eosio::name orderer,
eosio::asset amount,
document2 convert_statement) {
require_auth(coopname);
// ── Валидация параметров и подписи Заявления ────────────────────────
eosio::check(amount.is_valid() && amount.amount > 0,
"Сумма конвертации должна быть больше нуля");
eosio::check(amount.symbol == _root_govern_symbol,
"Некорректный символ валюты в сумме конвертации");
eosio::check(!is_empty_document(convert_statement),
"Отсутствует заявление о конвертации паевого взноса");
verify_document_or_fail(convert_statement, { orderer });
// Заказчик — активный пайщик кооператива (бросает если не найден / blocked)
get_participant_or_fail(coopname, orderer);
// ── Достаточность паевого: w.wal.share.available >= amount ───────────
auto bal_share = Marketplace::get_user_wallet_balance(
coopname, ledger2_wallets::SHARE_FUND_PAY, orderer);
eosio::check(bal_share.available >= amount,
std::string{"Недостаточно паевых средств для конвертации: требуется "} +
amount.to_string() + ", доступно " + bal_share.available.to_string());
// ── o.mkt.conv: TRANSFER w.wal.share → w.mkt.member (Дт 80 / Кт 86) ──
Ledger2::apply(_marketplace, coopname,
operations::marketplace::CONVERT_TO_MKT_MEMBER,
amount, orderer, convert_statement.hash,
Marketplace::Memo::get_convert_to_member_memo());
// Заявление о конвертации публикуется в реестр документов отдельным
// самостоятельным пакетом (package = hash самого заявления).
Soviet::make_complete_document(_marketplace, coopname, orderer,
"convert"_n,
convert_statement.hash, convert_statement);
}
@@ -1,14 +1,24 @@
/**
* @brief Поставщик отказывается от одного Order'а до акцепта (Story 4.5, p.mkt.supply).
* @brief Поставщик отказывается от одного Order'а (Story 4.5 + отказ в приёмке,
* p.mkt.supply).
*
* Два случая, оба со стороны поставщика и оба без штрафа — полный возврат
* резерва и членского взноса заказчику (вина не его):
* 1. До акцепта (active) — поставщик не берёт заявку в работу.
* 2. Отказ в приёмке (accepted / supply_prepared) — поставщик привёз
* некондицию; кооператив не принимает позицию (ноль единиц в факт),
* поставщик подтверждает отмену поставки этой позиции и забирает имущество
* обратно. Имущество ещё не оприходовано на склад (purch на закрывающей
* подписи приёмки) и поставщик ещё не оплачен — клоубэка нет.
*
* Per-Order: o.mkt.unlock на total_cost (TRANSFER w.mkt.order → w.mkt.member —
* резерв возвращается на членский «Стола заказов» заказчика) + статус active →
* cancelled. Backend проходит циклом по orders соответствующего batch'а,
* вызывая `declineorder` per Order — векторов order_hash нет.
* резерв возвращается на членский «Стола заказов» заказчика) + полный возврат
* взноса + erase. Backend проходит циклом по неотмеченным в приёмке orders —
* векторов order_hash нет.
*
* Guards:
* - Order существует и в статусе active.
* - actor == order.offerer.
* - Order существует, actor == order.offerer.
* - Статус active / accepted / supply_prepared (до оприходования имущества).
*
* @ingroup public_marketplace_actions
*/
@@ -19,8 +29,10 @@ void marketplace::declineorder(eosio::name coopname,
auto o = Marketplace::get_order_by_hash_or_fail(coopname, order_hash);
eosio::check(o.offerer == offerer, "Вы не поставщик этого заказа");
eosio::check(o.status == OrderStatus::ACTIVE,
"Заказ уже не в активном статусе — отклонить нельзя");
eosio::check(o.status == OrderStatus::ACTIVE ||
o.status == OrderStatus::ACCEPTED ||
o.status == OrderStatus::SUPPLY_PREPARED,
"Заказ нельзя отклонить: имущество уже принято кооперативом");
Ledger2::apply(_marketplace, coopname,
operations::marketplace::UNLOCK_ORDER,
@@ -13,14 +13,15 @@
* w.mkt.member на разницу (остаток резерва на членский «Стола заказов»).
* затем — consum на fact_cost.
*
* 3) actual > ordered (доплата по факту — НЕ списываем с паевого напрямую):
* 3) actual > ordered (доплата по факту — ТОЛЬКО с членского, паевой не трогаем):
* diff = fact_cost - ordered_cost
* проверка достаточности средств для diff на w.wal.share (источник конвертации).
* o.mkt.conv(diff) — TRANSFER w.wal.share → w.mkt.member, Дт 80 / Кт 86 —
* дополнительный паевой взнос конвертируется в членский «Стола заказов».
* проверка достаточности diff на w.mkt.member (членский «Стола заказов»).
* o.mkt.lockm(diff) — TRANSFER w.mkt.member → w.mkt.order (без проводки) —
* добор резерва на этот же Order ИМЕННО с членского программы.
* добор резерва на этот же Order с членского программы.
* Затем — consum на fact_cost.
* Недостающее на членском пайщик переводит ЗАРАНЕЕ отдельным Заявлением о
* конвертации (action convert, o.mkt.conv) — авто-конвертации паевого на
* выдаче нет НИГДЕ; при нехватке членского транзакция фейлится.
*
* Status: ready_to_receive → received. actual_quantity, fact_cost,
* issue_act_signiss2, warranty_until заполняются.
@@ -85,25 +86,20 @@ void marketplace::signiss2(eosio::name coopname,
Marketplace::Memo::get_signiss2_correction_less_memo(o.id));
} else if (fact_cost > o.total_cost) {
// actual > ordered: доплату НЕ списываем с паевого напрямую. Дополнительный
// паевой взнос конвертируется в членский «Стола заказов» (o.mkt.conv), и уже
// с членского программы добирается резерв этого же заказа (o.mkt.lockm).
// actual > ordered: доплата по факту — ТОЛЬКО с членского «Стола заказов».
// Паевой на выдаче не трогаем НИКОГДА: недостающее пайщик переводит в членский
// ЗАРАНЕЕ отдельным Заявлением о конвертации (action convert, o.mkt.conv).
// Здесь — проверка членского и добор резерва этого же заказа из него (o.mkt.lockm).
const eosio::asset diff = fact_cost - o.total_cost;
// Проверка доступности diff на паевом заказчика — источнике конвертации.
auto bal_share = Marketplace::get_user_wallet_balance(
coopname, ledger2_wallets::SHARE_FUND_PAY, orderer);
eosio::check(bal_share.available >= diff,
std::string{"Недостаточно средств для дооплаты по факту: требуется "} +
diff.to_string() + ", доступно " + bal_share.available.to_string());
auto bal_member = Marketplace::get_user_wallet_balance(
coopname, ledger2_wallets::MARKETPLACE_MEMBER_FUND, orderer);
eosio::check(bal_member.available >= diff,
std::string{"Недостаточно членских средств «Стола заказов» для дооплаты по факту: требуется "} +
diff.to_string() + ", доступно " + bal_member.available.to_string() +
". Сперва переведите паевой в членский (Заявление о конвертации).");
// 1) Конвертация паевой → членский «Стола заказов» (Дт 80 / Кт 86).
Ledger2::apply(_marketplace, coopname,
operations::marketplace::CONVERT_TO_MKT_MEMBER,
diff, orderer, o.hash,
Marketplace::Memo::get_signiss2_correction_more_convert_memo(o.id));
// 2) Добор резерва заказа с членского «Стола заказов» (без проводки, оба на 86).
// Добор резерва заказа с членского «Стола заказов» (без проводки, оба на 86).
Ledger2::apply(_marketplace, coopname,
operations::marketplace::LOCK_FROM_MEMBER,
diff, orderer, o.hash,
@@ -121,7 +117,7 @@ void marketplace::signiss2(eosio::name coopname,
// Взнос принят окончательно вместе с фактом выдачи: пересчитывается
// пропорционально факту (ставка зафиксирована в Order на момент заказа),
// излишек возвращается (o.mkt.refund), недостающее при факте больше заказа
// дособирается с паевого (o.mkt.fee), затем 100% фактической суммы взноса
// дособирается с уже внесённого членского (o.mkt.lockmf), затем 100% фактической суммы взноса
// инлайн-вызовом branch::accrue зачисляется в общий кошелёк КУ выдачи —
// приоритет общего кошелька (раунд 5): распределение доверенным —
// отдельная команда председателя после контроля планового резерва.
@@ -139,12 +135,20 @@ void marketplace::signiss2(eosio::name coopname,
locked_fee - fact_fee, orderer, o.hash,
Marketplace::Memo::get_membership_fee_refund_memo(o.id));
} else if (fact_fee > locked_fee) {
// Факт больше заказа: взнос дособирается с паевого по той же ставке.
// Достаточность проверена выше вместе с доплатой стоимости — при
// нехватке упадёт walletop с человекочитаемым сообщением.
// Факт больше заказа: недостающая часть ЧЛЕНСКОГО ВЗНОСА (комиссии КУ)
// дособирается с уже внесённого членского (o.mkt.lockmf: w.mkt.member →
// w.mkt.fee, без проводки), НЕ с паевого. Источник членского пополняется
// заранее Заявлением о конвертации; здесь — проверка членского.
const eosio::asset fee_diff = fact_fee - locked_fee;
auto bal_member_fee = Marketplace::get_user_wallet_balance(
coopname, ledger2_wallets::MARKETPLACE_MEMBER_FUND, orderer);
eosio::check(bal_member_fee.available >= fee_diff,
std::string{"Недостаточно членских средств «Стола заказов» для довзноса по факту: требуется "} +
fee_diff.to_string() + ", доступно " + bal_member_fee.available.to_string() +
". Сперва переведите паевой в членский (Заявление о конвертации).");
Ledger2::apply(_marketplace, coopname,
operations::marketplace::MEMBERSHIP_FEE_LOCK,
fact_fee - locked_fee, orderer, o.hash,
operations::marketplace::LOCK_FEE_FROM_MEMBER,
fee_diff, orderer, o.hash,
Marketplace::Memo::get_membership_fee_topup_memo(o.id));
}
@@ -14,11 +14,14 @@
* По нему `cancelorder` разрешает отмену в `acceptcoop` (откат оператора
* до своей подписи), а `payout` отклоняет попытку выплаты.
*
* Одна ledger2-операция (как у createorder):
* - `o.mkt.lock` (TRANSFER w.wal.share → w.mkt.order на total_cost,
* Дт 80 / Кт 86) — паевой взнос заказчика резервируется под заказ
* в момент акцепта предложения (неакцептованное предложение докладки
* ничего не резервирует — requirement 76, решение 10).
* Фондируется ВСЕГДА из членского кошелька «Стола заказов» пайщика начисто
* (без паевого), две ledger2-операции (оба кошелька на 86, без проводок):
* - `o.mkt.lockm` (TRANSFER w.mkt.member → w.mkt.order на total_cost) — тело;
* - `o.mkt.lockmf` (TRANSFER w.mkt.member → w.mkt.fee на взнос) — взнос.
* Паевой взнос пополняет членский кошелёк ЗАРАНЕЕ отдельным действием `convert`
* (Заявление о конвертации). При замене непоставленного на свободный остаток
* высвобожденные отменой средства уже лежат в w.mkt.member — заказ создаётся
* без конвертации и без доплаты с паевого.
*
* Guards (зеркало createorder + специфика остатка):
* - quantity > 0; unit_price > 0 в _root_govern_symbol (цена публикации
@@ -28,7 +31,8 @@
* - `delivery_braname` существует: КУ, на складе которого лежит остаток;
* он же — КУ выдачи (`accept_braname` и `current_warehouse_braname`
* равны ему: имущество уже на месте, логистики нет).
* - w.wal.share.available заказчика >= total_cost (Locked Decision L6).
* - w.mkt.member.available заказчика >= total_cost + взнос (членских средств
* должно хватать; недостающее конвертируется заранее через `convert`).
*
* @ingroup public_marketplace_actions
*/
@@ -40,15 +44,11 @@ void marketplace::stockorder(eosio::name coopname,
uint64_t quantity,
eosio::asset unit_price,
uint32_t warranty_period_secs,
checksum256 batch_hash,
document2 convert_statement) {
checksum256 batch_hash) {
require_auth(coopname);
// ── Базовая валидация параметров ────────────────────────────────────
eosio::check(quantity > 0, "Количество должно быть больше нуля");
eosio::check(!is_empty_document(convert_statement),
"Отсутствует заявление о конвертации паевого взноса");
verify_document_or_fail(convert_statement, { orderer });
eosio::check(unit_price.is_valid() && unit_price.amount > 0,
"Некорректная цена за единицу");
eosio::check(unit_price.symbol == _root_govern_symbol,
@@ -75,17 +75,20 @@ void marketplace::stockorder(eosio::name coopname,
const eosio::asset membership_fee = Marketplace::calc_membership_fee(
total_cost, Marketplace::get_membership_fee_percent(coopname));
// ── Достаточность средств: w.wal.share.available >= стоимость + взнос ──
// ── Достаточность ЧЛЕНСКИХ средств: w.mkt.member.available >= тело + взнос ──
// Заказ из остатка фондируется только из членского кошелька; если средств
// не хватает, пайщик сперва пополняет его действием `convert` (с паевого).
const eosio::asset required_total = total_cost + membership_fee;
auto bal_share = Marketplace::get_user_wallet_balance(
coopname, ledger2_wallets::SHARE_FUND_PAY, orderer);
eosio::check(bal_share.available >= required_total,
std::string{"Недостаточно средств для заказа: требуется "} +
auto bal_member = Marketplace::get_user_wallet_balance(
coopname, ledger2_wallets::MARKETPLACE_MEMBER_FUND, orderer);
eosio::check(bal_member.available >= required_total,
std::string{"Недостаточно членских средств «Стола заказов» для заказа из остатка: требуется "} +
required_total.to_string() +
(membership_fee.amount > 0
? " (включая членский взнос " + membership_fee.to_string() + ")"
: "") +
", доступно " + bal_share.available.to_string());
", доступно " + bal_member.available.to_string() +
". Сперва переведите паевой в членский (конвертация).");
// ── Создание Order entity сразу в acceptcoop ─────────────────────────
orders_index orders(_marketplace, coopname.value);
@@ -120,24 +123,18 @@ void marketplace::stockorder(eosio::name coopname,
o.membership_fee = membership_fee;
});
// ── o.mkt.lock: TRANSFER w.wal.share → w.mkt.order (Дт 80 / Кт 86) ──
// ── o.mkt.lockm: тело — TRANSFER w.mkt.member → w.mkt.order (без Dr/Cr) ──
Ledger2::apply(_marketplace, coopname,
operations::marketplace::LOCK_ORDER,
operations::marketplace::LOCK_FROM_MEMBER,
total_cost, orderer, order_hash,
Marketplace::Memo::get_stock_order_block_memo(new_id));
// ── o.mkt.fee: членский взнос — TRANSFER w.wal.share → w.mkt.fee
// (Дт 80 / Кт 86); ставка зафиксирована в Order.membership_fee ─────
// ── o.mkt.lockmf: членский взнос — TRANSFER w.mkt.member → w.mkt.fee
// (без Dr/Cr, оба на 86); ставка зафиксирована в Order.membership_fee ──
if (membership_fee.amount > 0) {
Ledger2::apply(_marketplace, coopname,
operations::marketplace::MEMBERSHIP_FEE_LOCK,
operations::marketplace::LOCK_FEE_FROM_MEMBER,
membership_fee, orderer, order_hash,
Marketplace::Memo::get_membership_fee_lock_memo(new_id));
}
// Заявление о конвертации публикуется в реестр документов отдельным
// самостоятельным пакетом (package = hash самого заявления) — как в createorder.
Soviet::make_complete_document(_marketplace, coopname, orderer,
"stockorder"_n,
convert_statement.hash, convert_statement);
}
@@ -0,0 +1,78 @@
/**
* @brief Председатель кооперативного участка подтверждает фактическое
* списание со склада своего КУ по авторизованному советом проекту
* (ручной шаг стола ПВЗ, p.mkt.wroff).
*
* Совет своим решением (proposed → authorized через `onmktwoauth`) лишь
* признаёт списание допустимым. Имущество физически выбывает со склада
* только после того, как ответственный за склад председатель КУ подпишет
* Служебную записку о списании (registry 1111) — это и делает данный action.
*
* Гранулярность — по одному КУ (`braname`) за вызов: проект списания может
* охватывать несколько участков, и каждый председатель закрывает только
* свою часть. Все неисполненные позиции данного КУ списываются в одной
* транзакции (Ledger2::apply o.mkt.wroff, Дт 86 / Кт 10 — как в `execwroff`).
* Служебная записка публикуется в реестр документов в пакете процесса
* списания (package = proposal_hash). Исполнение последней позиции проекта
* (по всем КУ) стирает запись из RAM — терминал жизненного цикла.
*
* @ingroup public_marketplace_actions
*/
void marketplace::confirmwroff(eosio::name coopname,
eosio::name signer,
checksum256 proposal_hash,
eosio::name braname,
document2 memo) {
require_auth(coopname);
auto p = Marketplace::get_writeoff_proposal_by_hash_or_fail(coopname, proposal_hash);
eosio::check(p.status == WroffStatus::AUTHORIZED,
"Проект списания не авторизован советом");
auto branch = get_branch_or_fail(coopname, braname);
eosio::check(branch.is_user_authorized(signer),
"Подписант не уполномочен подтверждать списание данного кооперативного участка");
// Списываем все неисполненные позиции этого КУ. По каждой — o.mkt.wroff
// (Дт 86 / Кт 10), как в execwroff; per-item, чтобы привязать memo к
// конкретной позиции в журнале действий.
uint64_t confirmed_count = 0;
for (uint64_t i = 0; i < p.items.size(); ++i) {
const auto& item = p.items[i];
if (item.executed || item.braname != braname) continue;
Ledger2::apply(_marketplace, coopname,
operations::marketplace::WRITE_OFF_PERISHABLE,
item.amount, item.braname, p.hash,
Marketplace::Memo::get_writeoff_memo(p.id, i));
confirmed_count++;
}
eosio::check(confirmed_count > 0,
"Нет неисполненных позиций данного кооперативного участка в проекте списания");
// Служебная записка о списании (1111) — в реестр документов, пакет
// процесса списания (package = proposal_hash), рядом с Заявлением и
// Протоколом совета.
Soviet::make_complete_document(_marketplace, coopname, signer,
"confirmwroff"_n, proposal_hash, memo);
// Помечаем позиции КУ исполненными; если после этого исполнены все
// позиции проекта (по всем КУ) — стираем запись из RAM.
bool all_done = true;
for (const auto& item : p.items) {
if (item.executed) continue;
if (item.braname == braname) continue; // эти только что списали
all_done = false;
break;
}
if (all_done) {
Marketplace::erase_writeoff_proposal(coopname, p.id);
} else {
Marketplace::update_writeoff_proposal(coopname, p.id, [&](auto& upd) {
for (auto& it : upd.items) {
if (!it.executed && it.braname == braname) it.executed = true;
}
upd.decided_by = signer;
});
}
}
@@ -7,6 +7,7 @@ import { MonoAccountDTO } from './mono-account.dto';
import { UserAccountDTO } from '~/application/account/dto/base-user-account.dto';
import { ParticipantAccountDTO } from './participant-account.dto';
import { PrivateAccountDTO } from './private-account.dto';
import { AccountKind } from '~/application/account/enum/account-kind.enum';
@ObjectType('Account')
export class AccountDTO {
@@ -58,6 +59,12 @@ export class AccountDTO {
@Type(() => PrivateAccountDTO)
public readonly private_account!: PrivateAccountDTO | null;
@Field(() => AccountKind, {
description:
'Вид аккаунта: пайщик, кооперативный участок, кооператив или нераспознанный. Позволяет единообразно отображать субъект во всех реестрах — например, пометить кооперативный участок, а не принять его за организацию-пайщика.',
})
public readonly account_kind: AccountKind;
constructor(entity: AccountDomainEntity) {
this.username = entity.username;
this.blockchain_account = entity.blockchain_account || null;
@@ -65,5 +72,6 @@ export class AccountDTO {
this.user_account = entity.user_account ? new UserAccountDTO(entity.user_account) : null;
this.participant_account = entity.participant_account ? new ParticipantAccountDTO(entity.participant_account) : null;
this.private_account = entity.private_account ? new PrivateAccountDTO(entity.private_account) : null;
this.account_kind = entity.account_kind ?? AccountKind.unknown;
}
}
@@ -0,0 +1,22 @@
import { registerEnumType } from '@nestjs/graphql';
/**
* Вид аккаунта в кооперативе — верхнеуровневая классификация субъекта, к
* которому относится аккаунт. В отличие от AccountType (individual/organization/
* entrepreneur — форма приватных данных пайщика), AccountKind отвечает на вопрос
* «кто это»: пайщик, кооперативный участок, сам кооператив или нераспознанный.
* Нужен потребителям (реестры процессов/заказов/платежей), чтобы единообразно
* отрисовать субъект — например, пометить кооперативный участок, а не принять
* его за организацию-пайщика.
*/
export enum AccountKind {
participant = 'participant',
branch = 'branch',
cooperative = 'cooperative',
unknown = 'unknown',
}
registerEnumType(AccountKind, {
name: 'AccountKind',
description: 'Вид аккаунта: пайщик, кооперативный участок, кооператив или нераспознанный',
});
@@ -0,0 +1,34 @@
import { ObjectType, Field, ID } from '@nestjs/graphql';
/**
* Кошелёк пайщика «как есть» — одна строка `ledger2::userwallets` без
* сворачивания share+member в одно число. В отличие от `ProgramWallet`
* (срез по программе), отдаёт каждый кошелёк отдельно по его идентификатору,
* чтобы UI мог показывать паевой / членский / программные кошельки раздельно.
*/
@ObjectType('UserWallet')
export class UserWalletDTO {
@Field(() => ID, { description: 'Идентификатор записи кошелька' })
id!: string;
@Field(() => String, { description: 'Имя кооператива' })
coopname!: string;
@Field(() => String, { description: 'Идентификатор кошелька' })
wallet_name!: string;
@Field(() => String, { description: 'Человекочитаемое название кошелька' })
human_name!: string;
@Field(() => ID, { nullable: true, description: 'Идентификатор программы кошелька' })
program_id?: string | null;
@Field(() => String, { description: 'Имя пользователя' })
username!: string;
@Field(() => String, { description: 'Доступный остаток (формат: "100.0000 RUB")' })
available!: string;
@Field(() => String, { description: 'Заблокированный остаток (формат: "0.0000 RUB")' })
blocked!: string;
}
@@ -21,6 +21,7 @@ import type { CreateDepositPaymentInputDomainInterface } from '~/domain/gateway/
import { PaymentDomainEntity } from '~/domain/gateway/entities/payment-domain.entity';
import { PaymentStatusEnum } from '~/domain/gateway/enums/payment-status.enum';
import type { ProgramWalletFilterInputDTO } from '../dto/program-wallet-filter-input.dto';
import { UserWalletDTO } from '../dto/user-wallet.dto';
import { PaginationResult, PaginationInputDTO } from '~/application/common/dto/pagination.dto';
import { getProgramId, getProgramType } from '~/domain/wallet/enums/program-type.enum';
import { config } from '~/config';
@@ -189,6 +190,34 @@ export class WalletInteractor {
return wallets.length > 0 ? wallets[0] : null;
}
/**
* Кошельки пайщика «как есть» — сырой срез `ledger2::userwallets` без
* сворачивания share+member. Каждый кошелёк — отдельная строка со своим
* идентификатором; human_name и program_id берём из реестра кошельков
* cooptypes. Источник — PG-кеш репозитория (read-path, ADR-011).
*/
async getUserWallets(username: string, coopname?: string): Promise<UserWalletDTO[]> {
const coop = coopname || config.coopname;
const rows = await this.userWalletRepository.findByUsername(coop, username);
return rows.map((row) => {
const wallet_name = row.wallet_name ?? '';
const named = wallet_name as `${string}.${string}.${string}`;
const pid = Ledger2.programIdForWallet(named);
const dto = new UserWalletDTO();
dto.id = row.id ?? '';
dto.coopname = row.coopname ?? coop;
dto.wallet_name = wallet_name;
dto.human_name = Ledger2.getWalletHumanName(named) ?? wallet_name;
dto.program_id = pid !== undefined ? String(pid) : null;
dto.username = row.username ?? username;
dto.available = row.available ?? '0';
dto.blocked = row.blocked ?? '0';
return dto;
});
}
/**
* Собирает плоский ряд `ProgramWalletDomainEntity` для пайщика(ов).
*
@@ -16,6 +16,7 @@ import { CreateDepositPaymentInputDTO } from '../../gateway/dto/create-deposit-p
import { GatewayPaymentDTO } from '../../gateway/dto/gateway-payment.dto';
import { ProgramWalletDTO } from '../dto/program-wallet.dto';
import { ProgramWalletFilterInputDTO } from '../dto/program-wallet-filter-input.dto';
import { UserWalletDTO } from '../dto/user-wallet.dto';
import { createPaginationResult, PaginationResult, PaginationInputDTO } from '~/application/common/dto/pagination.dto';
// Пагинированные результаты для программных кошельков
@@ -125,4 +126,20 @@ export class WalletResolver {
async getProgramWallet(@Args('filter') filter: ProgramWalletFilterInputDTO): Promise<ProgramWalletDTO | null> {
return await this.walletService.getProgramWallet(filter);
}
/**
* Query: Кошельки пайщика без сворачивания (каждый кошелёк отдельной строкой)
*/
@Query(() => [UserWalletDTO], {
name: 'getUserWallets',
description: 'Кошельки пайщика — каждый кошелёк отдельной строкой, без объединения паевого и членского',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman', 'member'])
async getUserWallets(
@Args('username') username: string,
@Args('coopname', { nullable: true }) coopname?: string
): Promise<UserWalletDTO[]> {
return await this.walletService.getUserWallets(username, coopname);
}
}
@@ -13,6 +13,7 @@ import { CreateWithdrawInputDTO } from '../dto/create-withdraw-input.dto';
import { CreateWithdrawResponseDTO } from '../dto/create-withdraw-response.dto';
import { ProgramWalletDTO } from '../dto/program-wallet.dto';
import { ProgramWalletFilterInputDTO } from '../dto/program-wallet-filter-input.dto';
import { UserWalletDTO } from '../dto/user-wallet.dto';
import { PaginationResult, PaginationInputDTO } from '~/application/common/dto/pagination.dto';
import { Cooperative } from 'cooptypes';
import type { CreateDepositPaymentInputDTO } from '../../gateway/dto/create-deposit-payment-input.dto';
@@ -108,4 +109,11 @@ export class WalletService {
const wallet = await this.walletInteractor.getProgramWallet(filter);
return wallet ? ProgramWalletDTO.fromDomain(wallet) : null;
}
/**
* Получить кошельки пайщика «как есть» (без сворачивания share+member).
*/
public async getUserWallets(username: string, coopname?: string): Promise<UserWalletDTO[]> {
return this.walletInteractor.getUserWallets(username, coopname);
}
}
@@ -2,6 +2,7 @@ import { RegistratorContract, type SovietContract } from 'cooptypes';
import type { BlockchainAccountInterface } from '~/types/shared';
import type { MonoAccountDomainInterface } from '../interfaces/mono-account-domain.interface';
import type { PrivateAccountDomainInterface } from '../interfaces/private-account-domain.interface';
import { AccountKind } from '~/application/account/enum/account-kind.enum';
export class AccountDomainEntity {
public readonly username!: string;
@@ -10,6 +11,8 @@ export class AccountDomainEntity {
public provider_account!: MonoAccountDomainInterface | null;
public participant_account!: SovietContract.Tables.Participants.IParticipants | null;
public private_account!: PrivateAccountDomainInterface | null;
// Вид субъекта аккаунта (пайщик / кооперативный участок / кооператив / прочее).
public account_kind!: AccountKind;
constructor(data: AccountDomainEntity) {
Object.assign(this, data);
@@ -28,6 +28,8 @@ import {
import { userStatus } from '~/types';
import type { PrivateAccountDomainInterface } from '../interfaces/private-account-domain.interface';
import { AccountType } from '~/application/account/enum/account-type.enum';
import { AccountKind } from '~/application/account/enum/account-kind.enum';
import { BRANCH_BLOCKCHAIN_PORT, type BranchBlockchainPort } from '~/domain/branch/interfaces/branch-blockchain.port';
import { normalizeUserEmail } from '~/utils/normalize-user-email';
@Injectable()
@@ -36,6 +38,7 @@ export class AccountDomainService {
constructor(
@Inject(ACCOUNT_BLOCKCHAIN_PORT) private readonly accountBlockchainPort: AccountBlockchainPort,
@Inject(BRANCH_BLOCKCHAIN_PORT) private readonly branchBlockchainPort: BranchBlockchainPort,
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository,
@Inject(INDIVIDUAL_REPOSITORY) private readonly individualRepository: IndividualRepository,
@Inject(ENTREPRENEUR_REPOSITORY) private readonly entrepreneurRepository: EntrepreneurRepository,
@@ -229,6 +232,7 @@ export class AccountDomainService {
provider_account: null,
participant_account,
private_account,
account_kind: AccountKind.cooperative,
});
}
@@ -274,6 +278,29 @@ export class AccountDomainService {
};
}
let account_kind: AccountKind = provider_account ? AccountKind.participant : AccountKind.unknown;
// Кооперативный участок (КУ): это реальный аккаунт-подразделение кооператива
// без учётной записи пайщика (нет provider_account), его орг-данные хранятся
// в том же organizationRepository по braname (как у самого кооператива выше).
// Признак КУ — наличие записи в реестре участков. Резолвим имя через
// private_account.organization_data, чтобы getDisplayName/сертификаты/фронт
// получали человеческое имя участка тем же каналом, что и у организаций —
// без отдельной ветки у каждого потребителя.
if (!provider_account) {
const branch = await this.branchBlockchainPort.getBranch(config.coopname, username);
if (branch) {
const branch_organization_data = await this.organizationRepository.findByUsername(username);
private_account = {
type: AccountType.organization,
individual_data: undefined,
organization_data: branch_organization_data,
entrepreneur_data: undefined,
};
account_kind = AccountKind.branch;
}
}
const finalAccount = new AccountDomainEntity({
username,
user_account,
@@ -281,6 +308,7 @@ export class AccountDomainService {
provider_account: provider_account,
participant_account,
private_account,
account_kind,
});
return finalAccount;
@@ -1,8 +1,74 @@
# Marketplace - Расширение маркетплейса
# Marketplace расширение «Стол заказов»
Расширение для работы с категориями товаров и атрибутами маркетплейса. Предоставляет GraphQL API для управления деревом категорий и типов товаров на основе данных Ozon API.
Бэкенд кооперативной закупки/распределения имущества (членская модель): каталог
оферт, коллективные заказы, приёмка на склад КУ (ПВЗ), **выдача пайщику**,
возвраты, списание скоропорта, экономика КУ (членский взнос/распределение).
Ниже — быстрый контекст по самому нетривиальному потоку (выдача); внизу файла
осталась справка по дереву категорий (донорская часть Ozon).
## Возможности
---
## ⭐ Выдача имущества — ЕДИНЫЙ ПУТЬ (бандл-пропозал)
Главный инвариант: **до подписи пайщика в блокчейне по выдаче не происходит
ничего.** И обычные заказы, и докладку со склада оператор кладёт в ОДИН
оффчейн-бандл (PG), подписывая свою (первую) подпись акта. Связка подписей
уходит на цепь только когда пайщик у стойки контрподписывает акт. Поэтому
отмена пайщиком = отказ от бандла (оффчейн), без он-чейн отката, и нет двух
веток «уже в цепи / ещё в БД».
```
ОПЕРАТОР у стойки (IssueActOpenDialog):
по выдаваемым заказам И докладке со склада подписывает АПП-выдачи первой
подписью (signiss1) → marketplaceCreateStockProposal (бандл в PG).
Заказы остаются ACCEPTED_TO_COOP; в цепи — ничего.
│ (пайщику realtime-сигнал → гейт «подпись на месте»)
ПАЙЩИК в OnsiteSignatureGate — одна карточка-акт, две кнопки:
├─ «Подписать и получить» → marketplaceFinalizeStockIssuance:
│ (0) контрольная сверка: контрподписанный акт байт-в-байт = выданный
│ оператором signiss1 (защита от подмены тела «вагон алюминия»);
│ (1) при дефиците членских — ОДНА конвертация паевого по Заявлению;
│ (2) докладка → createStockOrder (обычный заказ уже существует);
│ (3) по строке: openIssuance(signiss1 оператора) → finalizeIssuance(signiss2 пайщика).
│ Заказы → RECEIVED.
└─ «Отменить» → marketplaceDeclineStockProposal (оффчейн).
Заказы остаются ACCEPTED_TO_COOP — оператор
переоткрывает выдачу и формирует акт заново.
```
**Экономика по типу строки бандла** (`order_id` в `MarketplaceStockProposalItem`
— дискриминатор): докладка (нет `order_id`) фондируется целиком с членского
(lock на stockorder, дефицит закрывает конвертация); обычный заказ (есть
`order_id`) уже профондирован с паевого на `createorder` — конвертация ему не
нужна, доплату при факт>заказ берёт сам `signiss2` на цепи и фейлится при
нехватке членских.
**`READY_TO_RECEIVE` теперь транзитный**: `signiss1` ставит его, `signiss2`
сразу читает и переводит в `RECEIVED` внутри одной finalize-связки. Отдельного
member-видимого состояния «готов к получению» НЕТ (поэтому в «Мои заказы» нет
подписи получения и нет вкладки «Готовы к выдаче»).
### Ключевые файлы выдачи
| Слой | Файл | Роль |
|---|---|---|
| Сервис-оркестратор | `application/services/marketplace-stock-proposal.service.ts` | бандл: createProposal / getAcceptSignablePayloads / **finalizeStockIssuance** / decline / cancel + контрольная сверка `assertCountersignMatchesStored` |
| Сервис выдачи | `application/services/marketplace-issuance.service.ts` | `openIssuance`/`finalizeIssuance` (signiss1/signiss2 на цепь) + генерация АПП (1105); зовутся ИЗНУТРИ бандла, не из GraphQL |
| Резолвер бандла | `application/resolvers/marketplace-stock.resolver.ts` | `marketplaceCreateStockProposal` / `marketplaceStockProposalSignablePayloads` / `marketplaceFinalizeStockIssuance` / decline / cancel |
| Резолвер выдачи | `application/resolvers/marketplace-issuance.resolver.ts` | `marketplaceIssueActChairmanSignablePayload` (signiss1-превью оператору) + `marketplaceListIssuancesByBraname` (лента оператора) |
| Домен-строка | `domain/entities/marketplace-stock-proposal.types.ts` | `MarketplaceStockProposalItem` (+ `order_id`/`order_hash`/`signiss1_act`) |
| Фронт оператора | `desktop/.../OperatorIssuance/ui/IssueActOpenDialog.vue` | сборка одного бандла (order_items + докладка), подпись signiss1 |
| Фронт пайщика | `desktop/.../OnsiteSignatureGate/` | единая карточка-акт: «Подписать и получить» / «Отменить» |
> Контракт не меняли: используются существующие `signiss1`/`signiss2`/`stockorder`/
> `convert`. Старого немедленного он-чейн-пути выдачи (`marketplaceOpenIssuance`/
> `marketplaceFinalizeIssuance`/`marketplaceListMyReadyToReceive`/orderer-payload)
> больше нет — он удалён как мёртвый после унификации.
---
## Возможности (дерево категорий — донорская часть Ozon)
### 🗂️ Дерево категорий
- Получение полного дерева категорий с иерархией
@@ -93,9 +93,15 @@ export const marketplaceAccessMatrix: Record<MarketplaceRole, Record<string, str
// доверенные видят экономику своих КУ и распоряжаются персональными
// средствами (перевод в «Стол заказов», материальная помощь).
Economy: ['read', 'read:own-KU', 'configure:own-KU', 'use:own'],
// Эпик 8: председатель КУ подтверждает фактическое списание со склада
// своего участка по решению совета (подпись Служебной записки 1111 →
// confirmwroff) и видит список таких ожидающих подтверждения групп.
Writeoff: ['read:own-KU', 'confirm:own-KU'],
},
admin: {
Offer: ['moderate', 'read'],
// read:all — реестр всех предложений кооператива любого статуса (наряду с
// модерацией PENDING); read:all есть и у совета (board_readonly).
Offer: ['moderate', 'read', 'read:all'],
Order: ['read:all'],
KU: ['manage'],
Whitelist: ['manage'],
@@ -1,72 +1,5 @@
import { Field, ID, InputType, Int, ObjectType } from '@nestjs/graphql';
import { IsInt, IsNotEmpty, IsOptional, IsString, Min, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { MarketplaceIssueActSignedDocumentInputDTO } from '~/application/document/documents-dto/marketplace-issue-act-document.dto';
import { MarketplaceOrderDTO } from './marketplace-order.dto';
/**
* Story 6.1: вход на открытие выдачи председателем КУ (первая подпись АПП).
*
* `actual_quantity` — фактически выдаваемое количество единиц. Оператор КУ
* сверяет привезённое имущество с заказом и фиксирует факт именно в момент
* открытия выдачи (взвешивание/пересчёт на стойке). Это количество зашивается
* в подписываемый председателем акт и сохраняется на заказе; финальная подпись
* заказчика факт уже не редактирует.
*/
@InputType('MarketplaceOpenIssuanceInput')
export class MarketplaceOpenIssuanceInputDTO {
@Field(() => ID, { description: 'Идентификатор заказа, выдачу которого открываем.' })
@IsString()
@IsNotEmpty()
public readonly order_id!: string;
@Field(() => Int, {
description: 'Фактически выдаваемое количество единиц (равно/меньше/больше заказа).',
})
@IsInt()
@Min(1)
public readonly actual_quantity!: number;
@Field(() => String, {
description: 'Фактическая цена за единицу (оператор может изменить её при открытии выдачи).',
})
@IsString()
@IsNotEmpty()
public readonly actual_unit_price!: string;
@Field(() => MarketplaceIssueActSignedDocumentInputDTO, {
description:
'Подписанный председателем кооперативного участка акт выдачи. Backend верифицирует подпись и отправляет on-chain первую подпись.',
})
@ValidateNested()
@Type(() => MarketplaceIssueActSignedDocumentInputDTO)
public readonly signed_document!: MarketplaceIssueActSignedDocumentInputDTO;
}
/**
* Story 6.3: вход на финальную подпись заказчика (получение имущества).
*
* Заказчик ставит финальную подпись в своём кабинете на своём устройстве своим
* ключом — он только подтверждает уже сформированный акт, факт не редактирует.
* Поэтому фактическое количество и сторона кооператива, открывшая выдачу,
* берутся backend'ом из заказа (зафиксированы оператором при открытии), а не
* передаются заказчиком.
*/
@InputType('MarketplaceFinalizeIssuanceInput')
export class MarketplaceFinalizeIssuanceInputDTO {
@Field(() => ID, { description: 'Идентификатор заказа, который получаем.' })
@IsString()
@IsNotEmpty()
public readonly order_id!: string;
@Field(() => MarketplaceIssueActSignedDocumentInputDTO, {
description:
'Подписанный заказчиком акт выдачи (поверх подписи председателя). Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями.',
})
@ValidateNested()
@Type(() => MarketplaceIssueActSignedDocumentInputDTO)
public readonly signed_document!: MarketplaceIssueActSignedDocumentInputDTO;
}
import { Field, ID, InputType, Int } from '@nestjs/graphql';
import { IsInt, IsNotEmpty, IsOptional, IsString, Min } from 'class-validator';
@InputType('MarketplaceListIssuancesByBranameInput')
export class MarketplaceListIssuancesByBranameInputDTO {
@@ -76,6 +9,11 @@ export class MarketplaceListIssuancesByBranameInputDTO {
public readonly delivery_braname!: string;
}
/**
* Превью акта выдачи (registry 1105) для подписи ОПЕРАТОРОМ КУ первой подписью
* (signiss1) в едином пути выдачи: оператор кладёт подписанный акт в бандл
* (marketplaceCreateStockProposal), на цепь он уходит только при подписи пайщика.
*/
@InputType('MarketplaceIssueActPayloadInput')
export class MarketplaceIssueActPayloadInputDTO {
@Field(() => ID, { description: 'Заказ, по которому формируется preview акта выдачи.' })
@@ -102,19 +40,3 @@ export class MarketplaceIssueActPayloadInputDTO {
@IsString()
public readonly actual_unit_price?: string;
}
@ObjectType('MarketplaceIssuanceResult', {
description: 'Результат подписания акта выдачи имущества пайщику.',
})
export class MarketplaceIssuanceResultDTO {
@Field(() => MarketplaceOrderDTO, { description: 'Заказ после применения подписи.' })
public readonly order!: MarketplaceOrderDTO;
@Field(() => String, { description: 'Хэш транзакции подписи в блокчейне.' })
public readonly tx_hash!: string;
constructor(init: { order: MarketplaceOrderDTO; tx_hash: string }) {
this.order = init.order;
this.tx_hash = init.tx_hash;
}
}
@@ -18,7 +18,7 @@ import {
} from 'class-validator';
import { PaginationInputDTO } from '~/application/common/dto/pagination.dto';
import { MARKETPLACE_OFFER_MAX_IMAGES } from '../../domain/entities/marketplace-offer.types';
import { MarketplaceBarcodeStrategyEnum } from './marketplace-offer.dto';
import { MarketplaceBarcodeStrategyEnum, MarketplaceOfferStatusEnum } from './marketplace-offer.dto';
const UNITS = ['piece', 'kg', 'liter', 'pack'] as const;
@@ -249,3 +249,17 @@ export class MarketplaceRepublishOfferInputDTO {
@InputType('MarketplaceListMyOffersInput')
export class MarketplaceListMyOffersInputDTO extends PaginationInputDTO {}
@InputType('MarketplaceListAllOffersInput', {
description: 'Параметры фильтрации реестра всех предложений кооператива (стол администратора).',
})
export class MarketplaceListAllOffersInputDTO extends PaginationInputDTO {
@Field(() => [MarketplaceOfferStatusEnum], {
nullable: true,
description: 'Один или несколько статусов предложения, по которым нужно отфильтровать список.',
})
public readonly statuses?: MarketplaceOfferStatusEnum[];
@Field(() => String, { nullable: true, description: 'Фильтр по аккаунту поставщика.' })
public readonly supplier_account?: string;
}
@@ -11,7 +11,10 @@ import {
ValidateNested,
} from 'class-validator';
import type { MarketplaceStockProposalDomainEntity } from '../../domain/entities/marketplace-stock-proposal.entity';
import { MarketplaceCheckoutSignedLineInputDTO } from './marketplace-checkout.dto';
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
import { DocumentAggregateDTO } from '~/application/document/dto/document-aggregate.dto';
import { MarketplaceConvertStatementSignedInputDTO } from '~/application/document/documents-dto/marketplace-convert-statement-document.dto';
import { MarketplaceIssueActSignedDocumentInputDTO } from '~/application/document/documents-dto/marketplace-issue-act-document.dto';
export enum MarketplaceStockProposalStatusEnum {
PROPOSED = 'PROPOSED',
@@ -72,20 +75,20 @@ export class MarketplaceStockProposalItemInputDTO {
quantity!: number;
}
@InputType('MarketplaceCreateStockProposalInput')
export class MarketplaceCreateStockProposalInputDTO {
@InputType('MarketplaceStockIssuancePrepareInput')
export class MarketplaceStockIssuancePrepareInputDTO {
@Field(() => String, { description: 'Кооперативный участок, со склада которого идёт докладка.' })
@IsString()
@IsNotEmpty()
braname!: string;
@Field(() => String, { description: 'Пайщик-адресат предложения.' })
@Field(() => String, { description: 'Пайщик-адресат докладки.' })
@IsString()
@IsNotEmpty()
member_account!: string;
@Field(() => [MarketplaceStockProposalItemInputDTO], {
description: 'Строки предложения: что и сколько предлагается со склада.',
description: 'Корзина докладки: что и сколько предлагается со склада.',
})
@IsArray()
@ArrayNotEmpty()
@@ -94,6 +97,112 @@ export class MarketplaceCreateStockProposalInputDTO {
items!: MarketplaceStockProposalItemInputDTO[];
}
@ObjectType('MarketplaceStockIssuanceOperatorLine')
export class MarketplaceStockIssuanceOperatorLineDTO {
@Field(() => ID, { description: 'Предложение кооператива из остатка.' })
offer_id!: string;
@Field(() => Int, { description: 'Количество единиц.' })
quantity!: number;
@Field(() => String, { description: 'Детерминированный order_hash будущего заказа из остатка.' })
order_hash!: string;
@Field(() => String, { description: 'Цена за единицу.' })
unit_price!: string;
@Field(() => String, { description: 'Наименование товара.' })
product_name!: string;
@Field(() => GeneratedDocumentDTO, {
description: 'Акт приёма-передачи к подписи оператором КУ (первая подпись).',
})
signiss1_document!: GeneratedDocumentDTO;
}
@InputType('MarketplaceCreateStockProposalLineInput')
export class MarketplaceCreateStockProposalLineInputDTO {
@Field(() => ID, { description: 'Предложение кооператива из остатка.' })
@IsString()
@IsNotEmpty()
offer_id!: string;
@Field(() => Int, { description: 'Количество единиц.' })
@IsInt()
@Min(1)
quantity!: number;
@Field(() => String, { description: 'order_hash из подготовки (marketplaceStockIssuancePayloads).' })
@IsString()
@IsNotEmpty()
order_hash!: string;
@Field(() => MarketplaceIssueActSignedDocumentInputDTO, {
description: 'Акт приёма-передачи, подписанный оператором КУ первой подписью.',
})
@ValidateNested()
@Type(() => MarketplaceIssueActSignedDocumentInputDTO)
signiss1_act!: MarketplaceIssueActSignedDocumentInputDTO;
}
@InputType('MarketplaceCreateOrderProposalLineInput')
export class MarketplaceCreateOrderProposalLineInputDTO {
@Field(() => ID, { description: 'Существующий заказ пайщика к выдаче этим бандлом.' })
@IsString()
@IsNotEmpty()
order_id!: string;
@Field(() => Int, { description: 'Фактическое количество к выдаче (сверено оператором).' })
@IsInt()
@Min(1)
actual_quantity!: number;
@Field(() => String, { description: 'Фактическая цена за единицу (оператор мог скорректировать).' })
@IsString()
@IsNotEmpty()
actual_unit_price!: string;
@Field(() => MarketplaceIssueActSignedDocumentInputDTO, {
description: 'Акт приёма-передачи, подписанный оператором КУ первой подписью.',
})
@ValidateNested()
@Type(() => MarketplaceIssueActSignedDocumentInputDTO)
signiss1_act!: MarketplaceIssueActSignedDocumentInputDTO;
}
@InputType('MarketplaceCreateStockProposalInput')
export class MarketplaceCreateStockProposalInputDTO {
@Field(() => String, { description: 'Кооперативный участок, со склада которого идёт выдача.' })
@IsString()
@IsNotEmpty()
braname!: string;
@Field(() => String, { description: 'Пайщик-адресат бандла.' })
@IsString()
@IsNotEmpty()
member_account!: string;
@Field(() => [MarketplaceCreateStockProposalLineInputDTO], {
nullable: true,
description: 'Строки докладки со склада: order_hash и подпись оператора (signiss1).',
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => MarketplaceCreateStockProposalLineInputDTO)
items?: MarketplaceCreateStockProposalLineInputDTO[];
@Field(() => [MarketplaceCreateOrderProposalLineInputDTO], {
nullable: true,
description: 'Строки уже существующих заказов пайщика к выдаче (подпись оператора signiss1).',
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => MarketplaceCreateOrderProposalLineInputDTO)
order_items?: MarketplaceCreateOrderProposalLineInputDTO[];
}
@InputType('MarketplaceResolveStockProposalInput')
export class MarketplaceResolveStockProposalInputDTO {
@Field(() => ID, { description: 'Предложение со склада кооператива.' })
@@ -102,19 +211,87 @@ export class MarketplaceResolveStockProposalInputDTO {
proposal_id!: string;
}
@InputType('MarketplaceAcceptStockProposalInput')
export class MarketplaceAcceptStockProposalInputDTO extends MarketplaceResolveStockProposalInputDTO {
@Field(() => [MarketplaceCheckoutSignedLineInputDTO], {
@ObjectType('MarketplaceStockAcceptOrderLine')
export class MarketplaceStockAcceptOrderLineDTO {
@Field(() => String, { description: 'Идентификатор предложения позиции.' })
public readonly offer_id!: string;
@Field(() => String, { description: 'order_hash будущего заказа из остатка.' })
public readonly order_hash!: string;
@Field(() => DocumentAggregateDTO, {
description:
'Акт приёма-передачи, уже подписанный оператором КУ. Пайщик накладывает свою подпись поверх (получение).',
})
public readonly signiss1_aggregate!: DocumentAggregateDTO;
}
@ObjectType('MarketplaceStockAcceptPayload')
export class MarketplaceStockAcceptPayloadDTO {
@Field(() => [MarketplaceStockAcceptOrderLineDTO], {
description: 'Строки-заказы к созданию — вернуть их в принятии предложения.',
})
public readonly order_lines!: MarketplaceStockAcceptOrderLineDTO[];
@Field(() => String, {
description: 'Сколько спишется с уже внесённых членских средств «Стола заказов» (доплата с членского).',
})
public readonly member_amount!: string;
@Field(() => String, {
nullable: true,
description: 'Сумма доплаты с паевого через конвертацию. Пусто — членских средств хватает, доплаты с паевого нет.',
})
public readonly convert_amount?: string | null;
@Field(() => String, {
nullable: true,
description: 'Идентификатор Заявления о конвертации (для подписи).',
})
public readonly convert_hash?: string | null;
@Field(() => GeneratedDocumentDTO, {
nullable: true,
description: 'Единое Заявление о конвертации к подписи. Пусто — подписывать нечего (доплаты нет).',
})
public readonly convert_document?: GeneratedDocumentDTO | null;
}
@InputType('MarketplaceStockFinalizeLineInput')
export class MarketplaceStockFinalizeLineInputDTO {
@Field(() => String, { description: 'order_hash строки бандла (из payloads).' })
@IsString()
@IsNotEmpty()
public readonly order_hash!: string;
@Field(() => MarketplaceIssueActSignedDocumentInputDTO, {
description: 'Акт приёма-передачи, контрподписанный пайщиком (подпись получения).',
})
@ValidateNested()
@Type(() => MarketplaceIssueActSignedDocumentInputDTO)
public readonly signed_signiss2_act!: MarketplaceIssueActSignedDocumentInputDTO;
}
@InputType('MarketplaceFinalizeStockIssuanceInput')
export class MarketplaceFinalizeStockIssuanceInputDTO extends MarketplaceResolveStockProposalInputDTO {
@Field(() => [MarketplaceStockFinalizeLineInputDTO], {
description: 'Строки докладки с подписью получения (signiss2) из marketplaceStockProposalSignablePayloads.',
})
@IsArray()
@ArrayNotEmpty()
@ValidateNested({ each: true })
@Type(() => MarketplaceStockFinalizeLineInputDTO)
public readonly order_lines!: MarketplaceStockFinalizeLineInputDTO[];
@Field(() => MarketplaceConvertStatementSignedInputDTO, {
nullable: true,
description:
'Подписанные заявления о конвертации паевого взноса — по одному на каждую строку предложения ' +
'(из превью marketplaceStockProposalSignablePayloads).',
'Единое подписанное Заявление о конвертации на всю сумму доплаты с паевого. Не передаётся, когда членских средств хватает.',
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => MarketplaceCheckoutSignedLineInputDTO)
lines?: MarketplaceCheckoutSignedLineInputDTO[] | null;
@ValidateNested()
@Type(() => MarketplaceConvertStatementSignedInputDTO)
public readonly signed_convert?: MarketplaceConvertStatementSignedInputDTO | null;
}
@InputType('MarketplaceCancelStockOrderInput')
@@ -1,4 +1,5 @@
import { Field, InputType, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
import { createPaginationResult } from '~/application/common/dto/pagination.dto';
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
import {
@@ -10,6 +11,7 @@ export enum MarketplaceWriteoffProposalStatusEnum {
DRAFT = 'DRAFT',
ON_AGENDA = 'ON_AGENDA',
AUTHORIZED = 'AUTHORIZED',
PENDING_CONFIRMATION = 'PENDING_CONFIRMATION',
EXECUTING = 'EXECUTING',
EXECUTED = 'EXECUTED',
REJECTED = 'REJECTED',
@@ -33,6 +35,11 @@ registerEnumType(MarketplaceWriteoffProposalTriggerEnum, {
export class MarketplaceWriteoffProposalItemDTO {
@Field({ description: 'Кооперативный участок (склад) — источник позиции к списанию.' })
braname!: string;
@Field(() => String, {
nullable: true,
description: 'Человеко-читаемое наименование кооперативного участка (для показа в интерфейсе).',
})
branch_name?: string | null;
@Field({ description: 'Наименование позиции или артикул из карточки имущества.' })
asset_title!: string;
@Field({ description: 'Количество единиц к списанию.' })
@@ -41,11 +48,10 @@ export class MarketplaceWriteoffProposalItemDTO {
amount!: string;
@Field({ description: 'Причина списания (срок годности, повреждение и т.п.).' })
reason!: string;
@Field(() => String, {
nullable: true,
description: 'Идентификатор инвентарной позиции, если известна.',
@Field(() => [String], {
description: 'Идентификаторы партий на складе, слитых в эту строку списания.',
})
inventory_id?: string | null;
inventory_ids!: string[];
@Field({ description: 'Признак того, что позиция уже исполнена через execwroff.' })
executed!: boolean;
}
@@ -132,8 +138,11 @@ export class MarketplaceWriteoffItemInputDTO {
amount!: string;
@Field()
reason!: string;
@Field({ nullable: true })
inventory_id?: string;
@Field(() => [String], {
nullable: true,
description: 'Идентификаторы партий на складе, которые покрывает эта строка списания.',
})
inventory_ids?: string[];
}
@InputType('MarketplaceCreateWriteoffDraftInput')
@@ -174,5 +183,84 @@ export class MarketplaceListWriteoffProposalsInputDTO {
statuses?: MarketplaceWriteoffProposalStatusEnum[];
}
@ObjectType('MarketplaceWriteoffCandidate')
export class MarketplaceWriteoffCandidateDTO {
@Field({ description: 'Стабильный ключ строки (склад + наименование + состояние).' })
key!: string;
@Field(() => [String], {
description: 'Идентификаторы всех партий на складе, слитых в эту строку-кандидат.',
})
inventory_ids!: string[];
@Field({ description: 'Кооперативный участок (склад), где лежит позиция.' })
braname!: string;
@Field({ description: 'Человеко-читаемое наименование кооперативного участка.' })
branch_name!: string;
@Field({ description: 'Наименование позиции (из карточки имущества).' })
asset_title!: string;
@Field({ description: 'Суммарное количество единиц по всем партиям строки.' })
quantity!: string;
@Field({ description: 'Суммарная сумма к списанию (закупочная цена × количество, 4 знака).' })
amount!: string;
@Field({ description: 'Причина-кандидат (по умолчанию — истёк срок годности).' })
reason!: string;
@Field(() => String, { nullable: true, description: 'Ближайший срок годности среди партий (ISO).' })
expiry_date?: string | null;
@Field({
description:
'Срок годности истёк (просрочено) — первоочередной кандидат к списанию. false — имущество ещё годно, списывается вручную (порча, невозврат).',
})
is_expired!: boolean;
@Field(() => Int, { description: 'Сколько партий слито в эту строку (для подсказки в интерфейсе).' })
lots_count!: number;
}
@ObjectType('MarketplaceWriteoffConfirmationGroup')
export class MarketplaceWriteoffConfirmationGroupDTO {
@Field({ description: 'Идентификатор проекта списания.' })
proposal_id!: string;
@Field({ description: 'Канонический хеш проекта (process_hash on-chain).' })
proposal_hash!: string;
@Field({ description: 'Кооперативный участок (склад), по которому подтверждается списание.' })
braname!: string;
@Field({ description: 'Человеко-читаемое наименование кооперативного участка.' })
branch_name!: string;
@Field({ description: 'Начало расчётного цикла списания (ISO).' })
cycle_started_at!: string;
@Field(() => String, { nullable: true, description: 'Когда совет авторизовал проект (ISO).' })
authorized_at?: string | null;
@Field(() => GraphQLJSON, { nullable: true, description: 'Протокол совета о списании (документ для просмотра).' })
protocol_doc?: unknown;
@Field(() => [MarketplaceWriteoffProposalItemDTO], { description: 'Неподтверждённые позиции этого участка.' })
items!: MarketplaceWriteoffProposalItemDTO[];
@Field({ description: 'Σ сумм позиций участка (4 знака).' })
total_amount!: string;
}
@InputType('MarketplaceWriteoffServiceMemoSignablePayloadInput')
export class MarketplaceWriteoffServiceMemoSignablePayloadInputDTO {
@Field({ description: 'Идентификатор проекта списания.' })
proposal_id!: string;
@Field({ description: 'Кооперативный участок, по которому подтверждается списание.' })
braname!: string;
}
@InputType('MarketplaceWriteoffProtocolDocumentInput')
export class MarketplaceWriteoffProtocolDocumentInputDTO {
@Field({ description: 'Идентификатор проекта списания.' })
proposal_id!: string;
}
@InputType('MarketplaceConfirmWriteoffInput')
export class MarketplaceConfirmWriteoffInputDTO {
@Field({ description: 'Идентификатор проекта списания.' })
proposal_id!: string;
@Field({ description: 'Кооперативный участок, по которому подтверждается списание.' })
braname!: string;
@Field(() => SignedDigitalDocumentInputDTO, {
description: 'Подписанная председателем КУ Служебная записка о списании (registry_id=1111).',
})
signed_memo!: SignedDigitalDocumentInputDTO;
}
export const MarketplaceWriteoffProposalStatusMap = MarketplaceWriteoffProposalStatuses;
export const MarketplaceWriteoffProposalTriggerMap = MarketplaceWriteoffProposalTriggers;
@@ -57,6 +57,28 @@ export interface MarketplaceNewOrderForSupplierEvent {
total_cost: string;
}
/**
* Поставщик отклонил активный заказ до приёма к поставке (`declineorder`).
* Заказчику уходит multi-channel уведомление с причиной отказа — заблокированные
* средства возвращаются ему. Эмитится по одному событию на каждый отклонённый
* заказ (у каждого свой заказчик) ПОСЛЕ записи статуса в PG (INV-12).
*/
export const MARKETPLACE_ORDER_DECLINED_BY_SUPPLIER_EVENT =
'marketplace.order.orderer.declinedBySupplier';
export interface MarketplaceOrderDeclinedBySupplierEvent {
coopname: string;
order_id: string;
/** Заказчик — адресат уведомления. */
orderer_account: string;
/** КУ доставки — для человекочитаемого текста (резолвится в имя участка). */
delivery_braname: string;
/** Название товара из предложения. */
product_name: string;
/** Причина отказа, указанная поставщиком. */
reason: string;
}
/**
* Остаток предложения изменился (заказ заблокировал/освободил/списал единицы).
* Сигнал широковещательному каналу каталога, чтобы у всех пайщиков карточка
@@ -143,6 +143,7 @@ import { FileStorageInfrastructureModule } from '~/infrastructure/file-storage';
// Эпик 8 — списание скоропорта через решение совета
import { MarketplaceWriteoffService } from './services/marketplace-writeoff.service';
import { MarketplaceWriteoffCronService } from './services/marketplace-writeoff-cron.service';
import { MarketplaceWriteoffSyncService } from './services/marketplace-writeoff-sync.service';
import { MarketplaceWriteoffResolver } from './resolvers/marketplace-writeoff.resolver';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MarketplaceInventoryEntity } from '../infrastructure/entities/marketplace-inventory.entity';
@@ -414,6 +415,7 @@ import { MarketplaceRealtimeBridge } from './realtime/marketplace-realtime.bridg
// Эпик 8 — списание скоропорта
MarketplaceWriteoffService,
MarketplaceWriteoffCronService,
MarketplaceWriteoffSyncService,
MarketplaceWriteoffResolver,
// Конечный жизненный цикл заказов: закрытие выданных после гарантии
MarketplaceOrderCloseCronService,
@@ -176,6 +176,50 @@ export class MarketplaceAplReceptionResolver {
return dto;
}
@Mutation(() => MarketplaceAplReceptionResultDTO, {
name: 'marketplaceCancelAplReception',
description:
'Оператор отменяет акт приёмки до подписи поставщика (поставщик не согласен со снятыми позициями) — партия возвращается к приёмке для повторного формирования.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Receiving', 'create')
async marketplaceCancelAplReception(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceAplReceptionByIdInputDTO
): Promise<MarketplaceAplReceptionResultDTO> {
const coopname = config.coopname;
const roles = member.marketplace_roles as MarketplaceRole[];
// Ownership: отменить приёмку может только оператор своего КУ (как и
// открытие/закрытие приёмки) — иначе можно было бы отменить чужой акт по
// подставленному apl_reception_id.
if (!canAccess(roles, 'Receiving', 'read:all')) {
const reception = await this.receptionRepo.findById(data.apl_reception_id);
if (!reception || reception.coopname !== coopname) {
throw new NotFoundException('Акт приёмки не найден.');
}
const isMember = await this.kuChairmanService.isMemberOfBranch(
coopname,
reception.braname,
member.username
);
if (!isMember) {
throw new ForbiddenException(
'Отмена приёмки доступна только по участку, на котором вы являетесь председателем или доверенным лицом.'
);
}
}
const result = await this.service.cancelReception({
coopname,
operator_account: member.username,
apl_reception_id: data.apl_reception_id,
});
const dto = new MarketplaceAplReceptionResultDTO();
dto.apl_reception = toMarketplaceAplReceptionDTO(result.apl_reception);
return dto;
}
@Query(() => [GeneratedDocumentDTO], {
name: 'marketplaceAplReceptionSupplierSignablePayloads',
description:
@@ -1,5 +1,5 @@
import { ForbiddenException, Inject, Injectable, NotFoundException, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Args, Query, Resolver } from '@nestjs/graphql';
import config from '~/config/config';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { CurrentMarketplaceMember } from '../decorators/current-marketplace-member.decorator';
@@ -14,11 +14,8 @@ import {
} from '../services/marketplace-ku-chairman.service';
import type { IMarketplaceCurrentMember } from '../dto/marketplace-current-member.dto';
import {
MarketplaceFinalizeIssuanceInputDTO,
MarketplaceIssuanceResultDTO,
MarketplaceIssueActPayloadInputDTO,
MarketplaceListIssuancesByBranameInputDTO,
MarketplaceOpenIssuanceInputDTO,
} from '../dto/marketplace-issuance.dto';
import { MarketplaceOrderDTO, toMarketplaceOrderDTO } from '../dto/marketplace-order.dto';
import {
@@ -34,7 +31,6 @@ import {
MarketplaceOrderDisplayService,
} from '../services/marketplace-order-display.service';
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
import { DocumentAggregateDTO } from '~/application/document/dto/document-aggregate.dto';
import type { DocumentDomainEntity } from '~/domain/document/entity/document-domain.entity';
function toGeneratedDocumentDTO(e: DocumentDomainEntity): GeneratedDocumentDTO {
@@ -61,53 +57,12 @@ export class MarketplaceIssuanceResolver {
private readonly displayService: MarketplaceOrderDisplayService
) {}
@Mutation(() => MarketplaceIssuanceResultDTO, {
name: 'marketplaceOpenIssuance',
description:
'Председатель кооперативного участка открывает выдачу первой подписью акта — заказ готов к получению пайщиком.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Issuance', 'sign:first')
async marketplaceOpenIssuance(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceOpenIssuanceInputDTO
): Promise<MarketplaceIssuanceResultDTO> {
const result = await this.service.openIssuance({
coopname: config.coopname,
chairman_account: member.username,
order_id: data.order_id,
actual_quantity: data.actual_quantity,
actual_unit_price: data.actual_unit_price,
signed_document: data.signed_document,
});
return new MarketplaceIssuanceResultDTO({
order: toMarketplaceOrderDTO(result.order),
tx_hash: result.tx_hash,
});
}
@Mutation(() => MarketplaceIssuanceResultDTO, {
name: 'marketplaceFinalizeIssuance',
description:
'Заказчик ставит финальную подпись акта выдачи — имущество переходит к нему, в зависимости от сверки факта применяются корректирующие операции.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Issuance', 'sign:final')
async marketplaceFinalizeIssuance(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceFinalizeIssuanceInputDTO
): Promise<MarketplaceIssuanceResultDTO> {
const result = await this.service.finalizeIssuance({
coopname: config.coopname,
orderer_account: member.username,
order_id: data.order_id,
signed_document: data.signed_document,
});
return new MarketplaceIssuanceResultDTO({
order: toMarketplaceOrderDTO(result.order),
tx_hash: result.tx_hash,
});
}
// Единый путь выдачи (см. README расширения): оператор подписывает signiss1 и
// кладёт его в бандл (marketplaceCreateStockProposal); связка signiss1+signiss2
// уходит на цепь только при подписи пайщика внутри marketplaceFinalizeStockIssuance.
// Отдельных он-чейн мутаций открытия/финализации выдачи и member-запросов
// «готово к получению»/«акт для подписи заказчика» больше нет — сервис-методы
// openIssuance/finalizeIssuance вызываются ИЗНУТРИ бандла, не из GraphQL.
@Query(() => GeneratedDocumentDTO, {
name: 'marketplaceIssueActChairmanSignablePayload',
@@ -154,56 +109,6 @@ export class MarketplaceIssuanceResolver {
return toGeneratedDocumentDTO(doc);
}
@Query(() => DocumentAggregateDTO, {
name: 'marketplaceIssueActOrdererSignablePayload',
description:
'Акт выдачи, уже подписанный председателем, для финальной подписи заказчика. Содержит исходный документ для ознакомления и подпись председателя; заказчик накладывает свою подпись поверх.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Issuance', 'sign:final')
async marketplaceIssueActOrdererSignablePayload(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceIssueActPayloadInputDTO
): Promise<DocumentAggregateDTO> {
const coopname = config.coopname;
const roles = member.marketplace_roles as MarketplaceRole[];
// Ownership-фильтрация — ответственность резолвера. `sign:final` есть у
// каждого пайщика (роль orderer), поэтому без проверки владельца любой
// пайщик прочитал бы акт чужого заказа по подставленному order_id.
//
// Канон выдачи (UX-DR4, см. finalizeIssuance): заказчик ставит финальную
// подпись на устройстве оператора КУ (operator-assisted POS) — сессия при
// этом оператора, а не заказчика. Поэтому превью доступно либо самому
// заказчику, либо члену КУ выдачи (председатель/доверенное лицо/оператор),
// который проводит выдачу — иначе превью утечёт по подставленному order_id.
// Сама финальная подпись всё равно обязана нести ключ заказчика-владельца —
// это отдельно проверяет finalizeIssuance по signer'у подписи.
if (!canAccess(roles, 'Issuance', 'read:all')) {
const order = await this.orderRepo.findById(data.order_id);
if (!order || order.coopname !== coopname) {
throw new NotFoundException('Заказ не найден.');
}
const isOrderer = order.orderer_account === member.username;
const isKuMember = await this.kuChairmanService.isMemberOfBranch(
coopname,
order.delivery_braname,
member.username
);
if (!isOrderer && !isKuMember) {
throw new ForbiddenException(
'Превью акта выдачи доступно заказчику или члену кооперативного участка выдачи.'
);
}
}
const aggregate = await this.service.getFinalizeIssuanceSignablePayload(
coopname,
data.order_id
);
return new DocumentAggregateDTO(aggregate);
}
@Query(() => [MarketplaceOrderDTO], {
name: 'marketplaceListIssuancesByBraname',
description:
@@ -247,25 +152,4 @@ export class MarketplaceIssuanceResolver {
});
return orders.map((order) => toMarketplaceOrderDTO(order, display.get(order.id)));
}
@Query(() => [MarketplaceOrderDTO], {
name: 'marketplaceListMyReadyToReceive',
description:
'Список заказов текущего пайщика, готовых к получению на пункте выдачи.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Issuance', 'read:own')
async marketplaceListMyReadyToReceive(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember
): Promise<MarketplaceOrderDTO[]> {
const orders = await this.orderRepo.listReadyToReceiveByOrderer(
config.coopname,
member.username
);
const display = await this.displayService.enrich(orders, {
withParticipantNames: true,
withWarehouseQuantity: true,
});
return orders.map((order) => toMarketplaceOrderDTO(order, display.get(order.id)));
}
}
@@ -14,6 +14,7 @@ import {
} from '../dto/marketplace-offer.dto';
import {
MarketplaceCreateOfferInputDTO,
MarketplaceListAllOffersInputDTO,
MarketplaceListMyOffersInputDTO,
MarketplaceRepublishOfferInputDTO,
MarketplaceUpdateOfferInputDTO,
@@ -35,6 +36,7 @@ import {
type AvailableCategoryDomainService,
} from '../../domain/services/available-category-domain.service';
import type { MarketplaceOfferDomainEntity } from '../../domain/entities/marketplace-offer.entity';
import type { MarketplaceOfferStatus } from '../../domain/entities/marketplace-offer.types';
const toDTO = toMarketplaceOfferDTO;
@@ -216,4 +218,37 @@ export class MarketplaceOfferResolver {
currentPage: result.currentPage,
};
}
@Query(() => MarketplaceOfferPaginationResultDTO, {
name: 'marketplaceListAllOffers',
description: 'Реестр всех предложений кооператива любого статуса (стол администратора).',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Offer', 'read:all')
async marketplaceListAllOffers(
@Args('input', { nullable: true }) input?: MarketplaceListAllOffersInputDTO
): Promise<MarketplaceOfferPaginationResultDTO> {
const pagination = {
page: input?.page ?? 1,
limit: input?.limit ?? 50,
sortBy: input?.sortBy ?? 'created_at',
sortOrder: (input?.sortOrder ?? 'DESC') as 'ASC' | 'DESC',
};
const result = await this.offerService.listAll(
config.coopname,
{
statuses: input?.statuses?.length
? (input.statuses as unknown as MarketplaceOfferStatus[])
: undefined,
supplier_account: input?.supplier_account,
},
pagination
);
return {
items: result.items.map(toDTO),
totalCount: result.totalCount,
totalPages: result.totalPages,
currentPage: result.currentPage,
};
}
}
@@ -176,6 +176,28 @@ export class MarketplaceOrderResolver {
return this.runListQuery(filter, options, { withParticipantNames: true });
}
@Query(() => MarketplaceOrderPaginationResultDTO, {
name: 'marketplaceListAllOrders',
description: 'Реестр всех заказов кооператива с их текущими статусами (стол администратора).',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Order', 'read:all')
async marketplaceListAllOrders(
@Args('input', { nullable: true }) input?: MarketplaceListOrdersInputDTO,
@Args('options', { nullable: true }) options?: PaginationInputDTO
): Promise<MarketplaceOrderPaginationResultDTO> {
const filter: MarketplaceOrderListFilter = {
coopname: config.coopname,
orderer_account: input?.orderer_account,
supplier_account: input?.supplier_account,
offer_id: input?.offer_id,
status: input?.statuses?.length ? (input.statuses as MarketplaceOrderStatus[]) : undefined,
};
// Стол администратора видит обе стороны сделки: и ФИО заказчика, и
// наименование поставщика — поэтому обогащаем именами участников.
return this.runListQuery(filter, options, { withParticipantNames: true });
}
@Query(() => MarketplaceOrderDTO, {
name: 'marketplaceGetOrder',
description: 'Получить один заказ по его идентификатору (доступ зависит от роли).',
@@ -27,22 +27,26 @@ import {
} from '../dto/marketplace-inventory.dto';
import { MarketplaceOfferDTO, toMarketplaceOfferDTO } from '../dto/marketplace-offer.dto';
import {
MarketplaceAcceptStockProposalInputDTO,
MarketplaceCancelStockOrderInputDTO,
MarketplaceCreateStockProposalInputDTO,
MarketplaceFinalizeStockIssuanceInputDTO,
MarketplaceListStockProposalsInputDTO,
MarketplacePublishStockInputDTO,
MarketplaceResolveStockProposalInputDTO,
MarketplaceStockAcceptPayloadDTO,
MarketplaceStockIssuanceOperatorLineDTO,
MarketplaceStockIssuancePrepareInputDTO,
MarketplaceStockProposalAcceptResultDTO,
MarketplaceStockProposalDTO,
MarketplaceUnpublishStockInputDTO,
MarketplaceUnpublishStockResultDTO,
toMarketplaceStockProposalDTO,
} from '../dto/marketplace-stock.dto';
import { MarketplaceCheckoutSignableLineDTO } from '../dto/marketplace-checkout.dto';
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
import { DocumentAggregateDTO } from '~/application/document/dto/document-aggregate.dto';
import { MarketplaceOrderDTO, toMarketplaceOrderDTO } from '../dto/marketplace-order.dto';
import type { MarketplaceStockProposalStatus } from '../../domain/entities/marketplace-stock-proposal.types';
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
/**
* requirement 76 «Склад кооператива на КУ»: обезличенный остаток, его
@@ -121,10 +125,41 @@ export class MarketplaceStockResolver {
// ── Докладка у стойки (двухфазная) ───────────────────────────────────
@Query(() => [MarketplaceStockIssuanceOperatorLineDTO], {
name: 'marketplaceStockIssuancePayloads',
description:
'Акты приёма-передачи к подписи оператором КУ для докладки со склада: по строке корзины — order_hash будущего заказа и документ для подписи передающей стороны.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('StockProposal', 'create:own-KU')
async marketplaceStockIssuancePayloads(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceStockIssuancePrepareInputDTO
): Promise<MarketplaceStockIssuanceOperatorLineDTO[]> {
await this.assertBranameAllowed(member, data.braname, 'StockProposal', 'create');
const lines = await this.proposalService.getOperatorIssuancePayloads({
coopname: config.coopname,
braname: data.braname,
member_account: data.member_account,
operator_account: member.username,
items: data.items,
});
return lines.map((l) => {
const dto = new MarketplaceStockIssuanceOperatorLineDTO();
dto.offer_id = l.offer_id;
dto.quantity = l.quantity;
dto.order_hash = l.order_hash;
dto.unit_price = l.unit_price;
dto.product_name = l.product_name;
dto.signiss1_document = new GeneratedDocumentDTO(l.signiss1_document);
return dto;
});
}
@Mutation(() => MarketplaceStockProposalDTO, {
name: 'marketplaceCreateStockProposal',
description:
'Оператор у стойки предлагает пайщику имущество со склада кооператива — пайщику немедленно приходит запрос на решение.',
'Оператор у стойки формирует бандл выдачи пайщику (существующие заказы и/или докладка со склада), с уже подписанными им актами передачи — пайщику немедленно приходит акт на подпись получения. До его подписи ничего в блокчейне не происходит.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('StockProposal', 'create:own-KU')
@@ -138,7 +173,18 @@ export class MarketplaceStockResolver {
operator_account: member.username,
braname: data.braname,
member_account: data.member_account,
items: data.items,
items: (data.items ?? []).map((i) => ({
offer_id: i.offer_id,
quantity: i.quantity,
order_hash: i.order_hash,
signiss1_act: i.signiss1_act as unknown as ISignedDocumentDomainInterface,
})),
order_items: (data.order_items ?? []).map((i) => ({
order_id: i.order_id,
actual_quantity: i.actual_quantity,
actual_unit_price: i.actual_unit_price,
signiss1_act: i.signiss1_act as unknown as ISignedDocumentDomainInterface,
})),
});
return toMarketplaceStockProposalDTO(proposal);
}
@@ -161,56 +207,65 @@ export class MarketplaceStockResolver {
return toMarketplaceStockProposalDTO(proposal);
}
@Query(() => [MarketplaceCheckoutSignableLineDTO], {
@Query(() => MarketplaceStockAcceptPayloadDTO, {
name: 'marketplaceStockProposalSignablePayloads',
description:
'Заявления о конвертации паевого взноса к подписи по строкам предложения со склада. ' +
'Подписанные заявления возвращаются строками lines в marketplaceAcceptStockProposal.',
'Нагрузка к принятию предложения со склада: строки-заказы и ОДНО Заявление о конвертации ' +
'на всю сумму доплаты. Если членских средств хватает (замена из высвобожденных) — заявления нет.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('StockProposal', 'resolve:own')
async marketplaceStockProposalSignablePayloads(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceResolveStockProposalInputDTO
): Promise<MarketplaceCheckoutSignableLineDTO[]> {
const lines = await this.proposalService.getAcceptSignablePayloads(
): Promise<MarketplaceStockAcceptPayloadDTO> {
const payload = await this.proposalService.getAcceptSignablePayloads(
config.coopname,
data.proposal_id,
member.username
);
return lines.map((l) => {
const document = new GeneratedDocumentDTO();
document.full_title = l.document.full_title;
document.html = l.document.html;
document.hash = l.document.hash;
document.meta = l.document.meta;
document.binary = l.document.binary;
return new MarketplaceCheckoutSignableLineDTO({
let convert_document: GeneratedDocumentDTO | null = null;
if (payload.convert) {
convert_document = new GeneratedDocumentDTO();
convert_document.full_title = payload.convert.document.full_title;
convert_document.html = payload.convert.document.html;
convert_document.hash = payload.convert.document.hash;
convert_document.meta = payload.convert.document.meta;
convert_document.binary = payload.convert.document.binary;
}
return {
order_lines: payload.order_lines.map((l) => ({
offer_id: l.offer_id,
order_hash: l.order_hash,
amount: l.amount,
document,
});
});
signiss1_aggregate: new DocumentAggregateDTO(l.signiss1_aggregate),
})),
member_amount: payload.member_amount,
convert_amount: payload.convert?.amount ?? null,
convert_hash: payload.convert?.convert_hash ?? null,
convert_document,
};
}
@Mutation(() => MarketplaceStockProposalAcceptResultDTO, {
name: 'marketplaceAcceptStockProposal',
name: 'marketplaceFinalizeStockIssuance',
description:
'Пайщик принимает предложение со склада: по каждой строке создаётся заказ, средства резервируются, акт уходит на подпись. ' +
'Каждая строка сопровождается подписанным заявлением о конвертации паевого взноса (lines).',
'Пайщик одной подписью утверждает докладку как акт: при дефиците членских средств — конвертация с паевого по подписанному Заявлению, ' +
'затем по каждой строке создаётся заказ из остатка и проводится выдача (подпись передачи оператора + подпись получения пайщика). ' +
'Имущество выдаётся сразу. Заявление (signed_convert) не передаётся, когда членских средств хватает.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('StockProposal', 'resolve:own')
async marketplaceAcceptStockProposal(
async marketplaceFinalizeStockIssuance(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceAcceptStockProposalInputDTO
@Args('data') data: MarketplaceFinalizeStockIssuanceInputDTO
): Promise<MarketplaceStockProposalAcceptResultDTO> {
const result = await this.proposalService.acceptProposal(
const result = await this.proposalService.finalizeStockIssuance(
config.coopname,
data.proposal_id,
member.username,
data.lines ?? null
{ order_lines: data.order_lines, signed_convert: data.signed_convert ?? null }
);
const dto = new MarketplaceStockProposalAcceptResultDTO();
dto.proposal = toMarketplaceStockProposalDTO(result.proposal);
@@ -28,7 +28,7 @@ export function toMarketplaceWriteoffProposalDTO(
d.quantity = it.quantity;
d.amount = it.amount;
d.reason = it.reason;
d.inventory_id = it.inventory_id;
d.inventory_ids = it.inventory_ids ?? [];
d.executed = it.executed;
return d;
});
@@ -1,24 +1,36 @@
import { BadRequestException, Injectable, UseGuards } from '@nestjs/common';
import { BadRequestException, Inject, Injectable, NotFoundException, 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 { DocumentAggregateDTO } from '~/application/document/dto/document-aggregate.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 { canAccess } from '../access/marketplace-access-matrix';
import type { MarketplaceRole } from '../membership/marketplace-roles.mapper';
import {
MARKETPLACE_KU_CHAIRMAN_SERVICE,
type MarketplaceKuChairmanService,
} from '../services/marketplace-ku-chairman.service';
import type { IMarketplaceCurrentMember } from '../dto/marketplace-current-member.dto';
import {
MarketplaceConfirmWriteoffInputDTO,
MarketplaceCreateWriteoffDraftInputDTO,
MarketplaceListWriteoffProposalsInputDTO,
MarketplaceSubmitWriteoffDraftInputDTO,
MarketplaceUpdateWriteoffDraftInputDTO,
MarketplaceWriteoffCandidateDTO,
MarketplaceWriteoffConfirmationGroupDTO,
MarketplaceWriteoffProposalDTO,
MarketplaceWriteoffProposalStatusEnum,
MarketplaceWriteoffProtocolDocumentInputDTO,
MarketplaceWriteoffServiceMemoSignablePayloadInputDTO,
MarketplaceWriteoffStatementSignablePayloadInputDTO,
PaginatedMarketplaceWriteoffProposalsDTO,
} from '../dto/marketplace-writeoff.dto';
@@ -58,7 +70,9 @@ function toGeneratedDocumentDTO(e: DocumentDomainEntity): GeneratedDocumentDTO {
export class MarketplaceWriteoffResolver {
constructor(
private readonly service: MarketplaceWriteoffService,
private readonly documentDomainService: DocumentDomainService
private readonly documentDomainService: DocumentDomainService,
@Inject(MARKETPLACE_KU_CHAIRMAN_SERVICE)
private readonly kuChairmanService: MarketplaceKuChairmanService
) {}
@Query(() => MarketplaceWriteoffProposalDTO, {
@@ -72,7 +86,10 @@ export class MarketplaceWriteoffResolver {
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember
): Promise<MarketplaceWriteoffProposalDTO | null> {
const draft = await this.service.getOpenDraft(config.coopname);
return draft ? toMarketplaceWriteoffProposalDTO(draft) : null;
if (!draft) return null;
const dto = toMarketplaceWriteoffProposalDTO(draft);
await this.enrichItemBranchNames([dto]);
return dto;
}
@Query(() => PaginatedMarketplaceWriteoffProposalsDTO, {
@@ -93,8 +110,10 @@ export class MarketplaceWriteoffResolver {
});
const page = options?.page ?? 1;
const limit = options?.limit ?? 50;
const items = result.items.map(toMarketplaceWriteoffProposalDTO);
await this.enrichItemBranchNames(items);
return {
items: result.items.map(toMarketplaceWriteoffProposalDTO),
items,
totalCount: result.total,
totalPages: Math.max(1, Math.ceil(result.total / limit)),
currentPage: page,
@@ -110,7 +129,9 @@ export class MarketplaceWriteoffResolver {
async marketplaceWriteoffProposal(
@Args('id') id: string
): Promise<MarketplaceWriteoffProposalDTO> {
return toMarketplaceWriteoffProposalDTO(await this.service.getProposal(id));
const dto = toMarketplaceWriteoffProposalDTO(await this.service.getProposal(id));
await this.enrichItemBranchNames([dto]);
return dto;
}
@Mutation(() => MarketplaceWriteoffProposalDTO, {
@@ -186,21 +207,26 @@ export class MarketplaceWriteoffResolver {
draft_id: draft.id,
items: draft.items,
});
// Единицы измерения — снапшот с офферов товаров (шт./кг/л/упак.).
const units = await this.service.resolveUnitLabels(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) => ({
// Дата начала цикла — в формате документов ДД.ММ.ГГГГ (UTC, без сдвига tz).
cycle_started_at: this.formatDocumentDate(draft.cycle_started_at),
items: draft.items.map((it, i) => ({
braname: it.braname,
asset_title: it.asset_title,
quantity: it.quantity,
amount: it.amount,
unit: units[i],
// Человекочитаемая сумма «1 020,00 RUB» (2 знака), не машинные 4 знака.
amount: this.service.formatAssetHuman(Number(it.amount)),
reason: it.reason,
})),
total_amount: draft.total_amount,
total_amount: this.service.formatAssetHuman(parseFloat(draft.total_amount)),
};
const document = await this.documentDomainService.generateDocument({
data: action,
@@ -226,4 +252,164 @@ export class MarketplaceWriteoffResolver {
});
return toMarketplaceWriteoffProposalDTO(submitted);
}
// ── Стол администратора: кандидаты на списание ──────────────────────
@Query(() => [MarketplaceWriteoffCandidateDTO], {
name: 'marketplaceListWriteoffCandidates',
description:
'Кандидаты на списание скоропорта: просроченные позиции на складах кооператива. Председатель выделяет нужные и создаёт из них черновик проекта списания.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Writeoff', 'read:all')
async marketplaceListWriteoffCandidates(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember
): Promise<MarketplaceWriteoffCandidateDTO[]> {
return (await this.service.listCandidates(config.coopname)) as MarketplaceWriteoffCandidateDTO[];
}
// ── Стол ПВЗ: подтверждение списания председателем КУ ───────────────
@Query(() => [MarketplaceWriteoffConfirmationGroupDTO], {
name: 'marketplaceWriteoffPendingConfirmations',
description:
'Группы списаний, ожидающих подтверждения складом: по проекту, одобренному советом, — отдельная строка на каждый кооперативный участок. Председатель КУ видит только свои участки.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Writeoff', 'read:own-KU')
async marketplaceWriteoffPendingConfirmations(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember
): Promise<MarketplaceWriteoffConfirmationGroupDTO[]> {
const coopname = config.coopname;
const roles = member.marketplace_roles as MarketplaceRole[];
// Совет/админ (read:all) видят все участки; председатель КУ — только свои.
let branames: string[] | null = null;
if (!canAccess(roles, 'Writeoff', 'read:all')) {
branames = await this.kuChairmanService.listBranamesForMember(coopname, member.username);
if (branames.length === 0) return [];
}
return (await this.service.listPendingConfirmations(
coopname,
branames
)) as MarketplaceWriteoffConfirmationGroupDTO[];
}
@Query(() => GeneratedDocumentDTO, {
name: 'marketplaceWriteoffServiceMemoSignablePayload',
description:
'Превью Служебной записки о списании (registry 1111) по одному участку проекта — для подписания председателем КУ.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Writeoff', 'confirm:own-KU')
async marketplaceWriteoffServiceMemoSignablePayload(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceWriteoffServiceMemoSignablePayloadInputDTO
): Promise<GeneratedDocumentDTO> {
await this.assertBranchAuthorized(member, data.braname);
const memo = await this.service.getServiceMemoData(data.proposal_id, data.braname);
const action: Cooperative.Registry.MarketplaceWriteoffServiceMemo.Action = {
registry_id: Cooperative.Registry.MarketplaceWriteoffServiceMemo.registry_id,
coopname: config.coopname,
username: member.username,
lang: 'ru',
proposal_hash: memo.proposal_hash,
braname: memo.braname,
branch_name: memo.branch_name,
cycle_started_at: this.formatDocumentDate(memo.cycle_started_at),
items: memo.items.map((it) => ({
...it,
amount: this.service.formatAssetHuman(Number(it.amount)),
})),
total_amount: this.service.formatAssetHuman(parseFloat(memo.total_amount)),
};
const document = await this.documentDomainService.generateDocument({ data: action });
return toGeneratedDocumentDTO(document);
}
@Query(() => DocumentAggregateDTO, {
name: 'marketplaceWriteoffProtocolDocument',
description:
'Протокол совета об одобрении списания — подписанный документ (агрегат) для просмотра председателем КУ.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Writeoff', 'confirm:own-KU')
async marketplaceWriteoffProtocolDocument(
@CurrentMarketplaceMember() _member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceWriteoffProtocolDocumentInputDTO
): Promise<DocumentAggregateDTO> {
// Протокол уже подписан советом и лежит в реестре документов: собираем
// агрегат из подписанного документа по doc_hash (тело + подписи), НЕ
// регенерируем. Канон — issuance/return-claim.
const aggregate = await this.service.getProtocolDocumentAggregate(data.proposal_id);
if (!aggregate) {
throw new NotFoundException('Протокол совета по этому проекту недоступен');
}
return new DocumentAggregateDTO(aggregate);
}
@Mutation(() => MarketplaceWriteoffProposalDTO, {
name: 'marketplaceConfirmWriteoff',
description:
'Подтвердить фактическое списание со склада участка подписанной председателем КУ Служебной запиской (registry 1111). Запускает on-chain confirmwroff по всем позициям этого участка.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Writeoff', 'confirm:own-KU')
async marketplaceConfirmWriteoff(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceConfirmWriteoffInputDTO
): Promise<MarketplaceWriteoffProposalDTO> {
await this.assertBranchAuthorized(member, data.braname);
const updated = await this.service.confirmWriteoff({
id: data.proposal_id,
braname: data.braname,
chairman_account: member.username,
signed_memo: data.signed_memo,
});
return toMarketplaceWriteoffProposalDTO(updated);
}
/**
* Проставляет позициям проектов человеко-читаемое имя КУ: в интерфейсе не
* показываем служебный braname (правило платформы). Резолв с общим кэшем по
* всем переданным проектам.
*/
private async enrichItemBranchNames(dtos: MarketplaceWriteoffProposalDTO[]): Promise<void> {
const branames = dtos.flatMap((d) => d.items.map((it) => it.braname));
if (branames.length === 0) return;
const names = await this.service.resolveBranchNames(branames);
for (const d of dtos) {
for (const it of d.items) {
it.branch_name = names[it.braname] ?? it.braname;
}
}
}
/**
* Дата для тела документа в формате ДД.ММ.ГГГГ (UTC, без сдвига часового
* пояса). Принимает Date или ISO-строку.
*/
private formatDocumentDate(input: Date | string): string {
const d = input instanceof Date ? input : new Date(input);
const dd = String(d.getUTCDate()).padStart(2, '0');
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
return `${dd}.${mm}.${d.getUTCFullYear()}`;
}
/**
* Гард ownership: председатель КУ может подтверждать списание только по
* своим участкам. Админ/совет (read:all) — по любому.
*/
private async assertBranchAuthorized(
member: IMarketplaceCurrentMember,
braname: string
): Promise<void> {
const roles = member.marketplace_roles as MarketplaceRole[];
if (canAccess(roles, 'Writeoff', 'read:all')) return;
const own = await this.kuChairmanService.listBranamesForMember(config.coopname, member.username);
if (!own.includes(braname)) {
throw new BadRequestException(
'Подтвердить списание можно только по участку, на котором вы являетесь председателем или доверенным лицом.'
);
}
}
}
@@ -123,6 +123,10 @@ function buildMocks() {
resolvePayoutMethod: jest.fn().mockResolvedValue(null),
} as any;
const supplierActionService = {
declineOrdersAtReception: jest.fn().mockResolvedValue([]),
} as any;
const logger = {
setContext: jest.fn(),
debug: jest.fn(),
@@ -143,6 +147,7 @@ function buildMocks() {
inventoryRepo,
coreGateway,
supplierSettings,
supplierActionService,
documentDomainService,
logger,
};
@@ -161,6 +166,7 @@ function buildService(mocks: ReturnType<typeof buildMocks>): MarketplaceAplRecep
{ symbol: 'RUB', decimals: 4 },
mocks.coreGateway,
mocks.supplierSettings,
mocks.supplierActionService,
mocks.documentDomainService,
new EventEmitter2(),
mocks.logger
@@ -375,4 +381,170 @@ describe('MarketplaceAplReceptionService — FR45 AC5 compensating-rollback', ()
expect(mocks.receptionRepo.applySignatures).not.toHaveBeenCalled();
});
});
describe('отказ в приёмке (некондиция): разнос принято/отклонено', () => {
it('signAsSupplier: позиция с факт=0 уходит в declineOrdersAtReception, signsupp только по принятой, статус → PENDING_CHAIRMAN', async () => {
const reception = buildReception({
fact_quantity_per_order: [
{ order_id: 'order-1', fact_quantity: 2 }, // принято
{ order_id: 'order-2', fact_quantity: 0 }, // снято оператором (некондиция)
],
});
const accepted = buildOrder({ id: 'order-1', order_hash: '0xorder1hash' });
const rejected = buildOrder({ id: 'order-2', order_hash: '0xorder2hash' });
mocks.receptionRepo.findById.mockResolvedValue(reception);
mocks.orderRepo.findByCycleId.mockResolvedValue([accepted, rejected]);
(mocks.chainPort.signSupp as jest.Mock).mockResolvedValue({
response: { transaction_id: 'tx-supplier-ok' },
});
mocks.receptionRepo.applySignatures.mockImplementation(async (_id, patch) => {
Object.assign(reception, patch);
return reception;
});
await service.signAsSupplier({
coopname: 'voskhod',
supplier_account: 'supplier1',
apl_reception_id: 'apl-1',
signed_documents: buildSignedDocs(['order-1']),
});
// signsupp — только по принятой позиции (одна).
expect(mocks.chainPort.signSupp).toHaveBeenCalledTimes(1);
// Снятая позиция ушла в отказ приёмки (полный возврат заказчику).
expect(mocks.supplierActionService.declineOrdersAtReception).toHaveBeenCalledTimes(1);
const declineArg = mocks.supplierActionService.declineOrdersAtReception.mock.calls[0][0];
expect(declineArg.orders.map((o: any) => o.id)).toEqual(['order-2']);
// Акт уходит председателю на закрывающую подпись.
const patch = mocks.receptionRepo.applySignatures.mock.calls[0][1] as any;
expect(patch.status).toBe(MarketplaceAplReceptionStatuses.PENDING_CHAIRMAN_RECEPTION_SIGN);
});
it('signAsSupplier: вся партия некондиция (все факт=0) → нет signsupp, отказ всех, приёмка CANCELLED + shipment CANCELLED', async () => {
const reception = buildReception({
fact_quantity_per_order: [
{ order_id: 'order-1', fact_quantity: 0 },
{ order_id: 'order-2', fact_quantity: 0 },
],
});
const o1 = buildOrder({ id: 'order-1', order_hash: '0xorder1hash' });
const o2 = buildOrder({ id: 'order-2', order_hash: '0xorder2hash' });
mocks.receptionRepo.findById.mockResolvedValue(reception);
mocks.orderRepo.findByCycleId.mockResolvedValue([o1, o2]);
mocks.receptionRepo.applySignatures.mockImplementation(async (_id, patch) => {
Object.assign(reception, patch);
return reception;
});
const result = await service.signAsSupplier({
coopname: 'voskhod',
supplier_account: 'supplier1',
apl_reception_id: 'apl-1',
signed_documents: [],
});
expect(mocks.chainPort.signSupp).not.toHaveBeenCalled();
const declineArg = mocks.supplierActionService.declineOrdersAtReception.mock.calls[0][0];
expect(declineArg.orders.map((o: any) => o.id).sort()).toEqual(['order-1', 'order-2']);
const patch = mocks.receptionRepo.applySignatures.mock.calls[0][1] as any;
expect(patch.status).toBe(MarketplaceAplReceptionStatuses.CANCELLED);
expect(mocks.shipmentRepo.applyStatusTransition).toHaveBeenCalledWith('ship-1', 'CANCELLED');
expect(result.apl_reception.status).toBe(MarketplaceAplReceptionStatuses.CANCELLED);
});
it('signAsChairman: отклонённую в приёмке позицию (факт=0) НЕ подписывает и НЕ продвигает в ACCEPTED_TO_COOP', async () => {
const reception = buildReception({
status: MarketplaceAplReceptionStatuses.PENDING_CHAIRMAN_RECEPTION_SIGN,
supplier_signed_at: new Date('2026-05-19T01:00:00Z'),
supplier_signsupp_tx_hash: 'tx-supplier-ok',
fact_quantity_per_order: [
{ order_id: 'order-1', fact_quantity: 2 }, // принято
{ order_id: 'order-2', fact_quantity: 0 }, // отклонено в приёмке
],
});
const accepted = buildOrder({ id: 'order-1', order_hash: '0xorder1hash' });
// Отклонённая позиция в PG уже терминальна.
const rejected = buildOrder({
id: 'order-2',
order_hash: '0xorder2hash',
status: 'CANCELLED_BY_SUPPLIER',
});
mocks.receptionRepo.findById.mockResolvedValue(reception);
mocks.orderRepo.findByCycleId.mockResolvedValue([accepted, rejected]);
(mocks.chainPort.signChair as jest.Mock).mockResolvedValue({
response: { transaction_id: 'tx-chair-ok' },
});
mocks.receptionRepo.applySignatures.mockImplementation(async (_id, patch) => {
Object.assign(reception, patch);
return reception;
});
await service.signAsChairman({
coopname: 'voskhod',
chairman_account: 'chair1',
apl_reception_id: 'apl-1',
signed_documents: buildSignedDocs(['order-1']),
});
// signchair — только по принятой позиции.
expect(mocks.chainPort.signChair).toHaveBeenCalledTimes(1);
// В ACCEPTED_TO_COOP продвигается только принятая, отклонённая — нет.
expect(mocks.orderRepo.applyStatusTransition).toHaveBeenCalledTimes(1);
expect(mocks.orderRepo.applyStatusTransition).toHaveBeenCalledWith(
'order-1',
'ACCEPTED_TO_COOP',
expect.any(String)
);
expect(mocks.orderRepo.applyStatusTransition).not.toHaveBeenCalledWith(
'order-2',
'ACCEPTED_TO_COOP',
expect.any(String)
);
});
});
describe('cancelReception: откат черновика приёмки (поставщик не согласен)', () => {
it('на PENDING_SUPPLIER_SIGN → приёмка CANCELLED + партия назад в SUPPLY_PREPARED', async () => {
const reception = buildReception({
status: MarketplaceAplReceptionStatuses.PENDING_SUPPLIER_SIGN,
});
mocks.receptionRepo.findById.mockResolvedValue(reception);
mocks.receptionRepo.applySignatures.mockImplementation(async (_id, patch) => {
Object.assign(reception, patch);
return reception;
});
const result = await service.cancelReception({
coopname: 'voskhod',
operator_account: 'operator1',
apl_reception_id: 'apl-1',
});
const patch = mocks.receptionRepo.applySignatures.mock.calls[0][1] as any;
expect(patch.status).toBe(MarketplaceAplReceptionStatuses.CANCELLED);
expect(mocks.shipmentRepo.applyStatusTransition).toHaveBeenCalledWith('ship-1', 'SUPPLY_PREPARED');
expect(result.apl_reception.status).toBe(MarketplaceAplReceptionStatuses.CANCELLED);
});
it('после подписи поставщика (PENDING_CHAIRMAN) → ConflictException, ничего не меняется', async () => {
const reception = buildReception({
status: MarketplaceAplReceptionStatuses.PENDING_CHAIRMAN_RECEPTION_SIGN,
});
mocks.receptionRepo.findById.mockResolvedValue(reception);
await expect(
service.cancelReception({
coopname: 'voskhod',
operator_account: 'operator1',
apl_reception_id: 'apl-1',
})
).rejects.toThrow(ConflictException);
expect(mocks.receptionRepo.applySignatures).not.toHaveBeenCalled();
expect(mocks.shipmentRepo.applyStatusTransition).not.toHaveBeenCalled();
});
});
});
@@ -84,6 +84,10 @@ import {
MARKETPLACE_SUPPLIER_SETTINGS_SERVICE,
MarketplaceSupplierSettingsService,
} from './marketplace-supplier-settings.service';
import {
MARKETPLACE_ORDER_SUPPLIER_ACTION_SERVICE,
MarketplaceOrderSupplierActionService,
} from './marketplace-order-supplier-action.service';
import { formatPayoutDestination } from '../shared/payout-destination.util';
import type { PaymentMethodDomainEntity } from '~/domain/payment-method/entities/method-domain.entity';
import type { MarketplaceAplReceptionDomainEntity } from '../../domain/entities/marketplace-apl-reception.entity';
@@ -217,6 +221,8 @@ export class MarketplaceAplReceptionService {
private readonly coreGateway: GatewayInteractorPort,
@Inject(MARKETPLACE_SUPPLIER_SETTINGS_SERVICE)
private readonly supplierSettings: MarketplaceSupplierSettingsService,
@Inject(MARKETPLACE_ORDER_SUPPLIER_ACTION_SERVICE)
private readonly supplierActionService: MarketplaceOrderSupplierActionService,
private readonly documentDomainService: DocumentDomainService,
private readonly eventBus: EventEmitter2,
private readonly logger: WinstonLoggerService
@@ -239,8 +245,12 @@ export class MarketplaceAplReceptionService {
): Promise<DocumentDomainEntity[]> {
const reception = await this.loadReception(coopname, apl_reception_id);
const groupOrders = await this.loadGroupOrders(reception);
// Поставщик подписывает акт только по принятым позициям (факт > 0). Снятые
// оператором позиции (факт = 0, некондиция) в акт не попадают — они уходят
// отказом в приёмке (declineorder) на шаге подписи поставщика.
const { accepted } = this.splitGroupByFact(reception, groupOrders);
const docs: DocumentDomainEntity[] = [];
for (const order of groupOrders) {
for (const order of accepted) {
docs.push(
await this.generateReceptionDocument({
reception,
@@ -603,12 +613,42 @@ export class MarketplaceAplReceptionService {
);
}
// Разнос позиций: оператор на стойке снял некондицию (факт = 0). Поставщик
// подтверждает приёмку целиком — подписывает акт по принятым позициям и тем
// же действием отменяет поставку снятых (отказ в приёмке без штрафа, полный
// возврат заказчику). Симметрия с отказом пайщика на выдаче.
const groupOrders = await this.loadGroupOrders(reception);
const { accepted, rejected } = this.splitGroupByFact(reception, groupOrders);
// Вся партия некондиция: подписывать нечего. Отклоняем все позиции (полный
// возврат заказчикам, поставщику без штрафа) и закрываем приёмку как
// отменённую — имущество поставщик увозит, принимать нечего.
if (accepted.length === 0) {
await this.supplierActionService.declineOrdersAtReception({
coopname: reception.coopname,
offerer_account: input.supplier_account,
orders: rejected,
reason: 'Отказ в приёмке: вся партия снята оператором (некондиция)',
});
const cancelled = await this.receptionRepo.applySignatures(reception.id, {
status: MarketplaceAplReceptionStatuses.CANCELLED,
});
await this.shipmentRepo.applyStatusTransition(
reception.shipment_id,
MarketplaceShipmentStatuses.CANCELLED
);
this.logger.log(
`АПП ${reception.id}: вся партия отклонена в приёмке (${rejected.length} поз.) — приёмка отменена, заказчикам полный возврат.`
);
this.emitReceptionStatusChanged(cancelled);
return { apl_reception: cancelled };
}
let txHash: string;
try {
const groupOrders = await this.loadGroupOrders(reception);
txHash = await this.submitOnChainSignSupp(
reception,
groupOrders,
accepted,
input.signed_documents,
input.supplier_account
);
@@ -621,6 +661,18 @@ export class MarketplaceAplReceptionService {
);
}
// Снятые позиции — отказ в приёмке (полный возврат заказчику, без штрафа).
// Best-effort: подпись по принятым уже на цепи, сбой отказа отдельной
// позиции логируется внутри сервиса и не валит фиксацию подписи.
if (rejected.length > 0) {
await this.supplierActionService.declineOrdersAtReception({
coopname: reception.coopname,
offerer_account: input.supplier_account,
orders: rejected,
reason: 'Отказ в приёмке: позиция снята оператором (некондиция)',
});
}
const updated = await this.receptionRepo.applySignatures(reception.id, {
supplier_signed_at: new Date(),
supplier_signsupp_tx_hash: txHash,
@@ -631,7 +683,9 @@ export class MarketplaceAplReceptionService {
status: MarketplaceAplReceptionStatuses.PENDING_CHAIRMAN_RECEPTION_SIGN,
});
this.logger.log(`АПП ${reception.id}: подпись поставщика принята (tx=${txHash}).`);
this.logger.log(
`АПП ${reception.id}: подпись поставщика принята (tx=${txHash}); принято ${accepted.length} поз., отклонено в приёмке ${rejected.length} поз.`
);
this.emitReceptionStatusChanged(updated);
@@ -648,12 +702,18 @@ export class MarketplaceAplReceptionService {
);
}
// Председатель закрывает приёмку только по принятым позициям (факт > 0).
// Снятые при подписи поставщика позиции уже отклонены (declineorder, заказ
// терминальный) — их нет в акте, в signchair, в переходе ACCEPTED_TO_COOP,
// на складе и в выплатах.
const groupOrders = await this.loadGroupOrders(reception);
const { accepted: acceptedOrders } = this.splitGroupByFact(reception, groupOrders);
let txHash: string;
try {
const groupOrders = await this.loadGroupOrders(reception);
txHash = await this.submitOnChainSignChair(
reception,
groupOrders,
acceptedOrders,
input.signed_documents,
input.chairman_account
);
@@ -674,10 +734,10 @@ export class MarketplaceAplReceptionService {
status: MarketplaceAplReceptionStatuses.ACCEPTED_TO_COOP,
});
// Order'ы группы → ACCEPTED_TO_COOP (FR19a выдача разблокируется только
// после этого момента).
const orders = await this.orderRepo.findByCycleId(reception.coopname, reception.cycle_id);
for (const o of orders.filter((x) => x.delivery_braname === reception.braname)) {
// Принятые Order'ы → ACCEPTED_TO_COOP (FR19a выдача разблокируется только
// после этого момента). Отклонённые в приёмке сюда не попадают — они уже
// терминальные (CANCELLED_BY_SUPPLIER).
for (const o of acceptedOrders) {
await this.orderRepo.applyStatusTransition(
o.id,
'ACCEPTED_TO_COOP',
@@ -693,13 +753,13 @@ export class MarketplaceAplReceptionService {
// Имущество, принятое по акту, появляется на складе КУ сразу — независимо
// от маркировки (штрих-код опционален). Полку и штрих-код оператор назначит
// позже на столе раскладки/маркировки.
await this.materializeInventory(updated, orders);
await this.materializeInventory(updated, acceptedOrders);
// Story 5.6 / 598-16 (L12): инициируем выплаты поставщику per-Order
// через gateway. Кассир увидит каждую выплату в общем реестре
// платежей кооператива и подтвердит/отклонит её там.
const orderHashByOrderId = new Map(orders.map((o) => [o.id, o.order_hash] as const));
await this.initiatePayouts(updated, orders, orderHashByOrderId);
const orderHashByOrderId = new Map(acceptedOrders.map((o) => [o.id, o.order_hash] as const));
await this.initiatePayouts(updated, acceptedOrders, orderHashByOrderId);
// Story 598-20: push кассиру о новой партии выплат — одно
// суммарное уведомление на АПП с агрегатом по поставщику.
@@ -713,7 +773,7 @@ export class MarketplaceAplReceptionService {
this.eventBus.emit(MARKETPLACE_CASHIER_NEW_PAYMENT_EVENT, event);
this.logger.log(
`АПП ${reception.id}: закрывающая подпись председателя ${input.chairman_account} принята (tx=${txHash}); выплаты по ${orders.filter((x) => x.delivery_braname === reception.braname).length} заказам инициированы через gateway.`
`АПП ${reception.id}: закрывающая подпись председателя ${input.chairman_account} принята (tx=${txHash}); выплаты по ${acceptedOrders.length} заказам инициированы через gateway.`
);
this.emitReceptionStatusChanged(updated);
@@ -721,6 +781,45 @@ export class MarketplaceAplReceptionService {
return { apl_reception: updated };
}
/**
* Откат черновика приёмки: поставщик не согласен со снятыми оператором
* позициями целиком (повезёт замену в другой раз) — оператор отменяет акт и
* пересобирает его заново. Допустимо ТОЛЬКО до подписи поставщика
* (PENDING_SUPPLIER_SIGN): on-chain ещё ничего не произошло (заказы в ACCEPTED,
* signsupp не отправлялся), поэтому откат — чисто PG: приёмка → CANCELLED,
* партия возвращается в SUPPLY_PREPARED (готова к новой приёмке), заказы не
* трогаем. После подписи поставщика откат невозможен — signsupp/declineorder
* уже на цепи.
*/
async cancelReception(input: {
coopname: string;
operator_account: string;
apl_reception_id: string;
}): Promise<MarketplaceAplReceptionResult> {
const reception = await this.loadReception(input.coopname, input.apl_reception_id);
if (reception.status !== MarketplaceAplReceptionStatuses.PENDING_SUPPLIER_SIGN) {
throw new ConflictException(
`Отменить приёмку можно только до подписи поставщика. Текущий статус: «${reception.status}».`
);
}
const cancelled = await this.receptionRepo.applySignatures(reception.id, {
status: MarketplaceAplReceptionStatuses.CANCELLED,
});
// Партия снова доступна к приёмке — оператор пересоберёт акт (findByShipmentId
// исключает CANCELLED, поэтому повторный create() по этой партии пройдёт).
await this.shipmentRepo.applyStatusTransition(
reception.shipment_id,
MarketplaceShipmentStatuses.SUPPLY_PREPARED
);
this.logger.log(
`АПП ${reception.id}: приёмка отменена оператором ${input.operator_account} (поставщик не согласен) — партия ${reception.shipment_id} снова готова к приёмке.`
);
this.emitReceptionStatusChanged(cancelled);
return { apl_reception: cancelled };
}
/**
* Realtime-сигнал смены статуса акта: оператор у стойки и поставщик видят
* проставленную подпись сразу, без поллинга. Эмитится ПОСЛЕ commit'а
@@ -927,6 +1026,36 @@ export class MarketplaceAplReceptionService {
return orders.filter((o) => o.delivery_braname === reception.braname);
}
/**
* Разнос заказов группы на принятые (факт > 0 — идут в акт, на баланс
* кооператива и в выплату поставщику) и отклонённые в приёмке (факт = 0 —
* оператор снял позицию: некондиция). Потолок факта = акцепт (см.
* buildFactQuantity); отсутствие записи факта трактуется как полный приём.
* Заказы с уже терминальным статусом (например, ранее отклонённые в этой же
* приёмке при повторном вызове) в принятые не попадают.
*/
private splitGroupByFact(
reception: MarketplaceAplReceptionDomainEntity,
groupOrders: MarketplaceOrderDomainEntity[]
): { accepted: MarketplaceOrderDomainEntity[]; rejected: MarketplaceOrderDomainEntity[] } {
const factByOrderId = new Map(
reception.fact_quantity_per_order.map((f) => [f.order_id, f.fact_quantity])
);
const accepted: MarketplaceOrderDomainEntity[] = [];
const rejected: MarketplaceOrderDomainEntity[] = [];
for (const o of groupOrders) {
const fact = factByOrderId.get(o.id) ?? o.quantity;
if (
o.status === MarketplaceOrderStatuses.CANCELLED_BY_SUPPLIER ||
o.status === MarketplaceOrderStatuses.CANCELLED_BY_ORDERER
) {
continue;
}
(fact > 0 ? accepted : rejected).push(o);
}
return { accepted, rejected };
}
/**
* Оприходование склада: на каждый принятый Order группы (fact_quantity > 0)
* заводим позицию инвентаря в статусе RECEIVED — имущество физически на
@@ -245,7 +245,10 @@ export class MarketplaceCheckoutService {
quantity: line.quantity,
checkout_id: checkoutId,
order_hash: signedLine.order_hash,
// Заказ из остатка из членских: паевой конвертируется на сумму строки
// отдельным действием перед заказом (см. createStockOrder).
convert_statement,
convert_amount: expectedAmount,
});
createdDTOs.push(toMarketplaceOrderDTO(res.order));
succeededOfferIds.push(line.offer_id);
@@ -0,0 +1,62 @@
import { MarketplaceEconomyService } from './marketplace-economy.service';
/**
* Чистая units-математика суммы конвертации/заказа из остатка. Эти помощники —
* основа deficit-аллокации замены непоставленного: ошибка округления/формата
* здесь рассинхронизирует сумму `convert` с фактическим списанием on-chain.
*
* Контрактная шкала процентов HUNDR_PERCENTS = 1_000_000 (100%); 5% = 50_000.
*/
describe('MarketplaceEconomyService — units↔asset, lineUnits', () => {
const service = new MarketplaceEconomyService(
null as never,
null as never,
{ decimals: 4, symbol: 'RUB' } as never,
null as never,
null as never
);
it('assetToUnits: с символом и без, дробная часть добивается', () => {
expect(service.assetToUnits('123.4500 RUB')).toBe(1_234_500n);
expect(service.assetToUnits('50.0000')).toBe(500_000n);
expect(service.assetToUnits('0.0001 RUB')).toBe(1n);
expect(service.assetToUnits('7')).toBe(70_000n);
});
it('unitsToAsset: формат X.XXXX RUB', () => {
expect(service.unitsToAsset(1_234_500n)).toBe('123.4500 RUB');
expect(service.unitsToAsset(1n)).toBe('0.0001 RUB');
expect(service.unitsToAsset(0n)).toBe('0.0000 RUB');
});
it('round-trip assetToUnits→unitsToAsset', () => {
for (const a of ['99.9999 RUB', '1000.0000 RUB', '0.0500 RUB']) {
expect(service.unitsToAsset(service.assetToUnits(a))).toBe(a);
}
});
it('lineUnits = тело + членский взнос (формула контракта)', () => {
// 3 × 10.0000, ставка 0% → только тело
expect(service.lineUnits('10.0000', 3, 0)).toBe(300_000n);
// 3 × 10.0000 = 300000, взнос 5% = 15000 → 315000
expect(service.lineUnits('10.0000', 3, 50_000)).toBe(315_000n);
});
it('инвариант замены: членских хватает → дефицит 0', () => {
// Высвобождено отменой ≥ сумма строки → конвертировать нечего.
const lineUnits = service.lineUnits('25.0000', 2, 50_000); // 500000 + 25000 = 525000
const memberAvailable = service.assetToUnits('60.0000 RUB'); // 600000
const fromMember = memberAvailable >= lineUnits ? lineUnits : memberAvailable;
const convertUnits = lineUnits - fromMember;
expect(convertUnits).toBe(0n);
});
it('инвариант доплаты: членских не хватает → конвертируется только дефицит', () => {
const lineUnits = service.lineUnits('25.0000', 2, 50_000); // 525000
const memberAvailable = service.assetToUnits('20.0000 RUB'); // 200000
const fromMember = memberAvailable >= lineUnits ? lineUnits : memberAvailable;
const convertUnits = lineUnits - fromMember;
expect(convertUnits).toBe(325_000n);
expect(service.unitsToAsset(convertUnits)).toBe('32.5000 RUB');
});
});
@@ -154,6 +154,28 @@ export class MarketplaceEconomyService {
return BigInt(int || '0') * BigInt(10) ** BigInt(decimals) + BigInt(fracPadded || '0');
}
/**
* Asset-строка («X.XXXX RUB» или «X.XXXX») → минимальные единицы валюты.
* Символ отбрасывается; целочисленно, без float-погрешности.
*/
assetToUnits(value: string): bigint {
const numeric = String(value).trim().split(/\s+/)[0] ?? '0';
return this.toUnits(numeric, this.assetConfig.decimals);
}
/** Минимальные единицы валюты → asset-строка «X.XXXX RUB». */
unitsToAsset(units: bigint): string {
const decimals = this.assetConfig.decimals;
const padded = units.toString().padStart(decimals + 1, '0');
return `${padded.slice(0, padded.length - decimals)}.${padded.slice(-decimals)} ${this.assetConfig.symbol}`;
}
/** Сумма строки заказа (тело + членский взнос) в минимальных единицах валюты. */
lineUnits(pricePerUnit: string, quantity: number, contractPercent: number): bigint {
const totalUnits = this.toUnits(pricePerUnit, this.assetConfig.decimals) * BigInt(quantity);
return totalUnits + this.membershipFeeUnits(totalUnits, contractPercent);
}
async setMembershipFee(coopname: string, feePercentHuman: number): Promise<number> {
const membership_fee_percent = this.toContractPercent(
feePercentHuman,
@@ -18,7 +18,6 @@ import {
} from './marketplace-asset.config';
import { DocumentDomainService } from '~/domain/document/services/document-domain.service';
import type { DocumentDomainEntity } from '~/domain/document/entity/document-domain.entity';
import type { DocumentDomainAggregate } from '~/domain/document/aggregates/document-domain.aggregate';
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
import type { MarketplaceIssueActSignedDocumentInputDTO } from '~/application/document/documents-dto/marketplace-issue-act-document.dto';
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
@@ -162,39 +161,6 @@ export class MarketplaceIssuanceService {
});
}
/**
* Документ для финальной подписи заказчика (вторая подпись АПП выдачи).
* Канон двухподписного акта (как закрытие АПП приёмки председателем):
* backend отдаёт DocumentAggregate — исходный документ из стора по doc_hash
* (`rawDocument`, для ознакомления) + документ, уже подписанный председателем
* (`document`, с его подписью). Заказчик накладывает свою подпись поверх:
* `signDocument(rawDocument, orderer, 2, [document])`. Фронтенд цепь не читает.
*/
async getFinalizeIssuanceSignablePayload(
coopname: string,
order_id: string
): Promise<DocumentDomainAggregate> {
const order = await this.loadOrder(coopname, order_id);
if (order.status !== 'READY_TO_RECEIVE') {
throw new ConflictException(
`Заказ в статусе «${order.status}», финальная подпись выдачи недопустима.`
);
}
const signed = order.issue_act_signiss1_document;
if (!signed) {
throw new ConflictException(
`Заказ ${order.id}: нет документа, подписанного председателем при открытии выдачи — финальная подпись недоступна до открытия выдачи.`
);
}
const aggregate = await this.documentDomainService.buildDocumentAggregate(signed);
if (!aggregate) {
throw new ConflictException(
`Заказ ${order.id}: исходный документ акта выдачи по doc_hash ${signed.doc_hash} не найден в сторе. Требуется заново открыть выдачу (тело документа не сохранено).`
);
}
return aggregate;
}
async openIssuance(input: MarketplaceOpenIssuanceInput): Promise<MarketplaceIssuanceResult> {
const order = await this.loadOrder(input.coopname, input.order_id);
if (order.status !== 'ACCEPTED_TO_COOP') {
@@ -535,30 +501,95 @@ export class MarketplaceIssuanceService {
actual_unit_price?: string;
}): Promise<DocumentDomainEntity> {
const unit_price = input.actual_unit_price ?? input.order.price_per_unit;
const total_amount = (
input.actual_quantity * Number.parseFloat(unit_price)
).toFixed(4);
// Артикул/наименование/единица — из оферты заказа: акт выдачи несёт ИМЕННО
// заказ заказчика (его СКУ, кол-во и стоимость), а не заглушку фабрики.
const offer = await this.offerRepo.findById(input.order.offer_id);
const action: Cooperative.Registry.MarketplaceAplIssuance.Action = {
registry_id: Cooperative.Registry.MarketplaceAplIssuance.registry_id,
return this.buildIssueActDocument({
coopname: input.order.coopname,
username: input.order.orderer_account,
orderer_account: input.order.orderer_account,
order_id: input.order.id,
order_hash: input.order.order_hash,
reception_id: input.order.id,
act_id: computeActNumber(input.order.order_hash),
transmitter: input.transmitter,
braname: input.order.delivery_braname,
accept_braname: input.order.delivery_braname,
fact_quantity: input.actual_quantity,
total_amount,
delivery_braname: input.order.delivery_braname,
supplier_account: input.order.supplier_account,
sku: input.order.offer_id,
offer_id: input.order.offer_id,
product_title: offer?.product_name ?? 'Товар по предложению',
unit_of_measurement: offer ? MARKETPLACE_UNIT_LABEL[offer.unit_of_measure] : '',
unit_cost: unit_price,
transmitter: input.transmitter,
actual_quantity: input.actual_quantity,
unit_price,
});
}
/**
* АПП-выдачи (registry 1105) для строки докладки со склада — заказа ещё нет,
* поэтому поля берутся из оффера остатка и детерминированного order_hash.
* Оператор КУ подписывает этот документ первой подписью (signiss1) при
* формировании бандла; пайщик контрподписывает его (signiss2) одной кнопкой.
* supplier_account = coopname (продавец — кооператив, маркер stock-заказа).
*/
async generateStockIssueActDocument(input: {
coopname: string;
orderer_account: string;
order_hash: string;
braname: string;
transmitter: string;
offer_id: string;
product_title: string;
unit_of_measurement: string;
quantity: number;
unit_price: string;
}): Promise<DocumentDomainEntity> {
return this.buildIssueActDocument({
coopname: input.coopname,
orderer_account: input.orderer_account,
// Заказа ещё нет — человеко-номер акта берём из order_hash; на цепи
// документ привязывается к заказу по order_hash (package=order_hash).
order_id: computeActNumber(input.order_hash),
order_hash: input.order_hash,
delivery_braname: input.braname,
supplier_account: input.coopname,
offer_id: input.offer_id,
product_title: input.product_title,
unit_of_measurement: input.unit_of_measurement,
transmitter: input.transmitter,
actual_quantity: input.quantity,
unit_price: input.unit_price,
});
}
private async buildIssueActDocument(p: {
coopname: string;
orderer_account: string;
order_id: string;
order_hash: string;
delivery_braname: string;
supplier_account: string;
offer_id: string;
product_title: string;
unit_of_measurement: string;
transmitter: string;
actual_quantity: number;
unit_price: string;
}): Promise<DocumentDomainEntity> {
const total_amount = (p.actual_quantity * Number.parseFloat(p.unit_price)).toFixed(4);
const action: Cooperative.Registry.MarketplaceAplIssuance.Action = {
registry_id: Cooperative.Registry.MarketplaceAplIssuance.registry_id,
coopname: p.coopname,
username: p.orderer_account,
order_id: p.order_id,
order_hash: p.order_hash,
reception_id: p.order_id,
act_id: computeActNumber(p.order_hash),
transmitter: p.transmitter,
braname: p.delivery_braname,
accept_braname: p.delivery_braname,
fact_quantity: p.actual_quantity,
total_amount,
supplier_account: p.supplier_account,
sku: p.offer_id,
product_title: p.product_title,
unit_of_measurement: p.unit_of_measurement,
unit_cost: p.unit_price,
currency: this.assetConfig.symbol,
// false: тело акта выдачи сохраняется в стор документов, чтобы заказчик
// мог получить исходник по doc_hash через buildDocumentAggregate и
@@ -15,6 +15,7 @@ import {
MARKETPLACE_CASHIER_NEW_PAYMENT_EVENT,
MARKETPLACE_ORDER_READY_TO_RECEIVE_EVENT,
MARKETPLACE_NEW_ORDER_FOR_SUPPLIER_EVENT,
MARKETPLACE_ORDER_DECLINED_BY_SUPPLIER_EVENT,
MARKETPLACE_RETURN_CLAIM_DECIDED_EVENT,
MARKETPLACE_RETURN_CLAIM_FINALIZED_EVENT,
MARKETPLACE_RETURN_CLAIM_SUBMITTED_EVENT,
@@ -25,6 +26,7 @@ import {
type MarketplaceCashierNewPaymentEvent,
type MarketplaceOrderReadyToReceiveEvent,
type MarketplaceNewOrderForSupplierEvent,
type MarketplaceOrderDeclinedBySupplierEvent,
type MarketplaceReturnClaimDecidedEvent,
type MarketplaceReturnClaimFinalizedEvent,
type MarketplaceReturnClaimSubmittedEvent,
@@ -477,6 +479,55 @@ export class MarketplaceNotificationService implements OnModuleInit {
}
}
@OnEvent(MARKETPLACE_ORDER_DECLINED_BY_SUPPLIER_EVENT)
async handleOrderDeclinedBySupplier(event: MarketplaceOrderDeclinedBySupplierEvent): Promise<void> {
try {
const ordererAccount = await this.accountPort.getAccount(event.orderer_account);
const subscriberId = ordererAccount.provider_account?.subscriber_id?.trim();
const email = ordererAccount.provider_account?.email;
if (!subscriberId || !email) {
this.logger.warn(
`Order ${event.order_id}: subscriber_id/email заказчика ${event.orderer_account} не найден — push об отказе поставщика пропущен.`
);
return;
}
const ordererName = await this.accountPort.getDisplayName(event.orderer_account);
// КУ резолвится в человеческое имя участка (account_kind=branch); при сбое
// деградируем к braname, чтобы текст не остался пустым.
let kuName = event.delivery_braname;
try {
kuName = await this.accountPort.getDisplayName(event.delivery_braname);
} catch {
/* оставляем braname */
}
const reasonExcerpt =
event.reason.length > 240 ? event.reason.slice(0, 240) + '…' : event.reason;
const payload: Workflows.MarketplaceOrderDeclinedBySupplier.IPayload = {
ordererName,
productName: event.product_name,
kuName,
reasonExcerpt,
coopname: event.coopname,
order_id: event.order_id,
deepLinkUrl: `${config.frontend_url}/${event.coopname}/orderer/MyOrders`,
};
const triggerData: WorkflowTriggerDomainInterface = {
name: Workflows.MarketplaceOrderDeclinedBySupplier.id,
to: { subscriberId, email },
payload,
};
await this.novuWorkflowPort.triggerWorkflow(triggerData);
this.logger.log(
`Order ${event.order_id}: push заказчику ${event.orderer_account} об отказе поставщика отправлен.`
);
} catch (err: any) {
this.logger.warn(
`Order ${event.order_id}: ошибка отправки push заказчику об отказе поставщика (${err.message}) — flow не блокируется.`
);
}
}
@OnEvent(MARKETPLACE_RETURN_ACCEPTED_FOR_SUPPLIER_EVENT)
async handleReturnAcceptedForSupplier(
event: MarketplaceReturnAcceptedForSupplierEvent
@@ -400,6 +400,23 @@ export class MarketplaceOfferService {
return this.repo.list({ coopname, supplier_account }, pagination);
}
// Реестр всех предложений кооператива (стол администратора): любой статус
// и любой поставщик, с опциональными фильтрами по статусу и поставщику.
async listAll(
coopname: string,
filter: { statuses?: MarketplaceOfferStatus[]; supplier_account?: string },
pagination: PaginationInputDomainInterface
): Promise<PaginationResultDomainInterface<MarketplaceOfferDomainEntity>> {
return this.repo.list(
{
coopname,
...(filter.supplier_account ? { supplier_account: filter.supplier_account } : {}),
...(filter.statuses?.length ? { status: filter.statuses } : {}),
},
pagination
);
}
async getById(id: string): Promise<MarketplaceOfferDomainEntity | null> {
return this.repo.findById(id);
}
@@ -119,11 +119,13 @@ describe('MarketplaceOrderCancelService', () => {
expect(mocks.chainPort.cancelOrder).not.toHaveBeenCalled();
});
// После открытия акта выдачи (readyrecv) и далее отмена закрыта —
// контракт её отвергнет; уже отменённый — тем более.
it.each([
'ACCEPTED',
'READY_TO_RECEIVE',
'RECEIVED',
'CANCELLED_BY_ORDERER',
] as const)('BadRequest когда status = %s (после акцепта/получения/уже отменён)', async (status) => {
] as const)('BadRequest когда status = %s (акт выдачи открыт / уже отменён)', async (status) => {
mocks.orderRepo.findById.mockResolvedValue(buildOrder({ status }));
await expect(
@@ -132,12 +134,16 @@ describe('MarketplaceOrderCancelService', () => {
expect(mocks.chainPort.cancelOrder).not.toHaveBeenCalled();
});
// Story 4.4 AC: ACCEPTED_PENDING_SUPPLIER* — отмена доступна до запуска
// поставщиком поставки. См. can_be_cancelled_by_orderer.
// Отмена/отказ доступны до открытия акта выдачи. Граница бесплатно/удержание
// 50% — на стороне контракта (до акцепта поставщиком vs после). Бэкенд лишь
// не закрывает действие раньше времени; на выдаче отказ идёт в ACCEPTED_TO_COOP.
it.each([
'ACTIVE',
'ACCEPTED_PENDING_SUPPLIER',
'ACCEPTED_PENDING_SUPPLIER_INDIVIDUAL',
'ACCEPTED',
'SUPPLY_PREPARED',
'ACCEPTED_TO_COOP',
] as const)('cancel доступен в статусе %s', async (status) => {
mocks.orderRepo.findById.mockResolvedValue(buildOrder({ status }));
mocks.chainPort.cancelOrder.mockResolvedValue({ transaction: { id: 'tx-cancel-abc' } } as any);
@@ -87,18 +87,24 @@ export class MarketplaceOrderCancelService {
if (order.orderer_account !== input.orderer_account) {
throw new ForbiddenException('Отменить заказ может только его заказчик.');
}
// Story 4.4 AC: отмена доступна заказчиком пока поставщик не запустил
// поставку. Это охватывает ACTIVE (in pool) и ACCEPTED_PENDING_SUPPLIER*
// (batch/individual ожидание подтверждения). После ACCEPTED отмена
// только через гарантийный возврат (Эпик 7).
// Отмена / отказ от получения заказчиком. Граница удержания — акцепт
// поставщиком (это решает КОНТРАКТ): до акцепта (ACTIVE / ожидание
// поставщика) — бесплатно, полный возврат; после акцепта и до открытия
// выдачи (ACCEPTED / SUPPLY_PREPARED / ACCEPTED_TO_COOP) — отказ с
// удержанием 50% (поставщик уже взял обязательство). На выдаче (отказ от
// позиции) заказ как раз в ACCEPTED_TO_COOP. После открытия акта выдачи
// (READY_TO_RECEIVE / RECEIVED) отмена закрыта — контракт её отвергнет.
const CANCELABLE_STATUSES = [
MarketplaceOrderStatuses.ACTIVE,
MarketplaceOrderStatuses.ACCEPTED_PENDING_SUPPLIER,
MarketplaceOrderStatuses.ACCEPTED_PENDING_SUPPLIER_INDIVIDUAL,
MarketplaceOrderStatuses.ACCEPTED,
MarketplaceOrderStatuses.SUPPLY_PREPARED,
MarketplaceOrderStatuses.ACCEPTED_TO_COOP,
] as const;
if (!(CANCELABLE_STATUSES as readonly string[]).includes(order.status)) {
throw new BadRequestException(
`Нельзя отменить заказ в статусе «${order.status}». Отмена доступна только до запуска поставщиком поставки.`
`Нельзя отменить заказ в статусе «${order.status}». Отмена закрыта после открытия акта выдачи.`
);
}
@@ -42,6 +42,11 @@ function buildMocks() {
create: jest.fn().mockResolvedValue({ id: 'cycle-1' } as any),
} as unknown as jest.Mocked<MarketplaceConsolidatedRequestDomainRepository>;
// Имя товара для уведомления заказчику об отказе тянется батчем по offer_id.
const offerRepo = {
findByIds: jest.fn().mockResolvedValue([{ id: 'offer-1', product_name: 'Молоко' }] as any),
} as any;
const offerCounters: jest.Mocked<MarketplaceOfferCountersService> = {
onOrderUnblocked: jest.fn().mockResolvedValue({} as any),
} as unknown as jest.Mocked<MarketplaceOfferCountersService>;
@@ -51,6 +56,9 @@ function buildMocks() {
declineOrder: jest.fn().mockResolvedValue({ transaction: { id: 'tx-dec-1' } } as any),
} as unknown as jest.Mocked<MarketplaceCanonicalBlockchainPort>;
// Шина событий: отказ эмитит уведомление заказчику (best-effort, не критично).
const eventBus = { emit: jest.fn() } as any;
const logger = {
setContext: jest.fn(),
debug: jest.fn(),
@@ -59,7 +67,7 @@ function buildMocks() {
warn: jest.fn(),
} as any;
return { orderRepo, cycleRepo, offerCounters, chainPort, logger };
return { orderRepo, cycleRepo, offerRepo, offerCounters, chainPort, eventBus, logger };
}
describe('MarketplaceOrderSupplierActionService (Эпик 15 — batch)', () => {
@@ -71,8 +79,10 @@ describe('MarketplaceOrderSupplierActionService (Эпик 15 — batch)', () =>
service = new MarketplaceOrderSupplierActionService(
mocks.orderRepo,
mocks.cycleRepo,
mocks.offerRepo,
mocks.offerCounters,
mocks.chainPort,
mocks.eventBus,
mocks.logger
);
});
@@ -1,4 +1,5 @@
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import {
MARKETPLACE_ORDER_REPOSITORY,
@@ -8,6 +9,10 @@ import {
MARKETPLACE_CONSOLIDATED_REQUEST_REPOSITORY,
type MarketplaceConsolidatedRequestDomainRepository,
} from '../../domain/repositories/marketplace-consolidated-request.repository';
import {
MARKETPLACE_OFFER_REPOSITORY,
type MarketplaceOfferDomainRepository,
} from '../../domain/repositories/marketplace-offer.repository';
import {
MARKETPLACE_OFFER_COUNTERS_SERVICE,
MarketplaceOfferCountersService,
@@ -19,6 +24,10 @@ import {
import type { MarketplaceOrderDomainEntity } from '../../domain/entities/marketplace-order.entity';
import { MarketplaceOrderStatuses } from '../../domain/entities/marketplace-order.types';
import { normalizeChainTxHash, rethrowChainError } from '../shared/chain-tx.util';
import {
MARKETPLACE_ORDER_DECLINED_BY_SUPPLIER_EVENT,
type MarketplaceOrderDeclinedBySupplierEvent,
} from '../events/marketplace-notification.events';
export interface MarketplaceSupplierAcceptBatchInput {
coopname: string;
@@ -53,10 +62,13 @@ export class MarketplaceOrderSupplierActionService {
private readonly orderRepo: MarketplaceOrderDomainRepository,
@Inject(MARKETPLACE_CONSOLIDATED_REQUEST_REPOSITORY)
private readonly cycleRepo: MarketplaceConsolidatedRequestDomainRepository,
@Inject(MARKETPLACE_OFFER_REPOSITORY)
private readonly offerRepo: MarketplaceOfferDomainRepository,
@Inject(MARKETPLACE_OFFER_COUNTERS_SERVICE)
private readonly offerCounters: MarketplaceOfferCountersService,
@Inject(MARKETPLACE_CANONICAL_BLOCKCHAIN_PORT)
private readonly chainPort: MarketplaceCanonicalBlockchainPort,
private readonly eventBus: EventEmitter2,
private readonly logger: WinstonLoggerService
) {
this.logger.setContext(MarketplaceOrderSupplierActionService.name);
@@ -203,9 +215,85 @@ export class MarketplaceOrderSupplierActionService {
if (declined.length === 0) {
throw new BadRequestException('Не указано ни одного заказа для отказа.');
}
// Уведомляем каждого заказчика отклонённого заказа (у каждого свой) с
// причиной отказа — заблокированные средства ему возвращены. Эмит ПОСЛЕ
// записи статусов в PG (INV-12); название товара тянем одним батчем по
// offer_id, чтобы текст уведомления был человекочитаемым.
await this.emitDeclinedNotifications(declined, reason);
return { cycle_id: null, orders: declined, tx_hashes: txHashes };
}
/**
* Отказ в приёмке (некондиция): поставщик привёз позиции, которые оператор
* снял с приёмки (факт = 0). Переиспользует тот же on-chain путь, что и
* pre-cycle отказ — `declineorder` (полный возврат резерва и членского взноса
* заказчику без штрафа + erase), но заказы здесь уже акцептованы и в партии:
* статус ACCEPTED / SUPPLY_PREPARED, не ACTIVE, и `cycle_id != null`. Поэтому
* это отдельный публичный вход, минующий guard `ACTIVE && cycle_id == null`
* массового pre-cycle отказа; контракт `declineorder` сам допускает эти
* статусы (имущество ещё не оприходовано — клоубэка нет).
*
* Best-effort per-order: накладная подпись поставщика по принятым позициям
* уже зафиксирована вызывающим, откатить её нельзя — поэтому сбой отказа по
* отдельной позиции логируется (заказчику возврат не выполнен, требуется
* ручной разбор), но не валит всю операцию. Ownership заказов гарантирован
* вызывающим (приёмка принадлежит этому поставщику).
*/
async declineOrdersAtReception(input: {
coopname: string;
offerer_account: string;
orders: MarketplaceOrderDomainEntity[];
reason: string;
}): Promise<MarketplaceOrderDomainEntity[]> {
const reason = (input.reason ?? '').trim() || 'Отказ в приёмке: некондиция';
const declined: MarketplaceOrderDomainEntity[] = [];
for (const order of input.orders) {
if (order.supplier_account !== input.offerer_account) continue;
try {
const res = await this.runDeclineChain(order, input.offerer_account, reason);
declined.push(res.order);
} catch (err: any) {
this.logger.error(
`MarketplaceOrderSupplierActionService.declineOrdersAtReception: отказ позиции ${order.id} (hash=${order.order_hash}) упал: ${err.message}; заказчику ${order.orderer_account} возврат не выполнен — требуется ручной разбор.`,
err.stack
);
}
}
if (declined.length > 0) {
await this.emitDeclinedNotifications(declined, reason);
}
return declined;
}
private async emitDeclinedNotifications(
declined: MarketplaceOrderDomainEntity[],
reason: string
): Promise<void> {
try {
const offerIds = Array.from(new Set(declined.map((o) => o.offer_id)));
const offers = await this.offerRepo.findByIds(offerIds);
const productNameByOfferId = new Map(offers.map((of) => [of.id, of.product_name]));
for (const order of declined) {
const event: MarketplaceOrderDeclinedBySupplierEvent = {
coopname: order.coopname,
order_id: order.id,
orderer_account: order.orderer_account,
delivery_braname: order.delivery_braname,
product_name: productNameByOfferId.get(order.offer_id) ?? 'Товар по предложению',
reason,
};
this.eventBus.emit(MARKETPLACE_ORDER_DECLINED_BY_SUPPLIER_EVENT, event);
}
} catch (err: any) {
// Уведомление не критично для доменного результата — не валим отказ.
this.logger.warn(
`MarketplaceOrderSupplierActionService.emitDeclinedNotifications: не удалось разослать уведомления об отказе (${err.message}) — flow не блокируется.`
);
}
}
private dedupeOrderIds(order_ids: string[]): string[] {
const ids = (order_ids ?? []).map((s) => (s ?? '').trim()).filter(Boolean);
if (ids.length === 0) throw new BadRequestException('Не выбрано ни одного заказа.');
@@ -10,14 +10,30 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { Cooperative, type MarketContract } from 'cooptypes';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { DocumentDomainService } from '~/domain/document/services/document-domain.service';
import {
DOCUMENT_VALIDATION_SERVICE,
type DocumentValidationService,
} from '~/domain/document/services/document-validation.service';
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
import type { MarketplaceCheckoutSignedLineInputDTO } from '../dto/marketplace-checkout.dto';
import { computeStockOrderHash } from '../shared/order-hash.util';
import {
USER_WALLET_REPOSITORY,
type UserWalletRepository,
} from '~/domain/wallet/repositories/user-wallet.repository';
import type { DocumentDomainEntity } from '~/domain/document/entity/document-domain.entity';
import type { DocumentDomainAggregate } from '~/domain/document/aggregates/document-domain.aggregate';
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
import type { MarketplaceConvertStatementSignedInputDTO } from '~/application/document/documents-dto/marketplace-convert-statement-document.dto';
import type { MarketplaceIssueActSignedDocumentInputDTO } from '~/application/document/documents-dto/marketplace-issue-act-document.dto';
import { computeStockOrderHash, computeConvertAnchorHash } from '../shared/order-hash.util';
import { MARKETPLACE_UNIT_LABEL } from '../shared/unit-label.util';
import {
MARKETPLACE_ISSUANCE_SERVICE,
type MarketplaceIssuanceService,
} from './marketplace-issuance.service';
import {
MARKETPLACE_ECONOMY_SERVICE,
MarketplaceEconomyService,
} from './marketplace-economy.service';
import type { MarketplaceCheckoutSignableLine } from './marketplace-checkout.service';
import {
MARKETPLACE_STOCK_PROPOSAL_REPOSITORY,
type MarketplaceStockProposalDomainRepository,
@@ -26,8 +42,13 @@ import {
MARKETPLACE_OFFER_REPOSITORY,
type MarketplaceOfferDomainRepository,
} from '../../domain/repositories/marketplace-offer.repository';
import {
MARKETPLACE_ORDER_REPOSITORY,
type MarketplaceOrderDomainRepository,
} from '../../domain/repositories/marketplace-order.repository';
import { MarketplaceStockService, MARKETPLACE_STOCK_SERVICE } from './marketplace-stock.service';
import { MarketplaceOfferStatuses } from '../../domain/entities/marketplace-offer.types';
import type { MarketplaceOfferDomainEntity } from '../../domain/entities/marketplace-offer.entity';
import {
MarketplaceStockProposalStatuses,
type MarketplaceStockProposalItem,
@@ -41,12 +62,40 @@ import {
type MarketplaceStockProposalResolvedEvent,
} from '../events/marketplace-notification.events';
/** Строка корзины докладки на этапе формирования бандла оператором. */
export interface MarketplaceStockProposalCreateLine {
offer_id: string;
quantity: number;
/** Детерминированный order_hash (из payloads оператора). */
order_hash: string;
/** АПП-выдачи, подписанный оператором первой подписью (signiss1). */
signiss1_act: ISignedDocumentDomainInterface;
}
/**
* Строка обычного заказа в бандле: заказ уже существует (ACCEPTED_TO_COOP),
* оператор сверил факт (кол-во/цену) и подписал signiss1-акт. На финализации
* заказ не создаётся — сразу выдача (openIssuance+signiss2).
*/
export interface MarketplaceOrderProposalCreateLine {
order_id: string;
/** Фактическое количество к выдаче (оператор сверил при открытии). */
actual_quantity: number;
/** Фактическая цена за единицу (оператор мог скорректировать). */
actual_unit_price: string;
/** АПП-выдачи, подписанный оператором первой подписью (signiss1). */
signiss1_act: ISignedDocumentDomainInterface;
}
export interface MarketplaceStockProposalCreateInput {
coopname: string;
operator_account: string;
braname: string;
member_account: string;
items: Array<{ offer_id: string; quantity: number }>;
/** Строки докладки со склада (заказ родится на финализации). */
items: MarketplaceStockProposalCreateLine[];
/** Строки уже существующих обычных заказов пайщика к выдаче. */
order_items?: MarketplaceOrderProposalCreateLine[];
}
export interface MarketplaceStockProposalAcceptResult {
@@ -54,6 +103,68 @@ export interface MarketplaceStockProposalAcceptResult {
order_ids: string[];
}
/**
* Нагрузка оператору для подписи signiss1 при формировании бандла: по строке —
* детерминированный order_hash и сгенерированный АПП-выдачи (registry 1105),
* который оператор подписывает своим ключом. Снапшоты — для показа в корзине.
*/
export interface MarketplaceStockIssuanceOperatorLine {
offer_id: string;
quantity: number;
order_hash: string;
unit_price: string;
product_name: string;
signiss1_document: DocumentDomainEntity;
}
/**
* Строка к подписи пайщиком: offer + order_hash + АПП-выдачи, уже подписанный
* оператором (агрегат: исходник по doc_hash + подписанный оператором документ),
* который пайщик контрподписывает (signiss2).
*/
export interface MarketplaceStockAcceptOrderLine {
offer_id: string;
order_hash: string;
signiss1_aggregate: DocumentDomainAggregate;
}
/**
* Полезная нагрузка к подписи пайщиком (одна кнопка «Подписать»). Заявление о
* конвертации — ОДНО на весь дефицит (не по каждой строке): `convert` != null
* только когда членских средств не хватает; при замене из высвобожденных средств
* `convert` == null — пайщику подписывать нечего, кроме самих актов.
*/
export interface MarketplaceStockAcceptPayload {
/**
* Сколько из стоимости докладки спишется с уже внесённых членских средств
* «Стола заказов» (покрытие имеющимся; включая высвобожденные недосдачей).
* Вместе с convert.amount даёт полную стоимость докладки.
*/
member_amount: string;
order_lines: MarketplaceStockAcceptOrderLine[];
/**
* Дефицит сверх членских — доплата с паевого через Заявление о конвертации.
* null, если членских хватает (подписывать нечего).
*/
convert: {
amount: string;
convert_hash: string;
document: DocumentDomainEntity;
} | null;
}
/** Строка финализации: order_hash + контрподписанный пайщиком АПП-выдачи (signiss2). */
export interface MarketplaceStockFinalizeLine {
order_hash: string;
signed_signiss2_act: MarketplaceIssueActSignedDocumentInputDTO;
}
/** Вход финализации: строки с подписью получения + единое Заявление (или нет). */
export interface MarketplaceStockFinalizeInput {
order_lines: MarketplaceStockFinalizeLine[];
signed_convert?: MarketplaceConvertStatementSignedInputDTO | null;
}
/**
* requirement 76, решения 10–11: двухфазная докладка у стойки выдачи.
*
@@ -74,10 +185,18 @@ export class MarketplaceStockProposalService {
private readonly proposalRepo: MarketplaceStockProposalDomainRepository,
@Inject(MARKETPLACE_OFFER_REPOSITORY)
private readonly offerRepo: MarketplaceOfferDomainRepository,
@Inject(MARKETPLACE_ORDER_REPOSITORY)
private readonly orderRepo: MarketplaceOrderDomainRepository,
@Inject(MARKETPLACE_STOCK_SERVICE)
private readonly stockService: MarketplaceStockService,
@Inject(MARKETPLACE_ISSUANCE_SERVICE)
private readonly issuanceService: MarketplaceIssuanceService,
@Inject(MARKETPLACE_ECONOMY_SERVICE)
private readonly economyService: MarketplaceEconomyService,
@Inject(USER_WALLET_REPOSITORY)
private readonly userWalletRepo: UserWalletRepository,
@Inject(DOCUMENT_VALIDATION_SERVICE)
private readonly documentValidationService: DocumentValidationService,
private readonly documentDomainService: DocumentDomainService,
private readonly eventBus: EventEmitter2,
private readonly logger: WinstonLoggerService
@@ -86,16 +205,17 @@ export class MarketplaceStockProposalService {
}
/**
* Заявления о конвертации паевого взноса к подписи по строкам докладки —
* как в checkout'е корзины: order_hash будущего stock-заказа рождается
* здесь и зашивается в мету; подписанные заявления возвращаются в
* `acceptProposal` строками `lines`.
* Полезная нагрузка к ОДНОЙ подписи пайщика по докладке: по строке — её
* фиксированный order_hash и подписанный оператором signiss1-акт (агрегат для
* контрподписи), плюс единое Заявление о конвертации на весь дефицит сверх
* уже внесённых членских средств. Если членских хватает — convert == null,
* подписывать нужно только сами акты получения.
*/
async getAcceptSignablePayloads(
coopname: string,
proposal_id: string,
member_account: string
): Promise<MarketplaceCheckoutSignableLine[]> {
): Promise<MarketplaceStockAcceptPayload> {
const proposal = await this.loadProposal(coopname, proposal_id);
if (proposal.member_account !== member_account) {
throw new ForbiddenException('Подписать заявления может только адресат предложения.');
@@ -103,63 +223,246 @@ export class MarketplaceStockProposalService {
this.assertProposed(proposal);
const feePercent = await this.economyService.getMembershipFeeContractPercent(coopname);
const result: MarketplaceCheckoutSignableLine[] = [];
// По строке: фиксированный order_hash (из бандла) + агрегат signiss1-акта,
// уже подписанного оператором, чтобы пайщик контрподписал его (signiss2).
// Плюс суммарная потребность в членских средствах для разложения сумм.
const order_lines: MarketplaceStockAcceptOrderLine[] = [];
let neededUnits = 0n;
for (const item of proposal.items) {
const order_hash = computeStockOrderHash(coopname, member_account, item.offer_id);
const amount = this.economyService.convertAmountForLine(
item.unit_price,
item.quantity,
feePercent
if (!item.order_hash || !item.signiss1_act) {
throw new ConflictException(
'Докладка сформирована в устаревшем формате (без подписи оператора) — переформируйте её у стойки.'
);
}
const signiss1_aggregate = await this.documentDomainService.buildDocumentAggregate(
item.signiss1_act
);
const action: Cooperative.Registry.MarketplaceConvertStatement.Action = {
registry_id: Cooperative.Registry.MarketplaceConvertStatement.registry_id,
coopname,
username: member_account,
lang: 'ru',
order_hash,
amount,
// Тело сохраняется в стор — реестр документов пересобирает агрегат
// по doc_hash (rawDocument).
skip_save: false,
};
const document = await this.documentDomainService.generateDocument({ data: action });
result.push({ offer_id: item.offer_id, order_hash, amount, document });
if (!signiss1_aggregate) {
throw new ConflictException(
`Исходник акта выдачи по строке «${item.product_name}» не найден в сторе — переформируйте докладку.`
);
}
order_lines.push({
offer_id: item.offer_id,
order_hash: item.order_hash,
signiss1_aggregate,
});
// Конвертация паевого нужна ТОЛЬКО под докладку (строка без order_id):
// её полная стоимость блокируется с членского на stockorder. Обычный
// заказ уже профондирован с паевого на createorder — доплату по факту
// (overage) signiss2 берёт с членского сам и фейлится при нехватке.
if (!item.order_id) {
neededUnits += this.economyService.lineUnits(item.unit_price, item.quantity, feePercent);
}
}
return result;
// Дефицит = потребность − уже внесённые членские средства. Конвертируем
// только его, одним заявлением.
const memberUnits = await this.readMemberUnits(coopname, member_account);
const deficitUnits = neededUnits > memberUnits ? neededUnits - memberUnits : 0n;
// Покрытие имеющимися членскими = вся стоимость минус дефицит (= min(needed, member)).
const coveredUnits = neededUnits - deficitUnits;
const member_amount = this.economyService.unitsToAsset(coveredUnits);
if (deficitUnits === 0n) {
return { member_amount, order_lines, convert: null };
}
const amount = this.economyService.unitsToAsset(deficitUnits);
const convert_hash = computeConvertAnchorHash(coopname, member_account, proposal_id);
const action: Cooperative.Registry.MarketplaceConvertStatement.Action = {
registry_id: Cooperative.Registry.MarketplaceConvertStatement.registry_id,
coopname,
username: member_account,
lang: 'ru',
order_hash: convert_hash,
amount,
skip_save: false,
};
const document = await this.documentDomainService.generateDocument({ data: action });
return { member_amount, order_lines, convert: { amount, convert_hash, document } };
}
/** Баланс членского кошелька «Стола заказов» пайщика в минимальных единицах. */
private async readMemberUnits(coopname: string, member_account: string): Promise<bigint> {
const wallet = await this.userWalletRepo.findByWalletAndUsername(
coopname,
'w.mkt.member',
member_account
);
return wallet?.available ? this.economyService.assetToUnits(wallet.available) : 0n;
}
/** Валидация строки докладки: активный оффер остатка ИМЕННО этого КУ + кол-во. */
private async validateStockLine(
coopname: string,
braname: string,
offer_id: string,
quantity: number
): Promise<MarketplaceOfferDomainEntity> {
if (!Number.isInteger(quantity) || quantity <= 0) {
throw new BadRequestException('Количество в строке должно быть целым числом больше нуля.');
}
const offer = await this.offerRepo.findById(offer_id);
if (!offer || offer.coopname !== coopname) {
throw new NotFoundException('Предложение остатка не найдено.');
}
if (offer.stock_braname !== braname) {
throw new BadRequestException(
`«${offer.product_name}» — остаток другого КУ, со стойки ${braname} не докладывается.`
);
}
if (offer.status !== MarketplaceOfferStatuses.ACTIVE) {
throw new BadRequestException(`«${offer.product_name}» снят с публикации.`);
}
if (offer.quantity_available < quantity) {
throw new BadRequestException(
`«${offer.product_name}»: на складе свободно ${offer.quantity_available} ед., нельзя предложить ${quantity}.`
);
}
return offer;
}
/**
* Валидация строки обычного заказа в бандле + снапшот для показа пайщику.
* Заказ обязан быть ACCEPTED_TO_COOP без открытой выдачи, принадлежать
* адресату бандла и выдаваться ИМЕННО с этого КУ. order_hash берётся из
* заказа (бэкенд авторитетен), product_name — из оффера заказа.
*/
private async buildOrderItem(
coopname: string,
braname: string,
member_account: string,
line: MarketplaceOrderProposalCreateLine
): Promise<MarketplaceStockProposalItem> {
if (!Number.isInteger(line.actual_quantity) || line.actual_quantity <= 0) {
throw new BadRequestException('Количество в строке заказа должно быть целым больше нуля.');
}
if (Number.parseFloat(line.actual_unit_price) <= 0) {
throw new BadRequestException('Цена в строке заказа должна быть больше нуля.');
}
if (!line.signiss1_act?.signatures?.length) {
throw new BadRequestException(
'Строка заказа без подписи оператора (signiss1) — переподпишите выдачу.'
);
}
const order = await this.orderRepo.findById(line.order_id);
if (!order || order.coopname !== coopname) {
throw new NotFoundException(`Заказ ${line.order_id} не найден.`);
}
if (order.orderer_account !== member_account) {
throw new BadRequestException(
'Заказ принадлежит другому пайщику — нельзя включить его в бандл этого получателя.'
);
}
if (order.delivery_braname !== braname) {
throw new BadRequestException(
`Заказ выдаётся на другом КУ (${order.delivery_braname}), со стойки ${braname} не выдаётся.`
);
}
if (!order.awaits_chairman_issue_open) {
throw new ConflictException(
`Заказ ${order.id} (статус «${order.status}») не готов к открытию выдачи.`
);
}
const offer = await this.offerRepo.findById(order.offer_id);
return {
order_id: order.id,
offer_id: order.offer_id,
quantity: line.actual_quantity,
unit_price: line.actual_unit_price,
product_name: offer?.product_name ?? 'Товар по предложению',
order_hash: order.order_hash,
signiss1_act: line.signiss1_act,
};
}
/**
* Нагрузка оператору для подписи signiss1 при формировании бандла докладки.
* По строке: детерминированный order_hash (он же уйдёт в будущий stockorder) +
* сгенерированный АПП-выдачи (registry 1105), который оператор подписывает
* своим ключом первой подписью. Заказ ещё НЕ создаётся — он родится на
* финализации, когда пайщик контрподпишет акт (одна кнопка).
*/
async getOperatorIssuancePayloads(input: {
coopname: string;
braname: string;
member_account: string;
operator_account: string;
items: Array<{ offer_id: string; quantity: number }>;
}): Promise<MarketplaceStockIssuanceOperatorLine[]> {
if (input.items.length === 0) {
throw new BadRequestException('Корзина докладки пуста — добавьте позиции из остатка.');
}
if (input.member_account === input.operator_account) {
throw new BadRequestException('Нельзя доложить самому себе.');
}
const lines: MarketplaceStockIssuanceOperatorLine[] = [];
for (const item of input.items) {
const offer = await this.validateStockLine(
input.coopname,
input.braname,
item.offer_id,
item.quantity
);
const order_hash = computeStockOrderHash(input.coopname, input.member_account, offer.id);
const signiss1_document = await this.issuanceService.generateStockIssueActDocument({
coopname: input.coopname,
orderer_account: input.member_account,
order_hash,
braname: input.braname,
transmitter: input.operator_account,
offer_id: offer.id,
product_title: offer.product_name,
unit_of_measurement: MARKETPLACE_UNIT_LABEL[offer.unit_of_measure] ?? '',
quantity: item.quantity,
unit_price: offer.price_per_unit,
});
lines.push({
offer_id: offer.id,
quantity: item.quantity,
order_hash,
unit_price: offer.price_per_unit,
product_name: offer.product_name,
signiss1_document,
});
}
return lines;
}
async createProposal(
input: MarketplaceStockProposalCreateInput
): Promise<MarketplaceStockProposalDomainEntity> {
if (input.items.length === 0) {
throw new BadRequestException('Предложение пустое — добавьте позиции из остатка.');
const orderLines = input.order_items ?? [];
if (input.items.length === 0 && orderLines.length === 0) {
throw new BadRequestException('Бандл пуст — добавьте позиции заказа или докладку со склада.');
}
if (input.member_account === input.operator_account) {
throw new BadRequestException('Нельзя отправить предложение самому себе.');
}
// Снапшоты строк: только активные офферы кооператива ИМЕННО этого КУ,
// количество в пределах доступного остатка.
// Единый бандл несёт строки двух видов:
// • докладка — активные офферы остатка ИМЕННО этого КУ, кол-во в пределах
// остатка; заказ родится на финализации.
// • обычный заказ — уже существующий ACCEPTED_TO_COOP заказ пайщика
// (профондирован с паевого), сразу к выдаче.
// Каждая строка несёт order_hash и подписанный оператором signiss1-акт —
// пайщику останется одна подпись.
const items: MarketplaceStockProposalItem[] = [];
for (const line of input.items) {
if (!Number.isInteger(line.quantity) || line.quantity <= 0) {
throw new BadRequestException('Количество в строке должно быть целым числом больше нуля.');
const offer = await this.validateStockLine(
input.coopname,
input.braname,
line.offer_id,
line.quantity
);
if (!line.order_hash) {
throw new BadRequestException('Строка без order_hash — переформируйте докладку.');
}
const offer = await this.offerRepo.findById(line.offer_id);
if (!offer || offer.coopname !== input.coopname) {
throw new NotFoundException('Предложение остатка не найдено.');
}
if (offer.stock_braname !== input.braname) {
if (!line.signiss1_act?.signatures?.length) {
throw new BadRequestException(
`«${offer.product_name}» — остаток другого КУ, со стойки ${input.braname} не докладывается.`
);
}
if (offer.status !== MarketplaceOfferStatuses.ACTIVE) {
throw new BadRequestException(`«${offer.product_name}» снят с публикации.`);
}
if (offer.quantity_available < line.quantity) {
throw new BadRequestException(
`«${offer.product_name}»: на складе свободно ${offer.quantity_available} ед., нельзя предложить ${line.quantity}.`
'Строка без подписи оператора (signiss1) — переподпишите докладку.'
);
}
items.push({
@@ -167,8 +470,15 @@ export class MarketplaceStockProposalService {
quantity: line.quantity,
unit_price: offer.price_per_unit,
product_name: offer.product_name,
order_hash: line.order_hash,
signiss1_act: line.signiss1_act,
});
}
for (const line of orderLines) {
items.push(
await this.buildOrderItem(input.coopname, input.braname, input.member_account, line)
);
}
const proposal = await this.proposalRepo.create({
coopname: input.coopname,
@@ -192,84 +502,175 @@ export class MarketplaceStockProposalService {
return proposal;
}
/** Пайщик принимает: по каждой строке — заказ из остатка (средства блокируются здесь). */
async acceptProposal(
/**
* Пайщик ОДНОЙ подписью утверждает докладку как акт. При дефиците — сперва
* конвертация паевого по подписанному Заявлению; затем по каждой строке:
* заказ из остатка (stockorder) → открытие выдачи подписанным ОПЕРАТОРОМ актом
* (signiss1) → закрытие контрподписью ПАЙЩИКА (signiss2), заказ сразу RECEIVED.
*
* Фаза создания заказов атомарна (на фейле — компенсирующая отмена всех
* созданных, они ещё ACCEPTED_TO_COOP). Подписи signiss1/signiss2 идут после:
* документы провалидированы заранее, фейл здесь маловероятен и не откатывается
* (заказ уже выдаётся) — логируем для ручной достройки оператором.
*/
async finalizeStockIssuance(
coopname: string,
proposal_id: string,
member_account: string,
lines?: MarketplaceCheckoutSignedLineInputDTO[] | null
input: MarketplaceStockFinalizeInput
): Promise<MarketplaceStockProposalAcceptResult> {
const proposal = await this.loadProposal(coopname, proposal_id);
if (proposal.member_account !== member_account) {
throw new ForbiddenException(ринять предложение может только его адресат.');
throw new ForbiddenException(одписать докладку может только её адресат.');
}
this.assertProposed(proposal);
const feePercent = await this.economyService.getMembershipFeeContractPercent(coopname);
const signedByOffer = new Map((lines ?? []).map((l) => [l.offer_id, l]));
// Построчно: успехи сохраняем, на первом фейле останавливаемся и
// компенсируем уже созданные заказы (атомарность докладки — UX-инвариант:
// пайщик принимает её целиком).
const order_ids: string[] = [];
// signiss2-акт по order_hash строки (уникален в бандле; offer_id мог бы
// совпасть у двух обычных заказов одного оффера).
const signiss2ByHash = new Map(
(input.order_lines ?? []).map((l) => [l.order_hash, l.signed_signiss2_act])
);
// ── 0) Контрольная сверка: пайщик подписал РОВНО тот акт, что мы выдали ──
// Защита от подмены тела («выдали вагон алюминия»): по каждой строке
// контрподписанный пайщиком документ обязан совпасть с сохранённым
// signiss1-актом оператора и пройти крипто-/структурную валидацию. Делаем
// ДО любых транзакций и блокировок средств — на подмене падаем рано.
for (const item of proposal.items) {
if (!item.order_hash || !item.signiss1_act) {
throw new ConflictException(
'Бандл в устаревшем формате (без подписи оператора) — переформируйте его у стойки.'
);
}
const submitted = signiss2ByHash.get(item.order_hash);
if (!submitted) {
throw new BadRequestException(
'Состав подписания не совпадает с бандлом — обновите подписание.'
);
}
await this.assertCountersignMatchesStored(item, submitted, member_account);
}
// Потребность и дефицит сверх членских — ТОЛЬКО по докладке (как в
// getAcceptSignablePayloads; суммы обязаны совпасть с подписанным Заявлением).
// Обычный заказ профондирован с паевого ранее и в дефицит не входит.
let neededUnits = 0n;
for (const item of proposal.items) {
if (!item.order_id) {
neededUnits += this.economyService.lineUnits(item.unit_price, item.quantity, feePercent);
}
}
const memberUnits = await this.readMemberUnits(coopname, member_account);
const deficitUnits = neededUnits > memberUnits ? neededUnits - memberUnits : 0n;
// ── 1) Дефицит — ОДНА конвертация паевого по подписанному Заявлению ──────
if (deficitUnits > 0n) {
if (!input.signed_convert) {
throw new BadRequestException(
'Нет подписанного Заявления о конвертации паевого взноса — обновите подписание докладки.'
);
}
const meta = input.signed_convert.meta;
const expectedAmount = this.economyService.unitsToAsset(deficitUnits);
const expectedHash = computeConvertAnchorHash(coopname, member_account, proposal_id);
if (
meta.registry_id !== Cooperative.Registry.MarketplaceConvertStatement.registry_id ||
meta.order_hash !== expectedHash ||
meta.amount !== expectedAmount
) {
throw new BadRequestException(
'Заявление о конвертации не соответствует сумме доплаты — обновите подписание.'
);
}
const convert_statement = new SignedDigitalDocumentInputDTO(
input.signed_convert
).toDocument() as MarketContract.Actions.Convert.IConvert['convert_statement'];
await this.stockService.convertToMember({
coopname,
orderer: member_account,
amount: expectedAmount,
convert_statement,
});
}
// ── 2) Заказы к выдаче: докладка → создаём из остатка (атомарно, на фейле —
// отмена созданных); обычный заказ → уже существует, берём его order_id.
const issuables: Array<{
order_id: string;
item: MarketplaceStockProposalItem;
signiss2: MarketplaceIssueActSignedDocumentInputDTO;
}> = [];
const createdStock: Array<{ order_id: string }> = [];
try {
for (const item of proposal.items) {
// Заявление о конвертации — обязательный спутник каждой строки
// (контракт требует подписанный документ и публикует его в реестр).
const signedLine = signedByOffer.get(item.offer_id);
if (!signedLine) {
throw new BadRequestException(
'Нет подписанного заявления о конвертации паевого взноса — обновите принятие предложения.'
if (!item.order_hash || !item.signiss1_act) {
throw new ConflictException(
'Бандл в устаревшем формате (без подписи оператора) — переформируйте его у стойки.'
);
}
const meta = signedLine.signed_statement.meta;
const expectedAmount = this.economyService.convertAmountForLine(
item.unit_price,
item.quantity,
feePercent
);
if (
meta.registry_id !== Cooperative.Registry.MarketplaceConvertStatement.registry_id ||
meta.order_hash !== signedLine.order_hash ||
meta.amount !== expectedAmount
) {
const signiss2 = signiss2ByHash.get(item.order_hash);
if (!signiss2) {
throw new BadRequestException(
'Заявление о конвертации не соответствует строке предложения — обновите принятие.'
'Состав подписания не совпадает с бандлом — обновите подписание.'
);
}
const convert_statement = new SignedDigitalDocumentInputDTO(
signedLine.signed_statement
).toDocument() as MarketContract.Actions.StockOrder.IStockOrder['convert_statement'];
if (item.order_id) {
// Обычный заказ — уже ACCEPTED_TO_COOP, создавать нечего.
issuables.push({ order_id: item.order_id, item, signiss2 });
continue;
}
// Докладка: средства уже в членском (конвертация выше при дефиците) — без Заявления.
const { order } = await this.stockService.createStockOrder({
coopname,
orderer_account: member_account,
offer_id: item.offer_id,
quantity: item.quantity,
checkout_id: proposal.id,
order_hash: signedLine.order_hash,
convert_statement,
order_hash: item.order_hash,
});
order_ids.push(order.id);
createdStock.push({ order_id: order.id });
issuables.push({ order_id: order.id, item, signiss2 });
}
} catch (error) {
for (const orderId of order_ids) {
for (const { order_id } of createdStock) {
try {
await this.stockService.cancelStockOrder(
coopname,
orderId,
order_id,
member_account,
'Акцепт докладки не завершился — строка отменена'
'Подписание бандла не завершилось — строка отменена'
);
} catch (compErr: any) {
this.logger.error(
`acceptProposal: компенсирующая отмена stock-order ${orderId} упала: ${compErr.message}. РУЧНАЯ СВЕРКА!`
`finalizeStockIssuance: компенсирующая отмена stock-order ${order_id} упала: ${compErr.message}. РУЧНАЯ СВЕРКА!`
);
}
}
throw error;
}
// ── 3) Выдача: signiss1 оператора → signiss2 пайщика (заказ RECEIVED) ────
const order_ids: string[] = [];
for (const { order_id, item, signiss2 } of issuables) {
await this.issuanceService.openIssuance({
coopname,
chairman_account: proposal.operator_account,
order_id,
actual_quantity: item.quantity,
actual_unit_price: item.unit_price,
signed_document: item.signiss1_act as unknown as MarketplaceIssueActSignedDocumentInputDTO,
});
await this.issuanceService.finalizeIssuance({
coopname,
orderer_account: member_account,
order_id,
signed_document: signiss2,
});
order_ids.push(order_id);
}
const resolved = await this.proposalRepo.applyResolution(
proposal.id,
MarketplaceStockProposalStatuses.PROPOSED,
@@ -277,27 +678,19 @@ export class MarketplaceStockProposalService {
order_ids
);
if (!resolved) {
// Гонка: оператор отозвал предложение, пока создавались заказы.
for (const orderId of order_ids) {
try {
await this.stockService.cancelStockOrder(
coopname,
orderId,
member_account,
'Предложение было отозвано оператором'
);
} catch (compErr: any) {
this.logger.error(
`acceptProposal: отмена после гонки cancel/accept упала (order=${orderId}): ${compErr.message}`
);
}
}
throw new ConflictException('Предложение уже отозвано оператором.');
// Гонка: оператор отозвал докладку, пока шла подпись. Заказы уже выданы
// (RECEIVED) — откатить нельзя; подпись пайщика первична. Логируем.
this.logger.warn(
`finalizeStockIssuance: бандл ${proposal.id} отозван в гонке, но заказы уже выданы (${order_ids.join(',')}).`
);
const stale = await this.loadProposal(coopname, proposal_id);
this.emitResolved(stale);
return { proposal: stale, order_ids };
}
this.emitResolved(resolved);
this.logger.log(
`Докладка ${proposal.id} принята пайщиком ${member_account}: создано заказов из остатка${order_ids.length}.`
`Докладка ${proposal.id} подписана пайщиком ${member_account}: выдано позиций${order_ids.length}.`
);
return { proposal: resolved, order_ids };
}
@@ -364,6 +757,71 @@ export class MarketplaceStockProposalService {
return proposal;
}
/**
* Контрольная сверка контрподписанного пайщиком акта со ВЗ сохранённым актом
* оператора. Закрывает вектор подмены: пайщику передаётся signiss1-акт
* оператора на его устройство, и без сверки он мог бы подписать ПОДМЕНЁННОЕ
* тело (другой товар/количество) и вернуть его — мы бы выдали не то.
*
* Бьёт, если: тело/мета не совпадают с выданным к подписи; подпись оператора
* не сохранена/подменена; пайщик не подписал; документ не прошёл крипто-,
* структурную валидацию или сверку тела с оригиналом в сторе (канон-валидатор
* recompute'ит signed_hash из doc_hash+meta_hash, поэтому произвольный hash не
* пройдёт). Контракт дополнительно режет подделку on-chain
* (verify_document_or_fail требует валидную подпись оператора над телом) — это
* defence-in-depth и ранний внятный отказ.
*/
private async assertCountersignMatchesStored(
item: MarketplaceStockProposalItem,
submitted: MarketplaceIssueActSignedDocumentInputDTO,
member_account: string
): Promise<void> {
const stored = item.signiss1_act!;
const sub = submitted as unknown as ISignedDocumentDomainInterface;
const label = item.product_name || item.offer_id;
// 1. Тот же документ, что подписал оператор: тело и мета НЕ подменены.
if (sub.doc_hash !== stored.doc_hash || sub.meta_hash !== stored.meta_hash) {
throw new ForbiddenException(
`Подписанный акт по «${label}» не совпадает с выданным к подписи — подпись отклонена.`
);
}
// 2. Подпись оператора (первая) сохранена нетронутой — её не подменили.
const opSig = stored.signatures?.[0];
const opPreserved =
!!opSig &&
sub.signatures.some(
(s) =>
s.signer === opSig.signer &&
s.signature === opSig.signature &&
s.public_key === opSig.public_key
);
if (!opPreserved) {
throw new ForbiddenException(
`Подпись оператора по «${label}» утеряна или подменена — подпись отклонена.`
);
}
// 3. Пайщик подписал акт своим ключом (вторая подпись, получение).
if (!sub.signatures.some((s) => s.signer === member_account)) {
throw new ForbiddenException(
`Акт по «${label}» не подписан получателем — подпись отклонена.`
);
}
// 4. Крипто + структура + сверка тела с оригиналом в сторе документов.
const validation = await this.documentValidationService.validateSignedDocument(
item.offer_id,
sub
);
if (!validation.is_valid) {
throw new ForbiddenException(
`Акт по «${label}» не прошёл проверку подлинности: ${validation.error_message ?? 'причина не указана'}.`
);
}
}
private assertProposed(proposal: MarketplaceStockProposalDomainEntity): void {
if (proposal.status !== MarketplaceStockProposalStatuses.PROPOSED) {
throw new ConflictException(
@@ -78,11 +78,20 @@ export interface MarketplaceStockOrderCreateInput {
/** Предвычисленный order_hash из заявления о конвертации (см. order-create). */
order_hash?: string;
/**
* Подписанное заказчиком заявление о конвертации паевого взноса в
* членский (registry 1110) — обязательный параметр
* `marketplace::stockorder`; контракт публикует его в реестр документов.
* Подписанное заказчиком заявление о конвертации паевого взноса в членский
* (registry 1110). Заказ из остатка фондируется из членских средств; если
* членских не хватает, перед заказом выполняется отдельное действие `convert`
* на сумму `convert_amount` с этим заявлением. Опционально: при замене
* непоставленного высвобожденные средства уже в членском — конвертация не
* нужна, заявление не передаётся.
*/
convert_statement: MarketContract.Actions.StockOrder.IStockOrder['convert_statement'];
convert_statement?: MarketContract.Actions.Convert.IConvert['convert_statement'] | null;
/**
* Сумма конвертации (asset «X.XXXX RUB», тело + членский взнос строки) —
* обязательна вместе с `convert_statement`; столько паевого переводится в
* членский кошелёк перед заказом из остатка.
*/
convert_amount?: string | null;
}
export interface MarketplaceStockOrderCreateResult {
@@ -273,9 +282,33 @@ export class MarketplaceStockService {
return affected;
}
/**
* Конвертация паевого взноса в членский кошелёк «Стола заказов» (chain
* `convert`). Пополняет членские средства под заказы из остатка — одним
* действием на весь дефицит принятия. Заявление о конвертации публикуется в
* реестр документов контрактом.
*/
async convertToMember(input: {
coopname: string;
orderer: string;
amount: string;
convert_statement: MarketContract.Actions.Convert.IConvert['convert_statement'];
}): Promise<void> {
try {
await this.chainPort.convert({
coopname: input.coopname,
orderer: input.orderer,
amount: input.amount,
convert_statement: input.convert_statement,
});
} catch (error: any) {
rethrowChainError(error);
}
}
/**
* Заказ из остатка кооператива: chain `stockorder` (Order сразу acceptcoop,
* паевые средства блокируются на акцепте) + резерв конкретных позиций.
* средства блокируются из членского кошелька на акцепте) + резерв позиций.
*/
async createStockOrder(
input: MarketplaceStockOrderCreateInput
@@ -307,6 +340,24 @@ export class MarketplaceStockService {
const unit_price_asset = `${priceFloat.toFixed(this.assetConfig.decimals)} ${this.assetConfig.symbol}`;
const warranty_period_secs = offer.warranty_days * 86_400;
// Заказ из остатка фондируется из членского кошелька. Если передано
// Заявление о конвертации — сперва пополняем членский с паевого на сумму
// строки (тело + взнос) отдельным действием `convert`; иначе средства уже
// в членском (замена непоставленного — высвобождены отменой), конвертации нет.
if (input.convert_statement) {
if (!input.convert_amount) {
throw new BadRequestException(
'Не указана сумма конвертации для заказа из остатка с паевого.'
);
}
await this.chainPort.convert({
coopname: input.coopname,
orderer: input.orderer_account,
amount: input.convert_amount,
convert_statement: input.convert_statement,
});
}
// Optimistic counter ДО chain submit (как в createOrder поставщика).
await this.offerCounters.onOrderBlocked(offer.id, input.quantity);
@@ -322,7 +373,6 @@ export class MarketplaceStockService {
unit_price: unit_price_asset,
warranty_period_secs,
batch_hash: MarketplaceStockService.ZERO_HASH,
convert_statement: input.convert_statement,
});
txHash = normalizeChainTxHash(
tx,
@@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { MarketContract } from 'cooptypes';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { MarketplaceWriteoffService } from './marketplace-writeoff.service';
import type { IAction } from '~/types';
/**
* Слушатель callback'ов совета по проекту списания скоропорта.
*
* Проект уходит в совет через `marketplace::propwroff` (inline
* `soviet::createagenda(mktwroff)`). После голосования совета председатель
* подписывает Протокол списания и `soviet::exec` вызывает один из callback'ов:
* - `marketplace::onmktwoauth(coopname, hash, authorization)` — совет одобрил;
* проект переходит ON_AGENDA → PENDING_CONFIRMATION, Протокол сохраняется.
* - `marketplace::onmktwodecl(coopname, hash, reason)` — отклонён/просрочен.
*
* Без этого слушателя одобрение совета не отражалось в проекции: статус
* оставался «На повестке», Решение совета не прицеплялось.
*/
@Injectable()
export class MarketplaceWriteoffSyncService {
constructor(
private readonly writeoffService: MarketplaceWriteoffService,
private readonly logger: WinstonLoggerService
) {
this.logger.setContext(MarketplaceWriteoffSyncService.name);
}
@OnEvent(
`action::${MarketContract.contractName.production}::${MarketContract.Actions.OnMktWoAuth.actionName}`
)
async handleAuthorized(action: IAction): Promise<void> {
try {
const data = action.data as MarketContract.Actions.OnMktWoAuth.IOnMktWoAuth;
if (!data?.coopname || !data?.hash) {
this.logger.warn('onmktwoauth: пустые coopname/hash — пропускаю.');
return;
}
// On-chain hash приходит в верхнем регистре, proposal_hash в проекции —
// в нижнем; нормализуем, иначе findByHash не найдёт проект.
await this.writeoffService.onCouncilAuthorized({
coopname: data.coopname,
proposal_hash: String(data.hash).toLowerCase(),
authorized_by: null,
protocol_doc: data.authorization,
});
} catch (err: any) {
this.logger.error(`onmktwoauth listener упал: ${err.message}`, err.stack);
}
}
@OnEvent(
`action::${MarketContract.contractName.production}::${MarketContract.Actions.OnMktWoDecl.actionName}`
)
async handleDeclined(action: IAction): Promise<void> {
try {
const data = action.data as MarketContract.Actions.OnMktWoDecl.IOnMktWoDecl;
if (!data?.coopname || !data?.hash) {
this.logger.warn('onmktwodecl: пустые coopname/hash — пропускаю.');
return;
}
await this.writeoffService.onCouncilDeclined({
coopname: data.coopname,
proposal_hash: String(data.hash).toLowerCase(),
reason: data.reason ?? 'причина не указана',
});
} catch (err: any) {
this.logger.error(`onmktwodecl listener упал: ${err.message}`, err.stack);
}
}
}
@@ -35,7 +35,7 @@ function buildProposal(
quantity: '5',
amount: '485.0000',
reason: 'Истёк срок годности',
inventory_id: null,
inventory_ids: [],
executed: false,
},
],
@@ -234,7 +234,7 @@ describe('MarketplaceWriteoffService', () => {
quantity: '1',
amount: '10.0000',
reason: 'r',
inventory_id: null,
inventory_ids: [],
executed: false,
},
{
@@ -243,7 +243,7 @@ describe('MarketplaceWriteoffService', () => {
quantity: '2',
amount: '20.0000',
reason: 'r',
inventory_id: null,
inventory_ids: [],
executed: false,
},
];
@@ -272,7 +272,7 @@ describe('MarketplaceWriteoffService', () => {
quantity: '1',
amount: '10.0000',
reason: 'r',
inventory_id: null,
inventory_ids: [],
executed: false,
},
];
@@ -15,6 +15,7 @@ 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 type { DocumentDomainAggregate } from '~/domain/document/aggregates/document-domain.aggregate';
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
import {
MARKETPLACE_WRITEOFF_PROPOSAL_REPOSITORY,
@@ -24,6 +25,16 @@ import {
MARKETPLACE_INVENTORY_REPOSITORY,
type MarketplaceInventoryDomainRepository,
} from '../../domain/repositories/marketplace-inventory.repository';
import {
MARKETPLACE_ORDER_REPOSITORY,
type MarketplaceOrderDomainRepository,
} from '../../domain/repositories/marketplace-order.repository';
import {
MARKETPLACE_OFFER_REPOSITORY,
type MarketplaceOfferDomainRepository,
} from '../../domain/repositories/marketplace-offer.repository';
import { MARKETPLACE_UNIT_LABEL } from '../shared/unit-label.util';
import { MarketplaceOrderDisplayService } from './marketplace-order-display.service';
import {
MARKETPLACE_CANONICAL_BLOCKCHAIN_PORT,
type MarketplaceCanonicalBlockchainPort,
@@ -53,7 +64,7 @@ export interface MarketplaceWriteoffItemInput {
quantity: string;
amount: string;
reason: string;
inventory_id?: string | null;
inventory_ids?: string[];
}
export interface MarketplaceCreateWriteoffDraftInput {
@@ -82,6 +93,55 @@ export interface MarketplaceListWriteoffProposalsInput {
pagination?: PaginationInputDTO;
}
export interface MarketplaceConfirmWriteoffInput {
id: string;
braname: string;
chairman_account: string;
signed_memo: SignedDigitalDocumentInputDTO;
}
export interface MarketplaceWriteoffCandidateView {
/** Стабильный ключ строки = КУ|наименование|состояние (для row-key и выбора). */
key: string;
/** Все партии (штрих-коды), слитые в этот агрегат. */
inventory_ids: string[];
braname: string;
branch_name: string;
asset_title: string;
/** Суммарное количество по всем партиям агрегата. */
quantity: string;
/** Суммарная стоимость по всем партиям агрегата. */
amount: string;
reason: string;
/** Ближайший (самый срочный) срок годности среди партий агрегата. */
expiry_date: string | null;
is_expired: boolean;
/** Сколько партий слито в строку — для подсказки «N партий» в интерфейсе. */
lots_count: number;
}
export interface MarketplaceWriteoffConfirmationGroup {
proposal_id: string;
proposal_hash: string;
braname: string;
branch_name: string;
cycle_started_at: string;
authorized_at: string | null;
protocol_doc: unknown;
items: MarketplaceWriteoffProposalItem[];
total_amount: string;
}
export interface MarketplaceWriteoffServiceMemoData {
proposal: MarketplaceWriteoffProposalDomainEntity;
braname: string;
branch_name: string;
cycle_started_at: string;
proposal_hash: string;
items: { asset_title: string; quantity: string; unit: string; amount: string; reason: string }[];
total_amount: string;
}
@Injectable()
export class MarketplaceWriteoffService {
constructor(
@@ -89,11 +149,16 @@ export class MarketplaceWriteoffService {
private readonly repo: MarketplaceWriteoffProposalDomainRepository,
@Inject(MARKETPLACE_INVENTORY_REPOSITORY)
private readonly inventoryRepo: MarketplaceInventoryDomainRepository,
@Inject(MARKETPLACE_ORDER_REPOSITORY)
private readonly orderRepo: MarketplaceOrderDomainRepository,
@Inject(MARKETPLACE_OFFER_REPOSITORY)
private readonly offerRepo: MarketplaceOfferDomainRepository,
@Inject(MARKETPLACE_CANONICAL_BLOCKCHAIN_PORT)
private readonly chainPort: MarketplaceCanonicalBlockchainPort,
@Inject(MARKETPLACE_ASSET_CONFIG)
private readonly assetConfig: MarketplaceAssetConfig,
private readonly documentDomainService: DocumentDomainService,
private readonly orderDisplay: MarketplaceOrderDisplayService,
private readonly eventBus: EventEmitter2,
private readonly logger: WinstonLoggerService
) {
@@ -127,6 +192,247 @@ export class MarketplaceWriteoffService {
return this.repo.findOpenDraft(coopname);
}
/**
* Резолвит человеко-читаемые имена кооперативных участков по их braname —
* для показа в интерфейсе вместо служебного идентификатора. Кэш на один
* вызов: позиций по одному складу обычно много.
*/
async resolveBranchNames(branames: string[]): Promise<Record<string, string>> {
const map: Record<string, string> = {};
for (const bn of new Set(branames)) {
const branch = await this.orderDisplay.resolveBranchDisplay(bn);
map[bn] = branch.name || bn;
}
return map;
}
/**
* Человекочитаемая единица измерения (шт./кг/л/упак.) для каждой позиции
* проекта — по её партиям. Единица живёт на оффере (снапшот товара); партии
* одного наименования агрегированы в одну позицию, поэтому берём единицу по
* первой партии: inventory → оффер (через published_offer_id либо заказ,
* который сослался на оффер). Фолбэк «ед.» — если товар/оффер уже удалён.
*/
async resolveUnitLabels(items: { inventory_ids: string[] }[]): Promise<string[]> {
return Promise.all(items.map((it) => this.resolveUnitLabel(it.inventory_ids)));
}
private async resolveUnitLabel(inventoryIds: string[]): Promise<string> {
const FALLBACK = 'ед.';
const invId = inventoryIds[0];
if (!invId) return FALLBACK;
const inv = await this.inventoryRepo.findById(invId);
if (!inv) return FALLBACK;
let offerId = inv.published_offer_id;
if (!offerId && inv.order_id) {
const order = await this.orderRepo.findById(inv.order_id);
offerId = order?.offer_id ?? null;
}
if (!offerId) return FALLBACK;
const offer = await this.offerRepo.findById(offerId);
if (!offer) return FALLBACK;
return MARKETPLACE_UNIT_LABEL[offer.unit_of_measure] ?? FALLBACK;
}
/**
* Кандидаты на списание для admin-стола: все позиции на складах кооператива.
* Председатель выделяет нужные и создаёт из них черновик — вручную можно
* списать как просроченный скоропорт (флаг is_expired), так и ещё годное
* имущество (порча, невозврат). Сумма = arrival_price × quantity.
*/
async listCandidates(coopname: string): Promise<MarketplaceWriteoffCandidateView[]> {
const candidates = await this.inventoryRepo.findWriteoffCandidates(coopname, new Date());
// Партии, уже занятые в незавершённых проектах списания, исключаем —
// иначе один и тот же товар попадёт в два проекта (двойное списание).
const lockedIds = new Set(await this.repo.findActiveLockedInventoryIds(coopname));
// Имя КУ показываем человеку, не служебный braname — резолвим с кэшем,
// позиций по одному складу обычно много.
const branchNameCache = new Map<string, string>();
// Несколько партий одного наименования на одном КУ в одном состоянии
// визуально неразличимы (та же дата «годен до», тот же КУ) — пайщик видит
// «прыгающее количество». Сливаем их в одну строку-агрегат: суммарное
// кол-во + сумма, под капотом — список всех партий. Ключ группировки
// включает состояние (просрочено / без гарантии / годно), потому что у них
// разный визуальный статус и разная причина по умолчанию.
const groups = new Map<
string,
{
key: string;
inventory_ids: string[];
braname: string;
asset_title: string;
quantity: number;
amount: number;
reason: string;
expiry_ms: number | null;
is_expired: boolean;
state: string;
}
>();
for (const c of candidates) {
if (lockedIds.has(c.inventory_id)) continue;
// Состояние партии: просрочена / без срока (без гарантии) / ещё годна.
const state = c.is_expired ? 'expired' : c.expiry_date === null ? 'nowarranty' : 'valid';
const key = `${c.braname}|${c.asset_title}|${state}`;
const unit = c.arrival_price !== null ? Number(c.arrival_price) : 0;
const amount = Number.isFinite(unit) ? unit * c.quantity : 0;
const expiryMs = c.expiry_date ? c.expiry_date.getTime() : null;
const existing = groups.get(key);
if (existing) {
existing.inventory_ids.push(c.inventory_id);
existing.quantity += c.quantity;
existing.amount += amount;
// Ближайший (самый срочный) срок годности — представитель агрегата.
if (expiryMs !== null && (existing.expiry_ms === null || expiryMs < existing.expiry_ms)) {
existing.expiry_ms = expiryMs;
}
} else {
groups.set(key, {
key,
inventory_ids: [c.inventory_id],
braname: c.braname,
asset_title: c.asset_title,
quantity: c.quantity,
amount,
// Причина по умолчанию: просрочка ИЛИ товар без гарантии (его считаем
// уже непригодным) → «Истёк срок годности»; ещё годное, списываемое
// вручную (порча/использование) → «Списание вручную». Председатель
// может уточнить причину в поле перед отправкой.
reason: state === 'valid' ? 'Списание вручную' : 'Истёк срок годности',
expiry_ms: expiryMs,
is_expired: c.is_expired,
state,
});
}
}
const result: MarketplaceWriteoffCandidateView[] = [];
for (const g of groups.values()) {
let branchName = branchNameCache.get(g.braname);
if (branchName === undefined) {
const branch = await this.orderDisplay.resolveBranchDisplay(g.braname);
branchName = branch.name || g.braname;
branchNameCache.set(g.braname, branchName);
}
result.push({
key: g.key,
inventory_ids: g.inventory_ids,
braname: g.braname,
branch_name: branchName,
asset_title: g.asset_title,
quantity: String(g.quantity),
amount: this.formatAssetNumber(g.amount),
reason: g.reason,
expiry_date: g.expiry_ms !== null ? new Date(g.expiry_ms).toISOString() : null,
is_expired: g.is_expired,
lots_count: g.inventory_ids.length,
});
}
// Просроченное — наверх (первоочередные кандидаты), затем остальное.
result.sort((a, b) => Number(b.is_expired) - Number(a.is_expired));
return result;
}
/**
* Группы для стола ПВЗ: по каждому проекту в PENDING_CONFIRMATION — отдельная
* строка на каждый кооперативный участок с неподтверждёнными позициями.
* `branames=null` (admin/совет read:all) — все участки; иначе только КУ
* оператора.
*/
async listPendingConfirmations(
coopname: string,
branames: string[] | null
): Promise<MarketplaceWriteoffConfirmationGroup[]> {
const { items } = await this.repo.list({
coopname,
statuses: ['PENDING_CONFIRMATION'],
});
const groups: MarketplaceWriteoffConfirmationGroup[] = [];
const branchNameCache = new Map<string, string>();
for (const p of items) {
const pendingBranames = [
...new Set(p.items.filter((it) => !it.executed).map((it) => it.braname)),
];
for (const bn of pendingBranames) {
if (branames && !branames.includes(bn)) continue;
let name = branchNameCache.get(bn);
if (name === undefined) {
const branch = await this.orderDisplay.resolveBranchDisplay(bn);
name = branch.name || bn;
branchNameCache.set(bn, name);
}
const groupItems = p.items.filter((it) => it.braname === bn && !it.executed);
groups.push({
proposal_id: p.id,
proposal_hash: p.proposal_hash,
braname: bn,
branch_name: name,
cycle_started_at: p.cycle_started_at.toISOString(),
authorized_at: p.authorized_at ? p.authorized_at.toISOString() : null,
protocol_doc: p.protocol_doc,
items: groupItems,
total_amount: this.formatAssetNumber(this.sumItems(groupItems)),
});
}
}
return groups;
}
/**
* Данные для рендера Служебной записки о списании (1111) по одному КУ
* проекта, ожидающего подтверждения. Резолвер собирает из них документ для
* подписания председателем КУ.
*/
async getServiceMemoData(id: string, braname: string): Promise<MarketplaceWriteoffServiceMemoData> {
const proposal = await this.getProposal(id);
if (!proposal.is_pending_confirmation) {
throw new BadRequestException(
'Подтвердить списание можно только по проекту, ожидающему подтверждения складов'
);
}
const items = proposal.items.filter((it) => it.braname === braname && !it.executed);
if (items.length === 0) {
throw new BadRequestException(
`В проекте нет неподтверждённых позиций кооперативного участка ${braname}`
);
}
const branch = await this.orderDisplay.resolveBranchDisplay(braname);
const total = this.sumItems(items);
const units = await this.resolveUnitLabels(items);
return {
proposal,
braname,
branch_name: branch.name || braname,
cycle_started_at: proposal.cycle_started_at.toISOString(),
proposal_hash: proposal.proposal_hash,
items: items.map((it, i) => ({
asset_title: it.asset_title,
quantity: it.quantity,
unit: units[i],
amount: it.amount,
reason: it.reason,
})),
total_amount: this.formatAssetNumber(total),
};
}
/**
* Агрегат Протокола совета о списании для просмотра на столе ПВЗ. Протокол
* уже подписан советом и опубликован в реестр документов — собираем агрегат
* из подписанного документа (тело по doc_hash + подписи), НЕ регенерируем.
* `protocol_doc` = signed-документ из callback'а onmktwoauth. Канон —
* issuance/return-claim. null, если протокола ещё нет (проект не одобрен).
*/
async getProtocolDocumentAggregate(id: string): Promise<DocumentDomainAggregate | null> {
const proposal = await this.getProposal(id);
const signed = proposal.protocol_doc as ISignedDocumentDomainInterface | null;
if (!signed) return null;
return this.documentDomainService.buildDocumentAggregate(signed);
}
// ── DRAFT pipeline ─────────────────────────────────────────────────
async createDraft(
@@ -138,12 +444,10 @@ export class MarketplaceWriteoffService {
`У кооператива уже есть открытый черновик списания (id=${existingDraft.id}). Удалите его перед созданием нового.`
);
}
const existingActive = await this.repo.findOpenInCouncil(input.coopname);
if (existingActive) {
throw new ConflictException(
`Проект списания id=${existingActive.id} уже отправлен в совет (статус=${existingActive.status}). Дождитесь решения совета.`
);
}
// Несколько проектов списания одновременно — допустимо: разные партии
// скоропорта подаются в совет независимо. Защита от двойного списания
// одной позиции — на уровне кандидатов (findActiveLockedInventoryIds),
// не запретом второго проекта.
const normalizedItems = this.validateAndNormalizeItems(input.items);
const total = this.sumItems(normalizedItems);
@@ -333,6 +637,10 @@ export class MarketplaceWriteoffService {
);
return;
}
// Совет одобрил → проект переходит в PENDING_CONFIRMATION (markAuthorized
// пишет этот статус). Фактическое списание НЕ запускается автоматически:
// его инициирует председатель каждого КУ подписью Служебной записки 1111
// на столе ПВЗ (см. confirmWriteoff → confirmwroff). Per-КУ гранулярность.
const updated = await this.repo.markAuthorized(proposal.id, {
protocol_doc: input.protocol_doc,
authorized_at: new Date(),
@@ -351,18 +659,6 @@ export class MarketplaceWriteoffService {
items_count: updated.items.length,
total_amount: updated.total_amount,
});
// Сразу запускаем исполнение. Signer должен быть пользователем
// (chairman), уполномоченным по КУ каждой позиции; coopname как fallback
// запрещён — это аккаунт кооператива, не пайщик, и контракт execwroff
// не сможет проверить is_user_authorized.
const signer = input.authorized_by ?? proposal.proposed_by_account;
if (!signer) {
throw new ConflictException(
`Проект списания ${proposal.id}: после авторизации нет ни authorized_by, ни proposed_by — исполнение не запущено.`
);
}
await this.executeAuthorizedProposal(updated.id, signer);
}
async onCouncilDeclined(input: {
@@ -454,12 +750,12 @@ export class MarketplaceWriteoffService {
execwroff_tx_hash: execTxHash,
},
});
if (item.inventory_id) {
// Inventory transition обязателен — иначе позиция остаётся
// RETURNED_TO_WAREHOUSE в БД и может повторно попасть в следующий
// cron-цикл. Если update упадёт — re-throw, чтобы видна была проблема
// и proposal остался в EXECUTING для retry.
await this.inventoryRepo.applyStatusTransition(item.inventory_id, 'WRITTEN_OFF');
// Inventory transition обязателен для каждой партии агрегата — иначе
// позиция остаётся RETURNED_TO_WAREHOUSE в БД и может повторно попасть в
// следующий cron-цикл. Если update упадёт — re-throw, чтобы видна была
// проблема и proposal остался в EXECUTING для retry.
for (const invId of item.inventory_ids ?? []) {
await this.inventoryRepo.applyStatusTransition(invId, 'WRITTEN_OFF');
}
}
@@ -478,6 +774,105 @@ export class MarketplaceWriteoffService {
});
}
// ── Подтверждение списания председателем КУ (стол ПВЗ) ──────────────
/**
* Председатель кооперативного участка подтверждает фактическое списание со
* склада своего КУ, подписывая Служебную записку 1111. Закрывает все
* неисполненные позиции этого КУ за один on-chain `confirmwroff`. Когда
* подтверждены все КУ проекта — проект EXECUTED.
*/
async confirmWriteoff(
input: MarketplaceConfirmWriteoffInput
): Promise<MarketplaceWriteoffProposalDomainEntity> {
const proposal = await this.repo.findById(input.id);
if (!proposal) throw new NotFoundException('Проект списания не найден');
if (!proposal.is_pending_confirmation) {
throw new BadRequestException(
`Подтвердить списание можно только по проекту, ожидающему подтверждения складов (текущий: ${proposal.status})`
);
}
const pendingIndexes = proposal.items
.map((it, idx) => ({ it, idx }))
.filter(({ it }) => it.braname === input.braname && !it.executed)
.map(({ idx }) => idx);
if (pendingIndexes.length === 0) {
throw new BadRequestException(
`В проекте нет неподтверждённых позиций кооперативного участка ${input.braname}`
);
}
this.verifyDocumentSignature(input.signed_memo);
const memoMeta = input.signed_memo.meta as {
registry_id?: number;
proposal_hash?: string;
} | undefined;
if (
!memoMeta ||
memoMeta.registry_id !== Cooperative.Registry.MarketplaceWriteoffServiceMemo.registry_id
) {
throw new BadRequestException(
`Служебная записка должна быть зарегистрирована с registry_id=${Cooperative.Registry.MarketplaceWriteoffServiceMemo.registry_id}`
);
}
if (memoMeta.proposal_hash && memoMeta.proposal_hash !== proposal.proposal_hash) {
throw new BadRequestException(
'Служебная записка подписана для другого проекта списания'
);
}
// on-chain confirmwroff: закрывает все неисполненные позиции КУ за вызов,
// проводит o.mkt.wroff и якорит записку в реестр документов.
const confirmTx = await this.chainPort.confirmWroff({
coopname: proposal.coopname,
signer: input.chairman_account,
proposal_hash: proposal.proposal_hash,
braname: input.braname as MarketContract.Actions.ConfirmWroff.IConfirmWroff['braname'],
memo: new SignedDigitalDocumentInputDTO(input.signed_memo).toDocument() as MarketContract.Actions.ConfirmWroff.IConfirmWroff['memo'],
});
const confirmTxHash = this.extractTxHash(confirmTx);
if (!confirmTxHash) {
throw new ConflictException(
'Подтверждение списания: цепь не вернула tx_hash (confirmwroff). Повторите.'
);
}
let working = proposal;
for (const idx of pendingIndexes) {
working = await this.repo.markItemExecuted(input.id, idx, {
at: new Date().toISOString(),
actor: input.chairman_account,
action: 'confirmed_by_branch',
payload: {
item_index: idx,
braname: input.braname,
confirmwroff_tx_hash: confirmTxHash,
},
});
for (const invId of proposal.items[idx].inventory_ids ?? []) {
await this.inventoryRepo.applyStatusTransition(invId, 'WRITTEN_OFF');
}
}
// Все КУ подтверждены → проект исполнен.
if (working.items.every((it) => it.executed)) {
working = await this.repo.markFullyExecuted(input.id, {
at: new Date().toISOString(),
actor: input.chairman_account,
action: 'execution_completed',
});
this.eventBus.emit(MARKETPLACE_WRITEOFF_EXECUTED_EVENT, {
coopname: working.coopname,
proposal_id: working.id,
proposal_hash: working.proposal_hash,
items_count: working.items.length,
total_amount: working.total_amount,
});
}
return working;
}
// ── Утилиты ────────────────────────────────────────────────────────
computeProposalHash(input: {
@@ -523,7 +918,7 @@ export class MarketplaceWriteoffService {
quantity: it.quantity,
amount: this.formatAssetNumber(amount),
reason: it.reason ?? '',
inventory_id: it.inventory_id ?? null,
inventory_ids: it.inventory_ids ?? [],
executed: false,
};
});
@@ -537,6 +932,19 @@ export class MarketplaceWriteoffService {
return `${this.formatAssetNumber(value)} ${this.assetSymbol}`;
}
/**
* Сумма для тела документа: 2 знака, разделители разрядов, запятая —
* «1 020,00 RUB» (не машинные 4 знака). Для печатных Заявлений/Записок.
*/
formatAssetHuman(value: number): string {
const num = Number.isFinite(value) ? value : 0;
const formatted = num.toLocaleString('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return `${formatted} ${this.assetSymbol}`;
}
formatAssetNumber(value: number): string {
return value.toFixed(this.assetDecimals);
}
@@ -19,3 +19,19 @@ export function computeStockOrderHash(coopname: string, orderer: string, offer_i
.update(`${coopname}|${orderer}|stock|${offer_id}|${nonce}`)
.digest('hex');
}
/**
* Анкер единого Заявления о конвертации под одно принятие предложения/докладки:
* детерминированный SHA256(coopname|orderer|convert|proposal_id) — без nonce,
* чтобы backend на акцепте пересчитал его и сверил с подписанным заявлением.
* Одно заявление на весь дефицит вместо документа на каждую строку.
*/
export function computeConvertAnchorHash(
coopname: string,
orderer: string,
proposal_id: string
): string {
return createHash('sha256')
.update(`${coopname}|${orderer}|convert|${proposal_id}`)
.digest('hex');
}
@@ -213,6 +213,38 @@ describe('MarketplaceOrderDomainEntity', () => {
expect(order.on_chain_present).toBe(false);
});
// Regression: отклонённый заказ стирается из chain-RAM (decline = erase).
// Парсер на erase шлёт present=false с последним «живым» value строки
// ('active'). Без terminal-guard'а это перетирало backend-статус
// CANCELLED_BY_SUPPLIER обратно в ACTIVE — заказ «воскресал» в списке
// «Ждут акцепта», а повторный decline падал «не найден по хэшу».
it('НЕ воскрешает терминальный CANCELLED_BY_SUPPLIER erase-дельтой с active', () => {
const order = new MarketplaceOrderDomainEntity(
buildProps({ status: 'CANCELLED_BY_SUPPLIER' })
);
order.updateFromBlockchain(
{ order_hash: 'a'.repeat(64), on_chain_id: '20', status: 'ACTIVE' },
2_500_000,
false
);
expect(order.status).toBe('CANCELLED_BY_SUPPLIER'); // не воскрешён
expect(order.on_chain_present).toBe(false); // строка стёрта on-chain
});
it('НЕ воскрешает терминальный RETURNED erase-дельтой с received', () => {
const order = new MarketplaceOrderDomainEntity(buildProps({ status: 'RETURNED' }));
order.updateFromBlockchain(
{ order_hash: 'a'.repeat(64), on_chain_id: '21', status: 'RECEIVED' },
2_600_000,
false
);
expect(order.status).toBe('RETURNED');
});
it('применяет тот же ранг (ACCEPTED_PENDING_SUPPLIER ↔ ACCEPTED_PENDING_SUPPLIER_INDIVIDUAL)', () => {
const order = new MarketplaceOrderDomainEntity(
buildProps({ status: 'ACCEPTED_PENDING_SUPPLIER' })
@@ -171,7 +171,19 @@ export class MarketplaceOrderDomainEntity implements IBlockchainSynchronizable {
const currentRank = MARKETPLACE_ORDER_FORWARD_RANK[this.status];
const isBackendAhead =
incomingRank !== undefined && currentRank !== undefined && incomingRank < currentRank;
if (!isBackendAhead) {
// Terminal-resurrection guard. Заказ в терминальной отмене/возврате
// (CANCELLED_*/RETURNED) стёрт из chain-RAM (decline/cancel/return = erase
// строки, не смена статуса). Парсер на erase шлёт дельту с present=false, в
// value которой — ПОСЛЕДНЕЕ «живое» состояние стёртой строки (как правило
// ещё 'active'). Mapper выдаёт только живые on-chain статусы ('cancelled' —
// KNOWN_UNMAPPED → null), поэтому ЛЮБОЙ приходящий статус поверх
// терминального = воскрешение стёртой строки. Без guard'а отклонённый заказ
// «возвращался» в ACTIVE (backend уже выставил CANCELLED_BY_SUPPLIER, но
// erase-дельта перетирала его — у терминала forward-rank=undefined), снова
// всплывал в списке «Ждут акцепта», а повторный decline/accept падал
// «заказ не найден по хэшу» (on-chain строки уже нет). Точный терминальный
// под-статус выставляет backend в момент submit — он и есть источник истины.
if (!isBackendAhead && !this.is_terminal) {
this.status = blockchainData.status;
}
this.updated_at = new Date();
@@ -1,3 +1,5 @@
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
/**
* Предложение докладки из остатка склада КУ (requirement 76, решения 1011).
*
@@ -21,7 +23,17 @@ export const MarketplaceStockProposalStatuses = {
CANCELLED: 'CANCELLED',
} as const satisfies Record<string, MarketplaceStockProposalStatus>;
/** Строка предложения: опубликованный оффер кооператива + количество. */
/**
* Строка бандла выдачи. Единый путь: бандл несёт И докладку со склада, И уже
* существующие обычные заказы пайщика — отличает их `order_id`:
* - `order_id` отсутствует → ДОКЛАДКА: заказ из остатка родится на финализации
* (stockorder по `order_hash`), средства блокируются с членского.
* - `order_id` задан → ОБЫЧНЫЙ ЗАКАЗ: он уже ACCEPTED_TO_COOP (профондирован с
* паевого на createorder); на финализации создавать нечего — сразу выдача.
* В обоих случаях signiss1 оператора отложен в бандл (оффчейн) и уходит на цепь
* только вместе с signiss2 пайщика при подписи — поэтому отмена = отказ от
* бандла, без он-чейн отката.
*/
export interface MarketplaceStockProposalItem {
offer_id: string;
quantity: number;
@@ -29,6 +41,25 @@ export interface MarketplaceStockProposalItem {
unit_price: string;
/** Снапшот наименования — для показа пайщику без дозапросов. */
product_name: string;
/**
* Существующий обычный заказ пайщика (ACCEPTED_TO_COOP), выдаваемый этим
* бандлом. Пусто → строка докладки со склада (заказ родится на финализации).
*/
order_id?: string;
/**
* Детерминированный order_hash заказа из остатка (докладка) — рождается при
* формировании бандла (оператором у стойки), чтобы и signiss1-акт оператора,
* и заказ создавались по одному и тому же хэшу. Для строки обычного заказа —
* order_hash самого заказа (берётся бэкендом из заказа).
*/
order_hash?: string;
/**
* АПП-выдачи (registry 1105), уже подписанный ОПЕРАТОРОМ КУ (первая подпись,
* signiss1) на этапе формирования бандла. Пайщик контрподписывает его
* (signiss2) одной кнопкой; на финализации бэкенд создаёт заказ из остатка и
* проводит обе подписи. Хранится в jsonb строки бандла.
*/
signiss1_act?: ISignedDocumentDomainInterface;
}
export interface MarketplaceStockProposalProps {
@@ -75,6 +75,9 @@ export class MarketplaceWriteoffProposalDomainEntity {
public get is_authorized(): boolean {
return this.status === MarketplaceWriteoffProposalStatuses.AUTHORIZED;
}
public get is_pending_confirmation(): boolean {
return this.status === MarketplaceWriteoffProposalStatuses.PENDING_CONFIRMATION;
}
public get is_executing(): boolean {
return this.status === MarketplaceWriteoffProposalStatuses.EXECUTING;
}
@@ -93,4 +96,8 @@ export class MarketplaceWriteoffProposalDomainEntity {
public get items_pending_count(): number {
return this.items.filter((it) => !it.executed).length;
}
/** Кооперативные участки, чьи позиции ещё не списаны (ждут подтверждения). */
public get pending_branames(): string[] {
return [...new Set(this.items.filter((it) => !it.executed).map((it) => it.braname))];
}
}
@@ -17,6 +17,7 @@ export type MarketplaceWriteoffProposalStatus =
| 'DRAFT'
| 'ON_AGENDA'
| 'AUTHORIZED'
| 'PENDING_CONFIRMATION'
| 'EXECUTING'
| 'EXECUTED'
| 'REJECTED';
@@ -25,6 +26,11 @@ export const MarketplaceWriteoffProposalStatuses = {
DRAFT: 'DRAFT',
ON_AGENDA: 'ON_AGENDA',
AUTHORIZED: 'AUTHORIZED',
// Совет одобрил проект; ожидается подтверждение фактического списания
// председателем каждого кооперативного участка (подпись Служебной записки
// 1111 → on-chain marketplace::confirmwroff). На цепи статус остаётся
// authorized — PENDING_CONFIRMATION это PG-маркер «ждём склады».
PENDING_CONFIRMATION: 'PENDING_CONFIRMATION',
EXECUTING: 'EXECUTING',
EXECUTED: 'EXECUTED',
REJECTED: 'REJECTED',
@@ -42,9 +48,12 @@ export const MarketplaceWriteoffProposalTriggers = {
} as const satisfies Record<string, MarketplaceWriteoffProposalTrigger>;
/**
* Позиция к списанию. Опциональный `inventory_id` — пометка, какой
* штрих-код из `marketplace_inventory` попал в корзину (нужно крону для
* последующего пометить статус позиции на WRITTEN_OFF после executed).
* Позиция к списанию = агрегат партий одного наименования на одном КУ в одном
* состоянии (просрочено / без гарантии / годно). `inventory_ids` — все
* штрих-коды из `marketplace_inventory`, попавшие в этот агрегат: после
* executed каждый из них помечается WRITTEN_OFF. Несколько партий одного
* товара сливаются в одну строку Заявления (а не дублируются), поэтому
* связь one-item → many-lots.
*/
export interface MarketplaceWriteoffProposalItem {
braname: string;
@@ -52,7 +61,7 @@ export interface MarketplaceWriteoffProposalItem {
quantity: string;
amount: string;
reason: string;
inventory_id: string | null;
inventory_ids: string[];
executed: boolean;
}
@@ -28,14 +28,27 @@ export interface MarketplaceCanonicalBlockchainPort {
* requirement 76: заказ из обезличенного остатка склада кооператива.
* Продавец — сам кооператив (offerer == coopname на цепи); Order рождается
* сразу в `acceptcoop` (имущество уже на счёте 10 после первичной приёмки)
* и идёт только через выдачу signiss1/signiss2. Один шаг ledger2:
* o.mkt.lock (TRANSFER w.wal.share → w.mkt.order) — средства пайщика
* блокируются в момент акцепта предложения/заказа.
* и идёт только через выдачу signiss1/signiss2. Фондируется из членских
* средств пайщика: o.mkt.lockm (тело, w.mkt.member → w.mkt.order) + o.mkt.lockmf
* (взнос, w.mkt.member → w.mkt.fee). Паевой пополняет членский кошелёк заранее
* отдельным действием `convert`.
*
* Авторизация — кооператив (`require_auth(coopname)`).
*/
stockOrder(data: MarketContract.Actions.StockOrder.IStockOrder): Promise<TransactResult>;
/**
* requirement 76: конвертация паевого взноса пайщика в членский кошелёк
* «Стола заказов» (o.mkt.conv: TRANSFER w.wal.share → w.mkt.member). Пополняет
* членские средства под заказ из остатка (`stockOrder` фондируется только из
* членских). Подписанное Заявление о конвертации публикуется в реестр
* документов. Выполняется перед `stockOrder`, когда членских средств не
* хватает (на всю сумму либо на дельту превышения над высвобождённым).
*
* Авторизация — кооператив (`require_auth(coopname)`).
*/
convert(data: MarketContract.Actions.Convert.IConvert): Promise<TransactResult>;
/**
* requirement 76 (вопрос 4): списание уценки по заказу из остатка после
* финализации выдачи — o.mkt.loss (NONE, Дт 91 / Кт 10) на разницу между
@@ -268,6 +281,16 @@ export interface MarketplaceCanonicalBlockchainPort {
*/
execWroff(data: MarketContract.Actions.ExecWroff.IExecWroff): Promise<TransactResult>;
/**
* Председатель кооперативного участка подтверждает фактическое списание
* со склада своего КУ (ручной шаг стола ПВЗ). Закрывает все неисполненные
* позиции участка `braname` за вызов, проводит `o.mkt.wroff` и якорит
* подписанную Служебную записку о списании (registry 1111) в реестр
* документов. Авторизация — кооператив (`require_auth(coopname)`); C++
* проверяет, что `signer` уполномочен для `braname`.
*/
confirmWroff(data: MarketContract.Actions.ConfirmWroff.IConfirmWroff): Promise<TransactResult>;
// ── Экономика КУ (requirement b6): членский взнос и распределение ────
/** Единая ставка членского взноса кооператива (администратор). */
@@ -53,6 +53,26 @@ export interface MarketplaceInventoryListFilter {
published?: boolean;
}
/**
* Кандидат на списание: позиция на складе, доступная председателю для
* ручного сбора корзины проекта списания (admin-стол). Возвращаем ВСЕ позиции
* на складе — и просроченный скоропорт, и ещё годное имущество: председатель
* вправе списать вручную и то, что испорчено/не возвращено, а не только то,
* у чего формально истёк `expiry_date`. `is_expired` отмечает позиции с
* истёкшим сроком (auto-кандидаты крона) для подсветки в интерфейсе.
* `arrival_price` — закупочная цена за единицу из акта приёмки;
* сумма списания = arrival_price × quantity.
*/
export interface MarketplaceWriteoffCandidate {
inventory_id: string;
braname: string;
asset_title: string;
quantity: number;
arrival_price: string | null;
expiry_date: Date | null;
is_expired: boolean;
}
export interface MarketplaceInventoryDomainRepository {
create(input: MarketplaceInventoryCreateInput): Promise<MarketplaceInventoryDomainEntity>;
@@ -86,6 +106,19 @@ export interface MarketplaceInventoryDomainRepository {
list(filter: MarketplaceInventoryListFilter): Promise<MarketplaceInventoryDomainEntity[]>;
/**
* Позиции на складе (RECEIVED/LABELED) для ручного сбора корзины списания на
* admin-столе. Возвращает ВСЁ имущество на складе, а не только просроченное:
* председатель может списать вручную и годное (порча, невозврат). `cutoff`
* используется лишь для вычисления флага `is_expired` (expiry_date <= cutoff),
* не как фильтр. Авто-крон отбирает кандидатов отдельным запросом с
* grace-периодом и сюда не обращается.
*/
findWriteoffCandidates(
coopname: string,
cutoff: Date
): Promise<MarketplaceWriteoffCandidate[]>;
applyStatusTransition(
id: string,
newStatus: MarketplaceInventoryStatus
@@ -204,13 +204,4 @@ export interface MarketplaceOrderDomainRepository
coopname: string,
delivery_braname: string
): Promise<MarketplaceOrderDomainEntity[]>;
/**
* Story 6.3: ленты «к получению» для orderer-стола. Возвращает Order'ы
* заказчика в статусе READY_TO_RECEIVE.
*/
listReadyToReceiveByOrderer(
coopname: string,
orderer_account: string
): Promise<MarketplaceOrderDomainEntity[]>;
}
@@ -49,6 +49,13 @@ export interface MarketplaceWriteoffProposalDomainRepository {
coopname: string
): Promise<MarketplaceWriteoffProposalDomainEntity | null>;
/**
* inventory_id позиций, занятых в незавершённых проектах списания (черновик,
* в совете, одобрено, ожидает подтверждения, в исполнении). Используется,
* чтобы не показывать одни и те же позиции кандидатами в новый проект.
*/
findActiveLockedInventoryIds(coopname: string): Promise<string[]>;
list(
filter: MarketplaceWriteoffProposalListFilter,
pagination?: PaginationInputDTO
@@ -1,8 +1,11 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { In, Not, Repository } from 'typeorm';
import { MarketplaceAplReceptionDomainEntity } from '../../domain/entities/marketplace-apl-reception.entity';
import type { MarketplaceAplReceptionStatus } from '../../domain/entities/marketplace-apl-reception.types';
import {
MarketplaceAplReceptionStatuses,
type MarketplaceAplReceptionStatus,
} from '../../domain/entities/marketplace-apl-reception.types';
import type {
MarketplaceAplReceptionCreateInput,
MarketplaceAplReceptionDomainRepository,
@@ -57,7 +60,12 @@ export class MarketplaceAplReceptionRepositoryAdapter
coopname: string,
shipment_id: string
): Promise<MarketplaceAplReceptionDomainEntity | null> {
const row = await this.repo.findOne({ where: { coopname, shipment_id } });
// Отменённые приёмки (откат черновика оператором) не считаются активными —
// иначе повторное открытие приёмки по этой партии заблокировалось бы гардом
// «одна партия — одна активная АПП».
const row = await this.repo.findOne({
where: { coopname, shipment_id, status: Not(MarketplaceAplReceptionStatuses.CANCELLED) },
});
return row ? this.mapper.toDomain(row) : null;
}
@@ -68,6 +68,27 @@ export class MarketplaceCanonicalBlockchainAdapter implements MarketplaceCanonic
});
}
async convert(data: MarketContract.Actions.Convert.IConvert): Promise<TransactResult> {
const wif = await this.vaultDomainService.getWif(data.coopname);
if (!wif) {
throw new HttpApiError(httpStatus.BAD_GATEWAY, 'Не найден приватный ключ кооператива для submit convert');
}
this.blockchainService.initialize(data.coopname, wif);
return await this.blockchainService.transact({
account: MarketContract.contractName.production,
name: MarketContract.Actions.Convert.actionName,
authorization: [
{
actor: data.coopname,
permission: 'active',
},
],
data,
});
}
async markdown(data: MarketContract.Actions.Markdown.IMarkdown): Promise<TransactResult> {
const wif = await this.vaultDomainService.getWif(data.coopname);
if (!wif) {
@@ -429,6 +450,23 @@ export class MarketplaceCanonicalBlockchainAdapter implements MarketplaceCanonic
});
}
async confirmWroff(data: MarketContract.Actions.ConfirmWroff.IConfirmWroff): Promise<TransactResult> {
const wif = await this.vaultDomainService.getWif(data.coopname);
if (!wif) {
throw new HttpApiError(
httpStatus.BAD_GATEWAY,
'Не найден приватный ключ кооператива для submit confirmwroff'
);
}
this.blockchainService.initialize(data.coopname, wif);
return await this.blockchainService.transact({
account: MarketContract.contractName.production,
name: MarketContract.Actions.ConfirmWroff.actionName,
authorization: [{ actor: data.coopname, permission: 'active' }],
data,
});
}
// ── Экономика КУ (requirement b6) ────────────────────────────────────
/** Общий submit от ключа кооператива для actions экономики КУ (DRY). */
@@ -13,6 +13,7 @@ import type {
MarketplaceInventoryDomainRepository,
MarketplaceInventoryLabelPatch,
MarketplaceInventoryListFilter,
MarketplaceWriteoffCandidate,
} from '../../domain/repositories/marketplace-inventory.repository';
import { MarketplaceInventoryEntity } from '../entities/marketplace-inventory.entity';
import { MarketplaceInventoryMapper } from '../mappers/marketplace-inventory.mapper';
@@ -136,6 +137,36 @@ export class MarketplaceInventoryRepositoryAdapter implements MarketplaceInvento
return rows.map((r) => this.mapper.toDomain(r));
}
async findWriteoffCandidates(
coopname: string,
cutoff: Date
): Promise<MarketplaceWriteoffCandidate[]> {
// Все позиции на складе — председатель собирает корзину вручную и может
// списать как просроченное, так и ещё годное (порча/невозврат). Просрочку
// подсвечиваем флагом is_expired, не отсекаем фильтром.
const rows = await this.repo.find({
where: {
coopname,
status: In([...MarketplaceInventoryOnWarehouseStatuses]),
},
order: { expiry_date: 'ASC', created_at: 'ASC' },
take: 500,
});
const cutoffMs = cutoff.getTime();
return rows
.map((r) => ({
inventory_id: r.id,
braname: r.braname,
asset_title: r.product_name_snapshot,
quantity: r.quantity_per_label,
arrival_price: r.arrival_price,
expiry_date: r.expiry_date,
is_expired: r.expiry_date !== null && r.expiry_date.getTime() <= cutoffMs,
}))
// Просроченное — наверх (первоочередные кандидаты), затем остальное.
.sort((a, b) => Number(b.is_expired) - Number(a.is_expired));
}
async applyStatusTransition(
id: string,
newStatus: MarketplaceInventoryStatus
@@ -486,22 +486,6 @@ export class MarketplaceOrderRepositoryAdapter implements MarketplaceOrderDomain
return rows.map((r) => this.mapper.toDomain(r));
}
async listReadyToReceiveByOrderer(
coopname: string,
orderer_account: string
): Promise<MarketplaceOrderDomainEntity[]> {
const rows = await this.repo
.createQueryBuilder('o')
.where('o.coopname = :coop AND o.orderer_account = :u AND o.status = :st', {
coop: coopname,
u: orderer_account,
st: 'READY_TO_RECEIVE',
})
.orderBy('o.accepted_at', 'ASC')
.getMany();
return rows.map((r) => this.mapper.toDomain(r));
}
// ── private ──
private async persistDomain(
@@ -92,6 +92,7 @@ export class MarketplaceWriteoffProposalRepositoryAdapter
status: In([
MarketplaceWriteoffProposalStatuses.ON_AGENDA,
MarketplaceWriteoffProposalStatuses.AUTHORIZED,
MarketplaceWriteoffProposalStatuses.PENDING_CONFIRMATION,
MarketplaceWriteoffProposalStatuses.EXECUTING,
]),
},
@@ -99,6 +100,38 @@ export class MarketplaceWriteoffProposalRepositoryAdapter
return row ? this.mapper.toDomain(row) : null;
}
async findActiveLockedInventoryIds(coopname: string): Promise<string[]> {
// Позиции, уже занятые в незавершённых проектах (черновик, в совете,
// одобрено, ожидает подтверждения, в исполнении) — нельзя предлагать их
// в кандидаты повторно, иначе один и тот же товар попадёт в два списания.
// EXECUTED/REJECTED не блокируют (товар либо списан, либо освобождён).
const rows = await this.repo.find({
where: {
coopname,
status: In([
MarketplaceWriteoffProposalStatuses.DRAFT,
MarketplaceWriteoffProposalStatuses.ON_AGENDA,
MarketplaceWriteoffProposalStatuses.AUTHORIZED,
MarketplaceWriteoffProposalStatuses.PENDING_CONFIRMATION,
MarketplaceWriteoffProposalStatuses.EXECUTING,
]),
},
select: { items: true } as never,
});
const ids = new Set<string>();
for (const row of rows) {
for (const item of row.items ?? []) {
for (const id of item.inventory_ids ?? []) ids.add(id);
// Обратная совместимость: проекты, созданные до агрегации партий,
// хранят одиночный inventory_id — учитываем и его, иначе их партии
// ошибочно «разблокируются» и всплывут в кандидатах.
const legacyId = (item as { inventory_id?: string | null }).inventory_id;
if (legacyId) ids.add(legacyId);
}
}
return [...ids];
}
async list(
filter: MarketplaceWriteoffProposalListFilter,
pagination?: PaginationInputDTO
@@ -173,7 +206,9 @@ export class MarketplaceWriteoffProposalRepositoryAdapter
if (row.status !== MarketplaceWriteoffProposalStatuses.ON_AGENDA) {
throw new Error('Авторизовать можно только проект, отправленный в совет (ON_AGENDA)');
}
row.status = MarketplaceWriteoffProposalStatuses.AUTHORIZED;
// Совет одобрил → ждём подтверждения складов председателями КУ. На цепи
// wroffprops.status = authorized; в PG — PENDING_CONFIRMATION.
row.status = MarketplaceWriteoffProposalStatuses.PENDING_CONFIRMATION;
row.protocol_doc = patch.protocol_doc;
row.authorized_at = patch.authorized_at;
row.decided_by_account = patch.decided_by_account;
@@ -34,10 +34,6 @@ import type {
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;
@@ -48,7 +44,11 @@ export class MarketplaceWriteoffProposalEntity {
@Column({ type: 'varchar', length: 16 })
public trigger!: MarketplaceWriteoffProposalTrigger;
@Column({ type: 'varchar', length: 16 })
// length 32: самый длинный статус — PENDING_CONFIRMATION (20 символов). При
// varchar(16) UPDATE статуса в markAuthorized падал «value too long for type
// character varying(16)», проект навсегда застревал в ON_AGENDA (решение
// совета не отлавливалось). synchronize:true расширит колонку при старте.
@Column({ type: 'varchar', length: 32 })
public status!: MarketplaceWriteoffProposalStatus;
@Column({ type: 'timestamptz' })
@@ -63,10 +63,6 @@ const orderOf = (overrides: Record<string, unknown>) =>
const makePayloadResolver = (order: any, isMember: boolean) => {
const service = {
getOpenIssuanceSignablePayload: jest.fn().mockResolvedValue({}),
getFinalizeIssuanceSignablePayload: jest.fn().mockResolvedValue({
hash: 'h',
document: { version: '1', hash: 'dh', doc_hash: 'dh', meta_hash: 'mh', meta: '{}', signatures: [] },
}),
} as any;
const orderRepo = { findById: jest.fn().mockResolvedValue(order) } as any;
const kuChairmanService = { isMemberOfBranch: jest.fn().mockResolvedValue(isMember) } as any;
@@ -101,23 +97,3 @@ describe('marketplaceIssueActChairmanSignablePayload ownership-scoping', () => {
expect(service.getOpenIssuanceSignablePayload).not.toHaveBeenCalled();
});
});
describe('marketplaceIssueActOrdererSignablePayload ownership-scoping', () => {
it('заказчик своего заказа → превью отдаётся', async () => {
const { resolver, service } = makePayloadResolver(orderOf({ orderer_account: 'op' }), false);
await resolver.marketplaceIssueActOrdererSignablePayload(asMember(['orderer']), {
order_id: 'o1',
} as any);
expect(service.getFinalizeIssuanceSignablePayload).toHaveBeenCalledWith('voskhod', 'o1');
});
it('НЕ заказчик заказа → ForbiddenException, сервис не дёргается', async () => {
const { resolver, service } = makePayloadResolver(orderOf({ orderer_account: 'someoneelse' }), false);
await expect(
resolver.marketplaceIssueActOrdererSignablePayload(asMember(['orderer']), {
order_id: 'o1',
} as any)
).rejects.toBeInstanceOf(ForbiddenException);
expect(service.getFinalizeIssuanceSignablePayload).not.toHaveBeenCalled();
});
});
+126 -31
View File
@@ -1,6 +1,7 @@
/* eslint-disable */
export const AllTypesProps: Record<string,any> = {
AccountKind: "enum" as const,
AccountType: "enum" as const,
ActionFiltersInput:{
@@ -653,9 +654,6 @@ export const AllTypesProps: Record<string,any> = {
inspection_photos:"MarketplaceReturnClaimPhotoUploadInput",
signed_statement:"MarketplaceReturnStatementSignedInput"
},
MarketplaceAcceptStockProposalInput:{
lines:"MarketplaceCheckoutSignedLineInput"
},
MarketplaceAddToCartInput:{
},
@@ -709,6 +707,9 @@ export const AllTypesProps: Record<string,any> = {
},
MarketplaceClearInventoryLabelInput:{
},
MarketplaceConfirmWriteoffInput:{
signed_memo:"SignedDigitalDocumentInput"
},
MarketplaceConsolidatedRequestStatus: "enum" as const,
MarketplaceConvertBranchFundsInput:{
@@ -735,6 +736,9 @@ export const AllTypesProps: Record<string,any> = {
delivery_points:"MarketplaceOfferDeliveryPointInput",
images:"MarketplaceOfferImageUploadInput"
},
MarketplaceCreateOrderProposalLineInput:{
signiss1_act:"MarketplaceIssueActSignedDocumentInput"
},
MarketplaceCreateReturnClaimInput:{
defect_category:"MarketplaceReturnClaimDefectCategory",
photos:"MarketplaceReturnClaimPhotoUploadInput",
@@ -744,7 +748,11 @@ export const AllTypesProps: Record<string,any> = {
groups:"MarketplaceShipmentGroupInput"
},
MarketplaceCreateStockProposalInput:{
items:"MarketplaceStockProposalItemInput"
items:"MarketplaceCreateStockProposalLineInput",
order_items:"MarketplaceCreateOrderProposalLineInput"
},
MarketplaceCreateStockProposalLineInput:{
signiss1_act:"MarketplaceIssueActSignedDocumentInput"
},
MarketplaceCreateWriteoffDraftInput:{
items:"MarketplaceWriteoffItemInput"
@@ -764,8 +772,9 @@ export const AllTypesProps: Record<string,any> = {
MarketplaceEventsInput:{
},
MarketplaceFinalizeIssuanceInput:{
signed_document:"MarketplaceIssueActSignedDocumentInput"
MarketplaceFinalizeStockIssuanceInput:{
order_lines:"MarketplaceStockFinalizeLineInput",
signed_convert:"MarketplaceConvertStatementSignedInput"
},
MarketplaceGenerateInventoryLabelInput:{
format:"MarketplaceBarcodeFormat"
@@ -795,6 +804,9 @@ export const AllTypesProps: Record<string,any> = {
MarketplaceKUStatus: "enum" as const,
MarketplaceListAidsInput:{
},
MarketplaceListAllOffersInput:{
statuses:"MarketplaceOfferStatus"
},
MarketplaceListAplReceptionsByBranameInput:{
@@ -851,9 +863,6 @@ export const AllTypesProps: Record<string,any> = {
},
MarketplaceOfferStatus: "enum" as const,
MarketplaceOpenIssuanceInput:{
signed_document:"MarketplaceIssueActSignedDocumentInput"
},
MarketplaceOrderIssuanceFactDiffState: "enum" as const,
MarketplaceOrderStatus: "enum" as const,
MarketplaceOutgoingPaymentRequestStatus: "enum" as const,
@@ -933,6 +942,12 @@ export const AllTypesProps: Record<string,any> = {
MarketplaceSplitInventoryInput:{
splits:"MarketplaceInventorySplitEntryInput"
},
MarketplaceStockFinalizeLineInput:{
signed_signiss2_act:"MarketplaceIssueActSignedDocumentInput"
},
MarketplaceStockIssuancePrepareInput:{
items:"MarketplaceStockProposalItemInput"
},
MarketplaceStockProposalItemInput:{
},
@@ -962,6 +977,12 @@ export const AllTypesProps: Record<string,any> = {
},
MarketplaceWriteoffProposalStatus: "enum" as const,
MarketplaceWriteoffProposalTrigger: "enum" as const,
MarketplaceWriteoffProtocolDocumentInput:{
},
MarketplaceWriteoffServiceMemoSignablePayloadInput:{
},
MarketplaceWriteoffStatementSignablePayloadInput:{
},
@@ -1431,9 +1452,6 @@ export const AllTypesProps: Record<string,any> = {
marketplaceAcceptReturnAtVisit:{
data:"MarketplaceAcceptReturnAtVisitInput"
},
marketplaceAcceptStockProposal:{
data:"MarketplaceAcceptStockProposalInput"
},
marketplaceAddAvailableCategories:{
input:"AddAvailableCategoriesInput"
},
@@ -1458,6 +1476,9 @@ export const AllTypesProps: Record<string,any> = {
marketplaceBindInventoryBarcode:{
data:"MarketplaceBindInventoryBarcodeInput"
},
marketplaceCancelAplReception:{
data:"MarketplaceAplReceptionByIdInput"
},
marketplaceCancelOrder:{
input:"MarketplaceCancelOrderInput"
},
@@ -1476,6 +1497,9 @@ export const AllTypesProps: Record<string,any> = {
marketplaceClearInventoryLabel:{
data:"MarketplaceClearInventoryLabelInput"
},
marketplaceConfirmWriteoff:{
data:"MarketplaceConfirmWriteoffInput"
},
marketplaceConvertBranchFunds:{
data:"MarketplaceConvertBranchFundsInput"
},
@@ -1527,15 +1551,12 @@ export const AllTypesProps: Record<string,any> = {
marketplaceDistributeBranchFunds:{
data:"MarketplaceDistributeBranchFundsInput"
},
marketplaceFinalizeIssuance:{
data:"MarketplaceFinalizeIssuanceInput"
marketplaceFinalizeStockIssuance:{
data:"MarketplaceFinalizeStockIssuanceInput"
},
marketplaceGenerateInventoryLabel:{
data:"MarketplaceGenerateInventoryLabelInput"
},
marketplaceOpenIssuance:{
data:"MarketplaceOpenIssuanceInput"
},
marketplacePublishStock:{
data:"MarketplacePublishStockInput"
},
@@ -2078,6 +2099,9 @@ export const AllTypesProps: Record<string,any> = {
},
getReportPreview:{
input:"ReportPreviewInput"
},
getUserWallets:{
},
getUserWebPushSubscriptions:{
data:"GetUserSubscriptionsInput"
@@ -2157,12 +2181,16 @@ export const AllTypesProps: Record<string,any> = {
marketplaceIssueActChairmanSignablePayload:{
data:"MarketplaceIssueActPayloadInput"
},
marketplaceIssueActOrdererSignablePayload:{
data:"MarketplaceIssueActPayloadInput"
},
marketplaceListAids:{
data:"MarketplaceListAidsInput"
},
marketplaceListAllOffers:{
input:"MarketplaceListAllOffersInput"
},
marketplaceListAllOrders:{
input:"MarketplaceListOrdersInput",
options:"PaginationInput"
},
marketplaceListAplReceptionsByBraname:{
data:"MarketplaceListAplReceptionsByBranameInput"
},
@@ -2251,6 +2279,9 @@ export const AllTypesProps: Record<string,any> = {
marketplaceSearchRequests:{
data:"SearchRequestsInput"
},
marketplaceStockIssuancePayloads:{
data:"MarketplaceStockIssuancePrepareInput"
},
marketplaceStockProposalSignablePayloads:{
data:"MarketplaceResolveStockProposalInput"
},
@@ -2259,6 +2290,12 @@ export const AllTypesProps: Record<string,any> = {
},
marketplaceWriteoffProposal:{
},
marketplaceWriteoffProtocolDocument:{
data:"MarketplaceWriteoffProtocolDocumentInput"
},
marketplaceWriteoffServiceMemoSignablePayload:{
data:"MarketplaceWriteoffServiceMemoSignablePayloadInput"
},
marketplaceWriteoffStatementSignablePayload:{
data:"MarketplaceWriteoffStatementSignablePayloadInput"
@@ -2614,6 +2651,7 @@ export const AllTypesProps: Record<string,any> = {
export const ReturnTypes: Record<string,any> = {
Account:{
account_kind:"AccountKind",
blockchain_account:"BlockchainAccount",
participant_account:"ParticipantAccount",
private_account:"PrivateAccount",
@@ -4399,10 +4437,6 @@ export const ReturnTypes: Record<string,any> = {
MarketplaceInventoryMutationResult:{
inventory:"MarketplaceInventoryItem"
},
MarketplaceIssuanceResult:{
order:"MarketplaceOrder",
tx_hash:"String"
},
MarketplaceKUDetails:{
addressFull:"String",
contactEmail:"String",
@@ -4796,6 +4830,26 @@ export const ReturnTypes: Record<string,any> = {
packaging:"MarketplaceShipmentLinePackaging",
vehicle_number:"String"
},
MarketplaceStockAcceptOrderLine:{
offer_id:"String",
order_hash:"String",
signiss1_aggregate:"DocumentAggregate"
},
MarketplaceStockAcceptPayload:{
convert_amount:"String",
convert_document:"GeneratedDocument",
convert_hash:"String",
member_amount:"String",
order_lines:"MarketplaceStockAcceptOrderLine"
},
MarketplaceStockIssuanceOperatorLine:{
offer_id:"ID",
order_hash:"String",
product_name:"String",
quantity:"Int",
signiss1_document:"GeneratedDocument",
unit_price:"String"
},
MarketplaceStockProposal:{
braname:"String",
created_at:"DateTime",
@@ -4870,6 +4924,30 @@ export const ReturnTypes: Record<string,any> = {
member_account:"String",
role:"String"
},
MarketplaceWriteoffCandidate:{
amount:"String",
asset_title:"String",
braname:"String",
branch_name:"String",
expiry_date:"String",
inventory_ids:"String",
is_expired:"Boolean",
key:"String",
lots_count:"Int",
quantity:"String",
reason:"String"
},
MarketplaceWriteoffConfirmationGroup:{
authorized_at:"String",
braname:"String",
branch_name:"String",
cycle_started_at:"String",
items:"MarketplaceWriteoffProposalItem",
proposal_hash:"String",
proposal_id:"String",
protocol_doc:"JSON",
total_amount:"String"
},
MarketplaceWriteoffDecisionEntry:{
action:"String",
actor:"String",
@@ -4900,8 +4978,9 @@ export const ReturnTypes: Record<string,any> = {
amount:"String",
asset_title:"String",
braname:"String",
branch_name:"String",
executed:"Boolean",
inventory_id:"String",
inventory_ids:"String",
quantity:"String",
reason:"String"
},
@@ -5159,7 +5238,6 @@ export const ReturnTypes: Record<string,any> = {
marketplaceAcceptCpp:"MarketplaceCppStatus",
marketplaceAcceptOrdersBatch:"MarketplaceSupplierBatchActionResult",
marketplaceAcceptReturnAtVisit:"MarketplaceReturnClaimResult",
marketplaceAcceptStockProposal:"MarketplaceStockProposalAcceptResult",
marketplaceAddAvailableCategories:"MarketplaceAvailableCategory",
marketplaceAddAvailableCategoryTypes:"MarketplaceAvailableCategory",
marketplaceAddToCart:"MarketplaceCart",
@@ -5168,6 +5246,7 @@ export const ReturnTypes: Record<string,any> = {
marketplaceApproveReturnVisit:"MarketplaceReturnClaimResult",
marketplaceAssignInventoryShelf:"MarketplaceInventoryMutationResult",
marketplaceBindInventoryBarcode:"MarketplaceInventoryMutationResult",
marketplaceCancelAplReception:"MarketplaceAplReceptionResult",
marketplaceCancelOrder:"MarketplaceCancelOrderResult",
marketplaceCancelStockOrder:"MarketplaceOrder",
marketplaceCancelStockProposal:"MarketplaceStockProposal",
@@ -5176,6 +5255,7 @@ export const ReturnTypes: Record<string,any> = {
marketplaceClearAvailableCategories:"Boolean",
marketplaceClearCart:"MarketplaceCart",
marketplaceClearInventoryLabel:"MarketplaceInventoryMutationResult",
marketplaceConfirmWriteoff:"MarketplaceWriteoffProposal",
marketplaceConvertBranchFunds:"Boolean",
marketplaceCreateAid:"Boolean",
marketplaceCreateAplReception:"MarketplaceAplReceptionResult",
@@ -5193,9 +5273,8 @@ export const ReturnTypes: Record<string,any> = {
marketplaceDeleteTrusteeWeight:"Boolean",
marketplaceDetailKU:"MarketplaceKUDetails",
marketplaceDistributeBranchFunds:"Boolean",
marketplaceFinalizeIssuance:"MarketplaceIssuanceResult",
marketplaceFinalizeStockIssuance:"MarketplaceStockProposalAcceptResult",
marketplaceGenerateInventoryLabel:"MarketplaceInventoryMutationResult",
marketplaceOpenIssuance:"MarketplaceIssuanceResult",
marketplacePublishStock:"MarketplaceOffer",
marketplaceRejectOffer:"MarketplaceOffer",
marketplaceRejectReturnAtVisit:"MarketplaceReturnClaimResult",
@@ -5804,6 +5883,7 @@ export const ReturnTypes: Record<string,any> = {
getReportPreview:"ReportPreview",
getReportRequisites:"ReportRequisitesView",
getSystemInfo:"SystemInfo",
getUserWallets:"UserWallet",
getUserWebPushSubscriptions:"WebPushSubscriptionDto",
getWebPushSubscriptionStats:"SubscriptionStatsDto",
listExpensePlans:"ExpensePlan",
@@ -5844,8 +5924,9 @@ export const ReturnTypes: Record<string,any> = {
marketplaceGetSupplierPaymentSettings:"MarketplaceSupplierPaymentSettings",
marketplaceGetUserRequests:"MarketplaceRequest",
marketplaceIssueActChairmanSignablePayload:"GeneratedDocument",
marketplaceIssueActOrdererSignablePayload:"DocumentAggregate",
marketplaceListAids:"MarketplaceAid",
marketplaceListAllOffers:"MarketplaceOfferPaginationResult",
marketplaceListAllOrders:"MarketplaceOrderPaginationResult",
marketplaceListAplReceptionsAsSupplier:"MarketplaceAplReception",
marketplaceListAplReceptionsByBraname:"MarketplaceAplReception",
marketplaceListAvailableCategories:"MarketplaceCategory",
@@ -5860,7 +5941,6 @@ export const ReturnTypes: Record<string,any> = {
marketplaceListModerationLog:"MarketplaceModerationLogEntry",
marketplaceListMyOffers:"MarketplaceOfferPaginationResult",
marketplaceListMyOrders:"MarketplaceOrderPaginationResult",
marketplaceListMyReadyToReceive:"MarketplaceOrder",
marketplaceListMyReturnClaims:"MarketplaceReturnClaim",
marketplaceListOutgoingPayments:"MarketplaceOutgoingPaymentRequest",
marketplaceListOutgoingPaymentsAsSupplier:"MarketplaceOutgoingPaymentRequest",
@@ -5873,6 +5953,7 @@ export const ReturnTypes: Record<string,any> = {
marketplaceListSupplierOrders:"MarketplaceOrderPaginationResult",
marketplaceListSupplierPickupOrders:"MarketplaceOrder",
marketplaceListWhitelist:"MarketplaceWhitelistEntry",
marketplaceListWriteoffCandidates:"MarketplaceWriteoffCandidate",
marketplaceListWriteoffProposals:"PaginatedMarketplaceWriteoffProposals",
marketplaceMemberWallet:"MarketplaceMemberWallet",
marketplaceOnboardingState:"MarketplaceOnboardingState",
@@ -5885,10 +5966,14 @@ export const ReturnTypes: Record<string,any> = {
marketplaceSearchAttributes:"MarketplaceAttribute",
marketplaceSearchDictionaryValues:"MarketplaceDictionaryValue",
marketplaceSearchRequests:"MarketplaceRequest",
marketplaceStockProposalSignablePayloads:"MarketplaceCheckoutSignableLine",
marketplaceStockIssuancePayloads:"MarketplaceStockIssuanceOperatorLine",
marketplaceStockProposalSignablePayloads:"MarketplaceStockAcceptPayload",
marketplaceValidateAttributeValues:"MarketplaceAttributeValidation",
marketplaceWhoAmI:"MarketplaceCurrentMember",
marketplaceWriteoffPendingConfirmations:"MarketplaceWriteoffConfirmationGroup",
marketplaceWriteoffProposal:"MarketplaceWriteoffProposal",
marketplaceWriteoffProtocolDocument:"DocumentAggregate",
marketplaceWriteoffServiceMemoSignablePayload:"GeneratedDocument",
marketplaceWriteoffStatementSignablePayload:"GeneratedDocument",
onecoopGetDocuments:"OneCoopDocumentsResponse",
process:"ProcessView",
@@ -6185,6 +6270,16 @@ export const ReturnTypes: Record<string,any> = {
"...on IndividualCertificate":"IndividualCertificate",
"...on OrganizationCertificate":"OrganizationCertificate"
},
UserWallet:{
available:"String",
blocked:"String",
coopname:"String",
human_name:"String",
id:"ID",
program_id:"ID",
username:"String",
wallet_name:"String"
},
Vars:{
confidential_email:"String",
confidential_link:"String",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
import * as Permissions from '../../../common/permissions'
import type * as Marketplace from '../../../interfaces/marketplace'
import { Actors } from '../../../common'
/**
* Председатель кооперативного участка подтверждает фактическое списание со
* склада своего КУ по авторизованному советом проекту (ручной шаг стола ПВЗ,
* p.mkt.wroff). Совет лишь признаёт списание допустимым; имущество выбывает
* со склада только после подписи Служебной записки о списании (registry 1111)
* ответственным за склад председателем КУ.
*
* Гранулярность по одному КУ (`braname`) за вызов: проект может охватывать
* несколько участков, каждый председатель закрывает только свою часть.
* Авторизация `coopname`; `signer` должен быть авторизован для `braname`
* через Branch::is_user_authorized.
*/
export const authorizations = [{ permissions: [Permissions.active], actor: Actors._coopname }] as const
export const actionName = 'confirmwroff'
/**
* @interface
*/
export type IConfirmWroff = Marketplace.IConfirmWroff
@@ -0,0 +1,19 @@
import * as Permissions from '../../../common/permissions'
import type * as Marketplace from '../../../interfaces/marketplace'
import { Actors } from '../../../common'
/**
* Конвертация паевого взноса пайщика в членский кошелёк «Стола заказов»
* (requirement 76). Один шаг ledger2: o.mkt.conv (TRANSFER w.wal.share
* w.mkt.member). Пополняет членские средства под заказ имущества со склада
* (stockorder фондируется только из членских). Заявление о конвертации (1110)
* публикуется в реестр документов отдельным пакетом.
*/
export const authorizations = [{ permissions: [Permissions.active], actor: Actors._username }] as const
export const actionName = 'convert'
/**
* @interface
*/
export type IConvert = Marketplace.IConvert
@@ -13,10 +13,18 @@ export * as CreateOrder from './createOrder'
/**
* Заказ имущества из обезличенного остатка склада кооператива (requirement 76).
* Продавец кооператив; Order рождается сразу в acceptcoop. Один шаг ledger2: o.mkt.lock.
* Продавец кооператив; Order рождается сразу в acceptcoop. Фондируется из
* членских средств: o.mkt.lockm (тело) + o.mkt.lockmf (взнос).
*/
export * as StockOrder from './stockOrder'
/**
* Конвертация паевого взноса в членский кошелёк «Стола заказов» (requirement 76):
* o.mkt.conv (w.wal.share w.mkt.member). Пополняет членские средства под заказ
* со склада; выполняется перед stockorder, когда членских средств не хватает.
*/
export * as Convert from './convert'
/**
* Списание уценки по заказу из остатка (requirement 76): o.mkt.loss (Дт 91 / Кт 10)
* после финализации выдачи выбытие со склада по полной стоимости прибытия.
@@ -157,6 +165,13 @@ export * as OnMktWoDecl from './onMktWoDecl'
*/
export * as ExecWroff from './execWroff'
/**
* Председатель КУ подтверждает фактическое списание со склада своего участка
* (ручной шаг стола ПВЗ), подписывая Служебную записку о списании (1111).
* Закрывает все неисполненные позиции одного КУ за вызов.
*/
export * as ConfirmWroff from './confirmWroff'
// ── service ────────────────────────────────────────────────────────────
/**
@@ -6,7 +6,9 @@ import { Actors } from '../../../common'
* Заказ имущества из обезличенного остатка склада кооператива (requirement 76).
* Продавец сам кооператив (offerer == coopname); Order рождается сразу в
* acceptcoop и идёт только через выдачу signiss1/signiss2.
* Один шаг ledger2: o.mkt.lock (TRANSFER w.wal.share w.mkt.order).
* Фондируется из членских средств пайщика начисто: o.mkt.lockm (тело,
* w.mkt.member w.mkt.order) + o.mkt.lockmf (взнос, w.mkt.member w.mkt.fee).
* Паевой пополняет членский кошелёк заранее отдельным действием `convert`.
*/
export const authorizations = [{ permissions: [Permissions.active], actor: Actors._username }] as const
@@ -142,8 +142,6 @@ th { width: 30%; }
</tbody>
</table>
<p>{% trans 'proposal_ref' %} {{ proposal_hash }}</p>
<hr />
<p>{% trans 'closing_time', decision.time %}</p>
<div class="signature">
@@ -184,7 +182,6 @@ export const translations = {
meeting_format_value: 'заочная',
agenda_subject: 'Утвердить списание {0} позиций имущества со складов кооперативных участков на общую сумму {1} (цикл от {2}) по ЦПП «Стол заказов» — поданное Заявление председателя о списании скоропорта.',
decision_writeoff_text: 'Списать {0} позиций имущества на общую сумму {1} с балансов кооперативных участков через транзит счёта 91 «Прочие доходы и расходы» с закрытием на счёт 86 Целевого финансирования программы. Движений по кошелькам пайщиков не производить.',
proposal_ref: 'Идентификатор проекта списания:',
},
}
@@ -23,6 +23,8 @@ export interface WriteoffItemAction {
asset_title: string
/** Количество единиц к списанию. */
quantity: string
/** Единица измерения (шт. / кг / л / упак.) — снапшот с оффера товара. */
unit: string
/** Стоимость списания (4 знака после запятой, валюта `_root_govern_symbol`). */
amount: string
/** Причина списания (просрочка / порча / малооценность). */
@@ -47,6 +49,7 @@ export interface WriteoffItemModel {
braname: string
asset_title: string
quantity: string
unit: string
amount: string
reason: string
}
@@ -70,80 +73,107 @@ export interface Model {
export const title = 'Заявление о списании скоропорта'
export const description = 'Заявление председателя в совет кооператива о признании списания со складов кооперативных участков скоропортящегося, повреждённого или малооценного имущества по ЦПП «Стол заказов»'
// Вёрстка 1-в-1 с эталоном фабричного документа 1106 (Заявление о возврате),
// который КОРРЕКТНО отображается и в повестке совета (рендерер BaseDocument), и
// в предпросмотре. Ключевое: BaseDocument прогоняет html через sanitizeHtml —
// он ВЫРЕЗАЕТ <style> документа и принудительно ставит в Shadow DOM
// `.digital-document { white-space: pre-wrap }`. Поэтому вёрстка НЕ должна
// полагаться на <style> с классами (он исчезнет): выравнивание задаём ИНЛАЙН
// (`style="text-align: ..."` — инлайн переживает sanitize), вертикальный ритм —
// ПУСТЫМИ СТРОКАМИ под pre-wrap (а не margin'ами), плотные абзацы — инлайн
// `margin: 0px`. Центр-заголовок — <h1 class="header"> (shadowStyles центрирует
// `.digital-document .header`). Многоколоночная таблица позиций использует
// <td style="font-weight:bold"> для шапки (а не <th>: рендерер навязывает
// th{width:30%}, и пять <th> уехали бы за край). Свой <style> ниже нужен для
// нешадоу-рендеров (предпросмотр) и совпадает с 1106.
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 currentColor; padding: 8px; text-align: left; word-wrap: break-word; overflow-wrap: break-word; }
th { font-weight: bold; }
h1 {
margin: 0px;
text-align: center;
}
.digital-document {
padding: 20px;
white-space: pre-wrap;
}
.subheader {
padding-bottom: 20px;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
border: 1px solid currentColor;
padding: 8px;
text-align: left;
word-wrap: break-word;
overflow-wrap: break-word;
}
</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>
<p style="margin: 0px !important">{% trans 'v_soviet' %} {{ vars.full_abbr_genitive }} "{{ vars.name }}"</p>
<p style="margin: 0px !important">{% trans 'from_chairman' %} {{ chairman.full_name_or_short_name }}</p>
</div>
<div style="text-align: center">
<h1>{% trans 'statement_title' %}</h1>
<h1 class="header">{% trans 'statement_title' %}</h1>
<p class="subheader">{% trans 'statement_subheader', program.name %}</p>
</div>
<p>{% trans 'preamble', program.name, cycle_started_at %}</p>
<p style="text-align: right; margin: 0px">{% trans 'place', coop.city %}</p>
<p>{% trans 'preamble', 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>
<tr>
<td style="font-weight: bold"></td>
<td style="font-weight: bold">{% trans 'col_asset' %}</td>
<td style="font-weight: bold">{% trans 'col_quantity' %}</td>
<td style="font-weight: bold">{% trans 'col_unit' %}</td>
<td style="font-weight: bold">{% trans 'col_amount' %}</td>
<td style="font-weight: bold">{% trans 'col_reason' %}</td>
</tr>
{% for it in items %}
<tr>
<td>{{ forloop.counter }}</td>
<td>{{ it.braname }}</td>
<td>{{ it.asset_title }}</td>
<td>{{ it.quantity }}</td>
<td>{{ it.unit }}</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>
<tr>
<td colspan="4" style="font-weight: bold">{% trans 'total' %}</td>
<td style="font-weight: bold">{{ 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>
<p style="margin: 0px">{{ chairman.full_name_or_short_name }}</p>
<p style="margin: 0px">{{ meta.created_at }}</p>
</div>
`
export const translations = {
ru: {
v_soviet: 'В Совет',
from_chairman: 'от Председателя',
from_chairman: 'от Председателя Совета',
statement_title: 'ЗАЯВЛЕНИЕ',
statement_subheader: 'о списании имущества со складов кооперативных участков по Целевой Потребительской Программе «{0}»',
preamble: 'Прошу совет принять решение о списании с балансов кооперативных участков следующих позиций имущества, признанных по итогам цикла «{0}» от {1} непригодными к выдаче пайщикам:',
col_branch: 'КУ',
place: 'г. {0}',
preamble: 'Прошу совет принять решение о списании с балансов кооперативных участков следующих позиций имущества, признанных от {0} непригодными к выдаче пайщикам:',
col_asset: 'Наименование/Артикул',
col_quantity: 'Количество',
col_unit: 'Ед. изм.',
col_amount: 'Сумма списания',
col_reason: 'Причина',
total: 'ИТОГО',
ledger_note: 'Списание оформляется через транзит счёта 91 «Прочие доходы и расходы» атомарной парой ledger2-операций o.mkt.wroff (Дт 91 / Кт 10) и o.mkt.wroff2 (Дт 86 / Кт 91), движений по кошелькам пайщиков не происходит.',
signature: 'Подписано электронной подписью.',
},
}
@@ -161,22 +191,24 @@ export const exampleData = {
chairman: { full_name_or_short_name: 'Муравьев Алексей Николаевич' },
program: { name: 'СТОЛ ЗАКАЗОВ' },
proposal_hash: '0000abcd...',
cycle_started_at: '2026-06-01',
cycle_started_at: '01.06.2026',
items: [
{
braname: 'KU-MOSKVA-1',
braname: 'ku-moskva-1',
asset_title: 'Сахар-песок «Сладкий», 1 кг',
quantity: '12',
amount: '1020.0000',
unit: 'шт.',
amount: '1 020,00 RUB',
reason: 'Истёк срок годности',
},
{
braname: 'KU-MOSKVA-2',
braname: 'ku-moskva-2',
asset_title: 'Молоко «Доброе», 1 л',
quantity: '5',
amount: '485.0000',
unit: 'шт.',
amount: '485,00 RUB',
reason: 'Повреждена упаковка',
},
],
total_amount: '1505.0000',
total_amount: '1 505,00 RUB',
}
@@ -0,0 +1,201 @@
import type { IGenerate, IMetaDocument } from '../../document'
import type { ICommonProgram, ICommonUser, ICooperativeData, IVars } from '../../model'
export const registry_id = 1111
/**
* Служебная записка о списании имущества со склада кооперативного участка
* Эпик 8 ЦПП «Стол заказов» (членские взносы).
*
* Завершающий документ пути списания скоропорта. Совет своим решением
* (Протокол 1107) лишь признаёт списание допустимым; имущество физически
* выбывает со склада только после того, как ответственный за склад
* председатель кооперативного участка подпишет эту записку и тем самым
* подтвердит фактическое списание. Подпись инициирует on-chain action
* `marketplace::confirmwroff(coopname, signer, proposal_hash, braname, memo)`,
* который проводит ledger2-операции `o.mkt.wroff` по всем неисполненным
* позициям данного КУ. Документ публикуется в реестр документов в пакете
* процесса списания (package = proposal_hash), рядом с Заявлением 1108 и
* Протоколом совета 1107.
*
* Гранулярность по одному кооперативному участку: председатель КУ
* подтверждает списание только своей части проекта.
*/
export interface WriteoffMemoItem {
/** Краткое название позиции (название товара или артикул). */
asset_title: string
/** Количество единиц к списанию. */
quantity: string
/** Единица измерения (шт. / кг / л / упак.) — снапшот с оффера товара. */
unit: 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
/** Кооперативный участок (склад) — источник списания. */
braname: string
/** Человеко-читаемое имя кооперативного участка для шапки документа. */
branch_name: string
/** Идентификатор расчётного цикла списания (ISO-дата начала цикла). */
cycle_started_at: string
/** Позиции к списанию по данному КУ. */
items: WriteoffMemoItem[]
/** Σ items.amount по данному КУ. */
total_amount: string
}
export type Meta = IMetaDocument & Action
/**
* Модель данных для PDF-рендера Служебной записки о списании.
* Подписант председатель кооперативного участка.
*/
export interface Model {
meta: IMetaDocument
coop: ICooperativeData
vars: IVars
chairman: ICommonUser
program: ICommonProgram
proposal_hash: string
branch_name: string
cycle_started_at: string
items: WriteoffMemoItem[]
total_amount: string
}
export const title = 'Служебная записка о списании'
export const description = 'Служебная записка председателя кооперативного участка о фактическом списании со склада скоропортящегося, повреждённого или малооценного имущества по решению совета (ЦПП «Стол заказов»)'
// Вёрстка 1-в-1 с эталоном фабричного документа 1106 (Заявление о возврате),
// который КОРРЕКТНО отображается и в повестке совета (рендерер BaseDocument), и
// в предпросмотре. BaseDocument прогоняет html через sanitizeHtml — он ВЫРЕЗАЕТ
// <style> документа и форсит в Shadow DOM `.digital-document{white-space:pre-wrap}`.
// Поэтому: выравнивание — ИНЛАЙН (`style="text-align"`), вертикальный ритм —
// ПУСТЫМИ СТРОКАМИ под pre-wrap, плотные абзацы — инлайн `margin:0px`,
// центр-заголовок — <h1 class="header">. Шапка таблицы — <td style="font-weight">,
// не <th> (рендерер навязывает th{width:30%}). Свой <style> — для нешадоу-рендеров.
export const context = `<style>
h1 {
margin: 0px;
text-align: center;
}
.digital-document {
padding: 20px;
white-space: pre-wrap;
}
.subheader {
padding-bottom: 20px;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
border: 1px solid currentColor;
padding: 8px;
text-align: left;
word-wrap: break-word;
overflow-wrap: break-word;
}
</style>
<div class="digital-document">
<div style="text-align: center">
<h1 class="header">{% trans 'memo_title' %}</h1>
<p class="subheader">{% trans 'memo_subheader', branch_name %}</p>
</div>
<p>{% trans 'preamble', program.name, cycle_started_at %}</p>
<table>
<tbody>
<tr>
<td style="font-weight: bold"></td>
<td style="font-weight: bold">{% trans 'col_asset' %}</td>
<td style="font-weight: bold">{% trans 'col_quantity' %}</td>
<td style="font-weight: bold">{% trans 'col_unit' %}</td>
<td style="font-weight: bold">{% trans 'col_amount' %}</td>
<td style="font-weight: bold">{% trans 'col_reason' %}</td>
</tr>
{% for it in items %}
<tr>
<td>{{ forloop.counter }}</td>
<td>{{ it.asset_title }}</td>
<td>{{ it.quantity }}</td>
<td>{{ it.unit }}</td>
<td>{{ it.amount }}</td>
<td>{{ it.reason }}</td>
</tr>
{% endfor %}
<tr>
<td colspan="4" style="font-weight: bold">{% trans 'total' %}</td>
<td style="font-weight: bold">{{ total_amount }}</td>
<td></td>
</tr>
</tbody>
</table>
<p>{% trans 'confirmation' %}</p>
<p>{% trans 'signature' %}</p>
<p style="margin: 0px">{% trans 'chairman_of_branch', branch_name %} {{ chairman.full_name_or_short_name }}</p>
<p style="margin: 0px">{{ meta.created_at }}</p>
</div>
`
export const translations = {
ru: {
memo_title: 'СЛУЖЕБНАЯ ЗАПИСКА',
memo_subheader: 'о списании имущества со склада кооперативного участка «{0}»',
preamble: 'По решению совета кооператива в рамках Целевой Потребительской Программы «{0}» (цикл от {1}) подтверждаю фактическое списание и выбытие со склада кооперативного участка следующего имущества, признанного непригодным к выдаче пайщикам:',
col_asset: 'Наименование/Артикул',
col_quantity: 'Количество',
col_unit: 'Ед. изм.',
col_amount: 'Сумма списания',
col_reason: 'Причина',
total: 'ИТОГО',
confirmation: 'Списание произведено. Имущество физически выбыло со склада кооперативного участка и снято с учёта.',
signature: 'Подписано электронной подписью.',
chairman_of_branch: 'Председатель кооперативного участка «{0}»:',
},
}
export const exampleData = {
meta: { created_at: '13.06.2026 12:00' },
coop: {
short_name: 'ПК ВОСХОД',
city: 'Москва',
},
vars: {
name: 'ВОСХОД',
full_abbr_genitive: 'Потребительского Кооператива',
},
chairman: { full_name_or_short_name: 'Иванов Иван Иванович' },
program: { name: 'СТОЛ ЗАКАЗОВ' },
proposal_hash: '0000abcd...',
branch_name: 'Кооперативный участок «Москва-1»',
cycle_started_at: '2026-06-01',
items: [
{
asset_title: 'Сахар-песок «Сладкий», 1 кг',
quantity: '12',
unit: 'шт.',
amount: '1 020,00 RUB',
reason: 'Истёк срок годности',
},
{
asset_title: 'Молоко «Доброе», 1 л',
quantity: '5',
unit: 'шт.',
amount: '485,00 RUB',
reason: 'Повреждена упаковка',
},
],
total_amount: '1 505,00 RUB',
}
@@ -78,6 +78,7 @@ export * as MarketplaceWriteoffProtocol from './1107.MarketplaceWriteoffProtocol
export * as MarketplaceWriteoffStatement from './1108.MarketplaceWriteoffStatement'
export * as BranchFinancialAidStatement from './1109.BranchFinancialAidStatement'
export * as MarketplaceConvertStatement from './1110.MarketplaceConvertStatement'
export * as MarketplaceWriteoffServiceMemo from './1111.MarketplaceWriteoffServiceMemo'
export * as SosediAgreement from './699.SosediAgreement'
@@ -65,6 +65,12 @@ export interface IStockOrder {
unit_price: IAsset
warranty_period_secs: IUint32
batch_hash: IChecksum256
}
export interface IConvert {
coopname: IName
orderer: IName
amount: IAsset
convert_statement: IDocument2
}
@@ -223,6 +229,22 @@ export interface IExecWroff {
item_index: IUint64
}
/**
* Председатель кооперативного участка подтверждает фактическое списание со
* склада своего КУ по авторизованному советом проекту. Один вызов закрывает
* все неисполненные позиции одного КУ (`braname`); `memo` подписанная
* Служебная записка о списании (registry 1111). Авторизация `coopname`;
* `signer` должен быть авторизован для `braname` через
* Branch::is_user_authorized.
*/
export interface IConfirmWroff {
coopname: IName
signer: IName
proposal_hash: IChecksum256
braname: IName
memo: IDocument2
}
/**
* Callback от `soviet::exec` после авторизации Протокола Совета о
* списании скоропорта (Story 8.4). Сигнатура `(coopname, hash, authorization)`
+18 -1
View File
@@ -180,6 +180,15 @@ export const LEDGER2_OPERATION_REGISTRY: readonly OperationMeta[] = [
debit: null, credit: null,
human_name: 'Снятие резерва при отмене заказа' },
// Удержание 50% при отказе пайщика от получения после акцепта поставщиком:
// удержанная половина тела заказа транзитом через пул членских взносов уходит
// в общий кошелёк КУ (тем же o.brn.common). Прямой перевод на w.brn.common
// невозможен — walletop держит один username на обе стороны.
{ code: 'o.mkt.penal', process_type: 'p.mkt.supply', contract: 'marketplace',
name: 'REFUSAL_PENALTY', wallet_op: 'TRANSFER', wallet_from: 'w.mkt.order', wallet_to: 'w.mkt.fee',
debit: null, credit: null,
human_name: 'Удержание при отказе пайщика от получения после акцепта поставщиком' },
{ code: 'o.mkt.purch', process_type: 'p.mkt.supply', contract: 'marketplace',
name: 'PURCHASE_FROM_SUPPLIER', wallet_op: 'NONE', wallet_from: null, wallet_to: null,
debit: 10, credit: 86,
@@ -215,7 +224,7 @@ export const LEDGER2_OPERATION_REGISTRY: readonly OperationMeta[] = [
human_name: 'Уценка имущества при выдаче со склада кооператива' },
// Членский взнос по заказу (requirement b6 «Экономика КУ»): блокировка по
// единой ставке кооператива на createorder/stockorder, дособор на signiss2.
// единой ставке кооператива на createorder с паевого, дособор на signiss2.
{ code: 'o.mkt.fee', process_type: 'p.mkt.supply', contract: 'marketplace',
name: 'MEMBERSHIP_FEE_LOCK', wallet_op: 'TRANSFER', wallet_from: 'w.wal.share', wallet_to: 'w.mkt.fee',
debit: 80, credit: 86,
@@ -228,6 +237,14 @@ export const LEDGER2_OPERATION_REGISTRY: readonly OperationMeta[] = [
debit: null, credit: null,
human_name: 'Возврат членского взноса по заказу' },
// Членский взнос под заказ из остатка — из уже внесённых членских средств
// пайщика (stockorder фондируется из членского начисто). Парный по взносу к
// o.mkt.lockm; инверсия o.mkt.refund. Паевой пополняет членский заранее (o.mkt.conv).
{ code: 'o.mkt.lockmf', process_type: 'p.mkt.supply', contract: 'marketplace',
name: 'LOCK_FEE_FROM_MEMBER', wallet_op: 'TRANSFER', wallet_from: 'w.mkt.member', wallet_to: 'w.mkt.fee',
debit: null, credit: null,
human_name: 'Членский взнос «Стола заказов» из внесённых средств' },
// branch — экономика кооперативного участка (requirement b6)
{ code: 'o.brn.common', process_type: 'p.brn.fees', contract: 'branch',
name: 'DISTRIBUTE_COMMON', wallet_op: 'TRANSFER', wallet_from: 'w.mkt.fee', wallet_to: 'w.brn.common',
@@ -1,6 +1,7 @@
import { markRaw } from 'vue';
import { agreementsBase } from 'src/shared/lib/consts/workspaces';
import type { IWorkspaceConfig } from 'src/shared/lib/types/workspace';
import type { DesktopWalletCard } from 'src/shared/lib/types/desktop-wallet';
import { ContributorsPage } from './pages';
import { CapitalBase } from './pages/CapitalBase';
import { ProjectsListPage } from './pages/ProjectsListPage';
@@ -526,3 +527,18 @@ export default async function (): Promise<IWorkspaceConfig[]> {
],
}];
}
/**
* Кошельки, которые «Благорост» приносит на стол пайщика (путь B).
* Единый кошелёк программы у пайщика (`w.cap.blago`). Генератор
* (`w.cap.gen`) кооперативный кошелёк без L3-разреза, на столе пайщика
* НЕ показываем.
*/
export const walletCards: DesktopWalletCard[] = [
{
wallet_name: 'w.cap.blago',
label: 'Благорост',
accent: 'blagorost',
icon: 'savings',
},
];
+75 -16
View File
@@ -1,4 +1,5 @@
import { markRaw } from 'vue'
import type { DesktopWalletCard } from 'src/shared/lib/types/desktop-wallet'
import { MarketplaceCatalogPage } from 'src/pages/Marketplace/MarketplaceCatalog'
import { MarketplaceOfferDetailPage } from 'src/pages/Marketplace/MarketplaceOfferDetail'
import { CartPage } from 'src/pages/Marketplace/Cart'
@@ -20,7 +21,10 @@ import { OffererSupplyPreparationPage } from 'src/pages/Marketplace/OffererSuppl
import { OffererShipPartyPage } from 'src/pages/Marketplace/OffererShipParty'
import { OffererPaymentHistoryPage } from 'src/pages/Marketplace/OffererPaymentHistory'
import { AdminWriteoffsPage } from 'src/pages/Marketplace/AdminWriteoffs'
import { PvzWriteoffsPage } from 'src/pages/Marketplace/PvzWriteoffs'
import { ChairmanModerationPage } from 'src/pages/Marketplace/ChairmanModeration'
import { AdminOrdersPage } from 'src/pages/Marketplace/AdminOrders'
import { AdminOffersPage } from 'src/pages/Marketplace/AdminOffers'
import { AdminIssuancePointsPage } from 'src/pages/Marketplace/AdminIssuancePoints'
import { OperatorOwnWarehousePage } from 'src/pages/Marketplace/OperatorOwnWarehouse'
import { AdminWarehouseSummaryPage } from 'src/pages/Marketplace/AdminWarehouseSummary'
@@ -29,7 +33,6 @@ import { OnboardingCoopAcceptCppPage } from 'src/pages/Marketplace/OnboardingCoo
import { OrdererConsolidatedPage } from 'src/pages/Marketplace/OrdererConsolidated'
import { OffererIncomingOrdersPage } from 'src/pages/Marketplace/OffererIncomingOrders'
import { OffererMyOffersPage } from 'src/pages/Marketplace/OffererMyOffers'
import { BoardAgendaWriteoffPage } from 'src/pages/Marketplace/BoardAgendaWriteoff'
import { ChairmanCategoryWhitelistPage } from 'src/pages/Marketplace/ChairmanCategoryWhitelist'
import { BoardPayoutsReadonlyPage } from 'src/pages/Marketplace/BoardPayoutsReadonly'
import { OnboardingMemberPickCppPage } from 'src/pages/Marketplace/OnboardingMemberPickCpp'
@@ -515,6 +518,21 @@ export default async function (): Promise<IWorkspaceConfig[]> {
agreements: agreementsBase,
},
},
{
// Эпик 8: operator-стол подтверждения списания со склада. Совет
// одобрил проект списания — председатель КУ подтверждает
// фактическое выбытие имущества, подписав Служебную записку 1111.
path: 'writeoffs',
name: 'marketplace-pvz-writeoffs',
component: markRaw(PvzWriteoffsPage),
meta: {
title: 'Списание со склада',
icon: 'fa-solid fa-trash-can-arrow-up',
requires: 'Writeoff:read:own-KU',
requiresAuth: true,
agreements: agreementsBase,
},
},
{
// Эпик 6 / Story 6.6: operator-стол выдачи имущества пайщику на ПВЗ.
path: 'issuance',
@@ -605,7 +623,9 @@ export default async function (): Promise<IWorkspaceConfig[]> {
extension_name: 'market',
title: 'Стол администратора',
icon: 'fa-solid fa-shield-halved',
defaultRoute: 'marketplace-moderation',
// Реестр всех заказов кооператива — центральный обзорный экран стола;
// открывается первым.
defaultRoute: 'marketplace-admin-orders',
routes: [
{
meta: {
@@ -615,6 +635,43 @@ export default async function (): Promise<IWorkspaceConfig[]> {
path: '/:coopname/market-admin',
name: 'market-admin',
children: [
{
// Единый реестр всех заказов кооператива с текущими статусами
// (`Order:read:all` есть у board_readonly и admin). Раскрытие заказа
// показывает его состояние (таймлайн) и детализацию процесса
// p.mkt.supply — документы + операции + проводки по order_hash —
// через общий виджет ProcessDetailCard, без ссылок на стол
// бухгалтера (у администратора/совета может не быть к нему доступа).
path: 'orders',
name: 'marketplace-admin-orders',
component: markRaw(AdminOrdersPage),
meta: {
title: 'Реестр заказов',
icon: 'receipt_long',
requires: 'Order:read:all',
requiresAuth: true,
agreements: agreementsBase,
},
children: [],
},
{
// Реестр всех предложений кооператива любого статуса
// (опубликованные/снятые/отклонённые/на модерации), всех
// поставщиков (`Offer:read:all` — у admin и board_readonly).
// Отдельно от «Модерации» (там только ждущие решения); на эти
// карточки ведёт переход «Открыть предложение» из реестра заказов.
path: 'offers',
name: 'marketplace-admin-offers',
component: markRaw(AdminOffersPage),
meta: {
title: 'Реестр предложений',
icon: 'storefront',
requires: 'Offer:read:all',
requiresAuth: true,
agreements: agreementsBase,
},
children: [],
},
{
// Эпик 3 / Story 3.6: admin-стол модерации offer'ов. Виден совету
// и председателю (`Order:read:all` есть у board_readonly и admin).
@@ -712,20 +769,6 @@ export default async function (): Promise<IWorkspaceConfig[]> {
},
children: [],
},
{
// Эпик 8 / Story 8.x: read-only лента writeoff-проектов для совета.
path: 'board-writeoff',
name: 'marketplace-board-writeoff',
component: markRaw(BoardAgendaWriteoffPage),
meta: {
title: 'Повестка совета — списания',
icon: 'fa-solid fa-gavel',
requires: 'Order:read:all',
requiresAuth: true,
agreements: agreementsBase,
},
children: [],
},
{
// Эпик 9 / Story 9.2: admin-стол сводного склада кооператива.
path: 'warehouse-summary',
@@ -796,3 +839,19 @@ export default async function (): Promise<IWorkspaceConfig[]> {
},
]
}
/**
* Кошельки, которые «Стол заказов» приносит на стол пайщика (путь B).
* Членский кошелёк программы (`w.mkt.member`) туда зачисляются возвратные
* членские средства стола заказов. Главный членский ЦК (`w.wal.member`) и
* резерв под заказ (`w.mkt.order`) здесь НЕ показываем.
*/
export const walletCards: DesktopWalletCard[] = [
{
wallet_name: 'w.mkt.member',
label: 'Членский кошелёк',
description: 'Стол заказов',
accent: 'wallet',
icon: 'card_membership',
},
]
@@ -94,7 +94,18 @@ div.page-shell
@click='copyText(props.row.processHash)'
)
q-tooltip Клик копировать полный хэш
q-td {{ fioCache.get(props.row.username ?? '') || props.row.username || '—' }}
q-td
span {{ subjectName(props.row.username) }}
q-chip.q-ml-xs(
v-if='isBranch(props.row.username)'
dense
square
size='sm'
color='orange-1'
text-color='orange-9'
label='КУ'
)
q-tooltip Кооперативный участок
q-td {{ formatDate(props.row.firstSeenAt) }}
q-td {{ formatDate(props.row.lastSeenAt) }}
@@ -106,103 +117,15 @@ div.page-shell
)
q-td(colspan='100%')
.q-pa-md
.op-header.q-mb-md(
:style='{ borderLeftColor: processAccentColor(props.row.processType) }'
//- Детализация процесса (документы + операции + проводки)
//- общий виджет; операции/проводки ведут в реестры бухгалтера.
ProcessDetailCard(
:coopname='info.coopname'
:process-hash='props.row.processHash'
:process-type='props.row.processType'
operation-route-name='reports-operations'
posting-route-name='reports-postings'
)
.text-h6.text-weight-medium {{ processTypeLabel(props.row.processType) }}
.row.items-center.q-gutter-sm.q-mb-xs
.text-caption.text-grey-7 ID процесса:
EntityIdBadge(:rawId='props.row.processHash' copy-on-click)
template(v-if='!detailLoaded.has(props.row.processHash)')
q-spinner(size='sm')
template(v-else)
//- Документы процесса (наименование / дата / подписанты),
//- открытие и скачивание на странице самого документа.
q-card.q-mb-md(flat bordered)
q-card-section.q-pb-none
.text-subtitle2 Документы
q-card-section.q-pt-sm
.text-body2.text-grey-7(v-if='!processDocs(props.row.processHash).length') Документы не приложены
.column.q-gutter-xs(v-else)
DocumentRow(
v-for='d in processDocs(props.row.processHash)'
:key='docHash(d)'
:document='toDocRow(d)'
@open='openDoc(d)'
)
.row.q-col-gutter-md
//- Операции процесса (apply + корректировки)
.col-12.col-md-6
q-card(flat bordered)
q-card-section.q-pb-none
.text-subtitle2 Операции
q-card-section.q-pt-sm
.text-body2.text-grey-7(v-if='!processOps(props.row.processHash).length') Операций нет
q-table(
v-else
flat dense
:rows='processOps(props.row.processHash)'
:columns='opColumns'
row-key='globalSequence'
hide-pagination
:pagination='{ rowsPerPage: 0 }'
)
template(#body-cell-date='cp')
q-td(:props='cp') {{ formatDate(cp.row.createdAt) }}
template(#body-cell-op='cp')
q-td(:props='cp')
EntityIdBadge(
:rawId='cp.row.globalSequence'
@click='goToOperation(cp.row.globalSequence)'
)
q-tooltip Открыть в реестре операций
template(#body-cell-label='cp')
q-td(:props='cp')
q-chip(
dense square
:color='processChipBg(props.row.processType)'
:text-color='processChipText(props.row.processType)'
) {{ opLabel(cp.row) }}
template(#body-cell-amount='cp')
q-td.text-right.font-monospace(:props='cp') {{ formatAmount(cp.row.quantity) }}
//- Проводки процесса (Дт Кт парами)
.col-12.col-md-6
q-card(flat bordered)
q-card-section.q-pb-none
.text-subtitle2 Проводки
q-card-section.q-pt-sm
.text-body2.text-grey-7(v-if='!processPostings(props.row.processHash).length') Проводок нет
q-table(
v-else
flat dense
:rows='processPostings(props.row.processHash)'
:columns='pstColumns'
row-key='key'
hide-pagination
:pagination='{ rowsPerPage: 0 }'
)
template(#body-cell-date='cp')
q-td(:props='cp') {{ formatDate(cp.row.createdAt) }}
template(#body-cell-posting='cp')
q-td(:props='cp')
EntityIdBadge(
v-if='cp.row.debitGlobalSequence'
:rawId='cp.row.debitGlobalSequence'
@click='goToPosting(cp.row.debitGlobalSequence)'
)
q-tooltip Открыть в реестре проводок
span.text-grey-6(v-else)
template(#body-cell-debit='cp')
q-td.text-center(:props='cp')
AccountIdCell(:account-code='accCode(cp.row.debitAccountId)')
template(#body-cell-credit='cp')
q-td.text-center(:props='cp')
AccountIdCell(:account-code='accCode(cp.row.creditAccountId)')
template(#body-cell-amount='cp')
q-td.text-right.font-monospace(:props='cp') {{ formatAmount(cp.row.quantity) }}
template(#item='props')
.col-12
@@ -212,21 +135,13 @@ div.page-shell
.text-caption.text-grey-6 {{ formatDate(props.row.lastSeenAt) }}
.text-body2.text-weight-medium {{ processTypeLabel(props.row.processType) }}
.col-12.text-caption.text-grey-7
| Пайщик: {{ fioCache.get(props.row.username ?? '') || props.row.username || '—' }}
| {{ isBranch(props.row.username) ? 'Участок' : 'Пайщик' }}: {{ subjectName(props.row.username) }}
.col-12.row.q-gutter-xs.q-mt-xs.items-center
.text-caption.text-grey-7 ID процесса
EntityIdBadge(
:rawId='shortHash(props.row.processHash)'
@click='copyText(props.row.processHash)'
)
//- Просмотр документа процесса во всплывающем окне (без перехода в реестр
//- документов совета у бухгалтера может не быть к нему доступа).
DocumentViewerDialog(
v-model='viewerOpen'
:document-aggregate='viewerDoc'
:title='viewerTitle'
)
</template>
<script setup lang="ts">
@@ -238,47 +153,37 @@ import { FailAlert, SuccessAlert } from 'src/shared/api'
import { ExpandToggleButton } from 'src/shared/ui/ExpandToggleButton'
import { EntityIdBadge } from 'src/shared/ui'
import { copyToClipboard } from 'quasar'
import { useProcessStore, type IProcessSummary } from 'src/entities/Process'
import { useFioCache } from 'src/shared/lib/account/useFioCache'
import { ProcessDetailCard } from 'src/widgets/Process/ProcessDetailCard'
import {
useProcessStore,
type IProcessSummary,
type IProcessView,
type IProcessDocument,
} from 'src/entities/Process'
import {
useLedger2Store,
type ILedger2Operation,
type ILedger2Posting,
} from 'src/entities/Ledger2'
import { useAccountStore } from 'src/entities/Account'
import type { IDocumentAggregate } from 'src/entities/Document/model'
import { DocumentRow, type DocumentRowDoc } from 'src/shared/ui/domain/DocumentRow'
import { DocumentViewerDialog } from 'src/shared/ui/domain/DocumentViewerDialog'
import { getShortNameFromCertificate } from 'src/shared/lib/utils/getNameFromCertificate'
import { formatAsset2Digits } from 'src/shared/lib/utils'
import { AccountIdCell } from '../../../shared/ui'
processChipBg,
processChipText,
processTypeLabel,
} from 'src/shared/lib/ledger2'
import { Ledger2 } from 'cooptypes'
import { Zeus } from '@coopenomics/sdk'
const { info } = useSystemStore()
const { isMobile } = useWindowSize()
const route = useRoute()
const router = useRouter()
const processStore = useProcessStore()
const ledger2Store = useLedger2Store()
const accountStore = useAccountStore()
const { fioCache, kindCache, enrichFio } = useFioCache()
// Просмотр документа во всплывающем окне (агрегат уже загружен в getProcess).
const viewerOpen = ref(false)
const viewerDoc = ref<IDocumentAggregate | null>(null)
const viewerTitle = ref('')
// Человекочитаемое имя субъекта процесса (пайщик/КУ/кооператив) резолв с бэка.
function subjectName(username: string | null | undefined): string {
const u = username ?? ''
return fioCache.value.get(u) || u || ''
}
// Субъект кооперативный участок (показываем метку, чтобы не путать с пайщиком).
function isBranch(username: string | null | undefined): boolean {
return kindCache.value.get(username ?? '') === Zeus.AccountKind.branch
}
const loading = ref(false)
const items = ref<IProcessSummary[]>([])
const expanded = ref(new Map<string, boolean>())
const fioCache = ref(new Map<string, string>())
const detailLoaded = ref(new Set<string>())
const processDetails = ref(new Map<string, IProcessView>())
const procOpsMap = ref(new Map<string, ILedger2Operation[]>())
const procPostingsMap = ref(new Map<string, ILedger2Posting[]>())
const pagination = ref({ page: 1, rowsPerPage: 50, rowsNumber: 0 })
@@ -302,53 +207,6 @@ const processTypeOptions = computed(() =>
})),
)
function processTypeLabel(type: string | null | undefined): string {
if (!type) return '—'
return Ledger2.getProcessHumanName(type) ?? type
}
// Человекочитаемое название операции (apply). Корректировки walmove/revert
// не несут operation_code берём название по action через тот же реестр.
function opLabel(row: ILedger2Operation): string {
if (row.operationCode) return Ledger2.getOperationHumanName(row.operationCode) ?? row.operationCode
if (row.action === 'walmove') return Ledger2.getOperationHumanName('o.adj.walmove') ?? 'Перевод между кошельками'
if (row.action === 'revert') return Ledger2.getOperationHumanName('o.adj.rev') ?? 'Откат операции'
return '—'
}
interface ProcessColorEntry {
accent: string
chipBg: string
chipText: string
}
const PROCESS_COLORS: Record<string, ProcessColorEntry> = {
reg: { accent: '#1976d2', chipBg: 'blue-1', chipText: 'blue-9' },
wal: { accent: '#00796b', chipBg: 'teal-1', chipText: 'teal-9' },
cap: { accent: '#5e35b1', chipBg: 'deep-purple-1', chipText: 'deep-purple-9' },
mkt: { accent: '#ef6c00', chipBg: 'orange-1', chipText: 'orange-9' },
sov: { accent: '#5d4037', chipBg: 'brown-1', chipText: 'brown-9' },
mig: { accent: '#616161', chipBg: 'grey-3', chipText: 'grey-9' },
adj: { accent: '#ef6c00', chipBg: 'amber-2', chipText: 'amber-10' },
}
const PROCESS_COLOR_DEFAULT: ProcessColorEntry = {
accent: '#9e9e9e', chipBg: 'grey-3', chipText: 'grey-9',
}
function processColorEntry(type: string | null | undefined): ProcessColorEntry {
if (!type) return PROCESS_COLOR_DEFAULT
const parts = type.split('.')
const contract = parts.length >= 3 ? parts[1] : parts[0]
return PROCESS_COLORS[contract ?? ''] ?? PROCESS_COLOR_DEFAULT
}
function processAccentColor(type: string | null | undefined): string {
return processColorEntry(type).accent
}
function processChipBg(type: string | null | undefined): string {
return processColorEntry(type).chipBg
}
function processChipText(type: string | null | undefined): string {
return processColorEntry(type).chipText
}
function shortHash(hash: string | null | undefined): string {
if (!hash) return '—'
return hash.slice(0, 8)
@@ -371,16 +229,6 @@ function formatDate(d: string | Date | null | undefined): string {
})
}
function formatAmount(qty: string | null | undefined): string {
if (!qty) return '—'
return formatAsset2Digits(qty)
}
// id счёта хранится ×1000 к UI-коду приводим целочисленным делением (51000 51).
function accCode(id: number | null | undefined): number | null {
return id != null ? Math.round(id / 1000) : null
}
const hasAnyFilter = computed(
() => !!filters.processType || !!filters.username || !!filters.processHash,
)
@@ -394,88 +242,6 @@ const columns = [
{ name: 'lastSeenAt', align: 'left' as const, label: 'Последнее событие', field: 'lastSeenAt' },
]
const opColumns = [
{ name: 'date', align: 'left' as const, label: 'Дата', field: 'createdAt' },
{ name: 'op', align: 'left' as const, label: '№ операции', field: 'globalSequence' },
{ name: 'label', align: 'left' as const, label: 'Операция', field: 'operationCode' },
{ name: 'amount', align: 'right' as const, label: 'Сумма', field: 'quantity' },
]
const pstColumns = [
{ name: 'date', align: 'left' as const, label: 'Дата', field: 'createdAt' },
{ name: 'posting', align: 'left' as const, label: '№ проводки', field: 'debitGlobalSequence' },
{ name: 'debit', align: 'center' as const, label: 'Дебет', field: 'debitAccountId' },
{ name: 'credit', align: 'center' as const, label: 'Кредит', field: 'creditAccountId' },
{ name: 'amount', align: 'right' as const, label: 'Сумма', field: 'quantity' },
]
// =====================================================================
// Документы процесса
// =====================================================================
function processDocs(hash: string): IProcessDocument[] {
return processDetails.value.get(hash)?.documents ?? []
}
// document.hash хеш ПОДПИСАННОГО документа (колонка hash в реестре), по нему
// открывается страница документа. rawDocument.hash = doc_hash не подходит.
function docHash(d: IProcessDocument): string {
const doc = d.document as any
return doc?.hash || d.hash || ''
}
function toDocRow(d: IProcessDocument): DocumentRowDoc {
const doc = d.document as any
const raw = d.raw as any
const signers: string[] = Array.isArray(doc?.signatures)
? doc.signatures
.map((s: any) => getShortNameFromCertificate(s?.signer_certificate))
.filter((x: string): x is string => !!x)
: []
return {
type: 'pdf',
title: doc?.meta?.title || raw?.full_title || 'Документ',
date: doc?.meta?.created_at || undefined,
author: signers.length ? signers.join(', ') : undefined,
}
}
// Документ уже загружен целиком в getProcess (document + raw). Открываем его
// во всплывающем окне через BaseDocument без догрузки и переадресации.
// IDocumentAggregate ждёт поле rawDocument, в процессном документе оно `raw`.
function openDoc(d: IProcessDocument) {
const doc = d.document as any
viewerDoc.value = { document: d.document, rawDocument: d.raw } as unknown as IDocumentAggregate
viewerTitle.value = doc?.meta?.title || (d.raw as any)?.full_title || 'Документ'
viewerOpen.value = true
}
// =====================================================================
// Операции / проводки процесса
// =====================================================================
function processOps(hash: string): ILedger2Operation[] {
return procOpsMap.value.get(hash) ?? []
}
function processPostings(hash: string): ILedger2Posting[] {
return procPostingsMap.value.get(hash) ?? []
}
function goToOperation(seq: string) {
router.push({
name: 'reports-operations',
params: { coopname: info.coopname },
query: { operation_id: seq },
})
}
function goToPosting(id: string) {
router.push({
name: 'reports-postings',
params: { coopname: info.coopname },
query: { posting_id: id },
})
}
// =====================================================================
// Фильтры
// =====================================================================
@@ -582,71 +348,10 @@ function onRequest(props: { pagination: { page: number; rowsPerPage: number; row
load()
}
async function toggleExpand(processHash: string) {
const wasOpen = expanded.value.get(processHash)
expanded.value.set(processHash, !wasOpen)
if (!wasOpen && !detailLoaded.value.has(processHash)) {
await loadProcessDetail(processHash)
}
}
async function loadProcessDetail(processHash: string) {
try {
// Документы + операции + проводки одного процесса грузим параллельно.
const [view, history, postings] = await Promise.all([
processStore.loadProcess({ coopname: info.coopname, hash: processHash }),
ledger2Store.loadHistory({
coopname: info.coopname,
processHash,
actionNames: ['apply', 'walmove', 'revert'],
limit: 100,
sortOrder: 'ASC',
}),
ledger2Store.loadPostings({
coopname: info.coopname,
processHash,
limit: 100,
sortOrder: 'ASC',
}),
])
if (view) processDetails.value.set(processHash, view)
procOpsMap.value.set(processHash, history?.items ?? [])
procPostingsMap.value.set(processHash, postings?.items ?? [])
enrichFio((history?.items ?? []).map((o) => o.username))
} catch (e) {
FailAlert(e)
} finally {
detailLoaded.value.add(processHash)
}
}
async function enrichFio(rawUsernames: (string | null | undefined)[]) {
const usernames = [
...new Set(rawUsernames.filter((u): u is string => !!u && !fioCache.value.has(u))),
]
if (!usernames.length) return
await Promise.allSettled(
usernames.map(async (username) => {
try {
const acc = await accountStore.getAccount(username)
const pd = acc?.private_account
if (!pd) return
let fio = ''
if (pd.type === 'individual' && pd.individual_data) {
const d = pd.individual_data
fio = [d.last_name, d.first_name, d.middle_name].filter(Boolean).join(' ')
} else if (pd.type === 'organization' && pd.organization_data) {
fio = (pd.organization_data as any).short_name ?? username
} else if (pd.type === 'entrepreneur' && pd.entrepreneur_data) {
const d = pd.entrepreneur_data as any
fio = [d.last_name, d.first_name, d.middle_name].filter(Boolean).join(' ')
}
if (fio) fioCache.value.set(username, fio)
} catch {
// молча username остаётся как fallback
}
}),
)
// Детализация процесса грузится внутри ProcessDetailCard при разворачивании
// строки (виджет монтируется по v-if) странице достаточно переключить флаг.
function toggleExpand(processHash: string) {
expanded.value.set(processHash, !expanded.value.get(processHash))
}
onMounted(async () => {
@@ -673,8 +378,10 @@ onMounted(async () => {
font-family: 'JetBrains Mono', 'Courier New', monospace;
letter-spacing: 0.03em;
}
.op-header {
border-left: 4px solid #9e9e9e;
padding: 4px 0 4px 12px;
/* Реестр обзорный список: гасим подсветку строки при наведении (canon-правило
.q-table tbody tr:hover) и в основной таблице, и во вложенных таблицах
детализации (операции/проводки) мигание при наведении мешает. */
.page-shell :deep(.q-table tbody tr:hover) {
background: transparent;
}
</style>
@@ -11,6 +11,7 @@ import {
IDepositData,
IWithdrawData,
IProgramWalletData,
IUserWalletData,
ICoopProgramSummary,
ExtendedProgramWalletData,
IGetPaymentMethods,
@@ -131,6 +132,21 @@ async function loadUserProgramWalletsData(
}
/**
* Сырые кошельки пайщика «как есть» (`getUserWallets`) каждый кошелёк
* отдельной строкой по своему `wallet_name`, без сворачивания паевого и
* членского. Источник реестра кошельков на столе пайщика (карточки
* устанавливаются расширениями по `wallet_name`).
*/
async function loadUserWalletsData(
params: ILoadUserProgramWallets
): Promise<IUserWalletData[]> {
const response = await client.Query(Queries.Wallet.GetUserWallets.query, {
variables: { coopname: params.coopname, username: params.username },
});
return (response[Queries.Wallet.GetUserWallets.name] ?? []) as IUserWalletData[];
}
async function loadMethods(params: IGetPaymentMethods): Promise<IPaymentMethodData[]> {
const result = await client.Query(Queries.PaymentMethods.GetPaymentMethods.query, {
variables: {
@@ -182,6 +198,7 @@ export const api = {
loadUserDepositsData,
loadUserWithdrawsData,
loadUserProgramWalletsData,
loadUserWalletsData,
loadMethods,
loadUserAgreements
};
@@ -5,6 +5,7 @@ import {
IDepositData,
IWithdrawData,
ExtendedProgramWalletData,
IUserWalletData,
IPaymentMethodData,
IUserAgreement,
} from './types';
@@ -27,6 +28,13 @@ const WALLET_AGREEMENT_TYPE = 'wallet';
interface IWalletStore {
/* доменный интерфейс кошелька пользователя */
program_wallets: Ref<ExtendedProgramWalletData[]>;
/**
* Сырые кошельки пайщика «как есть» (по `wallet_name`, без сворачивания
* паевого и членского) источник реестра карточек кошельков на столе
* пайщика. В отличие от `program_wallets` (срез по программе), даёт каждый
* кошелёк отдельной строкой.
*/
user_wallets: Ref<IUserWalletData[]>;
deposits: Ref<IDepositData[]>;
withdraws: Ref<IWithdrawData[]>;
methods: Ref<IPaymentMethodData[]>;
@@ -59,6 +67,7 @@ const DEFAULT_OPTIMISTIC_TTL_MS = 8000;
export const useWalletStore = defineStore(namespace, (): IWalletStore => {
const deposits = ref<IDepositData[]>([]);
const withdraws = ref<IWithdrawData[]>([]);
const user_wallets = ref<IUserWalletData[]>([]);
const _program_wallets_base = ref<ExtendedProgramWalletData[]>([]);
const methods = ref<IPaymentMethodData[]>([]);
const agreements = ref<IUserAgreement[]>([]);
@@ -122,6 +131,7 @@ export const useWalletStore = defineStore(namespace, (): IWalletStore => {
api.loadUserProgramWalletsData(params),
api.loadMethods(params),
api.loadUserAgreements(params.coopname, params.username),
api.loadUserWalletsData(params),
]);
deposits.value = data[0] ?? [];
@@ -129,6 +139,7 @@ export const useWalletStore = defineStore(namespace, (): IWalletStore => {
_program_wallets_base.value = data[2] ?? [];
methods.value = data[3] ?? [];
agreements.value = data[4] ?? [];
user_wallets.value = data[5] ?? [];
// Серверная правда выигрывает — все наложенные оптимистичные патчи
// сбрасываются. Если расхождение есть, оно будет видно сразу (а не
// как «откат через TTL» через несколько секунд).
@@ -140,6 +151,7 @@ export const useWalletStore = defineStore(namespace, (): IWalletStore => {
return {
program_wallets: program_wallets as unknown as Ref<ExtendedProgramWalletData[]>,
user_wallets,
deposits,
withdraws,
methods,
@@ -1,4 +1,4 @@
import type { Mutations, Zeus } from '@coopenomics/sdk';
import type { Mutations, Queries, Zeus } from '@coopenomics/sdk';
import { SovietContract, GatewayContract, Cooperative } from 'cooptypes';
/**
@@ -16,6 +16,14 @@ export type ICreateInitialPayment =
export type IProgramWalletData =
SovietContract.Tables.ProgramWallets.IProgramWallet;
/**
* Сырой кошелёк пайщика «как есть» (`getUserWallets`) одна строка
* `ledger2::userwallets` по своему `wallet_name`, без сворачивания
* паевого и членского. Источник реестра кошельков на столе пайщика.
*/
export type IUserWalletData =
Queries.Wallet.GetUserWallets.IOutput['getUserWallets'][number];
/**
* Минимальная сводка программы кооператива, нужная UI: id + читаемое title
* (из cooptypes/programs.ts) + тип программы. Полная chain-запись больше
@@ -0,0 +1,40 @@
import { Queries } from '@coopenomics/sdk';
import { client } from 'src/shared/api/client';
import type { AdminOfferPage, AdminOfferStatusView } from '../types';
export interface ListAllOffersVariables {
statuses?: AdminOfferStatusView[];
supplier_account?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
/**
* Реестр всех предложений кооператива любого статуса (стол администратора,
* Offer:read:all). Фильтры (статус/поставщик) и пагинация серверные,
* передаются единым input'ом.
*/
export async function fetchAllOffers(
variables: ListAllOffersVariables = {},
): Promise<AdminOfferPage> {
const { page, limit, sortBy, sortOrder, statuses, supplier_account } = variables;
const { [Queries.Marketplace.ListAllOffers.name]: result } = await client.Query(
Queries.Marketplace.ListAllOffers.query,
{
variables: {
input: {
statuses,
supplier_account,
page: page ?? 1,
limit: limit ?? 50,
sortBy: sortBy ?? 'created_at',
sortOrder: sortOrder ?? 'DESC',
},
},
},
);
// Zeus отдаёт DateTime как unknown; сужаем скалярную дату до строки во view-типе.
return result as AdminOfferPage;
}
@@ -0,0 +1,2 @@
export * from './ui';
export * from './types';
@@ -0,0 +1,26 @@
/**
* Реестр всех предложений кооператива (стол администратора).
* Источник истины Zeus-вывод операции marketplaceListAllOffers из @coopenomics/sdk.
*/
import { Queries } from '@coopenomics/sdk';
type _RawOfferPage =
Queries.Marketplace.ListAllOffers.IOutput['marketplaceListAllOffers'];
type _RawOffer = _RawOfferPage['items'][number];
/**
* Zeus маппит DateTime в `unknown`. Структуру/enum'ы оставляем из Zeus, но
* скалярную дату создания переопределяем на строку для форматирования в UI.
*/
export type AdminOfferView = Omit<_RawOffer, 'created_at'> & {
created_at: string;
};
/** Страница предложений: обёртка из Zeus, но items со строгой датой создания. */
export type AdminOfferPage = Omit<_RawOfferPage, 'items'> & {
items: AdminOfferView[];
};
/** Доменный статус предложения как строковый литерал (совпадает с Zeus-enum'ом). */
export type AdminOfferStatusView = `${AdminOfferView['status']}`;
@@ -0,0 +1,272 @@
<script lang="ts" setup>
/**
* Реестр всех предложений кооператива (стол администратора).
* Backend: marketplaceListAllOffers (Offer:read:all) все предложения любого
* статуса (опубликованные/снятые/отклонённые/на модерации), всех поставщиков.
* Отдельно от «Модерации» (там только то, что прямо сейчас ждёт решения).
* Клик по строке открывает readonly-карточку предложения; на эти же карточки
* ведёт переход «Открыть предложение» из реестра заказов.
*/
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { FailAlert } from 'src/shared/api';
import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits';
import { marketplaceUnitShort } from 'src/shared/lib/consts/marketplace-units';
import { useSystemStore } from 'src/entities/System/model';
import { useFioCache } from 'src/shared/lib/account/useFioCache';
import { BaseBadge, BaseButton, EmptyState } from 'src/shared/ui/base';
import type { BaseBadgeVariant } from 'src/shared/ui/base';
import { EntityIdBadge } from 'src/shared/ui';
import { PageHint } from 'src/shared/ui/domain';
import { fetchAllOffers } from '../api';
import type { AdminOfferView, AdminOfferStatusView } from '../types';
const { info } = useSystemStore();
const router = useRouter();
const { fioCache, enrichFio } = useFioCache();
const items = ref<AdminOfferView[]>([]);
const loading = ref(false);
const statusFilter = ref<AdminOfferStatusView[]>([]);
const pagination = ref({ page: 1, rowsPerPage: 50, rowsNumber: 0 });
const OFFER_STATUS: Record<string, { label: string; variant: BaseBadgeVariant }> = {
PENDING_MODERATION: { label: 'На модерации', variant: 'warn' },
ACTIVE: { label: 'Опубликовано', variant: 'pos' },
REJECTED: { label: 'Отклонено', variant: 'neg' },
WITHDRAWN: { label: 'Снято с публикации', variant: 'neutral' },
};
const ALL_STATUSES = Object.keys(OFFER_STATUS) as AdminOfferStatusView[];
function statusLabel(s: string): string {
return OFFER_STATUS[s]?.label ?? s;
}
function statusVariant(s: string): BaseBadgeVariant {
return OFFER_STATUS[s]?.variant ?? 'neutral';
}
const columns = [
{ name: 'status', align: 'left' as const, label: 'Статус', field: 'status' },
{ name: 'offer', align: 'left' as const, label: 'Предложение', field: 'id' },
{ name: 'product', align: 'left' as const, label: 'Товар', field: 'product_name' },
{ name: 'supplier', align: 'left' as const, label: 'Поставщик', field: 'supplier_account' },
{ name: 'price', align: 'right' as const, label: 'Цена', field: 'price_per_unit' },
{ name: 'available', align: 'right' as const, label: 'Доступно', field: 'quantity_available' },
{ name: 'warranty', align: 'right' as const, label: 'Гарантия', field: 'warranty_days' },
{ name: 'created', align: 'left' as const, label: 'Создано', field: 'created_at' },
{ name: 'actions', align: 'right' as const, label: '', field: 'id' },
];
function isStatusActive(s: AdminOfferStatusView): boolean {
return statusFilter.value.includes(s);
}
function toggleStatus(s: AdminOfferStatusView): void {
statusFilter.value = isStatusActive(s)
? statusFilter.value.filter((x) => x !== s)
: [...statusFilter.value, s];
void reload();
}
function resetFilters(): void {
if (!statusFilter.value.length) return;
statusFilter.value = [];
void reload();
}
function shortId(id: string | null | undefined): string {
return id ? id.slice(0, 8) : '—';
}
function unitLabel(o: AdminOfferView): string {
return marketplaceUnitShort(o.unit_of_measure);
}
function formatPrice(v: string | null | undefined): string {
return v ? formatAsset2Digits(String(v)) : '—';
}
function formatWarranty(days: number | null | undefined): string {
return days && days > 0 ? `${days} дн.` : 'Без гарантии';
}
function supplierTitle(o: AdminOfferView): string {
return fioCache.value.get(o.supplier_account) || o.supplier_account || '—';
}
function formatDate(d: unknown): string {
if (d === null || d === undefined) return '—';
const parsed = new Date(String(d));
return Number.isNaN(parsed.getTime())
? String(d)
: parsed.toLocaleString('ru-RU', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
});
}
function goToOffer(o: AdminOfferView): void {
if (!o.id) return;
void router.push({
name: 'marketplace-admin-offer-detail',
params: { coopname: info.coopname, offerId: o.id },
query: { from: 'offers' },
});
}
let lastRequestId = 0;
async function reload(): Promise<void> {
pagination.value.page = 1;
await load();
}
async function load(): Promise<void> {
const myId = ++lastRequestId;
loading.value = true;
try {
const resp = await fetchAllOffers({
statuses: statusFilter.value.length ? statusFilter.value : undefined,
page: pagination.value.page,
limit: pagination.value.rowsPerPage,
sortOrder: 'DESC',
});
if (myId !== lastRequestId) return;
items.value = resp.items ?? [];
pagination.value.rowsNumber = resp.totalCount ?? 0;
void enrichFio(items.value.map((o) => o.supplier_account));
} catch (e) {
if (myId === lastRequestId) FailAlert(e, 'Не удалось загрузить реестр предложений');
} finally {
if (myId === lastRequestId) loading.value = false;
}
}
function onRequest(props: { pagination: { page: number; rowsPerPage: number; rowsNumber?: number } }): void {
pagination.value = {
page: props.pagination.page,
rowsPerPage: props.pagination.rowsPerPage,
rowsNumber: props.pagination.rowsNumber ?? pagination.value.rowsNumber,
};
void load();
}
onMounted(() => {
void load();
});
</script>
<template lang="pug">
q-page.admin-offers(role="region", aria-label="Реестр предложений кооператива")
PageHint(storage-key="mp:admin-offers:banner-dismissed")
| Все предложения поставщиков кооператива любого статуса опубликованные, снятые, отклонённые и ждущие модерации. Нажмите на предложение, чтобы открыть его карточку. Модерация ждущих решения на отдельной странице.
.admin-offers__chips(role="group", aria-label="Фильтр по статусу")
.chip(
v-for="s in ALL_STATUSES",
:key="s",
:class="isStatusActive(s) ? 'chip--accent' : 'chip--neutral'",
role="button",
tabindex="0",
@click="toggleStatus(s)",
@keydown.enter="toggleStatus(s)"
) {{ statusLabel(s) }}
.chip.chip--reset(
v-if="statusFilter.length",
role="button",
tabindex="0",
@click="resetFilters",
@keydown.enter="resetFilters"
)
q-icon(name="close", size="14px")
| Сбросить
q-card.q-mt-md(flat)
q-table.full-height(
flat,
:rows="items",
:columns="columns",
row-key="id",
:loading="loading",
:pagination="pagination",
:rows-per-page-options="[25, 50, 100, 200]",
no-data-label="Предложения не найдены",
@request="onRequest",
@row-click="(_evt, row) => goToOffer(row)"
)
template(#body-cell-status="props")
q-td(:props="props")
BaseBadge(:variant="statusVariant(props.row.status)") {{ statusLabel(props.row.status) }}
template(#body-cell-offer="props")
q-td(:props="props")
EntityIdBadge(:rawId="shortId(props.row.id)", copy-on-click)
template(#body-cell-product="props")
q-td(:props="props") {{ props.row.product_name || 'Товар по предложению' }}
template(#body-cell-supplier="props")
q-td(:props="props") {{ supplierTitle(props.row) }}
template(#body-cell-price="props")
q-td.text-right.font-monospace(:props="props") {{ formatPrice(props.row.price_per_unit) }}
template(#body-cell-available="props")
q-td.text-right(:props="props") {{ props.row.unlimited_flag ? '∞' : props.row.quantity_available }} {{ unitLabel(props.row) }}
template(#body-cell-warranty="props")
q-td.text-right(:props="props") {{ formatWarranty(props.row.warranty_days) }}
template(#body-cell-created="props")
q-td(:props="props") {{ formatDate(props.row.created_at) }}
template(#body-cell-actions="props")
q-td.text-right(:props="props", @click.stop)
BaseButton(
variant="secondary",
size="sm",
@click="goToOffer(props.row)"
)
template(#icon-left)
q-icon(name="open_in_new", size="16px")
| Открыть
template(#no-data)
.admin-offers__nodata
EmptyState(
title="Предложений нет",
body="Предложений по выбранным фильтрам не найдено."
)
template(#icon)
q-icon(name="storefront", size="48px")
</template>
<style scoped lang="scss">
.admin-offers {
padding: var(--p-6, 24px);
display: flex;
flex-direction: column;
gap: var(--p-4, 16px);
&__nodata {
width: 100%;
display: flex;
justify-content: center;
}
&__chips {
display: flex;
flex-wrap: wrap;
gap: var(--p-2, 8px);
.chip {
cursor: pointer;
user-select: none;
height: 28px;
padding: 0 12px;
display: inline-flex;
align-items: center;
gap: 4px;
}
}
// Строки кликабельны (ведут на карточку) курсор-указатель как аффорданс.
:deep(.q-table tbody tr) {
cursor: pointer;
}
}
.font-monospace {
font-family: 'JetBrains Mono', 'Courier New', monospace;
letter-spacing: 0.03em;
}
@media (max-width: 768px) {
.admin-offers {
padding: var(--p-4, 16px);
}
}
</style>
@@ -0,0 +1 @@
export { default as AdminOffersPage } from './AdminOffersPage.vue';
@@ -0,0 +1,47 @@
import { Queries } from '@coopenomics/sdk';
import { client } from 'src/shared/api/client';
import type { AdminOrderPage, AdminOrderStatusView } from '../types';
export interface ListAllOrdersVariables {
statuses?: AdminOrderStatusView[];
orderer_account?: string;
supplier_account?: string;
offer_id?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
/**
* Реестр всех заказов кооператива со статусами (стол администратора,
* Order:read:all). Фильтры (статус/заказчик/поставщик/предложение) и
* пагинация серверные.
*/
export async function fetchAllOrders(
variables: ListAllOrdersVariables = {},
): Promise<AdminOrderPage> {
const { page, limit, sortBy, sortOrder, statuses, orderer_account, supplier_account, offer_id } =
variables;
const { [Queries.Marketplace.ListAllOrders.name]: result } = await client.Query(
Queries.Marketplace.ListAllOrders.query,
{
variables: {
input: {
orderer_account,
supplier_account,
offer_id,
statuses,
},
options: {
page: page ?? 1,
limit: limit ?? 50,
sortBy: sortBy ?? 'updated_at',
sortOrder: sortOrder ?? 'DESC',
},
},
},
);
// Zeus отдаёт DateTime как unknown; сужаем скалярную дату до строки во view-типе.
return result as AdminOrderPage;
}
@@ -0,0 +1,2 @@
export * from './ui';
export * from './types';
@@ -0,0 +1,26 @@
/**
* Реестр всех заказов кооператива (стол администратора).
* Источник истины Zeus-вывод операции marketplaceListAllOrders из @coopenomics/sdk.
*/
import { Queries } from '@coopenomics/sdk';
type _RawOrderPage =
Queries.Marketplace.ListAllOrders.IOutput['marketplaceListAllOrders'];
type _RawOrder = _RawOrderPage['items'][number];
/**
* Zeus маппит DateTime в `unknown`. Структуру/enum'ы оставляем из Zeus, но
* скалярную дату создания переопределяем на строку для форматирования в UI.
*/
export type AdminOrderView = Omit<_RawOrder, 'created_at'> & {
created_at: string;
};
/** Страница заказов: обёртка из Zeus, но items со строгой датой создания. */
export type AdminOrderPage = Omit<_RawOrderPage, 'items'> & {
items: AdminOrderView[];
};
/** Доменный статус заказа как строковый литерал (совпадает с Zeus-enum'ом). */
export type AdminOrderStatusView = `${AdminOrderView['status']}`;
@@ -0,0 +1,353 @@
<script lang="ts" setup>
/**
* Реестр всех заказов кооператива со статусами (стол администратора).
* Backend: marketplaceListAllOrders (Order:read:all). По клику строка
* разворачивается в состояние заказа (таймлайн статусов) и детализацию
* процесса p.mkt.supply (документы + операции + проводки по order_hash)
* через общий виджет ProcessDetailCard. Ссылок на стол бухгалтера нет:
* операций/проводок здесь копируются, а не ведут в чужие реестры.
*/
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { FailAlert } from 'src/shared/api';
import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits';
import { marketplaceUnitShort } from 'src/shared/lib/consts/marketplace-units';
import { useSystemStore } from 'src/entities/System/model';
import { BaseBadge, BaseButton, EmptyState } from 'src/shared/ui/base';
import { EntityIdBadge } from 'src/shared/ui';
import { ExpandToggleButton } from 'src/shared/ui/ExpandToggleButton';
import { PageHint, ActivityTimeline, type ActivityEvent } from 'src/shared/ui/domain';
import { ProcessDetailCard } from 'src/widgets/Process/ProcessDetailCard';
import { orderStatusDisplay } from 'src/widgets/Marketplace/OrderCard';
import { fetchAllOrders } from '../api';
import type { AdminOrderView, AdminOrderStatusView } from '../types';
// Процесс marketplace-заказа в реестре процессов order_hash и есть его хэш.
const SUPPLY_PROCESS_TYPE = 'p.mkt.supply';
const { info } = useSystemStore();
const router = useRouter();
const items = ref<AdminOrderView[]>([]);
const loading = ref(false);
const expanded = ref(new Map<string, boolean>());
const statusFilter = ref<AdminOrderStatusView[]>([]);
const pagination = ref({ page: 1, rowsPerPage: 50, rowsNumber: 0 });
// Полный набор статусов для чипов-фильтра (порядок по жизненному циклу).
// orderStatusDisplay деградирует мягко, если enum расширится.
const ALL_STATUSES: AdminOrderStatusView[] = [
'ACTIVE',
'ACCEPTED_PENDING_SUPPLIER',
'ACCEPTED_PENDING_SUPPLIER_INDIVIDUAL',
'ACCEPTED',
'SUPPLY_PREPARED',
'ACCEPTED_TO_COOP',
'READY_TO_RECEIVE',
'RECEIVED',
'RETURNED',
'CANCELLED_BY_ORDERER',
'CANCELLED_BY_SUPPLIER',
];
const columns = [
{ name: 'expand', align: 'left' as const, label: '', field: 'expand', sortable: false },
{ name: 'status', align: 'left' as const, label: 'Статус', field: 'status' },
{ name: 'order', align: 'left' as const, label: 'Заказ', field: 'id' },
{ name: 'product', align: 'left' as const, label: 'Товар', field: 'product_name' },
{ name: 'orderer', align: 'left' as const, label: 'Заказчик', field: 'orderer_name' },
{ name: 'supplier', align: 'left' as const, label: 'Поставщик', field: 'supplier_name' },
{ name: 'quantity', align: 'right' as const, label: 'Кол-во', field: 'quantity' },
{ name: 'total', align: 'right' as const, label: 'Сумма', field: 'total_cost' },
{ name: 'created', align: 'left' as const, label: 'Создан', field: 'created_at' },
];
function statusLabel(s: string): string {
return orderStatusDisplay(s).label;
}
function statusVariant(s: string) {
return orderStatusDisplay(s).variant;
}
function isStatusActive(s: AdminOrderStatusView): boolean {
return statusFilter.value.includes(s);
}
function toggleStatus(s: AdminOrderStatusView): void {
statusFilter.value = isStatusActive(s)
? statusFilter.value.filter((x) => x !== s)
: [...statusFilter.value, s];
reload();
}
function resetFilters(): void {
if (!statusFilter.value.length) return;
statusFilter.value = [];
reload();
}
function shortId(id: string | null | undefined): string {
return id ? id.slice(0, 8) : '—';
}
function unitLabel(o: AdminOrderView): string {
return marketplaceUnitShort(o.unit_of_measure);
}
function formatTotal(v: string | null | undefined): string {
return v ? formatAsset2Digits(String(v)) : '—';
}
function formatDate(d: unknown): string {
if (d === null || d === undefined) return '—';
const parsed = new Date(String(d));
return Number.isNaN(parsed.getTime())
? String(d)
: parsed.toLocaleString('ru-RU', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
});
}
function ordererTitle(o: AdminOrderView): string {
return o.orderer_name || o.orderer_account || '—';
}
function supplierTitle(o: AdminOrderView): string {
return o.supplier_name || o.supplier_account || '—';
}
// Состояние заказа канонической лентой ActivityTimeline: показываем только
// НАСТУПИВШИЕ вехи с датами (текущий статус виден бейджем в строке). Причина
// (last_status_reason) это описание соответствующего события (отмены/отказа),
// а не отдельная неподписанная строка «Причина» (непонятно «причина чего»).
function orderEvents(o: AdminOrderView): ActivityEvent[] {
const ev: ActivityEvent[] = [];
const placed = o.blocked_at ?? o.created_at;
if (placed) {
ev.push({ id: 'placed', type: 'create', icon: 'shopping_cart', title: 'Заказ размещён', date: formatDate(placed) });
}
if (o.accepted_at) {
ev.push({ id: 'accepted', type: 'update', icon: 'inventory_2', title: 'Поставщик принял заказ', actor: supplierTitle(o), date: formatDate(o.accepted_at) });
}
if (o.chairman_signed_at) {
ev.push({ id: 'chairman', type: 'sign', title: 'Принят кооперативом (АПП приёмки)', date: formatDate(o.chairman_signed_at) });
}
const issued = o.orderer_signed_at ?? o.received_at;
if (issued) {
ev.push({ id: 'issued', type: 'sign', title: 'Получен заказчиком (АПП выдачи)', actor: ordererTitle(o), date: formatDate(issued) });
}
if (o.cancelled_at) {
ev.push({ id: 'cancelled', type: 'reject', title: statusLabel(o.status), description: o.last_status_reason || undefined, date: formatDate(o.cancelled_at) });
} else if (o.last_status_reason) {
ev.push({ id: 'reason', type: 'comment', title: 'Комментарий к статусу', description: o.last_status_reason, date: formatDate(o.updated_at) });
}
return ev;
}
// Переход на карточку предложения (имущества) на столе администратора
// readonly-карточка, без перехода в каталог/на стол заказчика.
function goToOffer(o: AdminOrderView): void {
if (!o.offer_id) return;
void router.push({
name: 'marketplace-admin-offer-detail',
params: { coopname: info.coopname, offerId: o.offer_id },
query: { from: 'orders' },
});
}
function toggleExpand(id: string): void {
expanded.value.set(id, !expanded.value.get(id));
}
let lastRequestId = 0;
async function reload(): Promise<void> {
pagination.value.page = 1;
await load();
}
async function load(): Promise<void> {
const myId = ++lastRequestId;
loading.value = true;
try {
const resp = await fetchAllOrders({
statuses: statusFilter.value.length ? statusFilter.value : undefined,
page: pagination.value.page,
limit: pagination.value.rowsPerPage,
sortOrder: 'DESC',
});
if (myId !== lastRequestId) return;
items.value = resp.items ?? [];
pagination.value.rowsNumber = resp.totalCount ?? 0;
} catch (e) {
if (myId === lastRequestId) FailAlert(e, 'Не удалось загрузить реестр заказов');
} finally {
if (myId === lastRequestId) loading.value = false;
}
}
function onRequest(props: { pagination: { page: number; rowsPerPage: number; rowsNumber?: number } }): void {
pagination.value = {
page: props.pagination.page,
rowsPerPage: props.pagination.rowsPerPage,
rowsNumber: props.pagination.rowsNumber ?? pagination.value.rowsNumber,
};
void load();
}
onMounted(() => {
void load();
});
</script>
<template lang="pug">
q-page.admin-orders(role="region", aria-label="Реестр заказов кооператива")
PageHint(storage-key="mp:admin-orders:banner-dismissed")
| Реестр всех заказов кооператива с текущими статусами. Раскройте заказ, чтобы увидеть его состояние (таймлайн), документы, операции и проводки процесса.
.admin-orders__chips(role="group", aria-label="Фильтр по статусу")
.chip(
v-for="s in ALL_STATUSES",
:key="s",
:class="isStatusActive(s) ? 'chip--accent' : 'chip--neutral'",
role="button",
tabindex="0",
@click="toggleStatus(s)",
@keydown.enter="toggleStatus(s)"
) {{ statusLabel(s) }}
.chip.chip--reset(
v-if="statusFilter.length",
role="button",
tabindex="0",
@click="resetFilters",
@keydown.enter="resetFilters"
)
q-icon(name="close", size="14px")
| Сбросить
q-card.q-mt-md(flat)
q-table.full-height(
flat,
:rows="items",
:columns="columns",
row-key="id",
:loading="loading",
:pagination="pagination",
:rows-per-page-options="[25, 50, 100, 200]",
no-data-label="Заказы не найдены",
@request="onRequest"
)
template(#body="props")
q-tr(no-hover, :key="`ord_${props.row.id}`", :props="props")
q-td(auto-width)
ExpandToggleButton(
:expanded="expanded.get(props.row.id)",
@click="toggleExpand(props.row.id)"
)
q-td
BaseBadge(:variant="statusVariant(props.row.status)") {{ statusLabel(props.row.status) }}
q-td
EntityIdBadge(:rawId="shortId(props.row.id)", copy-on-click)
q-td
.row.items-center.no-wrap.q-gutter-xs
span {{ props.row.product_name || 'Товар по предложению' }}
q-icon.cursor-pointer.text-primary(
v-if="props.row.offer_id",
name="open_in_new",
size="16px",
@click="goToOffer(props.row)"
)
q-tooltip Открыть предложение
q-td {{ ordererTitle(props.row) }}
q-td {{ supplierTitle(props.row) }}
q-td.text-right {{ props.row.quantity }} {{ unitLabel(props.row) }}
q-td.text-right.font-monospace {{ formatTotal(props.row.total_cost) }}
q-td {{ formatDate(props.row.created_at) }}
q-tr.q-virtual-scroll--with-prev(
no-hover,
v-if="expanded.get(props.row.id)",
:key="`exp_${props.row.id}`",
:props="props"
)
q-td(colspan="100%")
.q-pa-md
//- Состояние заказа канонический таймлайн (ActivityTimeline) +
//- переход на карточку предложения (имущества).
q-card.q-mb-md(flat, bordered)
q-card-section.row.items-center.justify-between.q-pb-none
.text-subtitle2 Состояние заказа
BaseButton(
v-if="props.row.offer_id",
variant="secondary",
size="sm",
@click="goToOffer(props.row)"
)
template(#icon-left)
q-icon(name="open_in_new", size="16px")
| Открыть предложение
q-card-section.q-pt-sm
ActivityTimeline(:events="orderEvents(props.row)")
//- Документы + операции + проводки процесса заказа (общий виджет).
//- order_hash = хэш процесса p.mkt.supply. Без перехода в чужие реестры.
ProcessDetailCard(
v-if="props.row.order_hash",
:coopname="info.coopname",
:process-hash="props.row.order_hash",
:process-type="SUPPLY_PROCESS_TYPE"
)
.text-body2.text-grey-7(v-else) Хэш процесса заказа недоступен
template(#no-data)
.admin-orders__nodata
EmptyState(
title="Заказов нет",
body="Заказов по выбранным фильтрам не найдено."
)
template(#icon)
q-icon(name="receipt_long", size="48px")
</template>
<style scoped lang="scss">
.admin-orders {
padding: var(--p-6, 24px);
display: flex;
flex-direction: column;
gap: var(--p-4, 16px);
&__nodata {
width: 100%;
display: flex;
justify-content: center;
}
&__chips {
display: flex;
flex-wrap: wrap;
gap: var(--p-2, 8px);
.chip {
cursor: pointer;
user-select: none;
height: 28px;
padding: 0 12px;
display: inline-flex;
align-items: center;
gap: 4px;
}
}
// Реестр обзорный список, не интерактивные строки: гасим подсветку строки
// при наведении (canon-правило .q-table tbody tr:hover) и в основной таблице,
// и во вложенных таблицах детализации (операции/проводки) мигание мешает.
:deep(.q-table tbody tr:hover) {
background: transparent;
}
}
.font-monospace {
font-family: 'JetBrains Mono', 'Courier New', monospace;
letter-spacing: 0.03em;
}
@media (max-width: 768px) {
.admin-orders {
padding: var(--p-4, 16px);
}
}
</style>
@@ -0,0 +1 @@
export { default as AdminOrdersPage } from './AdminOrdersPage.vue';
@@ -24,6 +24,17 @@ export type ISubmitWriteoffDraftInput =
export type IWriteoffStatementSignablePayloadInput =
Queries.Marketplace.WriteoffStatementSignablePayload.IInput['data'];
export type MarketplaceWriteoffCandidateView =
Queries.Marketplace.ListWriteoffCandidates.IOutput['marketplaceListWriteoffCandidates'][number];
export async function listWriteoffCandidates(): Promise<MarketplaceWriteoffCandidateView[]> {
const { [Queries.Marketplace.ListWriteoffCandidates.name]: result } = await client.Query(
Queries.Marketplace.ListWriteoffCandidates.query,
{},
);
return result;
}
export async function getOpenWriteoffDraft(): Promise<MarketplaceWriteoffProposalView | null> {
const { [Queries.Marketplace.OpenWriteoffDraft.name]: result } = await client.Query(
Queries.Marketplace.OpenWriteoffDraft.query,
@@ -0,0 +1,22 @@
import type { MarketplaceWriteoffProposalView } from '../api';
// Русское склонение «позиция / позиции / позиций».
export function positionsLabel(n: number): string {
const mod10 = n % 10;
const mod100 = n % 100;
let word = 'позиций';
if (mod10 === 1 && mod100 !== 11) word = 'позиция';
else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) word = 'позиции';
return `${n} ${word}`;
}
function formatDate(value: string | null | undefined): string {
if (!value) return '—';
const d = new Date(value);
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleDateString('ru-RU');
}
// Человеческое имя проекта списания — по дате подачи/цикла, не «N позиций».
export function proposalTitle(p: MarketplaceWriteoffProposalView): string {
return `Списание от ${formatDate(p.submitted_at ?? p.cycle_started_at ?? p.updated_at)}`;
}

Some files were not shown because too many files have changed in this diff Show More