Реестр заказов админа + докладка со склада в окне выдачи + фикс паевого на выдаче (598-50) #136
@@ -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)
|
||||
|
||||
### 🗂️ Дерево категорий
|
||||
- Получение полного дерева категорий с иерархией
|
||||
|
||||
+7
-1
@@ -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'],
|
||||
|
||||
+7
-85
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -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;
|
||||
}
|
||||
|
||||
+191
-14
@@ -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')
|
||||
|
||||
+94
-6
@@ -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;
|
||||
|
||||
+22
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Остаток предложения изменился (заказ заблокировал/освободил/списал единицы).
|
||||
* Сигнал широковещательному каналу каталога, чтобы у всех пайщиков карточка
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+44
@@ -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:
|
||||
|
||||
+7
-123
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -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: 'Получить один заказ по его идентификатору (доступ зависит от роли).',
|
||||
|
||||
+83
-28
@@ -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);
|
||||
|
||||
+1
-1
@@ -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;
|
||||
});
|
||||
|
||||
+195
-9
@@ -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(
|
||||
'Подтвердить списание можно только по участку, на котором вы являетесь председателем или доверенным лицом.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+172
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+143
-14
@@ -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 — имущество физически на
|
||||
|
||||
+3
@@ -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);
|
||||
|
||||
+62
@@ -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');
|
||||
});
|
||||
});
|
||||
+22
@@ -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,
|
||||
|
||||
+80
-49
@@ -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 и
|
||||
|
||||
+51
@@ -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
|
||||
|
||||
+17
@@ -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);
|
||||
}
|
||||
|
||||
+10
-4
@@ -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);
|
||||
|
||||
+11
-5
@@ -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}». Отмена закрыта после открытия акта выдачи.`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+11
-1
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
+88
@@ -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('Не выбрано ни одного заказа.');
|
||||
|
||||
+562
-104
@@ -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(
|
||||
|
||||
+56
-6
@@ -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,
|
||||
|
||||
+72
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
+434
-26
@@ -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);
|
||||
}
|
||||
|
||||
+16
@@ -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');
|
||||
}
|
||||
|
||||
+32
@@ -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' })
|
||||
|
||||
+13
-1
@@ -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();
|
||||
|
||||
+32
-1
@@ -1,3 +1,5 @@
|
||||
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
|
||||
|
||||
/**
|
||||
* Предложение докладки из остатка склада КУ (requirement 76, решения 10–11).
|
||||
*
|
||||
@@ -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 {
|
||||
|
||||
+7
@@ -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))];
|
||||
}
|
||||
}
|
||||
|
||||
+13
-4
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+26
-3
@@ -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): членский взнос и распределение ────
|
||||
|
||||
/** Единая ставка членского взноса кооператива (администратор). */
|
||||
|
||||
+33
@@ -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
|
||||
|
||||
-9
@@ -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[]>;
|
||||
}
|
||||
|
||||
+7
@@ -49,6 +49,13 @@ export interface MarketplaceWriteoffProposalDomainRepository {
|
||||
coopname: string
|
||||
): Promise<MarketplaceWriteoffProposalDomainEntity | null>;
|
||||
|
||||
/**
|
||||
* inventory_id позиций, занятых в незавершённых проектах списания (черновик,
|
||||
* в совете, одобрено, ожидает подтверждения, в исполнении). Используется,
|
||||
* чтобы не показывать одни и те же позиции кандидатами в новый проект.
|
||||
*/
|
||||
findActiveLockedInventoryIds(coopname: string): Promise<string[]>;
|
||||
|
||||
list(
|
||||
filter: MarketplaceWriteoffProposalListFilter,
|
||||
pagination?: PaginationInputDTO
|
||||
|
||||
+11
-3
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+38
@@ -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). */
|
||||
|
||||
+31
@@ -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
|
||||
|
||||
-16
@@ -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(
|
||||
|
||||
+36
-1
@@ -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;
|
||||
|
||||
+5
-5
@@ -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' })
|
||||
|
||||
-24
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
+878
-193
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
|
||||
|
||||
|
||||
-3
@@ -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: 'Идентификатор проекта списания:',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+70
-38
@@ -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',
|
||||
}
|
||||
|
||||
+201
@@ -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)`
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user