[C28-33][@ant] fix!: оплата СЗ — из кооперативного пула расходов w.cap.pgexp, не с личного кошелька пайщика — зачем: payexp списывал w.cap.blago (USER_SHARED) с L3 инициатора, уменьшая его паевые взносы; topupprogexp теперь наполняет кошелёк пула (o.cap.pgtop), подотчёт w.exp.adv числится на получателе аванса; имена o.exp.*/p.exp.expns в cooptypes-словари (названия операций в UI); форма: пайщик-получатель только физлицо, поле ФИО по канону; страница расхода: кнопка «К расходам», без дубля суммы, позиции отклонённой СЗ не «ожидают оплаты»
Typecheck / desktop (pull_request) Failing after 8m39s
Typecheck / controller (pull_request) Failing after 8m12s

This commit is contained in:
coopops
2026-06-12 13:19:20 +00:00
parent 23cd31861a
commit 23bb2ad51f
13 changed files with 250 additions and 113 deletions
@@ -60,6 +60,6 @@ void capital::createpgexp(name coopname, checksum256 expense_hash, name creator,
_expense,
Names::External::CREATE_EXPENSE_PROPOSAL,
std::make_tuple(coopname, creator, expense_hash,
ledger2_wallets::BLAGOROST_FUND, items, callback, statement)
ledger2_wallets::PROGRAM_EXPENSE_POOL, items, callback, statement)
).send();
}
@@ -4,8 +4,10 @@
* Программные расходы — это целевые списания «Благорост», не аллоцированные ни в один проект
* (хозяйственные расходы программы, оплата инфраструктуры программы и т.п.). Прежде чем
* создавать запрос на программный расход, председатель пополняет дедикейтнутый пул
* @c program_expense_pool из общего @c global_available_invest_pool — деньги остаются
* на том же ledger2-кошельке @c BLAGOROST_FUND, меняется только разбивка по назначению.
* @c program_expense_pool из общего @c global_available_invest_pool. Помимо счётчиков
* state наполняется КООПЕРАТИВНЫЙ ledger2-кошелёк @c PROGRAM_EXPENSE_POOL (w.cap.pgexp) —
* именно из него шасси expense оплачивает СЗ; личные L3-кошельки пайщиков (w.cap.blago)
* при расходах не изменяются.
*
* @param coopname Кооператив (scope state)
* @param amount Сумма пополнения (символ строго совпадает с программой)
@@ -18,4 +20,14 @@ void capital::topupprogexp(name coopname, eosio::asset amount) {
require_auth(coopname);
Capital::State::topup_program_expense_pool(coopname, amount);
// Кошелёк-резерв пула: ISSUE w.cap.pgexp без бухпроводки (деньги физически
// на 51 с момента взносов — выделяется только назначение под расходы).
// Синтетический process_hash — у пополнения нет естественного анкера.
const uint64_t now_us = eosio::current_time_point().time_since_epoch().count();
const std::string anchor = coopname.to_string() + ":pgtopup:" + std::to_string(now_us);
const checksum256 process_hash = eosio::sha256(anchor.c_str(), anchor.size());
Ledger2::apply(_capital, coopname, operations::capital::PROGRAM_EXPENSE_TOPUP, amount,
name{}, process_hash, "capital:topupprogexp");
}
+19 -7
View File
@@ -209,6 +209,7 @@ void expense::payexp(name coopname, checksum256 proposal_hash, checksum256 item_
bool item_found = false;
uint8_t paid_mechanics = 0;
name paid_recipient{};
idx.modify(it, get_self(), [&](auto& row) {
bool all_reported = true;
@@ -227,6 +228,7 @@ void expense::payexp(name coopname, checksum256 proposal_hash, checksum256 item_
? static_cast<uint8_t>(D::ItemStatus::REPORTED)
: static_cast<uint8_t>(D::ItemStatus::PAID);
paid_mechanics = i.mechanics;
paid_recipient = i.recipient;
item_found = true;
}
if (i.status != static_cast<uint8_t>(D::ItemStatus::REPORTED)) {
@@ -244,8 +246,10 @@ void expense::payexp(name coopname, checksum256 proposal_hash, checksum256 item_
// ledger2-проводка по механике оплаченного item'а — выдача аванса (blgadv)
// или прямая оплата (blgdir). СЗ может смешивать обе механики.
// Источник — кооперативный пул расходов; username нужен ledger2 только для
// USER_SHARED-стороны (w.exp.adv) — это ПОЛУЧАТЕЛЬ аванса, не автор СЗ.
Ledger2::apply(get_self(), coopname, operation_code_for(paid_mechanics), actual_amount,
it->username, proposal_hash, "expense:payexp");
paid_recipient, proposal_hash, "expense:payexp");
}
void expense::reportexp(name coopname, checksum256 proposal_hash, checksum256 item_hash) {
@@ -260,6 +264,7 @@ void expense::reportexp(name coopname, checksum256 proposal_hash, checksum256 it
asset item_amount = asset{0, it->total_actual.symbol};
bool item_found = false;
name item_recipient{};
idx.modify(it, get_self(), [&](auto& row) {
bool all_reported = true;
@@ -271,6 +276,7 @@ void expense::reportexp(name coopname, checksum256 proposal_hash, checksum256 it
"reportexp применим только к ADVANCE-механике");
i.status = static_cast<uint8_t>(D::ItemStatus::REPORTED);
item_amount = i.actual_amount;
item_recipient = i.recipient;
item_found = true;
}
if (i.status != static_cast<uint8_t>(D::ItemStatus::REPORTED)) {
@@ -286,10 +292,11 @@ void expense::reportexp(name coopname, checksum256 proposal_hash, checksum256 it
// ledger2: BURN ADVANCE_HOLD на остаточный actual item'а (выдано − возвращено
// + доплачено), без бухпроводки — canal 08/51 уже сделан на blgadv/over.
// Подотчёт числится на получателе аванса — его username и закрывает burn.
// Полный возврат аванса (actual == 0) — burn не нужен, ADVANCE_HOLD уже пуст.
if (item_amount.amount > 0) {
Ledger2::apply(get_self(), coopname, operations::expense::ADVANCE_REPORT, item_amount,
it->username, proposal_hash, "expense:reportexp");
item_recipient, proposal_hash, "expense:reportexp");
}
}
@@ -326,6 +333,7 @@ void expense::returnexp(name coopname, checksum256 proposal_hash, checksum256 it
"Возврат возможен только по СЗ в статусе PARTIALLY_PAID");
bool item_found = false;
name item_recipient{};
idx.modify(it, get_self(), [&](auto& row) {
for (auto& i : row.items) {
@@ -343,6 +351,7 @@ void expense::returnexp(name coopname, checksum256 proposal_hash, checksum256 it
// возврата остатка получатель штатно отчитывается reportexp по
// фактически потраченной части (при полном возврате — actual == 0).
i.actual_amount -= return_amount;
item_recipient = i.recipient;
item_found = true;
break;
}
@@ -352,9 +361,10 @@ void expense::returnexp(name coopname, checksum256 proposal_hash, checksum256 it
row.updated_at = eosio::current_time_point();
});
// ledger2: TRANSFER ADVANCE_HOLD → BLAGOROST_FUND, Dr 51 / Cr 08.
// ledger2: TRANSFER ADVANCE_HOLD → PROGRAM_EXPENSE_POOL, Dr 51 / Cr 08.
// Подотчёт снимается с получателя аванса.
Ledger2::apply(get_self(), coopname, operations::expense::ADVANCE_RETURN, return_amount,
it->username, proposal_hash, "expense:returnexp");
item_recipient, proposal_hash, "expense:returnexp");
}
void expense::overspendexp(name coopname, checksum256 proposal_hash, checksum256 item_hash,
@@ -370,6 +380,7 @@ void expense::overspendexp(name coopname, checksum256 proposal_hash, checksum256
"Перерасход возможен только по СЗ в статусе PARTIALLY_PAID");
bool item_found = false;
name item_recipient{};
idx.modify(it, get_self(), [&](auto& row) {
for (auto& i : row.items) {
@@ -385,6 +396,7 @@ void expense::overspendexp(name coopname, checksum256 proposal_hash, checksum256
// (теперь на полную сумму выдано + доплата) закрывается штатным reportexp,
// который сделает burn ADVANCE_HOLD на итоговый actual.
i.actual_amount += overspend_amount;
item_recipient = i.recipient;
item_found = true;
break;
}
@@ -394,8 +406,8 @@ void expense::overspendexp(name coopname, checksum256 proposal_hash, checksum256
row.updated_at = eosio::current_time_point();
});
// ledger2: OVERSPEND — выдача доплаты (TRANSFER BLAGOROST_FUND → ADVANCE_HOLD,
// Dr 08 / Cr 51). Закрытие подотчёта на полную сумму — в reportexp.
// ledger2: OVERSPEND — выдача доплаты (TRANSFER PROGRAM_EXPENSE_POOL → ADVANCE_HOLD,
// Dr 08 / Cr 51) на подотчёт получателя. Закрытие на полную сумму — в reportexp.
Ledger2::apply(get_self(), coopname, operations::expense::OVERSPEND, overspend_amount,
it->username, proposal_hash, "expense:overspend");
item_recipient, proposal_hash, "expense:overspend");
}
@@ -85,6 +85,7 @@ namespace operations {
inline constexpr eosio::name WITHDRAW_FROM_CAPITAL = "o.cap.wthcap"_n; ///< Возврат паевого из ЦПП «Благорост» в кошелёк пайщика (TRANSFER BLAGOROST_FUND → SHARE_FUND_PAY, без Dr/Cr).
inline constexpr eosio::name CONVERT_TO_SHARE = "o.cap.cnvshr"_n; ///< Конвертация сегмента: РИД → главный кошелёк (TRANSFER GENERATOR_FUND → SHARE_FUND_PAY, без Dr/Cr — бухпроводка уже была сделана в ACCEPT_RID).
inline constexpr eosio::name CONVERT_TO_BLAGO = "o.cap.cnvbl"_n; ///< Конвертация сегмента: РИД → ЦПП «Благорост» (TRANSFER GENERATOR_FUND → BLAGOROST_FUND, без Dr/Cr — бухпроводка уже была сделана в ACCEPT_RID).
inline constexpr eosio::name PROGRAM_EXPENSE_TOPUP = "o.cap.pgtop"_n; ///< Пополнение пула программных расходов из инвестиций программы (ISSUE PROGRAM_EXPENSE_POOL, без Dr/Cr — деньги уже на 51, выделяется кооперативный резерв под расходы; паевые L3-кошельки пайщиков не трогаются).
}
// marketplace
@@ -102,6 +103,9 @@ namespace operations {
//
// Принципы (см. components/desktop/extensions/expenses/NAMING-C28-28.md):
// - Контракт `expense` универсальный: operation_code передаётся в payload.
// - Источник оплат — КООПЕРАТИВНЫЙ пул расходов (PROGRAM_EXPENSE_POOL),
// пополняемый o.cap.pgtop; личные L3-кошельки пайщиков (w.cap.blago)
// при оплатах СЗ не изменяются.
// - При расходе из Благороста паевой фонд (80) НЕ трогается: меняется только форма
// актива 51 → 08 (Дт 08 / Кт 51 для обеих механик).
// - ADVANCE-отчёт (`o.exp.advrpt`) НЕ создаёт новой бухпроводки: проводка уже
@@ -109,11 +113,11 @@ namespace operations {
// - Callback на финализацию — переменная (`callback{contract, action, data}`),
// заполняется при `expense::createexp`; expense ничего не знает про capital.
namespace expense {
inline constexpr eosio::name BLAGO_ADVANCE = "o.exp.blgadv"_n; ///< Выдача подотчётных из ЦПП «Благорост» (TRANSFER BLAGOROST_FUND → ADVANCE_HOLD, Dr 08 / Cr 51).
inline constexpr eosio::name BLAGO_DIRECT = "o.exp.blgdir"_n; ///< Прямая оплата из ЦПП «Благорост» (BURN BLAGOROST_FUND, Dr 08 / Cr 51).
inline constexpr eosio::name BLAGO_ADVANCE = "o.exp.blgadv"_n; ///< Выдача подотчётных из пула расходов (TRANSFER PROGRAM_EXPENSE_POOL → ADVANCE_HOLD, Dr 08 / Cr 51).
inline constexpr eosio::name BLAGO_DIRECT = "o.exp.blgdir"_n; ///< Прямая оплата из пула расходов (BURN PROGRAM_EXPENSE_POOL, Dr 08 / Cr 51).
inline constexpr eosio::name ADVANCE_REPORT = "o.exp.advrpt"_n; ///< Закрытие подотчёта пайщика (BURN ADVANCE_HOLD, без бухпроводки — canal 08/51 уже сделан на blgadv).
inline constexpr eosio::name ADVANCE_RETURN = "o.exp.advret"_n; ///< Возврат неиспользованного подотчёта (TRANSFER ADVANCE_HOLD → BLAGOROST_FUND, Dr 51 / Cr 08).
inline constexpr eosio::name OVERSPEND = "o.exp.over"_n; ///< Доплата сверх подотчёта (TRANSFER BLAGOROST_FUND → ADVANCE_HOLD, Dr 08 / Cr 51); сразу за ней expense вызывает ADVANCE_REPORT.
inline constexpr eosio::name ADVANCE_RETURN = "o.exp.advret"_n; ///< Возврат неиспользованного подотчёта (TRANSFER ADVANCE_HOLD → PROGRAM_EXPENSE_POOL, Dr 51 / Cr 08).
inline constexpr eosio::name OVERSPEND = "o.exp.over"_n; ///< Доплата сверх подотчёта (TRANSFER PROGRAM_EXPENSE_POOL → ADVANCE_HOLD, Dr 08 / Cr 51); сразу за ней expense вызывает ADVANCE_REPORT.
}
// migration (только из migrate.cpp)
@@ -370,6 +374,16 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
0, 0,
"Конвертация сегмента: РИД → ЦПП «Благорост»" },
// 19a. Пополнение пула программных расходов: ISSUE PROGRAM_EXPENSE_POOL, без Dr/Cr.
// Совет выделяет часть свободных инвестиций программы под целевые расходы:
// деньги физически на 51 с момента взносов, здесь появляется кооперативный
// резерв-кошелёк, из которого шасси expense оплачивает СЗ. Паевые L3-кошельки
// пайщиков (w.cap.blago, счёт 80) не изменяются — права требования сохраняются.
{ operations::capital::PROGRAM_EXPENSE_TOPUP, processes::capital::PGEXP, WalletOp::ISSUE,
eosio::name{}, ledger2_wallets::PROGRAM_EXPENSE_POOL,
0, 0,
"Пополнение пула программных расходов ЦПП «Благорост»" },
// ----- Шасси расходов (o.exp.*) — вызываются из контракта expense -----
//
// Базовое состояние Благороста ДО расхода:
@@ -380,21 +394,25 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
// Принцип: расход не уменьшает паевой фонд (80). Меняется только форма актива
// 51 → 08 (банк уходит, появляется WIP-проект).
// 20. Выдача подотчётных из Благороста: Dr 08 / Cr 51, TRANSFER BLAGOROST_FUND → ADVANCE_HOLD.
// Деньги физически уходят пайщику (Cr 51), стоимость капитализируется в WIP проекта (Dr 08).
// ADVANCE_HOLD фиксирует ответственность пайщика (USER_SHARED) до отчёта.
// 20. Выдача подотчётных из пула программных расходов: Dr 08 / Cr 51,
// TRANSFER PROGRAM_EXPENSE_POOL → ADVANCE_HOLD.
// Источник — КООПЕРАТИВНЫЙ пул расходов (пополняется o.cap.pgtop); личные
// L3-кошельки пайщиков (w.cap.blago) при оплате СЗ не трогаются — их паевые
// взносы в программе не уменьшаются. Деньги физически уходят пайщику (Cr 51),
// стоимость капитализируется в WIP (Dr 08). ADVANCE_HOLD фиксирует
// ответственность получателя аванса (USER_SHARED) до отчёта.
{ operations::expense::BLAGO_ADVANCE, processes::expense::PROPOSAL, WalletOp::TRANSFER,
ledger2_wallets::BLAGOROST_FUND, ledger2_wallets::ADVANCE_HOLD,
ledger2_wallets::PROGRAM_EXPENSE_POOL, ledger2_wallets::ADVANCE_HOLD,
ledger2_accounts::NON_CURRENT_INVESTMENTS, ledger2_accounts::BANK_ACCOUNT,
"Выдача подотчётных из ЦПП «Благорост»" },
"Выдача подотчётных из пула расходов ЦПП «Благорост»" },
// 21. Прямая оплата из Благороста (DIRECT): Dr 08 / Cr 51, BURN BLAGOROST_FUND.
// Прямая оплата организации; деньги уходят с 51, стоимость капитализируется в 08.
// Кошелёк-резерв не задействован — оплата фиксируется сразу.
// 21. Прямая оплата из пула программных расходов (DIRECT): Dr 08 / Cr 51,
// BURN PROGRAM_EXPENSE_POOL. Оплата организации по счёту; деньги уходят с 51,
// стоимость капитализируется в 08. Кошелёк-резерв не задействован.
{ operations::expense::BLAGO_DIRECT, processes::expense::PROPOSAL, WalletOp::BURN,
ledger2_wallets::BLAGOROST_FUND, eosio::name{},
ledger2_wallets::PROGRAM_EXPENSE_POOL, eosio::name{},
ledger2_accounts::NON_CURRENT_INVESTMENTS, ledger2_accounts::BANK_ACCOUNT,
"Прямая оплата из ЦПП «Благорост»" },
"Прямая оплата из пула расходов ЦПП «Благорост»" },
// 22. Закрытие подотчёта пайщика по отчёту: BURN ADVANCE_HOLD, БЕЗ бухпроводки.
// Проводка Dr 08 / Cr 51 уже сделана на BLAGO_ADVANCE при выдаче. При отчёте только
@@ -404,18 +422,21 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
0, 0,
"Закрытие подотчёта пайщика по отчёту" },
// 23. Возврат неиспользованного подотчёта: Dr 51 / Cr 08, TRANSFER ADVANCE_HOLD → BLAGOROST_FUND.
// Зеркало BLAGO_ADVANCE: деньги возвращаются на 51, WIP-стоимость уменьшается.
// 23. Возврат неиспользованного подотчёта: Dr 51 / Cr 08,
// TRANSFER ADVANCE_HOLD → PROGRAM_EXPENSE_POOL.
// Зеркало BLAGO_ADVANCE: деньги возвращаются на 51, WIP-стоимость уменьшается,
// остаток снова доступен пулу расходов.
{ operations::expense::ADVANCE_RETURN, processes::expense::PROPOSAL, WalletOp::TRANSFER,
ledger2_wallets::ADVANCE_HOLD, ledger2_wallets::BLAGOROST_FUND,
ledger2_wallets::ADVANCE_HOLD, ledger2_wallets::PROGRAM_EXPENSE_POOL,
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::NON_CURRENT_INVESTMENTS,
"Возврат неиспользованного подотчёта в ЦПП «Благорост»" },
"Возврат неиспользованного подотчёта в пул расходов" },
// 24. Доплата сверх подотчёта (перерасход): Dr 08 / Cr 51, TRANSFER BLAGOROST_FUND → ADVANCE_HOLD.
// 24. Доплата сверх подотчёта (перерасход): Dr 08 / Cr 51,
// TRANSFER PROGRAM_EXPENSE_POOL → ADVANCE_HOLD.
// Зеркало BLAGO_ADVANCE на сумму перерасхода. Контракт expense сразу за OVERSPEND
// вызывает ADVANCE_REPORT — две последовательные записи в одной транзакции `expense::overspendexp`.
{ operations::expense::OVERSPEND, processes::expense::PROPOSAL, WalletOp::TRANSFER,
ledger2_wallets::BLAGOROST_FUND, ledger2_wallets::ADVANCE_HOLD,
ledger2_wallets::PROGRAM_EXPENSE_POOL, ledger2_wallets::ADVANCE_HOLD,
ledger2_accounts::NON_CURRENT_INVESTMENTS, ledger2_accounts::BANK_ACCOUNT,
"Доплата сверх подотчёта (перерасход)" },
@@ -54,6 +54,7 @@ namespace processes {
inline constexpr eosio::name PROPERTY = "p.cap.prop"_n; ///< Приём имущественного паевого взноса (одноактовый).
inline constexpr eosio::name PREIMP = "p.cap.preimp"_n; ///< Первичный учёт РИД-взноса до перехода на электронный учёт (одноактовый, anchor = preimp register hash).
inline constexpr eosio::name WTHCAP = "p.cap.wthcap"_n; ///< Возврат паевого из ЦПП «Благорост» в кошелёк пайщика (одноактовый).
inline constexpr eosio::name PGEXP = "p.cap.pgexp"_n; ///< Пул программных расходов: пополнение из инвестиций программы (o.cap.pgtop, одноактовый).
}
// marketplace
@@ -65,6 +65,7 @@ struct ledger2_wallets {
static constexpr eosio::name BLAGOROST_FUND = "w.cap.blago"_n; ///< Благорост — единый агрегированный кошелёк программы (USER_SHARED; ADR-009)
static constexpr eosio::name GENERATOR_FUND = "w.cap.gen"_n; ///< Генератор — единый агрегированный кошелёк программы (COOPERATIVE — кооперативный пул, без L3-разреза по пайщику; L3-разрез из ADR-009 отменён из-за несовместимости с CRPS-перераспределением, см. wallets.hpp:107)
static constexpr eosio::name PREIMP_FUND = "w.cap.preimp"_n; ///< Первичный учёт РИД-взносов до перехода на электронный учёт (USER_SHARED; o.cap.preimp / o.cap.drppre)
static constexpr eosio::name PROGRAM_EXPENSE_POOL = "w.cap.pgexp"_n; ///< Пул программных расходов ЦПП «Благорост» (COOPERATIVE) — кооперативный кошелёк, из которого шасси expense оплачивает СЗ; пополняется topupprogexp (o.cap.pgtop), паевые L3-кошельки пайщиков (w.cap.blago) при расходах не трогаются
// marketplace — выплаты
static constexpr eosio::name SUPPLIER_PAYMENTS = "w.mkt.payout"_n; ///< Выплаты поставщикам (sink RECEIVE_CONFIRM, COOPERATIVE)
@@ -102,7 +103,7 @@ struct Ledger2WalletMeta {
WalletKind kind;
};
inline constexpr std::array<Ledger2WalletMeta, 17> LEDGER2_WALLET_REGISTRY = {{
inline constexpr std::array<Ledger2WalletMeta, 18> LEDGER2_WALLET_REGISTRY = {{
// USER_SHARED (7) — L3-разрез по пайщику
{ ledger2_wallets::MIN_SHARE_FUND, "Минимальный паевой взнос", WalletKind::USER_SHARED },
{ ledger2_wallets::SHARE_FUND_PAY, "Паевой взнос пайщика", WalletKind::USER_SHARED },
@@ -127,6 +128,7 @@ inline constexpr std::array<Ledger2WalletMeta, 17> LEDGER2_WALLET_REGISTRY = {{
{ ledger2_wallets::MIN_SHARE_USED, "Использованные минимальные паевые взносы", WalletKind::COOPERATIVE },
{ ledger2_wallets::LOAN_ISSUED, "Выданные пайщикам беспроцентные займы", WalletKind::COOPERATIVE },
{ ledger2_wallets::SUPPLIER_PAYMENTS, "Выплаты поставщикам", WalletKind::COOPERATIVE },
{ ledger2_wallets::PROGRAM_EXPENSE_POOL, "Пул программных расходов ЦПП «Благорост»", WalletKind::COOPERATIVE },
}};
static constexpr size_t LEDGER2_WALLET_REGISTRY_SIZE = LEDGER2_WALLET_REGISTRY.size();
@@ -172,6 +172,37 @@ export const LEDGER2_OPERATION_REGISTRY: readonly OperationMeta[] = [
debit: null, credit: null,
human_name: 'Конвертация сегмента: РИД → ЦПП «Благорост»' },
{ code: 'o.cap.pgtop', process_type: 'p.cap.pgexp', contract: 'capital',
name: 'PROGRAM_EXPENSE_TOPUP', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.cap.pgexp',
debit: null, credit: null,
human_name: 'Пополнение пула программных расходов ЦПП «Благорост»' },
// expense — шасси расходов (оплата СЗ из кооперативного пула расходов)
{ code: 'o.exp.blgadv', process_type: 'p.exp.expns', contract: 'expense',
name: 'BLAGO_ADVANCE', wallet_op: 'TRANSFER', wallet_from: 'w.cap.pgexp', wallet_to: 'w.exp.adv',
debit: 8, credit: 51,
human_name: 'Выдача подотчётных из пула расходов ЦПП «Благорост»' },
{ code: 'o.exp.blgdir', process_type: 'p.exp.expns', contract: 'expense',
name: 'BLAGO_DIRECT', wallet_op: 'BURN', wallet_from: 'w.cap.pgexp', wallet_to: null,
debit: 8, credit: 51,
human_name: 'Прямая оплата из пула расходов ЦПП «Благорост»' },
{ code: 'o.exp.advrpt', process_type: 'p.exp.expns', contract: 'expense',
name: 'ADVANCE_REPORT', wallet_op: 'BURN', wallet_from: 'w.exp.adv', wallet_to: null,
debit: null, credit: null,
human_name: 'Закрытие подотчёта пайщика по отчёту' },
{ code: 'o.exp.advret', process_type: 'p.exp.expns', contract: 'expense',
name: 'ADVANCE_RETURN', wallet_op: 'TRANSFER', wallet_from: 'w.exp.adv', wallet_to: 'w.cap.pgexp',
debit: 51, credit: 8,
human_name: 'Возврат неиспользованного подотчёта в пул расходов' },
{ code: 'o.exp.over', process_type: 'p.exp.expns', contract: 'expense',
name: 'OVERSPEND', wallet_op: 'TRANSFER', wallet_from: 'w.cap.pgexp', wallet_to: 'w.exp.adv',
debit: 8, credit: 51,
human_name: 'Доплата сверх подотчёта (перерасход)' },
// marketplace
{ code: 'o.mkt.supply', process_type: 'p.mkt.reqst', contract: 'marketplace',
name: 'CONFIRM_SUPPLY', wallet_op: 'ISSUE', wallet_from: null, wallet_to: 'w.wal.share',
@@ -36,6 +36,10 @@ export const LEDGER2_PROCESS_REGISTRY: readonly ProcessMeta[] = [
{ type: 'p.cap.prop', contract: 'capital', name: 'PROPERTY', human_name: 'Приём имущественного паевого взноса' },
{ type: 'p.cap.preimp', contract: 'capital', name: 'PREIMP', human_name: 'Первичный учёт РИД-взноса до перехода на электронный учёт' },
{ type: 'p.cap.wthcap', contract: 'capital', name: 'WTHCAP', human_name: 'Возврат паевого из ЦПП «Благорост» в кошелёк' },
{ type: 'p.cap.pgexp', contract: 'capital', name: 'PGEXP', human_name: 'Пополнение пула программных расходов' },
// expense
{ type: 'p.exp.expns', contract: 'expense', name: 'PROPOSAL', human_name: 'Расход по служебной записке' },
// marketplace
{ type: 'p.mkt.reqst', contract: 'marketplace', name: 'REQUEST', human_name: 'Запрос маркетплейса' },
@@ -36,6 +36,7 @@ export const LEDGER2_WALLET_REGISTRY: readonly WalletMeta[] = [
{ name: "w.sov.mnused", human_name: "Использованные минимальные паевые взносы", kind: "COOPERATIVE" },
{ name: "w.cap.loan", human_name: "Выданные пайщикам беспроцентные займы", kind: "COOPERATIVE" },
{ name: "w.mkt.payout", human_name: "Выплаты поставщикам", kind: "COOPERATIVE" },
{ name: "w.cap.pgexp", human_name: "Пул программных расходов ЦПП «Благорост»", kind: "COOPERATIVE" },
] as const
export interface ProgramWalletMapping {
@@ -60,10 +60,15 @@ BaseDialog(
disabled
)
.col-12.col-md-6(v-if='item.recipient_type === Zeus.ExpenseRecipientType.MEMBER')
UserSearchSelector(
//- Аванс под отчёт выдаётся только физическим лицам — организации и ИП
//- получают оплату по счёту (тип «Организация/ИП»).
UserSearchSelector.base-select(
v-model='item.recipient_account',
label='Пайщик-получатель (по ФИО)',
outlined,
dense,
reserve-hint-space,
:types='["individual"]',
@update:model-value='item.payment_method_id = null'
)
.col-12.col-md-6(v-if='item.recipient_type === Zeus.ExpenseRecipientType.ORG')
@@ -1,82 +1,90 @@
<template lang="pug">
q-page.program-expense-page
template(v-if='loading && !expense')
.skel
q-skeleton(type='rect', height='96px')
q-skeleton(type='rect', height='180px')
q-skeleton(type='rect', height='180px')
//- Липкий бар с кнопкой «Назад» — канон detail-страниц (реестр документов).
.program-expense-page__bar
button.expense-back(type='button', @click='goBack')
q-icon(name='arrow_back', size='18px')
span К расходам
template(v-else-if='expense')
//- Статус + сумма — верхняя строка страницы (канон: статус сущности вверху).
.head-row
BaseChip(:variant='proposalStatusVariant(expense.status)') {{ proposalStatusLabel(expense.status) }}
.head-row__amount.t-mono {{ expense.total_planned }}
.program-expense-page__content
template(v-if='loading && !expense')
.skel
q-skeleton(type='rect', height='96px')
q-skeleton(type='rect', height='180px')
q-skeleton(type='rect', height='180px')
BaseCard
.summary
DataRow(label='№ служебной записки', :value='shortExpenseId(expense.expense_hash)', mono, copyable)
DataRow(label='Инициатор', :value='expense.creator_name')
DataRow(label='Создан', :value='formatDate(expense.created_at)')
DataRow(label='Сумма по смете', :value='expense.total_planned')
DataRow(
v-if='hasActual',
label='Фактически израсходовано',
:value='expense.total_actual'
)
template(v-else-if='expense')
//- Статус сущности — вверху страницы (сумма — в сводке, не дублируем).
.head-row
BaseChip(:variant='proposalStatusVariant(expense.status)') {{ proposalStatusLabel(expense.status) }}
.section
.t-eyebrow.t-muted Позиции расхода
.items
BaseCard(v-for='(item, idx) in itemRows', :key='item.item_hash')
.item
.item__head
.item__title Позиция {{ idx + 1 }} {{ item.description }}
BaseChip(:variant='itemStatusVariant(item.status)') {{ itemStatusLabel(item.status, item.mechanics) }}
.item__rows
DataRow(label='Получатель', :value='item.recipient_name')
DataRow(label='Способ оплаты', :value='mechanicsLabel(item.mechanics)')
DataRow(label='Сумма (план)', :value='item.planned_amount')
DataRow(v-if='itemHasActual(item)', label='Фактически', :value='item.actual_amount')
DataRow(
v-if='item.requisite',
label='Реквизиты получателя',
:value='item.requisite.requisites'
)
DataRow(
v-if='item.requisite?.payment_purpose',
label='Назначение платежа',
:value='item.requisite.payment_purpose'
)
.item__files(v-if='item.files.length')
.t-sm.t-muted Первичные документы
button.file-link(
v-for='file in item.files',
:key='file.id',
type='button',
:disabled='openingId === file.id',
@click='openFile(file)'
)
q-icon(name='attach_file', size='16px')
span {{ fileKindLabel(file.kind) }}: {{ fileLabel(file) }}
q-spinner(v-if='openingId === file.id', size='14px')
.section
.t-eyebrow.t-muted История состояний
BaseCard
ActivityTimeline(:events='timeline', group-by-date)
.summary
DataRow(label='№ служебной записки', :value='shortExpenseId(expense.expense_hash)', mono, copyable)
DataRow(label='Инициатор', :value='expense.creator_name')
DataRow(label='Создан', :value='formatDate(expense.created_at)')
DataRow(label='Сумма по смете', :value='expense.total_planned')
DataRow(
v-if='hasActual',
label='Фактически израсходовано',
:value='expense.total_actual'
)
.empty(v-else)
EmptyState(
title='Расход не найден',
body='Служебная записка с таким номером отсутствует или ещё не синхронизирована.'
)
template(#icon)
q-icon(name='receipt_long', size='48px')
.section
.t-eyebrow.t-muted Позиции расхода
.items
BaseCard(v-for='(item, idx) in itemRows', :key='item.item_hash')
.item
.item__head
.item__title Позиция {{ idx + 1 }} {{ item.description }}
//- У отклонённой СЗ позиции ничего не «ожидают» — оплат не будет.
BaseChip(v-if='isDeclined', variant='neutral') Не будет оплачена
BaseChip(v-else, :variant='itemStatusVariant(item.status)') {{ itemStatusLabel(item.status, item.mechanics) }}
.item__rows
DataRow(label='Получатель', :value='item.recipient_name')
DataRow(label='Способ оплаты', :value='mechanicsLabel(item.mechanics)')
DataRow(label='Сумма (план)', :value='item.planned_amount')
DataRow(v-if='itemHasActual(item)', label='Фактически', :value='item.actual_amount')
DataRow(
v-if='item.requisite',
label='Реквизиты получателя',
:value='item.requisite.requisites'
)
DataRow(
v-if='item.requisite?.payment_purpose',
label='Назначение платежа',
:value='item.requisite.payment_purpose'
)
.item__files(v-if='item.files.length')
.t-sm.t-muted Первичные документы
button.file-link(
v-for='file in item.files',
:key='file.id',
type='button',
:disabled='openingId === file.id',
@click='openFile(file)'
)
q-icon(name='attach_file', size='16px')
span {{ fileKindLabel(file.kind) }}: {{ fileLabel(file) }}
q-spinner(v-if='openingId === file.id', size='14px')
.section
.t-eyebrow.t-muted История состояний
BaseCard
ActivityTimeline(:events='timeline', group-by-date)
.empty(v-else)
EmptyState(
title='Расход не найден',
body='Служебная записка с таким номером отсутствует или ещё не синхронизирована.'
)
template(#icon)
q-icon(name='receipt_long', size='48px')
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { Zeus } from '@coopenomics/sdk';
import { FailAlert } from 'src/shared/api';
import { useSystemStore } from 'src/entities/System/model';
@@ -104,7 +112,12 @@ import {
} from 'app/extensions/capital/entities/ProgramExpense/model';
const route = useRoute();
const router = useRouter();
const system = useSystemStore();
function goBack(): void {
void router.push({ name: 'capital-program-expenses' });
}
const desktopStore = useDesktopStore();
const store = useProgramExpenseStore();
@@ -154,6 +167,10 @@ const hasActual = computed(
() => parseFloat(expense.value?.total_actual ?? '0') > 0,
);
const isDeclined = computed(
() => expense.value?.status === Zeus.ExpenseProposalStatus.DECLINED,
);
type ItemRow = IProgramExpenseItem & {
requisite: IExpenseRequisite | null;
files: IExpenseProposalFile[];
@@ -289,7 +306,18 @@ const timeline = computed<ActivityEvent[]>(() => {
</script>
<style lang="scss" scoped>
.program-expense-page {
/* Липкий бар во всю ширину — отступы несут бар и контент раздельно.
top = высота фиксированного топбара (--p-topbar-h), иначе бар уезжает под него. */
.program-expense-page__bar {
position: sticky;
top: var(--p-topbar-h, 56px);
z-index: 2;
background: var(--p-canvas);
border-bottom: 1px solid var(--p-line);
padding: var(--p-3, 12px) var(--p-6, 24px);
}
.program-expense-page__content {
padding: var(--p-6, 24px);
display: flex;
flex-direction: column;
@@ -297,11 +325,30 @@ const timeline = computed<ActivityEvent[]>(() => {
}
@media (max-width: 768px) {
.program-expense-page {
.program-expense-page__bar {
padding: var(--p-3, 12px) var(--p-4, 16px);
}
.program-expense-page__content {
padding: var(--p-4, 16px);
}
}
.expense-back {
display: inline-flex;
align-items: center;
gap: var(--p-1, 4px);
padding: 0;
border: none;
background: transparent;
color: var(--p-ink-2);
font-size: var(--p-fs-body-sm, 13px);
cursor: pointer;
transition: color var(--p-dur-fast, 120ms) var(--p-ease-standard);
}
.expense-back:hover {
color: var(--p-ink);
}
.skel {
display: flex;
flex-direction: column;
@@ -315,12 +362,6 @@ const timeline = computed<ActivityEvent[]>(() => {
gap: var(--p-3);
}
.head-row__amount {
font-size: var(--p-fs-h3);
font-weight: 700;
color: var(--p-ink);
}
.summary {
display: flex;
flex-direction: column;
@@ -50,11 +50,13 @@
| # | C++ константа | eosio::name | WalletOp | wallet_from → wallet_to | Dr / Cr | human_name |
|---|---|---|---|---|---|---|
| 3.1 | `operations::expense::BLAGO_ADVANCE` | `o.exp.blgadv` | TRANSFER | `BLAGOROST_FUND``w.exp.adv` | **08 / 51** | «Выдача подотчётных из ЦПП «Благорост»» |
| 3.2 | `operations::expense::BLAGO_DIRECT` | `o.exp.blgdir` | BURN | `BLAGOROST_FUND` → ∅ | **08 / 51** | «Прямая оплата из ЦПП «Благорост» (DIRECT)» |
| 3.1 | `operations::expense::BLAGO_ADVANCE` | `o.exp.blgadv` | TRANSFER | `PROGRAM_EXPENSE_POOL``w.exp.adv` | **08 / 51** | «Выдача подотчётных из пула расходов ЦПП «Благорост»» |
| 3.2 | `operations::expense::BLAGO_DIRECT` | `o.exp.blgdir` | BURN | `PROGRAM_EXPENSE_POOL` → ∅ | **08 / 51** | «Прямая оплата из пула расходов ЦПП «Благорост»» |
| 3.3 | `operations::expense::ADVANCE_REPORT` | `o.exp.advrpt` | BURN | `w.exp.adv` → ∅ | 0 / 0 | «Закрытие подотчёта пайщика по отчёту (canal 08/51 уже сделан на blgadv)» |
| 3.4 | `operations::expense::ADVANCE_RETURN` | `o.exp.advret` | TRANSFER | `w.exp.adv``BLAGOROST_FUND` | **51 / 08** | «Возврат неиспользованного подотчёта в ЦПП «Благорост» (зеркало blgadv)» |
| 3.5 | `operations::expense::OVERSPEND` | `o.exp.over` | TRANSFER | `BLAGOROST_FUND``w.exp.adv` | **08 / 51** | «Доплата сверх подотчёта (перерасход)» |
| 3.4 | `operations::expense::ADVANCE_RETURN` | `o.exp.advret` | TRANSFER | `w.exp.adv``PROGRAM_EXPENSE_POOL` | **51 / 08** | «Возврат неиспользованного подотчёта в пул расходов (зеркало blgadv)» |
| 3.5 | `operations::expense::OVERSPEND` | `o.exp.over` | TRANSFER | `PROGRAM_EXPENSE_POOL``w.exp.adv` | **08 / 51** | «Доплата сверх подотчёта (перерасход)» |
**Изменение v4 (2026-06-12, голосовое):** источник оплат — НЕ `w.cap.blago` (он USER_SHARED: списание уменьшало личный паевой кошелёк пайщика-инициатора — категорически неверно). Введён КООПЕРАТИВНЫЙ кошелёк `w.cap.pgexp` (`PROGRAM_EXPENSE_POOL`, «Пул программных расходов ЦПП «Благорост»»): пополняется `capital::topupprogexp` операцией `o.cap.pgtop` (ISSUE, без Dr/Cr — деньги физически на 51 со взносов, выделяется назначение), из него `expense::payexp` платит обе механики. `username` в ledger2-проводках шасси — получатель позиции (подотчёт `w.exp.adv` числится на получателе аванса), не автор СЗ.
**Process_type:** один — `processes::expense::PROPOSAL = "p.exp.proposal"_n`. Все 5 кодов привязаны к нему.
@@ -73,6 +73,8 @@ const props = defineProps<{
filled?: boolean;
outlined?: boolean;
color?: string;
/** Ограничить выдачу типами аккаунтов (например, только физлица: ['individual']). */
types?: string[];
}>();
// Эмиты
@@ -127,7 +129,10 @@ const selectOptions = computed(() => {
// console.log('searchResults.value:', searchResults.value); // Отладка
// console.log('selectedUserData.value:', selectedUserData.value); // Отладка
const options = [...searchResults.value];
const allowed = props.types;
const options = searchResults.value.filter(
(r) => !allowed?.length || allowed.includes(r.type),
);
// Если есть выбранный пользователь, но его нет в результатах поиска, добавляем
if (selectedUserData.value) {