[mp2-9][@ant] feat(standards-site): уровень 3 кошельков пайщика, UX рабочей области и happy-path навигация

Учётная модель ledger2 теперь выражается в стандарте на трёх уровнях:
L1 — бухгалтерские проводки (debit/credit), L2 — кошельки кооператива
(wallet_from → wallet_to, агрегаты), L3 — кошелёк пайщика (per-user split
available/blocked: user_wallet, user_ref, available_delta, blocked_delta).
В types/standard.ts добавлены поля под L3, расширен enum WalletOp до полного
набора _blago (BURN/REVOKE/ACCOUNT_ONLY); FocusBar показывает три отдельные
карточки «Проводки / Кошельки кооператива / Кошелёк пайщика» с условным
показом — пустой уровень не выводится; L3-карточка тянется на всю ширину
op-сетки и идёт горизонтальной строкой (wallet · user · Доступно · Заблокировано),
лейблы по-русски. На прозрачные --soft-варианты фона добавлен override на
непрозрачный --bg, чтобы overlay-панель не светила сквозь себя.

Рабочая область процесса доработана: FocusBar переехал из нижней горизонтальной
полосы в левую вертикальную панель (правый верх занят зум-контролами VueFlow,
куда добавлена кнопка Maximize/Minimize2 для fullscreen-режима через нативный
Fullscreen API + автo-фит при смене размера); стартовый зум усилен (boost
2.0 → 2.8 ≈ +3 нажатия «+»). В fullscreen появляются дублирующие prev/next
оверлеями у нижнего края (внешние ◀/▶ остаются за экраном). Навигация по ←/→
теперь идёт по happy-path: pickHappyTransition в graph/layout.ts штрафует
имена-отказы (cancel/decline/expire/reject/abort/abandon/fail/revoke/discard)
и legacy actions[].role === 'reject', штрафует самопетли (from === to),
бонус за target-статус с продолжением; tie-break — порядок YAML.

Vite dev: cooptypes (workspace ESM-бандл ~2 МБ) добавлен в optimizeDeps.include —
без этого vite отдавал его через @fs трансформ-конвейером (раздувался до 10 МБ),
по сети валился dynamic import ProcessPage с «Failed to fetch dynamically
imported module». Pre-bundled chunk теперь 987 КБ.

Пилот L3 на p.mkt.supply.standard.yaml (createorder + полный жизненный цикл):
переименовано w.mkt.fund → w.mkt.member по всему файлу (33 случая в supply +
return — это per-user членский кошелёк программы Marketplace, fund-наименование
вводило в заблуждение); o.mkt.fconv (TRANSFER) получил L3 +order.total_cost
на available заказчика, o.mkt.fblock (BLOCK) — only-L3 split −available/+blocked,
o.mkt.funblk (UNBLOCK) — зеркало, o.mkt.fefund (TRANSFER) — −available на возврат,
o.mkt.consum (REVOKE) — only-L3 −blocked + L1 Дт 86/Кт 10. Также введён
промежуточный статус ship_ready («Отгрузка собрана») между accepted и
supply_prepared: prepship больше не самопетля accepted→accepted, а accepted→
ship_ready, signsupp идёт от ship_ready → supply_prepared — линейный поток
без обратных стрелок dagre.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
coopops
2026-05-04 17:21:23 +00:00
parent 5ab5234507
commit 28d4583a15
7 changed files with 451 additions and 77 deletions
@@ -8,7 +8,7 @@
# используется (упрощение реализации MVP — реверты исключены из системы).
#
# **Модель кошельков (упрощённая, refinement 2026-05-04):** один программный
# кошелёк w.mkt.fund (per-user) — членские взносы пайщика в программу.
# кошелёк w.mkt.member (per-user) — членские взносы пайщика в программу.
# Возврат суммы при гарантии происходит на тот же программный кошелёк
# (.available восстанавливается); пайщик может потратить на следующий заказ
# или вручную вывести на цифровой паевой w.wal.share через o.mkt.fefund.
@@ -24,7 +24,7 @@
# В процессе участвует 1 ledger2-операция:
# • o.mkt.return — compensating forward для отката выдачи имущества
# (ISSUE: восстановление .available на пайщикском
# w.mkt.fund; проводка Дт 10 / Кт 86 —
# w.mkt.member; проводка Дт 10 / Кт 86 —
# обратная к o.mkt.consum, имущество назад на склад)
#
# Канон формата:
@@ -35,7 +35,7 @@
# • cpp/lib/core/ledger2/operations.hpp — OPERATION_REGISTRY
# расширение o.mkt.return
# • cpp/lib/core/ledger2/processes.hpp — processes::marketplace::RETURN
# • cpp/lib/core/ledger2/wallets.hpp — w.mkt.fund (programmatic)
# • cpp/lib/core/ledger2/wallets.hpp — w.mkt.member (programmatic)
# • cpp/lib/core/ledger2/accounts.hpp — Целевое финансирование (86),
# Материалы (10)
# ─────────────────────────────────────────────────────────────────────────────
@@ -104,7 +104,7 @@ actions:
ledger2-операцию o.mkt.return — compensating forward к выдаче:
имущество возвращается на склад КУ (Дт 10 / Кт 86), сумма
восстанавливается на программном кошельке заказчика
(w.mkt.fund .available +fact_cost).
(w.mkt.member .available +fact_cost).
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
# Сущность: marketplace::return_request (таблица return_requests, scope=coopname).
@@ -134,7 +134,7 @@ states:
Председатель очно принял возврат. Применена o.mkt.return —
имущество возвращено на склад КУ (Дт 10 / Кт 86 — обратная проводка
к o.mkt.consum), сумма order.fact_cost восстановлена на программном
кошельке пайщика как .available на w.mkt.fund. Кошелёк
кошельке пайщика как .available на w.mkt.member. Кошелёк
пайщика обновлён в реальном времени; пайщик может направить
вернувшуюся сумму на следующий заказ в Столе заказов либо вывести
на цифровой паевой через отдельную операцию o.mkt.fefund.
@@ -261,7 +261,7 @@ scenario:
возвращается в учёт по счёту 10 КУ-получателя (проводка
Дт 10 / Кт 86 — обратная к o.mkt.consum); (2) сумма order.fact_cost
восстанавливается на программном кошельке заказчика
(ISSUE на w.mkt.fund пайщика, .available +fact_cost).
(ISSUE на w.mkt.member пайщика, .available +fact_cost).
Кошелёк пайщика обновляется в реальном времени; пайщик может
потратить вернувшуюся сумму на следующий заказ в Столе заказов
либо вывести на цифровой паевой через отдельную операцию
@@ -272,7 +272,7 @@ scenario:
- return_request в approved_for_visit.
- Пайщик прибыл с продукцией.
post:
- При принятии — return_request в return_accepted; имущество в учёте по счёту 10 КУ; сумма на w.mkt.fund пайщика как .available; в журнале опубликована o.mkt.return.
- При принятии — return_request в return_accepted; имущество в учёте по счёту 10 КУ; сумма на w.mkt.member пайщика как .available; в журнале опубликована o.mkt.return.
- При отказе — return_request в rejected_at_ku; пайщик забрал имущество; в журнале изменений по кошелькам нет.
alternatives:
@@ -339,7 +339,7 @@ operations:
human_name: Гарантийный возврат имущества
wallet_op: ISSUE
wallet_from: '' # вне системы — восстановление средств на пайщикском кошельке
wallet_to: w.mkt.fund # ЦПП «Стол Заказов» — программный кошелёк (per-user, .available +fact_cost)
wallet_to: w.mkt.member # ЦПП «Стол Заказов» — программный кошелёк (per-user, .available +fact_cost)
debit: 10 # Материалы — обратная проводка к o.mkt.consum
credit: 86 # Целевое финансирование
amount_ref: order.fact_cost
@@ -347,7 +347,7 @@ operations:
description: >
Compensating forward к выдаче имущества (o.mkt.consum). Атомарно
выполняет два эффекта в одной операции: (1) ISSUE на пайщикском
w.mkt.fund — .available +fact_cost (восстановление ранее
w.mkt.member — .available +fact_cost (восстановление ранее
списанной суммы); (2) проводка Дт 10 / Кт 86 — имущество возвращается
в учёт по счёту 10 КУ-получателя (обратная к проводке o.mkt.consum
Дт 86 / Кт 10). Журнал содержит payload original_consume_op_id
@@ -11,7 +11,7 @@
# двойные подписи на АПП приёмки и АПП выдачи.
#
# **Модель кошельков (упрощённая, refinement 2026-05-04):** один программный
# кошелёк w.mkt.fund (per-user) — членские взносы пайщика в программу
# кошелёк w.mkt.member (per-user) — членские взносы пайщика в программу
# «Стол заказов». Транзитные кошельки не используются. Имущество
# отслеживается бухгалтерской аналитикой по счёту 10 (per-КУ субсчета),
# без отдельного кошелька. Платформенные кошельки w.wal.share (Цифровой
@@ -24,21 +24,21 @@
#
# В процессе участвуют 6 ledger2-операций (из 9 общих по контракту):
# • o.mkt.fconv — конвертация цифрового рубля → членский взнос ЦПП
# (TRANSFER, w.wal.share → w.mkt.fund,
# (TRANSFER, w.wal.share → w.mkt.member,
# Дт 80 / Кт 86)
# • o.mkt.fblock — блокировка средств под Order на программном кошельке
# (BLOCK)
# • o.mkt.funblk — разблокировка при отмене Order'а
# (UNBLOCK)
# • o.mkt.fefund — возврат разблокированной суммы на цифровой паевой
# (TRANSFER, w.mkt.fund → w.wal.share,
# (TRANSFER, w.mkt.member → w.wal.share,
# Дт 86 / Кт 80)
# • o.mkt.purch — приёмка имущества кооперативом по АПП приёмки
# (ACCOUNT_ONLY, только проводка, Дт 10 / Кт 86)
# • o.mkt.payspl — оплата поставщику с расчётного счёта
# (TRANSFER, ∅ → w.mkt.payout, Дт 86 / Кт 51)
# • o.mkt.consum — выдача имущества пайщику по АПП выдачи
# (REVOKE, w.mkt.fund .blocked → 0,
# (REVOKE, w.mkt.member .blocked → 0,
# Дт 86 / Кт 10)
#
# Канон формата:
@@ -48,7 +48,7 @@
# • cpp/marketplace/src/ — реализация (предстоит)
# • cpp/lib/core/ledger2/operations.hpp — OPERATION_REGISTRY (расширение)
# • cpp/lib/core/ledger2/processes.hpp — processes::marketplace::SUPPLY
# • cpp/lib/core/ledger2/wallets.hpp — w.mkt.fund (programmatic),
# • cpp/lib/core/ledger2/wallets.hpp — w.mkt.member (programmatic),
# w.wal.share (платформенный),
# w.mkt.payout (платформенный)
# • cpp/lib/core/ledger2/accounts.hpp — Расчётный (51),
@@ -198,7 +198,7 @@ actions:
Заказчик ставит вторую (финальную) подпись на АПП выдачи на устройстве
оператора КУ. Это закрывающее действие всего процесса: контракт
применяет ledger2-операцию o.mkt.consum (Дт 86 / Кт 10, REVOKE —
w.mkt.fund пайщика .blocked → 0, имущество выбывает со
w.mkt.member пайщика .blocked → 0, имущество выбывает со
склада через проводку обратную к o.mkt.purch). При расхождении «факт
vs заказ» контракт предварительно выполняет корректировку: факт меньше
→ o.mkt.funblk на разницу (остаётся available на программном кошельке
@@ -218,7 +218,7 @@ states:
human: Заявка размещена
description: >
Order создан, средства заказчика прошли пару o.mkt.fconv + o.mkt.fblock
и заблокированы на программном кошельке w.mkt.fund. Заявка
и заблокированы на программном кошельке w.mkt.member. Заявка
ждёт окончания цикла отсечки и акцепта поставщика.
kind: normal
@@ -238,10 +238,19 @@ states:
description: >
Поставщик акцептовал консолидированную заявку, пара Order+Offer
опубликована on-chain. Средства заказчика по-прежнему заблокированы
на w.mkt.fund; имущество ещё не передано. Поставщик
на w.mkt.member; имущество ещё не передано. Поставщик
готовит отгрузку.
kind: normal
- name: ship_ready
human: Отгрузка собрана
description: >
Поставщик собрал партию по составу акцептованной заявки и выбрал
режим передачи (A — самовывоз / B — экспедитор + ТТН). Имущество
готово к подписанию АПП приёмки, но фактически ещё не передано КУ
и юридически не оприходовано.
kind: normal
- name: supply_prepared
human: Поставщик подписал
description: >
@@ -271,7 +280,7 @@ states:
human: Имущество выдано
description: >
Заказчик закрыл АПП выдачи. Применена o.mkt.consum (REVOKE на
w.mkt.fund пайщика .blocked → 0; проводка Дт 86 / Кт 10 —
w.mkt.member пайщика .blocked → 0; проводка Дт 86 / Кт 10 —
целевое использование членского взноса с одновременным выбытием
имущества со склада). Процесс поставки-приобретения полностью завершён.
Имущество на руках у пайщика, средства учтены. Гарантийное окно
@@ -289,9 +298,9 @@ transitions:
- o.mkt.fblock
guards:
- Заказчик — активный пайщик кооператива.
- Заказчик подписал Соглашение ЦПП «Стол заказов» (программный кошелёк w.mkt.fund открыт).
- Заказчик подписал Соглашение ЦПП «Стол заказов» (программный кошелёк w.mkt.member открыт).
- Стоимость корзины не превышает 100 000 ₽ (валидируется Backend'ом до вызова контракта).
- Достаточно средств на цифровом паевом (w.wal.share) либо available на программном кошельке (w.mkt.fund) — частичная конвертация если на программном уже что-то есть.
- Достаточно средств на цифровом паевом (w.wal.share) либо available на программном кошельке (w.mkt.member) — частичная конвертация если на программном уже что-то есть.
- Указанный КУ принадлежит этому же кооперативу.
- from: active
@@ -337,14 +346,14 @@ transitions:
- Поставщик отказывается от консолидированной партии до её публикации on-chain.
- from: accepted
to: accepted
to: ship_ready
action: marketplace::prepship
actor: supplier
guards:
- Состав Shipment'а равен составу акцептованной партии (жёсткий акцепт; замены номенклатуры запрещены).
- Поставщик выбрал режим: A (самовывоз) или B (экспедитор + ТТН).
- from: accepted
- from: ship_ready
to: supply_prepared
action: marketplace::signsupp
actor: supplier
@@ -397,18 +406,18 @@ scenario:
налоговый порог ТМЦ). Контракт createorder атомарно выполняет пару
операций по программному кошельку: (1) o.mkt.fconv — конвертация
цифрового рубля заказчика (w.wal.share) в членский взнос на ЦПП
«Стол заказов» (w.mkt.fund), Дт 80 / Кт 86; (2) o.mkt.fblock
«Стол заказов» (w.mkt.member), Дт 80 / Кт 86; (2) o.mkt.fblock
— блокировка суммы order.total_cost под Order на этом же кошельке,
available -= total_cost, blocked += total_cost. Order переходит
в статус active.
pre:
- Пайщик имеет статус active.
- Подписано Соглашение ЦПП «Стол заказов» (программный кошелёк w.mkt.fund открыт).
- На w.wal.share либо w.mkt.fund.available достаточно средств.
- Подписано Соглашение ЦПП «Стол заказов» (программный кошелёк w.mkt.member открыт).
- На w.wal.share либо w.mkt.member.available достаточно средств.
- Стоимость корзины ≤ 100 000 ₽.
post:
- Order создан со статусом active.
- На w.mkt.fund пайщика заблокирована сумма order.total_cost.
- На w.mkt.member пайщика заблокирована сумма order.total_cost.
- В журнале опубликованы 2 операции серии (fconv → fblock).
- step: 2
@@ -422,7 +431,7 @@ scenario:
(off-chain) и заявки остаются в статусе active. Если порог не
достигнут — для каждого Order'а контракт выполняет o.mkt.funblk
(разблокировка) + o.mkt.fefund (возврат на цифровой паевой через
TRANSFER w.mkt.fund → w.wal.share с проводкой
TRANSFER w.mkt.member → w.wal.share с проводкой
Дт 86 / Кт 80). Order'ы переходят в статус cancelled, заказчики
получают push-уведомление об отмене.
pre:
@@ -547,7 +556,7 @@ scenario:
отрицательный баланс ≤5% либо пайщик пополняет паевой через
кассира. Заказчик подписывает АПП выдачи на устройстве оператора —
это финальная (вторая) подпись. Контракт применяет o.mkt.consum
(REVOKE — w.mkt.fund пайщика .blocked → 0; проводка
(REVOKE — w.mkt.member пайщика .blocked → 0; проводка
Дт 86 / Кт 10 — расход целевых средств на расход имущества). Order
переходит в финальный статус received.
pre:
@@ -556,7 +565,7 @@ scenario:
post:
- Имущество выдано пайщику.
- Order в финальном статусе received.
- Сумма order.total_cost (или fact_cost при коррекции) списана с w.mkt.fund пайщика.
- Сумма order.total_cost (или fact_cost при коррекции) списана с w.mkt.member пайщика.
- В бухгалтерии — Дт 86 / Кт 10 на сумму fact_cost.
- В журнале опубликована o.mkt.consum (с предшествующими корректировками при необходимости).
- Гарантийное окно открыто (запуск p.mkt.return по заявлению пайщика возможен до конца окна).
@@ -685,7 +694,7 @@ documents:
# Семь ledger2-операций для процесса прямой поставки-приобретения. Имена
# ≤12 символов eosio::name. Кошельки идентифицируются eosio::name-строками
# с префиксом w.<contract>.<waltype> (рефакторинг 2026-04-27, ветка reports):
# • w.mkt.fund — ЦПП «Стол Заказов» — программный кошелёк (per-user)
# • w.mkt.member — ЦПП «Стол Заказов» — программный кошелёк (per-user)
# [members fees программы]
# • w.wal.share — ЦПП «Цифровой Кошелёк» — паевые взносы деньгами
# [платформенный]
@@ -696,59 +705,93 @@ operations:
- ledger_code: o.mkt.fconv
human_name: Конвертация цифрового рубля в членский взнос
wallet_op: TRANSFER
wallet_from: w.wal.share # ЦПП «Цифровой Кошелёк» — паевые взносы деньгами
wallet_to: w.mkt.fund # ЦПП «Стол Заказов» — программный кошелёк (членские взносы)
# L1 — двойная запись
debit: 80 # Паевой фонд
credit: 86 # Целевое финансирование
# L2 — кошельки кооператива (агрегаты)
wallet_from: w.wal.share # ЦПП «Цифровой Кошелёк» — паевые взносы деньгами
wallet_to: w.mkt.member # ЦПП «Стол Заказов» — членские взносы пайщиков по программе
# L3 — на стороне заказчика приходит зачисление в available на
# w.mkt.member; блокировка под Order — следующая операция o.mkt.fblock
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: >
Атомарная серия — шаг 1 из 2. Конвертация цифрового рубля заказчика
из ЦПП «Цифровой Кошелёк» в членский взнос на ЦПП «Стол заказов»
(программный кошелёк пайщика в этой ЦПП). Двойная запись Дт 80 / Кт 86 —
средства переходят из складочного капитала в целевое финансирование.
из ЦПП «Цифровой Кошелёк» в членский взнос на ЦПП «Стол заказов».
Двойная запись Дт 80 / Кт 86 — средства переходят из складочного
капитала в целевое финансирование. На уровне 3 — пополнение available
на w.mkt.member заказчика; блокировка этой суммы под конкретный
Order — отдельная операция o.mkt.fblock следом.
(TBD-Standardization: проводка фиксируется как baseline; финальный
выбор — при формализации стандарта совместно с Ангелиной.)
- ledger_code: o.mkt.fblock
human_name: Блокировка членского взноса под заказ
wallet_op: BLOCK
wallet_from: w.mkt.fund # ЦПП «Стол Заказов» — программный кошелёк
wallet_to: ''
# L1 — операция чисто L3, бухгалтерия не двигается
debit: null
credit: null
# L2 — без перевода между кошельками кооператива
wallet_from: null
wallet_to: null
# L3 — split available → blocked на одном кошельке заказчика
user_wallet: w.mkt.member # ЦПП «Стол Заказов» — членские взносы заказчика
user_ref: order.customer
available_delta: -order.total_cost
blocked_delta: +order.total_cost
amount_ref: order.total_cost
triggered_by: marketplace::createorder
description: >
Атомарная серия — шаг 2 из 2. Блокировка суммы order.total_cost под
конкретный Order на программном кошельке заказчика. available -=
total_cost, blocked += total_cost. Без бухгалтерских проводок.
Блокировка снимается либо при выдаче (o.mkt.consum), либо при отмене
(o.mkt.funblk + o.mkt.fefund).
конкретный Order на кошельке заказчика w.mkt.member. Это операция
уровня 3 — split available → blocked на одном кошельке без перевода
между кошельками кооператива и без бухгалтерских проводок.
Блокировка снимается либо при выдаче (o.mkt.consum, REVOKE blocked → 0),
либо при отмене (o.mkt.funblk + o.mkt.fefund — UNBLOCK + возврат).
- ledger_code: o.mkt.funblk
human_name: Разблокировка членского взноса при отмене
wallet_op: UNBLOCK
wallet_from: w.mkt.fund # ЦПП «Стол Заказов» — программный кошелёк
wallet_to: ''
# L1 — без проводок
debit: null
credit: null
# L2 — без перевода
wallet_from: null
wallet_to: null
# L3 — обратное движение blocked → available
user_wallet: w.mkt.member
user_ref: order.customer
available_delta: +order.total_cost
blocked_delta: -order.total_cost
amount_ref: order.total_cost
triggered_by: marketplace::cancelorder | marketplace::expirecycle | marketplace::declinebatch
description: >
Зеркало o.mkt.fblock. Снятие блокировки суммы на программном кошельке
заказчика при отмене Order'а (заказчик отменяет до акцепта; цикл истёк
без достижения порога; поставщик отказался от партии). blocked -=
total_cost, available += total_cost. Без проводок. Парная операция —
o.mkt.fefund (возврат суммы на цифровой паевой через TRANSFER).
Зеркало o.mkt.fblock. Снятие блокировки на кошельке заказчика
w.mkt.member при отмене Order'а (заказчик отменяет до акцепта;
цикл истёк без достижения порога; поставщик отказался от партии).
На уровне 3 blocked → available. Без L1, без L2. Парная операция —
o.mkt.fefund (возврат разблокированной суммы на цифровой паевой через
TRANSFER).
- ledger_code: o.mkt.fefund
human_name: Возврат членского взноса на цифровой паевой
wallet_op: TRANSFER
wallet_from: w.mkt.fund # ЦПП «Стол Заказов» — программный кошелёк
wallet_to: w.wal.share # ЦПП «Цифровой Кошелёк» — паевые взносы деньгами
# L1
debit: 86 # Целевое финансирование
credit: 80 # Паевой фонд
# L2 — кошельки кооператива
wallet_from: w.mkt.member # ЦПП «Стол Заказов»
wallet_to: w.wal.share # ЦПП «Цифровой Кошелёк»
# L3 — у заказчика на w.mkt.member списывается available; зачисление
# на w.wal.share — отдельная операция стандарта wallet
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::cancelorder | marketplace::expirecycle | marketplace::declinebatch
description: >
@@ -801,10 +844,18 @@ operations:
- ledger_code: o.mkt.consum
human_name: Выдача имущества пайщику
wallet_op: REVOKE
wallet_from: w.mkt.fund # ЦПП «Стол Заказов» — программный кошелёк пайщика (.blocked → 0)
wallet_to: ''
# L1 — выбытие имущества за счёт целевых средств
debit: 86 # Целевое финансирование
credit: 10 # Материалы
# L2 — без перевода между кошельками кооператива (REVOKE списывает
# blocked в пустоту, это L3-семантика)
wallet_from: null
wallet_to: null
# L3 — blocked заказчика обнуляется (целевое расходование членского взноса)
user_wallet: w.mkt.member
user_ref: order.customer
available_delta: null
blocked_delta: -order.fact_cost
amount_ref: order.fact_cost
triggered_by: marketplace::signiss2
description: >
@@ -222,6 +222,25 @@ function walletDisplayId(name: string | null | undefined): string {
return name == null || name === '' ? '∅' : name;
}
// ── L3 helpers ──────────────────────────────────────────────────────────
// Дельты на user-wallet хранятся как выражения вида '-order.total_cost' /
// '+order.total_cost'. Если знак не задан — нормализуем явно для UI.
function formatDelta(expr: string | null | undefined): string {
if (expr == null || expr === '') return '—';
const s = String(expr).trim();
if (s.startsWith('+') || s.startsWith('-') || s.startsWith('±')) return s;
return `±${s}`;
}
// Показываем L3-карточку только когда операция реально двигает кошелёк
// пайщика. Достаточно одного из полей user_wallet / available_delta /
// blocked_delta — остальные инферим как «не указано».
function hasL3(op: Ledger2Operation): boolean {
return Boolean(
op.user_wallet || op.user_ref || op.available_delta || op.blocked_delta,
);
}
</script>
<template>
@@ -336,9 +355,10 @@ function walletDisplayId(name: string | null | undefined): string {
<div class="focus-bar__op-name">{{ op.human_name }}</div>
<div class="focus-bar__op-subs">
<!-- L1 бухгалтерские проводки -->
<div
v-if="op.debit != null || op.credit != null"
class="focus-bar__op-sub"
class="focus-bar__op-sub focus-bar__op-sub--l1"
>
<div class="focus-bar__op-sub-label">Проводки</div>
<div class="focus-bar__op-sub-body">
@@ -352,11 +372,13 @@ function walletDisplayId(name: string | null | undefined): string {
</div>
</div>
<!-- L2 переводы по кошелькам кооператива -->
<div
v-if="op.wallet_op !== 'WALLET_ONLY' && (!!op.wallet_from || !!op.wallet_to)"
class="focus-bar__op-sub"
v-if="['ISSUE', 'TRANSFER', 'BURN', 'WALLET_ONLY'].includes(op.wallet_op)
&& (!!op.wallet_from || !!op.wallet_to)"
class="focus-bar__op-sub focus-bar__op-sub--l2"
>
<div class="focus-bar__op-sub-label">Переводы</div>
<div class="focus-bar__op-sub-label">Кошельки кооператива</div>
<div class="focus-bar__op-sub-body">
<span class="tooltip" :data-tip="walletTitle(op.wallet_from, op.wallet_op)">
<code>{{ walletDisplayId(op.wallet_from) }}</code>
@@ -367,6 +389,32 @@ function walletDisplayId(name: string | null | undefined): string {
</span>
</div>
</div>
<!-- L3 — кошелёк пайщика (per-user available/blocked split) -->
<div
v-if="hasL3(op)"
class="focus-bar__op-sub focus-bar__op-sub--l3"
>
<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>
</div>
</div>
</div>
</div>
</div>
@@ -403,9 +451,10 @@ function walletDisplayId(name: string | null | undefined): string {
</div>
<div class="focus-bar__col focus-bar__col--meta">
<!-- L1 -->
<div
v-if="focusedOperation.debit != null || focusedOperation.credit != null"
class="focus-bar__op-sub"
class="focus-bar__op-sub focus-bar__op-sub--l1"
>
<div class="focus-bar__op-sub-label">Проводки</div>
<div class="focus-bar__op-sub-body">
@@ -419,12 +468,13 @@ function walletDisplayId(name: string | null | undefined): string {
</div>
</div>
<!-- L2 -->
<div
v-if="focusedOperation.wallet_op !== 'WALLET_ONLY'
v-if="['ISSUE', 'TRANSFER', 'BURN', 'WALLET_ONLY'].includes(focusedOperation.wallet_op)
&& (!!focusedOperation.wallet_from || !!focusedOperation.wallet_to)"
class="focus-bar__op-sub"
class="focus-bar__op-sub focus-bar__op-sub--l2"
>
<div class="focus-bar__op-sub-label">Переводы</div>
<div class="focus-bar__op-sub-label">Переводы (кооператив)</div>
<div class="focus-bar__op-sub-body">
<span class="tooltip" :data-tip="walletTitle(focusedOperation.wallet_from, focusedOperation.wallet_op)">
<code>{{ walletDisplayId(focusedOperation.wallet_from) }}</code>
@@ -436,6 +486,32 @@ function walletDisplayId(name: string | null | undefined): string {
</div>
</div>
<!-- L3 -->
<div
v-if="hasL3(focusedOperation)"
class="focus-bar__op-sub focus-bar__op-sub--l3"
>
<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>
</div>
</div>
<div class="focus-bar__op-sub">
<div class="focus-bar__op-sub-label">Сумма</div>
<div class="focus-bar__op-sub-body"><code>{{ focusedOperation.amount_ref }}</code></div>
@@ -650,6 +726,41 @@ function walletDisplayId(name: string | null | undefined): string {
.focus-bar__op-sep { color: var(--text-subtle); }
.focus-bar__sep { color: var(--text-subtle); }
/* L1/L2/L3 — лёгкая дифференциация фоном без перекраски рамки. */
.focus-bar__op-sub--l1 { background: var(--bg); }
.focus-bar__op-sub--l2 { background: var(--edge-focus-soft); }
.focus-bar__op-sub--l3 {
background: var(--accent-soft);
border-color: var(--accent-border);
/* L3 — самая «нагруженная» карточка (wallet · user / Доступно / Заблокировано),
поэтому растягиваем на всю ширину сетки op-subs, чтобы дельты не зажимались
в узкую колонку и читались одной строкой. */
grid-column: 1 / -1;
}
.focus-bar__op-sub--l3 .focus-bar__op-sub-body code {
background: var(--bg); border-color: var(--accent-border); color: var(--accent);
}
/* L3-карточка: горизонтальная (wallet · user · Доступно · Заблокировано). */
.focus-bar__op-sub-body--stack {
display: flex; flex-direction: row; align-items: baseline;
gap: 8px 18px; flex-wrap: wrap;
}
.focus-bar__l3-row {
display: inline-flex; align-items: baseline; gap: 4px; flex-wrap: wrap;
}
.focus-bar__l3-user {
font-size: 11px; color: var(--text-muted); font-style: italic;
}
.focus-bar__l3-delta {
display: inline-flex; align-items: baseline; gap: 6px;
font-size: 11.5px; color: var(--text);
}
.focus-bar__l3-delta-label {
font-size: 10px; font-weight: 600; letter-spacing: 0.04em;
color: var(--text-subtle);
}
/* ── CSS-tooltip, надёжный (title часто блокируется) ─────────────────────── */
.tooltip { position: relative; display: inline-flex; align-items: center; gap: 4px; cursor: help; }
.tooltip[data-tip]:hover::after,
@@ -3,8 +3,8 @@ import { computed, markRaw, onBeforeUnmount, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { VueFlow, type EdgeMouseEvent, type NodeMouseEvent, type VueFlowStore } from '@vue-flow/core';
import { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls';
import { ChevronLeft, ChevronRight } from 'lucide-vue-next';
import { Controls, ControlButton } from '@vue-flow/controls';
import { ChevronLeft, ChevronRight, Maximize2, Minimize2 } from 'lucide-vue-next';
import '@vue-flow/core/dist/style.css';
import '@vue-flow/core/dist/theme-default.css';
@@ -64,8 +64,8 @@ const layout = computed(() =>
const vfInstance = ref<VueFlowStore | null>(null);
const minZoom = 0.15;
const maxZoom = 2.0;
// Во сколько раз сильнее, чем fitView (≈ 4 нажатия «+» в зум-контролах).
const START_ZOOM_BOOST = 2.0;
// Во сколько раз сильнее, чем fitView (≈ 67 нажатий «+» в зум-контролах).
const START_ZOOM_BOOST = 2.8;
function doFit(): void {
if (!vfInstance.value) return;
@@ -171,6 +171,34 @@ onBeforeUnmount(() => {
resizeObserver = null;
});
// ── Fullscreen рабочей области ────────────────────────────────────────
const isFullscreen = ref(false);
function onFsChange(): void {
isFullscreen.value = document.fullscreenElement === containerRef.value;
// После смены размера — перепосчитать viewport.
requestAnimationFrame(() => doFit());
}
if (typeof document !== 'undefined') {
document.addEventListener('fullscreenchange', onFsChange);
}
onBeforeUnmount(() => {
if (typeof document !== 'undefined') {
document.removeEventListener('fullscreenchange', onFsChange);
}
});
function toggleFullscreen(): void {
const el = containerRef.value;
if (!el) return;
if (document.fullscreenElement === el) {
void document.exitFullscreen();
} else {
void el.requestFullscreen();
}
}
function onNodeClick({ node }: NodeMouseEvent) {
switch (node.type) {
case NODE_TYPES.START:
@@ -274,10 +302,42 @@ function labelForNav(n: Nav): string {
@edge-click="onEdgeClick"
>
<Background :pattern-color="gridColor" :gap="16" :size="1" />
<Controls position="top-right" :show-interactive="false" />
<Controls position="top-right" :show-interactive="false">
<ControlButton
:title="isFullscreen ? 'Свернуть из полноэкранного режима' : 'Развернуть на весь экран'"
:aria-label="isFullscreen ? 'Свернуть' : 'Развернуть на весь экран'"
@click="toggleFullscreen"
>
<Minimize2 v-if="isFullscreen" :size="14" />
<Maximize2 v-else :size="14" />
</ControlButton>
</Controls>
</VueFlow>
<!-- Горизонтальная полоса деталей фокуса, прилипшая к низу рабочей области. -->
<!-- Дублирующие prev/next в виде оверлеев видны только в fullscreen,
где внешние кнопки .graph-nav скрыты за пределами экрана. -->
<button
type="button"
class="graph-nav graph-nav--floating graph-nav--floating-prev"
:disabled="!prevItem"
:aria-label="prevItem ? `Назад: ${labelForNav(prevItem)}` : 'Назад (нет)'"
:title="prevItem ? `Назад: ${labelForNav(prevItem)}` : ''"
@click="pushNav(prevItem)"
>
<ChevronLeft :size="20" />
</button>
<button
type="button"
class="graph-nav graph-nav--floating graph-nav--floating-next"
:disabled="!nextItem"
:aria-label="nextItem ? `Вперёд: ${labelForNav(nextItem)}` : 'Вперёд (нет)'"
:title="nextItem ? `Вперёд: ${labelForNav(nextItem)}` : ''"
@click="pushNav(nextItem)"
>
<ChevronRight :size="20" />
</button>
<!-- Левая панель деталей фокуса, прилипшая к левому краю рабочей области. -->
<div class="focus-bar-slot">
<FocusBar
:standard="standard"
@@ -345,21 +405,65 @@ function labelForNav(n: Nav): string {
position: relative;
}
/* Полоса деталей фокуса — приклеена к низу рабочей области изнутри */
/* Панель деталей фокуса — приклеена к левому краю рабочей области изнутри
(правый верх занят зум-контролами VueFlow). */
.focus-bar-slot {
position: absolute;
top: 12px;
left: 12px;
right: 12px;
bottom: 12px;
width: min(380px, 42%);
z-index: 10;
pointer-events: auto;
}
.focus-bar-slot :deep(.focus-bar) {
margin-bottom: 0;
background: var(--bg);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
max-height: 42%;
box-shadow: 6px 0 20px rgba(0, 0, 0, 0.12);
height: 100%;
max-height: 100%;
overflow-y: auto;
flex-direction: column;
flex-wrap: nowrap;
gap: 14px;
}
/* Внутри overlay-слота фон панели всегда непрозрачный — варианты
--edge / --doc / --op / --process / --rejected используют *-soft цвета
(rgba с alpha), сквозь которые просвечивает граф. Цвет окантовки
(border-color) остаётся вариантным — это и есть индикатор типа фокуса. */
.focus-bar-slot :deep(.focus-bar.focus-bar--edge),
.focus-bar-slot :deep(.focus-bar.focus-bar--doc),
.focus-bar-slot :deep(.focus-bar.focus-bar--op),
.focus-bar-slot :deep(.focus-bar.focus-bar--process),
.focus-bar-slot :deep(.focus-bar.focus-bar--rejected) {
background: var(--bg);
}
/* В полноэкранном режиме рабочая зона занимает весь экран. */
.process-graph:fullscreen {
border: none;
border-radius: 0;
height: 100vh;
width: 100vw;
}
/* Floating prev/next: пара кнопок у нижнего края в fullscreen — рядом,
а не по краям, чтобы не перекрывать вертикальный focus-bar слева. */
.graph-nav--floating {
display: none;
position: absolute;
bottom: 16px;
height: 44px;
z-index: 11;
background: var(--surface);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.14);
}
.graph-nav--floating-prev { right: 68px; }
.graph-nav--floating-next { right: 16px; }
.process-graph:fullscreen .graph-nav--floating { display: flex; }
.focus-bar-slot :deep(.focus-bar__col) {
flex: 0 0 auto;
width: 100%;
}
.focus-bar-slot :deep(.focus-bar--edge) {
background: var(--edge-focus-soft);
@@ -125,6 +125,57 @@ export interface NavItem {
to?: string;
}
// Эвристика «happy-path»: action-имена, которые уводят на ветку отмены/отказа.
// Сюда же относится legacy-поле `actions[].role === 'reject'` (если задано).
// Когда из текущего статуса несколько transitions, при выборе следующего шага
// штрафуем reject-кандидатов и предпочитаем тех, чей target имеет продолжение.
const REJECT_ACTION_KEYWORDS = [
'cancel',
'decline',
'expire',
'reject',
'abort',
'abandon',
'fail',
'revoke',
'discard',
];
function looksLikeRejectAction(actionName: string): boolean {
const short = actionShort(actionName).toLowerCase();
return REJECT_ACTION_KEYWORDS.some((kw) => short.includes(kw));
}
function pickHappyTransition(
outs: Transition[],
allTransitions: Transition[],
virtualSet: Set<string>,
rejectActionNames: Set<string>,
): Transition {
if (outs.length === 1) return outs[0];
const scored = outs.map((t, idx) => {
const isReject = rejectActionNames.has(t.action) || looksLikeRejectAction(t.action);
// Самопетля (from === to) — внутренний sub-step, не двигает цепочку:
// walk-loop сразу остановится на visited.has(to). Штрафуем.
const isSelfLoop = t.from === t.to;
// Если из target-статуса есть исходящие переходы — путь продолжается,
// это аргумент в пользу happy-ветки (отказы обычно сразу терминальны).
const targetHasContinuation = allTransitions.some(
(next) => next.from === t.to && !virtualSet.has(next.to),
);
return { t, idx, isReject, isSelfLoop, targetHasContinuation };
});
scored.sort((a, b) => {
if (a.isReject !== b.isReject) return a.isReject ? 1 : -1;
if (a.isSelfLoop !== b.isSelfLoop) return a.isSelfLoop ? 1 : -1;
if (a.targetHasContinuation !== b.targetHasContinuation) {
return a.targetHasContinuation ? -1 : 1;
}
return a.idx - b.idx;
});
return scored[0].t;
}
export function computeProcessFlow(standard: Standard): NavItem[] {
const items: NavItem[] = [];
const transitions = standard.transitions ?? [];
@@ -133,6 +184,12 @@ export function computeProcessFlow(standard: Standard): NavItem[] {
const hasTerminal = states.some((s) =>
isTerminalSuccess(s, transitions, virtualStateNames),
);
// legacy: actions[].role === 'reject' — явная пометка отказа в манифесте
const rejectActionNames = new Set(
(standard.actions ?? [])
.filter((a) => a.role === 'reject')
.map((a) => a.name),
);
const hasStart = transitions.some((t) => t.from === INITIAL_MARKER);
let current: string;
@@ -152,7 +209,12 @@ export function computeProcessFlow(standard: Standard): NavItem[] {
(t) => t.from === current && !virtualStateNames.has(t.to),
);
if (outs.length === 0) break;
const chosen = outs[0];
const chosen = pickHappyTransition(
outs,
transitions,
virtualStateNames,
rejectActionNames,
);
const actionNodeId = actionNodeIdFor(chosen);
items.push({
kind: 'action',
@@ -11,7 +11,21 @@
*/
// ── Типы-строки для wallet_op / relation / status / role ────────────────────
export type WalletOp = 'ISSUE' | 'TRANSFER' | 'BLOCK' | 'UNBLOCK' | 'WALLET_ONLY';
// Полный набор операций ledger2 (см. _blago L1/L2/L3 spec):
// ISSUE / TRANSFER / BURN — двигают L2 + опционально L1 + опционально L3
// BLOCK / UNBLOCK — только L3 (available ↔ blocked одного кошелька)
// REVOKE — целевое расходование L3.blocked, обычно с L1
// ACCOUNT_ONLY — только L1 (бухгалтерская проводка без кошельков)
// WALLET_ONLY — legacy: L2 без L1
export type WalletOp =
| 'ISSUE'
| 'TRANSFER'
| 'BURN'
| 'BLOCK'
| 'UNBLOCK'
| 'REVOKE'
| 'ACCOUNT_ONLY'
| 'WALLET_ONLY';
export type StandardLifecycle = 'proposed' | 'approved' | 'active' | 'deprecated';
@@ -122,19 +136,43 @@ export interface ProcessDocument {
note?: string;
}
// §6 Операции
// §6 Операции — три уровня учёта (L1/L2/L3)
//
// L1 — бухгалтерская проводка: debit / credit (счета 50/76/86/…)
// L2 — переводы между кошельками кооператива: wallet_from / wallet_to
// (агрегированные балансы кошельков ledger2)
// L3 — движения по кошельку пайщика (per-user available/blocked split):
// user_wallet, user_ref, available_delta, blocked_delta
//
// Каждая операция указывает только те уровни, которые реально затрагивает.
// Для BLOCK/UNBLOCK имеет смысл только L3. Для TRANSFER user-affecting —
// L1 + L2 + L3 (один и тот же `amount_ref` отражается на всех уровнях).
//
// В YAML указываются только коды/id. Человекочитаемые имена — из registries.
export interface Ledger2Operation {
ledger_code: string; // cap.lnissue
human_name: string;
wallet_op: WalletOp;
wallet_from: WalletId | null; // id кошелька-источника (null — ISSUE/extern)
wallet_to: WalletId | null; // id кошелька-приёмника (null — CONSUME)
debit: AccountCode | null; // код счёта (51, 80, …)
credit: AccountCode | null;
amount_ref: string; // debt.amount
triggered_by: string; // capital::debtpaycnfrm
description?: string;
// L1 — двойная запись (null — операция не затрагивает бухгалтерию)
debit: AccountCode | null;
credit: AccountCode | null;
// L2 — кошельки кооператива (null/'' — операция не двигает L2)
wallet_from: WalletId | null;
wallet_to: WalletId | null;
// L3 — кошелёк пайщика (опционально). Указывается, когда операция
// затрагивает per-user split available/blocked. Поля независимы от L2 —
// user_wallet может совпадать по имени с одним из L2-кошельков (для
// USER_SHARED-кошельков типа w.mkt.member) или быть отдельным.
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
}
// §7 Связи
@@ -15,6 +15,14 @@ export default defineConfig(({ command }) => ({
'@': path.resolve(rootDir, 'src'),
},
},
// cooptypes — workspace-пакет (~2 МБ ESM-бандла), vite по умолчанию
// workspace-deps не пре-бандлит и отдаёт через @fs трансформ-конвейер,
// что в dev по сети растягивает chunk до 10 МБ и валит lazy-import
// ProcessPage.vue. Принудительный optimizeDeps.include даёт нормальный
// pre-bundled chunk, как для vue/vue-router.
optimizeDeps: {
include: ['cooptypes'],
},
server: {
host: true, // слушать 0.0.0.0 — доступ по IP из сети
port: 5173,