[mp2-11][@ant] feat(standards): двусторонний L3 + игнор Дт=Кт same-account — расширяем схему Ledger2Operation массивом l3[] для случаев перевода между двумя USER_SHARED одного пайщика (классика — w.wal.member ↔ w.mkt.member); FocusBar рендерит каждое движение отдельной строкой; supply ftarg/fefund переведены на массив l3 (-X на источнике, +X на получателе) + L1 проводки Дт 86 / Кт 86 убраны (нетто-нулевое изменение по 86 — смарт-контракт игнорирует, аналитика «по программе» фиксируется на L2/L3)

This commit is contained in:
coopops
2026-05-04 17:50:56 +00:00
parent 0f41f0d9d4
commit fdc0035954
3 changed files with 135 additions and 61 deletions
@@ -36,7 +36,8 @@
# Дт 80 / Кт 86) — conditional, только если на
# w.wal.member.available заказчика не хватает
# • o.mkt.ftarg — целевое назначение членского взноса в программу
# (TRANSFER, w.wal.member → w.mkt.member) — conditional,
# (TRANSFER, w.wal.member → w.mkt.member, без проводки —
# Дт=Кт по 86 смарт-контракт игнорирует) — conditional,
# только если на w.mkt.member.available не хватает
# • o.mkt.fblock — блокировка средств под Order на программном кошельке
# (BLOCK)
@@ -44,7 +45,7 @@
# (UNBLOCK; средства остаются на w.mkt.member.available
# и могут быть потрачены на следующий заказ)
# • o.mkt.fefund — вывод из программы в общий членский кошелёк пайщика
# (TRANSFER, w.mkt.member → w.wal.member) —
# (TRANSFER, w.mkt.member → w.wal.member, без проводки) —
# ОТДЕЛЬНОЕ явное действие пайщика, не часть авто-flow
# отмены/возврата
# • o.mkt.purch — приёмка имущества кооперативом по АПП приёмки
@@ -765,18 +766,25 @@ operations:
- ledger_code: o.mkt.ftarg
human_name: Целевое назначение членского взноса в программу Marketplace
wallet_op: TRANSFER
# L1 — внутри 86 счёта между аналитиками (TBD-Standardization)
debit: 86 # Целевое финансирование (программа Marketplace)
credit: 86 # Целевое финансирование (общий членский пайщика)
# L2 — кошельки кооператива (агрегаты, по аналитике программ)
# L1 — нет проводки. По плану счетов оба кошелька висят на 86, и Дт 86 /
# Кт 86 даёт нетто-нулевое изменение по счёту — смарт-контракт такие
# «фиктивные» проводки игнорирует. Аналитика «по программе» фиксируется
# на L2 (через имена агрегатных кошельков) — этого достаточно.
debit: null
credit: null
# L2 — реальный перевод между двумя именованными агрегатами кооператива
wallet_from: w.wal.member # Общий членский — пайщик расходует
wallet_to: w.mkt.member # ЦПП «Стол Заказов» — целевое назначение в программу
# L3 — у того же пайщика available перетекает с w.wal.member на w.mkt.member;
# рендерим сторону получателя; на источнике подразумевается -X
user_wallet: w.mkt.member
user_ref: order.customer
available_delta: +order.total_cost
blocked_delta: null
# L3 — две стороны движения у одного и того же пайщика
l3:
- user_wallet: w.wal.member
user_ref: order.customer
available_delta: -order.total_cost
blocked_delta: null
- user_wallet: w.mkt.member
user_ref: order.customer
available_delta: +order.total_cost
blocked_delta: null
amount_ref: order.total_cost
triggered_by: marketplace::createorder
description: >
@@ -784,12 +792,9 @@ operations:
w.mkt.member.available заказчика не хватает суммы заказа). Перенос
членского взноса пайщика из универсального кошелька (w.wal.member)
в программу Marketplace (w.mkt.member). Сумма берётся ровно как
недостача. По бухгалтерии — внутрисчётное движение по 86 (членский
фонд кооператива), меняется аналитика «программа: marketplace»; в
ledger2 это отражено как L2-перевод между двумя именованными агрегатами.
(TBD-Standardization: проводка Дт 86 / Кт 86 фиксируется как baseline;
возможна альтернатива через null/null если бухгалтерия посчитает
перенос аналитики не требующим явной двойной записи.)
недостача. По бухгалтерии — без проводки (Дт=Кт по 86 нетто-нулевое
изменение, смарт-контракт такие фиктивные проводки игнорирует);
аналитика «по программе» фиксируется на L2/L3.
- ledger_code: o.mkt.fblock
human_name: Блокировка членского взноса под заказ
@@ -844,17 +849,22 @@ operations:
- ledger_code: o.mkt.fefund
human_name: Вывод членского взноса в общий членский кошелёк
wallet_op: TRANSFER
# L1 — внутри 86 счёта между аналитиками (TBD-Standardization)
debit: 86 # Целевое финансирование (общий членский пайщика)
credit: 86 # Целевое финансирование (программа Marketplace)
# L2 — обратный перенос аналитики
# L1 — нет проводки (Дт=Кт по 86 нетто-нулевое; смарт-контракт игнорирует).
debit: null
credit: null
# L2 — реальный перевод между двумя именованными агрегатами кооператива
wallet_from: w.mkt.member # ЦПП «Стол Заказов» — программный членский
wallet_to: w.wal.member # Универсальный членский — общий членский пайщика
# L3 — у того же пайщика available перетекает с w.mkt.member на w.wal.member
user_wallet: w.wal.member
user_ref: order.customer
available_delta: +order.total_cost
blocked_delta: null
# L3 — две стороны движения у одного и того же пайщика
l3:
- user_wallet: w.mkt.member
user_ref: order.customer
available_delta: -order.total_cost
blocked_delta: null
- user_wallet: w.wal.member
user_ref: order.customer
available_delta: +order.total_cost
blocked_delta: null
amount_ref: order.total_cost
triggered_by: TBD
description: >
@@ -864,8 +874,7 @@ operations:
Благорост или обратно на цифровой паевой через отдельную операцию
контракта wallet). НЕ часть авто-flow отмены/возврата — на cancel/
expire/decline средства остаются на w.mkt.member.available, и
инициатива вывода — у пайщика. Внутрисчётное движение по 86
(TBD-Standardization).
инициатива вывода — у пайщика.
- ledger_code: o.mkt.purch
human_name: Приёмка имущества кооперативом
@@ -234,13 +234,33 @@ function formatDelta(expr: string | null | undefined): string {
// Показываем L3-карточку только когда операция реально двигает кошелёк
// пайщика. Достаточно одного из полей user_wallet / available_delta /
// blocked_delta — остальные инферим как «не указано».
// blocked_delta (flat) или непустой массив l3 — остальные инферим как
// «не указано».
function hasL3(op: Ledger2Operation): boolean {
if (op.l3 && op.l3.length > 0) return true;
return Boolean(
op.user_wallet || op.user_ref || op.available_delta || op.blocked_delta,
);
}
// Возвращаем все L3-строки операции в едином формате — массив movements.
// Если op.l3 задан — используем его; иначе строим единственный movement
// из flat-полей (legacy-совместимость с одиночным L3).
function getL3Movements(op: Ledger2Operation) {
if (op.l3 && op.l3.length > 0) return op.l3;
if (
op.user_wallet || op.user_ref || op.available_delta || op.blocked_delta
) {
return [{
user_wallet: op.user_wallet ?? null,
user_ref: op.user_ref,
available_delta: op.available_delta,
blocked_delta: op.blocked_delta,
}];
}
return [];
}
</script>
<template>
@@ -397,21 +417,27 @@ function hasL3(op: Ledger2Operation): boolean {
>
<div class="focus-bar__op-sub-label">Кошелёк пайщика</div>
<div class="focus-bar__op-sub-body focus-bar__op-sub-body--stack">
<div class="focus-bar__l3-row">
<span class="tooltip" :data-tip="walletTitle(op.user_wallet, op.wallet_op)">
<code>{{ walletDisplayId(op.user_wallet) }}</code>
</span>
<span v-if="op.user_ref" class="focus-bar__l3-user">
· {{ op.user_ref }}
</span>
</div>
<div v-if="op.available_delta" class="focus-bar__l3-delta">
<span class="focus-bar__l3-delta-label">Доступно</span>
<code>{{ formatDelta(op.available_delta) }}</code>
</div>
<div v-if="op.blocked_delta" class="focus-bar__l3-delta">
<span class="focus-bar__l3-delta-label">Заблокировано</span>
<code>{{ formatDelta(op.blocked_delta) }}</code>
<div
v-for="(mov, mi) in getL3Movements(op)"
:key="mi"
class="focus-bar__l3-movement"
>
<div class="focus-bar__l3-row">
<span class="tooltip" :data-tip="walletTitle(mov.user_wallet, op.wallet_op)">
<code>{{ walletDisplayId(mov.user_wallet) }}</code>
</span>
<span v-if="mov.user_ref" class="focus-bar__l3-user">
· {{ mov.user_ref }}
</span>
</div>
<div v-if="mov.available_delta" class="focus-bar__l3-delta">
<span class="focus-bar__l3-delta-label">Доступно</span>
<code>{{ formatDelta(mov.available_delta) }}</code>
</div>
<div v-if="mov.blocked_delta" class="focus-bar__l3-delta">
<span class="focus-bar__l3-delta-label">Заблокировано</span>
<code>{{ formatDelta(mov.blocked_delta) }}</code>
</div>
</div>
</div>
</div>
@@ -493,21 +519,27 @@ function hasL3(op: Ledger2Operation): boolean {
>
<div class="focus-bar__op-sub-label">Кошелёк пайщика</div>
<div class="focus-bar__op-sub-body focus-bar__op-sub-body--stack">
<div class="focus-bar__l3-row">
<span class="tooltip" :data-tip="walletTitle(focusedOperation.user_wallet, focusedOperation.wallet_op)">
<code>{{ walletDisplayId(focusedOperation.user_wallet) }}</code>
</span>
<span v-if="focusedOperation.user_ref" class="focus-bar__l3-user">
· {{ focusedOperation.user_ref }}
</span>
</div>
<div v-if="focusedOperation.available_delta" class="focus-bar__l3-delta">
<span class="focus-bar__l3-delta-label">Доступно</span>
<code>{{ formatDelta(focusedOperation.available_delta) }}</code>
</div>
<div v-if="focusedOperation.blocked_delta" class="focus-bar__l3-delta">
<span class="focus-bar__l3-delta-label">Заблокировано</span>
<code>{{ formatDelta(focusedOperation.blocked_delta) }}</code>
<div
v-for="(mov, mi) in getL3Movements(focusedOperation)"
:key="mi"
class="focus-bar__l3-movement"
>
<div class="focus-bar__l3-row">
<span class="tooltip" :data-tip="walletTitle(mov.user_wallet, focusedOperation.wallet_op)">
<code>{{ walletDisplayId(mov.user_wallet) }}</code>
</span>
<span v-if="mov.user_ref" class="focus-bar__l3-user">
· {{ mov.user_ref }}
</span>
</div>
<div v-if="mov.available_delta" class="focus-bar__l3-delta">
<span class="focus-bar__l3-delta-label">Доступно</span>
<code>{{ formatDelta(mov.available_delta) }}</code>
</div>
<div v-if="mov.blocked_delta" class="focus-bar__l3-delta">
<span class="focus-bar__l3-delta-label">Заблокировано</span>
<code>{{ formatDelta(mov.blocked_delta) }}</code>
</div>
</div>
</div>
</div>
@@ -741,11 +773,21 @@ function hasL3(op: Ledger2Operation): boolean {
background: var(--bg); border-color: var(--accent-border); color: var(--accent);
}
/* L3-карточка: горизонтальная (wallet · user · Доступно · Заблокировано). */
/* L3-карточка: каждое движение — отдельная строка (wallet · user · Доступно ·
Заблокировано). Несколько movement'ов рендерятся вертикально друг под
другом — для TRANSFER между двумя USER_SHARED одного пайщика и подобных. */
.focus-bar__op-sub-body--stack {
display: flex; flex-direction: column; align-items: stretch;
gap: 6px;
}
.focus-bar__l3-movement {
display: flex; flex-direction: row; align-items: baseline;
gap: 8px 18px; flex-wrap: wrap;
}
.focus-bar__l3-movement + .focus-bar__l3-movement {
border-top: 1px dashed var(--border-subtle, rgba(0,0,0,0.08));
padding-top: 6px;
}
.focus-bar__l3-row {
display: inline-flex; align-items: baseline; gap: 4px; flex-wrap: wrap;
}
@@ -169,10 +169,33 @@ export interface Ledger2Operation {
// затрагивает per-user split available/blocked. Поля независимы от L2 —
// user_wallet может совпадать по имени с одним из L2-кошельков (для
// USER_SHARED-кошельков типа w.mkt.member) или быть отдельным.
//
// Для одиночного движения — flat-поля ниже. Для TRANSFER между двумя
// USER_SHARED одного пайщика (списание -X с одного, зачисление +X на
// другой) — массив `l3` ниже с двумя элементами; flat-поля при этом
// не используются (или дублируют первый элемент массива для legacy-
// совместимости).
user_wallet?: WalletId | null;
user_ref?: string; // ссылка на пайщика — order.customer, debt.holder
available_delta?: string | null; // выражение по amount_ref: -order.total_cost
blocked_delta?: string | null; // +order.total_cost
// L3 multi-entry — для случаев, когда одна операция затрагивает
// несколько per-user строк (например, TRANSFER между двумя
// USER_SHARED-кошельками одного пайщика, или multi-user effects).
// Если задан — рендерим каждый элемент отдельной L3-строкой.
l3?: L3Movement[];
}
// Один per-user split, одна сторона движения. Массив таких объектов —
// в `Ledger2Operation.l3` для случаев, когда операция касается нескольких
// USER_SHARED-кошельков (TRANSFER между ними у одного и того же пайщика,
// либо multi-user effects).
export interface L3Movement {
user_wallet: WalletId | null;
user_ref?: string;
available_delta?: string | null;
blocked_delta?: string | null;
}
// §7 Связи