diff --git a/components/contracts/cpp/lib/core/ledger2/operations.hpp b/components/contracts/cpp/lib/core/ledger2/operations.hpp index 4b9e4104cd4..95ab886455d 100644 --- a/components/contracts/cpp/lib/core/ledger2/operations.hpp +++ b/components/contracts/cpp/lib/core/ledger2/operations.hpp @@ -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 инлайн от контракта-источника при финализации заказа. diff --git a/components/contracts/cpp/lib/core/marketplace/marketplace.hpp b/components/contracts/cpp/lib/core/marketplace/marketplace.hpp index f013e3b0c6d..05195f0ced9 100644 --- a/components/contracts/cpp/lib/core/marketplace/marketplace.hpp +++ b/components/contracts/cpp/lib/core/marketplace/marketplace.hpp @@ -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 diff --git a/components/contracts/cpp/lib/core/marketplace/memo.hpp b/components/contracts/cpp/lib/core/marketplace/memo.hpp index 2a9421d6778..98acbd57937 100644 --- a/components/contracts/cpp/lib/core/marketplace/memo.hpp +++ b/components/contracts/cpp/lib/core/marketplace/memo.hpp @@ -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) + " в Столе заказов"; } diff --git a/components/contracts/cpp/marketplace/marketplace.cpp b/components/contracts/cpp/marketplace/marketplace.cpp index ad8a9b53f0c..f869e1b4eae 100644 --- a/components/contracts/cpp/marketplace/marketplace.cpp +++ b/components/contracts/cpp/marketplace/marketplace.cpp @@ -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/ diff --git a/components/contracts/cpp/marketplace/marketplace.hpp b/components/contracts/cpp/marketplace/marketplace.hpp index aa69339872b..9e8733e46d2 100644 --- a/components/contracts/cpp/marketplace/marketplace.hpp +++ b/components/contracts/cpp/marketplace/marketplace.hpp @@ -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 ────────────────────────────────────────────────────────── /** diff --git a/components/contracts/cpp/marketplace/p.mkt.supply.standard.yaml b/components/contracts/cpp/marketplace/p.mkt.supply.standard.yaml index 679500f881b..7e017dfa829 100644 --- a/components/contracts/cpp/marketplace/p.mkt.supply.standard.yaml +++ b/components/contracts/cpp/marketplace/p.mkt.supply.standard.yaml @@ -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 diff --git a/components/contracts/cpp/marketplace/p.mkt.wroff.standard.yaml b/components/contracts/cpp/marketplace/p.mkt.wroff.standard.yaml index 675362d4c80..363e7d2ba1e 100644 --- a/components/contracts/cpp/marketplace/p.mkt.wroff.standard.yaml +++ b/components/contracts/cpp/marketplace/p.mkt.wroff.standard.yaml @@ -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 — расход целевых + средств программы на выбытие имущества со склада. Кошельки не двигаются — + расход чисто кооперативный. Применяется по каждой позиции участка — + несколько последовательных операций в одной транзакции подтверждения. diff --git a/components/contracts/cpp/marketplace/src/p.mkt.supply/cancelorder.cpp b/components/contracts/cpp/marketplace/src/p.mkt.supply/cancelorder.cpp index 87ac66660e4..d0cda157a4c 100644 --- a/components/contracts/cpp/marketplace/src/p.mkt.supply/cancelorder.cpp +++ b/components/contracts/cpp/marketplace/src/p.mkt.supply/cancelorder.cpp @@ -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); } diff --git a/components/contracts/cpp/marketplace/src/p.mkt.supply/convert.cpp b/components/contracts/cpp/marketplace/src/p.mkt.supply/convert.cpp new file mode 100644 index 00000000000..40456a951b3 --- /dev/null +++ b/components/contracts/cpp/marketplace/src/p.mkt.supply/convert.cpp @@ -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); +} diff --git a/components/contracts/cpp/marketplace/src/p.mkt.supply/declineorder.cpp b/components/contracts/cpp/marketplace/src/p.mkt.supply/declineorder.cpp index 4d3ddf5e9d8..a27b1cbbf68 100644 --- a/components/contracts/cpp/marketplace/src/p.mkt.supply/declineorder.cpp +++ b/components/contracts/cpp/marketplace/src/p.mkt.supply/declineorder.cpp @@ -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, diff --git a/components/contracts/cpp/marketplace/src/p.mkt.supply/signiss2.cpp b/components/contracts/cpp/marketplace/src/p.mkt.supply/signiss2.cpp index b1d0feed64f..cb649e5b101 100644 --- a/components/contracts/cpp/marketplace/src/p.mkt.supply/signiss2.cpp +++ b/components/contracts/cpp/marketplace/src/p.mkt.supply/signiss2.cpp @@ -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)); } diff --git a/components/contracts/cpp/marketplace/src/p.mkt.supply/stockorder.cpp b/components/contracts/cpp/marketplace/src/p.mkt.supply/stockorder.cpp index 4f0b67dff0c..9445351e8a9 100644 --- a/components/contracts/cpp/marketplace/src/p.mkt.supply/stockorder.cpp +++ b/components/contracts/cpp/marketplace/src/p.mkt.supply/stockorder.cpp @@ -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); } diff --git a/components/contracts/cpp/marketplace/src/p.mkt.wroff/confirmwroff.cpp b/components/contracts/cpp/marketplace/src/p.mkt.wroff/confirmwroff.cpp new file mode 100644 index 00000000000..174bd9e6875 --- /dev/null +++ b/components/contracts/cpp/marketplace/src/p.mkt.wroff/confirmwroff.cpp @@ -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; + }); + } +} diff --git a/components/controller/src/application/account/dto/account.dto.ts b/components/controller/src/application/account/dto/account.dto.ts index 9ea79ad1f73..8c99de2ad0f 100644 --- a/components/controller/src/application/account/dto/account.dto.ts +++ b/components/controller/src/application/account/dto/account.dto.ts @@ -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; } } diff --git a/components/controller/src/application/account/enum/account-kind.enum.ts b/components/controller/src/application/account/enum/account-kind.enum.ts new file mode 100644 index 00000000000..9d439945c2d --- /dev/null +++ b/components/controller/src/application/account/enum/account-kind.enum.ts @@ -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: 'Вид аккаунта: пайщик, кооперативный участок, кооператив или нераспознанный', +}); diff --git a/components/controller/src/application/wallet/dto/user-wallet.dto.ts b/components/controller/src/application/wallet/dto/user-wallet.dto.ts new file mode 100644 index 00000000000..630c6281928 --- /dev/null +++ b/components/controller/src/application/wallet/dto/user-wallet.dto.ts @@ -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; +} diff --git a/components/controller/src/application/wallet/interactors/wallet.interactor.ts b/components/controller/src/application/wallet/interactors/wallet.interactor.ts index 9c550d1cca3..e32dcf42a61 100644 --- a/components/controller/src/application/wallet/interactors/wallet.interactor.ts +++ b/components/controller/src/application/wallet/interactors/wallet.interactor.ts @@ -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 { + 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` для пайщика(ов). * diff --git a/components/controller/src/application/wallet/resolvers/wallet.resolver.ts b/components/controller/src/application/wallet/resolvers/wallet.resolver.ts index 2f10c6708de..1556052d91c 100644 --- a/components/controller/src/application/wallet/resolvers/wallet.resolver.ts +++ b/components/controller/src/application/wallet/resolvers/wallet.resolver.ts @@ -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 { 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 { + return await this.walletService.getUserWallets(username, coopname); + } } diff --git a/components/controller/src/application/wallet/services/wallet.service.ts b/components/controller/src/application/wallet/services/wallet.service.ts index 8830a97d179..ac10480e78a 100644 --- a/components/controller/src/application/wallet/services/wallet.service.ts +++ b/components/controller/src/application/wallet/services/wallet.service.ts @@ -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 { + return this.walletInteractor.getUserWallets(username, coopname); + } } diff --git a/components/controller/src/domain/account/entities/account-domain.entity.ts b/components/controller/src/domain/account/entities/account-domain.entity.ts index a84c7dd5dc3..053c29dd12f 100644 --- a/components/controller/src/domain/account/entities/account-domain.entity.ts +++ b/components/controller/src/domain/account/entities/account-domain.entity.ts @@ -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); diff --git a/components/controller/src/domain/account/services/account-domain.service.ts b/components/controller/src/domain/account/services/account-domain.service.ts index d99b5de60bb..5f9e286f71a 100644 --- a/components/controller/src/domain/account/services/account-domain.service.ts +++ b/components/controller/src/domain/account/services/account-domain.service.ts @@ -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; diff --git a/components/controller/src/extensions/marketplace/README.md b/components/controller/src/extensions/marketplace/README.md index 74864fef3fb..9387c7e223f 100644 --- a/components/controller/src/extensions/marketplace/README.md +++ b/components/controller/src/extensions/marketplace/README.md @@ -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) ### 🗂️ Дерево категорий - Получение полного дерева категорий с иерархией diff --git a/components/controller/src/extensions/marketplace/application/access/marketplace-access-matrix.ts b/components/controller/src/extensions/marketplace/application/access/marketplace-access-matrix.ts index 20dd18855e5..228e7b35731 100644 --- a/components/controller/src/extensions/marketplace/application/access/marketplace-access-matrix.ts +++ b/components/controller/src/extensions/marketplace/application/access/marketplace-access-matrix.ts @@ -93,9 +93,15 @@ export const marketplaceAccessMatrix: Record 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; - } -} diff --git a/components/controller/src/extensions/marketplace/application/dto/marketplace-offer-input.dto.ts b/components/controller/src/extensions/marketplace/application/dto/marketplace-offer-input.dto.ts index e7bf74d4cbd..64a67ca146c 100644 --- a/components/controller/src/extensions/marketplace/application/dto/marketplace-offer-input.dto.ts +++ b/components/controller/src/extensions/marketplace/application/dto/marketplace-offer-input.dto.ts @@ -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; +} diff --git a/components/controller/src/extensions/marketplace/application/dto/marketplace-stock.dto.ts b/components/controller/src/extensions/marketplace/application/dto/marketplace-stock.dto.ts index d6bf15e826e..e54c6865512 100644 --- a/components/controller/src/extensions/marketplace/application/dto/marketplace-stock.dto.ts +++ b/components/controller/src/extensions/marketplace/application/dto/marketplace-stock.dto.ts @@ -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') diff --git a/components/controller/src/extensions/marketplace/application/dto/marketplace-writeoff.dto.ts b/components/controller/src/extensions/marketplace/application/dto/marketplace-writeoff.dto.ts index 11b618fa9e9..46c35c5bea8 100644 --- a/components/controller/src/extensions/marketplace/application/dto/marketplace-writeoff.dto.ts +++ b/components/controller/src/extensions/marketplace/application/dto/marketplace-writeoff.dto.ts @@ -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; diff --git a/components/controller/src/extensions/marketplace/application/events/marketplace-notification.events.ts b/components/controller/src/extensions/marketplace/application/events/marketplace-notification.events.ts index a3fa0d3f7d6..d0fd53529be 100644 --- a/components/controller/src/extensions/marketplace/application/events/marketplace-notification.events.ts +++ b/components/controller/src/extensions/marketplace/application/events/marketplace-notification.events.ts @@ -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; +} + /** * Остаток предложения изменился (заказ заблокировал/освободил/списал единицы). * Сигнал широковещательному каналу каталога, чтобы у всех пайщиков карточка diff --git a/components/controller/src/extensions/marketplace/application/marketplace-application.module.ts b/components/controller/src/extensions/marketplace/application/marketplace-application.module.ts index e5332d5f30b..138eb2c2992 100644 --- a/components/controller/src/extensions/marketplace/application/marketplace-application.module.ts +++ b/components/controller/src/extensions/marketplace/application/marketplace-application.module.ts @@ -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, diff --git a/components/controller/src/extensions/marketplace/application/resolvers/marketplace-apl-reception.resolver.ts b/components/controller/src/extensions/marketplace/application/resolvers/marketplace-apl-reception.resolver.ts index 3aa3a0eb944..1b41d021ac0 100644 --- a/components/controller/src/extensions/marketplace/application/resolvers/marketplace-apl-reception.resolver.ts +++ b/components/controller/src/extensions/marketplace/application/resolvers/marketplace-apl-reception.resolver.ts @@ -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 { + 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: diff --git a/components/controller/src/extensions/marketplace/application/resolvers/marketplace-issuance.resolver.ts b/components/controller/src/extensions/marketplace/application/resolvers/marketplace-issuance.resolver.ts index d4216dff9dc..04c553c7279 100644 --- a/components/controller/src/extensions/marketplace/application/resolvers/marketplace-issuance.resolver.ts +++ b/components/controller/src/extensions/marketplace/application/resolvers/marketplace-issuance.resolver.ts @@ -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 { - 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 { - 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 { - 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 { - 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))); - } } diff --git a/components/controller/src/extensions/marketplace/application/resolvers/marketplace-offer.resolver.ts b/components/controller/src/extensions/marketplace/application/resolvers/marketplace-offer.resolver.ts index 19d6ed88a4e..da9d24b427d 100644 --- a/components/controller/src/extensions/marketplace/application/resolvers/marketplace-offer.resolver.ts +++ b/components/controller/src/extensions/marketplace/application/resolvers/marketplace-offer.resolver.ts @@ -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 { + 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, + }; + } } diff --git a/components/controller/src/extensions/marketplace/application/resolvers/marketplace-order.resolver.ts b/components/controller/src/extensions/marketplace/application/resolvers/marketplace-order.resolver.ts index 956eb96c5b7..d678bb7b02f 100644 --- a/components/controller/src/extensions/marketplace/application/resolvers/marketplace-order.resolver.ts +++ b/components/controller/src/extensions/marketplace/application/resolvers/marketplace-order.resolver.ts @@ -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 { + 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: 'Получить один заказ по его идентификатору (доступ зависит от роли).', diff --git a/components/controller/src/extensions/marketplace/application/resolvers/marketplace-stock.resolver.ts b/components/controller/src/extensions/marketplace/application/resolvers/marketplace-stock.resolver.ts index b37559944cb..2e7f10adb6c 100644 --- a/components/controller/src/extensions/marketplace/application/resolvers/marketplace-stock.resolver.ts +++ b/components/controller/src/extensions/marketplace/application/resolvers/marketplace-stock.resolver.ts @@ -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 { + 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 { - const lines = await this.proposalService.getAcceptSignablePayloads( + ): Promise { + 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 { - 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); diff --git a/components/controller/src/extensions/marketplace/application/resolvers/marketplace-writeoff.mapper.ts b/components/controller/src/extensions/marketplace/application/resolvers/marketplace-writeoff.mapper.ts index 3022ca1cc52..45214c19410 100644 --- a/components/controller/src/extensions/marketplace/application/resolvers/marketplace-writeoff.mapper.ts +++ b/components/controller/src/extensions/marketplace/application/resolvers/marketplace-writeoff.mapper.ts @@ -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; }); diff --git a/components/controller/src/extensions/marketplace/application/resolvers/marketplace-writeoff.resolver.ts b/components/controller/src/extensions/marketplace/application/resolvers/marketplace-writeoff.resolver.ts index 15f6b8114ed..4aa4f3a0cd4 100644 --- a/components/controller/src/extensions/marketplace/application/resolvers/marketplace-writeoff.resolver.ts +++ b/components/controller/src/extensions/marketplace/application/resolvers/marketplace-writeoff.resolver.ts @@ -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 { 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 { - 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 { + 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 { + 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 { + 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 { + // Протокол уже подписан советом и лежит в реестре документов: собираем + // агрегат из подписанного документа по 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 { + 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 { + 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 { + 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( + 'Подтвердить списание можно только по участку, на котором вы являетесь председателем или доверенным лицом.' + ); + } + } } diff --git a/components/controller/src/extensions/marketplace/application/services/marketplace-apl-reception.service.spec.ts b/components/controller/src/extensions/marketplace/application/services/marketplace-apl-reception.service.spec.ts index 1b48b66e226..17ae9fc399f 100644 --- a/components/controller/src/extensions/marketplace/application/services/marketplace-apl-reception.service.spec.ts +++ b/components/controller/src/extensions/marketplace/application/services/marketplace-apl-reception.service.spec.ts @@ -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): 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(); + }); + }); }); diff --git a/components/controller/src/extensions/marketplace/application/services/marketplace-apl-reception.service.ts b/components/controller/src/extensions/marketplace/application/services/marketplace-apl-reception.service.ts index c5483c58991..a699749b936 100644 --- a/components/controller/src/extensions/marketplace/application/services/marketplace-apl-reception.service.ts +++ b/components/controller/src/extensions/marketplace/application/services/marketplace-apl-reception.service.ts @@ -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 { 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 { + 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 — имущество физически на diff --git a/components/controller/src/extensions/marketplace/application/services/marketplace-checkout.service.ts b/components/controller/src/extensions/marketplace/application/services/marketplace-checkout.service.ts index a53215d8ba4..0afa15282b6 100644 --- a/components/controller/src/extensions/marketplace/application/services/marketplace-checkout.service.ts +++ b/components/controller/src/extensions/marketplace/application/services/marketplace-checkout.service.ts @@ -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); diff --git a/components/controller/src/extensions/marketplace/application/services/marketplace-economy.service.spec.ts b/components/controller/src/extensions/marketplace/application/services/marketplace-economy.service.spec.ts new file mode 100644 index 00000000000..b8963e93da1 --- /dev/null +++ b/components/controller/src/extensions/marketplace/application/services/marketplace-economy.service.spec.ts @@ -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'); + }); +}); diff --git a/components/controller/src/extensions/marketplace/application/services/marketplace-economy.service.ts b/components/controller/src/extensions/marketplace/application/services/marketplace-economy.service.ts index 38327d7806d..92e342a5764 100644 --- a/components/controller/src/extensions/marketplace/application/services/marketplace-economy.service.ts +++ b/components/controller/src/extensions/marketplace/application/services/marketplace-economy.service.ts @@ -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 { const membership_fee_percent = this.toContractPercent( feePercentHuman, diff --git a/components/controller/src/extensions/marketplace/application/services/marketplace-issuance.service.ts b/components/controller/src/extensions/marketplace/application/services/marketplace-issuance.service.ts index be4622a68ba..6e0ec6e5ce3 100644 --- a/components/controller/src/extensions/marketplace/application/services/marketplace-issuance.service.ts +++ b/components/controller/src/extensions/marketplace/application/services/marketplace-issuance.service.ts @@ -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 { - 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 { 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 { 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 { + 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 { + 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 и diff --git a/components/controller/src/extensions/marketplace/application/services/marketplace-notification.service.ts b/components/controller/src/extensions/marketplace/application/services/marketplace-notification.service.ts index fe6cedc3d40..b713be94ee2 100644 --- a/components/controller/src/extensions/marketplace/application/services/marketplace-notification.service.ts +++ b/components/controller/src/extensions/marketplace/application/services/marketplace-notification.service.ts @@ -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 { + 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 diff --git a/components/controller/src/extensions/marketplace/application/services/marketplace-offer.service.ts b/components/controller/src/extensions/marketplace/application/services/marketplace-offer.service.ts index cfa574d862a..56bf5b779c0 100644 --- a/components/controller/src/extensions/marketplace/application/services/marketplace-offer.service.ts +++ b/components/controller/src/extensions/marketplace/application/services/marketplace-offer.service.ts @@ -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> { + 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 { return this.repo.findById(id); } diff --git a/components/controller/src/extensions/marketplace/application/services/marketplace-order-cancel.service.spec.ts b/components/controller/src/extensions/marketplace/application/services/marketplace-order-cancel.service.spec.ts index 750eb7a0318..b889616153d 100644 --- a/components/controller/src/extensions/marketplace/application/services/marketplace-order-cancel.service.spec.ts +++ b/components/controller/src/extensions/marketplace/application/services/marketplace-order-cancel.service.spec.ts @@ -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); diff --git a/components/controller/src/extensions/marketplace/application/services/marketplace-order-cancel.service.ts b/components/controller/src/extensions/marketplace/application/services/marketplace-order-cancel.service.ts index 813350e1134..b0e29095a54 100644 --- a/components/controller/src/extensions/marketplace/application/services/marketplace-order-cancel.service.ts +++ b/components/controller/src/extensions/marketplace/application/services/marketplace-order-cancel.service.ts @@ -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}». Отмена закрыта после открытия акта выдачи.` ); } diff --git a/components/controller/src/extensions/marketplace/application/services/marketplace-order-supplier-action.service.spec.ts b/components/controller/src/extensions/marketplace/application/services/marketplace-order-supplier-action.service.spec.ts index c4b371c00db..8c082ce9e43 100644 --- a/components/controller/src/extensions/marketplace/application/services/marketplace-order-supplier-action.service.spec.ts +++ b/components/controller/src/extensions/marketplace/application/services/marketplace-order-supplier-action.service.spec.ts @@ -42,6 +42,11 @@ function buildMocks() { create: jest.fn().mockResolvedValue({ id: 'cycle-1' } as any), } as unknown as jest.Mocked; + // Имя товара для уведомления заказчику об отказе тянется батчем по offer_id. + const offerRepo = { + findByIds: jest.fn().mockResolvedValue([{ id: 'offer-1', product_name: 'Молоко' }] as any), + } as any; + const offerCounters: jest.Mocked = { onOrderUnblocked: jest.fn().mockResolvedValue({} as any), } as unknown as jest.Mocked; @@ -51,6 +56,9 @@ function buildMocks() { declineOrder: jest.fn().mockResolvedValue({ transaction: { id: 'tx-dec-1' } } as any), } as unknown as jest.Mocked; + // Шина событий: отказ эмитит уведомление заказчику (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 ); }); diff --git a/components/controller/src/extensions/marketplace/application/services/marketplace-order-supplier-action.service.ts b/components/controller/src/extensions/marketplace/application/services/marketplace-order-supplier-action.service.ts index 4055fdcbe2c..d82cb996e02 100644 --- a/components/controller/src/extensions/marketplace/application/services/marketplace-order-supplier-action.service.ts +++ b/components/controller/src/extensions/marketplace/application/services/marketplace-order-supplier-action.service.ts @@ -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 { + 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 { + 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('Не выбрано ни одного заказа.'); diff --git a/components/controller/src/extensions/marketplace/application/services/marketplace-stock-proposal.service.ts b/components/controller/src/extensions/marketplace/application/services/marketplace-stock-proposal.service.ts index bd284052219..07e53e97520 100644 --- a/components/controller/src/extensions/marketplace/application/services/marketplace-stock-proposal.service.ts +++ b/components/controller/src/extensions/marketplace/application/services/marketplace-stock-proposal.service.ts @@ -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 { + ): Promise { 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 { + 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 { + 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 { + 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 { + 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 { - 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 { 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 { + 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( diff --git a/components/controller/src/extensions/marketplace/application/services/marketplace-stock.service.ts b/components/controller/src/extensions/marketplace/application/services/marketplace-stock.service.ts index f57e6f5977d..e5937f107a4 100644 --- a/components/controller/src/extensions/marketplace/application/services/marketplace-stock.service.ts +++ b/components/controller/src/extensions/marketplace/application/services/marketplace-stock.service.ts @@ -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 { + 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, diff --git a/components/controller/src/extensions/marketplace/application/services/marketplace-writeoff-sync.service.ts b/components/controller/src/extensions/marketplace/application/services/marketplace-writeoff-sync.service.ts new file mode 100644 index 00000000000..e64c276f7cb --- /dev/null +++ b/components/controller/src/extensions/marketplace/application/services/marketplace-writeoff-sync.service.ts @@ -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 { + 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 { + 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); + } + } +} diff --git a/components/controller/src/extensions/marketplace/application/services/marketplace-writeoff.service.spec.ts b/components/controller/src/extensions/marketplace/application/services/marketplace-writeoff.service.spec.ts index f40f877e525..b3ac8cb620d 100644 --- a/components/controller/src/extensions/marketplace/application/services/marketplace-writeoff.service.spec.ts +++ b/components/controller/src/extensions/marketplace/application/services/marketplace-writeoff.service.spec.ts @@ -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, }, ]; diff --git a/components/controller/src/extensions/marketplace/application/services/marketplace-writeoff.service.ts b/components/controller/src/extensions/marketplace/application/services/marketplace-writeoff.service.ts index 6e1a4644e5e..ca2cd9f0e54 100644 --- a/components/controller/src/extensions/marketplace/application/services/marketplace-writeoff.service.ts +++ b/components/controller/src/extensions/marketplace/application/services/marketplace-writeoff.service.ts @@ -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> { + const map: Record = {}; + 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 { + return Promise.all(items.map((it) => this.resolveUnitLabel(it.inventory_ids))); + } + + private async resolveUnitLabel(inventoryIds: string[]): Promise { + 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 { + const candidates = await this.inventoryRepo.findWriteoffCandidates(coopname, new Date()); + // Партии, уже занятые в незавершённых проектах списания, исключаем — + // иначе один и тот же товар попадёт в два проекта (двойное списание). + const lockedIds = new Set(await this.repo.findActiveLockedInventoryIds(coopname)); + // Имя КУ показываем человеку, не служебный braname — резолвим с кэшем, + // позиций по одному складу обычно много. + const branchNameCache = new Map(); + + // Несколько партий одного наименования на одном КУ в одном состоянии + // визуально неразличимы (та же дата «годен до», тот же КУ) — пайщик видит + // «прыгающее количество». Сливаем их в одну строку-агрегат: суммарное + // кол-во + сумма, под капотом — список всех партий. Ключ группировки + // включает состояние (просрочено / без гарантии / годно), потому что у них + // разный визуальный статус и разная причина по умолчанию. + 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 { + const { items } = await this.repo.list({ + coopname, + statuses: ['PENDING_CONFIRMATION'], + }); + const groups: MarketplaceWriteoffConfirmationGroup[] = []; + const branchNameCache = new Map(); + 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 { + 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 { + 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 { + 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); } diff --git a/components/controller/src/extensions/marketplace/application/shared/order-hash.util.ts b/components/controller/src/extensions/marketplace/application/shared/order-hash.util.ts index 1b1a9be1b48..9b4ba8aae17 100644 --- a/components/controller/src/extensions/marketplace/application/shared/order-hash.util.ts +++ b/components/controller/src/extensions/marketplace/application/shared/order-hash.util.ts @@ -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'); +} diff --git a/components/controller/src/extensions/marketplace/domain/entities/marketplace-order.entity.spec.ts b/components/controller/src/extensions/marketplace/domain/entities/marketplace-order.entity.spec.ts index 20ba10b7e5f..50a0676011d 100644 --- a/components/controller/src/extensions/marketplace/domain/entities/marketplace-order.entity.spec.ts +++ b/components/controller/src/extensions/marketplace/domain/entities/marketplace-order.entity.spec.ts @@ -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' }) diff --git a/components/controller/src/extensions/marketplace/domain/entities/marketplace-order.entity.ts b/components/controller/src/extensions/marketplace/domain/entities/marketplace-order.entity.ts index 7da48d1713f..a62c83c5710 100644 --- a/components/controller/src/extensions/marketplace/domain/entities/marketplace-order.entity.ts +++ b/components/controller/src/extensions/marketplace/domain/entities/marketplace-order.entity.ts @@ -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(); diff --git a/components/controller/src/extensions/marketplace/domain/entities/marketplace-stock-proposal.types.ts b/components/controller/src/extensions/marketplace/domain/entities/marketplace-stock-proposal.types.ts index 491bfe95eeb..86392d56824 100644 --- a/components/controller/src/extensions/marketplace/domain/entities/marketplace-stock-proposal.types.ts +++ b/components/controller/src/extensions/marketplace/domain/entities/marketplace-stock-proposal.types.ts @@ -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; -/** Строка предложения: опубликованный оффер кооператива + количество. */ +/** + * Строка бандла выдачи. Единый путь: бандл несёт И докладку со склада, И уже + * существующие обычные заказы пайщика — отличает их `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 { diff --git a/components/controller/src/extensions/marketplace/domain/entities/marketplace-writeoff-proposal.entity.ts b/components/controller/src/extensions/marketplace/domain/entities/marketplace-writeoff-proposal.entity.ts index 53d09251179..44ddc89573f 100644 --- a/components/controller/src/extensions/marketplace/domain/entities/marketplace-writeoff-proposal.entity.ts +++ b/components/controller/src/extensions/marketplace/domain/entities/marketplace-writeoff-proposal.entity.ts @@ -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))]; + } } diff --git a/components/controller/src/extensions/marketplace/domain/entities/marketplace-writeoff-proposal.types.ts b/components/controller/src/extensions/marketplace/domain/entities/marketplace-writeoff-proposal.types.ts index ed5b781db38..a74be6a8280 100644 --- a/components/controller/src/extensions/marketplace/domain/entities/marketplace-writeoff-proposal.types.ts +++ b/components/controller/src/extensions/marketplace/domain/entities/marketplace-writeoff-proposal.types.ts @@ -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; /** - * Позиция к списанию. Опциональный `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; } diff --git a/components/controller/src/extensions/marketplace/domain/ports/marketplace-canonical-blockchain.port.ts b/components/controller/src/extensions/marketplace/domain/ports/marketplace-canonical-blockchain.port.ts index 5d24852c48a..ade2784b4fb 100644 --- a/components/controller/src/extensions/marketplace/domain/ports/marketplace-canonical-blockchain.port.ts +++ b/components/controller/src/extensions/marketplace/domain/ports/marketplace-canonical-blockchain.port.ts @@ -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; + /** + * requirement 76: конвертация паевого взноса пайщика в членский кошелёк + * «Стола заказов» (o.mkt.conv: TRANSFER w.wal.share → w.mkt.member). Пополняет + * членские средства под заказ из остатка (`stockOrder` фондируется только из + * членских). Подписанное Заявление о конвертации публикуется в реестр + * документов. Выполняется перед `stockOrder`, когда членских средств не + * хватает (на всю сумму либо на дельту превышения над высвобождённым). + * + * Авторизация — кооператив (`require_auth(coopname)`). + */ + convert(data: MarketContract.Actions.Convert.IConvert): Promise; + /** * requirement 76 (вопрос 4): списание уценки по заказу из остатка после * финализации выдачи — o.mkt.loss (NONE, Дт 91 / Кт 10) на разницу между @@ -268,6 +281,16 @@ export interface MarketplaceCanonicalBlockchainPort { */ execWroff(data: MarketContract.Actions.ExecWroff.IExecWroff): Promise; + /** + * Председатель кооперативного участка подтверждает фактическое списание + * со склада своего КУ (ручной шаг стола ПВЗ). Закрывает все неисполненные + * позиции участка `braname` за вызов, проводит `o.mkt.wroff` и якорит + * подписанную Служебную записку о списании (registry 1111) в реестр + * документов. Авторизация — кооператив (`require_auth(coopname)`); C++ + * проверяет, что `signer` уполномочен для `braname`. + */ + confirmWroff(data: MarketContract.Actions.ConfirmWroff.IConfirmWroff): Promise; + // ── Экономика КУ (requirement b6): членский взнос и распределение ──── /** Единая ставка членского взноса кооператива (администратор). */ diff --git a/components/controller/src/extensions/marketplace/domain/repositories/marketplace-inventory.repository.ts b/components/controller/src/extensions/marketplace/domain/repositories/marketplace-inventory.repository.ts index dafcdf49fc8..6c060ecb2cd 100644 --- a/components/controller/src/extensions/marketplace/domain/repositories/marketplace-inventory.repository.ts +++ b/components/controller/src/extensions/marketplace/domain/repositories/marketplace-inventory.repository.ts @@ -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; @@ -86,6 +106,19 @@ export interface MarketplaceInventoryDomainRepository { list(filter: MarketplaceInventoryListFilter): Promise; + /** + * Позиции на складе (RECEIVED/LABELED) для ручного сбора корзины списания на + * admin-столе. Возвращает ВСЁ имущество на складе, а не только просроченное: + * председатель может списать вручную и годное (порча, невозврат). `cutoff` + * используется лишь для вычисления флага `is_expired` (expiry_date <= cutoff), + * не как фильтр. Авто-крон отбирает кандидатов отдельным запросом с + * grace-периодом и сюда не обращается. + */ + findWriteoffCandidates( + coopname: string, + cutoff: Date + ): Promise; + applyStatusTransition( id: string, newStatus: MarketplaceInventoryStatus diff --git a/components/controller/src/extensions/marketplace/domain/repositories/marketplace-order.repository.ts b/components/controller/src/extensions/marketplace/domain/repositories/marketplace-order.repository.ts index 8895fc03334..b9fab461e7a 100644 --- a/components/controller/src/extensions/marketplace/domain/repositories/marketplace-order.repository.ts +++ b/components/controller/src/extensions/marketplace/domain/repositories/marketplace-order.repository.ts @@ -204,13 +204,4 @@ export interface MarketplaceOrderDomainRepository coopname: string, delivery_braname: string ): Promise; - - /** - * Story 6.3: ленты «к получению» для orderer-стола. Возвращает Order'ы - * заказчика в статусе READY_TO_RECEIVE. - */ - listReadyToReceiveByOrderer( - coopname: string, - orderer_account: string - ): Promise; } diff --git a/components/controller/src/extensions/marketplace/domain/repositories/marketplace-writeoff-proposal.repository.ts b/components/controller/src/extensions/marketplace/domain/repositories/marketplace-writeoff-proposal.repository.ts index be8256d4366..50d76c2bc6b 100644 --- a/components/controller/src/extensions/marketplace/domain/repositories/marketplace-writeoff-proposal.repository.ts +++ b/components/controller/src/extensions/marketplace/domain/repositories/marketplace-writeoff-proposal.repository.ts @@ -49,6 +49,13 @@ export interface MarketplaceWriteoffProposalDomainRepository { coopname: string ): Promise; + /** + * inventory_id позиций, занятых в незавершённых проектах списания (черновик, + * в совете, одобрено, ожидает подтверждения, в исполнении). Используется, + * чтобы не показывать одни и те же позиции кандидатами в новый проект. + */ + findActiveLockedInventoryIds(coopname: string): Promise; + list( filter: MarketplaceWriteoffProposalListFilter, pagination?: PaginationInputDTO diff --git a/components/controller/src/extensions/marketplace/infrastructure/adapters/marketplace-apl-reception-repository.adapter.ts b/components/controller/src/extensions/marketplace/infrastructure/adapters/marketplace-apl-reception-repository.adapter.ts index 216c646f447..23f310bc2f5 100644 --- a/components/controller/src/extensions/marketplace/infrastructure/adapters/marketplace-apl-reception-repository.adapter.ts +++ b/components/controller/src/extensions/marketplace/infrastructure/adapters/marketplace-apl-reception-repository.adapter.ts @@ -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 { - 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; } diff --git a/components/controller/src/extensions/marketplace/infrastructure/adapters/marketplace-canonical-blockchain.adapter.ts b/components/controller/src/extensions/marketplace/infrastructure/adapters/marketplace-canonical-blockchain.adapter.ts index 4a55a017e3c..08f0dea87ff 100644 --- a/components/controller/src/extensions/marketplace/infrastructure/adapters/marketplace-canonical-blockchain.adapter.ts +++ b/components/controller/src/extensions/marketplace/infrastructure/adapters/marketplace-canonical-blockchain.adapter.ts @@ -68,6 +68,27 @@ export class MarketplaceCanonicalBlockchainAdapter implements MarketplaceCanonic }); } + async convert(data: MarketContract.Actions.Convert.IConvert): Promise { + 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 { 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 { + 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). */ diff --git a/components/controller/src/extensions/marketplace/infrastructure/adapters/marketplace-inventory-repository.adapter.ts b/components/controller/src/extensions/marketplace/infrastructure/adapters/marketplace-inventory-repository.adapter.ts index 631c1aad65d..34f662ca0d2 100644 --- a/components/controller/src/extensions/marketplace/infrastructure/adapters/marketplace-inventory-repository.adapter.ts +++ b/components/controller/src/extensions/marketplace/infrastructure/adapters/marketplace-inventory-repository.adapter.ts @@ -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 { + // Все позиции на складе — председатель собирает корзину вручную и может + // списать как просроченное, так и ещё годное (порча/невозврат). Просрочку + // подсвечиваем флагом 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 diff --git a/components/controller/src/extensions/marketplace/infrastructure/adapters/marketplace-order-repository.adapter.ts b/components/controller/src/extensions/marketplace/infrastructure/adapters/marketplace-order-repository.adapter.ts index 690d965ecb5..e1cc9751d62 100644 --- a/components/controller/src/extensions/marketplace/infrastructure/adapters/marketplace-order-repository.adapter.ts +++ b/components/controller/src/extensions/marketplace/infrastructure/adapters/marketplace-order-repository.adapter.ts @@ -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 { - 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( diff --git a/components/controller/src/extensions/marketplace/infrastructure/adapters/marketplace-writeoff-proposal-repository.adapter.ts b/components/controller/src/extensions/marketplace/infrastructure/adapters/marketplace-writeoff-proposal-repository.adapter.ts index ab9315274b3..533f8828db1 100644 --- a/components/controller/src/extensions/marketplace/infrastructure/adapters/marketplace-writeoff-proposal-repository.adapter.ts +++ b/components/controller/src/extensions/marketplace/infrastructure/adapters/marketplace-writeoff-proposal-repository.adapter.ts @@ -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 { + // Позиции, уже занятые в незавершённых проектах (черновик, в совете, + // одобрено, ожидает подтверждения, в исполнении) — нельзя предлагать их + // в кандидаты повторно, иначе один и тот же товар попадёт в два списания. + // 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(); + 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; diff --git a/components/controller/src/extensions/marketplace/infrastructure/entities/marketplace-writeoff-proposal.entity.ts b/components/controller/src/extensions/marketplace/infrastructure/entities/marketplace-writeoff-proposal.entity.ts index f8778878edf..8f859c0579e 100644 --- a/components/controller/src/extensions/marketplace/infrastructure/entities/marketplace-writeoff-proposal.entity.ts +++ b/components/controller/src/extensions/marketplace/infrastructure/entities/marketplace-writeoff-proposal.entity.ts @@ -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' }) diff --git a/components/controller/tests/unit/marketplace/marketplace-issuance-resolver-scoping.test.ts b/components/controller/tests/unit/marketplace/marketplace-issuance-resolver-scoping.test.ts index 57209a35f3c..3cbae5827be 100644 --- a/components/controller/tests/unit/marketplace/marketplace-issuance-resolver-scoping.test.ts +++ b/components/controller/tests/unit/marketplace/marketplace-issuance-resolver-scoping.test.ts @@ -63,10 +63,6 @@ const orderOf = (overrides: Record) => 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(); - }); -}); diff --git a/components/controller/zeus/const.ts b/components/controller/zeus/const.ts index 7270aab53a9..1af7e8cad4b 100644 --- a/components/controller/zeus/const.ts +++ b/components/controller/zeus/const.ts @@ -1,6 +1,7 @@ /* eslint-disable */ export const AllTypesProps: Record = { + AccountKind: "enum" as const, AccountType: "enum" as const, ActionFiltersInput:{ @@ -653,9 +654,6 @@ export const AllTypesProps: Record = { inspection_photos:"MarketplaceReturnClaimPhotoUploadInput", signed_statement:"MarketplaceReturnStatementSignedInput" }, - MarketplaceAcceptStockProposalInput:{ - lines:"MarketplaceCheckoutSignedLineInput" - }, MarketplaceAddToCartInput:{ }, @@ -709,6 +707,9 @@ export const AllTypesProps: Record = { }, MarketplaceClearInventoryLabelInput:{ + }, + MarketplaceConfirmWriteoffInput:{ + signed_memo:"SignedDigitalDocumentInput" }, MarketplaceConsolidatedRequestStatus: "enum" as const, MarketplaceConvertBranchFundsInput:{ @@ -735,6 +736,9 @@ export const AllTypesProps: Record = { delivery_points:"MarketplaceOfferDeliveryPointInput", images:"MarketplaceOfferImageUploadInput" }, + MarketplaceCreateOrderProposalLineInput:{ + signiss1_act:"MarketplaceIssueActSignedDocumentInput" + }, MarketplaceCreateReturnClaimInput:{ defect_category:"MarketplaceReturnClaimDefectCategory", photos:"MarketplaceReturnClaimPhotoUploadInput", @@ -744,7 +748,11 @@ export const AllTypesProps: Record = { 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 = { MarketplaceEventsInput:{ }, - MarketplaceFinalizeIssuanceInput:{ - signed_document:"MarketplaceIssueActSignedDocumentInput" + MarketplaceFinalizeStockIssuanceInput:{ + order_lines:"MarketplaceStockFinalizeLineInput", + signed_convert:"MarketplaceConvertStatementSignedInput" }, MarketplaceGenerateInventoryLabelInput:{ format:"MarketplaceBarcodeFormat" @@ -795,6 +804,9 @@ export const AllTypesProps: Record = { MarketplaceKUStatus: "enum" as const, MarketplaceListAidsInput:{ + }, + MarketplaceListAllOffersInput:{ + statuses:"MarketplaceOfferStatus" }, MarketplaceListAplReceptionsByBranameInput:{ @@ -851,9 +863,6 @@ export const AllTypesProps: Record = { }, 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 = { MarketplaceSplitInventoryInput:{ splits:"MarketplaceInventorySplitEntryInput" }, + MarketplaceStockFinalizeLineInput:{ + signed_signiss2_act:"MarketplaceIssueActSignedDocumentInput" + }, + MarketplaceStockIssuancePrepareInput:{ + items:"MarketplaceStockProposalItemInput" + }, MarketplaceStockProposalItemInput:{ }, @@ -962,6 +977,12 @@ export const AllTypesProps: Record = { }, MarketplaceWriteoffProposalStatus: "enum" as const, MarketplaceWriteoffProposalTrigger: "enum" as const, + MarketplaceWriteoffProtocolDocumentInput:{ + + }, + MarketplaceWriteoffServiceMemoSignablePayloadInput:{ + + }, MarketplaceWriteoffStatementSignablePayloadInput:{ }, @@ -1431,9 +1452,6 @@ export const AllTypesProps: Record = { marketplaceAcceptReturnAtVisit:{ data:"MarketplaceAcceptReturnAtVisitInput" }, - marketplaceAcceptStockProposal:{ - data:"MarketplaceAcceptStockProposalInput" - }, marketplaceAddAvailableCategories:{ input:"AddAvailableCategoriesInput" }, @@ -1458,6 +1476,9 @@ export const AllTypesProps: Record = { marketplaceBindInventoryBarcode:{ data:"MarketplaceBindInventoryBarcodeInput" }, + marketplaceCancelAplReception:{ + data:"MarketplaceAplReceptionByIdInput" + }, marketplaceCancelOrder:{ input:"MarketplaceCancelOrderInput" }, @@ -1476,6 +1497,9 @@ export const AllTypesProps: Record = { marketplaceClearInventoryLabel:{ data:"MarketplaceClearInventoryLabelInput" }, + marketplaceConfirmWriteoff:{ + data:"MarketplaceConfirmWriteoffInput" + }, marketplaceConvertBranchFunds:{ data:"MarketplaceConvertBranchFundsInput" }, @@ -1527,15 +1551,12 @@ export const AllTypesProps: Record = { 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 = { }, getReportPreview:{ input:"ReportPreviewInput" + }, + getUserWallets:{ + }, getUserWebPushSubscriptions:{ data:"GetUserSubscriptionsInput" @@ -2157,12 +2181,16 @@ export const AllTypesProps: Record = { 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 = { marketplaceSearchRequests:{ data:"SearchRequestsInput" }, + marketplaceStockIssuancePayloads:{ + data:"MarketplaceStockIssuancePrepareInput" + }, marketplaceStockProposalSignablePayloads:{ data:"MarketplaceResolveStockProposalInput" }, @@ -2259,6 +2290,12 @@ export const AllTypesProps: Record = { }, marketplaceWriteoffProposal:{ + }, + marketplaceWriteoffProtocolDocument:{ + data:"MarketplaceWriteoffProtocolDocumentInput" + }, + marketplaceWriteoffServiceMemoSignablePayload:{ + data:"MarketplaceWriteoffServiceMemoSignablePayloadInput" }, marketplaceWriteoffStatementSignablePayload:{ data:"MarketplaceWriteoffStatementSignablePayloadInput" @@ -2614,6 +2651,7 @@ export const AllTypesProps: Record = { export const ReturnTypes: Record = { Account:{ + account_kind:"AccountKind", blockchain_account:"BlockchainAccount", participant_account:"ParticipantAccount", private_account:"PrivateAccount", @@ -4399,10 +4437,6 @@ export const ReturnTypes: Record = { MarketplaceInventoryMutationResult:{ inventory:"MarketplaceInventoryItem" }, - MarketplaceIssuanceResult:{ - order:"MarketplaceOrder", - tx_hash:"String" - }, MarketplaceKUDetails:{ addressFull:"String", contactEmail:"String", @@ -4796,6 +4830,26 @@ export const ReturnTypes: Record = { 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 = { 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 = { 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 = { marketplaceAcceptCpp:"MarketplaceCppStatus", marketplaceAcceptOrdersBatch:"MarketplaceSupplierBatchActionResult", marketplaceAcceptReturnAtVisit:"MarketplaceReturnClaimResult", - marketplaceAcceptStockProposal:"MarketplaceStockProposalAcceptResult", marketplaceAddAvailableCategories:"MarketplaceAvailableCategory", marketplaceAddAvailableCategoryTypes:"MarketplaceAvailableCategory", marketplaceAddToCart:"MarketplaceCart", @@ -5168,6 +5246,7 @@ export const ReturnTypes: Record = { marketplaceApproveReturnVisit:"MarketplaceReturnClaimResult", marketplaceAssignInventoryShelf:"MarketplaceInventoryMutationResult", marketplaceBindInventoryBarcode:"MarketplaceInventoryMutationResult", + marketplaceCancelAplReception:"MarketplaceAplReceptionResult", marketplaceCancelOrder:"MarketplaceCancelOrderResult", marketplaceCancelStockOrder:"MarketplaceOrder", marketplaceCancelStockProposal:"MarketplaceStockProposal", @@ -5176,6 +5255,7 @@ export const ReturnTypes: Record = { marketplaceClearAvailableCategories:"Boolean", marketplaceClearCart:"MarketplaceCart", marketplaceClearInventoryLabel:"MarketplaceInventoryMutationResult", + marketplaceConfirmWriteoff:"MarketplaceWriteoffProposal", marketplaceConvertBranchFunds:"Boolean", marketplaceCreateAid:"Boolean", marketplaceCreateAplReception:"MarketplaceAplReceptionResult", @@ -5193,9 +5273,8 @@ export const ReturnTypes: Record = { 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 = { getReportPreview:"ReportPreview", getReportRequisites:"ReportRequisitesView", getSystemInfo:"SystemInfo", + getUserWallets:"UserWallet", getUserWebPushSubscriptions:"WebPushSubscriptionDto", getWebPushSubscriptionStats:"SubscriptionStatsDto", listExpensePlans:"ExpensePlan", @@ -5844,8 +5924,9 @@ export const ReturnTypes: Record = { 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 = { marketplaceListModerationLog:"MarketplaceModerationLogEntry", marketplaceListMyOffers:"MarketplaceOfferPaginationResult", marketplaceListMyOrders:"MarketplaceOrderPaginationResult", - marketplaceListMyReadyToReceive:"MarketplaceOrder", marketplaceListMyReturnClaims:"MarketplaceReturnClaim", marketplaceListOutgoingPayments:"MarketplaceOutgoingPaymentRequest", marketplaceListOutgoingPaymentsAsSupplier:"MarketplaceOutgoingPaymentRequest", @@ -5873,6 +5953,7 @@ export const ReturnTypes: Record = { marketplaceListSupplierOrders:"MarketplaceOrderPaginationResult", marketplaceListSupplierPickupOrders:"MarketplaceOrder", marketplaceListWhitelist:"MarketplaceWhitelistEntry", + marketplaceListWriteoffCandidates:"MarketplaceWriteoffCandidate", marketplaceListWriteoffProposals:"PaginatedMarketplaceWriteoffProposals", marketplaceMemberWallet:"MarketplaceMemberWallet", marketplaceOnboardingState:"MarketplaceOnboardingState", @@ -5885,10 +5966,14 @@ export const ReturnTypes: Record = { 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 = { "...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", diff --git a/components/controller/zeus/index.ts b/components/controller/zeus/index.ts index 6b8ad199784..99a31909b2e 100644 --- a/components/controller/zeus/index.ts +++ b/components/controller/zeus/index.ts @@ -1108,6 +1108,8 @@ type ZEUS_UNIONS = GraphQLTypes["MarketplaceEvent"] | GraphQLTypes["PaymentMetho export type ValueTypes = { ["Account"]: AliasType<{ + /** Вид аккаунта: пайщик, кооперативный участок, кооператив или нераспознанный. Позволяет единообразно отображать субъект во всех реестрах — например, пометить кооперативный участок, а не принять его за организацию-пайщика. */ + account_kind?:boolean | `@${string}`, /** объект аккаунта в блокчейне содержит системную информацию, такую как публичные ключи доступа, доступные вычислительные ресурсы, информация об установленном смарт-контракте, и т.д. и т.п. Это системный уровень обслуживания, где у каждого пайщика есть аккаунт, но не каждый аккаунт может быть пайщиком в каком-либо кооперативе. Все смарт-контракты устанавливаются и исполняются на этом уровне. */ blockchain_account?:ValueTypes["BlockchainAccount"], /** объект пайщика кооператива в таблице блокчейне, который определяет членство пайщика в конкретном кооперативе. Поскольку MONO обслуживает только один кооператив, то в participant_account обычно содержится информация, которая описывает членство пайщика в этом кооперативе. Этот объект обезличен, публичен, и хранится в блокчейне. */ @@ -1123,6 +1125,8 @@ export type ValueTypes = { __typename?: boolean | `@${string}`, ['...on Account']?: Omit }>; + /** Вид аккаунта: пайщик, кооперативный участок, кооператив или нераспознанный */ +["AccountKind"]:AccountKind; ["AccountRamDelta"]: AliasType<{ account?:boolean | `@${string}`, delta?:boolean | `@${string}`, @@ -6141,12 +6145,6 @@ export type ValueTypes = { scanned_barcode?: string | undefined | null | Variable, /** Заявление пайщика со второй подписью председателя — принятие возврата оформляется со-подписью на том же документе. */ signed_statement?: ValueTypes["MarketplaceReturnStatementSignedInput"] | undefined | null | Variable -}; - ["MarketplaceAcceptStockProposalInput"]: { - /** Подписанные заявления о конвертации паевого взноса — по одному на каждую строку предложения (из превью marketplaceStockProposalSignablePayloads). */ - lines?: Array | undefined | null | Variable, - /** Предложение со склада кооператива. */ - proposal_id: ValueTypes["ID"] | Variable }; /** Добавить позицию в корзину (с привязкой к пункту выдачи). */ ["MarketplaceAddToCartInput"]: { @@ -6684,6 +6682,14 @@ export type ValueTypes = { ["MarketplaceClearInventoryLabelInput"]: { /** Позиция склада, с которой снимается штрих-код (для переклейки). */ inventory_id: ValueTypes["ID"] | Variable +}; + ["MarketplaceConfirmWriteoffInput"]: { + /** Кооперативный участок, по которому подтверждается списание. */ + braname: string | Variable, + /** Идентификатор проекта списания. */ + proposal_id: string | Variable, + /** Подписанная председателем КУ Служебная записка о списании (registry_id=1111). */ + signed_memo: ValueTypes["SignedDigitalDocumentInput"] | Variable }; ["MarketplaceConsolidatedRequest"]: AliasType<{ accepted_at?:boolean | `@${string}`, @@ -6827,6 +6833,16 @@ export type ValueTypes = { unit_of_measure: string | Variable, unlimited_flag: boolean | Variable, warranty_days: number | Variable +}; + ["MarketplaceCreateOrderProposalLineInput"]: { + /** Фактическое количество к выдаче (сверено оператором). */ + actual_quantity: number | Variable, + /** Фактическая цена за единицу (оператор мог скорректировать). */ + actual_unit_price: string | Variable, + /** Существующий заказ пайщика к выдаче этим бандлом. */ + order_id: ValueTypes["ID"] | Variable, + /** Акт приёма-передачи, подписанный оператором КУ первой подписью. */ + signiss1_act: ValueTypes["MarketplaceIssueActSignedDocumentInput"] | Variable }; ["MarketplaceCreateReturnClaimInput"]: { /** Возвращаемое количество единиц (по умолчанию — выданное количество). */ @@ -6855,12 +6871,24 @@ export type ValueTypes = { ['...on MarketplaceCreateShipmentResult']?: Omit }>; ["MarketplaceCreateStockProposalInput"]: { - /** Кооперативный участок, со склада которого идёт докладка. */ + /** Кооперативный участок, со склада которого идёт выдача. */ braname: string | Variable, - /** Строки предложения: что и сколько предлагается со склада. */ - items: Array | Variable, - /** Пайщик-адресат предложения. */ - member_account: string | Variable + /** Строки докладки со склада: order_hash и подпись оператора (signiss1). */ + items?: Array | undefined | null | Variable, + /** Пайщик-адресат бандла. */ + member_account: string | Variable, + /** Строки уже существующих заказов пайщика к выдаче (подпись оператора signiss1). */ + order_items?: Array | undefined | null | Variable +}; + ["MarketplaceCreateStockProposalLineInput"]: { + /** Предложение кооператива из остатка. */ + offer_id: ValueTypes["ID"] | Variable, + /** order_hash из подготовки (marketplaceStockIssuancePayloads). */ + order_hash: string | Variable, + /** Количество единиц. */ + quantity: number | Variable, + /** Акт приёма-передачи, подписанный оператором КУ первой подписью. */ + signiss1_act: ValueTypes["MarketplaceIssueActSignedDocumentInput"] | Variable }; ["MarketplaceCreateWriteoffDraftInput"]: { /** Начало расчётного цикла, если отличается от текущего момента. */ @@ -6980,11 +7008,13 @@ export type ValueTypes = { __typename?: boolean | `@${string}`, ['...on MarketplaceExpressPickupCandidate']?: Omit }>; - ["MarketplaceFinalizeIssuanceInput"]: { - /** Идентификатор заказа, который получаем. */ - order_id: ValueTypes["ID"] | Variable, - /** Подписанный заказчиком акт выдачи (поверх подписи председателя). Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями. */ - signed_document: ValueTypes["MarketplaceIssueActSignedDocumentInput"] | Variable + ["MarketplaceFinalizeStockIssuanceInput"]: { + /** Строки докладки с подписью получения (signiss2) из marketplaceStockProposalSignablePayloads. */ + order_lines: Array | Variable, + /** Предложение со склада кооператива. */ + proposal_id: ValueTypes["ID"] | Variable, + /** Единое подписанное Заявление о конвертации на всю сумму доплаты с паевого. Не передаётся, когда членских средств хватает. */ + signed_convert?: ValueTypes["MarketplaceConvertStatementSignedInput"] | undefined | null | Variable }; ["MarketplaceGenerateInventoryLabelInput"]: { /** Формат штрих-кода. По умолчанию — EAN-13. */ @@ -7072,15 +7102,6 @@ export type ValueTypes = { }; /** Состояние единицы имущества на складе КУ. */ ["MarketplaceInventoryStatus"]:MarketplaceInventoryStatus; - /** Результат подписания акта выдачи имущества пайщику. */ -["MarketplaceIssuanceResult"]: AliasType<{ - /** Заказ после применения подписи. */ - order?:ValueTypes["MarketplaceOrder"], - /** Хэш транзакции подписи в блокчейне. */ - tx_hash?:boolean | `@${string}`, - __typename?: boolean | `@${string}`, - ['...on MarketplaceIssuanceResult']?: Omit -}>; ["MarketplaceIssueActPayloadInput"]: { /** Фактически выдаваемое количество для предпросмотра акта (если не указано — берётся заказ). */ actual_quantity?: number | undefined | null | Variable, @@ -7193,6 +7214,21 @@ export type ValueTypes = { ["MarketplaceListAidsInput"]: { /** Показать заявки только этого получателя (по умолчанию — свои). */ username?: string | undefined | null | Variable +}; + /** Параметры фильтрации реестра всех предложений кооператива (стол администратора). */ +["MarketplaceListAllOffersInput"]: { + /** Количество элементов на странице */ + limit: number | Variable, + /** Номер страницы */ + page: number | Variable, + /** Ключ сортировки (например, "name") */ + sortBy?: string | undefined | null | Variable, + /** Направление сортировки ("ASC" или "DESC") */ + sortOrder: string | Variable, + /** Один или несколько статусов предложения, по которым нужно отфильтровать список. */ + statuses?: Array | undefined | null | Variable, + /** Фильтр по аккаунту поставщика. */ + supplier_account?: string | undefined | null | Variable }; ["MarketplaceListAplReceptionsByBranameInput"]: { /** Идентификатор КУ-получателя. */ @@ -7463,16 +7499,6 @@ export type ValueTypes = { __typename?: boolean | `@${string}`, ['...on MarketplaceOnboardingState']?: Omit }>; - ["MarketplaceOpenIssuanceInput"]: { - /** Фактически выдаваемое количество единиц (равно/меньше/больше заказа). */ - actual_quantity: number | Variable, - /** Фактическая цена за единицу (оператор может изменить её при открытии выдачи). */ - actual_unit_price: string | Variable, - /** Идентификатор заказа, выдачу которого открываем. */ - order_id: ValueTypes["ID"] | Variable, - /** Подписанный председателем кооперативного участка акт выдачи. Backend верифицирует подпись и отправляет on-chain первую подпись. */ - signed_document: ValueTypes["MarketplaceIssueActSignedDocumentInput"] | Variable -}; /** Заказ пайщика по предложению поставщика. */ ["MarketplaceOrder"]: AliasType<{ /** Когда поставщик принял заказ. */ @@ -8251,6 +8277,60 @@ export type ValueTypes = { inventory_id: ValueTypes["ID"] | Variable, /** Доли разбиения; сумма количеств обязана равняться количеству позиции. */ splits: Array | Variable +}; + ["MarketplaceStockAcceptOrderLine"]: AliasType<{ + /** Идентификатор предложения позиции. */ + offer_id?:boolean | `@${string}`, + /** order_hash будущего заказа из остатка. */ + order_hash?:boolean | `@${string}`, + /** Акт приёма-передачи, уже подписанный оператором КУ. Пайщик накладывает свою подпись поверх (получение). */ + signiss1_aggregate?:ValueTypes["DocumentAggregate"], + __typename?: boolean | `@${string}`, + ['...on MarketplaceStockAcceptOrderLine']?: Omit +}>; + ["MarketplaceStockAcceptPayload"]: AliasType<{ + /** Сумма доплаты с паевого через конвертацию. Пусто — членских средств хватает, доплаты с паевого нет. */ + convert_amount?:boolean | `@${string}`, + /** Единое Заявление о конвертации к подписи. Пусто — подписывать нечего (доплаты нет). */ + convert_document?:ValueTypes["GeneratedDocument"], + /** Идентификатор Заявления о конвертации (для подписи). */ + convert_hash?:boolean | `@${string}`, + /** Сколько спишется с уже внесённых членских средств «Стола заказов» (доплата с членского). */ + member_amount?:boolean | `@${string}`, + /** Строки-заказы к созданию — вернуть их в принятии предложения. */ + order_lines?:ValueTypes["MarketplaceStockAcceptOrderLine"], + __typename?: boolean | `@${string}`, + ['...on MarketplaceStockAcceptPayload']?: Omit +}>; + ["MarketplaceStockFinalizeLineInput"]: { + /** order_hash строки бандла (из payloads). */ + order_hash: string | Variable, + /** Акт приёма-передачи, контрподписанный пайщиком (подпись получения). */ + signed_signiss2_act: ValueTypes["MarketplaceIssueActSignedDocumentInput"] | Variable +}; + ["MarketplaceStockIssuanceOperatorLine"]: AliasType<{ + /** Предложение кооператива из остатка. */ + offer_id?:boolean | `@${string}`, + /** Детерминированный order_hash будущего заказа из остатка. */ + order_hash?:boolean | `@${string}`, + /** Наименование товара. */ + product_name?:boolean | `@${string}`, + /** Количество единиц. */ + quantity?:boolean | `@${string}`, + /** Акт приёма-передачи к подписи оператором КУ (первая подпись). */ + signiss1_document?:ValueTypes["GeneratedDocument"], + /** Цена за единицу. */ + unit_price?:boolean | `@${string}`, + __typename?: boolean | `@${string}`, + ['...on MarketplaceStockIssuanceOperatorLine']?: Omit +}>; + ["MarketplaceStockIssuancePrepareInput"]: { + /** Кооперативный участок, со склада которого идёт докладка. */ + braname: string | Variable, + /** Корзина докладки: что и сколько предлагается со склада. */ + items: Array | Variable, + /** Пайщик-адресат докладки. */ + member_account: string | Variable }; ["MarketplaceStockProposal"]: AliasType<{ /** Кооперативный участок, со склада которого предложено имущество. */ @@ -8445,6 +8525,54 @@ export type ValueTypes = { ["MarketplaceWithdrawOfferInput"]: { id: string | Variable }; + ["MarketplaceWriteoffCandidate"]: AliasType<{ + /** Суммарная сумма к списанию (закупочная цена × количество, 4 знака). */ + amount?:boolean | `@${string}`, + /** Наименование позиции (из карточки имущества). */ + asset_title?:boolean | `@${string}`, + /** Кооперативный участок (склад), где лежит позиция. */ + braname?:boolean | `@${string}`, + /** Человеко-читаемое наименование кооперативного участка. */ + branch_name?:boolean | `@${string}`, + /** Ближайший срок годности среди партий (ISO). */ + expiry_date?:boolean | `@${string}`, + /** Идентификаторы всех партий на складе, слитых в эту строку-кандидат. */ + inventory_ids?:boolean | `@${string}`, + /** Срок годности истёк (просрочено) — первоочередной кандидат к списанию. false — имущество ещё годно, списывается вручную (порча, невозврат). */ + is_expired?:boolean | `@${string}`, + /** Стабильный ключ строки (склад + наименование + состояние). */ + key?:boolean | `@${string}`, + /** Сколько партий слито в эту строку (для подсказки в интерфейсе). */ + lots_count?:boolean | `@${string}`, + /** Суммарное количество единиц по всем партиям строки. */ + quantity?:boolean | `@${string}`, + /** Причина-кандидат (по умолчанию — истёк срок годности). */ + reason?:boolean | `@${string}`, + __typename?: boolean | `@${string}`, + ['...on MarketplaceWriteoffCandidate']?: Omit +}>; + ["MarketplaceWriteoffConfirmationGroup"]: AliasType<{ + /** Когда совет авторизовал проект (ISO). */ + authorized_at?:boolean | `@${string}`, + /** Кооперативный участок (склад), по которому подтверждается списание. */ + braname?:boolean | `@${string}`, + /** Человеко-читаемое наименование кооперативного участка. */ + branch_name?:boolean | `@${string}`, + /** Начало расчётного цикла списания (ISO). */ + cycle_started_at?:boolean | `@${string}`, + /** Неподтверждённые позиции этого участка. */ + items?:ValueTypes["MarketplaceWriteoffProposalItem"], + /** Канонический хеш проекта (process_hash on-chain). */ + proposal_hash?:boolean | `@${string}`, + /** Идентификатор проекта списания. */ + proposal_id?:boolean | `@${string}`, + /** Протокол совета о списании (документ для просмотра). */ + protocol_doc?:boolean | `@${string}`, + /** Σ сумм позиций участка (4 знака). */ + total_amount?:boolean | `@${string}`, + __typename?: boolean | `@${string}`, + ['...on MarketplaceWriteoffConfirmationGroup']?: Omit +}>; ["MarketplaceWriteoffDecisionEntry"]: AliasType<{ action?:boolean | `@${string}`, actor?:boolean | `@${string}`, @@ -8457,7 +8585,8 @@ export type ValueTypes = { amount: string | Variable, asset_title: string | Variable, braname: string | Variable, - inventory_id?: string | undefined | null | Variable, + /** Идентификаторы партий на складе, которые покрывает эта строка списания. */ + inventory_ids?: Array | undefined | null | Variable, quantity: string | Variable, reason: string | Variable }; @@ -8497,10 +8626,12 @@ export type ValueTypes = { asset_title?:boolean | `@${string}`, /** Кооперативный участок (склад) — источник позиции к списанию. */ braname?:boolean | `@${string}`, + /** Человеко-читаемое наименование кооперативного участка (для показа в интерфейсе). */ + branch_name?:boolean | `@${string}`, /** Признак того, что позиция уже исполнена через execwroff. */ executed?:boolean | `@${string}`, - /** Идентификатор инвентарной позиции, если известна. */ - inventory_id?:boolean | `@${string}`, + /** Идентификаторы партий на складе, слитых в эту строку списания. */ + inventory_ids?:boolean | `@${string}`, /** Количество единиц к списанию. */ quantity?:boolean | `@${string}`, /** Причина списания (срок годности, повреждение и т.п.). */ @@ -8512,6 +8643,16 @@ export type ValueTypes = { ["MarketplaceWriteoffProposalStatus"]:MarketplaceWriteoffProposalStatus; /** Источник проекта списания: автоматический ежемесячный крон или ручное создание. */ ["MarketplaceWriteoffProposalTrigger"]:MarketplaceWriteoffProposalTrigger; + ["MarketplaceWriteoffProtocolDocumentInput"]: { + /** Идентификатор проекта списания. */ + proposal_id: string | Variable +}; + ["MarketplaceWriteoffServiceMemoSignablePayloadInput"]: { + /** Кооперативный участок, по которому подтверждается списание. */ + braname: string | Variable, + /** Идентификатор проекта списания. */ + proposal_id: string | Variable +}; ["MarketplaceWriteoffStatementSignablePayloadInput"]: { draft_id: string | Variable }; @@ -8889,7 +9030,6 @@ markReportPeriod?: [{ data: ValueTypes["MarkReportPeriodInput"] | Variable},ValueTypes["MarketplaceCppStatus"]], marketplaceAcceptOrdersBatch?: [{ input: ValueTypes["MarketplaceAcceptOrdersBatchInput"] | Variable},ValueTypes["MarketplaceSupplierBatchActionResult"]], marketplaceAcceptReturnAtVisit?: [{ data: ValueTypes["MarketplaceAcceptReturnAtVisitInput"] | Variable},ValueTypes["MarketplaceReturnClaimResult"]], -marketplaceAcceptStockProposal?: [{ data: ValueTypes["MarketplaceAcceptStockProposalInput"] | Variable},ValueTypes["MarketplaceStockProposalAcceptResult"]], marketplaceAddAvailableCategories?: [{ input: ValueTypes["AddAvailableCategoriesInput"] | Variable},ValueTypes["MarketplaceAvailableCategory"]], marketplaceAddAvailableCategoryTypes?: [{ input: ValueTypes["AddAvailableCategoryTypesInput"] | Variable},ValueTypes["MarketplaceAvailableCategory"]], marketplaceAddToCart?: [{ input: ValueTypes["MarketplaceAddToCartInput"] | Variable},ValueTypes["MarketplaceCart"]], @@ -8898,6 +9038,7 @@ marketplaceApproveOffer?: [{ input: ValueTypes["MarketplaceApproveOfferInput"] | marketplaceApproveReturnVisit?: [{ data: ValueTypes["MarketplaceApproveReturnVisitInput"] | Variable},ValueTypes["MarketplaceReturnClaimResult"]], marketplaceAssignInventoryShelf?: [{ data: ValueTypes["MarketplaceAssignInventoryShelfInput"] | Variable},ValueTypes["MarketplaceInventoryMutationResult"]], marketplaceBindInventoryBarcode?: [{ data: ValueTypes["MarketplaceBindInventoryBarcodeInput"] | Variable},ValueTypes["MarketplaceInventoryMutationResult"]], +marketplaceCancelAplReception?: [{ data: ValueTypes["MarketplaceAplReceptionByIdInput"] | Variable},ValueTypes["MarketplaceAplReceptionResult"]], marketplaceCancelOrder?: [{ input: ValueTypes["MarketplaceCancelOrderInput"] | Variable},ValueTypes["MarketplaceCancelOrderResult"]], marketplaceCancelStockOrder?: [{ data: ValueTypes["MarketplaceCancelStockOrderInput"] | Variable},ValueTypes["MarketplaceOrder"]], marketplaceCancelStockProposal?: [{ data: ValueTypes["MarketplaceResolveStockProposalInput"] | Variable},ValueTypes["MarketplaceStockProposal"]], @@ -8910,6 +9051,7 @@ marketplaceCheckoutCart?: [{ input?: ValueTypes["MarketplaceCheckoutCartInput"] /** Очистить корзину (убрать все позиции). */ marketplaceClearCart?:ValueTypes["MarketplaceCart"], marketplaceClearInventoryLabel?: [{ data: ValueTypes["MarketplaceClearInventoryLabelInput"] | Variable},ValueTypes["MarketplaceInventoryMutationResult"]], +marketplaceConfirmWriteoff?: [{ data: ValueTypes["MarketplaceConfirmWriteoffInput"] | Variable},ValueTypes["MarketplaceWriteoffProposal"]], marketplaceConvertBranchFunds?: [{ data: ValueTypes["MarketplaceConvertBranchFundsInput"] | Variable},boolean | `@${string}`], marketplaceCreateAid?: [{ data: ValueTypes["MarketplaceCreateAidInput"] | Variable},boolean | `@${string}`], marketplaceCreateAplReception?: [{ data: ValueTypes["MarketplaceCreateAplReceptionInput"] | Variable},ValueTypes["MarketplaceAplReceptionResult"]], @@ -8927,9 +9069,8 @@ marketplaceDeleteCustomCategory?: [{ categoryId: number | Variable} marketplaceDeleteTrusteeWeight?: [{ data: ValueTypes["MarketplaceDeleteTrusteeWeightInput"] | Variable},boolean | `@${string}`], marketplaceDetailKU?: [{ data: ValueTypes["MarketplaceDetailKUInput"] | Variable},ValueTypes["MarketplaceKUDetails"]], marketplaceDistributeBranchFunds?: [{ data: ValueTypes["MarketplaceDistributeBranchFundsInput"] | Variable},boolean | `@${string}`], -marketplaceFinalizeIssuance?: [{ data: ValueTypes["MarketplaceFinalizeIssuanceInput"] | Variable},ValueTypes["MarketplaceIssuanceResult"]], +marketplaceFinalizeStockIssuance?: [{ data: ValueTypes["MarketplaceFinalizeStockIssuanceInput"] | Variable},ValueTypes["MarketplaceStockProposalAcceptResult"]], marketplaceGenerateInventoryLabel?: [{ data: ValueTypes["MarketplaceGenerateInventoryLabelInput"] | Variable},ValueTypes["MarketplaceInventoryMutationResult"]], -marketplaceOpenIssuance?: [{ data: ValueTypes["MarketplaceOpenIssuanceInput"] | Variable},ValueTypes["MarketplaceIssuanceResult"]], marketplacePublishStock?: [{ data: ValueTypes["MarketplacePublishStockInput"] | Variable},ValueTypes["MarketplaceOffer"]], marketplaceRejectOffer?: [{ input: ValueTypes["MarketplaceRejectOfferInput"] | Variable},ValueTypes["MarketplaceOffer"]], marketplaceRejectReturnAtVisit?: [{ data: ValueTypes["MarketplaceRejectReturnAtVisitInput"] | Variable},ValueTypes["MarketplaceReturnClaimResult"]], @@ -10304,6 +10445,7 @@ getReportPreview?: [{ input: ValueTypes["ReportPreviewInput"] | Variable, username: string | Variable},ValueTypes["UserWallet"]], getUserWebPushSubscriptions?: [{ data: ValueTypes["GetUserSubscriptionsInput"] | Variable},ValueTypes["WebPushSubscriptionDto"]], /** Получить статистику веб-пуш подписок (только для председателя) @@ -10367,8 +10509,9 @@ marketplaceGetShipment?: [{ data: ValueTypes["MarketplaceGetShipmentInput"] | Va marketplaceGetSupplierPaymentSettings?:ValueTypes["MarketplaceSupplierPaymentSettings"], marketplaceGetUserRequests?: [{ data?: ValueTypes["GetUserRequestsInput"] | undefined | null | Variable},ValueTypes["MarketplaceRequest"]], marketplaceIssueActChairmanSignablePayload?: [{ data: ValueTypes["MarketplaceIssueActPayloadInput"] | Variable},ValueTypes["GeneratedDocument"]], -marketplaceIssueActOrdererSignablePayload?: [{ data: ValueTypes["MarketplaceIssueActPayloadInput"] | Variable},ValueTypes["DocumentAggregate"]], marketplaceListAids?: [{ data?: ValueTypes["MarketplaceListAidsInput"] | undefined | null | Variable},ValueTypes["MarketplaceAid"]], +marketplaceListAllOffers?: [{ input?: ValueTypes["MarketplaceListAllOffersInput"] | undefined | null | Variable},ValueTypes["MarketplaceOfferPaginationResult"]], +marketplaceListAllOrders?: [{ input?: ValueTypes["MarketplaceListOrdersInput"] | undefined | null | Variable, options?: ValueTypes["PaginationInput"] | undefined | null | Variable},ValueTypes["MarketplaceOrderPaginationResult"]], /** Список актов приёмки, ожидающих подписи текущего поставщика. */ marketplaceListAplReceptionsAsSupplier?:ValueTypes["MarketplaceAplReception"], marketplaceListAplReceptionsByBraname?: [{ data: ValueTypes["MarketplaceListAplReceptionsByBranameInput"] | Variable},ValueTypes["MarketplaceAplReception"]], @@ -10389,8 +10532,6 @@ marketplaceListKUDetails?: [{ data: ValueTypes["ListMarketplaceKUInput"] | Varia marketplaceListModerationLog?: [{ offer_id: string | Variable},ValueTypes["MarketplaceModerationLogEntry"]], marketplaceListMyOffers?: [{ input?: ValueTypes["MarketplaceListMyOffersInput"] | undefined | null | Variable},ValueTypes["MarketplaceOfferPaginationResult"]], marketplaceListMyOrders?: [{ input?: ValueTypes["MarketplaceListOrdersInput"] | undefined | null | Variable, options?: ValueTypes["PaginationInput"] | undefined | null | Variable},ValueTypes["MarketplaceOrderPaginationResult"]], - /** Список заказов текущего пайщика, готовых к получению на пункте выдачи. */ - marketplaceListMyReadyToReceive?:ValueTypes["MarketplaceOrder"], /** Все заявления текущего пайщика на гарантийный возврат — активные и архивные. */ marketplaceListMyReturnClaims?:ValueTypes["MarketplaceReturnClaim"], marketplaceListOutgoingPayments?: [{ filter?: ValueTypes["MarketplaceListOutgoingPaymentsFilterInput"] | undefined | null | Variable},ValueTypes["MarketplaceOutgoingPaymentRequest"]], @@ -10405,6 +10546,8 @@ marketplaceListSupplierOrders?: [{ input?: ValueTypes["MarketplaceListOrdersInpu marketplaceListSupplierPickupOrders?: [{ data: ValueTypes["MarketplaceListSupplierPickupOrdersInput"] | Variable},ValueTypes["MarketplaceOrder"]], /** Список пайщиков-поставщиков, допущенных к публикации оферт */ marketplaceListWhitelist?:ValueTypes["MarketplaceWhitelistEntry"], + /** Кандидаты на списание скоропорта: просроченные позиции на складах кооператива. Председатель выделяет нужные и создаёт из них черновик проекта списания. */ + marketplaceListWriteoffCandidates?:ValueTypes["MarketplaceWriteoffCandidate"], marketplaceListWriteoffProposals?: [{ data: ValueTypes["MarketplaceListWriteoffProposalsInput"] | Variable, options?: ValueTypes["PaginationInput"] | undefined | null | Variable},ValueTypes["PaginatedMarketplaceWriteoffProposals"]], /** Кошельки пайщика, релевантные столу заказов: w.wal.share, w.wal.member, w.mkt.order */ marketplaceMemberWallet?:ValueTypes["MarketplaceMemberWallet"], @@ -10421,11 +10564,16 @@ marketplaceReturnClaimSignablePayload?: [{ data: ValueTypes["MarketplaceReturnCl marketplaceSearchAttributes?: [{ input: ValueTypes["SearchAttributesInput"] | Variable},ValueTypes["MarketplaceAttribute"]], marketplaceSearchDictionaryValues?: [{ input: ValueTypes["SearchDictionaryValuesInput"] | Variable},ValueTypes["MarketplaceDictionaryValue"]], marketplaceSearchRequests?: [{ data: ValueTypes["SearchRequestsInput"] | Variable},ValueTypes["MarketplaceRequest"]], -marketplaceStockProposalSignablePayloads?: [{ data: ValueTypes["MarketplaceResolveStockProposalInput"] | Variable},ValueTypes["MarketplaceCheckoutSignableLine"]], +marketplaceStockIssuancePayloads?: [{ data: ValueTypes["MarketplaceStockIssuancePrepareInput"] | Variable},ValueTypes["MarketplaceStockIssuanceOperatorLine"]], +marketplaceStockProposalSignablePayloads?: [{ data: ValueTypes["MarketplaceResolveStockProposalInput"] | Variable},ValueTypes["MarketplaceStockAcceptPayload"]], marketplaceValidateAttributeValues?: [{ input: ValueTypes["ValidateAttributeValuesInput"] | Variable},ValueTypes["MarketplaceAttributeValidation"]], /** Контекст пайщика для Стола заказов: core_roles + marketplace_roles + участки оператора */ marketplaceWhoAmI?:ValueTypes["MarketplaceCurrentMember"], + /** Группы списаний, ожидающих подтверждения складом: по проекту, одобренному советом, — отдельная строка на каждый кооперативный участок. Председатель КУ видит только свои участки. */ + marketplaceWriteoffPendingConfirmations?:ValueTypes["MarketplaceWriteoffConfirmationGroup"], marketplaceWriteoffProposal?: [{ id: string | Variable},ValueTypes["MarketplaceWriteoffProposal"]], +marketplaceWriteoffProtocolDocument?: [{ data: ValueTypes["MarketplaceWriteoffProtocolDocumentInput"] | Variable},ValueTypes["DocumentAggregate"]], +marketplaceWriteoffServiceMemoSignablePayload?: [{ data: ValueTypes["MarketplaceWriteoffServiceMemoSignablePayloadInput"] | Variable},ValueTypes["GeneratedDocument"]], marketplaceWriteoffStatementSignablePayload?: [{ data: ValueTypes["MarketplaceWriteoffStatementSignablePayloadInput"] | Variable},ValueTypes["GeneratedDocument"]], onecoopGetDocuments?: [{ data: ValueTypes["GetOneCoopDocumentsInput"] | Variable},ValueTypes["OneCoopDocumentsResponse"]], process?: [{ coopname: string | Variable, hash: string | Variable},ValueTypes["ProcessView"]], @@ -11783,6 +11931,26 @@ marketplaceEvents?: [{ input: ValueTypes["MarketplaceEventsInput"] | Variable; /** Статус пользователя */ ["UserStatus"]:UserStatus; + ["UserWallet"]: AliasType<{ + /** Доступный остаток (формат: "100.0000 RUB") */ + available?:boolean | `@${string}`, + /** Заблокированный остаток (формат: "0.0000 RUB") */ + blocked?:boolean | `@${string}`, + /** Имя кооператива */ + coopname?:boolean | `@${string}`, + /** Человекочитаемое название кошелька */ + human_name?:boolean | `@${string}`, + /** Идентификатор записи кошелька */ + id?:boolean | `@${string}`, + /** Идентификатор программы кошелька */ + program_id?:boolean | `@${string}`, + /** Имя пользователя */ + username?:boolean | `@${string}`, + /** Идентификатор кошелька */ + wallet_name?:boolean | `@${string}`, + __typename?: boolean | `@${string}`, + ['...on UserWallet']?: Omit +}>; ["ValidateAttributeValuesInput"]: { /** ID атрибута */ attributeId: number | Variable, @@ -12032,6 +12200,8 @@ marketplaceEvents?: [{ input: ValueTypes["MarketplaceEventsInput"] | Variable; + /** Вид аккаунта: пайщик, кооперативный участок, кооператив или нераспознанный */ +["AccountKind"]:AccountKind; ["AccountRamDelta"]: AliasType<{ account?:boolean | `@${string}`, delta?:boolean | `@${string}`, @@ -16936,12 +17108,6 @@ export type ResolverInputTypes = { scanned_barcode?: string | undefined | null, /** Заявление пайщика со второй подписью председателя — принятие возврата оформляется со-подписью на том же документе. */ signed_statement?: ResolverInputTypes["MarketplaceReturnStatementSignedInput"] | undefined | null -}; - ["MarketplaceAcceptStockProposalInput"]: { - /** Подписанные заявления о конвертации паевого взноса — по одному на каждую строку предложения (из превью marketplaceStockProposalSignablePayloads). */ - lines?: Array | undefined | null, - /** Предложение со склада кооператива. */ - proposal_id: ResolverInputTypes["ID"] }; /** Добавить позицию в корзину (с привязкой к пункту выдачи). */ ["MarketplaceAddToCartInput"]: { @@ -17456,6 +17622,14 @@ export type ResolverInputTypes = { ["MarketplaceClearInventoryLabelInput"]: { /** Позиция склада, с которой снимается штрих-код (для переклейки). */ inventory_id: ResolverInputTypes["ID"] +}; + ["MarketplaceConfirmWriteoffInput"]: { + /** Кооперативный участок, по которому подтверждается списание. */ + braname: string, + /** Идентификатор проекта списания. */ + proposal_id: string, + /** Подписанная председателем КУ Служебная записка о списании (registry_id=1111). */ + signed_memo: ResolverInputTypes["SignedDigitalDocumentInput"] }; ["MarketplaceConsolidatedRequest"]: AliasType<{ accepted_at?:boolean | `@${string}`, @@ -17595,6 +17769,16 @@ export type ResolverInputTypes = { unit_of_measure: string, unlimited_flag: boolean, warranty_days: number +}; + ["MarketplaceCreateOrderProposalLineInput"]: { + /** Фактическое количество к выдаче (сверено оператором). */ + actual_quantity: number, + /** Фактическая цена за единицу (оператор мог скорректировать). */ + actual_unit_price: string, + /** Существующий заказ пайщика к выдаче этим бандлом. */ + order_id: ResolverInputTypes["ID"], + /** Акт приёма-передачи, подписанный оператором КУ первой подписью. */ + signiss1_act: ResolverInputTypes["MarketplaceIssueActSignedDocumentInput"] }; ["MarketplaceCreateReturnClaimInput"]: { /** Возвращаемое количество единиц (по умолчанию — выданное количество). */ @@ -17622,12 +17806,24 @@ export type ResolverInputTypes = { __typename?: boolean | `@${string}` }>; ["MarketplaceCreateStockProposalInput"]: { - /** Кооперативный участок, со склада которого идёт докладка. */ + /** Кооперативный участок, со склада которого идёт выдача. */ braname: string, - /** Строки предложения: что и сколько предлагается со склада. */ - items: Array, - /** Пайщик-адресат предложения. */ - member_account: string + /** Строки докладки со склада: order_hash и подпись оператора (signiss1). */ + items?: Array | undefined | null, + /** Пайщик-адресат бандла. */ + member_account: string, + /** Строки уже существующих заказов пайщика к выдаче (подпись оператора signiss1). */ + order_items?: Array | undefined | null +}; + ["MarketplaceCreateStockProposalLineInput"]: { + /** Предложение кооператива из остатка. */ + offer_id: ResolverInputTypes["ID"], + /** order_hash из подготовки (marketplaceStockIssuancePayloads). */ + order_hash: string, + /** Количество единиц. */ + quantity: number, + /** Акт приёма-передачи, подписанный оператором КУ первой подписью. */ + signiss1_act: ResolverInputTypes["MarketplaceIssueActSignedDocumentInput"] }; ["MarketplaceCreateWriteoffDraftInput"]: { /** Начало расчётного цикла, если отличается от текущего момента. */ @@ -17743,11 +17939,13 @@ export type ResolverInputTypes = { total_units?:boolean | `@${string}`, __typename?: boolean | `@${string}` }>; - ["MarketplaceFinalizeIssuanceInput"]: { - /** Идентификатор заказа, который получаем. */ - order_id: ResolverInputTypes["ID"], - /** Подписанный заказчиком акт выдачи (поверх подписи председателя). Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями. */ - signed_document: ResolverInputTypes["MarketplaceIssueActSignedDocumentInput"] + ["MarketplaceFinalizeStockIssuanceInput"]: { + /** Строки докладки с подписью получения (signiss2) из marketplaceStockProposalSignablePayloads. */ + order_lines: Array, + /** Предложение со склада кооператива. */ + proposal_id: ResolverInputTypes["ID"], + /** Единое подписанное Заявление о конвертации на всю сумму доплаты с паевого. Не передаётся, когда членских средств хватает. */ + signed_convert?: ResolverInputTypes["MarketplaceConvertStatementSignedInput"] | undefined | null }; ["MarketplaceGenerateInventoryLabelInput"]: { /** Формат штрих-кода. По умолчанию — EAN-13. */ @@ -17833,14 +18031,6 @@ export type ResolverInputTypes = { }; /** Состояние единицы имущества на складе КУ. */ ["MarketplaceInventoryStatus"]:MarketplaceInventoryStatus; - /** Результат подписания акта выдачи имущества пайщику. */ -["MarketplaceIssuanceResult"]: AliasType<{ - /** Заказ после применения подписи. */ - order?:ResolverInputTypes["MarketplaceOrder"], - /** Хэш транзакции подписи в блокчейне. */ - tx_hash?:boolean | `@${string}`, - __typename?: boolean | `@${string}` -}>; ["MarketplaceIssueActPayloadInput"]: { /** Фактически выдаваемое количество для предпросмотра акта (если не указано — берётся заказ). */ actual_quantity?: number | undefined | null, @@ -17952,6 +18142,21 @@ export type ResolverInputTypes = { ["MarketplaceListAidsInput"]: { /** Показать заявки только этого получателя (по умолчанию — свои). */ username?: string | undefined | null +}; + /** Параметры фильтрации реестра всех предложений кооператива (стол администратора). */ +["MarketplaceListAllOffersInput"]: { + /** Количество элементов на странице */ + limit: number, + /** Номер страницы */ + page: number, + /** Ключ сортировки (например, "name") */ + sortBy?: string | undefined | null, + /** Направление сортировки ("ASC" или "DESC") */ + sortOrder: string, + /** Один или несколько статусов предложения, по которым нужно отфильтровать список. */ + statuses?: Array | undefined | null, + /** Фильтр по аккаунту поставщика. */ + supplier_account?: string | undefined | null }; ["MarketplaceListAplReceptionsByBranameInput"]: { /** Идентификатор КУ-получателя. */ @@ -18212,16 +18417,6 @@ export type ResolverInputTypes = { template_registry_id?:boolean | `@${string}`, __typename?: boolean | `@${string}` }>; - ["MarketplaceOpenIssuanceInput"]: { - /** Фактически выдаваемое количество единиц (равно/меньше/больше заказа). */ - actual_quantity: number, - /** Фактическая цена за единицу (оператор может изменить её при открытии выдачи). */ - actual_unit_price: string, - /** Идентификатор заказа, выдачу которого открываем. */ - order_id: ResolverInputTypes["ID"], - /** Подписанный председателем кооперативного участка акт выдачи. Backend верифицирует подпись и отправляет on-chain первую подпись. */ - signed_document: ResolverInputTypes["MarketplaceIssueActSignedDocumentInput"] -}; /** Заказ пайщика по предложению поставщика. */ ["MarketplaceOrder"]: AliasType<{ /** Когда поставщик принял заказ. */ @@ -18974,6 +19169,57 @@ export type ResolverInputTypes = { inventory_id: ResolverInputTypes["ID"], /** Доли разбиения; сумма количеств обязана равняться количеству позиции. */ splits: Array +}; + ["MarketplaceStockAcceptOrderLine"]: AliasType<{ + /** Идентификатор предложения позиции. */ + offer_id?:boolean | `@${string}`, + /** order_hash будущего заказа из остатка. */ + order_hash?:boolean | `@${string}`, + /** Акт приёма-передачи, уже подписанный оператором КУ. Пайщик накладывает свою подпись поверх (получение). */ + signiss1_aggregate?:ResolverInputTypes["DocumentAggregate"], + __typename?: boolean | `@${string}` +}>; + ["MarketplaceStockAcceptPayload"]: AliasType<{ + /** Сумма доплаты с паевого через конвертацию. Пусто — членских средств хватает, доплаты с паевого нет. */ + convert_amount?:boolean | `@${string}`, + /** Единое Заявление о конвертации к подписи. Пусто — подписывать нечего (доплаты нет). */ + convert_document?:ResolverInputTypes["GeneratedDocument"], + /** Идентификатор Заявления о конвертации (для подписи). */ + convert_hash?:boolean | `@${string}`, + /** Сколько спишется с уже внесённых членских средств «Стола заказов» (доплата с членского). */ + member_amount?:boolean | `@${string}`, + /** Строки-заказы к созданию — вернуть их в принятии предложения. */ + order_lines?:ResolverInputTypes["MarketplaceStockAcceptOrderLine"], + __typename?: boolean | `@${string}` +}>; + ["MarketplaceStockFinalizeLineInput"]: { + /** order_hash строки бандла (из payloads). */ + order_hash: string, + /** Акт приёма-передачи, контрподписанный пайщиком (подпись получения). */ + signed_signiss2_act: ResolverInputTypes["MarketplaceIssueActSignedDocumentInput"] +}; + ["MarketplaceStockIssuanceOperatorLine"]: AliasType<{ + /** Предложение кооператива из остатка. */ + offer_id?:boolean | `@${string}`, + /** Детерминированный order_hash будущего заказа из остатка. */ + order_hash?:boolean | `@${string}`, + /** Наименование товара. */ + product_name?:boolean | `@${string}`, + /** Количество единиц. */ + quantity?:boolean | `@${string}`, + /** Акт приёма-передачи к подписи оператором КУ (первая подпись). */ + signiss1_document?:ResolverInputTypes["GeneratedDocument"], + /** Цена за единицу. */ + unit_price?:boolean | `@${string}`, + __typename?: boolean | `@${string}` +}>; + ["MarketplaceStockIssuancePrepareInput"]: { + /** Кооперативный участок, со склада которого идёт докладка. */ + braname: string, + /** Корзина докладки: что и сколько предлагается со склада. */ + items: Array, + /** Пайщик-адресат докладки. */ + member_account: string }; ["MarketplaceStockProposal"]: AliasType<{ /** Кооперативный участок, со склада которого предложено имущество. */ @@ -19156,6 +19402,52 @@ export type ResolverInputTypes = { ["MarketplaceWithdrawOfferInput"]: { id: string }; + ["MarketplaceWriteoffCandidate"]: AliasType<{ + /** Суммарная сумма к списанию (закупочная цена × количество, 4 знака). */ + amount?:boolean | `@${string}`, + /** Наименование позиции (из карточки имущества). */ + asset_title?:boolean | `@${string}`, + /** Кооперативный участок (склад), где лежит позиция. */ + braname?:boolean | `@${string}`, + /** Человеко-читаемое наименование кооперативного участка. */ + branch_name?:boolean | `@${string}`, + /** Ближайший срок годности среди партий (ISO). */ + expiry_date?:boolean | `@${string}`, + /** Идентификаторы всех партий на складе, слитых в эту строку-кандидат. */ + inventory_ids?:boolean | `@${string}`, + /** Срок годности истёк (просрочено) — первоочередной кандидат к списанию. false — имущество ещё годно, списывается вручную (порча, невозврат). */ + is_expired?:boolean | `@${string}`, + /** Стабильный ключ строки (склад + наименование + состояние). */ + key?:boolean | `@${string}`, + /** Сколько партий слито в эту строку (для подсказки в интерфейсе). */ + lots_count?:boolean | `@${string}`, + /** Суммарное количество единиц по всем партиям строки. */ + quantity?:boolean | `@${string}`, + /** Причина-кандидат (по умолчанию — истёк срок годности). */ + reason?:boolean | `@${string}`, + __typename?: boolean | `@${string}` +}>; + ["MarketplaceWriteoffConfirmationGroup"]: AliasType<{ + /** Когда совет авторизовал проект (ISO). */ + authorized_at?:boolean | `@${string}`, + /** Кооперативный участок (склад), по которому подтверждается списание. */ + braname?:boolean | `@${string}`, + /** Человеко-читаемое наименование кооперативного участка. */ + branch_name?:boolean | `@${string}`, + /** Начало расчётного цикла списания (ISO). */ + cycle_started_at?:boolean | `@${string}`, + /** Неподтверждённые позиции этого участка. */ + items?:ResolverInputTypes["MarketplaceWriteoffProposalItem"], + /** Канонический хеш проекта (process_hash on-chain). */ + proposal_hash?:boolean | `@${string}`, + /** Идентификатор проекта списания. */ + proposal_id?:boolean | `@${string}`, + /** Протокол совета о списании (документ для просмотра). */ + protocol_doc?:boolean | `@${string}`, + /** Σ сумм позиций участка (4 знака). */ + total_amount?:boolean | `@${string}`, + __typename?: boolean | `@${string}` +}>; ["MarketplaceWriteoffDecisionEntry"]: AliasType<{ action?:boolean | `@${string}`, actor?:boolean | `@${string}`, @@ -19167,7 +19459,8 @@ export type ResolverInputTypes = { amount: string, asset_title: string, braname: string, - inventory_id?: string | undefined | null, + /** Идентификаторы партий на складе, которые покрывает эта строка списания. */ + inventory_ids?: Array | undefined | null, quantity: string, reason: string }; @@ -19206,10 +19499,12 @@ export type ResolverInputTypes = { asset_title?:boolean | `@${string}`, /** Кооперативный участок (склад) — источник позиции к списанию. */ braname?:boolean | `@${string}`, + /** Человеко-читаемое наименование кооперативного участка (для показа в интерфейсе). */ + branch_name?:boolean | `@${string}`, /** Признак того, что позиция уже исполнена через execwroff. */ executed?:boolean | `@${string}`, - /** Идентификатор инвентарной позиции, если известна. */ - inventory_id?:boolean | `@${string}`, + /** Идентификаторы партий на складе, слитых в эту строку списания. */ + inventory_ids?:boolean | `@${string}`, /** Количество единиц к списанию. */ quantity?:boolean | `@${string}`, /** Причина списания (срок годности, повреждение и т.п.). */ @@ -19220,6 +19515,16 @@ export type ResolverInputTypes = { ["MarketplaceWriteoffProposalStatus"]:MarketplaceWriteoffProposalStatus; /** Источник проекта списания: автоматический ежемесячный крон или ручное создание. */ ["MarketplaceWriteoffProposalTrigger"]:MarketplaceWriteoffProposalTrigger; + ["MarketplaceWriteoffProtocolDocumentInput"]: { + /** Идентификатор проекта списания. */ + proposal_id: string +}; + ["MarketplaceWriteoffServiceMemoSignablePayloadInput"]: { + /** Кооперативный участок, по которому подтверждается списание. */ + braname: string, + /** Идентификатор проекта списания. */ + proposal_id: string +}; ["MarketplaceWriteoffStatementSignablePayloadInput"]: { draft_id: string }; @@ -19587,7 +19892,6 @@ markReportPeriod?: [{ data: ResolverInputTypes["MarkReportPeriodInput"]},boolean marketplaceAcceptCpp?: [{ input: ResolverInputTypes["MarketplaceAcceptCppInput"]},ResolverInputTypes["MarketplaceCppStatus"]], marketplaceAcceptOrdersBatch?: [{ input: ResolverInputTypes["MarketplaceAcceptOrdersBatchInput"]},ResolverInputTypes["MarketplaceSupplierBatchActionResult"]], marketplaceAcceptReturnAtVisit?: [{ data: ResolverInputTypes["MarketplaceAcceptReturnAtVisitInput"]},ResolverInputTypes["MarketplaceReturnClaimResult"]], -marketplaceAcceptStockProposal?: [{ data: ResolverInputTypes["MarketplaceAcceptStockProposalInput"]},ResolverInputTypes["MarketplaceStockProposalAcceptResult"]], marketplaceAddAvailableCategories?: [{ input: ResolverInputTypes["AddAvailableCategoriesInput"]},ResolverInputTypes["MarketplaceAvailableCategory"]], marketplaceAddAvailableCategoryTypes?: [{ input: ResolverInputTypes["AddAvailableCategoryTypesInput"]},ResolverInputTypes["MarketplaceAvailableCategory"]], marketplaceAddToCart?: [{ input: ResolverInputTypes["MarketplaceAddToCartInput"]},ResolverInputTypes["MarketplaceCart"]], @@ -19596,6 +19900,7 @@ marketplaceApproveOffer?: [{ input: ResolverInputTypes["MarketplaceApproveOfferI marketplaceApproveReturnVisit?: [{ data: ResolverInputTypes["MarketplaceApproveReturnVisitInput"]},ResolverInputTypes["MarketplaceReturnClaimResult"]], marketplaceAssignInventoryShelf?: [{ data: ResolverInputTypes["MarketplaceAssignInventoryShelfInput"]},ResolverInputTypes["MarketplaceInventoryMutationResult"]], marketplaceBindInventoryBarcode?: [{ data: ResolverInputTypes["MarketplaceBindInventoryBarcodeInput"]},ResolverInputTypes["MarketplaceInventoryMutationResult"]], +marketplaceCancelAplReception?: [{ data: ResolverInputTypes["MarketplaceAplReceptionByIdInput"]},ResolverInputTypes["MarketplaceAplReceptionResult"]], marketplaceCancelOrder?: [{ input: ResolverInputTypes["MarketplaceCancelOrderInput"]},ResolverInputTypes["MarketplaceCancelOrderResult"]], marketplaceCancelStockOrder?: [{ data: ResolverInputTypes["MarketplaceCancelStockOrderInput"]},ResolverInputTypes["MarketplaceOrder"]], marketplaceCancelStockProposal?: [{ data: ResolverInputTypes["MarketplaceResolveStockProposalInput"]},ResolverInputTypes["MarketplaceStockProposal"]], @@ -19608,6 +19913,7 @@ marketplaceCheckoutCart?: [{ input?: ResolverInputTypes["MarketplaceCheckoutCart /** Очистить корзину (убрать все позиции). */ marketplaceClearCart?:ResolverInputTypes["MarketplaceCart"], marketplaceClearInventoryLabel?: [{ data: ResolverInputTypes["MarketplaceClearInventoryLabelInput"]},ResolverInputTypes["MarketplaceInventoryMutationResult"]], +marketplaceConfirmWriteoff?: [{ data: ResolverInputTypes["MarketplaceConfirmWriteoffInput"]},ResolverInputTypes["MarketplaceWriteoffProposal"]], marketplaceConvertBranchFunds?: [{ data: ResolverInputTypes["MarketplaceConvertBranchFundsInput"]},boolean | `@${string}`], marketplaceCreateAid?: [{ data: ResolverInputTypes["MarketplaceCreateAidInput"]},boolean | `@${string}`], marketplaceCreateAplReception?: [{ data: ResolverInputTypes["MarketplaceCreateAplReceptionInput"]},ResolverInputTypes["MarketplaceAplReceptionResult"]], @@ -19625,9 +19931,8 @@ marketplaceDeleteCustomCategory?: [{ categoryId: number},boolean | `@${string}`] marketplaceDeleteTrusteeWeight?: [{ data: ResolverInputTypes["MarketplaceDeleteTrusteeWeightInput"]},boolean | `@${string}`], marketplaceDetailKU?: [{ data: ResolverInputTypes["MarketplaceDetailKUInput"]},ResolverInputTypes["MarketplaceKUDetails"]], marketplaceDistributeBranchFunds?: [{ data: ResolverInputTypes["MarketplaceDistributeBranchFundsInput"]},boolean | `@${string}`], -marketplaceFinalizeIssuance?: [{ data: ResolverInputTypes["MarketplaceFinalizeIssuanceInput"]},ResolverInputTypes["MarketplaceIssuanceResult"]], +marketplaceFinalizeStockIssuance?: [{ data: ResolverInputTypes["MarketplaceFinalizeStockIssuanceInput"]},ResolverInputTypes["MarketplaceStockProposalAcceptResult"]], marketplaceGenerateInventoryLabel?: [{ data: ResolverInputTypes["MarketplaceGenerateInventoryLabelInput"]},ResolverInputTypes["MarketplaceInventoryMutationResult"]], -marketplaceOpenIssuance?: [{ data: ResolverInputTypes["MarketplaceOpenIssuanceInput"]},ResolverInputTypes["MarketplaceIssuanceResult"]], marketplacePublishStock?: [{ data: ResolverInputTypes["MarketplacePublishStockInput"]},ResolverInputTypes["MarketplaceOffer"]], marketplaceRejectOffer?: [{ input: ResolverInputTypes["MarketplaceRejectOfferInput"]},ResolverInputTypes["MarketplaceOffer"]], marketplaceRejectReturnAtVisit?: [{ data: ResolverInputTypes["MarketplaceRejectReturnAtVisitInput"]},ResolverInputTypes["MarketplaceReturnClaimResult"]], @@ -20945,6 +21250,7 @@ getReportPreview?: [{ input: ResolverInputTypes["ReportPreviewInput"]},ResolverI getReportRequisites?:ResolverInputTypes["ReportRequisitesView"], /** Получить сводную публичную информацию о системе */ getSystemInfo?:ResolverInputTypes["SystemInfo"], +getUserWallets?: [{ coopname?: string | undefined | null, username: string},ResolverInputTypes["UserWallet"]], getUserWebPushSubscriptions?: [{ data: ResolverInputTypes["GetUserSubscriptionsInput"]},ResolverInputTypes["WebPushSubscriptionDto"]], /** Получить статистику веб-пуш подписок (только для председателя) @@ -21008,8 +21314,9 @@ marketplaceGetShipment?: [{ data: ResolverInputTypes["MarketplaceGetShipmentInpu marketplaceGetSupplierPaymentSettings?:ResolverInputTypes["MarketplaceSupplierPaymentSettings"], marketplaceGetUserRequests?: [{ data?: ResolverInputTypes["GetUserRequestsInput"] | undefined | null},ResolverInputTypes["MarketplaceRequest"]], marketplaceIssueActChairmanSignablePayload?: [{ data: ResolverInputTypes["MarketplaceIssueActPayloadInput"]},ResolverInputTypes["GeneratedDocument"]], -marketplaceIssueActOrdererSignablePayload?: [{ data: ResolverInputTypes["MarketplaceIssueActPayloadInput"]},ResolverInputTypes["DocumentAggregate"]], marketplaceListAids?: [{ data?: ResolverInputTypes["MarketplaceListAidsInput"] | undefined | null},ResolverInputTypes["MarketplaceAid"]], +marketplaceListAllOffers?: [{ input?: ResolverInputTypes["MarketplaceListAllOffersInput"] | undefined | null},ResolverInputTypes["MarketplaceOfferPaginationResult"]], +marketplaceListAllOrders?: [{ input?: ResolverInputTypes["MarketplaceListOrdersInput"] | undefined | null, options?: ResolverInputTypes["PaginationInput"] | undefined | null},ResolverInputTypes["MarketplaceOrderPaginationResult"]], /** Список актов приёмки, ожидающих подписи текущего поставщика. */ marketplaceListAplReceptionsAsSupplier?:ResolverInputTypes["MarketplaceAplReception"], marketplaceListAplReceptionsByBraname?: [{ data: ResolverInputTypes["MarketplaceListAplReceptionsByBranameInput"]},ResolverInputTypes["MarketplaceAplReception"]], @@ -21030,8 +21337,6 @@ marketplaceListKUDetails?: [{ data: ResolverInputTypes["ListMarketplaceKUInput"] marketplaceListModerationLog?: [{ offer_id: string},ResolverInputTypes["MarketplaceModerationLogEntry"]], marketplaceListMyOffers?: [{ input?: ResolverInputTypes["MarketplaceListMyOffersInput"] | undefined | null},ResolverInputTypes["MarketplaceOfferPaginationResult"]], marketplaceListMyOrders?: [{ input?: ResolverInputTypes["MarketplaceListOrdersInput"] | undefined | null, options?: ResolverInputTypes["PaginationInput"] | undefined | null},ResolverInputTypes["MarketplaceOrderPaginationResult"]], - /** Список заказов текущего пайщика, готовых к получению на пункте выдачи. */ - marketplaceListMyReadyToReceive?:ResolverInputTypes["MarketplaceOrder"], /** Все заявления текущего пайщика на гарантийный возврат — активные и архивные. */ marketplaceListMyReturnClaims?:ResolverInputTypes["MarketplaceReturnClaim"], marketplaceListOutgoingPayments?: [{ filter?: ResolverInputTypes["MarketplaceListOutgoingPaymentsFilterInput"] | undefined | null},ResolverInputTypes["MarketplaceOutgoingPaymentRequest"]], @@ -21046,6 +21351,8 @@ marketplaceListSupplierOrders?: [{ input?: ResolverInputTypes["MarketplaceListOr marketplaceListSupplierPickupOrders?: [{ data: ResolverInputTypes["MarketplaceListSupplierPickupOrdersInput"]},ResolverInputTypes["MarketplaceOrder"]], /** Список пайщиков-поставщиков, допущенных к публикации оферт */ marketplaceListWhitelist?:ResolverInputTypes["MarketplaceWhitelistEntry"], + /** Кандидаты на списание скоропорта: просроченные позиции на складах кооператива. Председатель выделяет нужные и создаёт из них черновик проекта списания. */ + marketplaceListWriteoffCandidates?:ResolverInputTypes["MarketplaceWriteoffCandidate"], marketplaceListWriteoffProposals?: [{ data: ResolverInputTypes["MarketplaceListWriteoffProposalsInput"], options?: ResolverInputTypes["PaginationInput"] | undefined | null},ResolverInputTypes["PaginatedMarketplaceWriteoffProposals"]], /** Кошельки пайщика, релевантные столу заказов: w.wal.share, w.wal.member, w.mkt.order */ marketplaceMemberWallet?:ResolverInputTypes["MarketplaceMemberWallet"], @@ -21062,11 +21369,16 @@ marketplaceReturnClaimSignablePayload?: [{ data: ResolverInputTypes["Marketplace marketplaceSearchAttributes?: [{ input: ResolverInputTypes["SearchAttributesInput"]},ResolverInputTypes["MarketplaceAttribute"]], marketplaceSearchDictionaryValues?: [{ input: ResolverInputTypes["SearchDictionaryValuesInput"]},ResolverInputTypes["MarketplaceDictionaryValue"]], marketplaceSearchRequests?: [{ data: ResolverInputTypes["SearchRequestsInput"]},ResolverInputTypes["MarketplaceRequest"]], -marketplaceStockProposalSignablePayloads?: [{ data: ResolverInputTypes["MarketplaceResolveStockProposalInput"]},ResolverInputTypes["MarketplaceCheckoutSignableLine"]], +marketplaceStockIssuancePayloads?: [{ data: ResolverInputTypes["MarketplaceStockIssuancePrepareInput"]},ResolverInputTypes["MarketplaceStockIssuanceOperatorLine"]], +marketplaceStockProposalSignablePayloads?: [{ data: ResolverInputTypes["MarketplaceResolveStockProposalInput"]},ResolverInputTypes["MarketplaceStockAcceptPayload"]], marketplaceValidateAttributeValues?: [{ input: ResolverInputTypes["ValidateAttributeValuesInput"]},ResolverInputTypes["MarketplaceAttributeValidation"]], /** Контекст пайщика для Стола заказов: core_roles + marketplace_roles + участки оператора */ marketplaceWhoAmI?:ResolverInputTypes["MarketplaceCurrentMember"], + /** Группы списаний, ожидающих подтверждения складом: по проекту, одобренному советом, — отдельная строка на каждый кооперативный участок. Председатель КУ видит только свои участки. */ + marketplaceWriteoffPendingConfirmations?:ResolverInputTypes["MarketplaceWriteoffConfirmationGroup"], marketplaceWriteoffProposal?: [{ id: string},ResolverInputTypes["MarketplaceWriteoffProposal"]], +marketplaceWriteoffProtocolDocument?: [{ data: ResolverInputTypes["MarketplaceWriteoffProtocolDocumentInput"]},ResolverInputTypes["DocumentAggregate"]], +marketplaceWriteoffServiceMemoSignablePayload?: [{ data: ResolverInputTypes["MarketplaceWriteoffServiceMemoSignablePayloadInput"]},ResolverInputTypes["GeneratedDocument"]], marketplaceWriteoffStatementSignablePayload?: [{ data: ResolverInputTypes["MarketplaceWriteoffStatementSignablePayloadInput"]},ResolverInputTypes["GeneratedDocument"]], onecoopGetDocuments?: [{ data: ResolverInputTypes["GetOneCoopDocumentsInput"]},ResolverInputTypes["OneCoopDocumentsResponse"]], process?: [{ coopname: string, hash: string},ResolverInputTypes["ProcessView"]], @@ -22386,6 +22698,25 @@ marketplaceEvents?: [{ input: ResolverInputTypes["MarketplaceEventsInput"]},Reso }>; /** Статус пользователя */ ["UserStatus"]:UserStatus; + ["UserWallet"]: AliasType<{ + /** Доступный остаток (формат: "100.0000 RUB") */ + available?:boolean | `@${string}`, + /** Заблокированный остаток (формат: "0.0000 RUB") */ + blocked?:boolean | `@${string}`, + /** Имя кооператива */ + coopname?:boolean | `@${string}`, + /** Человекочитаемое название кошелька */ + human_name?:boolean | `@${string}`, + /** Идентификатор записи кошелька */ + id?:boolean | `@${string}`, + /** Идентификатор программы кошелька */ + program_id?:boolean | `@${string}`, + /** Имя пользователя */ + username?:boolean | `@${string}`, + /** Идентификатор кошелька */ + wallet_name?:boolean | `@${string}`, + __typename?: boolean | `@${string}` +}>; ["ValidateAttributeValuesInput"]: { /** ID атрибута */ attributeId: number, @@ -22631,7 +22962,9 @@ marketplaceEvents?: [{ input: ResolverInputTypes["MarketplaceEventsInput"]},Reso export type ModelTypes = { ["Account"]: { - /** объект аккаунта в блокчейне содержит системную информацию, такую как публичные ключи доступа, доступные вычислительные ресурсы, информация об установленном смарт-контракте, и т.д. и т.п. Это системный уровень обслуживания, где у каждого пайщика есть аккаунт, но не каждый аккаунт может быть пайщиком в каком-либо кооперативе. Все смарт-контракты устанавливаются и исполняются на этом уровне. */ + /** Вид аккаунта: пайщик, кооперативный участок, кооператив или нераспознанный. Позволяет единообразно отображать субъект во всех реестрах — например, пометить кооперативный участок, а не принять его за организацию-пайщика. */ + account_kind: ModelTypes["AccountKind"], + /** объект аккаунта в блокчейне содержит системную информацию, такую как публичные ключи доступа, доступные вычислительные ресурсы, информация об установленном смарт-контракте, и т.д. и т.п. Это системный уровень обслуживания, где у каждого пайщика есть аккаунт, но не каждый аккаунт может быть пайщиком в каком-либо кооперативе. Все смарт-контракты устанавливаются и исполняются на этом уровне. */ blockchain_account?: ModelTypes["BlockchainAccount"] | undefined | null, /** объект пайщика кооператива в таблице блокчейне, который определяет членство пайщика в конкретном кооперативе. Поскольку MONO обслуживает только один кооператив, то в participant_account обычно содержится информация, которая описывает членство пайщика в этом кооперативе. Этот объект обезличен, публичен, и хранится в блокчейне. */ participant_account?: ModelTypes["ParticipantAccount"] | undefined | null, @@ -22644,6 +22977,7 @@ export type ModelTypes = { /** Имя аккаунта кооператива */ username: string }; + ["AccountKind"]:AccountKind; ["AccountRamDelta"]: { account: string, delta: number @@ -27384,12 +27718,6 @@ export type ModelTypes = { scanned_barcode?: string | undefined | null, /** Заявление пайщика со второй подписью председателя — принятие возврата оформляется со-подписью на том же документе. */ signed_statement?: ModelTypes["MarketplaceReturnStatementSignedInput"] | undefined | null -}; - ["MarketplaceAcceptStockProposalInput"]: { - /** Подписанные заявления о конвертации паевого взноса — по одному на каждую строку предложения (из превью marketplaceStockProposalSignablePayloads). */ - lines?: Array | undefined | null, - /** Предложение со склада кооператива. */ - proposal_id: ModelTypes["ID"] }; /** Добавить позицию в корзину (с привязкой к пункту выдачи). */ ["MarketplaceAddToCartInput"]: { @@ -27876,6 +28204,14 @@ export type ModelTypes = { ["MarketplaceClearInventoryLabelInput"]: { /** Позиция склада, с которой снимается штрих-код (для переклейки). */ inventory_id: ModelTypes["ID"] +}; + ["MarketplaceConfirmWriteoffInput"]: { + /** Кооперативный участок, по которому подтверждается списание. */ + braname: string, + /** Идентификатор проекта списания. */ + proposal_id: string, + /** Подписанная председателем КУ Служебная записка о списании (registry_id=1111). */ + signed_memo: ModelTypes["SignedDigitalDocumentInput"] }; ["MarketplaceConsolidatedRequest"]: { accepted_at?: ModelTypes["DateTime"] | undefined | null, @@ -28010,6 +28346,16 @@ export type ModelTypes = { unit_of_measure: string, unlimited_flag: boolean, warranty_days: number +}; + ["MarketplaceCreateOrderProposalLineInput"]: { + /** Фактическое количество к выдаче (сверено оператором). */ + actual_quantity: number, + /** Фактическая цена за единицу (оператор мог скорректировать). */ + actual_unit_price: string, + /** Существующий заказ пайщика к выдаче этим бандлом. */ + order_id: ModelTypes["ID"], + /** Акт приёма-передачи, подписанный оператором КУ первой подписью. */ + signiss1_act: ModelTypes["MarketplaceIssueActSignedDocumentInput"] }; ["MarketplaceCreateReturnClaimInput"]: { /** Возвращаемое количество единиц (по умолчанию — выданное количество). */ @@ -28036,12 +28382,24 @@ export type ModelTypes = { shipments: Array }; ["MarketplaceCreateStockProposalInput"]: { - /** Кооперативный участок, со склада которого идёт докладка. */ + /** Кооперативный участок, со склада которого идёт выдача. */ braname: string, - /** Строки предложения: что и сколько предлагается со склада. */ - items: Array, - /** Пайщик-адресат предложения. */ - member_account: string + /** Строки докладки со склада: order_hash и подпись оператора (signiss1). */ + items?: Array | undefined | null, + /** Пайщик-адресат бандла. */ + member_account: string, + /** Строки уже существующих заказов пайщика к выдаче (подпись оператора signiss1). */ + order_items?: Array | undefined | null +}; + ["MarketplaceCreateStockProposalLineInput"]: { + /** Предложение кооператива из остатка. */ + offer_id: ModelTypes["ID"], + /** order_hash из подготовки (marketplaceStockIssuancePayloads). */ + order_hash: string, + /** Количество единиц. */ + quantity: number, + /** Акт приёма-передачи, подписанный оператором КУ первой подписью. */ + signiss1_act: ModelTypes["MarketplaceIssueActSignedDocumentInput"] }; ["MarketplaceCreateWriteoffDraftInput"]: { /** Начало расчётного цикла, если отличается от текущего момента. */ @@ -28138,11 +28496,13 @@ export type ModelTypes = { /** Суммарное количество единиц. */ total_units: number }; - ["MarketplaceFinalizeIssuanceInput"]: { - /** Идентификатор заказа, который получаем. */ - order_id: ModelTypes["ID"], - /** Подписанный заказчиком акт выдачи (поверх подписи председателя). Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями. */ - signed_document: ModelTypes["MarketplaceIssueActSignedDocumentInput"] + ["MarketplaceFinalizeStockIssuanceInput"]: { + /** Строки докладки с подписью получения (signiss2) из marketplaceStockProposalSignablePayloads. */ + order_lines: Array, + /** Предложение со склада кооператива. */ + proposal_id: ModelTypes["ID"], + /** Единое подписанное Заявление о конвертации на всю сумму доплаты с паевого. Не передаётся, когда членских средств хватает. */ + signed_convert?: ModelTypes["MarketplaceConvertStatementSignedInput"] | undefined | null }; ["MarketplaceGenerateInventoryLabelInput"]: { /** Формат штрих-кода. По умолчанию — EAN-13. */ @@ -28223,13 +28583,6 @@ export type ModelTypes = { shelf?: string | undefined | null }; ["MarketplaceInventoryStatus"]:MarketplaceInventoryStatus; - /** Результат подписания акта выдачи имущества пайщику. */ -["MarketplaceIssuanceResult"]: { - /** Заказ после применения подписи. */ - order: ModelTypes["MarketplaceOrder"], - /** Хэш транзакции подписи в блокчейне. */ - tx_hash: string -}; ["MarketplaceIssueActPayloadInput"]: { /** Фактически выдаваемое количество для предпросмотра акта (если не указано — берётся заказ). */ actual_quantity?: number | undefined | null, @@ -28339,6 +28692,21 @@ export type ModelTypes = { ["MarketplaceListAidsInput"]: { /** Показать заявки только этого получателя (по умолчанию — свои). */ username?: string | undefined | null +}; + /** Параметры фильтрации реестра всех предложений кооператива (стол администратора). */ +["MarketplaceListAllOffersInput"]: { + /** Количество элементов на странице */ + limit: number, + /** Номер страницы */ + page: number, + /** Ключ сортировки (например, "name") */ + sortBy?: string | undefined | null, + /** Направление сортировки ("ASC" или "DESC") */ + sortOrder: string, + /** Один или несколько статусов предложения, по которым нужно отфильтровать список. */ + statuses?: Array | undefined | null, + /** Фильтр по аккаунту поставщика. */ + supplier_account?: string | undefined | null }; ["MarketplaceListAplReceptionsByBranameInput"]: { /** Идентификатор КУ-получателя. */ @@ -28587,16 +28955,6 @@ export type ModelTypes = { source: string, /** registry_id шаблона оферты ЦПП marketplace в платформенной document factory (Story 1.7). 0 — placeholder, оферта ещё не зарегистрирована. */ template_registry_id: number -}; - ["MarketplaceOpenIssuanceInput"]: { - /** Фактически выдаваемое количество единиц (равно/меньше/больше заказа). */ - actual_quantity: number, - /** Фактическая цена за единицу (оператор может изменить её при открытии выдачи). */ - actual_unit_price: string, - /** Идентификатор заказа, выдачу которого открываем. */ - order_id: ModelTypes["ID"], - /** Подписанный председателем кооперативного участка акт выдачи. Backend верифицирует подпись и отправляет on-chain первую подпись. */ - signed_document: ModelTypes["MarketplaceIssueActSignedDocumentInput"] }; /** Заказ пайщика по предложению поставщика. */ ["MarketplaceOrder"]: { @@ -29316,6 +29674,54 @@ export type ModelTypes = { inventory_id: ModelTypes["ID"], /** Доли разбиения; сумма количеств обязана равняться количеству позиции. */ splits: Array +}; + ["MarketplaceStockAcceptOrderLine"]: { + /** Идентификатор предложения позиции. */ + offer_id: string, + /** order_hash будущего заказа из остатка. */ + order_hash: string, + /** Акт приёма-передачи, уже подписанный оператором КУ. Пайщик накладывает свою подпись поверх (получение). */ + signiss1_aggregate: ModelTypes["DocumentAggregate"] +}; + ["MarketplaceStockAcceptPayload"]: { + /** Сумма доплаты с паевого через конвертацию. Пусто — членских средств хватает, доплаты с паевого нет. */ + convert_amount?: string | undefined | null, + /** Единое Заявление о конвертации к подписи. Пусто — подписывать нечего (доплаты нет). */ + convert_document?: ModelTypes["GeneratedDocument"] | undefined | null, + /** Идентификатор Заявления о конвертации (для подписи). */ + convert_hash?: string | undefined | null, + /** Сколько спишется с уже внесённых членских средств «Стола заказов» (доплата с членского). */ + member_amount: string, + /** Строки-заказы к созданию — вернуть их в принятии предложения. */ + order_lines: Array +}; + ["MarketplaceStockFinalizeLineInput"]: { + /** order_hash строки бандла (из payloads). */ + order_hash: string, + /** Акт приёма-передачи, контрподписанный пайщиком (подпись получения). */ + signed_signiss2_act: ModelTypes["MarketplaceIssueActSignedDocumentInput"] +}; + ["MarketplaceStockIssuanceOperatorLine"]: { + /** Предложение кооператива из остатка. */ + offer_id: ModelTypes["ID"], + /** Детерминированный order_hash будущего заказа из остатка. */ + order_hash: string, + /** Наименование товара. */ + product_name: string, + /** Количество единиц. */ + quantity: number, + /** Акт приёма-передачи к подписи оператором КУ (первая подпись). */ + signiss1_document: ModelTypes["GeneratedDocument"], + /** Цена за единицу. */ + unit_price: string +}; + ["MarketplaceStockIssuancePrepareInput"]: { + /** Кооперативный участок, со склада которого идёт докладка. */ + braname: string, + /** Корзина докладки: что и сколько предлагается со склада. */ + items: Array, + /** Пайщик-адресат докладки. */ + member_account: string }; ["MarketplaceStockProposal"]: { /** Кооперативный участок, со склада которого предложено имущество. */ @@ -29484,6 +29890,50 @@ export type ModelTypes = { }; ["MarketplaceWithdrawOfferInput"]: { id: string +}; + ["MarketplaceWriteoffCandidate"]: { + /** Суммарная сумма к списанию (закупочная цена × количество, 4 знака). */ + amount: string, + /** Наименование позиции (из карточки имущества). */ + asset_title: string, + /** Кооперативный участок (склад), где лежит позиция. */ + braname: string, + /** Человеко-читаемое наименование кооперативного участка. */ + branch_name: string, + /** Ближайший срок годности среди партий (ISO). */ + expiry_date?: string | undefined | null, + /** Идентификаторы всех партий на складе, слитых в эту строку-кандидат. */ + inventory_ids: Array, + /** Срок годности истёк (просрочено) — первоочередной кандидат к списанию. false — имущество ещё годно, списывается вручную (порча, невозврат). */ + is_expired: boolean, + /** Стабильный ключ строки (склад + наименование + состояние). */ + key: string, + /** Сколько партий слито в эту строку (для подсказки в интерфейсе). */ + lots_count: number, + /** Суммарное количество единиц по всем партиям строки. */ + quantity: string, + /** Причина-кандидат (по умолчанию — истёк срок годности). */ + reason: string +}; + ["MarketplaceWriteoffConfirmationGroup"]: { + /** Когда совет авторизовал проект (ISO). */ + authorized_at?: string | undefined | null, + /** Кооперативный участок (склад), по которому подтверждается списание. */ + braname: string, + /** Человеко-читаемое наименование кооперативного участка. */ + branch_name: string, + /** Начало расчётного цикла списания (ISO). */ + cycle_started_at: string, + /** Неподтверждённые позиции этого участка. */ + items: Array, + /** Канонический хеш проекта (process_hash on-chain). */ + proposal_hash: string, + /** Идентификатор проекта списания. */ + proposal_id: string, + /** Протокол совета о списании (документ для просмотра). */ + protocol_doc?: ModelTypes["JSON"] | undefined | null, + /** Σ сумм позиций участка (4 знака). */ + total_amount: string }; ["MarketplaceWriteoffDecisionEntry"]: { action: string, @@ -29495,7 +29945,8 @@ export type ModelTypes = { amount: string, asset_title: string, braname: string, - inventory_id?: string | undefined | null, + /** Идентификаторы партий на складе, которые покрывает эта строка списания. */ + inventory_ids?: Array | undefined | null, quantity: string, reason: string }; @@ -29533,10 +29984,12 @@ export type ModelTypes = { asset_title: string, /** Кооперативный участок (склад) — источник позиции к списанию. */ braname: string, + /** Человеко-читаемое наименование кооперативного участка (для показа в интерфейсе). */ + branch_name?: string | undefined | null, /** Признак того, что позиция уже исполнена через execwroff. */ executed: boolean, - /** Идентификатор инвентарной позиции, если известна. */ - inventory_id?: string | undefined | null, + /** Идентификаторы партий на складе, слитых в эту строку списания. */ + inventory_ids: Array, /** Количество единиц к списанию. */ quantity: string, /** Причина списания (срок годности, повреждение и т.п.). */ @@ -29544,6 +29997,16 @@ export type ModelTypes = { }; ["MarketplaceWriteoffProposalStatus"]:MarketplaceWriteoffProposalStatus; ["MarketplaceWriteoffProposalTrigger"]:MarketplaceWriteoffProposalTrigger; + ["MarketplaceWriteoffProtocolDocumentInput"]: { + /** Идентификатор проекта списания. */ + proposal_id: string +}; + ["MarketplaceWriteoffServiceMemoSignablePayloadInput"]: { + /** Кооперативный участок, по которому подтверждается списание. */ + braname: string, + /** Идентификатор проекта списания. */ + proposal_id: string +}; ["MarketplaceWriteoffStatementSignablePayloadInput"]: { draft_id: string }; @@ -30296,8 +30759,6 @@ export type ModelTypes = { marketplaceAcceptOrdersBatch: ModelTypes["MarketplaceSupplierBatchActionResult"], /** Председатель по результатам очного осмотра принимает гарантийный возврат — атомарно восстанавливает средства на программный кошелёк пайщика и возвращает имущество на склад участка. */ marketplaceAcceptReturnAtVisit: ModelTypes["MarketplaceReturnClaimResult"], - /** Пайщик принимает предложение со склада: по каждой строке создаётся заказ, средства резервируются, акт уходит на подпись. Каждая строка сопровождается подписанным заявлением о конвертации паевого взноса (lines). */ - marketplaceAcceptStockProposal: ModelTypes["MarketplaceStockProposalAcceptResult"], /** Добавить категории в доступные для кооператива (целые категории) Требуемые роли: chairman. */ @@ -30318,6 +30779,8 @@ export type ModelTypes = { marketplaceAssignInventoryShelf: ModelTypes["MarketplaceInventoryMutationResult"], /** Оператор КУ привязывает к позиции склада штрих-код с заранее напечатанной этикетки (считанный сканером). */ marketplaceBindInventoryBarcode: ModelTypes["MarketplaceInventoryMutationResult"], + /** Оператор отменяет акт приёмки до подписи поставщика (поставщик не согласен со снятыми позициями) — партия возвращается к приёмке для повторного формирования. */ + marketplaceCancelAplReception: ModelTypes["MarketplaceAplReceptionResult"], /** Отменить свой заказ до его приёма поставщиком; средства разблокируются. */ marketplaceCancelOrder: ModelTypes["MarketplaceCancelOrderResult"], /** Оператор отменяет заказ со склада кооператива до открытия выдачи (например, при переформировании докладки). Средства возвращаются пайщику, позиции — в остаток. */ @@ -30336,6 +30799,8 @@ export type ModelTypes = { marketplaceClearCart: ModelTypes["MarketplaceCart"], /** Оператор КУ снимает штрих-код с позиции склада, чтобы переклеить этикетку (позиция возвращается в состояние «Принято»). */ marketplaceClearInventoryLabel: ModelTypes["MarketplaceInventoryMutationResult"], + /** Подтвердить фактическое списание со склада участка подписанной председателем КУ Служебной запиской (registry 1111). Запускает on-chain confirmwroff по всем позициям этого участка. */ + marketplaceConfirmWriteoff: ModelTypes["MarketplaceWriteoffProposal"], /** Перевести персональные членские средства в членский кошелёк «Стола заказов» — для заказа имущества как обычный пайщик. */ marketplaceConvertBranchFunds: boolean, /** Подать заявку на материальную помощь с собственного персонального кошелька членских средств: подписанное заявление уходит кассиру, выплата подтверждается фактическим банковским переводом. Налог с дохода получатель оплачивает самостоятельно. */ @@ -30358,7 +30823,7 @@ export type ModelTypes = { marketplaceCreateReturnClaim: ModelTypes["MarketplaceReturnClaimResult"], /** Сформировать партии поставки из акцептованной заявки. Каждая группа = одна партия (КУ + вариант доставки + опционально подмножество заказов). Покрытие всех КУ не обязательно — допустима частичная отгрузка и догрузка остатка отдельными партиями. */ marketplaceCreateShipment: ModelTypes["MarketplaceCreateShipmentResult"], - /** Оператор у стойки предлагает пайщику имущество со склада кооператива — пайщику немедленно приходит запрос на решение. */ + /** Оператор у стойки формирует бандл выдачи пайщику (существующие заказы и/или докладка со склада), с уже подписанными им актами передачи — пайщику немедленно приходит акт на подпись получения. До его подписи ничего в блокчейне не происходит. */ marketplaceCreateStockProposal: ModelTypes["MarketplaceStockProposal"], /** Создаёт ручной черновик проекта списания. На кооператив может быть только один открытый черновик и один проект, отправленный в совет. */ marketplaceCreateWriteoffDraft: ModelTypes["MarketplaceWriteoffProposal"], @@ -30378,12 +30843,10 @@ export type ModelTypes = { marketplaceDetailKU: ModelTypes["MarketplaceKUDetails"], /** Распределить указанную сумму из общего кошелька участка между председателем и доверенными по их весам. Возможно частично и несколько раз; после распределения в общем кошельке должно остаться не меньше планового резерва расходов на 30 дней. Доступно председателю участка. */ marketplaceDistributeBranchFunds: boolean, - /** Заказчик ставит финальную подпись акта выдачи — имущество переходит к нему, в зависимости от сверки факта применяются корректирующие операции. */ - marketplaceFinalizeIssuance: ModelTypes["MarketplaceIssuanceResult"], + /** Пайщик одной подписью утверждает докладку как акт: при дефиците членских средств — конвертация с паевого по подписанному Заявлению, затем по каждой строке создаётся заказ из остатка и проводится выдача (подпись передачи оператора + подпись получения пайщика). Имущество выдаётся сразу. Заявление (signed_convert) не передаётся, когда членских средств хватает. */ + marketplaceFinalizeStockIssuance: ModelTypes["MarketplaceStockProposalAcceptResult"], /** Оператор КУ наклеивает на позицию склада внутренний штрих-код (Code128 или EAN-13) для быстрого поиска на полке. */ marketplaceGenerateInventoryLabel: ModelTypes["MarketplaceInventoryMutationResult"], - /** Председатель кооперативного участка открывает выдачу первой подписью акта — заказ готов к получению пайщиком. */ - marketplaceOpenIssuance: ModelTypes["MarketplaceIssuanceResult"], /** Оператор публикует позиции остатка склада в каталог предложением от кооператива — по цене прибытия или с уценкой. */ marketplacePublishStock: Array, /** Отклонить Offer с причиной (status → REJECTED) (admin) */ @@ -31910,6 +32373,10 @@ export type ModelTypes = { getReportRequisites: ModelTypes["ReportRequisitesView"], /** Получить сводную публичную информацию о системе */ getSystemInfo: ModelTypes["SystemInfo"], + /** Кошельки пайщика — каждый кошелёк отдельной строкой, без объединения паевого и членского + +Требуемые роли: chairman, member. */ + getUserWallets: Array, /** Получить веб-пуш подписки пользователя Требуемые роли: chairman, member. */ @@ -32010,10 +32477,12 @@ export type ModelTypes = { marketplaceGetUserRequests: Array, /** Превью акта выдачи имущества для подписания председателем кооперативного участка. */ marketplaceIssueActChairmanSignablePayload: ModelTypes["GeneratedDocument"], - /** Акт выдачи, уже подписанный председателем, для финальной подписи заказчика. Содержит исходный документ для ознакомления и подпись председателя; заказчик накладывает свою подпись поверх. */ - marketplaceIssueActOrdererSignablePayload: ModelTypes["DocumentAggregate"], /** Заявки на материальную помощь: свои — для доверенного; все заявки кооператива — для администратора. */ marketplaceListAids: Array, + /** Реестр всех предложений кооператива любого статуса (стол администратора). */ + marketplaceListAllOffers: ModelTypes["MarketplaceOfferPaginationResult"], + /** Реестр всех заказов кооператива с их текущими статусами (стол администратора). */ + marketplaceListAllOrders: ModelTypes["MarketplaceOrderPaginationResult"], /** Список актов приёмки, ожидающих подписи текущего поставщика. */ marketplaceListAplReceptionsAsSupplier: Array, /** Список акций приёмки текущего КУ для operator-стола. */ @@ -32046,8 +32515,6 @@ export type ModelTypes = { marketplaceListMyOffers: ModelTypes["MarketplaceOfferPaginationResult"], /** Список заказов текущего пайщика (стол заказчика). */ marketplaceListMyOrders: ModelTypes["MarketplaceOrderPaginationResult"], - /** Список заказов текущего пайщика, готовых к получению на пункте выдачи. */ - marketplaceListMyReadyToReceive: Array, /** Все заявления текущего пайщика на гарантийный возврат — активные и архивные. */ marketplaceListMyReturnClaims: Array, /** Лента выплат поставщикам по всему кооперативу — для совета. Опциональные фильтры: по поставщику-получателю и по статусам. */ @@ -32072,6 +32539,8 @@ export type ModelTypes = { marketplaceListSupplierPickupOrders: Array, /** Список пайщиков-поставщиков, допущенных к публикации оферт */ marketplaceListWhitelist: Array, + /** Кандидаты на списание скоропорта: просроченные позиции на складах кооператива. Председатель выделяет нужные и создаёт из них черновик проекта списания. */ + marketplaceListWriteoffCandidates: Array, /** Лента всех проектов списания кооператива с фильтром по статусу. */ marketplaceListWriteoffProposals: ModelTypes["PaginatedMarketplaceWriteoffProposals"], /** Кошельки пайщика, релевантные столу заказов: w.wal.share, w.wal.member, w.mkt.order */ @@ -32096,14 +32565,22 @@ export type ModelTypes = { marketplaceSearchDictionaryValues: Array, /** Поиск заявок по названию товара */ marketplaceSearchRequests: Array, - /** Заявления о конвертации паевого взноса к подписи по строкам предложения со склада. Подписанные заявления возвращаются строками lines в marketplaceAcceptStockProposal. */ - marketplaceStockProposalSignablePayloads: Array, + /** Акты приёма-передачи к подписи оператором КУ для докладки со склада: по строке корзины — order_hash будущего заказа и документ для подписи передающей стороны. */ + marketplaceStockIssuancePayloads: Array, + /** Нагрузка к принятию предложения со склада: строки-заказы и ОДНО Заявление о конвертации на всю сумму доплаты. Если членских средств хватает (замена из высвобожденных) — заявления нет. */ + marketplaceStockProposalSignablePayloads: ModelTypes["MarketplaceStockAcceptPayload"], /** Валидация значений атрибута marketplace */ marketplaceValidateAttributeValues: ModelTypes["MarketplaceAttributeValidation"], /** Контекст пайщика для Стола заказов: core_roles + marketplace_roles + участки оператора */ marketplaceWhoAmI: ModelTypes["MarketplaceCurrentMember"], + /** Группы списаний, ожидающих подтверждения складом: по проекту, одобренному советом, — отдельная строка на каждый кооперативный участок. Председатель КУ видит только свои участки. */ + marketplaceWriteoffPendingConfirmations: Array, /** Детали одного проекта списания: items, decision_log, протокол. */ marketplaceWriteoffProposal: ModelTypes["MarketplaceWriteoffProposal"], + /** Протокол совета об одобрении списания — подписанный документ (агрегат) для просмотра председателем КУ. */ + marketplaceWriteoffProtocolDocument: ModelTypes["DocumentAggregate"], + /** Превью Служебной записки о списании (registry 1111) по одному участку проекта — для подписания председателем КУ. */ + marketplaceWriteoffServiceMemoSignablePayload: ModelTypes["GeneratedDocument"], /** Превью Заявления о списании скоропорта (registry 1106) для подписания председателем. */ marketplaceWriteoffStatementSignablePayload: ModelTypes["GeneratedDocument"], /** Получение документов кооператива для синхронизации с 1С. Требует секретный ключ в заголовке x-onecoop-secret-key. */ @@ -33382,6 +33859,24 @@ export type ModelTypes = { /** Объединение сертификатов пользователей (сокращенная информация) */ ["UserCertificateUnion"]:ModelTypes["EntrepreneurCertificate"] | ModelTypes["IndividualCertificate"] | ModelTypes["OrganizationCertificate"]; ["UserStatus"]:UserStatus; + ["UserWallet"]: { + /** Доступный остаток (формат: "100.0000 RUB") */ + available: string, + /** Заблокированный остаток (формат: "0.0000 RUB") */ + blocked: string, + /** Имя кооператива */ + coopname: string, + /** Человекочитаемое название кошелька */ + human_name: string, + /** Идентификатор записи кошелька */ + id: ModelTypes["ID"], + /** Идентификатор программы кошелька */ + program_id?: ModelTypes["ID"] | undefined | null, + /** Имя пользователя */ + username: string, + /** Идентификатор кошелька */ + wallet_name: string +}; ["ValidateAttributeValuesInput"]: { /** ID атрибута */ attributeId: number, @@ -33619,6 +34114,8 @@ export type GraphQLTypes = { // ------------------------------------------------------; ["Account"]: { __typename: "Account", + /** Вид аккаунта: пайщик, кооперативный участок, кооператив или нераспознанный. Позволяет единообразно отображать субъект во всех реестрах — например, пометить кооперативный участок, а не принять его за организацию-пайщика. */ + account_kind: GraphQLTypes["AccountKind"], /** объект аккаунта в блокчейне содержит системную информацию, такую как публичные ключи доступа, доступные вычислительные ресурсы, информация об установленном смарт-контракте, и т.д. и т.п. Это системный уровень обслуживания, где у каждого пайщика есть аккаунт, но не каждый аккаунт может быть пайщиком в каком-либо кооперативе. Все смарт-контракты устанавливаются и исполняются на этом уровне. */ blockchain_account?: GraphQLTypes["BlockchainAccount"] | undefined | null, /** объект пайщика кооператива в таблице блокчейне, который определяет членство пайщика в конкретном кооперативе. Поскольку MONO обслуживает только один кооператив, то в participant_account обычно содержится информация, которая описывает членство пайщика в этом кооперативе. Этот объект обезличен, публичен, и хранится в блокчейне. */ @@ -33633,6 +34130,8 @@ export type GraphQLTypes = { username: string, ['...on Account']: Omit }; + /** Вид аккаунта: пайщик, кооперативный участок, кооператив или нераспознанный */ +["AccountKind"]: AccountKind; ["AccountRamDelta"]: { __typename: "AccountRamDelta", account: string, @@ -38651,12 +39150,6 @@ export type GraphQLTypes = { scanned_barcode?: string | undefined | null, /** Заявление пайщика со второй подписью председателя — принятие возврата оформляется со-подписью на том же документе. */ signed_statement?: GraphQLTypes["MarketplaceReturnStatementSignedInput"] | undefined | null -}; - ["MarketplaceAcceptStockProposalInput"]: { - /** Подписанные заявления о конвертации паевого взноса — по одному на каждую строку предложения (из превью marketplaceStockProposalSignablePayloads). */ - lines?: Array | undefined | null, - /** Предложение со склада кооператива. */ - proposal_id: GraphQLTypes["ID"] }; /** Добавить позицию в корзину (с привязкой к пункту выдачи). */ ["MarketplaceAddToCartInput"]: { @@ -39194,6 +39687,14 @@ export type GraphQLTypes = { ["MarketplaceClearInventoryLabelInput"]: { /** Позиция склада, с которой снимается штрих-код (для переклейки). */ inventory_id: GraphQLTypes["ID"] +}; + ["MarketplaceConfirmWriteoffInput"]: { + /** Кооперативный участок, по которому подтверждается списание. */ + braname: string, + /** Идентификатор проекта списания. */ + proposal_id: string, + /** Подписанная председателем КУ Служебная записка о списании (registry_id=1111). */ + signed_memo: GraphQLTypes["SignedDigitalDocumentInput"] }; ["MarketplaceConsolidatedRequest"]: { __typename: "MarketplaceConsolidatedRequest", @@ -39337,6 +39838,16 @@ export type GraphQLTypes = { unit_of_measure: string, unlimited_flag: boolean, warranty_days: number +}; + ["MarketplaceCreateOrderProposalLineInput"]: { + /** Фактическое количество к выдаче (сверено оператором). */ + actual_quantity: number, + /** Фактическая цена за единицу (оператор мог скорректировать). */ + actual_unit_price: string, + /** Существующий заказ пайщика к выдаче этим бандлом. */ + order_id: GraphQLTypes["ID"], + /** Акт приёма-передачи, подписанный оператором КУ первой подписью. */ + signiss1_act: GraphQLTypes["MarketplaceIssueActSignedDocumentInput"] }; ["MarketplaceCreateReturnClaimInput"]: { /** Возвращаемое количество единиц (по умолчанию — выданное количество). */ @@ -39365,12 +39876,24 @@ export type GraphQLTypes = { ['...on MarketplaceCreateShipmentResult']: Omit }; ["MarketplaceCreateStockProposalInput"]: { - /** Кооперативный участок, со склада которого идёт докладка. */ + /** Кооперативный участок, со склада которого идёт выдача. */ braname: string, - /** Строки предложения: что и сколько предлагается со склада. */ - items: Array, - /** Пайщик-адресат предложения. */ - member_account: string + /** Строки докладки со склада: order_hash и подпись оператора (signiss1). */ + items?: Array | undefined | null, + /** Пайщик-адресат бандла. */ + member_account: string, + /** Строки уже существующих заказов пайщика к выдаче (подпись оператора signiss1). */ + order_items?: Array | undefined | null +}; + ["MarketplaceCreateStockProposalLineInput"]: { + /** Предложение кооператива из остатка. */ + offer_id: GraphQLTypes["ID"], + /** order_hash из подготовки (marketplaceStockIssuancePayloads). */ + order_hash: string, + /** Количество единиц. */ + quantity: number, + /** Акт приёма-передачи, подписанный оператором КУ первой подписью. */ + signiss1_act: GraphQLTypes["MarketplaceIssueActSignedDocumentInput"] }; ["MarketplaceCreateWriteoffDraftInput"]: { /** Начало расчётного цикла, если отличается от текущего момента. */ @@ -39491,11 +40014,13 @@ export type GraphQLTypes = { total_units: number, ['...on MarketplaceExpressPickupCandidate']: Omit }; - ["MarketplaceFinalizeIssuanceInput"]: { - /** Идентификатор заказа, который получаем. */ - order_id: GraphQLTypes["ID"], - /** Подписанный заказчиком акт выдачи (поверх подписи председателя). Backend верифицирует подписи и отправляет on-chain финальную подпись со всеми корректирующими операциями. */ - signed_document: GraphQLTypes["MarketplaceIssueActSignedDocumentInput"] + ["MarketplaceFinalizeStockIssuanceInput"]: { + /** Строки докладки с подписью получения (signiss2) из marketplaceStockProposalSignablePayloads. */ + order_lines: Array, + /** Предложение со склада кооператива. */ + proposal_id: GraphQLTypes["ID"], + /** Единое подписанное Заявление о конвертации на всю сумму доплаты с паевого. Не передаётся, когда членских средств хватает. */ + signed_convert?: GraphQLTypes["MarketplaceConvertStatementSignedInput"] | undefined | null }; ["MarketplaceGenerateInventoryLabelInput"]: { /** Формат штрих-кода. По умолчанию — EAN-13. */ @@ -39583,15 +40108,6 @@ export type GraphQLTypes = { }; /** Состояние единицы имущества на складе КУ. */ ["MarketplaceInventoryStatus"]: MarketplaceInventoryStatus; - /** Результат подписания акта выдачи имущества пайщику. */ -["MarketplaceIssuanceResult"]: { - __typename: "MarketplaceIssuanceResult", - /** Заказ после применения подписи. */ - order: GraphQLTypes["MarketplaceOrder"], - /** Хэш транзакции подписи в блокчейне. */ - tx_hash: string, - ['...on MarketplaceIssuanceResult']: Omit -}; ["MarketplaceIssueActPayloadInput"]: { /** Фактически выдаваемое количество для предпросмотра акта (если не указано — берётся заказ). */ actual_quantity?: number | undefined | null, @@ -39704,6 +40220,21 @@ export type GraphQLTypes = { ["MarketplaceListAidsInput"]: { /** Показать заявки только этого получателя (по умолчанию — свои). */ username?: string | undefined | null +}; + /** Параметры фильтрации реестра всех предложений кооператива (стол администратора). */ +["MarketplaceListAllOffersInput"]: { + /** Количество элементов на странице */ + limit: number, + /** Номер страницы */ + page: number, + /** Ключ сортировки (например, "name") */ + sortBy?: string | undefined | null, + /** Направление сортировки ("ASC" или "DESC") */ + sortOrder: string, + /** Один или несколько статусов предложения, по которым нужно отфильтровать список. */ + statuses?: Array | undefined | null, + /** Фильтр по аккаунту поставщика. */ + supplier_account?: string | undefined | null }; ["MarketplaceListAplReceptionsByBranameInput"]: { /** Идентификатор КУ-получателя. */ @@ -39973,16 +40504,6 @@ export type GraphQLTypes = { /** registry_id шаблона оферты ЦПП marketplace в платформенной document factory (Story 1.7). 0 — placeholder, оферта ещё не зарегистрирована. */ template_registry_id: number, ['...on MarketplaceOnboardingState']: Omit -}; - ["MarketplaceOpenIssuanceInput"]: { - /** Фактически выдаваемое количество единиц (равно/меньше/больше заказа). */ - actual_quantity: number, - /** Фактическая цена за единицу (оператор может изменить её при открытии выдачи). */ - actual_unit_price: string, - /** Идентификатор заказа, выдачу которого открываем. */ - order_id: GraphQLTypes["ID"], - /** Подписанный председателем кооперативного участка акт выдачи. Backend верифицирует подпись и отправляет on-chain первую подпись. */ - signed_document: GraphQLTypes["MarketplaceIssueActSignedDocumentInput"] }; /** Заказ пайщика по предложению поставщика. */ ["MarketplaceOrder"]: { @@ -40762,6 +41283,60 @@ export type GraphQLTypes = { inventory_id: GraphQLTypes["ID"], /** Доли разбиения; сумма количеств обязана равняться количеству позиции. */ splits: Array +}; + ["MarketplaceStockAcceptOrderLine"]: { + __typename: "MarketplaceStockAcceptOrderLine", + /** Идентификатор предложения позиции. */ + offer_id: string, + /** order_hash будущего заказа из остатка. */ + order_hash: string, + /** Акт приёма-передачи, уже подписанный оператором КУ. Пайщик накладывает свою подпись поверх (получение). */ + signiss1_aggregate: GraphQLTypes["DocumentAggregate"], + ['...on MarketplaceStockAcceptOrderLine']: Omit +}; + ["MarketplaceStockAcceptPayload"]: { + __typename: "MarketplaceStockAcceptPayload", + /** Сумма доплаты с паевого через конвертацию. Пусто — членских средств хватает, доплаты с паевого нет. */ + convert_amount?: string | undefined | null, + /** Единое Заявление о конвертации к подписи. Пусто — подписывать нечего (доплаты нет). */ + convert_document?: GraphQLTypes["GeneratedDocument"] | undefined | null, + /** Идентификатор Заявления о конвертации (для подписи). */ + convert_hash?: string | undefined | null, + /** Сколько спишется с уже внесённых членских средств «Стола заказов» (доплата с членского). */ + member_amount: string, + /** Строки-заказы к созданию — вернуть их в принятии предложения. */ + order_lines: Array, + ['...on MarketplaceStockAcceptPayload']: Omit +}; + ["MarketplaceStockFinalizeLineInput"]: { + /** order_hash строки бандла (из payloads). */ + order_hash: string, + /** Акт приёма-передачи, контрподписанный пайщиком (подпись получения). */ + signed_signiss2_act: GraphQLTypes["MarketplaceIssueActSignedDocumentInput"] +}; + ["MarketplaceStockIssuanceOperatorLine"]: { + __typename: "MarketplaceStockIssuanceOperatorLine", + /** Предложение кооператива из остатка. */ + offer_id: GraphQLTypes["ID"], + /** Детерминированный order_hash будущего заказа из остатка. */ + order_hash: string, + /** Наименование товара. */ + product_name: string, + /** Количество единиц. */ + quantity: number, + /** Акт приёма-передачи к подписи оператором КУ (первая подпись). */ + signiss1_document: GraphQLTypes["GeneratedDocument"], + /** Цена за единицу. */ + unit_price: string, + ['...on MarketplaceStockIssuanceOperatorLine']: Omit +}; + ["MarketplaceStockIssuancePrepareInput"]: { + /** Кооперативный участок, со склада которого идёт докладка. */ + braname: string, + /** Корзина докладки: что и сколько предлагается со склада. */ + items: Array, + /** Пайщик-адресат докладки. */ + member_account: string }; ["MarketplaceStockProposal"]: { __typename: "MarketplaceStockProposal", @@ -40955,6 +41530,54 @@ export type GraphQLTypes = { }; ["MarketplaceWithdrawOfferInput"]: { id: string +}; + ["MarketplaceWriteoffCandidate"]: { + __typename: "MarketplaceWriteoffCandidate", + /** Суммарная сумма к списанию (закупочная цена × количество, 4 знака). */ + amount: string, + /** Наименование позиции (из карточки имущества). */ + asset_title: string, + /** Кооперативный участок (склад), где лежит позиция. */ + braname: string, + /** Человеко-читаемое наименование кооперативного участка. */ + branch_name: string, + /** Ближайший срок годности среди партий (ISO). */ + expiry_date?: string | undefined | null, + /** Идентификаторы всех партий на складе, слитых в эту строку-кандидат. */ + inventory_ids: Array, + /** Срок годности истёк (просрочено) — первоочередной кандидат к списанию. false — имущество ещё годно, списывается вручную (порча, невозврат). */ + is_expired: boolean, + /** Стабильный ключ строки (склад + наименование + состояние). */ + key: string, + /** Сколько партий слито в эту строку (для подсказки в интерфейсе). */ + lots_count: number, + /** Суммарное количество единиц по всем партиям строки. */ + quantity: string, + /** Причина-кандидат (по умолчанию — истёк срок годности). */ + reason: string, + ['...on MarketplaceWriteoffCandidate']: Omit +}; + ["MarketplaceWriteoffConfirmationGroup"]: { + __typename: "MarketplaceWriteoffConfirmationGroup", + /** Когда совет авторизовал проект (ISO). */ + authorized_at?: string | undefined | null, + /** Кооперативный участок (склад), по которому подтверждается списание. */ + braname: string, + /** Человеко-читаемое наименование кооперативного участка. */ + branch_name: string, + /** Начало расчётного цикла списания (ISO). */ + cycle_started_at: string, + /** Неподтверждённые позиции этого участка. */ + items: Array, + /** Канонический хеш проекта (process_hash on-chain). */ + proposal_hash: string, + /** Идентификатор проекта списания. */ + proposal_id: string, + /** Протокол совета о списании (документ для просмотра). */ + protocol_doc?: GraphQLTypes["JSON"] | undefined | null, + /** Σ сумм позиций участка (4 знака). */ + total_amount: string, + ['...on MarketplaceWriteoffConfirmationGroup']: Omit }; ["MarketplaceWriteoffDecisionEntry"]: { __typename: "MarketplaceWriteoffDecisionEntry", @@ -40968,7 +41591,8 @@ export type GraphQLTypes = { amount: string, asset_title: string, braname: string, - inventory_id?: string | undefined | null, + /** Идентификаторы партий на складе, которые покрывает эта строка списания. */ + inventory_ids?: Array | undefined | null, quantity: string, reason: string }; @@ -41009,10 +41633,12 @@ export type GraphQLTypes = { asset_title: string, /** Кооперативный участок (склад) — источник позиции к списанию. */ braname: string, + /** Человеко-читаемое наименование кооперативного участка (для показа в интерфейсе). */ + branch_name?: string | undefined | null, /** Признак того, что позиция уже исполнена через execwroff. */ executed: boolean, - /** Идентификатор инвентарной позиции, если известна. */ - inventory_id?: string | undefined | null, + /** Идентификаторы партий на складе, слитых в эту строку списания. */ + inventory_ids: Array, /** Количество единиц к списанию. */ quantity: string, /** Причина списания (срок годности, повреждение и т.п.). */ @@ -41023,6 +41649,16 @@ export type GraphQLTypes = { ["MarketplaceWriteoffProposalStatus"]: MarketplaceWriteoffProposalStatus; /** Источник проекта списания: автоматический ежемесячный крон или ручное создание. */ ["MarketplaceWriteoffProposalTrigger"]: MarketplaceWriteoffProposalTrigger; + ["MarketplaceWriteoffProtocolDocumentInput"]: { + /** Идентификатор проекта списания. */ + proposal_id: string +}; + ["MarketplaceWriteoffServiceMemoSignablePayloadInput"]: { + /** Кооперативный участок, по которому подтверждается списание. */ + braname: string, + /** Идентификатор проекта списания. */ + proposal_id: string +}; ["MarketplaceWriteoffStatementSignablePayloadInput"]: { draft_id: string }; @@ -41796,8 +42432,6 @@ export type GraphQLTypes = { marketplaceAcceptOrdersBatch: GraphQLTypes["MarketplaceSupplierBatchActionResult"], /** Председатель по результатам очного осмотра принимает гарантийный возврат — атомарно восстанавливает средства на программный кошелёк пайщика и возвращает имущество на склад участка. */ marketplaceAcceptReturnAtVisit: GraphQLTypes["MarketplaceReturnClaimResult"], - /** Пайщик принимает предложение со склада: по каждой строке создаётся заказ, средства резервируются, акт уходит на подпись. Каждая строка сопровождается подписанным заявлением о конвертации паевого взноса (lines). */ - marketplaceAcceptStockProposal: GraphQLTypes["MarketplaceStockProposalAcceptResult"], /** Добавить категории в доступные для кооператива (целые категории) Требуемые роли: chairman. */ @@ -41818,6 +42452,8 @@ export type GraphQLTypes = { marketplaceAssignInventoryShelf: GraphQLTypes["MarketplaceInventoryMutationResult"], /** Оператор КУ привязывает к позиции склада штрих-код с заранее напечатанной этикетки (считанный сканером). */ marketplaceBindInventoryBarcode: GraphQLTypes["MarketplaceInventoryMutationResult"], + /** Оператор отменяет акт приёмки до подписи поставщика (поставщик не согласен со снятыми позициями) — партия возвращается к приёмке для повторного формирования. */ + marketplaceCancelAplReception: GraphQLTypes["MarketplaceAplReceptionResult"], /** Отменить свой заказ до его приёма поставщиком; средства разблокируются. */ marketplaceCancelOrder: GraphQLTypes["MarketplaceCancelOrderResult"], /** Оператор отменяет заказ со склада кооператива до открытия выдачи (например, при переформировании докладки). Средства возвращаются пайщику, позиции — в остаток. */ @@ -41836,6 +42472,8 @@ export type GraphQLTypes = { marketplaceClearCart: GraphQLTypes["MarketplaceCart"], /** Оператор КУ снимает штрих-код с позиции склада, чтобы переклеить этикетку (позиция возвращается в состояние «Принято»). */ marketplaceClearInventoryLabel: GraphQLTypes["MarketplaceInventoryMutationResult"], + /** Подтвердить фактическое списание со склада участка подписанной председателем КУ Служебной запиской (registry 1111). Запускает on-chain confirmwroff по всем позициям этого участка. */ + marketplaceConfirmWriteoff: GraphQLTypes["MarketplaceWriteoffProposal"], /** Перевести персональные членские средства в членский кошелёк «Стола заказов» — для заказа имущества как обычный пайщик. */ marketplaceConvertBranchFunds: boolean, /** Подать заявку на материальную помощь с собственного персонального кошелька членских средств: подписанное заявление уходит кассиру, выплата подтверждается фактическим банковским переводом. Налог с дохода получатель оплачивает самостоятельно. */ @@ -41858,7 +42496,7 @@ export type GraphQLTypes = { marketplaceCreateReturnClaim: GraphQLTypes["MarketplaceReturnClaimResult"], /** Сформировать партии поставки из акцептованной заявки. Каждая группа = одна партия (КУ + вариант доставки + опционально подмножество заказов). Покрытие всех КУ не обязательно — допустима частичная отгрузка и догрузка остатка отдельными партиями. */ marketplaceCreateShipment: GraphQLTypes["MarketplaceCreateShipmentResult"], - /** Оператор у стойки предлагает пайщику имущество со склада кооператива — пайщику немедленно приходит запрос на решение. */ + /** Оператор у стойки формирует бандл выдачи пайщику (существующие заказы и/или докладка со склада), с уже подписанными им актами передачи — пайщику немедленно приходит акт на подпись получения. До его подписи ничего в блокчейне не происходит. */ marketplaceCreateStockProposal: GraphQLTypes["MarketplaceStockProposal"], /** Создаёт ручной черновик проекта списания. На кооператив может быть только один открытый черновик и один проект, отправленный в совет. */ marketplaceCreateWriteoffDraft: GraphQLTypes["MarketplaceWriteoffProposal"], @@ -41878,12 +42516,10 @@ export type GraphQLTypes = { marketplaceDetailKU: GraphQLTypes["MarketplaceKUDetails"], /** Распределить указанную сумму из общего кошелька участка между председателем и доверенными по их весам. Возможно частично и несколько раз; после распределения в общем кошельке должно остаться не меньше планового резерва расходов на 30 дней. Доступно председателю участка. */ marketplaceDistributeBranchFunds: boolean, - /** Заказчик ставит финальную подпись акта выдачи — имущество переходит к нему, в зависимости от сверки факта применяются корректирующие операции. */ - marketplaceFinalizeIssuance: GraphQLTypes["MarketplaceIssuanceResult"], + /** Пайщик одной подписью утверждает докладку как акт: при дефиците членских средств — конвертация с паевого по подписанному Заявлению, затем по каждой строке создаётся заказ из остатка и проводится выдача (подпись передачи оператора + подпись получения пайщика). Имущество выдаётся сразу. Заявление (signed_convert) не передаётся, когда членских средств хватает. */ + marketplaceFinalizeStockIssuance: GraphQLTypes["MarketplaceStockProposalAcceptResult"], /** Оператор КУ наклеивает на позицию склада внутренний штрих-код (Code128 или EAN-13) для быстрого поиска на полке. */ marketplaceGenerateInventoryLabel: GraphQLTypes["MarketplaceInventoryMutationResult"], - /** Председатель кооперативного участка открывает выдачу первой подписью акта — заказ готов к получению пайщиком. */ - marketplaceOpenIssuance: GraphQLTypes["MarketplaceIssuanceResult"], /** Оператор публикует позиции остатка склада в каталог предложением от кооператива — по цене прибытия или с уценкой. */ marketplacePublishStock: Array, /** Отклонить Offer с причиной (status → REJECTED) (admin) */ @@ -43545,6 +44181,10 @@ export type GraphQLTypes = { getReportRequisites: GraphQLTypes["ReportRequisitesView"], /** Получить сводную публичную информацию о системе */ getSystemInfo: GraphQLTypes["SystemInfo"], + /** Кошельки пайщика — каждый кошелёк отдельной строкой, без объединения паевого и членского + +Требуемые роли: chairman, member. */ + getUserWallets: Array, /** Получить веб-пуш подписки пользователя Требуемые роли: chairman, member. */ @@ -43645,10 +44285,12 @@ export type GraphQLTypes = { marketplaceGetUserRequests: Array, /** Превью акта выдачи имущества для подписания председателем кооперативного участка. */ marketplaceIssueActChairmanSignablePayload: GraphQLTypes["GeneratedDocument"], - /** Акт выдачи, уже подписанный председателем, для финальной подписи заказчика. Содержит исходный документ для ознакомления и подпись председателя; заказчик накладывает свою подпись поверх. */ - marketplaceIssueActOrdererSignablePayload: GraphQLTypes["DocumentAggregate"], /** Заявки на материальную помощь: свои — для доверенного; все заявки кооператива — для администратора. */ marketplaceListAids: Array, + /** Реестр всех предложений кооператива любого статуса (стол администратора). */ + marketplaceListAllOffers: GraphQLTypes["MarketplaceOfferPaginationResult"], + /** Реестр всех заказов кооператива с их текущими статусами (стол администратора). */ + marketplaceListAllOrders: GraphQLTypes["MarketplaceOrderPaginationResult"], /** Список актов приёмки, ожидающих подписи текущего поставщика. */ marketplaceListAplReceptionsAsSupplier: Array, /** Список акций приёмки текущего КУ для operator-стола. */ @@ -43681,8 +44323,6 @@ export type GraphQLTypes = { marketplaceListMyOffers: GraphQLTypes["MarketplaceOfferPaginationResult"], /** Список заказов текущего пайщика (стол заказчика). */ marketplaceListMyOrders: GraphQLTypes["MarketplaceOrderPaginationResult"], - /** Список заказов текущего пайщика, готовых к получению на пункте выдачи. */ - marketplaceListMyReadyToReceive: Array, /** Все заявления текущего пайщика на гарантийный возврат — активные и архивные. */ marketplaceListMyReturnClaims: Array, /** Лента выплат поставщикам по всему кооперативу — для совета. Опциональные фильтры: по поставщику-получателю и по статусам. */ @@ -43707,6 +44347,8 @@ export type GraphQLTypes = { marketplaceListSupplierPickupOrders: Array, /** Список пайщиков-поставщиков, допущенных к публикации оферт */ marketplaceListWhitelist: Array, + /** Кандидаты на списание скоропорта: просроченные позиции на складах кооператива. Председатель выделяет нужные и создаёт из них черновик проекта списания. */ + marketplaceListWriteoffCandidates: Array, /** Лента всех проектов списания кооператива с фильтром по статусу. */ marketplaceListWriteoffProposals: GraphQLTypes["PaginatedMarketplaceWriteoffProposals"], /** Кошельки пайщика, релевантные столу заказов: w.wal.share, w.wal.member, w.mkt.order */ @@ -43731,14 +44373,22 @@ export type GraphQLTypes = { marketplaceSearchDictionaryValues: Array, /** Поиск заявок по названию товара */ marketplaceSearchRequests: Array, - /** Заявления о конвертации паевого взноса к подписи по строкам предложения со склада. Подписанные заявления возвращаются строками lines в marketplaceAcceptStockProposal. */ - marketplaceStockProposalSignablePayloads: Array, + /** Акты приёма-передачи к подписи оператором КУ для докладки со склада: по строке корзины — order_hash будущего заказа и документ для подписи передающей стороны. */ + marketplaceStockIssuancePayloads: Array, + /** Нагрузка к принятию предложения со склада: строки-заказы и ОДНО Заявление о конвертации на всю сумму доплаты. Если членских средств хватает (замена из высвобожденных) — заявления нет. */ + marketplaceStockProposalSignablePayloads: GraphQLTypes["MarketplaceStockAcceptPayload"], /** Валидация значений атрибута marketplace */ marketplaceValidateAttributeValues: GraphQLTypes["MarketplaceAttributeValidation"], /** Контекст пайщика для Стола заказов: core_roles + marketplace_roles + участки оператора */ marketplaceWhoAmI: GraphQLTypes["MarketplaceCurrentMember"], + /** Группы списаний, ожидающих подтверждения складом: по проекту, одобренному советом, — отдельная строка на каждый кооперативный участок. Председатель КУ видит только свои участки. */ + marketplaceWriteoffPendingConfirmations: Array, /** Детали одного проекта списания: items, decision_log, протокол. */ marketplaceWriteoffProposal: GraphQLTypes["MarketplaceWriteoffProposal"], + /** Протокол совета об одобрении списания — подписанный документ (агрегат) для просмотра председателем КУ. */ + marketplaceWriteoffProtocolDocument: GraphQLTypes["DocumentAggregate"], + /** Превью Служебной записки о списании (registry 1111) по одному участку проекта — для подписания председателем КУ. */ + marketplaceWriteoffServiceMemoSignablePayload: GraphQLTypes["GeneratedDocument"], /** Превью Заявления о списании скоропорта (registry 1106) для подписания председателем. */ marketplaceWriteoffStatementSignablePayload: GraphQLTypes["GeneratedDocument"], /** Получение документов кооператива для синхронизации с 1С. Требует секретный ключ в заголовке x-onecoop-secret-key. */ @@ -45112,6 +45762,26 @@ export type GraphQLTypes = { }; /** Статус пользователя */ ["UserStatus"]: UserStatus; + ["UserWallet"]: { + __typename: "UserWallet", + /** Доступный остаток (формат: "100.0000 RUB") */ + available: string, + /** Заблокированный остаток (формат: "0.0000 RUB") */ + blocked: string, + /** Имя кооператива */ + coopname: string, + /** Человекочитаемое название кошелька */ + human_name: string, + /** Идентификатор записи кошелька */ + id: GraphQLTypes["ID"], + /** Идентификатор программы кошелька */ + program_id?: GraphQLTypes["ID"] | undefined | null, + /** Имя пользователя */ + username: string, + /** Идентификатор кошелька */ + wallet_name: string, + ['...on UserWallet']: Omit +}; ["ValidateAttributeValuesInput"]: { /** ID атрибута */ attributeId: number, @@ -45358,6 +46028,13 @@ export type GraphQLTypes = { ["ZeroReportSignerType"]: ZeroReportSignerType; ["ID"]: "scalar" & { name: "ID" } } +/** Вид аккаунта: пайщик, кооперативный участок, кооператив или нераспознанный */ +export enum AccountKind { + branch = "branch", + cooperative = "cooperative", + participant = "participant", + unknown = "unknown" +} /** Тип аккаунта пользователя в системе */ export enum AccountType { entrepreneur = "entrepreneur", @@ -45738,6 +46415,7 @@ export enum MarketplaceWriteoffProposalStatus { EXECUTED = "EXECUTED", EXECUTING = "EXECUTING", ON_AGENDA = "ON_AGENDA", + PENDING_CONFIRMATION = "PENDING_CONFIRMATION", REJECTED = "REJECTED" } /** Источник проекта списания: автоматический ежемесячный крон или ручное создание. */ @@ -45963,6 +46641,7 @@ export enum ZeroReportSignerType { } type ZEUS_VARIABLES = { + ["AccountKind"]: ValueTypes["AccountKind"]; ["AccountType"]: ValueTypes["AccountType"]; ["ActionFiltersInput"]: ValueTypes["ActionFiltersInput"]; ["AddAuthorInput"]: ValueTypes["AddAuthorInput"]; @@ -46182,7 +46861,6 @@ type ZEUS_VARIABLES = { ["MarketplaceAcceptCppInput"]: ValueTypes["MarketplaceAcceptCppInput"]; ["MarketplaceAcceptOrdersBatchInput"]: ValueTypes["MarketplaceAcceptOrdersBatchInput"]; ["MarketplaceAcceptReturnAtVisitInput"]: ValueTypes["MarketplaceAcceptReturnAtVisitInput"]; - ["MarketplaceAcceptStockProposalInput"]: ValueTypes["MarketplaceAcceptStockProposalInput"]; ["MarketplaceAddToCartInput"]: ValueTypes["MarketplaceAddToCartInput"]; ["MarketplaceAddToWhitelistInput"]: ValueTypes["MarketplaceAddToWhitelistInput"]; ["MarketplaceAidStatementSignablePayloadInput"]: ValueTypes["MarketplaceAidStatementSignablePayloadInput"]; @@ -46204,6 +46882,7 @@ type ZEUS_VARIABLES = { ["MarketplaceCheckoutCartInput"]: ValueTypes["MarketplaceCheckoutCartInput"]; ["MarketplaceCheckoutSignedLineInput"]: ValueTypes["MarketplaceCheckoutSignedLineInput"]; ["MarketplaceClearInventoryLabelInput"]: ValueTypes["MarketplaceClearInventoryLabelInput"]; + ["MarketplaceConfirmWriteoffInput"]: ValueTypes["MarketplaceConfirmWriteoffInput"]; ["MarketplaceConsolidatedRequestStatus"]: ValueTypes["MarketplaceConsolidatedRequestStatus"]; ["MarketplaceConvertBranchFundsInput"]: ValueTypes["MarketplaceConvertBranchFundsInput"]; ["MarketplaceConvertStatementSignedInput"]: ValueTypes["MarketplaceConvertStatementSignedInput"]; @@ -46212,16 +46891,18 @@ type ZEUS_VARIABLES = { ["MarketplaceCreateAplReceptionInput"]: ValueTypes["MarketplaceCreateAplReceptionInput"]; ["MarketplaceCreateExpressReceptionInput"]: ValueTypes["MarketplaceCreateExpressReceptionInput"]; ["MarketplaceCreateOfferInput"]: ValueTypes["MarketplaceCreateOfferInput"]; + ["MarketplaceCreateOrderProposalLineInput"]: ValueTypes["MarketplaceCreateOrderProposalLineInput"]; ["MarketplaceCreateReturnClaimInput"]: ValueTypes["MarketplaceCreateReturnClaimInput"]; ["MarketplaceCreateShipmentInput"]: ValueTypes["MarketplaceCreateShipmentInput"]; ["MarketplaceCreateStockProposalInput"]: ValueTypes["MarketplaceCreateStockProposalInput"]; + ["MarketplaceCreateStockProposalLineInput"]: ValueTypes["MarketplaceCreateStockProposalLineInput"]; ["MarketplaceCreateWriteoffDraftInput"]: ValueTypes["MarketplaceCreateWriteoffDraftInput"]; ["MarketplaceDeclineOrdersBatchInput"]: ValueTypes["MarketplaceDeclineOrdersBatchInput"]; ["MarketplaceDeleteTrusteeWeightInput"]: ValueTypes["MarketplaceDeleteTrusteeWeightInput"]; ["MarketplaceDetailKUInput"]: ValueTypes["MarketplaceDetailKUInput"]; ["MarketplaceDistributeBranchFundsInput"]: ValueTypes["MarketplaceDistributeBranchFundsInput"]; ["MarketplaceEventsInput"]: ValueTypes["MarketplaceEventsInput"]; - ["MarketplaceFinalizeIssuanceInput"]: ValueTypes["MarketplaceFinalizeIssuanceInput"]; + ["MarketplaceFinalizeStockIssuanceInput"]: ValueTypes["MarketplaceFinalizeStockIssuanceInput"]; ["MarketplaceGenerateInventoryLabelInput"]: ValueTypes["MarketplaceGenerateInventoryLabelInput"]; ["MarketplaceGeocodeStatus"]: ValueTypes["MarketplaceGeocodeStatus"]; ["MarketplaceGetOrderInput"]: ValueTypes["MarketplaceGetOrderInput"]; @@ -46234,6 +46915,7 @@ type ZEUS_VARIABLES = { ["MarketplaceIssueActSignedMetaDocumentInput"]: ValueTypes["MarketplaceIssueActSignedMetaDocumentInput"]; ["MarketplaceKUStatus"]: ValueTypes["MarketplaceKUStatus"]; ["MarketplaceListAidsInput"]: ValueTypes["MarketplaceListAidsInput"]; + ["MarketplaceListAllOffersInput"]: ValueTypes["MarketplaceListAllOffersInput"]; ["MarketplaceListAplReceptionsByBranameInput"]: ValueTypes["MarketplaceListAplReceptionsByBranameInput"]; ["MarketplaceListCatalogInput"]: ValueTypes["MarketplaceListCatalogInput"]; ["MarketplaceListConsolidatedRequestsInput"]: ValueTypes["MarketplaceListConsolidatedRequestsInput"]; @@ -46253,7 +46935,6 @@ type ZEUS_VARIABLES = { ["MarketplaceOfferDeliveryPointInput"]: ValueTypes["MarketplaceOfferDeliveryPointInput"]; ["MarketplaceOfferImageUploadInput"]: ValueTypes["MarketplaceOfferImageUploadInput"]; ["MarketplaceOfferStatus"]: ValueTypes["MarketplaceOfferStatus"]; - ["MarketplaceOpenIssuanceInput"]: ValueTypes["MarketplaceOpenIssuanceInput"]; ["MarketplaceOrderIssuanceFactDiffState"]: ValueTypes["MarketplaceOrderIssuanceFactDiffState"]; ["MarketplaceOrderStatus"]: ValueTypes["MarketplaceOrderStatus"]; ["MarketplaceOutgoingPaymentRequestStatus"]: ValueTypes["MarketplaceOutgoingPaymentRequestStatus"]; @@ -46285,6 +46966,8 @@ type ZEUS_VARIABLES = { ["MarketplaceSignAplReceptionInput"]: ValueTypes["MarketplaceSignAplReceptionInput"]; ["MarketplaceSignOnboardingOfferInput"]: ValueTypes["MarketplaceSignOnboardingOfferInput"]; ["MarketplaceSplitInventoryInput"]: ValueTypes["MarketplaceSplitInventoryInput"]; + ["MarketplaceStockFinalizeLineInput"]: ValueTypes["MarketplaceStockFinalizeLineInput"]; + ["MarketplaceStockIssuancePrepareInput"]: ValueTypes["MarketplaceStockIssuancePrepareInput"]; ["MarketplaceStockProposalItemInput"]: ValueTypes["MarketplaceStockProposalItemInput"]; ["MarketplaceStockProposalStatus"]: ValueTypes["MarketplaceStockProposalStatus"]; ["MarketplaceSubmitWriteoffDraftInput"]: ValueTypes["MarketplaceSubmitWriteoffDraftInput"]; @@ -46296,6 +46979,8 @@ type ZEUS_VARIABLES = { ["MarketplaceWriteoffItemInput"]: ValueTypes["MarketplaceWriteoffItemInput"]; ["MarketplaceWriteoffProposalStatus"]: ValueTypes["MarketplaceWriteoffProposalStatus"]; ["MarketplaceWriteoffProposalTrigger"]: ValueTypes["MarketplaceWriteoffProposalTrigger"]; + ["MarketplaceWriteoffProtocolDocumentInput"]: ValueTypes["MarketplaceWriteoffProtocolDocumentInput"]; + ["MarketplaceWriteoffServiceMemoSignablePayloadInput"]: ValueTypes["MarketplaceWriteoffServiceMemoSignablePayloadInput"]; ["MarketplaceWriteoffStatementSignablePayloadInput"]: ValueTypes["MarketplaceWriteoffStatementSignablePayloadInput"]; ["MoveCapitalIssueToComponentInput"]: ValueTypes["MoveCapitalIssueToComponentInput"]; ["NonProjectRoomKind"]: ValueTypes["NonProjectRoomKind"]; diff --git a/components/cooptypes/src/contracts/marketplace/actions/confirmWroff.ts b/components/cooptypes/src/contracts/marketplace/actions/confirmWroff.ts new file mode 100644 index 00000000000..d77c5f0ebc7 --- /dev/null +++ b/components/cooptypes/src/contracts/marketplace/actions/confirmWroff.ts @@ -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 diff --git a/components/cooptypes/src/contracts/marketplace/actions/convert.ts b/components/cooptypes/src/contracts/marketplace/actions/convert.ts new file mode 100644 index 00000000000..9c1598f6d09 --- /dev/null +++ b/components/cooptypes/src/contracts/marketplace/actions/convert.ts @@ -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 diff --git a/components/cooptypes/src/contracts/marketplace/actions/index.ts b/components/cooptypes/src/contracts/marketplace/actions/index.ts index 0f9a79e524e..7e82ddef59d 100644 --- a/components/cooptypes/src/contracts/marketplace/actions/index.ts +++ b/components/cooptypes/src/contracts/marketplace/actions/index.ts @@ -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 ──────────────────────────────────────────────────────────── /** diff --git a/components/cooptypes/src/contracts/marketplace/actions/stockOrder.ts b/components/cooptypes/src/contracts/marketplace/actions/stockOrder.ts index 645e4e602ff..1619d1b6c0d 100644 --- a/components/cooptypes/src/contracts/marketplace/actions/stockOrder.ts +++ b/components/cooptypes/src/contracts/marketplace/actions/stockOrder.ts @@ -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 diff --git a/components/cooptypes/src/cooperative/registry/1107.MarketplaceWriteoffProtocol/index.ts b/components/cooptypes/src/cooperative/registry/1107.MarketplaceWriteoffProtocol/index.ts index e1ab0747be8..f0c581541e2 100644 --- a/components/cooptypes/src/cooperative/registry/1107.MarketplaceWriteoffProtocol/index.ts +++ b/components/cooptypes/src/cooperative/registry/1107.MarketplaceWriteoffProtocol/index.ts @@ -142,8 +142,6 @@ th { width: 30%; } -

{% trans 'proposal_ref' %} {{ proposal_hash }}

-

{% trans 'closing_time', decision.time %}

@@ -184,7 +182,6 @@ export const translations = { meeting_format_value: 'заочная', agenda_subject: 'Утвердить списание {0} позиций имущества со складов кооперативных участков на общую сумму {1} (цикл от {2}) по ЦПП «Стол заказов» — поданное Заявление председателя о списании скоропорта.', decision_writeoff_text: 'Списать {0} позиций имущества на общую сумму {1} с балансов кооперативных участков через транзит счёта 91 «Прочие доходы и расходы» с закрытием на счёт 86 Целевого финансирования программы. Движений по кошелькам пайщиков не производить.', - proposal_ref: 'Идентификатор проекта списания:', }, } diff --git a/components/cooptypes/src/cooperative/registry/1108.MarketplaceWriteoffStatement/index.ts b/components/cooptypes/src/cooperative/registry/1108.MarketplaceWriteoffStatement/index.ts index c2b11463634..26ab26e98d4 100644 --- a/components/cooptypes/src/cooperative/registry/1108.MarketplaceWriteoffStatement/index.ts +++ b/components/cooptypes/src/cooperative/registry/1108.MarketplaceWriteoffStatement/index.ts @@ -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 — +// он ВЫРЕЗАЕТ
-

{% trans 'v_soviet' %} {{ vars.full_abbr_genitive }} "{{ vars.name }}"

-

{% trans 'from_chairman' %} {{ chairman.full_name_or_short_name }}

+

{% trans 'v_soviet' %} {{ vars.full_abbr_genitive }} "{{ vars.name }}"

+

{% trans 'from_chairman' %} {{ chairman.full_name_or_short_name }}

-

{% trans 'statement_title' %}

+

{% trans 'statement_title' %}

{% trans 'statement_subheader', program.name %}

-

{% trans 'preamble', program.name, cycle_started_at %}

+

{% trans 'place', coop.city %}

+ +

{% trans 'preamble', cycle_started_at %}

- - - - - - - - - - + + + + + + + + {% for it in items %} - + {% endfor %} - - - + + +
{% trans 'col_branch' %}{% trans 'col_asset' %}{% trans 'col_quantity' %}{% trans 'col_amount' %}{% trans 'col_reason' %}
{% trans 'col_asset' %}{% trans 'col_quantity' %}{% trans 'col_unit' %}{% trans 'col_amount' %}{% trans 'col_reason' %}
{{ forloop.counter }}{{ it.braname }} {{ it.asset_title }} {{ it.quantity }}{{ it.unit }} {{ it.amount }} {{ it.reason }}
{% trans 'total' %}{{ total_amount }}
{% trans 'total' %}{{ total_amount }}
-

{% trans 'ledger_note' %}

{% trans 'signature' %}

-

{{ chairman.full_name_or_short_name }}

-

{{ meta.created_at }}

+

{{ chairman.full_name_or_short_name }}

+

{{ meta.created_at }}

` 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', } diff --git a/components/cooptypes/src/cooperative/registry/1111.MarketplaceWriteoffServiceMemo/index.ts b/components/cooptypes/src/cooperative/registry/1111.MarketplaceWriteoffServiceMemo/index.ts new file mode 100644 index 00000000000..281c732b44e --- /dev/null +++ b/components/cooptypes/src/cooperative/registry/1111.MarketplaceWriteoffServiceMemo/index.ts @@ -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 — он ВЫРЕЗАЕТ +// + +
+
+

{% trans 'memo_title' %}

+

{% trans 'memo_subheader', branch_name %}

+
+ +

{% trans 'preamble', program.name, cycle_started_at %}

+ + + + + + + + + + + + {% for it in items %} + + + + + + + + + {% endfor %} + + + + + + +
{% trans 'col_asset' %}{% trans 'col_quantity' %}{% trans 'col_unit' %}{% trans 'col_amount' %}{% trans 'col_reason' %}
{{ forloop.counter }}{{ it.asset_title }}{{ it.quantity }}{{ it.unit }}{{ it.amount }}{{ it.reason }}
{% trans 'total' %}{{ total_amount }}
+ +

{% trans 'confirmation' %}

+ +

{% trans 'signature' %}

+

{% trans 'chairman_of_branch', branch_name %} {{ chairman.full_name_or_short_name }}

+

{{ meta.created_at }}

+
+` + +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', +} diff --git a/components/cooptypes/src/cooperative/registry/index.ts b/components/cooptypes/src/cooperative/registry/index.ts index ac657a9e742..6d0137fd50d 100644 --- a/components/cooptypes/src/cooperative/registry/index.ts +++ b/components/cooptypes/src/cooperative/registry/index.ts @@ -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' diff --git a/components/cooptypes/src/interfaces/marketplace.ts b/components/cooptypes/src/interfaces/marketplace.ts index a02940642fe..c4d29f309d6 100644 --- a/components/cooptypes/src/interfaces/marketplace.ts +++ b/components/cooptypes/src/interfaces/marketplace.ts @@ -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)` diff --git a/components/cooptypes/src/ledger2/operations.ts b/components/cooptypes/src/ledger2/operations.ts index 6088a94e326..95e83c4f8ca 100644 --- a/components/cooptypes/src/ledger2/operations.ts +++ b/components/cooptypes/src/ledger2/operations.ts @@ -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', diff --git a/components/desktop/extensions/capital/install.ts b/components/desktop/extensions/capital/install.ts index 5c6b2c7fb49..1f4787abad3 100644 --- a/components/desktop/extensions/capital/install.ts +++ b/components/desktop/extensions/capital/install.ts @@ -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 { ], }]; } + +/** + * Кошельки, которые «Благорост» приносит на стол пайщика (путь B). + * Единый кошелёк программы у пайщика (`w.cap.blago`). Генератор + * (`w.cap.gen`) — кооперативный кошелёк без L3-разреза, на столе пайщика + * НЕ показываем. + */ +export const walletCards: DesktopWalletCard[] = [ + { + wallet_name: 'w.cap.blago', + label: 'Благорост', + accent: 'blagorost', + icon: 'savings', + }, +]; diff --git a/components/desktop/extensions/market/install.ts b/components/desktop/extensions/market/install.ts index 80ba78337e4..4278b4738db 100644 --- a/components/desktop/extensions/market/install.ts +++ b/components/desktop/extensions/market/install.ts @@ -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 { 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 { 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 { 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 { }, 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 { }, ] } + +/** + * Кошельки, которые «Стол заказов» приносит на стол пайщика (путь 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', + }, +] diff --git a/components/desktop/extensions/reports/pages/ProcessesPage/ui/ProcessesPage.vue b/components/desktop/extensions/reports/pages/ProcessesPage/ui/ProcessesPage.vue index 52f1a9649fc..cb083308e09 100644 --- a/components/desktop/extensions/reports/pages/ProcessesPage/ui/ProcessesPage.vue +++ b/components/desktop/extensions/reports/pages/ProcessesPage/ui/ProcessesPage.vue @@ -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' - ) + + + + diff --git a/components/desktop/src/pages/Marketplace/AdminOffers/ui/index.ts b/components/desktop/src/pages/Marketplace/AdminOffers/ui/index.ts new file mode 100644 index 00000000000..0c0fc981e7b --- /dev/null +++ b/components/desktop/src/pages/Marketplace/AdminOffers/ui/index.ts @@ -0,0 +1 @@ +export { default as AdminOffersPage } from './AdminOffersPage.vue'; diff --git a/components/desktop/src/pages/Marketplace/AdminOrders/api/index.ts b/components/desktop/src/pages/Marketplace/AdminOrders/api/index.ts new file mode 100644 index 00000000000..ba646c9c79f --- /dev/null +++ b/components/desktop/src/pages/Marketplace/AdminOrders/api/index.ts @@ -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 { + 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; +} diff --git a/components/desktop/src/pages/Marketplace/AdminOrders/index.ts b/components/desktop/src/pages/Marketplace/AdminOrders/index.ts new file mode 100644 index 00000000000..3ddd3894c18 --- /dev/null +++ b/components/desktop/src/pages/Marketplace/AdminOrders/index.ts @@ -0,0 +1,2 @@ +export * from './ui'; +export * from './types'; diff --git a/components/desktop/src/pages/Marketplace/AdminOrders/types.ts b/components/desktop/src/pages/Marketplace/AdminOrders/types.ts new file mode 100644 index 00000000000..4e7ac1410a4 --- /dev/null +++ b/components/desktop/src/pages/Marketplace/AdminOrders/types.ts @@ -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']}`; diff --git a/components/desktop/src/pages/Marketplace/AdminOrders/ui/AdminOrdersPage.vue b/components/desktop/src/pages/Marketplace/AdminOrders/ui/AdminOrdersPage.vue new file mode 100644 index 00000000000..0c57285f42d --- /dev/null +++ b/components/desktop/src/pages/Marketplace/AdminOrders/ui/AdminOrdersPage.vue @@ -0,0 +1,353 @@ + + + + + diff --git a/components/desktop/src/pages/Marketplace/AdminOrders/ui/index.ts b/components/desktop/src/pages/Marketplace/AdminOrders/ui/index.ts new file mode 100644 index 00000000000..7fb1b08fe6a --- /dev/null +++ b/components/desktop/src/pages/Marketplace/AdminOrders/ui/index.ts @@ -0,0 +1 @@ +export { default as AdminOrdersPage } from './AdminOrdersPage.vue'; diff --git a/components/desktop/src/pages/Marketplace/AdminWriteoffs/api/index.ts b/components/desktop/src/pages/Marketplace/AdminWriteoffs/api/index.ts index 577a58ac3dd..3b33b9bd68e 100644 --- a/components/desktop/src/pages/Marketplace/AdminWriteoffs/api/index.ts +++ b/components/desktop/src/pages/Marketplace/AdminWriteoffs/api/index.ts @@ -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 { + const { [Queries.Marketplace.ListWriteoffCandidates.name]: result } = await client.Query( + Queries.Marketplace.ListWriteoffCandidates.query, + {}, + ); + return result; +} + export async function getOpenWriteoffDraft(): Promise { const { [Queries.Marketplace.OpenWriteoffDraft.name]: result } = await client.Query( Queries.Marketplace.OpenWriteoffDraft.query, diff --git a/components/desktop/src/pages/Marketplace/AdminWriteoffs/lib/proposalDisplay.ts b/components/desktop/src/pages/Marketplace/AdminWriteoffs/lib/proposalDisplay.ts new file mode 100644 index 00000000000..a5f79a748d5 --- /dev/null +++ b/components/desktop/src/pages/Marketplace/AdminWriteoffs/lib/proposalDisplay.ts @@ -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)}`; +} diff --git a/components/desktop/src/pages/Marketplace/AdminWriteoffs/ui/AdminWriteoffsPage.vue b/components/desktop/src/pages/Marketplace/AdminWriteoffs/ui/AdminWriteoffsPage.vue index 668c304104c..c75813e0929 100644 --- a/components/desktop/src/pages/Marketplace/AdminWriteoffs/ui/AdminWriteoffsPage.vue +++ b/components/desktop/src/pages/Marketplace/AdminWriteoffs/ui/AdminWriteoffsPage.vue @@ -5,41 +5,70 @@ import { Zeus } from '@coopenomics/sdk'; import { FailAlert } from 'src/shared/api'; import { useMarketplaceRealtime } from 'src/shared/lib/marketplace'; import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits'; -import { BaseBadge, BaseButton, BaseCard, CardListSkeleton } from 'src/shared/ui/base'; +import { BaseBadge, BaseButton, BaseCard, BaseInput, CardListSkeleton, EmptyState } from 'src/shared/ui/base'; import type { BaseBadgeVariant } from 'src/shared/ui/base'; import { PageHint } from 'src/shared/ui/domain'; +import { PageTabs, type PageTab } from 'src/shared/ui/layout'; import { + cancelWriteoffDraft, + createWriteoffDraft, getOpenWriteoffDraft, + listWriteoffCandidates, listWriteoffProposals, + type MarketplaceWriteoffCandidateView, type MarketplaceWriteoffProposalView, } from '../api'; -import DraftEditorDialog from './DraftEditorDialog.vue'; +import { positionsLabel, proposalTitle } from '../lib/proposalDisplay'; import SubmitToCouncilDialog from './SubmitToCouncilDialog.vue'; import WriteoffProposalDetailsDialog from './WriteoffProposalDetailsDialog.vue'; /** - * Эпик 8: admin-стол «Списания на повестке». Лента проектов решения совета - * о списании скоропорта. - * - * Раздел 1 — открытый черновик (DRAFT). Кран сервиса или ручное действие - * предлагают председателю / общему администратору набор позиций к - * списанию. Здесь же можно отредактировать состав и подписать Заявление - * 1106 для отправки в совет. - * - * Раздел 2 — активные проекты на повестке совета (ON_AGENDA / - * AUTHORIZED / EXECUTING). Здесь только наблюдение — действующие - * процедуры голосования и подписания Протокола 1107 идут через - * стандартный sov.decision-flow повестки совета. - * - * Раздел 3 — архив (EXECUTED / REJECTED). + * Эпик 8: admin-стол «Списания». Три вкладки: «Кандидаты» — имущество на + * складах, председатель выделяет позиции, при желании пишет причину и одной + * кнопкой подписывает Заявление 1108 и выносит проект на повестку совета + * (черновик собирается под капотом). «На повестке» — проекты в работе совета + * (только наблюдение). «Архив» — исполненные и отклонённые. */ +// Черновик — внутренний эфемерный артефакт: backend submitToCouncil требует +// существующий DRAFT (его proposal_hash подписывается). Собираем его перед +// подписью и сразу выносим в совет; как отдельный экран не показываем. const draft = ref(null); const inCouncil = ref([]); const archive = ref([]); const loading = ref(false); -const draftEditorOpen = ref(false); +// Имущество на складах: председатель выделяет позиции, пишет причину и сразу +// отправляет в совет. +const candidates = ref([]); +const selectedCandidates = ref([]); +const writeoffReason = ref(''); +const preparing = ref(false); + +const activeKey = ref<'candidates' | 'council' | 'archive'>('candidates'); +const tabs = computed(() => [ + { key: 'candidates', label: 'Кандидаты', count: candidates.value.length }, + { key: 'council', label: 'На повестке', count: inCouncil.value.length }, + { key: 'archive', label: 'Архив', count: archive.value.length }, +]); +function onSelectTab(tab: PageTab): void { + activeKey.value = tab.key as typeof activeKey.value; +} + +// Лента проектов для вкладок «На повестке» / «Архив» — общая таблица. +const proposalsList = computed(() => + activeKey.value === 'archive' ? archive.value : inCouncil.value, +); + +const candidateColumns = [ + { name: 'branch_name', align: 'left' as const, label: 'Кооп. участок', field: 'branch_name' }, + { name: 'asset_title', align: 'left' as const, label: 'Наименование', field: 'asset_title' }, + { name: 'quantity', align: 'right' as const, label: 'Кол-во', field: 'quantity' }, + { name: 'state', align: 'left' as const, label: 'Состояние', field: 'is_expired' }, + { name: 'expiry_date', align: 'left' as const, label: 'Годен до', field: 'expiry_date' }, + { name: 'amount', align: 'right' as const, label: 'Сумма', field: 'amount' }, +]; + const submitDialogOpen = ref(false); const detailsOpen = ref(false); const selected = ref(null); @@ -47,21 +76,29 @@ const selected = ref(null); async function load(): Promise { loading.value = true; try { - const [openDraft, councilPage, archivePage] = await Promise.all([ + const [openDraft, councilPage, archivePage, candidatesList] = await Promise.all([ getOpenWriteoffDraft(), listWriteoffProposals({ statuses: [ Zeus.MarketplaceWriteoffProposalStatus.ON_AGENDA, Zeus.MarketplaceWriteoffProposalStatus.AUTHORIZED, + Zeus.MarketplaceWriteoffProposalStatus.PENDING_CONFIRMATION, Zeus.MarketplaceWriteoffProposalStatus.EXECUTING, ] }), listWriteoffProposals({ statuses: [ Zeus.MarketplaceWriteoffProposalStatus.EXECUTED, Zeus.MarketplaceWriteoffProposalStatus.REJECTED, ] }), + listWriteoffCandidates(), ]); draft.value = openDraft; inCouncil.value = councilPage.items; archive.value = archivePage.items; + candidates.value = candidatesList; + // Позиции, уже попавшие в открытый черновик, из выбора убираем. + if (selectedCandidates.value.length) { + const present = new Set(candidatesList.map((c) => c.key)); + selectedCandidates.value = selectedCandidates.value.filter((c) => present.has(c.key)); + } } catch (e) { FailAlert(e, 'Не удалось загрузить проекты списания'); } finally { @@ -69,6 +106,51 @@ async function load(): Promise { } } +function hasAmount(c: MarketplaceWriteoffCandidateView): boolean { + return Number.parseFloat(c.amount) > 0; +} + +// Один шаг: выделил имущество + (необязательно) причину → собираем черновик +// под капотом и сразу открываем подпись Заявления для отправки в совет. +async function signAndSend(): Promise { + const picked = selectedCandidates.value.filter(hasAmount); + if (picked.length === 0) { + FailAlert( + new Error('Нет позиций с известной стоимостью'), + 'Выделите позиции с ненулевой суммой списания', + ); + return; + } + preparing.value = true; + try { + // Снимаем возможный висящий черновик (от прерванной подписи или крон-сервиса), + // чтобы собрать свежий ровно из текущего выбора — один черновик за раз. + if (draft.value) { + await cancelWriteoffDraft(draft.value.id); + draft.value = null; + } + const reason = writeoffReason.value.trim(); + const created = await createWriteoffDraft({ + items: picked.map((c) => ({ + braname: c.braname, + asset_title: c.asset_title, + quantity: c.quantity, + amount: c.amount, + // Пусто — причина по состоянию позиции (просрочено / ручное списание). + reason: reason || c.reason, + // Агрегат партий: одна строка Заявления покрывает все партии товара. + inventory_ids: c.inventory_ids, + })), + }); + draft.value = created; + submitDialogOpen.value = true; + } catch (e) { + FailAlert(e, 'Не удалось подготовить проект к отправке в совет'); + } finally { + preparing.value = false; + } +} + function openDetails(proposal: MarketplaceWriteoffProposalView): void { selected.value = proposal; detailsOpen.value = true; @@ -81,6 +163,7 @@ function statusVariant(status: MarketplaceWriteoffProposalView['status']): BaseB case 'ON_AGENDA': return 'accent'; case 'AUTHORIZED': + case 'PENDING_CONFIRMATION': case 'EXECUTING': return 'info'; case 'EXECUTED': @@ -99,7 +182,9 @@ function humanStatus(status: MarketplaceWriteoffProposalView['status']): string case 'ON_AGENDA': return 'На повестке'; case 'AUTHORIZED': - return 'Утверждено'; + return 'Утверждено советом'; + case 'PENDING_CONFIRMATION': + return 'Ожидает подтверждения склада'; case 'EXECUTING': return 'Идёт списание'; case 'EXECUTED': @@ -118,23 +203,35 @@ function formatDate(value: string | null | undefined): string { return parsed.toLocaleDateString('ru-RU'); } -const totalActiveAmount = computed(() => - inCouncil.value - .reduce((acc, p) => acc + (Number.parseFloat(p.total_amount) || 0), 0) - .toFixed(2), -); +// Дата проекта для ленты: итоговая (исполнение/отказ) либо подачи в совет. +function proposalDate(p: MarketplaceWriteoffProposalView): string | null | undefined { + return p.executed_at ?? p.rejected_at ?? p.submitted_at ?? p.updated_at; +} -function onDraftCreatedOrUpdated(updated: MarketplaceWriteoffProposalView): void { - draft.value = updated; + +// Три состояния позиции на складе: +// · нет даты годности (гарантия 0) → «Без гарантии» — ручное списание сразу; +// · дата прошла → «Просрочен» — первоочередной авто-кандидат; +// · дата в будущем → «Годен» — ещё в сроке гарантии. +function candidateStateLabel(c: MarketplaceWriteoffCandidateView): string { + if (!c.expiry_date) return 'Без гарантии'; + return c.is_expired ? 'Просрочен' : 'Годен'; +} +function candidateStateVariant(c: MarketplaceWriteoffCandidateView): BaseBadgeVariant { + if (!c.expiry_date) return 'neutral'; + return c.is_expired ? 'neg' : 'pos'; } function onDraftSubmitted(): void { draft.value = null; + selectedCandidates.value = []; + writeoffReason.value = ''; + activeKey.value = 'council'; void load(); } -// Realtime: DRAFT строит cron, дальнейшие статусы двигает совет — лента -// проектов обновляется сигналом канала совета, без ручного обновления. +// Realtime: дальнейшие статусы двигает совет — лента проектов обновляется +// сигналом канала совета, без ручного обновления. const reloadLive = debounce(() => { if (loading.value) return; void load(); @@ -152,66 +249,88 @@ onMounted(() => { diff --git a/components/desktop/src/pages/Marketplace/OperatorReception/api/index.ts b/components/desktop/src/pages/Marketplace/OperatorReception/api/index.ts index c2f00a4ce52..c85c9b13da1 100644 --- a/components/desktop/src/pages/Marketplace/OperatorReception/api/index.ts +++ b/components/desktop/src/pages/Marketplace/OperatorReception/api/index.ts @@ -33,6 +33,7 @@ export type ListSupplierPickupOrdersInput = Queries.Marketplace.ListSupplierPickupOrders.IInput['data']; export type CreateAplReceptionInput = Mutations.Marketplace.CreateAplReception.IInput['data']; export type CreateExpressReceptionInput = Mutations.Marketplace.CreateExpressReception.IInput['data']; +export type CancelAplReceptionInput = Mutations.Marketplace.CancelAplReception.IInput['data']; export async function fetchChairmanSignablePayloads( data: AplReceptionChairmanSignablePayloadsInput, @@ -66,6 +67,16 @@ export async function createAplReception( return result; } +export async function cancelAplReception( + data: CancelAplReceptionInput, +): Promise { + const { [Mutations.Marketplace.CancelAplReception.name]: result } = await client.Mutation( + Mutations.Marketplace.CancelAplReception.mutation, + { variables: { data } }, + ); + return result; +} + export async function signAsChairman( data: SignAplReceptionAsChairmanInput, ): Promise { diff --git a/components/desktop/src/pages/Marketplace/OperatorReception/ui/OperatorReceptionPage.vue b/components/desktop/src/pages/Marketplace/OperatorReception/ui/OperatorReceptionPage.vue index dae782396e3..f1fe2a61c6f 100644 --- a/components/desktop/src/pages/Marketplace/OperatorReception/ui/OperatorReceptionPage.vue +++ b/components/desktop/src/pages/Marketplace/OperatorReception/ui/OperatorReceptionPage.vue @@ -26,6 +26,7 @@ import { type MarketplaceShipmentView, } from '../../OperatorIncomingShipments/api'; import { + cancelAplReception, createAplReception, createExpressReception, listAplReceptionsByBraname, @@ -464,7 +465,16 @@ async function openPickupForSupplier(account: string): Promise { pickupFact.value = Object.fromEntries(orders.map((o) => [o.id, o.quantity])); pickupPrice.value = Object.fromEntries(orders.map((o) => [o.id, o.price_per_unit])); selectedOrderIds.value = new Set(orders.map((o) => o.id)); - takeAddon.value = true; + // Чекбокс «Принять добор» по умолчанию ВЫКЛЮЧЕН, когда есть привезённая + // партия — добор не должен «залетать» автоматически, оператор включает его + // осознанно. Если партии нет (поставщик приехал без партии) — чекбокс не + // показываем вовсе (без контекста партии «добор» путает), а имущество + // принимаем: добор включён по умолчанию, иначе принимать было бы нечего. + const hasDeclaredBatch = orders.some( + (o) => + o.status === 'SUPPLY_PREPARED' && pendingShipments.value.some((s) => s.id === o.shipment_id), + ); + takeAddon.value = !hasDeclaredBatch; pickupDialogOpen.value = true; } catch (e) { FailAlert(e, 'Не удалось загрузить имущество поставщика'); @@ -623,6 +633,25 @@ async function onChairmanSigned(): Promise { await load(); } +// Откат приёмки: поставщик не согласен со снятыми оператором позициями целиком +// (повезёт замену в другой раз). До его подписи приёмка — черновик: отменяем +// все акты группы, партия возвращается к приёмке, оператор пересоберёт. +const cancellingKey = ref(null); +async function cancelReceptionGroup(group: ReceptionGroup): Promise { + cancellingKey.value = group.key; + try { + for (const r of group.receptions) { + await cancelAplReception({ apl_reception_id: r.id }); + } + SuccessAlert('Приёмка отменена — партия снова доступна к приёмке, можно пересобрать.'); + } catch (e) { + FailAlert(e, 'Не удалось отменить приёмку'); + } finally { + cancellingKey.value = null; + await load(); + } +} + watch(braname, () => void load()); // Повторный заход с новым кодом в query (универсальный сканер уже на этом столе). @@ -737,6 +766,18 @@ q-page.reception(role='region', aria-label='Ожидаемые поставки q-icon(name='draw', size='18px') | Подписать председателем + //- Поставщик ещё не подписал — оператор может отменить акт и пересобрать + //- (поставщик не согласен со снятыми позициями, повезёт замену позже). + .reception__card-foot(v-else-if='g.status === "PENDING_SUPPLIER_SIGN"') + BaseButton( + variant='ghost', + :loading='cancellingKey === g.key', + @click='cancelReceptionGroup(g)' + ) + template(#icon-left) + q-icon(name='undo', size='18px') + | Отменить и пересобрать + //- Ожидаемые поставки — примутся по скану QR поставщика (кнопка в шапке). BaseCard.reception__card(v-for='d in expectedDeliveries', :key='`exp-${d.offerer}`') template(#head) @@ -782,8 +823,10 @@ q-page.reception(role='region', aria-label='Ожидаемые поставки .reception__pickup-account {{ pickupSupplierName || pickupAccount }} .reception__pickup-hint | По каждой единице скорректируйте фактическое количество (не выше - | заказанного) и цену. Снимите галку с задекларированной единицы, чтобы - | не принимать её; партия без выбранных единиц не создаётся и ждёт. + | заказанного) и цену. + template(v-if='declaredGroups.length') + | Снимите галку с задекларированной единицы, чтобы не принимать её; + | партия без выбранных единиц не создаётся и ждёт. template(v-if='declaredGroups.length') .reception__pickup-section Задекларировано в партии (по ТТН) @@ -825,10 +868,17 @@ q-page.reception(role='region', aria-label='Ожидаемые поставки ) template(v-if='addonGroups.length') - .reception__pickup-divider - .reception__pickup-section-row - .reception__pickup-section Добор по акцепту (вне партии) - q-checkbox(v-model='takeAddon', dense, label='Принять добор') + //- Обёртку «Добор по акцепту» + чекбокс показываем ТОЛЬКО при наличии + //- партии: без партии standalone-«Принять добор» путает оператора. С + //- партией чекбокс по умолчанию выключен — добор не включается сам. + template(v-if='declaredGroups.length') + .reception__pickup-divider + .reception__pickup-section-row + .reception__pickup-section Добор по акцепту (вне партии) + q-checkbox(v-model='takeAddon', dense, label='Принять добор') + //- Без партии — это просто имущество поставщика; принимаем без обёртки + //- «добор» (добор включён скрыто, см. openPickupForSupplier). + .reception__pickup-section(v-else) Имущество поставщика .reception__group(v-for='g in addonGroups', :key='g.key') .reception__group-head span.reception__group-title {{ g.productName }} diff --git a/components/desktop/src/pages/Marketplace/OrdererOrderDetail/ui/OrdererOrderDetailPage.vue b/components/desktop/src/pages/Marketplace/OrdererOrderDetail/ui/OrdererOrderDetailPage.vue index a39ef5fbb11..743f72d0753 100644 --- a/components/desktop/src/pages/Marketplace/OrdererOrderDetail/ui/OrdererOrderDetailPage.vue +++ b/components/desktop/src/pages/Marketplace/OrdererOrderDetail/ui/OrdererOrderDetailPage.vue @@ -17,7 +17,6 @@ import { formatDateToLocalTimezone } from 'src/shared/lib/utils/dates'; import { cancelOrder, fetchOrder } from '../../MyOrders/api'; import type { MarketplaceOrderView } from '../../MyOrders/types'; import { fetchOffer } from '../../MarketplaceOfferDetail/api'; -import OrdererFinalizeIssuanceDialog from '../../MyOrders/ui/OrdererFinalizeIssuanceDialog.vue'; /** * Детальная страница заказа заказчика. Открывается кликом по карточке на @@ -41,13 +40,11 @@ const order = ref(null); const offerImages = ref([]); const loading = ref(false); -const finalizeDialogOpen = ref(false); const receiveDialogOpen = ref(false); const status = computed(() => (order.value ? orderStatusDisplay(order.value.status) : null)); const unitShort = computed(() => marketplaceUnitShort(order.value?.unit_of_measure)); const cancellable = computed(() => order.value?.status === 'ACTIVE'); -const receivable = computed(() => order.value?.status === 'READY_TO_RECEIVE'); const pvzName = computed(() => order.value?.delivery_point_name || order.value?.delivery_braname || ''); const pvzAddress = computed(() => order.value?.delivery_point_address || ''); @@ -147,10 +144,6 @@ function confirmCancel(): void { }); } -function onFinalized(): void { - void load(); -} - onMounted(() => { void load(); }); @@ -248,18 +241,8 @@ q-page.order-detail(role="region", aria-label="Заказ") .t-h3 Хронология ActivityTimeline(:events="timelineEvents", group-by-date) - .order-detail__actions(v-if="receivable || cancellable") - BaseButton(v-if="receivable", variant="primary", @click="finalizeDialogOpen = true") - template(#icon-left) - q-icon(name="draw", size="16px") - | Подписать и получить - BaseButton(v-if="cancellable", variant="danger", @click="confirmCancel") Отменить заказ - - OrdererFinalizeIssuanceDialog( - v-model="finalizeDialogOpen", - :orders="order ? [order] : []", - @finalized="onFinalized" - ) + .order-detail__actions(v-if="cancellable") + BaseButton(variant="danger", @click="confirmCancel") Отменить заказ HandoffCodeDialog(v-model="receiveDialogOpen", :coopname="coopname", :kind="HandoffTokenKind.Receive") diff --git a/components/desktop/src/pages/Marketplace/PvzWriteoffs/api/index.ts b/components/desktop/src/pages/Marketplace/PvzWriteoffs/api/index.ts new file mode 100644 index 00000000000..4b57a7dec23 --- /dev/null +++ b/components/desktop/src/pages/Marketplace/PvzWriteoffs/api/index.ts @@ -0,0 +1,57 @@ +import { Mutations, Queries, type Types } from '@coopenomics/sdk'; +import { client } from 'src/shared/api/client'; + +export type MarketplaceWriteoffConfirmationGroupView = + Queries.Marketplace.WriteoffPendingConfirmations.IOutput['marketplaceWriteoffPendingConfirmations'][number]; + +export type MarketplaceWriteoffServiceMemoDocumentView = Types.Document.IGeneratedDocument; + +// Протокол совета — подписанный документ (агрегат), не регенерируется. +export type MarketplaceWriteoffProtocolDocumentView = + Queries.Marketplace.WriteoffProtocolDocument.IOutput['marketplaceWriteoffProtocolDocument']; + +export type IConfirmWriteoffInput = Mutations.Marketplace.ConfirmWriteoff.IInput['data']; + +export type IServiceMemoSignablePayloadInput = + Queries.Marketplace.WriteoffServiceMemoSignablePayload.IInput['data']; + +export type IWriteoffProtocolDocumentInput = + Queries.Marketplace.WriteoffProtocolDocument.IInput['data']; + +export async function listWriteoffPendingConfirmations(): Promise< + MarketplaceWriteoffConfirmationGroupView[] +> { + const { [Queries.Marketplace.WriteoffPendingConfirmations.name]: result } = await client.Query( + Queries.Marketplace.WriteoffPendingConfirmations.query, + {}, + ); + return result; +} + +export async function getWriteoffServiceMemoSignablePayload( + data: IServiceMemoSignablePayloadInput, +): Promise { + const { [Queries.Marketplace.WriteoffServiceMemoSignablePayload.name]: result } = + await client.Query(Queries.Marketplace.WriteoffServiceMemoSignablePayload.query, { + variables: { data }, + }); + return result; +} + +export async function getWriteoffProtocolDocument( + data: IWriteoffProtocolDocumentInput, +): Promise { + const { [Queries.Marketplace.WriteoffProtocolDocument.name]: result } = await client.Query( + Queries.Marketplace.WriteoffProtocolDocument.query, + { variables: { data } }, + ); + return result; +} + +export async function confirmWriteoff(data: IConfirmWriteoffInput): Promise { + const { [Mutations.Marketplace.ConfirmWriteoff.name]: result } = await client.Mutation( + Mutations.Marketplace.ConfirmWriteoff.mutation, + { variables: { data } }, + ); + return result; +} diff --git a/components/desktop/src/pages/Marketplace/PvzWriteoffs/index.ts b/components/desktop/src/pages/Marketplace/PvzWriteoffs/index.ts new file mode 100644 index 00000000000..7a71583dc85 --- /dev/null +++ b/components/desktop/src/pages/Marketplace/PvzWriteoffs/index.ts @@ -0,0 +1,2 @@ +export { PvzWriteoffsPage } from './ui'; +export * from './api'; diff --git a/components/desktop/src/pages/Marketplace/PvzWriteoffs/ui/ConfirmWriteoffDialog.vue b/components/desktop/src/pages/Marketplace/PvzWriteoffs/ui/ConfirmWriteoffDialog.vue new file mode 100644 index 00000000000..06e88578197 --- /dev/null +++ b/components/desktop/src/pages/Marketplace/PvzWriteoffs/ui/ConfirmWriteoffDialog.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/components/desktop/src/pages/Marketplace/PvzWriteoffs/ui/PvzWriteoffsPage.vue b/components/desktop/src/pages/Marketplace/PvzWriteoffs/ui/PvzWriteoffsPage.vue new file mode 100644 index 00000000000..726cc7e841d --- /dev/null +++ b/components/desktop/src/pages/Marketplace/PvzWriteoffs/ui/PvzWriteoffsPage.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/components/desktop/src/pages/Marketplace/PvzWriteoffs/ui/index.ts b/components/desktop/src/pages/Marketplace/PvzWriteoffs/ui/index.ts new file mode 100644 index 00000000000..9edce57b7b1 --- /dev/null +++ b/components/desktop/src/pages/Marketplace/PvzWriteoffs/ui/index.ts @@ -0,0 +1 @@ +export { default as PvzWriteoffsPage } from './PvzWriteoffsPage.vue'; diff --git a/components/desktop/src/processes/init-installed-extensions/wallet-cards-registry.ts b/components/desktop/src/processes/init-installed-extensions/wallet-cards-registry.ts new file mode 100644 index 00000000000..5a45d13be62 --- /dev/null +++ b/components/desktop/src/processes/init-installed-extensions/wallet-cards-registry.ts @@ -0,0 +1,60 @@ +import type { DesktopWalletCard } from 'src/shared/lib/types/desktop-wallet'; +import { walletCards as capitalWalletCards } from '../../../extensions/capital/install'; +import { walletCards as marketWalletCards } from '../../../extensions/market/install'; + +/** + * Реестр-фабрика кошельков стола пайщика (путь B). + * + * Каждое расширение объявляет свои кошельки декларацией `walletCards` + * в своём `install.ts`. Здесь они собираются по имени расширения; core-набор + * (паевой кошелёк программы «Цифровой Кошелёк») добавляется всегда первым. + * `collectWalletCards` берёт только установленные у кооператива расширения, + * объединяет их декларации с core и дедупит по `wallet_name` (первое + * вхождение выигрывает — так пересечение нескольких расширений на один + * кошелёк схлопывается в одну карточку). Главный паевой кошелёк ЦК + * (`w.wal.share`) идёт ПЕРВЫМ (сразу после минимального неснижаемого + * остатка, который рендерится отдельной карточкой виджета) — у него + * приоритет; кошельки расширений идут после него. + * + * Что НЕ устанавливается на стол пайщика: главный членский (`w.wal.member`, + * программа ЦК) и Генератор (`w.cap.gen`, кооперативный кошелёк без L3). + * Минимальный неснижаемый паевой остаток — отдельная карточка виджета, + * не через реестр. + */ +const CORE_WALLET_CARDS: DesktopWalletCard[] = [ + { + wallet_name: 'w.wal.share', + label: 'Главный паевой кошелёк', + description: 'Цифровой кошелёк', + accent: 'wallet', + icon: 'account_balance_wallet', + }, +]; + +const WALLET_CARDS_BY_EXTENSION: Record = { + capital: capitalWalletCards, + market: marketWalletCards, +}; + +/** + * Собрать карточки кошельков для рендера на столе пайщика по набору + * установленных расширений (их `extension_name`). Дедуп по `wallet_name`. + */ +export function collectWalletCards(installedExtensionNames: Iterable): DesktopWalletCard[] { + // Главный паевой кошелёк ЦК — первым (приоритет), сразу за ним кошельки + // расширений. Минимальный неснижаемый остаток рендерится ещё выше отдельной + // карточкой виджета, мимо реестра. + const collected: DesktopWalletCard[] = [...CORE_WALLET_CARDS]; + + for (const ext of installedExtensionNames) { + const cards = WALLET_CARDS_BY_EXTENSION[ext]; + if (cards) collected.push(...cards); + } + + const seen = new Set(); + return collected.filter((card) => { + if (seen.has(card.wallet_name)) return false; + seen.add(card.wallet_name); + return true; + }); +} diff --git a/components/desktop/src/processes/process-decisions/handlers/index.ts b/components/desktop/src/processes/process-decisions/handlers/index.ts index e479b7cffd7..8260af512e8 100644 --- a/components/desktop/src/processes/process-decisions/handlers/index.ts +++ b/components/desktop/src/processes/process-decisions/handlers/index.ts @@ -1,5 +1,7 @@ import { Cooperative } from 'cooptypes'; import { decisionFactory } from 'src/shared/lib/decision-factory'; +import { DigitalDocument } from 'src/shared/lib/document'; +import { useSystemStore } from 'src/entities/System/model'; import { useGenerateFreeDecision } from 'src/features/FreeDecision/GenerateDecision'; import { useGenerateParticipantApplicationDecision } from 'src/features/Decision/ParticipantApplication'; import { useGenerateSovietDecisionOnAnnualMeet } from 'src/features/Meet/GenerateSovietDecision/model'; @@ -100,4 +102,46 @@ export function registerBaseDecisionHandlers() { }); }, }); + + // Обработчик для MarketplaceWriteoffProtocol (протокол совета о списании + // скоропорта со складов кооперативных участков, ЦПП «Стол заказов»). + // Документ-Решение совета (registry 1107) генерируется из метаданных + // подписанного Заявления о списании (1108) + decision_id текущего решения. + // Фабрика сама резолвит состав совета, голоса и реквизиты кооператива по + // decision_id — клиенту достаточно проброса итоговых полей проекта. + decisionFactory.registerHandler('mktwroff', { + generateHandler: async ({ decision_id, username, row }) => { + if (!row.table?.statement?.meta) { + throw new Error('Отсутствуют метаданные заявления для решения mktwroff'); + } + + const statementMeta = JSON.parse( + row.table.statement.meta, + ) as Cooperative.Registry.MarketplaceWriteoffStatement.Action; + + if (!statementMeta.proposal_hash || !Array.isArray(statementMeta.items)) { + throw new Error('Некорректные метаданные заявления для решения mktwroff'); + } + + const { info } = useSystemStore(); + + const protocolAction: Cooperative.Registry.MarketplaceWriteoffProtocol.Action = + { + registry_id: + Cooperative.Registry.MarketplaceWriteoffProtocol.registry_id, + coopname: info.coopname, + username, + lang: 'ru', + decision_id, + proposal_hash: statementMeta.proposal_hash, + cycle_started_at: statementMeta.cycle_started_at, + total_amount: statementMeta.total_amount, + items_count: statementMeta.items.length, + }; + + return await new DigitalDocument().generate(protocolAction, { + lang: 'ru', + }); + }, + }); } diff --git a/components/desktop/src/shared/api/alerts.ts b/components/desktop/src/shared/api/alerts.ts index 648463bc873..e9cb8ad309a 100644 --- a/components/desktop/src/shared/api/alerts.ts +++ b/components/desktop/src/shared/api/alerts.ts @@ -57,6 +57,9 @@ export function SuccessAlert( icon: 'check_circle', position: POSITION, timeout: TIMEOUT_INFO, + // Одна строка: иконка + заголовок + CTA + крестик в ряд, без прыгающей + // второй строки с разъезжающимися кнопками (multiLine авто-расхождение). + multiLine: false, actions: ctaAction ? [ctaAction, CLOSE_ACTION] : [CLOSE_ACTION], }); } diff --git a/components/desktop/src/shared/lib/account/useFioCache.ts b/components/desktop/src/shared/lib/account/useFioCache.ts new file mode 100644 index 00000000000..8a3e75a5efe --- /dev/null +++ b/components/desktop/src/shared/lib/account/useFioCache.ts @@ -0,0 +1,56 @@ +import { ref } from 'vue' +import { useAccountStore } from 'src/entities/Account' +import type { IAccount } from 'src/entities/Account/types' + +type AccountKindValue = NonNullable['account_kind'] + +/** + * Кэш человекочитаемых имён по username — общий для реестров (процессы/заказы/ + * предложения), где из цепочки приходит служебный аккаунт, а показывать нужно + * человеческое имя (НЕ braname). enrichFio догружает недостающие имена и кладёт + * в кэш; в UI используется `fioCache.get(username) || username`. + * + * Резолв имени системно живёт на бэкенде: getAccount по любому аккаунту + * (пайщик / организация / кооперативный участок / кооператив) собирает + * private_account с человеческим именем и возвращает account_kind. Поэтому здесь + * один путь — читаем private_account; КУ резолвится тем же каналом, что и + * организация (через organization_data.short_name). kindCache отдаёт вид + * аккаунта, чтобы UI мог пометить субъект (например, кооперативный участок). + */ +export function useFioCache() { + const accountStore = useAccountStore() + const fioCache = ref(new Map()) + const kindCache = ref(new Map()) + + async function enrichFio(rawUsernames: (string | null | undefined)[]): Promise { + 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) + if (acc?.account_kind) kindCache.value.set(username, acc.account_kind) + 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 + } + }), + ) + } + + return { fioCache, kindCache, enrichFio } +} diff --git a/components/desktop/src/shared/lib/ledger2/index.ts b/components/desktop/src/shared/lib/ledger2/index.ts index d65d88a2a5d..36d235d6067 100644 --- a/components/desktop/src/shared/lib/ledger2/index.ts +++ b/components/desktop/src/shared/lib/ledger2/index.ts @@ -1,2 +1,3 @@ export * from './wallet-registry' export * from './account-registry' +export * from './process-display' diff --git a/components/desktop/src/shared/lib/ledger2/process-display.ts b/components/desktop/src/shared/lib/ledger2/process-display.ts new file mode 100644 index 00000000000..02d0a80d32b --- /dev/null +++ b/components/desktop/src/shared/lib/ledger2/process-display.ts @@ -0,0 +1,85 @@ +import { Ledger2 } from 'cooptypes' +import { formatAsset2Digits } from 'src/shared/lib/utils' + +/** + * Единые helper'ы отображения процесса ledger2 (цвет/метка/формат) — общие для + * реестра процессов (стол бухгалтера) и реестра заказов (стол администратора), + * чтобы карта цветов и человекочитаемые подписи жили в одном месте, а не + * копировались по страницам. + */ + +export interface ProcessColorEntry { + /** Акцент левой полосы заголовка детализации (сырой hex — это палитра процесса, не токен темы). */ + accent: string + /** Цвет фона чипа (Quasar color-name). */ + chipBg: string + /** Цвет текста чипа (Quasar color-name). */ + chipText: string +} + +// Цвет процесса определяется контрактом-источником (reg/wal/cap/mkt/sov/mig/adj). +const PROCESS_COLORS: Record = { + 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', +} + +export 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 +} +export function processAccentColor(type: string | null | undefined): string { + return processColorEntry(type).accent +} +export function processChipBg(type: string | null | undefined): string { + return processColorEntry(type).chipBg +} +export function processChipText(type: string | null | undefined): string { + return processColorEntry(type).chipText +} + +export function processTypeLabel(type: string | null | undefined): string { + if (!type) return '—' + return Ledger2.getProcessHumanName(type) ?? type +} + +/** + * Человекочитаемое название операции. Запись apply несёт operation_code; + * корректировки walmove/revert его не несут — название берётся по action через + * тот же реестр операций. + */ +export function operationLabel( + row: { operationCode?: string | null; action?: string | null }, +): 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 '—' +} + +// id счёта хранится ×1000 — к UI-коду приводим целочисленным делением (51000 → 51). +export function accountCodeFromId(id: number | null | undefined): number | null { + return id != null ? Math.round(id / 1000) : null +} + +export function formatProcessAmount(qty: string | null | undefined): string { + if (!qty) return '—' + return formatAsset2Digits(qty) +} diff --git a/components/desktop/src/shared/lib/types/desktop-wallet.ts b/components/desktop/src/shared/lib/types/desktop-wallet.ts new file mode 100644 index 00000000000..5d3cd39031f --- /dev/null +++ b/components/desktop/src/shared/lib/types/desktop-wallet.ts @@ -0,0 +1,24 @@ +import type { WalletProgram } from 'src/shared/ui/domain/WalletCard'; + +/** + * Декларация кошелька, который расширение «приносит» на стол пайщика + * (страница кошелька). Путь B: каждое расширение объявляет свои кошельки + * по идентификатору (`wallet_name`), реестр собирает объединение деклараций + * установленных расширений и core-декларации, дедупит по `wallet_name` + * (пересеклись — остаётся первое вхождение) и рендерит карточки. + * + * Балансы тянутся отдельно по `wallet_name` из `getUserWallets` + * (сырой срез `ledger2::userwallets`, без сворачивания паевого и членского). + */ +export interface DesktopWalletCard { + /** Идентификатор кошелька (`eosio::name`) — ключ дедупликации и сопоставления с балансом. */ + wallet_name: string; + /** Наименование карточки (UI). */ + label: string; + /** Дополнительная подпись под заголовком. */ + description?: string; + /** Визуальный акцент плитки-иконки (`--prog-*`). По умолчанию 'wallet'. */ + accent?: WalletProgram; + /** Material-иконка карточки. */ + icon?: string; +} diff --git a/components/desktop/src/shared/ui/BaseDocument/BaseDocument.vue b/components/desktop/src/shared/ui/BaseDocument/BaseDocument.vue index 24b426d4da6..37256945359 100644 --- a/components/desktop/src/shared/ui/BaseDocument/BaseDocument.vue +++ b/components/desktop/src/shared/ui/BaseDocument/BaseDocument.vue @@ -131,6 +131,15 @@ const shadowStyles = computed( text-align: center; } + /* Заголовок документа (h1) — всегда по центру. Документы задают это в своём + diff --git a/components/desktop/src/widgets/Process/ProcessDetailCard/index.ts b/components/desktop/src/widgets/Process/ProcessDetailCard/index.ts new file mode 100644 index 00000000000..24e7b30ad50 --- /dev/null +++ b/components/desktop/src/widgets/Process/ProcessDetailCard/index.ts @@ -0,0 +1 @@ +export { default as ProcessDetailCard } from './ProcessDetailCard.vue' diff --git a/components/desktop/src/widgets/Wallet/WalletProgramWidget/ui/WalletProgramWidget.vue b/components/desktop/src/widgets/Wallet/WalletProgramWidget/ui/WalletProgramWidget.vue index e4c1020dabd..2320b3342a9 100644 --- a/components/desktop/src/widgets/Wallet/WalletProgramWidget/ui/WalletProgramWidget.vue +++ b/components/desktop/src/widgets/Wallet/WalletProgramWidget/ui/WalletProgramWidget.vue @@ -1,5 +1,5 @@