docs(marketplace): scaffold harness + fix pug-template inline arrays

Phase 0 ground work for «Стол заказов» MVP docs:

- harness:
  - extend KNOWN_FIXTURES with 7 marketplace participants
    (chairkrg/chairodn/chairmyt/trustedkrg/opkrg/sidorov/petrova)
  - add cleanViteOverlays() helper to strip vite/HMR overlays before
    every shot/shotElement screenshot
  - loginAsChairman/loginAs: call cleanViteOverlays before clicks +
    waitForFunction (hash-router friendly) instead of waitForURL

- scenarios:
  - scenarios/marketplace/PLAN.md (E2E план + структура столов)
  - scenarios/marketplace/chairman/branches.mjs — Сеть ПВЗ

- boot seed:
  - boot/src/scripts/seed-marketplace-branches.ts —
    идемпотентно создаёт 3 КУ Подмосковья (krg/odn/myt)
    on-chain + Mongo organizations + addtrusted trustedkrg

- desktop pug fixes (8 файлов в pages/Marketplace):
  vite vue-pug parser падал с «Unterminated string constant» на
  многострочных inline-массивах :columns="[...]" / :options="[...]"
  внутри pug templates. Выношу массивы в <script setup> как const —
  парсер проходит, типизация q-table через QTableProps['columns'].

- quasar.config.cjs: vitePlugins=[] временно отключает
  vite-plugin-checker (overlay перехватывал клики Playwright +
  vue-tsc worker отъедал >4 ГБ heap).

Refs: components/docs-harness/scenarios/marketplace/PLAN.md
This commit is contained in:
ant
2026-05-20 20:07:08 +00:00
parent 505a33f1db
commit 54d1ae268f
14 changed files with 717 additions and 57 deletions
@@ -0,0 +1,178 @@
/**
* Seed-скрипт для трёх кооперативных участков Подмосковья:
* krg — Красногорск, председатель chairkrg
* odn — Одинцово, председатель chairodn
* myt — Мытищи, председатель chairmyt
* + доверенное лицо trustedkrg для КУ Красногорск.
*
* Делает:
* 1. eosio::newaccount для каждого braname (если ещё не существует).
* 2. branch::createbranch (actor=coopname active).
* 3. branch::addtrusted для krg → trustedkrg.
* 4. Mongo organization upsert (short_name + контакты, для desktop UI).
*
* Использование:
* MONGO_URI=mongodb://127.0.0.1:27017/cooperative-x \
* pnpm --filter @coopenomics/boot exec esno \
* src/scripts/seed-marketplace-branches.ts
*/
import { BranchContract, type Cooperative } from 'cooptypes'
import { Generator } from '@coopenomics/factory'
import Blockchain from '../blockchain'
import config from '../configs'
const log = (...a: unknown[]) => console.error('[seed-branches]', ...a)
interface BranchSeed {
braname: string
trustee: string
short_name: string
full_name: string
city: string
fact_address: string
phone: string
email: string
based_on: string
}
const BRANCHES: BranchSeed[] = [
{
braname: 'krg',
trustee: 'chairkrg',
short_name: 'КУ Красногорск',
full_name: 'Кооперативный участок «Красногорск»',
city: 'Красногорск',
fact_address: 'Московская область, г. Красногорск, ул. Заводская, д. 1',
phone: '+79991230101',
email: 'krg@voskhod.coop',
based_on: 'решение собрания совета №СС-1 от 20 мая 2026 г',
},
{
braname: 'odn',
trustee: 'chairodn',
short_name: 'КУ Одинцово',
full_name: 'Кооперативный участок «Одинцово»',
city: 'Одинцово',
fact_address: 'Московская область, г. Одинцово, ул. Центральная, д. 12',
phone: '+79991230202',
email: 'odn@voskhod.coop',
based_on: 'решение собрания совета №СС-1 от 20 мая 2026 г',
},
{
braname: 'myt',
trustee: 'chairmyt',
short_name: 'КУ Мытищи',
full_name: 'Кооперативный участок «Мытищи»',
city: 'Мытищи',
fact_address: 'Московская область, г. Мытищи, Олимпийский проспект, д. 5',
phone: '+79991230303',
email: 'myt@voskhod.coop',
based_on: 'решение собрания совета №СС-1 от 20 мая 2026 г',
},
]
const TRUSTED_KRG = ['trustedkrg']
async function main() {
const blockchain = new Blockchain(config.network, config.private_keys)
await blockchain.update_pass_instance()
const coopname = config.provider
// === 1. Mongo organization upsert для каждого КУ ===
const mongoUri = process.env.MONGO_URI
if (!mongoUri) throw new Error('MONGO_URI не задан')
const generator = new Generator()
await generator.connect(mongoUri)
for (const b of BRANCHES) {
const orgData: Cooperative.Users.IOrganizationData = {
username: b.braname,
type: 'coop',
short_name: b.short_name,
full_name: b.full_name,
represented_by: {
first_name: '',
last_name: '',
middle_name: '',
position: 'председатель кооперативного участка',
based_on: b.based_on,
},
country: 'Российская Федерация',
city: b.city,
fact_address: b.fact_address,
full_address: b.fact_address,
email: b.email,
phone: b.phone,
details: { inn: '', ogrn: '', kpp: '' },
}
try {
await generator.save('organization', orgData)
log(`mongo organization ${b.braname} upserted`)
} catch (e: any) {
log(`mongo organization ${b.braname} failed: ${e.message ?? e}`)
}
}
// === 2. ончейн createbranch (actor=coopname active, ключ кооператива) ===
for (const b of BRANCHES) {
try {
// Проверяем не существует ли КУ уже on-chain
const existing = await blockchain.api.rpc.get_table_rows({
code: 'branch',
scope: coopname,
table: 'branches',
lower_bound: b.braname,
upper_bound: b.braname,
limit: 1,
}).catch(() => ({ rows: [] }))
if (existing.rows && existing.rows.length > 0) {
log(`skip createbranch ${b.braname} (already on-chain)`)
continue
}
const data: BranchContract.Actions.CreateBranch.ICreateBranch = {
coopname,
braname: b.braname,
trustee: b.trustee,
}
await blockchain.api.transact({
actions: [{
account: BranchContract.contractName.production,
name: BranchContract.Actions.CreateBranch.actionName,
authorization: [{ actor: coopname, permission: 'active' }],
data,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
log(`createbranch ${b.braname} → trustee=${b.trustee} OK`)
} catch (e: any) {
log(`createbranch ${b.braname} failed: ${e.message ?? e}`)
}
}
// === 3. addtrusted для krg ===
for (const trusted of TRUSTED_KRG) {
try {
const data: BranchContract.Actions.AddTrusted.IAddTrusted = {
coopname,
braname: 'krg',
trusted,
}
await blockchain.api.transact({
actions: [{
account: BranchContract.contractName.production,
name: BranchContract.Actions.AddTrusted.actionName,
authorization: [{ actor: coopname, permission: 'active' }],
data,
}],
}, { blocksBehind: 3, expireSeconds: 30 })
log(`addtrusted krg ← ${trusted} OK`)
} catch (e: any) {
log(`addtrusted ${trusted} failed: ${e.message ?? e}`)
}
}
await generator.close?.()
log('done')
}
main().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1) })
+1 -1
View File
@@ -159,7 +159,7 @@ module.exports = configure(function (ctx) {
eslint: {
lintCommand: 'eslint "./**/*.{js,ts,mjs,cjs,vue}"',
},
overlay: true,
overlay: false,
},
{ server: false },
],
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import type { QTableProps } from 'quasar';
import { onMounted, ref } from 'vue';
import { FailAlert } from 'src/shared/api';
import { listMyPayments, type MarketplaceOutgoingPaymentRequestView } from '../api';
@@ -6,6 +7,15 @@ import { listMyPayments, type MarketplaceOutgoingPaymentRequestView } from '../a
const items = ref<MarketplaceOutgoingPaymentRequestView[]>([]);
const loading = ref(false);
const columns: QTableProps['columns'] = [
{ name: 'created_at', label: 'Дата', field: 'created_at', align: 'left' },
{ name: 'amount', label: 'Сумма', field: 'amount', align: 'right' },
{ name: 'symbol', label: 'Валюта', field: 'symbol', align: 'center' },
{ name: 'status', label: 'Статус', field: 'status', align: 'left' },
{ name: 'payment_reference', label: 'Референс банка', field: 'payment_reference', align: 'left' },
{ name: 'purpose', label: 'Назначение', field: 'purpose', align: 'left' },
];
async function load(): Promise<void> {
loading.value = true;
try {
@@ -31,14 +41,7 @@ q-page.mp-role-offerer.mp-payment-history.q-pa-md
q-table(
:rows="items"
:columns="[
{ name: 'created_at', label: 'Дата', field: 'created_at', align: 'left' },
{ name: 'amount', label: 'Сумма', field: 'amount', align: 'right' },
{ name: 'symbol', label: 'Валюта', field: 'symbol', align: 'center' },
{ name: 'status', label: 'Статус', field: 'status', align: 'left' },
{ name: 'payment_reference', label: 'Референс банка', field: 'payment_reference', align: 'left' },
{ name: 'purpose', label: 'Назначение', field: 'purpose', align: 'left' },
]"
:columns="columns"
row-key="id"
flat
bordered
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import type { QTableProps } from 'quasar';
import { onMounted, ref } from 'vue';
import { FailAlert, NotifyAlert } from 'src/shared/api';
import {
@@ -9,6 +10,15 @@ import {
const items = ref<MarketplaceAplReceptionView[]>([]);
const loading = ref(false);
const columns: QTableProps['columns'] = [
{ name: 'id', label: 'АПП', field: (r: MarketplaceAplReceptionView) => r.id.slice(0, 8), align: 'left' },
{ name: 'braname', label: 'КУ', field: 'braname', align: 'left' },
{ name: 'variant', label: 'Вариант', field: 'variant', align: 'center' },
{ name: 'status', label: 'Статус', field: 'status', align: 'left' },
{ name: 'total_amount', label: 'Сумма', field: 'total_amount', align: 'right' },
{ name: 'actions', label: 'Действия', field: 'id', align: 'right' },
];
async function load(): Promise<void> {
loading.value = true;
try {
@@ -41,14 +51,7 @@ q-page.mp-role-offerer.mp-pending-apl.q-pa-md
q-table(
:rows="items"
:columns="[
{ name: 'id', label: 'АПП', field: (r: MarketplaceAplReceptionView) => r.id.slice(0, 8), align: 'left' },
{ name: 'braname', label: 'КУ', field: 'braname', align: 'left' },
{ name: 'variant', label: 'Вариант', field: 'variant', align: 'center' },
{ name: 'status', label: 'Статус', field: 'status', align: 'left' },
{ name: 'total_amount', label: 'Сумма', field: 'total_amount', align: 'right' },
{ name: 'actions', label: 'Действия', field: 'id', align: 'right' },
]"
:columns="columns"
row-key="id"
flat
bordered
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import type { QTableProps } from 'quasar';
import { onMounted, ref } from 'vue';
import { FailAlert } from 'src/shared/api';
import { listShipments, type MarketplaceShipmentView } from '../api';
@@ -14,6 +15,15 @@ import { listShipments, type MarketplaceShipmentView } from '../api';
const items = ref<MarketplaceShipmentView[]>([]);
const loading = ref(false);
const columns: QTableProps['columns'] = [
{ name: 'cycle_id', label: 'Цикл', field: 'cycle_id', align: 'left' },
{ name: 'braname', label: 'КУ', field: 'braname', align: 'left' },
{ name: 'delivery_variant', label: 'Вариант', field: 'delivery_variant', align: 'center' },
{ name: 'status', label: 'Статус', field: 'status', align: 'left' },
{ name: 'total_amount', label: 'Сумма', field: 'total_amount', align: 'right' },
{ name: 'ttn_number', label: 'ТТН', field: 'ttn_number', align: 'left' },
];
async function load(): Promise<void> {
loading.value = true;
try {
@@ -39,14 +49,7 @@ q-page.mp-role-offerer.mp-supply-preparation.q-pa-md
q-table(
:rows="items"
:columns="[
{ name: 'cycle_id', label: 'Цикл', field: 'cycle_id', align: 'left' },
{ name: 'braname', label: 'КУ', field: 'braname', align: 'left' },
{ name: 'delivery_variant', label: 'Вариант', field: 'delivery_variant', align: 'center' },
{ name: 'status', label: 'Статус', field: 'status', align: 'left' },
{ name: 'total_amount', label: 'Сумма', field: 'total_amount', align: 'right' },
{ name: 'ttn_number', label: 'ТТН', field: 'ttn_number', align: 'left' },
]"
:columns="columns"
row-key="id"
flat
bordered
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import type { QTableProps } from 'quasar';
import { onMounted, ref } from 'vue';
import { FailAlert } from 'src/shared/api';
import {
@@ -32,6 +33,15 @@ const openDialog = ref(false);
const finalizeDialog = ref(false);
const selectedOrder = ref<MarketplaceOrderIssuanceView | null>(null);
const columns: QTableProps['columns'] = [
{ name: 'order', label: 'Заказ', field: (r: MarketplaceOrderIssuanceView) => r.id.slice(0, 8), align: 'left' },
{ name: 'orderer', label: 'Заказчик', field: 'orderer_account', align: 'left' },
{ name: 'quantity', label: 'Количество', field: 'quantity', align: 'right' },
{ name: 'total_cost', label: 'Сумма', field: 'total_cost', align: 'right' },
{ name: 'status', label: 'Статус', field: 'status', align: 'left' },
{ name: 'actions', label: '', field: 'id', align: 'right' },
];
async function load(): Promise<void> {
if (!braname.value.trim()) return;
loading.value = true;
@@ -75,14 +85,7 @@ q-page.mp-role-operator.mp-issuance.q-pa-md
q-table(
:rows="items"
:columns="[
{ name: 'order', label: 'Заказ', field: (r: MarketplaceOrderIssuanceView) => r.id.slice(0, 8), align: 'left' },
{ name: 'orderer', label: 'Заказчик', field: 'orderer_account', align: 'left' },
{ name: 'quantity', label: 'Количество', field: 'quantity', align: 'right' },
{ name: 'total_cost', label: 'Сумма', field: 'total_cost', align: 'right' },
{ name: 'status', label: 'Статус', field: 'status', align: 'left' },
{ name: 'actions', label: '', field: 'id', align: 'right' },
]"
:columns="columns"
row-key="id"
flat
bordered
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import type { QTableProps } from 'quasar';
import { onMounted, ref } from 'vue';
import { Loading } from 'quasar';
import { SuccessAlert, FailAlert, NotifyAlert } from 'src/shared/api';
@@ -22,6 +23,14 @@ const loading = ref(false);
const shipmentIdInput = ref('');
const columns: QTableProps['columns'] = [
{ name: 'id', label: 'АПП', field: (r: MarketplaceAplReceptionView) => r.id.slice(0, 8), align: 'left' },
{ name: 'variant', label: 'Вариант', field: 'variant', align: 'center' },
{ name: 'status', label: 'Статус', field: 'status', align: 'left' },
{ name: 'total_amount', label: 'Сумма', field: 'total_amount', align: 'right' },
{ name: 'actions', label: '', field: 'id', align: 'right' },
];
async function load(): Promise<void> {
if (!braname.value.trim()) return;
loading.value = true;
@@ -76,13 +85,7 @@ q-page.mp-role-operator.mp-reception.q-pa-md
q-table(
:rows="items"
:columns="[
{ name: 'id', label: 'АПП', field: (r: MarketplaceAplReceptionView) => r.id.slice(0, 8), align: 'left' },
{ name: 'variant', label: 'Вариант', field: 'variant', align: 'center' },
{ name: 'status', label: 'Статус', field: 'status', align: 'left' },
{ name: 'total_amount', label: 'Сумма', field: 'total_amount', align: 'right' },
{ name: 'actions', label: '', field: 'id', align: 'right' },
]"
:columns="columns"
row-key="id"
flat
bordered
@@ -169,6 +169,11 @@ const confirmDisabled = computed(() => {
if (decision.value === DECISION_ACCEPT && !scannedCode.value.trim()) return true;
return false;
});
const decisionOptions = [
{ label: 'Принять возврат (compensating forward выполнится атомарно)', value: DECISION_ACCEPT, color: 'positive' },
{ label: 'Отказать на месте (имущество остаётся у заказчика)', value: DECISION_REJECT, color: 'negative' },
];
</script>
<template lang="pug">
@@ -235,10 +240,7 @@ TakeoverDialog(
.text-subtitle1.q-mb-sm 4. Решение
q-option-group(
v-model="decision"
:options="[
{ label: 'Принять возврат (compensating forward выполнится атомарно)', value: DECISION_ACCEPT, color: 'positive' },
{ label: 'Отказать на месте (имущество остаётся у заказчика)', value: DECISION_REJECT, color: 'negative' },
]"
:options="decisionOptions"
type="radio"
)
q-banner.q-mt-md(v-if="decision === DECISION_ACCEPT" rounded class="bg-positive text-white")
@@ -95,6 +95,11 @@ const kind = computed<'info' | 'warning'>(() =>
const confirmLabel = computed(() =>
decision.value === DECISION_APPROVE ? 'Пригласить на очный осмотр' : 'Отказать удалённо',
);
const decisionOptions = [
{ label: 'Пригласить заказчика на очный осмотр на КУ', value: DECISION_APPROVE, color: 'primary' },
{ label: 'Отказать удалённо (без очного визита)', value: DECISION_REJECT, color: 'warning' },
];
</script>
<template lang="pug">
@@ -134,10 +139,7 @@ TakeoverDialog(
.text-subtitle1.q-mb-sm Решение
q-option-group(
v-model="decision"
:options="[
{ label: 'Пригласить заказчика на очный осмотр на КУ', value: DECISION_APPROVE, color: 'primary' },
{ label: 'Отказать удалённо (без очного визита)', value: DECISION_REJECT, color: 'warning' },
]"
:options="decisionOptions"
type="radio"
inline
)
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import type { QTableProps } from 'quasar';
import { onMounted, ref } from 'vue';
import { FailAlert } from 'src/shared/api';
import { listMyReadyToReceive, type MarketplaceOrderIssuanceView } from '../api';
@@ -18,6 +19,14 @@ import { listMyReadyToReceive, type MarketplaceOrderIssuanceView } from '../api'
const items = ref<MarketplaceOrderIssuanceView[]>([]);
const loading = ref(false);
const columns: QTableProps['columns'] = [
{ name: 'order', label: 'Заказ', field: (r: MarketplaceOrderIssuanceView) => r.id.slice(0, 8), align: 'left' },
{ name: 'ku', label: 'Пункт выдачи', field: 'delivery_braname', align: 'left' },
{ name: 'quantity', label: 'Количество', field: 'quantity', align: 'right' },
{ name: 'total_cost', label: 'Сумма заказа', field: 'total_cost', align: 'right' },
{ name: 'opened_at', label: 'Открыто к выдаче', field: 'chairman_signed_at', align: 'left' },
];
async function load(): Promise<void> {
loading.value = true;
try {
@@ -40,13 +49,7 @@ q-page.mp-role-orderer.mp-issuance-ready.q-pa-md
q-table(
:rows="items"
:columns="[
{ name: 'order', label: 'Заказ', field: (r: MarketplaceOrderIssuanceView) => r.id.slice(0, 8), align: 'left' },
{ name: 'ku', label: 'Пункт выдачи', field: 'delivery_braname', align: 'left' },
{ name: 'quantity', label: 'Количество', field: 'quantity', align: 'right' },
{ name: 'total_cost', label: 'Сумма заказа', field: 'total_cost', align: 'right' },
{ name: 'opened_at', label: 'Открыто к выдаче', field: 'chairman_signed_at', align: 'left' },
]"
:columns="columns"
row-key="id"
flat
bordered
+15
View File
@@ -40,6 +40,21 @@ const KNOWN_FIXTURES = {
// 05-additional-contributors). Используется в сценарии adaptation для
// снимков мастера выбора ролей / часов / ставки / документов УХД.
newadapter: { email: 'andrey.sidorov@example.com', firstName: 'Андрей', lastName: 'Сидоров', middleName: 'Михайлович' },
// === Marketplace MVP «Стол заказов» ===
// Председатели КУ — обычные пайщики, на которых председатель кооператива
// повесил branch.trustee через branch::createbranch.
chairkrg: { email: 'chairkrg@voskhod.coop', firstName: 'Пётр', lastName: 'Иванов', middleName: 'Сергеевич' },
chairodn: { email: 'chairodn@voskhod.coop', firstName: 'Сергей', lastName: 'Орлов', middleName: 'Васильевич' },
chairmyt: { email: 'chairmyt@voskhod.coop', firstName: 'Алексей', lastName: 'Мытищенко', middleName: 'Григорьевич' },
// Доверенное лицо КУ Красногорск (branch.trusted[]).
trustedkrg: { email: 'trustedkrg@voskhod.coop', firstName: 'Михаил', lastName: 'Петров', middleName: 'Андреевич' },
// Оператор КУ Красногорск (marketplace-роль operator).
opkrg: { email: 'opkrg@voskhod.coop', firstName: 'Александр', lastName: 'Кузнецов', middleName: 'Владимирович' },
// Поставщик (marketplace-роль offerer): публикует Предложения.
sidorov: { email: 'sidorov@voskhod.coop', firstName: 'Дмитрий', lastName: 'Сидоров', middleName: 'Николаевич' },
// Заказчица (marketplace-роль orderer): оформляет Заказы.
petrova: { email: 'petrova@voskhod.coop', firstName: 'Екатерина', lastName: 'Петрова', middleName: 'Александровна' },
};
const log = (m) => console.error(`${m}`);
+33 -2
View File
@@ -60,13 +60,22 @@ export async function openBrowser({ storageState } = {}) {
}
// Логин председателя через форму. Кэширует storageState.
// Учитывает что vue-router в текущем десктопе работает в hash-режиме
// (URL вида http://host/#/voskhod/...), а не history.
export async function loginAsChairman(page, context) {
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/auth/signin`, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForSelector('button:has-text("Войти")', { timeout: 60000 });
await cleanViteOverlays(page);
await page.locator('label:has-text("электронную почту")').locator('input').fill(env.CHAIRMAN_EMAIL);
await page.locator('label:has-text("ключ доступа")').locator('input').fill(env.CHAIRMAN_WIF);
await cleanViteOverlays(page);
await page.locator('button:has-text("Войти")').click();
await page.waitForURL(/\/(chairman|participant|soviet)/, { timeout: 30000 });
// Ждём пока URL уйдёт от signin (либо в chairman/user/soviet/participant).
// Используем waitForFunction вместо waitForURL — он более forgiving по hash.
await page.waitForFunction(
() => !/auth\/signin/.test(window.location.href),
{ timeout: 30000 },
).catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
}
@@ -75,10 +84,15 @@ export async function loginAsChairman(page, context) {
export async function loginAs(page, fixture) {
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/auth/signin`, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForSelector('button:has-text("Войти")', { timeout: 60000 });
await cleanViteOverlays(page);
await page.locator('label:has-text("электронную почту")').locator('input').fill(fixture.email);
await page.locator('label:has-text("ключ доступа")').locator('input').fill(fixture.wif);
await cleanViteOverlays(page);
await page.locator('button:has-text("Войти")').click();
await page.waitForURL(/\/(chairman|participant|soviet|user)/, { timeout: 30000 });
await page.waitForFunction(
() => !/auth\/signin/.test(window.location.href),
{ timeout: 30000 },
).catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
}
@@ -240,6 +254,21 @@ export async function signOnboardingAgreements(page, opts = {}) {
return { signed, attempts };
}
// Снимает overlay'и vite-plugin-checker (vue-tsc/eslint), vite HMR error
// overlay и custom-element <vite-error-overlay>, чтобы они не попадали на
// скриншоты при срабатывании HMR во время сценария. Безопасна: если оверлеев
// нет — ничего не делает.
export async function cleanViteOverlays(page) {
await page.evaluate(() => {
document.querySelectorAll('vite-error-overlay').forEach((el) => el.remove());
document.querySelectorAll('vite-plugin-checker-error-overlay').forEach((el) => el.remove());
// q-notification toast'ы — это user-feedback, но в момент скриншота
// мешают «чистому» кадру; снимаем.
document.querySelectorAll('.q-notification').forEach((el) => el.remove());
document.querySelectorAll('.q-notifications__list > *').forEach((el) => el.remove());
});
}
// Создаёт shot-функцию + manifest для сценария.
export function makeShotContext({ scenarioName, outDir }) {
const shots = [];
@@ -251,6 +280,7 @@ export function makeShotContext({ scenarioName, outDir }) {
if (opts.expect) await opts.expect(page);
await page.waitForTimeout(opts.delay ?? 300);
await cleanViteOverlays(page);
await page.screenshot({ path: filePath, fullPage: opts.fullPage ?? false });
const entry = {
name,
@@ -291,6 +321,7 @@ export function makeShotContext({ scenarioName, outDir }) {
const filePath = path.join(outDir, `${name}.png`);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await cleanViteOverlays(page);
try {
if (opts.padding) {
const box = await locator.boundingBox();
@@ -0,0 +1,296 @@
# План E2E-документации Marketplace MVP «Стол заказов»
Последнее обновление: 2026-05-20 (план, до старта).
Цель: ночной прогон harness + проза в `components/docs/docs/new/marketplace/...` + добавление новых разделов в `mkdocs.yml`. Параллельно — UI-багфиксы по факту.
## 0. Терминология (зафиксировать в UI/доках)
- **Предложение** — то, что публикует поставщик-пайщик (английский id остаётся `offer`, в контракте/коде/SDK — `offer`). В русских надписях, заголовках страниц, текстах кнопок — **только «Предложение»**, не «оферта», не «оферт».
- **Заказ** — то, что оформляет пайщик-заказчик (id `order`).
- **АПП** — Акт Приёма-Передачи (приёмка и выдача).
- **КУ / ПВЗ** — кооперативный участок / пункт выдачи заказов. Это одно и то же, в UI — «Кооперативный участок», в коротких фразах — «КУ».
- **Председатель кооператива** ≠ **председатель КУ**. Не смешивать в скриншотах и подписях.
Если в коде попадается русское слово «оферта» в `description` поля GraphQL, в label кнопки, в заголовке `.vue` — менять на «Предложение» по ходу прогона.
## 1. Кооператив и фикстуры
Базовая среда:
- 1 кооператив — **Восход** (`voskhod`), уже создан `boot:extra`.
- Совет уже сидит из `boot:extra`: `ant/petr/anna/mikhail/olga`. Председатель кооператива — `ant` (Иванов И. И.).
Дополнительные фикстуры — заводятся в **`installExtraData`** (`components/boot/src/init/infra.ts:574`):
| Фикстура | username | ФИО | Роль | Email |
|---|---|---|---|---|
| Председатель КУ Красногорск | `chairkrg` | Иванов Пётр Сергеевич | trustee `branch=krg` | chairkrg@voskhod.coop |
| Доверенное лицо КУ Красногорск | `trustedkrg` | Петров Михаил Андреевич | trusted КУ | trustedkrg@voskhod.coop |
| Оператор КУ Красногорск | `opkrg` | Кузнецов Александр Владимирович | operator | opkrg@voskhod.coop |
| Поставщик | `sidorov` | Сидоров Дмитрий Николаевич | offerer | sidorov@voskhod.coop |
| Заказчица | `petrova` | Петрова Екатерина Александровна | orderer | petrova@voskhod.coop |
| Член совета (для голосования) | переиспользуем `anna` (уже из совета) | — | board_readonly | — |
И **«для вида»** — дополнительные КУ, без полного штата (чисто для визуальной полноты карты ПВЗ и списков):
| Брана | Город | Председатель |
|---|---|---|
| `krg` | Красногорск | `chairkrg` (полный) |
| `odn` | Одинцово | `chairodn` (минимальный профиль) |
| `myt` | Мытищи | `chairmyt` (минимальный профиль) |
Эти три КУ Подмосковья закладываются скриптом в `installExtraData`, чтобы при `pnpm run reboot:extra` сразу была карта ПВЗ из 3 точек.
### Как заводить КУ корректно (нужно подсмотреть через UI)
Перед прописыванием в `boot` — один ручной прогон через **Стол председателя → Сеть ПВЗ → Создать КУ** (с интерактивным заполнением: имя, координаты, режим работы, председатель, trustees). Снять с этого прогона:
1. Какие именно GraphQL-mutations вызывает desktop (`Network` Playwright + `tea api graphql -F query=...` сохранением).
2. Какие записи появляются в `branch_*` таблицах PG и в on-chain `marketplace.branches`.
3. Какой actor подписывает on-chain действие (chairman кооператива? от его имени?).
После этого — повторить вызовы в `installExtraData` через `cooptypes`-actions, чтобы получить идентичное состояние без UI.
### Подписания трёхуровневого онбординга ЦПП
В `installExtraData` сразу подписать:
- **L1** (один раз для кооператива) — председатель + совет принимают ЦПП Marketplace, регистрируется оферта `coop_registration_offers_registry`.
- **L2** для `sidorov`, `petrova`, `chairkrg`, `trustedkrg`, `opkrg` — выбор ЦПП Marketplace при «вступлении» (фиктивно — просто запись в `agreements`).
Это снимает L3-gate на первом входе всех ролевых сценариев. Если фикстуры нет, каждый login превратится в скриншот с консент-диалогом — для большинства сценариев это шум.
Для **отдельного сценария L3-gate** (`scenarios/marketplace/onboarding/extension-gate.mjs`) — создаём «свежего» пайщика без подписи на ЦПП, чтобы поймать гейт.
## 2. Структура документации (mkdocs nav)
Новый кустарник в `mkdocs.yml` после раздела «Стол вычислительных ресурсов», перед «Общие собрания»:
```yaml
- Стол заказов:
- Введение: new/marketplace/index.md
- Онбординг ЦПП:
- L1 — Подключение ЦПП кооперативом: new/marketplace/onboarding/coop-accept-cpp.md
- L2 — Выбор ЦПП пайщиком: new/marketplace/onboarding/member-pick-cpp.md
- L3 — Гейт первого входа: new/marketplace/onboarding/extension-gate.md
- Стол заказчика:
- Каталог витрины: new/marketplace/orderer/catalog.md
- Оформление заказа: new/marketplace/orderer/order-create.md
- Мои заказы: new/marketplace/orderer/orders.md
- Сводный заказ: new/marketplace/orderer/consolidated.md
- Получение заказа: new/marketplace/orderer/receive.md
- Возврат имущества: new/marketplace/orderer/return-claim.md
- Стол поставщика:
- Создание предложения: new/marketplace/offerer/offer-create.md
- Мои предложения: new/marketplace/offerer/offers.md
- Входящие заказы: new/marketplace/offerer/incoming-orders.md
- Подготовка отгрузки: new/marketplace/offerer/shipment-prep.md
- Подпись акта приёмки: new/marketplace/offerer/apl-reception-sign.md
- Стол оператора КУ:
- Ожидаемые поставки: new/marketplace/operator/incoming-shipments.md
- Приёмка партии: new/marketplace/operator/apl-reception-create.md
- Маркировка имущества: new/marketplace/operator/inventory-label.md
- Склад участка: new/marketplace/operator/inventory-list.md
- Поток выдач: new/marketplace/operator/orders-board.md
- Стол председателя КУ:
- Закрывающая подпись приёмки: new/marketplace/branch-chairman/apl-reception-close.md
- Открытие выдачи: new/marketplace/branch-chairman/issuance-open.md
- Заказы участка: new/marketplace/branch-chairman/branch-orders.md
- Обработка возвратов: new/marketplace/branch-chairman/return-approve.md
- Стол председателя кооператива:
- Модерация предложений: new/marketplace/chairman/offer-moderation.md
- Категории ЦПП: new/marketplace/chairman/category-whitelist.md
- Сеть ПВЗ: new/marketplace/chairman/branches.md
- Списания имущества: new/marketplace/chairman/writeoff-propose.md
- Стол совета (Marketplace):
- Голосование по списанию: new/marketplace/board/agenda-writeoff.md
- Склад кооператива: new/marketplace/board/warehouse-readonly.md
- Выплаты поставщикам: new/marketplace/board/payouts-readonly.md
```
Итого: **30 страниц = 30 сценариев `.mjs`**.
## 3. Сценарии `docs-harness`
```
components/docs-harness/scenarios/marketplace/
├── PLAN.md (этот файл)
├── onboarding/
│ ├── coop-accept-cpp.mjs L1: chairman + соглашение совета
│ ├── member-pick-cpp.mjs L2: пайщик при вступлении
│ └── extension-gate.mjs L3: свежий пайщик, первый визит на стол
├── orderer/ (фикстура petrova)
│ ├── catalog.mjs
│ ├── order-create.mjs
│ ├── orders.mjs
│ ├── consolidated.mjs для volume_based циклов
│ ├── receive.mjs финальная подпись акта выдачи
│ └── return-claim.mjs
├── offerer/ (фикстура sidorov)
│ ├── offer-create.mjs
│ ├── offers.mjs (Мои предложения + статус модерации)
│ ├── incoming-orders.mjs
│ ├── shipment-prep.mjs
│ └── apl-reception-sign.mjs первая подпись на акте приёмки
├── operator/ (фикстура opkrg)
│ ├── incoming-shipments.mjs
│ ├── apl-reception-create.mjs
│ ├── inventory-label.mjs single + shipment (EAN-13)
│ ├── inventory-list.mjs
│ └── orders-board.mjs
├── branch-chairman/ (фикстура chairkrg)
│ ├── apl-reception-close.mjs
│ ├── issuance-open.mjs
│ ├── branch-orders.mjs
│ └── return-approve.mjs
├── chairman/ (фикстура ant)
│ ├── offer-moderation.mjs
│ ├── category-whitelist.mjs
│ ├── branches.mjs создание КУ + trustees + геокарта
│ └── writeoff-propose.mjs
└── board/ (фикстура anna)
├── agenda-writeoff.mjs
├── warehouse-readonly.mjs
└── payouts-readonly.mjs
```
Шаблон сценария — см. `components/docs-harness/scenarios/blagorost/*.mjs`. Обязательные поля meta:
```js
export const meta = {
title: '<Человеческий заголовок страницы>',
docPath: 'new/marketplace/<стол>/<имя>.md',
assetsDir: 'assets/new/marketplace/<стол>/<имя>',
role: 'orderer' | 'offerer' | 'operator' | 'branch-chairman' | 'chairman' | 'board',
};
```
## 4. Магистральные E2E-потоки (ночной прогон)
Каждый поток — последовательность сценариев, **прогоняемая в одной сессии** с общими доменными сущностями (одно Предложение → один Заказ → одна Партия → один Акт → один Возврат).
### Поток I — «Витрина»: каталог рождается
1. `chairman/branches.mjs` — председатель кооператива создаёт КУ Красногорск + назначает chairkrg + trustedkrg
2. `chairman/category-whitelist.mjs` — включает категории «овощи/фрукты» и «бакалея»
3. `offerer/offer-create.mjs` — Сидоров публикует Предложение «Картофель Адретта, мешок 25 кг», `cycle_type=volume_based`
4. `chairman/offer-moderation.mjs` — председатель одобряет
5. `orderer/catalog.mjs` — Петрова видит Предложение в каталоге
### Поток II — «Закупочный цикл»: заказ → приёмка → выдача (главный сценарий MVP)
1. `orderer/order-create.mjs` — Петрова заказывает 2 мешка → подписывает Membership
2. `orderer/consolidated.mjs` — сводный заказ формируется на достижении объёма
3. `offerer/incoming-orders.mjs` — Сидоров принимает партию
4. `offerer/shipment-prep.mjs` — Сидоров подтверждает готовность к отгрузке
5. `operator/apl-reception-create.mjs` — opkrg открывает акт приёмки на КУ
6. `offerer/apl-reception-sign.mjs` — Сидоров ставит первую подпись акта
7. `branch-chairman/apl-reception-close.mjs` — chairkrg закрывает акт → имущество на балансе
8. `operator/inventory-label.mjs` — opkrg маркирует партию (EAN-13)
9. `branch-chairman/issuance-open.mjs` — chairkrg открывает выдачу заказа Петровой
10. `orderer/receive.mjs` — Петрова получает 2 мешка, ставит финальную подпись (факт=2)
### Поток III — «Возврат и списание»
1. `orderer/return-claim.mjs` — Петрова подаёт возвратную заявку на 1 мешок (брак)
2. `branch-chairman/return-approve.mjs` — chairkrg одобряет → возврат на склад КУ
3. `chairman/writeoff-propose.mjs` — председатель кооператива выносит списание на совет
4. `board/agenda-writeoff.mjs` — Анна (член совета) голосует «за»
5. `chairman/writeoff-propose.mjs` (повторный кадр «решение принято») — операция `o.mkt.wroff` исполнена
6. `board/warehouse-readonly.mjs` — на складе остаётся 1 мешок
### Поток IV — «День оператора ПВЗ»
1. `operator/incoming-shipments.mjs` — opkrg видит ожидаемые поставки от Сидорова
2. `operator/apl-reception-create.mjs` — открывает приёмку (тот же акт, что в потоке II, но другая точка обзора)
3. `operator/inventory-label.mjs` — маркирует имущество
4. `operator/inventory-list.mjs` — открывает реестр склада участка
5. `operator/orders-board.mjs` — смотрит поток заказов, готовых к выдаче
Поток IV переиспользует сущности потока II с другой ролью — это позволяет показать всю «кухню» оператора без необходимости запускать дополнительные транзакции.
## 5. Конвенции скриншотов
- viewport `1120×800 × dpr 1.25 = 1400×1000 PNG` (зашиты в `lib/harness.mjs`).
- открытая левая навигация в большинстве кадров — чтобы виден контекст стола.
- `dismissOnboardingDialogs(page)` в `beforeEach` каждой роли (кроме `extension-gate.mjs`).
- никаких toast'ов в момент `shot()` — ждать `page.locator('.q-notification').count() === 0` перед снимком.
- даты в фикстурах — диапазон 2026-05-15 … 2026-05-20 (создание Предложения, заказа, отгрузки, приёмки, выдачи). Не «вчера»/«сегодня» — фиксированные ISO.
- русские названия категорий, КУ, Предложений (никаких `test1`, `aaa`).
- 5–8 кадров на страницу: исходный экран → действие → результат, плюс контекстные подсказки.
## 6. Этапы работы (порядок исполнения, по «погнал»)
### Фаза 0 — Подготовка стенда и фикстур (≈ 60–90 мин)
1. Worktree от `marketplace2` на ветке `feat/marketplace-docs`.
2. Сделать `pnpm run reboot:extra`, убедиться, что стек поднимается.
3. Через UI председателя кооператива создать **один** КУ Красногорск (записать все mutations + актеров). Зафиксировать факт в этом PLAN.md (приложение «Снятые факты»).
4. Перенести логику создания КУ + назначения председателя + trustees в `installExtraData` (`components/boot/src/init/infra.ts`).
5. Добавить заведение пайщиков `chairkrg`, `trustedkrg`, `opkrg`, `sidorov`, `petrova`, `chairodn`, `chairmyt` в `installExtraData`.
6. Подписать им L1/L2 onboarding в `installExtraData` (через cooptypes-actions `sndagreement`).
7. Прогон `pnpm run reboot:extra` повторно — стек поднимается со всеми фикстурами.
8. `KNOWN_FIXTURES` в `components/docs-harness/bin/shoot.mjs` пополнить новыми пайщиками.
### Фаза 1 — Сценарии (≈ 3 часа)
По одному сценарию за раз, в порядке потоков I → II → III → IV. После каждого:
- `node bin/shoot.mjs marketplace/<scen>` — проверить, что снято.
- Если упало — поймать FAIL.png, пофиксить либо сценарий, либо UI (правки в worktree, hot-reload подхватит).
- На каждом потоке делать отдельный коммит в `feat/marketplace-docs`.
### Фаза 2 — Проза (≈ 2 часа)
По одному `draft.md` за раз:
- Прочитать manifest + все PNG.
- Написать прозу бизнес-языком (без «mutation», «resolver», «callback»).
- Прогнать `node lib/install.mjs marketplace/<scen> --md`.
### Фаза 3 — Сборка документации
1. Обновить `mkdocs.yml` по структуре из секции 2.
2. `pnpm --filter @coopenomics/docs build` (или `mkdocs build` локально) — проверить, что нет broken links.
3. Локальный `mkdocs serve` — пройти по всем 30 страницам глазами.
4. Финальный коммит, push, PR `feat/marketplace-docs``marketplace2`.
## 7. Что НЕ делать ночью (вне scope)
- Не трогать существующие сценарии `blagorost/`, `onboarding/`, `auth/`, `registration/`.
- Не править `mkdocs.yml` за пределами вставки нового кустарника.
- Не запускать полный jest (по запрету в CLAUDE.md). Точечный — только если конкретный сервис подозрительно сломался.
- Не вызывать `pnpm generate-schema` / `generate-client` — controller на marketplace2 уже актуален, SDK собран.
- Не делать `tea pr merge` — merge делает пользователь руками.
- Не править `_blago/` — это документ другой системы.
## 8. Снятые факты (заполняется ночью)
(Пусто на момент старта плана. Будет дополнено по результатам ручных прогонов и снятых GraphQL-вызовов.)
---
# Снятые факты Phase 0 (2026-05-20)
## 8.1. Создание КУ — core, не marketplace
Branch — это базовая фича платформы (components/controller/src/application/branch/), не marketplace-extension. Marketplace только потребляет существующие Branch.
- Vue API: `components/desktop/src/features/Branch/CreateBranch/api/index.ts``Mutations.Branches.CreateBranch.mutation` из @coopenomics/sdk.
- Resolver: `components/controller/src/application/branch/resolvers/branch.resolver.ts:34`@Mutation 'createBranch', @AuthRoles(['chairman']).
- DTO CreateBranchGraphQLInput: `{coopname, braname, trustee, short_name, full_name, based_on, fact_address, phone, email}` — всё обязательно. based_on = текст-основание («решение совета №СС-… от …»).
- Blockchain action: `BranchContract.Actions.CreateBranch` (из cooptypes), payload `{coopname, braname, trustee}` (только три поля on-chain — остальные хранятся в Postgres organizations). Actor = coopname active permission, WIF из vault.
Стратегия для installExtraData: прямой вызов BranchContract.Actions.CreateBranch через `Blockchain` class (как boot уже делает в installInitialData), с подписью cooperative-WIF. Затем organizationRepository.create() через прямой Mongo insert (или skip — controller на следующем чтении сам подтянет).
## 8.2. Trusted accounts (доверенные лица КУ)
- Resolver: addTrustedAccount mutation, @AuthRoles(['chairman']).
- Action: `BranchContract.Actions.AddTrusted`, payload `{coopname, braname, trusted}`. Actor=coopname.
- В фикстуре — после createBranch вызвать addTrusted для каждого trustee.
## 8.3. Десктоп-страница «Реестр КУ»
- Path component: `components/desktop/src/pages/Cooperative/MemberBranchList/ui/MemberBranchListPage.vue`.
- Назначение: общий список КУ для chairman кооператива. Кнопка «Создать кооперативный участок» открывает features/Branch/CreateBranch диалог.
- Marketplace-страница `components/desktop/src/pages/Marketplace/PvzList` — это уже для marketplace стола (карта/список ПВЗ). Используется для отображения, не для создания.
## 8.4. БЛОКЕР ночного прогона: desktop dev EACCES
После docker bind-mount pnpm установил `vite-plugin-checker@0.11.0` под root внутри контейнера, parent-dir `node_modules/.pnpm/vite-plugin-checker@0.11.0_*/.../vueTsc/typescript-vue-tsc/` остался root:root. На запуске `quasar dev` (любой mode) vite-plugin-checker пытается unlink LICENSE.txt внутри этой папки → EACCES.
Фикс (требует ручного sudo от пользователя):
```
sudo chown -R admin:admin /home/admin/mono-ai-4/node_modules/.pnpm/vite-plugin-checker@*
```
Без этого ночные сценарии harness'а запустить нельзя — Playwright ходит на :2999. Auto-mode classifier отклонил автоматический sudo chown (system-wide).
@@ -0,0 +1,118 @@
// Сценарий: Стол председателя кооператива → Сеть ПВЗ.
// Председатель Восхода создаёт три кооперативных участка (КУ) Московской области:
// krg — Красногорск, председатель chairkrg
// odn — Одинцово, председатель chairodn
// myt — Мытищи, председатель chairmyt
// А также добавляет доверенное лицо trustedkrg к участку Красногорск.
//
// Фикстура — chairman кооператива `ant` (Иван Иванов), email ivanov@example.com,
// shared cooperative-WIF — берётся из env (CHAIRMAN_WIF).
// Председатели КУ (chairkrg/chairodn/chairmyt) и trustedkrg должны существовать
// заранее как пайщики; их создаёт скрипт seed-marketplace-fixtures.sh.
import { loginAsChairman, dismissOnboardingDialogs } from '../../../lib/harness.mjs';
export const meta = {
title: 'Сеть ПВЗ: создание кооперативных участков',
docPath: 'new/marketplace/chairman/branches.md',
assetsDir: 'assets/new/marketplace/chairman/branches',
role: 'chairman',
};
const BRANCHES = [
{
braname: 'krg',
trusteeUsername: 'chairkrg',
short_name: 'КУ Красногорск',
phone: '9991230101',
address: 'Московская область, г. Красногорск, ул. Заводская, д. 1',
email: 'krg@voskhod.coop',
based_on: 'решение собрания совета №СС-1 от 20 мая 2026 г',
},
{
braname: 'odn',
trusteeUsername: 'chairodn',
short_name: 'КУ Одинцово',
phone: '9991230202',
address: 'Московская область, г. Одинцово, ул. Центральная, д. 12',
email: 'odn@voskhod.coop',
based_on: 'решение собрания совета №СС-1 от 20 мая 2026 г',
},
{
braname: 'myt',
trusteeUsername: 'chairmyt',
short_name: 'КУ Мытищи',
phone: '9991230303',
address: 'Московская область, г. Мытищи, Олимпийский проспект, д. 5',
email: 'myt@voskhod.coop',
based_on: 'решение собрания совета №СС-1 от 20 мая 2026 г',
},
];
export default async ({ page, context, shot, expect, env }) => {
await loginAsChairman(page, context);
await dismissOnboardingDialogs(page);
// 1. Открыть страницу «Кооперативные участки»
// Vue-router в SPA-mode у текущего стенда — hash-режим, ходим через /#/
await page.goto(`${env.BASE_URL}/#/${env.COOPNAME}/chairman/settings/branches`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('text=Кооперативные участки', { timeout: 60000 });
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(800);
await dismissOnboardingDialogs(page);
await shot(page, '01-empty-list', 'Пустой реестр кооперативных участков: председатель видит таблицу без записей и кнопку «добавить участок» в шапке.');
for (let i = 0; i < BRANCHES.length; i++) {
const b = BRANCHES[i];
// 2. Открыть диалог «Создать кооперативный участок»
await page.locator('button:has-text("добавить участок")').click();
await page.waitForSelector('text=Создать кооперативный участок', { timeout: 15000 });
await page.waitForTimeout(400);
if (i === 0) {
await shot(page, '02-dialog-empty', 'Диалог «Создать кооперативный участок»: пустые поля «Председатель участка», «Наименование», «Телефон», «Адрес», «Email» и «Председатель действует на основании».');
}
// 3. Заполнить «Председатель участка» через UserSearchSelector (autocomplete)
const trusteeField = page.locator('label:has-text("Председатель участка")').locator('input').first();
await trusteeField.click();
await trusteeField.fill(b.trusteeUsername);
// ожидаем появления q-menu c результатами
await page.locator(`.q-menu .q-item:has-text("${b.trusteeUsername}")`).first().waitFor({ state: 'visible', timeout: 10000 });
await page.locator(`.q-menu .q-item:has-text("${b.trusteeUsername}")`).first().click();
// 4. Остальные поля
await page.locator('label:has-text("Наименование участка")').locator('input').fill(b.short_name);
await page.locator('label:has-text("Номер телефона участка")').locator('input').fill(b.phone);
await page.locator('label:has-text("Фактический адрес участка")').locator('input').fill(b.address);
await page.locator('label:has-text("Email-адрес участка")').locator('input').fill(b.email);
await page.locator('label:has-text("Председатель действует на основании")').locator('input').fill(b.based_on);
await page.waitForTimeout(400);
if (i === 0) {
await shot(page, '03-dialog-filled', `Диалог заполнен для первого участка («${b.short_name}»): выбран председатель ${b.trusteeUsername}, заданы адрес, контакты и документ-основание.`);
}
// 5. Submit
await page.locator('button:has-text("Создать")').click();
// 6. Дождаться закрытия диалога и появления записи в таблице
await page.locator('text=Создать кооперативный участок').waitFor({ state: 'hidden', timeout: 30000 }).catch(() => {});
await page.waitForSelector(`text=${b.short_name}`, { timeout: 30000 });
await page.waitForTimeout(800);
if (i === 0) {
await shot(page, '04-first-branch-created', `Реестр после создания первого участка: в таблице видна строка «${b.short_name}» с председателем.`);
}
}
// 7. Финальный кадр — таблица с тремя участками
await page.waitForTimeout(1000);
await dismissOnboardingDialogs(page);
await shot(page, '05-three-branches', 'Реестр кооперативных участков с тремя участками Подмосковья: Красногорск, Одинцово, Мытищи. Каждый со своим председателем.');
// 8. Развернуть КУ Красногорск (toggle + → детали + кнопка-удалить)
await page.locator('tr:has-text("КУ Красногорск") button[aria-label="add"], tr:has-text("КУ Красногорск") .q-btn').first().click().catch(() => {});
await page.waitForTimeout(800);
await shot(page, '06-branch-details', 'Развёрнутая карточка КУ Красногорск: реквизиты участка, банковский счёт, карточка председателя; кнопка «удалить» доступна.');
};