[153][@dark.sun] WIP: marketplace docs e2e magistral II — before dev merge

Накопленная работа по реализации e2e сценариев Стола заказов:
backend (MarketplaceKuChairmenService + spec, KU-chairman guard,
notification broadcast), frontend (market-session-store FSD-extension,
beforeEnter роутер-гарды, 11 новых Vue-страниц marketplace), SDK
wrappers (whoAmI, listPendingOffers/myOffers/supplierOrders, moderation
log, CPP queries), harness-сценарии и MD-черновики для magistral II
шагов 1-4.

Перед merge dev (101 коммит с perf-фиксами controller/desktop/mongo)
для дальнейшего прогона magistral II шага 5+.
This commit is contained in:
ant
2026-05-23 05:54:19 +00:00
parent 505a33f1db
commit 172da20004
120 changed files with 7067 additions and 1193 deletions
@@ -0,0 +1,11 @@
{
"protocol_feature_type": "builtin",
"dependencies": [],
"description_digest": "2ad6b7aabba8edbc87b46f3340df4d0db0e4b65474db3e1cd5db33fb685a506b",
"subjective_restrictions": {
"earliest_allowed_activation_time": "1970-01-01T00:00:00.000",
"preactivation_required": true,
"enabled": true
},
"builtin_feature_codename": "ASSERT_RECOVER_KEY_ACCOUNT"
}
@@ -0,0 +1,88 @@
/**
* Idempotent сидинг базовых сущностей кооператива в Mongo:
* - vars(coopname=voskhod) — описание кооператива (отображается в UI хедер/футер)
* - monos(coopname=voskhod, status=active) — маркер, что инсталляция активна
* - vaults(username=voskhod, permission=active, wif=<encrypted from infra.ts>)
*
* Эти данные обычно создаются `pnpm boot:extra`, но если Mongo volume сбросился
* (а chain нет), их надо восстановить отдельно — этот скрипт делает только эту часть.
*
* Запуск:
* MONGO_URI=mongodb://127.0.0.1:27047/cooperative-x \
* pnpm --filter @coopenomics/boot exec esno src/scripts/seed-cooperative-base.ts
*/
import mongoose from 'mongoose'
async function main() {
const mongoUri = process.env.MONGO_URI
if (!mongoUri) throw new Error('MONGO_URI not set')
await mongoose.connect(mongoUri)
const vars = {
coopname: 'voskhod',
full_abbr: 'Потребительский Кооператив',
full_abbr_genitive: 'Потребительского Кооператива',
full_abbr_dative: 'Потребительскому Кооперативу',
short_abbr: 'ПК',
website: 'цифровой-кооператив.рф',
name: 'Восход',
confidential_link: 'coopenomics.world/privacy',
confidential_email: 'privacy@coopenomics.world',
contact_email: 'contact@coopenomics.world',
passport_request: 'no',
wallet_agreement: { protocol_number: '10-04-2024', protocol_day_month_year: '10 апреля 2024 г.' },
signature_agreement: { protocol_number: '10-04-2024', protocol_day_month_year: '10 апреля 2024 г.' },
privacy_agreement: { protocol_number: '10-04-2024', protocol_day_month_year: '10 апреля 2024 г.' },
user_agreement: { protocol_number: '10-04-2024', protocol_day_month_year: '10 апреля 2024 г.' },
participant_application: { protocol_number: '10-04-2024', protocol_day_month_year: '10 апреля 2024 г.' },
generator_program: { protocol_number: '1', protocol_day_month_year: '09.02.2026 10:24' },
generation_contract_template: { protocol_number: '2', protocol_day_month_year: '09.02.2026 10:24' },
blagorost_program: { protocol_number: '3', protocol_day_month_year: '09.02.2026 10:24' },
generator_offer_template: { protocol_number: '4', protocol_day_month_year: '09.02.2026 10:27' },
blagorost_offer_template: { protocol_number: '5', protocol_day_month_year: '09.02.2026 10:27' },
deleted: false,
block_num: 1,
}
const varsCol = mongoose.connection.collection('vars')
const existing = await varsCol.findOne({ coopname: 'voskhod' })
if (existing) {
await varsCol.updateOne({ coopname: 'voskhod' }, { $set: vars })
console.log('vars upserted (was present)')
}
else {
await varsCol.insertOne({ ...vars, _created_at: new Date() })
console.log('vars inserted')
}
const monosCol = mongoose.connection.collection('monos')
const mExisting = await monosCol.findOne({ coopname: 'voskhod' })
if (mExisting) {
await monosCol.updateOne({ coopname: 'voskhod' }, { $set: { status: 'active' } })
console.log('monos upserted (was present)')
}
else {
await monosCol.insertOne({ coopname: 'voskhod', status: 'active' })
console.log('monos inserted')
}
const vaultsCol = mongoose.connection.collection('vaults')
const vExisting = await vaultsCol.findOne({ username: 'voskhod', permission: 'active' })
if (vExisting) {
console.log('vaults already present (kept as is)')
}
else {
await vaultsCol.insertOne({
username: 'voskhod',
permission: 'active',
wif: '9d6479a9d77ead53fb0e5e54b3608a95:2046ee3c1577d48aecbee49e8f25c4c2df37ab02f15d73d0d1b6352f53a4b774cb9e71b6028fd7caf64568e195c7878dfbb5d2bf10a3766d90ba9e92ea724428',
})
console.log('vaults inserted')
}
await mongoose.disconnect()
}
main()
.then(() => process.exit(0))
.catch((e) => { console.error(e); process.exit(1) })
@@ -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) })
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,10 @@ import { MonoAccountStatusDomainInterface } from '~/domain/account/interfaces/mo
import type { IMarketplaceCurrentMember } from '../dto/marketplace-current-member.dto';
import { mapUserRoleToCoreRoles } from '../membership/core-roles.mapper';
import { mapCoreRolesToMarketplaceRoles } from '../membership/marketplace-roles.mapper';
import {
MARKETPLACE_KU_CHAIRMEN_SERVICE,
type MarketplaceKuChairmenService,
} from '../services/marketplace-ku-chairmen.service';
import {
MARKETPLACE_WHITELIST_SERVICE,
type MarketplaceWhitelistService,
@@ -32,7 +36,9 @@ import {
export class MarketplaceMembershipGuard implements CanActivate {
constructor(
@Inject(MARKETPLACE_WHITELIST_SERVICE)
private readonly whitelistService: MarketplaceWhitelistService
private readonly whitelistService: MarketplaceWhitelistService,
@Inject(MARKETPLACE_KU_CHAIRMEN_SERVICE)
private readonly kuChairmenService: MarketplaceKuChairmenService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -55,12 +61,16 @@ export class MarketplaceMembershipGuard implements CanActivate {
}
const coreRoles = mapUserRoleToCoreRoles(user.role);
// Story 3.1: source `isOfferer` берётся из whitelist-сервиса (с TTL-кешем,
// см. MarketplaceWhitelistService). Story 2.x source `isKuChairman` —
// реализуется в Эпике 2 (ПВЗ), пока false.
const isOfferer = await this.whitelistService.isOfferer(config.coopname, user.username);
// Story 3.1 / Эпик 2: оба источника берутся из dedicated-сервисов с
// TTL-кешем (MarketplaceWhitelistService / MarketplaceKuChairmenService),
// чтобы guard на каждом GraphQL-запросе не лез в RPC и в БД на N+1.
const [isOfferer, isKuChairman] = await Promise.all([
this.whitelistService.isOfferer(config.coopname, user.username),
this.kuChairmenService.isKuChairman(config.coopname, user.username),
]);
const marketplaceRoles = mapCoreRolesToMarketplaceRoles(coreRoles, {
isOfferer,
isKuChairman,
});
const currentMember: IMarketplaceCurrentMember = {
@@ -32,6 +32,10 @@ import {
MarketplaceWhitelistService,
MARKETPLACE_WHITELIST_SERVICE,
} from './services/marketplace-whitelist.service';
import {
MarketplaceKuChairmenService,
MARKETPLACE_KU_CHAIRMEN_SERVICE,
} from './services/marketplace-ku-chairmen.service';
import {
MarketplaceVitrineService,
MARKETPLACE_VITRINE_SERVICE,
@@ -197,6 +201,13 @@ import { MarketplaceInventoryEntity } from '../infrastructure/entities/marketpla
useClass: MarketplaceWhitelistService,
},
MarketplaceWhitelistService,
// Эпик 2 / Story 2.x — источник isKuChairman (trustee ИЛИ trusted
// одного из branches кооператива) для marketplace-роли `operator`.
{
provide: MARKETPLACE_KU_CHAIRMEN_SERVICE,
useClass: MarketplaceKuChairmenService,
},
MarketplaceKuChairmenService,
{
provide: MARKETPLACE_VITRINE_SERVICE,
useClass: MarketplaceVitrineService,
@@ -317,6 +328,8 @@ import { MarketplaceInventoryEntity } from '../infrastructure/entities/marketpla
MarketplaceCoopAcceptanceService,
MARKETPLACE_WHITELIST_SERVICE,
MarketplaceWhitelistService,
MARKETPLACE_KU_CHAIRMEN_SERVICE,
MarketplaceKuChairmenService,
MARKETPLACE_VITRINE_SERVICE,
MarketplaceVitrineService,
MARKETPLACE_OFFER_SERVICE,
@@ -18,10 +18,13 @@ export type MarketplaceRole =
/**
* Контекст для расширенных marketplace-ролей.
*
* `isOfferer` (whitelist поставщиков) реализуется в Эпике 3 — пока всегда
* false. `isKuChairman` (председатель КУ) — в Эпике 2; до этого момента
* `operator` никому не выдаётся. Поля оставлены в сигнатуре, чтобы
* добавить источники без изменения mapper.
* `isOfferer` (whitelist поставщиков) Эпик 3,
* `MarketplaceWhitelistService.isOfferer`.
*
* `isKuChairman` — Эпик 2 (ПВЗ): пайщик имеет операционные полномочия
* на хотя бы одном КУ кооператива. Источник — `MarketplaceKuChairmenService.isKuChairman`,
* семантика «trustee ИЛИ trusted одного из branches» (trustee и trusted
* равны в правах по столу ПВЗ — приёмка, выдача, маркировка, склад).
*/
export interface IMarketplaceRoleContext {
isOfferer?: boolean;
@@ -21,6 +21,11 @@ import {
MarketplaceReturnClaimSignablePayloadInputDTO,
} from '../dto/marketplace-return-claim.dto';
import { MarketplaceReturnClaimService } from '../services/marketplace-return-claim.service';
import {
MARKETPLACE_KU_CHAIRMEN_SERVICE,
type MarketplaceKuChairmenService,
} from '../services/marketplace-ku-chairmen.service';
import { Inject } from '@nestjs/common';
import { toMarketplaceReturnClaimDTO } from './marketplace-return-claim.mapper';
function toGeneratedDocumentDTO(e: DocumentDomainEntity): GeneratedDocumentDTO {
@@ -46,7 +51,11 @@ function toGeneratedDocumentDTO(e: DocumentDomainEntity): GeneratedDocumentDTO {
@Resolver()
@Injectable()
export class MarketplaceReturnClaimResolver {
constructor(private readonly service: MarketplaceReturnClaimService) {}
constructor(
private readonly service: MarketplaceReturnClaimService,
@Inject(MARKETPLACE_KU_CHAIRMEN_SERVICE)
private readonly kuChairmenService: MarketplaceKuChairmenService
) {}
@Query(() => GeneratedDocumentDTO, {
name: 'marketplaceReturnClaimSignablePayload',
@@ -209,13 +218,17 @@ export class MarketplaceReturnClaimResolver {
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceListReturnClaimsByBranameInputDTO
): Promise<MarketplaceReturnClaimDTO[]> {
// Ownership `:own-KU` — MVP-приближение: член-operator является
// chairman'ом braname если его username совпадает с account КУ
// (соответствует текущему fallback'у marketplace-notification.service.ts).
// Полная интеграция с `coop_ku.chairman_account` — Phase 2.
if (member.username !== data.delivery_braname) {
// Ownership `:own-KU` — пайщик должен быть trustee или trusted
// именно того КУ, к которому привязано заявление. Проверка идёт
// через единый источник истины состава КУ (MarketplaceKuChairmenService).
const isMember = await this.kuChairmenService.isMemberOfBranch(
config.coopname,
data.delivery_braname,
member.username
);
if (!isMember) {
throw new ForbiddenException(
'Чтение заявлений возможно только для участка, председателем которого вы являетесь.'
'Чтение заявлений возможно только для участка, на котором вы являетесь председателем или доверенным лицом.'
);
}
const claims = await this.service.listByDeliveryBraname(
@@ -237,12 +250,18 @@ export class MarketplaceReturnClaimResolver {
): Promise<MarketplaceReturnClaimDTO> {
const claim = await this.service.findById(config.coopname, claim_id);
// Ownership-проверка `:own` — matrix capability проверена guard'ом,
// здесь верифицируем что пайщик действительно владелец заявления
// (orderer Order'а либо председатель КУ доставки этого заявления).
// здесь верифицируем, что пайщик действительно владелец заявления:
// либо заказчик Order'а, либо член КУ доставки (trustee/trusted —
// в marketplace-домене они равны в правах).
const isOwnerOrderer = claim.orderer_account === member.username;
const isChairmanOfDeliveryKu = member.marketplace_roles.includes('operator')
&& claim.delivery_braname === member.username;
if (!isOwnerOrderer && !isChairmanOfDeliveryKu) {
const isOperatorOfDeliveryKu =
member.marketplace_roles.includes('operator') &&
(await this.kuChairmenService.isMemberOfBranch(
config.coopname,
claim.delivery_braname,
member.username
));
if (!isOwnerOrderer && !isOperatorOfDeliveryKu) {
throw new ForbiddenException('Это чужое заявление на возврат.');
}
return this.toClaimDTO(claim);
@@ -0,0 +1,142 @@
import { MarketplaceKuChairmenService } from './marketplace-ku-chairmen.service';
import type { BranchBlockchainPort } from '~/domain/branch/interfaces/branch-blockchain.port';
function buildPort(branches: Array<{ braname: string; trustee: string; trusted: string[] }>) {
const port: Partial<BranchBlockchainPort> & {
getBranches: jest.Mock;
} = {
getBranches: jest.fn().mockResolvedValue(branches),
};
return port as BranchBlockchainPort & { getBranches: jest.Mock };
}
describe('MarketplaceKuChairmenService', () => {
describe('isKuChairman', () => {
it('возвращает true для trustee любого КУ', async () => {
const port = buildPort([
{ braname: 'krg', trustee: 'chairkrg', trusted: ['opkrg'] },
{ braname: 'myt', trustee: 'chairmyt', trusted: [] },
]);
const service = new MarketplaceKuChairmenService(port);
expect(await service.isKuChairman('voskhod', 'chairkrg')).toBe(true);
expect(await service.isKuChairman('voskhod', 'chairmyt')).toBe(true);
});
it('возвращает true для trusted (равенство в правах с trustee)', async () => {
const port = buildPort([
{ braname: 'krg', trustee: 'chairkrg', trusted: ['opkrg', 'trustedkrg'] },
]);
const service = new MarketplaceKuChairmenService(port);
expect(await service.isKuChairman('voskhod', 'opkrg')).toBe(true);
expect(await service.isKuChairman('voskhod', 'trustedkrg')).toBe(true);
});
it('возвращает false для постороннего пайщика', async () => {
const port = buildPort([
{ braname: 'krg', trustee: 'chairkrg', trusted: ['opkrg'] },
]);
const service = new MarketplaceKuChairmenService(port);
expect(await service.isKuChairman('voskhod', 'ivanpetrov')).toBe(false);
});
it('кеширует branches[] на TTL — повторный вызов не дёргает порт', async () => {
const port = buildPort([
{ braname: 'krg', trustee: 'chairkrg', trusted: [] },
]);
const service = new MarketplaceKuChairmenService(port);
await service.isKuChairman('voskhod', 'chairkrg');
await service.isKuChairman('voskhod', 'someoneelse');
await service.isKuChairman('voskhod', 'chairkrg');
expect(port.getBranches).toHaveBeenCalledTimes(1);
});
it('invalidate(coopname) сбрасывает кеш', async () => {
const port = buildPort([
{ braname: 'krg', trustee: 'chairkrg', trusted: [] },
]);
const service = new MarketplaceKuChairmenService(port);
await service.isKuChairman('voskhod', 'chairkrg');
service.invalidate('voskhod');
await service.isKuChairman('voskhod', 'chairkrg');
expect(port.getBranches).toHaveBeenCalledTimes(2);
});
});
describe('isMemberOfBranch', () => {
it('возвращает true только для членов конкретного branch (trustee или trusted)', async () => {
const port = buildPort([
{ braname: 'krg', trustee: 'chairkrg', trusted: ['opkrg'] },
{ braname: 'myt', trustee: 'chairmyt', trusted: [] },
]);
const service = new MarketplaceKuChairmenService(port);
expect(await service.isMemberOfBranch('voskhod', 'krg', 'chairkrg')).toBe(true);
expect(await service.isMemberOfBranch('voskhod', 'krg', 'opkrg')).toBe(true);
// chairmyt — председатель myt, не krg
expect(await service.isMemberOfBranch('voskhod', 'krg', 'chairmyt')).toBe(false);
expect(await service.isMemberOfBranch('voskhod', 'unknown', 'chairkrg')).toBe(false);
});
});
describe('listOperatorsOfBranch', () => {
it('возвращает trustee + всех trusted', async () => {
const port = buildPort([
{ braname: 'krg', trustee: 'chairkrg', trusted: ['opkrg', 'trustedkrg'] },
]);
const service = new MarketplaceKuChairmenService(port);
expect(await service.listOperatorsOfBranch('voskhod', 'krg')).toEqual([
'chairkrg',
'opkrg',
'trustedkrg',
]);
});
it('пустой список для несуществующего КУ', async () => {
const port = buildPort([]);
const service = new MarketplaceKuChairmenService(port);
expect(await service.listOperatorsOfBranch('voskhod', 'nope')).toEqual([]);
});
});
describe('listBranamesForMember', () => {
it('возвращает все КУ, где пайщик — trustee или trusted', async () => {
const port = buildPort([
{ braname: 'krg', trustee: 'chairkrg', trusted: ['ekaterina'] },
{ braname: 'myt', trustee: 'chairmyt', trusted: [] },
{ braname: 'odn', trustee: 'chairodn', trusted: ['ekaterina'] },
]);
const service = new MarketplaceKuChairmenService(port);
expect(await service.listBranamesForMember('voskhod', 'ekaterina')).toEqual(['krg', 'odn']);
expect(await service.listBranamesForMember('voskhod', 'chairmyt')).toEqual(['myt']);
expect(await service.listBranamesForMember('voskhod', 'ivanpetrov')).toEqual([]);
});
});
describe('getTrusteeOfBranch', () => {
it('возвращает trustee существующего КУ', async () => {
const port = buildPort([
{ braname: 'krg', trustee: 'chairkrg', trusted: ['opkrg'] },
]);
const service = new MarketplaceKuChairmenService(port);
expect(await service.getTrusteeOfBranch('voskhod', 'krg')).toBe('chairkrg');
});
it('возвращает null для несуществующего', async () => {
const port = buildPort([]);
const service = new MarketplaceKuChairmenService(port);
expect(await service.getTrusteeOfBranch('voskhod', 'nope')).toBeNull();
});
});
});
@@ -0,0 +1,128 @@
import { Inject, Injectable } from '@nestjs/common';
import type { BranchContract } from 'cooptypes';
import {
BRANCH_BLOCKCHAIN_PORT,
type BranchBlockchainPort,
} from '~/domain/branch/interfaces/branch-blockchain.port';
export const MARKETPLACE_KU_CHAIRMEN_SERVICE = Symbol('MARKETPLACE_KU_CHAIRMEN_SERVICE');
interface IBranchesCacheEntry {
branches: BranchContract.Tables.Branches.IBranch[];
expires_at: number;
}
/**
* Эпик 2 (ПВЗ): источник состава кооперативных участков (branches) для
* marketplace-домена. Используется в двух местах:
*
* 1. `MarketplaceMembershipGuard` берёт `isKuChairman(coopname, username)`
* для контекста `mapCoreRolesToMarketplaceRoles` — marketplace-роль
* `operator` выдаётся пайщику, если он `trustee` ИЛИ `trusted[i]`
* хотя бы одного branch'а кооператива.
* 2. Resolver'ы с ownership `:own-KU` (return-claim, issuance, warehouse)
* берут `isMemberOfBranch(coopname, braname, username)` для проверки
* «принадлежит ли пайщик именно этому КУ».
*
* **Инвариант**: trustee и trusted имеют ИДЕНТИЧНЫЕ операционные права в
* marketplace-домене (приёмка, выдача, маркировка, склад, ленты КУ). Этот
* сервис — единственная точка, где этот инвариант формализован. Любая
* проверка «является ли пайщик X работником КУ Y» должна идти через него,
* а не через прямое сравнение с `branch.trustee` или `member.username ==
* braname`.
*
* Источник истины — on-chain таблица `branches` контракта `branch`,
* читается через `BRANCH_BLOCKCHAIN_PORT.getBranches(coopname)` и
* кешируется in-memory с TTL `BRANCHES_CACHE_TTL_MS` (60 сек). Состав КУ
* меняется редко (`createbranch`/`editbranch`/`delbranch`/`addtrusted`/
* `deltrusted`), TTL покрывает обычные сценарии. Резолверы, меняющие
* состав КУ, дёргают `invalidate(coopname)`.
*/
@Injectable()
export class MarketplaceKuChairmenService {
private static readonly BRANCHES_CACHE_TTL_MS = 60_000;
private readonly branchesCache = new Map<string, IBranchesCacheEntry>();
constructor(
@Inject(BRANCH_BLOCKCHAIN_PORT)
private readonly branchPort: BranchBlockchainPort
) {}
async isKuChairman(coopname: string, member_account: string): Promise<boolean> {
const branches = await this.getBranches(coopname);
return branches.some((b) => this.branchIncludesMember(b, member_account));
}
async isMemberOfBranch(
coopname: string,
braname: string,
member_account: string
): Promise<boolean> {
const branches = await this.getBranches(coopname);
const branch = branches.find((b) => b.braname === braname);
if (!branch) return false;
return this.branchIncludesMember(branch, member_account);
}
/**
* Список branches, в которых пайщик имеет операционные полномочия
* (trustee либо trusted). Нужен, например, для отображения «своих» КУ
* в селекторе оператора или для bulk-фильтрации лент.
*/
async listBranamesForMember(coopname: string, member_account: string): Promise<string[]> {
const branches = await this.getBranches(coopname);
return branches
.filter((b) => this.branchIncludesMember(b, member_account))
.map((b) => b.braname);
}
/**
* Operational-роли конкретного КУ: trustee (председатель) + все
* trusted (доверенные лица). Семантика «равны в правах» — порядок
* списка не несёт смысла, потребитель решает сам кому слать первому
* (например, push можно посылать всем, а единственная подпись —
* trustee'у).
*/
async listOperatorsOfBranch(coopname: string, braname: string): Promise<string[]> {
const branches = await this.getBranches(coopname);
const branch = branches.find((b) => b.braname === braname);
if (!branch) return [];
return [branch.trustee, ...branch.trusted];
}
/**
* Председатель КУ — только trustee. Используется когда по контракту
* требуется именно `trustee` (например, on-chain действие, где adressee
* — формальный председатель), не операционный персонал.
*/
async getTrusteeOfBranch(coopname: string, braname: string): Promise<string | null> {
const branches = await this.getBranches(coopname);
return branches.find((b) => b.braname === braname)?.trustee ?? null;
}
invalidate(coopname: string): void {
this.branchesCache.delete(coopname);
}
private async getBranches(coopname: string): Promise<BranchContract.Tables.Branches.IBranch[]> {
const now = Date.now();
const cached = this.branchesCache.get(coopname);
if (cached && cached.expires_at > now) return cached.branches;
const branches = await this.branchPort.getBranches(coopname);
this.branchesCache.set(coopname, {
branches,
expires_at: now + MarketplaceKuChairmenService.BRANCHES_CACHE_TTL_MS,
});
return branches;
}
private branchIncludesMember(
branch: BranchContract.Tables.Branches.IBranch,
member_account: string
): boolean {
return branch.trustee === member_account || branch.trusted.includes(member_account);
}
}
@@ -6,6 +6,10 @@ import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { NOVU_WORKFLOW_PORT, type NovuWorkflowPort } from '~/domain/notification/interfaces/novu-workflow.port';
import { ACCOUNT_DATA_PORT, type AccountDataPort } from '~/domain/account/ports/account-data.port';
import type { WorkflowTriggerDomainInterface } from '~/domain/notification/interfaces/workflow-trigger-domain.interface';
import {
MARKETPLACE_KU_CHAIRMEN_SERVICE,
type MarketplaceKuChairmenService,
} from './marketplace-ku-chairmen.service';
import {
MARKETPLACE_APL_SUPPLIER_SIGN_REQUEST_EVENT,
MARKETPLACE_CASHIER_NEW_PAYMENT_EVENT,
@@ -47,6 +51,8 @@ export class MarketplaceNotificationService implements OnModuleInit {
private readonly novuWorkflowPort: NovuWorkflowPort,
@Inject(ACCOUNT_DATA_PORT)
private readonly accountPort: AccountDataPort,
@Inject(MARKETPLACE_KU_CHAIRMEN_SERVICE)
private readonly kuChairmenService: MarketplaceKuChairmenService,
private readonly logger: WinstonLoggerService
) {
this.logger.setContext(MarketplaceNotificationService.name);
@@ -226,47 +232,63 @@ export class MarketplaceNotificationService implements OnModuleInit {
@OnEvent(MARKETPLACE_RETURN_CLAIM_SUBMITTED_EVENT)
async handleReturnClaimSubmitted(event: MarketplaceReturnClaimSubmittedEvent): Promise<void> {
try {
// В MVP кооперативный участок — это account-like префикс; председатель
// КУ соответствует account == delivery_braname или назначен через
// coop_ku.chairman_account. Для подачи в Novu используем `delivery_braname`
// как fallback adressee; полная интеграция с `coop_ku.chairman_account`
// — Phase 2 (после расширения cooperative-coop_ku модели в core).
const chairmanAccount = event.delivery_braname;
const chairman = await this.accountPort.getAccount(chairmanAccount);
const subscriberId = chairman.provider_account?.subscriber_id?.trim();
const email = chairman.provider_account?.email;
if (!subscriberId || !email) {
// Адресуем всем операторам КУ доставки (trustee + trusted) — они
// равны в правах по столу ПВЗ, новый return-claim видят все. Veerom
// (несколько Novu-триггеров) сохраняет инвариант «никто не пропустил»
// без ввода понятия «главный получатель уведомления».
const operators = await this.kuChairmenService.listOperatorsOfBranch(
event.coopname,
event.delivery_braname
);
if (operators.length === 0) {
this.logger.warn(
`Заявление на возврат ${event.claim_id}: subscriber_id/email председателя ${chairmanAccount} не найден — push пропущен.`
`Заявление на возврат ${event.claim_id}: у КУ ${event.delivery_braname} нет ни председателя, ни доверенных лиц — push пропущен.`
);
return;
}
const chairmanName = await this.accountPort.getDisplayName(chairmanAccount);
const ordererName = await this.accountPort.getDisplayName(event.orderer_account);
const reasonExcerpt =
event.reason_text.length > 240 ? event.reason_text.slice(0, 240) + '…' : event.reason_text;
const payload: Workflows.MarketplaceReturnClaimSubmitted.IPayload = {
chairmanName,
ordererName,
brananame: event.delivery_braname,
coopname: event.coopname,
claim_id: event.claim_id,
order_id: event.order_id,
reasonExcerpt,
deepLinkUrl: `${config.frontend_url}/${event.coopname}/market-pvz/returns/${event.claim_id}`,
};
const triggerData: WorkflowTriggerDomainInterface = {
name: Workflows.MarketplaceReturnClaimSubmitted.id,
to: { subscriberId, email },
payload,
};
await this.novuWorkflowPort.triggerWorkflow(triggerData);
this.logger.log(
`Заявление на возврат ${event.claim_id}: push председателю ${chairmanAccount} отправлен.`
);
for (const operatorAccount of operators) {
try {
const operator = await this.accountPort.getAccount(operatorAccount);
const subscriberId = operator.provider_account?.subscriber_id?.trim();
const email = operator.provider_account?.email;
if (!subscriberId || !email) {
this.logger.warn(
`Заявление на возврат ${event.claim_id}: subscriber_id/email оператора ${operatorAccount} не найден — push пропущен.`
);
continue;
}
const chairmanName = await this.accountPort.getDisplayName(operatorAccount);
const payload: Workflows.MarketplaceReturnClaimSubmitted.IPayload = {
chairmanName,
ordererName,
brananame: event.delivery_braname,
coopname: event.coopname,
claim_id: event.claim_id,
order_id: event.order_id,
reasonExcerpt,
deepLinkUrl: `${config.frontend_url}/${event.coopname}/market-pvz/returns/${event.claim_id}`,
};
const triggerData: WorkflowTriggerDomainInterface = {
name: Workflows.MarketplaceReturnClaimSubmitted.id,
to: { subscriberId, email },
payload,
};
await this.novuWorkflowPort.triggerWorkflow(triggerData);
this.logger.log(
`Заявление на возврат ${event.claim_id}: push оператору ${operatorAccount} отправлен.`
);
} catch (innerErr: any) {
this.logger.warn(
`Заявление на возврат ${event.claim_id}: ошибка push оператору ${operatorAccount} (${innerErr.message}) — продолжаем веер.`
);
}
}
} catch (err: any) {
this.logger.warn(
`Заявление на возврат ${event.claim_id}: ошибка push председателю (${err.message}) — flow не блокируется.`
`Заявление на возврат ${event.claim_id}: ошибка push операторам КУ (${err.message}) — flow не блокируется.`
);
}
}
@@ -16,6 +16,7 @@
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { MarketplaceMembershipGuard } from '~/extensions/marketplace/application/guards/marketplace-membership.guard';
import type { MarketplaceKuChairmenService } from '~/extensions/marketplace/application/services/marketplace-ku-chairmen.service';
import type { MarketplaceWhitelistService } from '~/extensions/marketplace/application/services/marketplace-whitelist.service';
jest.mock('~/config/config', () => ({
@@ -47,9 +48,15 @@ function makeWhitelistService(isOffererResult: boolean): MarketplaceWhitelistSer
} as unknown as MarketplaceWhitelistService;
}
function makeKuChairmenService(isKuChairmanResult: boolean): MarketplaceKuChairmenService {
return {
isKuChairman: jest.fn().mockResolvedValue(isKuChairmanResult),
} as unknown as MarketplaceKuChairmenService;
}
describe('MarketplaceMembershipGuard', () => {
it('user.role=user, status=active, whitelist пуст → ctx [User] + marketplace_roles [orderer, offerer]', async () => {
const guard = new MarketplaceMembershipGuard(makeWhitelistService(true));
const guard = new MarketplaceMembershipGuard(makeWhitelistService(true), makeKuChairmenService(false));
const req = { user: { username: 'alice', role: 'user', status: 'active' }, headers: {} };
const ctx = makeCtx(req);
@@ -63,7 +70,7 @@ describe('MarketplaceMembershipGuard', () => {
});
it('user.role=user, whitelist непустой и пайщика там нет → marketplace_roles [orderer] (без offerer)', async () => {
const guard = new MarketplaceMembershipGuard(makeWhitelistService(false));
const guard = new MarketplaceMembershipGuard(makeWhitelistService(false), makeKuChairmenService(false));
const req = { user: { username: 'alice', role: 'user', status: 'active' }, headers: {} };
const ctx = makeCtx(req);
@@ -72,7 +79,7 @@ describe('MarketplaceMembershipGuard', () => {
});
it('user.role=member, status=active → core_roles [User, Member] + marketplace_roles [orderer, offerer, board_readonly]', async () => {
const guard = new MarketplaceMembershipGuard(makeWhitelistService(true));
const guard = new MarketplaceMembershipGuard(makeWhitelistService(true), makeKuChairmenService(false));
const req = { user: { username: 'bob', role: 'member', status: 'active' }, headers: {} };
const ctx = makeCtx(req);
await expect(guard.canActivate(ctx as any)).resolves.toBe(true);
@@ -85,7 +92,7 @@ describe('MarketplaceMembershipGuard', () => {
});
it('user.role=chairman, status=active → marketplace_roles полный набор', async () => {
const guard = new MarketplaceMembershipGuard(makeWhitelistService(true));
const guard = new MarketplaceMembershipGuard(makeWhitelistService(true), makeKuChairmenService(false));
const req = { user: { username: 'chair', role: 'chairman', status: 'active' }, headers: {} };
const ctx = makeCtx(req);
await expect(guard.canActivate(ctx as any)).resolves.toBe(true);
@@ -100,7 +107,7 @@ describe('MarketplaceMembershipGuard', () => {
});
it('status != active → 403 Forbidden «Доступ только для пайщиков кооператива»', async () => {
const guard = new MarketplaceMembershipGuard(makeWhitelistService(false));
const guard = new MarketplaceMembershipGuard(makeWhitelistService(false), makeKuChairmenService(false));
const req = { user: { username: 'alice', role: 'user', status: '4_Registered' }, headers: {} };
const ctx = makeCtx(req);
await expect(guard.canActivate(ctx as any)).rejects.toThrow(ForbiddenException);
@@ -110,19 +117,29 @@ describe('MarketplaceMembershipGuard', () => {
});
it('нет user (нет JWT) → 401 Unauthorized', async () => {
const guard = new MarketplaceMembershipGuard(makeWhitelistService(false));
const guard = new MarketplaceMembershipGuard(makeWhitelistService(false), makeKuChairmenService(false));
const req = { headers: {} };
const ctx = makeCtx(req);
await expect(guard.canActivate(ctx as any)).rejects.toThrow(UnauthorizedException);
});
it('server-secret bypass → true, currentMember не выставляется, whitelist не дёргается', async () => {
it('server-secret bypass → true, currentMember не выставляется, whitelist/branches не дёргаются', async () => {
const ws = makeWhitelistService(true);
const guard = new MarketplaceMembershipGuard(ws);
const ku = makeKuChairmenService(false);
const guard = new MarketplaceMembershipGuard(ws, ku);
const req = { headers: { 'server-secret': 'test-secret' } };
const ctx = makeCtx(req);
await expect(guard.canActivate(ctx as any)).resolves.toBe(true);
expect((ctx as any)._gqlCtx.currentMember).toBeUndefined();
expect(ws.isOfferer).not.toHaveBeenCalled();
expect(ku.isKuChairman).not.toHaveBeenCalled();
});
it('isKuChairman=true → marketplace_roles содержит operator', async () => {
const guard = new MarketplaceMembershipGuard(makeWhitelistService(false), makeKuChairmenService(true));
const req = { user: { username: 'chairkrg', role: 'user', status: 'active' }, headers: {} };
const ctx = makeCtx(req);
await expect(guard.canActivate(ctx as any)).resolves.toBe(true);
expect((ctx as any)._gqlCtx.currentMember.marketplace_roles).toEqual(['orderer', 'operator']);
});
});
+309 -5
View File
@@ -1,4 +1,6 @@
import { markRaw } from 'vue'
import type { NavigationGuardWithThis, RouteLocationNormalized } from 'vue-router'
import { useSessionStore } from 'src/entities/Session'
import { MarketplaceCatalogPage } from 'src/pages/Marketplace/MarketplaceCatalog'
import { CreateMarketplaceOfferPage } from 'src/pages/Marketplace/CreateMarketplaceOffer'
import { MyOrdersPage } from 'src/pages/Marketplace/MyOrders'
@@ -8,13 +10,51 @@ import { OperatorIssuancePage } from 'src/pages/Marketplace/OperatorIssuance'
import { OrdererReadyToReceivePage } from 'src/pages/Marketplace/OrdererReadyToReceive'
import { OrdererReturnClaimsPage } from 'src/pages/Marketplace/OrdererReturnClaims'
import { OperatorReturnClaimsPage } from 'src/pages/Marketplace/OperatorReturnClaims'
import { OperatorReceptionPage } from 'src/pages/Marketplace/OperatorReception'
import { OperatorInventoryLabelingPage } from 'src/pages/Marketplace/OperatorInventoryLabeling'
import { OffererPendingAplReceptionsPage } from 'src/pages/Marketplace/OffererPendingAplReceptions'
import { OffererSupplyPreparationPage } from 'src/pages/Marketplace/OffererSupplyPreparation'
import { OffererPaymentHistoryPage } from 'src/pages/Marketplace/OffererPaymentHistory'
import { AdminWriteoffsPage } from 'src/pages/Marketplace/AdminWriteoffs'
import { ChairmanModerationPage } from 'src/pages/Marketplace/ChairmanModeration'
import { OperatorOwnWarehousePage } from 'src/pages/Marketplace/OperatorOwnWarehouse'
import { AdminWarehouseSummaryPage } from 'src/pages/Marketplace/AdminWarehouseSummary'
import { EcosystemRegistryPage } from 'src/pages/Marketplace/EcosystemRegistry'
import { OnboardingCoopAcceptCppPage } from 'src/pages/Marketplace/OnboardingCoopAcceptCpp'
import { OrdererConsolidatedPage } from 'src/pages/Marketplace/OrdererConsolidated'
import { OffererIncomingOrdersPage } from 'src/pages/Marketplace/OffererIncomingOrders'
import { OffererMyOffersPage } from 'src/pages/Marketplace/OffererMyOffers'
import { BranchChairmanBranchOrdersPage } from 'src/pages/Marketplace/BranchChairmanBranchOrders'
import { BoardAgendaWriteoffPage } from 'src/pages/Marketplace/BoardAgendaWriteoff'
import { ChairmanCategoryWhitelistPage } from 'src/pages/Marketplace/ChairmanCategoryWhitelist'
import { BoardPayoutsReadonlyPage } from 'src/pages/Marketplace/BoardPayoutsReadonly'
import { OnboardingMemberPickCppPage } from 'src/pages/Marketplace/OnboardingMemberPickCpp'
import type { IWorkspaceConfig } from 'src/shared/lib/types/workspace'
import { agreementsBase } from 'src/shared/lib/consts/workspaces'
import { registerMarketplaceProcessInfoHandlers } from './app/extensions'
import { useMarketSessionStore } from './model/market-session-store'
// Per-route guard для `/market-pvz/*`: пускаем пайщиков, у которых
// есть marketplace-роль `operator` (председатель/доверенное лицо КУ)
// или `admin` (председатель кооператива). Core-guard (`roles: []`)
// эти роуты пропускает, итоговую проверку делает само расширение —
// по FSD ядро не знает про marketplace-policy.
const requireMarketplaceOperator: NavigationGuardWithThis<undefined> = async (
to: RouteLocationNormalized,
) => {
const session = useSessionStore()
if (!session.isAuth) {
return { name: 'login-redirect', query: to.query }
}
const market = useMarketSessionStore()
if (!market.loaded) {
await market.fetchRoles()
}
if (market.hasRole('operator') || market.hasRole('admin')) {
return true
}
return { name: 'permissionDenied', query: to.query }
}
export default async function (): Promise<IWorkspaceConfig[]> {
registerMarketplaceProcessInfoHandlers()
@@ -70,6 +110,22 @@ export default async function (): Promise<IWorkspaceConfig[]> {
agreements: agreementsBase,
},
},
{
// Эпик 4 / Story 4.4: сводный обзор заказов пайщика, сгруппированных
// по партиям (cycle_id). Дополняет «Мои заказы» — там плоский список,
// здесь — партии time_based/volume_based/open_subscription с этапом
// партии и суммарной стоимостью. Канон OrderCard для отдельных заказов.
path: 'consolidated',
name: 'marketplace-consolidated',
component: markRaw(OrdererConsolidatedPage),
meta: {
title: 'Сводный заказ',
icon: 'fa-solid fa-layer-group',
roles: [],
requiresAuth: true,
agreements: agreementsBase,
},
},
{
// Эпик 6 / Story 6.7: лента заказов пайщика, готовых к получению
// на пункте выдачи (статус READY_TO_RECEIVE) — визуальное
@@ -85,6 +141,88 @@ export default async function (): Promise<IWorkspaceConfig[]> {
agreements: agreementsBase,
},
},
{
// Эпик 3 / Story 3.4: «Мои предложения» — поставщик видит все
// свои Offer'ы во всех 4 статусах (PENDING_MODERATION / ACTIVE /
// REJECTED / WITHDRAWN). Канон CatalogOfferCard, client-side
// фильтр + поиск, polling 30s (статус меняет модерация).
path: 'my-offers',
name: 'marketplace-my-offers',
component: markRaw(OffererMyOffersPage),
meta: {
title: 'Мои предложения',
icon: 'fa-solid fa-clipboard-list',
roles: [],
requiresAuth: true,
agreements: agreementsBase,
},
},
{
// Эпик 4 / Story 4.5: «Входящие заказы» — поставщик видит заказы,
// по которым он supplier. Канон OrderCard с role='offerer'.
// Действия по акцепту партии — отдельно на «Подготовка отгрузки».
path: 'incoming-orders',
name: 'marketplace-incoming-orders',
component: markRaw(OffererIncomingOrdersPage),
meta: {
title: 'Входящие заказы',
icon: 'fa-solid fa-inbox',
roles: [],
requiresAuth: true,
agreements: agreementsBase,
},
},
{
// Эпик 5 / Story 5.5: offerer-стол подготовки отгрузки. Поставщик
// видит сформированные сводные заказы (статус CONFIRMED → SHIPPING)
// и подтверждает готовность к отгрузке через `marketplaceMarkSupplyReady`.
// После этого ПВЗ открывает приёмку. На MVP — лента + per-row
// действие «Готов к отгрузке».
path: 'supply-prep',
name: 'marketplace-supply-prep',
component: markRaw(OffererSupplyPreparationPage),
meta: {
title: 'Подготовка отгрузки',
icon: 'fa-solid fa-truck-ramp-box',
roles: [],
requiresAuth: true,
agreements: agreementsBase,
},
},
{
// Эпик 5 / Story 5.7: offerer-стол ожидающих подписи актов приёмки.
// Поставщик первой подписью signapl1 подтверждает факт приёмки
// партии ПВЗ — после этого ПВЗ закрывает акт второй подписью
// (см. `apl-reception-close` в branch-chairman). Бессрочного
// open-state не бывает: signapl1 — это окно ~24ч.
path: 'apl-receptions',
name: 'marketplace-apl-receptions',
component: markRaw(OffererPendingAplReceptionsPage),
meta: {
title: 'Подпись приёмки',
icon: 'fa-solid fa-file-signature',
roles: [],
requiresAuth: true,
agreements: agreementsBase,
},
},
{
// Эпик 5 / Story 5.9: offerer-стол «История выплат». Поставщик
// видит список MarketplaceOutgoingPaymentRequest со статусами
// INITIATED / CONFIRMED / FAILED по своим закрытым актам приёмки.
// Запрос идёт через `listOutgoingPaymentsAsSupplier` с фильтром
// payee_account = current user.
path: 'payments',
name: 'marketplace-payments',
component: markRaw(OffererPaymentHistoryPage),
meta: {
title: 'История выплат',
icon: 'fa-solid fa-money-bill-transfer',
roles: [],
requiresAuth: true,
agreements: agreementsBase,
},
},
{
// Эпик 7 / Story 7.1: orderer-стол гарантийных возвратов. Здесь
// пайщик подаёт заявление по выданному заказу в гарантийный срок,
@@ -139,6 +277,72 @@ export default async function (): Promise<IWorkspaceConfig[]> {
agreements: agreementsBase,
},
},
{
// Эпик 5 / Story 5.x: informational placeholder для совета —
// лента выплат всех поставщиков. Текущий backend отдаёт только
// выплаты текущего пайщика-supplier'а; для совета нужна доп.
// query с read:all policy. Подключится в Phase 2.
path: 'payouts',
name: 'marketplace-board-payouts',
component: markRaw(BoardPayoutsReadonlyPage),
meta: {
title: 'Выплаты — совет',
icon: 'fa-solid fa-coins',
roles: ['chairman', 'member'],
requiresAuth: true,
agreements: agreementsBase,
},
},
{
// Эпик 3 / Story 3.x: chairman-настройка whitelist'а доступных
// категорий. Пустой whitelist = весь глобальный каталог; иначе
// публиковать Offer'ы можно только в перечисленных категориях.
// Backend: available-category-admin.resolver.ts (@AuthRoles chairman).
path: 'category-whitelist',
name: 'marketplace-category-whitelist',
component: markRaw(ChairmanCategoryWhitelistPage),
meta: {
title: 'Доступные категории',
icon: 'fa-solid fa-filter',
roles: ['chairman'],
requiresAuth: true,
agreements: agreementsBase,
},
},
{
// Эпик 8 / Story 8.x: read-only лента writeoff-проектов для совета.
// Совет видит проекты в статусах ON_AGENDA / AUTHORIZED /
// EXECUTING / EXECUTED / REJECTED. Голосование совета идёт
// через core soviet agenda (sov-flow), здесь только обзор.
path: 'board-writeoff',
name: 'marketplace-board-writeoff',
component: markRaw(BoardAgendaWriteoffPage),
meta: {
title: 'Повестка совета — списания',
icon: 'fa-solid fa-gavel',
roles: ['chairman', 'member'],
requiresAuth: true,
agreements: agreementsBase,
},
},
{
// Эпик 3 / Story 3.6: admin-стол модерации offer'ов. Председатель
// (или общий администратор) видит ленту предложений в статусе
// PENDING_MODERATION и одобряет их через `marketplaceApproveOffer`
// (status → APPROVED → попадает в публичный каталог Story 3.5).
// Скрытие в access-matrix: `Marketplace.Offer: ['moderate']`
// открыто только chairman + member-совета.
path: 'moderation',
name: 'marketplace-moderation',
component: markRaw(ChairmanModerationPage),
meta: {
title: 'Модерация',
icon: 'fa-solid fa-clipboard-check',
roles: ['chairman', 'member'],
requiresAuth: true,
agreements: agreementsBase,
},
},
{
// Эпик 9 / Story 9.4: раздел экосистемы — список controller'ов
// других кооперативов с расширением «Стол заказов». MVP — режим
@@ -171,6 +375,42 @@ export default async function (): Promise<IWorkspaceConfig[]> {
agreements: agreementsBase,
},
},
{
// Эпик 1 / Story 1.9-1.10: L1 онбординг — приём кооперативом ЦПП
// «Стол заказов». Председатель кооператива принимает Положение
// ЦПП Marketplace, подписывая оферту в
// `coop_registration_offers_registry`. После этого:
// 1) пайщики могут проходить L3 onboarding gate;
// 2) расширение market становится активным
// (extension.config.coopAcceptance.status='active').
// Backend: marketplaceCppStatus + marketplaceAcceptCpp.
path: 'onboarding/coop-cpp',
name: 'marketplace-onboarding-coop-cpp',
component: markRaw(OnboardingCoopAcceptCppPage),
meta: {
title: 'Подключение ЦПП',
icon: 'fa-solid fa-handshake',
roles: ['chairman', 'member'],
requiresAuth: true,
agreements: agreementsBase,
},
},
{
// Эпик 1 / Story 1.4 + 1.11: L3 онбординг пайщика — gate ЦПП
// «Стол заказов». Реальная подпись делается через core
// Registrator-мастер; здесь информационная страница со списком
// документов + переход в мастер.
path: 'onboarding/member-cpp',
name: 'marketplace-onboarding-member-cpp',
component: markRaw(OnboardingMemberPickCppPage),
meta: {
title: 'Подключение к Marketplace',
icon: 'fa-solid fa-handshake-angle',
roles: [],
requiresAuth: true,
agreements: agreementsBase,
},
},
],
},
],
@@ -183,22 +423,66 @@ export default async function (): Promise<IWorkspaceConfig[]> {
defaultRoute: 'marketplace-pvz',
routes: [
{
// Стол ПВЗ открыт двум профилям пайщиков:
// - cooperative chairman (core-роль `chairman`) — видит все КУ;
// - председатель КУ или его доверенное лицо (marketplace-роль
// `operator`, выдаётся через `MarketplaceKuChairmenService`).
// OR-логика подключается router-guard'ом при наличии обоих
// ограничений (см. processes/navigation-guard-setup).
meta: {
title: 'Стол ПВЗ',
icon: 'fa-solid fa-map-location-dot',
roles: ['chairman'],
roles: [],
},
path: '/:coopname/market-pvz',
name: 'market-pvz',
beforeEnter: requireMarketplaceOperator,
children: [
{
path: 'list',
name: 'marketplace-pvz',
component: markRaw(PvzListPage),
beforeEnter: requireMarketplaceOperator,
meta: {
title: 'ПВЗ кооператива',
icon: 'fa-solid fa-map-location-dot',
roles: ['chairman'],
roles: [],
requiresAuth: true,
agreements: agreementsBase,
},
},
{
// Эпик 5 / Story 5.6: operator-стол приёмки партии на ПВЗ.
// Председатель КУ открывает акт приёмки (registry_id=1102) против
// ожидаемой поставки, сверяет фактические единицы с планом партии
// и подписывает signapl1 (поставщик ставит вторую). На входе —
// лента CONFIRMED партий, на выходе — переход в LABELING.
path: 'reception',
name: 'marketplace-pvz-reception',
component: markRaw(OperatorReceptionPage),
beforeEnter: requireMarketplaceOperator,
meta: {
title: 'Приёмка партии',
icon: 'fa-solid fa-box-open',
roles: [],
requiresAuth: true,
agreements: agreementsBase,
},
},
{
// Эпик 5 / Story 5.8 + Эпик 6: operator-стол маркировки
// имущества. После закрытия акта приёмки оператор клеит
// EAN-13 на каждую единицу через `BarcodeDisplay` (UX-DR12) +
// фиксирует факт маркировки через `marketplaceLabelInventoryItem`.
// На MVP — single + shipment-batch режимы.
path: 'labeling',
name: 'marketplace-pvz-labeling',
component: markRaw(OperatorInventoryLabelingPage),
beforeEnter: requireMarketplaceOperator,
meta: {
title: 'Маркировка имущества',
icon: 'fa-solid fa-tag',
roles: [],
requiresAuth: true,
agreements: agreementsBase,
},
@@ -212,10 +496,11 @@ export default async function (): Promise<IWorkspaceConfig[]> {
path: 'issuance',
name: 'marketplace-issuance',
component: markRaw(OperatorIssuancePage),
beforeEnter: requireMarketplaceOperator,
meta: {
title: 'Выдача заказов',
icon: 'fa-solid fa-handshake',
roles: ['chairman'],
roles: [],
requiresAuth: true,
agreements: agreementsBase,
},
@@ -231,10 +516,28 @@ export default async function (): Promise<IWorkspaceConfig[]> {
path: 'returns',
name: 'marketplace-pvz-returns',
component: markRaw(OperatorReturnClaimsPage),
beforeEnter: requireMarketplaceOperator,
meta: {
title: 'Гарантийные возвраты',
icon: 'fa-solid fa-clipboard-check',
roles: ['chairman'],
roles: [],
requiresAuth: true,
agreements: agreementsBase,
},
},
{
// Эпик 6 / Story 6.x: «Сводный стол КУ» — объединяет ленты
// приёмок / выдач / возвратов в один экран с табами и счётчиками
// для председателя КУ. Read-only обзор; действия — на /reception,
// /issuance, /returns.
path: 'branch-orders',
name: 'marketplace-pvz-branch-orders',
component: markRaw(BranchChairmanBranchOrdersPage),
beforeEnter: requireMarketplaceOperator,
meta: {
title: 'Сводный стол КУ',
icon: 'fa-solid fa-list-check',
roles: [],
requiresAuth: true,
agreements: agreementsBase,
},
@@ -250,10 +553,11 @@ export default async function (): Promise<IWorkspaceConfig[]> {
path: 'warehouse',
name: 'marketplace-pvz-warehouse',
component: markRaw(OperatorOwnWarehousePage),
beforeEnter: requireMarketplaceOperator,
meta: {
title: 'Склад моего КУ',
icon: 'fa-solid fa-boxes-stacked',
roles: ['chairman'],
roles: [],
requiresAuth: true,
agreements: agreementsBase,
},
@@ -0,0 +1,107 @@
import { defineStore } from 'pinia';
import { ref, watch } from 'vue';
import { Queries } from '@coopenomics/sdk';
import { client } from 'src/shared/api/client';
import { useSessionStore } from 'src/entities/Session';
// Pinia-store расширения «Стол заказов». Отделён от core-session: ядро
// не должно знать про marketplace-роли (orderer/offerer/operator/
// board_readonly/board/admin), это policy самого расширения. Хранилище
// заполняется только по запросу — например, при попытке войти на
// защищённый маршрут `/market-pvz/*` через `beforeEnter` в install.ts.
//
// Reset при логауте — через watch на session.isAuth и session.username:
// composition-setup выполняется один раз при первом useMarketSessionStore(),
// watch'и живут до выгрузки страницы и автоматически сбрасывают стор при
// session.close() или смене пайщика.
interface IMarketplaceWhoAmI {
username?: string;
core_roles?: string[];
marketplace_roles?: string[];
}
export const useMarketSessionStore = defineStore('market-session', () => {
const session = useSessionStore();
const marketplaceRoles = ref<string[]>([]);
const coreRoles = ref<string[]>([]);
const username = ref<string | null>(null);
const loaded = ref(false);
const inflight = ref<Promise<void> | null>(null);
function reset(): void {
marketplaceRoles.value = [];
coreRoles.value = [];
username.value = null;
loaded.value = false;
inflight.value = null;
}
async function fetchRoles(force = false): Promise<void> {
if (!session.isAuth) {
reset();
return;
}
if (loaded.value && !force) return;
if (inflight.value) {
await inflight.value;
return;
}
const task = (async () => {
try {
const res = (await client.Query(
Queries.Marketplace.WhoAmI.query,
{ variables: {} },
)) as Record<string, unknown>;
const data = res?.[Queries.Marketplace.WhoAmI.name] as
| IMarketplaceWhoAmI
| undefined;
username.value = data?.username ?? null;
coreRoles.value = data?.core_roles ?? [];
marketplaceRoles.value = data?.marketplace_roles ?? [];
loaded.value = true;
} catch (err) {
console.warn('[market-session] marketplaceWhoAmI failed:', err);
marketplaceRoles.value = [];
coreRoles.value = [];
username.value = null;
loaded.value = false;
} finally {
inflight.value = null;
}
})();
inflight.value = task;
await task;
}
function hasRole(role: string): boolean {
return marketplaceRoles.value.includes(role);
}
watch(
() => session.isAuth,
(isAuth) => {
if (!isAuth) reset();
},
);
watch(
() => session.username,
(next, prev) => {
if (next !== prev) reset();
},
);
return {
marketplaceRoles,
coreRoles,
username,
loaded,
fetchRoles,
hasRole,
reset,
};
});
@@ -128,7 +128,7 @@ div.page-shell
.text-body2(v-if='!processDetails.get(props.row.processHash)?.actions?.length') Нет событий
q-list(v-else dense)
q-item(
v-for='a in processDetails.get(props.row.processHash)!.actions'
v-for='a in (processDetails.get(props.row.processHash)?.actions ?? [])'
:key='a.global_sequence'
)
q-item-section
@@ -142,7 +142,7 @@ div.page-shell
.text-body2(v-if='!processDetails.get(props.row.processHash)?.documents?.length') Документы не приложены
q-list(v-else dense)
q-item(
v-for='d in processDetails.get(props.row.processHash)!.documents'
v-for='d in (processDetails.get(props.row.processHash)?.documents ?? [])'
:key='d.source.code + "::" + d.source.table + "::" + d.source.primary_key + "-" + d.hash'
)
q-item-section
+17 -15
View File
@@ -1,30 +1,32 @@
// Резервная конфигурация для PWA режима
// Используется когда SSR middleware недоступен
// Этот файл должен быть заменен на config.js при деплое
// Резервная конфигурация для SPA-dev режима (когда SSR middleware
// `generateConfig.ts` не запущен и /config.js отдаёт SPA fallback).
// Адреса соответствуют локальному стеку из docker-compose.yml: backend
// на :2998, EOSIO chain на :8888, mongo на :27047, postgres на :5532.
// В CI/prod этот файл переопределяется реальным /config.js, который
// генерирует SSR.
console.warn(
'Используется резервная конфигурация! Убедитесь, что config.js генерируется SSR middleware',
'Используется резервная конфигурация config.default.js! Убедитесь, что config.js генерируется SSR middleware в production.',
);
window.__APP_CONFIG__ = {
NODE_ENV: 'development',
BACKEND_URL: 'http://localhost:3000',
CHAIN_URL: 'http://localhost:8888',
CHAIN_ID: 'cae86058a6d8698833afb474ab8a5ad8599c6cf54f9ebcf275dbac7055c16fe1',
BACKEND_URL: 'http://127.0.0.1:2998',
CHAIN_URL: 'http://127.0.0.1:8888',
CHAIN_ID: 'db79c8409645082749ca50640d6f4ee511575acf26c4e2c8e4748e6bf6a01ed4',
CURRENCY: 'RUB',
COOP_SHORT_NAME: 'Цифровой Кооператив',
COOP_SHORT_NAME: 'DEV Кооператив',
SITE_DESCRIPTION: 'кооперативная экономика для сообществ и бизнеса',
SITE_IMAGE: 'https://ia.media-imdb.com/images/rock.jpg',
STORAGE_URL: 'http://localhost:3000/storage',
UPLOAD_URL: 'http://localhost:3000/upload',
STORAGE_URL: 'http://127.0.0.1:2998/storage',
UPLOAD_URL: 'http://127.0.0.1:2998/upload',
TIMEZONE: 'Europe/Moscow',
VUE_ROUTER_MODE: 'hash',
VUE_ROUTER_BASE: '/',
NOVU_APP_ID: '',
NOVU_APP_ID: 'BTaPV0bRL0dz',
NOVU_BACKEND_URL: 'https://novu.coopenomics.world/api',
NOVU_SOCKET_URL: 'https://novu.coopenomics.world/ws',
VAPID_PUBLIC_KEY: '',
NOVU_SOCKET_URL: 'https://novu.coopenomics.world',
VAPID_PUBLIC_KEY: 'BLomcBkzOF0jGYU_kfZ07-dhl6_euyZKGoZb-yKcP6vzNXvb49DQgce_7EyXhL4PPwS0MklVYQbq_mzJhFiLHaw',
SENTRY_DSN: '',
OPENREPLAY_PROJECT_KEY: '',
};
console.log('Резервная конфигурация загружена');
+1 -15
View File
@@ -149,21 +149,7 @@ module.exports = configure(function (ctx) {
},
// viteVuePluginOptions: {},
vitePlugins: [
[
'vite-plugin-checker',
{
vueTsc: {
tsconfigPath: 'tsconfig.vue-tsc.json',
},
eslint: {
lintCommand: 'eslint "./**/*.{js,ts,mjs,cjs,vue}"',
},
overlay: true,
},
{ server: false },
],
],
vitePlugins: [],
optimizeDeps: {
include: ['@dicebear/core', '@dicebear/collection'],
@@ -1,4 +1,4 @@
import type { Queries, Zeus } from '@coopenomics/sdk'
import type { Queries } from '@coopenomics/sdk'
export type IProcessView =
Queries.Processes.GetProcess.IOutput[typeof Queries.Processes.GetProcess.name]
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { MarketplaceWriteoffProposalView } from '../api';
const props = defineProps<{
defineProps<{
modelValue: boolean;
proposal: MarketplaceWriteoffProposalView;
}>();
@@ -0,0 +1 @@
export { default as BoardAgendaWriteoffPage } from './ui/BoardAgendaWriteoffPage.vue';
@@ -0,0 +1,191 @@
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { Notify } from 'quasar';
import {
listWriteoffProposals,
type MarketplaceWriteoffProposalsPageView,
} from '../../AdminWriteoffs/api';
/**
* Эпик 8 / Story 8.x: read-only лента писеof проектов для совета.
*
* Совет видит проекты в активных статусах (ON_AGENDA / AUTHORIZED /
* EXECUTING / EXECUTED / REJECTED). DRAFT'ы скрыты — это рабочая зона
* админа на странице «Проекты списания». Само голосование совета
* подключается через core soviet agenda (sov-flow), здесь только обзор.
*
* Backend: Queries.Marketplace.ListWriteoffProposals.
* Reuse helper listWriteoffProposals из AdminWriteoffs/api.
*
* Polling 30s — статусы меняются по решению совета (core sov-flow).
*/
type WriteoffStatus = 'ON_AGENDA' | 'AUTHORIZED' | 'EXECUTING' | 'EXECUTED' | 'REJECTED';
const POLL_INTERVAL_MS = 30_000;
const BOARD_STATUSES: WriteoffStatus[] = [
'ON_AGENDA',
'AUTHORIZED',
'EXECUTING',
'EXECUTED',
'REJECTED',
];
const STATUS_LABEL: Record<WriteoffStatus, string> = {
ON_AGENDA: 'На повестке',
AUTHORIZED: 'Одобрено',
EXECUTING: 'Исполняется',
EXECUTED: 'Исполнено',
REJECTED: 'Отклонено',
};
const STATUS_COLOR: Record<WriteoffStatus, string> = {
ON_AGENDA: 'warning',
AUTHORIZED: 'positive',
EXECUTING: 'info',
EXECUTED: 'positive',
REJECTED: 'negative',
};
const page = ref<MarketplaceWriteoffProposalsPageView | null>(null);
const loading = ref(false);
const statusFilter = ref<WriteoffStatus | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
const items = computed(() => page.value?.items ?? []);
const total = computed(() => page.value?.totalCount ?? 0);
async function load(): Promise<void> {
loading.value = true;
try {
const statuses = (statusFilter.value ? [statusFilter.value] : BOARD_STATUSES) as never;
page.value = await listWriteoffProposals(
{ statuses },
{ page: 1, limit: 100 },
);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message });
} finally {
loading.value = false;
}
}
function formatDate(value: string | null | undefined): string {
if (!value) return '—';
const d = new Date(value);
return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleString('ru-RU');
}
function changeFilter(value: WriteoffStatus | null): void {
statusFilter.value = value;
void load();
}
onMounted(async () => {
await load();
pollTimer = setInterval(() => {
void load();
}, POLL_INTERVAL_MS);
});
onUnmounted(() => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
});
</script>
<template lang="pug">
q-page.mp-role-admin.mp-board-writeoff(role="region", aria-label="Повестка совета — списания")
div.mp-board-writeoff__header
div
div.text-h5 Повестка совета проекты списания
div.text-caption.mp-board-writeoff__subtitle
| Проекты списания скоропорта, отправленные администратором в совет. Голосование совета проходит на core soviet agenda; здесь обзор статусов и принятых решений.
q-space
q-btn(flat, dense, round, icon="fa-solid fa-rotate", :loading="loading", @click="load", aria-label="Обновить")
q-tabs.mp-board-writeoff__tabs(
:model-value="statusFilter",
inline-label,
align="left",
dense,
no-caps,
@update:model-value="changeFilter"
)
q-tab(:name="null", label="Все")
q-tab(name="ON_AGENDA", label="На повестке")
q-tab(name="AUTHORIZED", label="Одобрены")
q-tab(name="EXECUTING", label="Исполняются")
q-tab(name="EXECUTED", label="Исполнены")
q-tab(name="REJECTED", label="Отклонены")
q-inner-loading(:showing="loading && items.length === 0")
q-spinner(color="primary", size="2em")
div.mp-board-writeoff__empty(v-if="!loading && items.length === 0")
q-icon(name="fa-solid fa-clipboard-check", size="48px", color="grey-5")
div.text-subtitle1.q-mt-md Совет ещё не получал проектов списания
div.text-caption Когда администратор отправит проект в совет, он появится здесь.
q-card(v-if="items.length > 0", flat, bordered)
q-list(separator)
q-item(v-for="p in items", :key="p.id")
q-item-section
q-item-label
| Проект {{ p.id.slice(0, 8) }}
| позиций: {{ p.items?.length ?? 0 }}
q-item-label(caption)
| Создан: {{ formatDate(p.created_at) }}
| / Триггер: {{ p.trigger }}
| / Сумма потерь:
| {{ p.total_loss_amount ?? '—' }}
q-item-section(side, top)
q-chip(
:color="STATUS_COLOR[p.status]",
text-color="white",
square,
dense
)
| {{ STATUS_LABEL[p.status] }}
div.mp-board-writeoff__counter
| Всего на повестке: {{ total }}
</template>
<style scoped lang="scss">
.mp-board-writeoff {
padding: var(--mp-space-lg);
display: flex;
flex-direction: column;
gap: var(--mp-space-md);
&__header {
display: flex;
align-items: flex-start;
gap: var(--mp-space-md);
}
&__subtitle {
color: var(--mp-on-surface-muted);
max-width: 720px;
}
&__counter {
color: var(--mp-on-surface-muted);
text-align: right;
margin-top: var(--mp-space-xs);
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--mp-space-xl) 0;
color: var(--mp-on-surface-muted);
}
}
</style>
@@ -0,0 +1 @@
export { default as BoardPayoutsReadonlyPage } from './ui/BoardPayoutsReadonlyPage.vue';
@@ -0,0 +1,70 @@
<script lang="ts" setup>
/**
* Эпик 5 / Story 5.x: read-only лента выплат поставщикам для совета.
*
* Текущий backend (`marketplaceListOutgoingPaymentsAsSupplier`) фильтрует
* по `member.username` — каждый поставщик видит только свои выплаты.
* Для совета (видит выплаты всех поставщиков) нужно дополнить:
* 1. Добавить `Payment` policy в marketplace-access-matrix.ts с
* разрешением `read:all` для роли committee/admin.
* 2. Новая query `marketplaceListOutgoingPayments` с фильтром
* `supplier_account?` (опциональный) под `read:all`.
* 3. SDK wrapper Queries.Marketplace.ListOutgoingPayments.
*
* До этого — страница informational. Совету пока доступны выплаты через
* core cassir-стол (общий стол кассира кооператива), который видит всю
* историю outgoing payments кооператива.
*/
</script>
<template lang="pug">
q-page.mp-role-admin.mp-board-payouts(role="region", aria-label="Выплаты поставщикам — совет")
div.text-h5 Выплаты поставщикам обзор совета
q-banner.q-mt-md(rounded, class="bg-amber-1 text-amber-9")
template(#avatar)
q-icon(name="fa-solid fa-screwdriver-wrench", color="amber-9")
div.text-subtitle2 Раздел в подготовке
div.text-body2
| Совет пока не имеет собственного среза по выплатам текущий backend marketplace отдаёт только историю текущего пользователя как поставщика. Чтобы открыть обзор по всем поставщикам кооператива, нужно расширить marketplace-access-matrix новой politykой
strong Payment / read:all
| , добавить query
strong marketplaceListOutgoingPayments
| с опциональным supplier_account и сгенерировать SDK wrapper.
div.text-body2.q-mt-sm
| До этого выплаты доступны через
strong core cassir-стол
| кооператива (общий стол кассира), который видит outgoing-payments всех расширений сразу.
q-card.q-mt-md(flat, bordered)
q-card-section
div.text-subtitle1 Что увидит совет, когда раздел будет готов
ul.q-pl-md
li Ленту всех PENDING / CONFIRMED / FAILED / RETRY выплат за период.
li Сумму выплаты, поставщика, заказ-источник, дату выплаты.
li Фильтр по статусу + per-supplier breakdown.
li Read-only само подтверждение делает кассир, не совет.
q-separator
q-card-section
div.text-subtitle1 Связанные документы
ul.q-pl-md
li
strong PRD MVP:
| /home/admin/blago/production/1-prilozhenie-stol-zakazov/_bmad-output/planning-artifacts/prd.md
li
strong Резолвер сейчас:
| components/controller/src/extensions/marketplace/application/resolvers/marketplace-outgoing-payment.resolver.ts
li
strong Access matrix:
| components/controller/src/extensions/marketplace/application/access/marketplace-access-matrix.ts
</template>
<style scoped lang="scss">
.mp-board-payouts {
padding: var(--mp-space-lg);
display: flex;
flex-direction: column;
gap: var(--mp-space-md);
}
</style>
@@ -0,0 +1 @@
export { default as BranchChairmanBranchOrdersPage } from './ui/BranchChairmanBranchOrdersPage.vue';
@@ -0,0 +1,226 @@
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { Notify } from 'quasar';
import { listAplReceptionsByBraname } from '../../OperatorReception/api';
import { listIssuancesByBraname } from '../../OperatorIssuance/api';
import { listReturnClaimsByBraname } from '../../OperatorReturnClaims/api';
import type { MarketplaceAplReceptionView } from '../../OffererPendingAplReceptions/api';
import type { MarketplaceOrderIssuanceView } from '../../OperatorIssuance/api';
import type { MarketplaceReturnClaimView } from '../../OperatorReturnClaims/api';
/**
* Эпик 6 / Story 6.x: «Сводный стол КУ» председателя кооперативного участка.
*
* Объединяет три существующих ленты по braname в один экран с табами:
* - Приёмки партий (ListAplReceptionsByBraname, Эпик 5)
* - Выдачи заказов (ListIssuancesByBraname, Эпик 6)
* - Гарантийные возвраты (ListReturnClaimsByBraname, Эпик 7)
*
* Действия (открыть акт приёмки, выдачу, решение по возврату) выполняются
* на соответствующих специализированных страницах /market-pvz/reception,
* /issuance, /returns — здесь только сводный read-обзор для председателя КУ.
*
* Поле braname — пока ручной ввод; в Story 6.x+1 подключится auto-detect
* из marketplace_whoami / marketplace_member_wallet (председатель привязан
* к одному КУ через trustee).
*/
const POLL_INTERVAL_MS = 20_000;
const braname = ref<string>('');
const activeTab = ref<'receptions' | 'issuances' | 'returns'>('receptions');
const receptions = ref<MarketplaceAplReceptionView[]>([]);
const issuances = ref<MarketplaceOrderIssuanceView[]>([]);
const returns = ref<MarketplaceReturnClaimView[]>([]);
const loading = ref(false);
let pollTimer: ReturnType<typeof setInterval> | null = null;
const totals = computed(() => ({
receptions: receptions.value.length,
issuances: issuances.value.length,
returns: returns.value.length,
}));
const hasBranch = computed(() => braname.value.trim().length > 0);
async function loadAll(): Promise<void> {
const name = braname.value.trim();
if (!name) return;
loading.value = true;
try {
const [r, i, ret] = await Promise.all([
listAplReceptionsByBraname(name),
listIssuancesByBraname(name),
listReturnClaimsByBraname({ delivery_braname: name }),
]);
receptions.value = r;
issuances.value = i;
returns.value = ret;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message });
} finally {
loading.value = false;
}
}
watch(braname, () => {
receptions.value = [];
issuances.value = [];
returns.value = [];
});
onMounted(() => {
pollTimer = setInterval(() => {
if (hasBranch.value) void loadAll();
}, POLL_INTERVAL_MS);
});
onUnmounted(() => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
});
function formatDate(value: string | null | undefined): string {
if (!value) return '—';
const d = new Date(value);
return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleString('ru-RU');
}
</script>
<template lang="pug">
q-page.mp-role-operator.mp-branch-orders(role="region", aria-label="Сводный стол КУ")
div.mp-branch-orders__header
div
div.text-h5 Сводный стол кооперативного участка
div.text-caption.mp-branch-orders__subtitle
| Все процессы вашего КУ в одном месте: приёмки партий, выдачи пайщикам, гарантийные возвраты. Для действий переходите на специализированные столы Стола ПВЗ.
q-card(flat, bordered)
q-card-section
div.row.q-col-gutter-md.items-end
div.col-12.col-md-8
q-input(
v-model="braname",
outlined,
dense,
clearable,
label="ID кооперативного участка (braname)",
hint="Введите код вашего КУ — например voskhod-kpvz",
@keyup.enter="loadAll"
)
template(#prepend)
q-icon(name="fa-solid fa-warehouse")
div.col-12.col-md-4
q-btn(
unelevated,
no-caps,
color="primary",
icon="fa-solid fa-magnifying-glass",
label="Загрузить",
:loading="loading",
:disable="!hasBranch",
@click="loadAll"
)
q-card(v-if="hasBranch", flat, bordered)
q-tabs(
v-model="activeTab",
inline-label,
align="left",
no-caps,
dense,
indicator-color="primary",
active-color="primary"
)
q-tab(name="receptions", icon="fa-solid fa-truck-ramp-box")
| Приёмки
q-badge.q-ml-sm(color="primary", :label="String(totals.receptions)")
q-tab(name="issuances", icon="fa-solid fa-handshake")
| Выдачи
q-badge.q-ml-sm(color="primary", :label="String(totals.issuances)")
q-tab(name="returns", icon="fa-solid fa-clipboard-check")
| Возвраты
q-badge.q-ml-sm(color="primary", :label="String(totals.returns)")
q-separator
q-tab-panels(v-model="activeTab", animated)
q-tab-panel(name="receptions")
div.mp-branch-orders__empty(v-if="!loading && receptions.length === 0")
q-icon(name="fa-solid fa-box-open", size="40px", color="grey-5")
div.text-subtitle1.q-mt-sm Нет активных приёмок
div.text-caption Партии в статусе CONFIRMED появятся здесь после подтверждения поставщиком.
q-list(v-else, separator)
q-item(v-for="r in receptions", :key="r.id")
q-item-section
q-item-label {{ r.id.slice(0, 8) }} партия {{ r.shipment_id?.slice?.(0, 8) ?? '—' }}
q-item-label(caption) Создана: {{ formatDate(r.created_at) }} / Статус: {{ r.status ?? '—' }}
q-tab-panel(name="issuances")
div.mp-branch-orders__empty(v-if="!loading && issuances.length === 0")
q-icon(name="fa-solid fa-people-carry-box", size="40px", color="grey-5")
div.text-subtitle1.q-mt-sm Нет заказов на выдачу
div.text-caption Заказы в статусе ACCEPTED_TO_COOP / READY_TO_RECEIVE появятся здесь после маркировки на ПВЗ.
q-list(v-else, separator)
q-item(v-for="o in issuances", :key="o.id")
q-item-section
q-item-label {{ o.id.slice(0, 8) }} {{ o.orderer_account }}
q-item-label(caption)
| Кол-во: {{ o.quantity }} ед. / Статус: {{ o.status }} / Создан: {{ formatDate(o.created_at) }}
q-tab-panel(name="returns")
div.mp-branch-orders__empty(v-if="!loading && returns.length === 0")
q-icon(name="fa-solid fa-clipboard-check", size="40px", color="grey-5")
div.text-subtitle1.q-mt-sm Нет заявлений на возврат
div.text-caption Заявления пайщиков в гарантийный период появятся здесь.
q-list(v-else, separator)
q-item(v-for="c in returns", :key="c.id")
q-item-section
q-item-label {{ c.id.slice(0, 8) }} {{ c.orderer_account }}
q-item-label(caption) Статус: {{ c.status }} / Создано: {{ formatDate(c.created_at) }}
div.mp-branch-orders__hint(v-if="!hasBranch")
q-icon(name="fa-solid fa-circle-info", color="primary")
| Введите ID вашего КУ, чтобы увидеть сводку процессов.
</template>
<style scoped lang="scss">
.mp-branch-orders {
padding: var(--mp-space-lg);
display: flex;
flex-direction: column;
gap: var(--mp-space-md);
&__header {
display: flex;
align-items: flex-start;
gap: var(--mp-space-md);
}
&__subtitle {
color: var(--mp-on-surface-muted);
max-width: 720px;
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--mp-space-lg) 0;
color: var(--mp-on-surface-muted);
}
&__hint {
display: flex;
align-items: center;
gap: var(--mp-space-xs);
color: var(--mp-on-surface-muted);
padding: var(--mp-space-md) 0;
}
}
</style>
@@ -0,0 +1,44 @@
import { Mutations, Queries } from '@coopenomics/sdk';
import { client } from 'src/shared/api/client';
/**
* Эпик 3 / Story 3.x: admin-настройка whitelist'а доступных категорий.
* Backend Resolver: available-category-admin.resolver.ts → @AuthRoles(['chairman']).
*
* Reuse 4 endpoint'а: Get, Stats, Add, Remove. Tree-выбор подключается
* на следующем шаге (нужен dialog с marketplaceGetCategoryTree).
*/
export type MarketplaceAvailableCategoryView =
Queries.Marketplace.GetAvailableCategories.IOutput['marketplaceGetAvailableCategories'][number];
export type MarketplaceAvailabilityStatsView =
Queries.Marketplace.GetAvailabilityStats.IOutput['marketplaceGetAvailabilityStats'];
export async function fetchAvailableCategories(): Promise<MarketplaceAvailableCategoryView[]> {
const { [Queries.Marketplace.GetAvailableCategories.name]: result } = await client.Query(
Queries.Marketplace.GetAvailableCategories.query,
{},
);
return result;
}
export async function fetchAvailabilityStats(): Promise<MarketplaceAvailabilityStatsView> {
const { [Queries.Marketplace.GetAvailabilityStats.name]: result } = await client.Query(
Queries.Marketplace.GetAvailabilityStats.query,
{},
);
return result;
}
export async function addAvailableCategories(categoryIds: number[]): Promise<void> {
await client.Mutation(Mutations.Marketplace.AddAvailableCategories.mutation, {
variables: { input: { categoryIds } },
});
}
export async function removeAvailableCategories(categoryIds: number[]): Promise<void> {
await client.Mutation(Mutations.Marketplace.RemoveAvailableCategories.mutation, {
variables: { input: { categoryIds } },
});
}
@@ -0,0 +1 @@
export { default as ChairmanCategoryWhitelistPage } from './ui/ChairmanCategoryWhitelistPage.vue';
@@ -0,0 +1,204 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { Dialog, Notify } from 'quasar';
import {
addAvailableCategories,
fetchAvailabilityStats,
fetchAvailableCategories,
removeAvailableCategories,
type MarketplaceAvailabilityStatsView,
type MarketplaceAvailableCategoryView,
} from '../api';
/**
* Эпик 3 / Story 3.x: «Доступные категории» — настройка whitelist'а
* категорий товаров для кооператива (chairman-only).
*
* MVP: список разрешённых категорий (entire / specific type), статистика
* и удаление. Добавление — через диалог с ручным вводом ID (tree-выбор
* через `marketplaceGetCategoryTree` подключится на следующем шаге).
*
* Без whitelist'а — кооператив видит весь глобальный каталог.
* С whitelist'ом — только перечисленные категории доступны для
* публикации Offer'ов.
*/
const items = ref<MarketplaceAvailableCategoryView[]>([]);
const stats = ref<MarketplaceAvailabilityStatsView | null>(null);
const loading = ref(false);
const entireCategories = computed(() =>
items.value.filter((i) => i.isForEntireCategory),
);
const specificTypes = computed(() => items.value.filter((i) => i.isForSpecificType));
async function load(): Promise<void> {
loading.value = true;
try {
const [list, st] = await Promise.all([
fetchAvailableCategories(),
fetchAvailabilityStats(),
]);
items.value = list;
stats.value = st;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message });
} finally {
loading.value = false;
}
}
function onAdd(): void {
Dialog.create({
title: 'Добавить категории в whitelist',
message:
'Введите ID категорий через запятую (например: 1, 7, 15). Tree-выбор подключится на следующем шаге Story 3.x.',
prompt: {
model: '',
type: 'text',
isValid: (v: string) => /^\s*\d+(\s*,\s*\d+)*\s*$/.test(v),
},
cancel: { label: 'Отмена', flat: true },
ok: { label: 'Добавить', color: 'primary', unelevated: true },
persistent: true,
}).onOk(async (raw: string) => {
const ids = raw
.split(',')
.map((s) => Number(s.trim()))
.filter((n) => Number.isFinite(n));
if (ids.length === 0) return;
try {
await addAvailableCategories(ids);
Notify.create({ type: 'positive', message: `Добавлено категорий: ${ids.length}` });
await load();
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message });
}
});
}
function onRemove(item: MarketplaceAvailableCategoryView): void {
Dialog.create({
title: 'Удалить из whitelist?',
message: `Категория ID ${item.categoryId} будет удалена из доступных. Существующие Offer'ы не пропадут, но новые публиковать в этой категории станет нельзя.`,
cancel: { label: 'Отмена', flat: true },
ok: { label: 'Удалить', color: 'negative', unelevated: true },
persistent: true,
}).onOk(async () => {
try {
await removeAvailableCategories([item.categoryId]);
Notify.create({ type: 'positive', message: 'Категория удалена из whitelist' });
await load();
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message });
}
});
}
onMounted(load);
</script>
<template lang="pug">
q-page.mp-role-admin.mp-category-whitelist(role="region", aria-label="Доступные категории кооператива")
div.mp-category-whitelist__header
div
div.text-h5 Доступные категории
div.text-caption.mp-category-whitelist__subtitle
| Whitelist категорий товаров, которые пайщики могут публиковать как Offer'ы. Пустой whitelist означает, что доступен весь глобальный каталог.
q-space
q-btn(
unelevated,
no-caps,
color="primary",
icon="fa-solid fa-plus",
label="Добавить",
@click="onAdd"
)
q-btn(flat, dense, round, icon="fa-solid fa-rotate", :loading="loading", @click="load", aria-label="Обновить")
div.row.q-col-gutter-md(v-if="stats")
div.col-6.col-md-3
q-card(flat, bordered)
q-card-section
div.text-caption Всего записей
div.text-h6 {{ stats.totalAvailable }}
div.col-6.col-md-3
q-card(flat, bordered)
q-card-section
div.text-caption Категорий
div.text-h6 {{ stats.categoriesCount }}
div.col-6.col-md-3
q-card(flat, bordered)
q-card-section
div.text-caption Типов товаров
div.text-h6 {{ stats.typesCount }}
div.col-6.col-md-3
q-card(flat, bordered)
q-card-section
div.text-caption Whitelist активен
div.text-h6
span(v-if="stats.hasRestrictions", class="text-positive") Да
span(v-else, class="text-grey-7") Нет (открыт каталог)
q-inner-loading(:showing="loading && items.length === 0")
q-spinner(color="primary", size="2em")
div.mp-category-whitelist__empty(v-if="!loading && items.length === 0")
q-icon(name="fa-solid fa-folder-open", size="48px", color="grey-5")
div.text-subtitle1.q-mt-md Whitelist пуст
div.text-caption Пайщикам доступен весь глобальный каталог. Чтобы ограничить — нажмите «Добавить».
q-card(v-if="entireCategories.length > 0", flat, bordered)
q-card-section
div.text-subtitle1.q-mb-sm Целые категории
q-separator
q-list(separator)
q-item(v-for="c in entireCategories", :key="c.id")
q-item-section
q-item-label Категория № {{ c.categoryId }}
q-item-label(caption) Добавлено: {{ c.addedBy }}, активна: {{ c.isActive ? 'да' : 'нет' }}
q-item-section(side)
q-btn(flat, dense, no-caps, color="negative", icon="fa-solid fa-trash", label="Убрать", @click="onRemove(c)")
q-card(v-if="specificTypes.length > 0", flat, bordered)
q-card-section
div.text-subtitle1.q-mb-sm Конкретные типы товаров
q-separator
q-list(separator)
q-item(v-for="t in specificTypes", :key="t.id")
q-item-section
q-item-label Категория № {{ t.categoryId }} → тип № {{ t.typeId ?? '' }}
q-item-label(caption) Добавлено: {{ t.addedBy }}, активна: {{ t.isActive ? 'да' : 'нет' }}
</template>
<style scoped lang="scss">
.mp-category-whitelist {
padding: var(--mp-space-lg);
display: flex;
flex-direction: column;
gap: var(--mp-space-md);
&__header {
display: flex;
align-items: flex-start;
gap: var(--mp-space-md);
}
&__subtitle {
color: var(--mp-on-surface-muted);
max-width: 720px;
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--mp-space-xl) 0;
color: var(--mp-on-surface-muted);
}
}
</style>
@@ -0,0 +1,57 @@
import { Mutations, Queries } from '@coopenomics/sdk';
import { client } from 'src/shared/api/client';
/**
* Эпик 3 / модерация offer'ов: типизированные запросы через SDK Zeus.
* Backend Resolver: `marketplace-moderation.resolver.ts`
* (Query.marketplaceListPendingOffers + Mutation.marketplaceApproveOffer).
*/
export type MarketplaceOfferPage =
Queries.Marketplace.ListPendingOffers.IOutput['marketplaceListPendingOffers'];
export type MarketplacePendingOfferView = MarketplaceOfferPage['items'][number];
export type MarketplaceModerationLogEntryView =
Queries.Marketplace.ListModerationLog.IOutput['marketplaceListModerationLog'];
export interface ListPendingOffersVariables {
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
export async function fetchPendingOffers(
variables: ListPendingOffersVariables = {},
): Promise<MarketplaceOfferPage> {
const input = {
page: variables.page ?? 1,
limit: variables.limit ?? 24,
sortBy: variables.sortBy ?? 'created_at',
sortOrder: variables.sortOrder ?? 'DESC',
};
const { [Queries.Marketplace.ListPendingOffers.name]: page } = await client.Query(
Queries.Marketplace.ListPendingOffers.query,
{ variables: { input } },
);
return page;
}
export async function approveOffer(offer_id: string): Promise<MarketplacePendingOfferView> {
const { [Mutations.Marketplace.ApproveOffer.name]: result } = await client.Mutation(
Mutations.Marketplace.ApproveOffer.mutation,
{ variables: { input: { offer_id } } },
);
return result;
}
export async function fetchModerationLog(
offer_id: string,
): Promise<MarketplaceModerationLogEntryView> {
const { [Queries.Marketplace.ListModerationLog.name]: result } = await client.Query(
Queries.Marketplace.ListModerationLog.query,
{ variables: { offer_id } },
);
return result;
}
@@ -0,0 +1,2 @@
export { ChairmanModerationPage } from './ui';
export * from './api';
@@ -0,0 +1,176 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { Dialog, Notify } from 'quasar';
import {
CatalogOfferCard,
type CatalogOffer,
} from 'src/widgets/Marketplace/CatalogOfferCard';
import {
approveOffer,
fetchPendingOffers,
type MarketplacePendingOfferView,
} from '../api';
/**
* Эпик 3 / Story 3.6: модерация offer'ов председателем кооператива.
*
* Лента offer'ов в статусе PENDING_MODERATION. По «Одобрить» вызывается
* `marketplaceApproveOffer`, статус становится APPROVED, offer пропадает из
* ленты (фильтр на бэкенде) и появляется в публичном каталоге Story 3.5.
*
* Канон UI — `widgets/Marketplace/CatalogOfferCard` (UX-DR10) с per-card
* action-кнопкой «Одобрить» через slot `actions`. Корневой класс
* `mp-role-admin` подтягивает токены admin-стола из `marketplace-tokens.scss`.
*/
const PAGE_SIZE = 24;
const items = ref<MarketplacePendingOfferView[]>([]);
const total = ref(0);
const loading = ref(false);
const approving = ref<Set<string>>(new Set());
const currentPage = ref(1);
const hasMore = computed(() => items.value.length < total.value);
const UNIT_LABEL: Record<MarketplacePendingOfferView['unit_of_measure'], string> = {
piece: 'шт',
kg: 'кг',
liter: 'л',
pack: 'упак',
};
function toCatalogOffer(offer: MarketplacePendingOfferView): CatalogOffer {
return {
id: offer.id,
title: offer.product_name,
description: offer.description ?? undefined,
remainUnits: offer.unlimited_flag ? undefined : offer.quantity_available,
unitCost: offer.price_per_unit,
unitLabel: UNIT_LABEL[offer.unit_of_measure],
status: 'moderation',
};
}
async function loadPage(append: boolean): Promise<void> {
loading.value = true;
try {
const page = await fetchPendingOffers({
page: currentPage.value,
limit: PAGE_SIZE,
});
total.value = page.totalCount;
items.value = append ? items.value.concat(page.items) : page.items;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message });
} finally {
loading.value = false;
}
}
async function onLoadMore(): Promise<void> {
if (!hasMore.value || loading.value) return;
currentPage.value += 1;
await loadPage(true);
}
function onApprove(offer: MarketplacePendingOfferView): void {
Dialog.create({
title: 'Одобрить предложение?',
message: `«${offer.product_name}» появится в публичном каталоге кооператива.`,
ok: { label: 'Одобрить', color: 'primary', unelevated: true, noCaps: true },
cancel: { label: 'Отмена', flat: true, noCaps: true },
persistent: true,
}).onOk(async () => {
approving.value.add(offer.id);
try {
await approveOffer(offer.id);
items.value = items.value.filter((o) => o.id !== offer.id);
total.value = Math.max(0, total.value - 1);
Notify.create({
type: 'positive',
message: `Предложение «${offer.product_name}» одобрено`,
});
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message });
} finally {
approving.value.delete(offer.id);
}
});
}
onMounted(async () => {
await loadPage(false);
});
</script>
<template lang="pug">
q-page.mp-role-admin.mp-moderation-page(role="region", aria-label="Модерация предложений")
div.mp-moderation-page__header
div
div.text-h5 Модерация предложений
div.text-caption.mp-moderation-page__subtitle
| Предложения поставщиков ожидают вашего одобрения. После «Одобрить» товар появится в публичном каталоге кооператива.
q-space
q-chip(outline, color="warning")
q-icon(name="fa-solid fa-hourglass-half", left)
| На модерации: {{ total }}
q-inner-loading(:showing="loading && items.length === 0")
q-spinner(color="primary", size="2em")
div.mp-moderation-page__empty(v-if="!loading && items.length === 0")
q-icon(name="fa-regular fa-circle-check", size="3em", color="positive")
div.text-subtitle1 Очередь модерации пуста
div.text-caption Все предложения поставщиков рассмотрены
q-infinite-scroll(@load="onLoadMore", :disable="!hasMore || loading")
div.row.q-col-gutter-md
div.col-12.col-sm-6.col-md-4.col-lg-3(v-for="o in items", :key="o.id")
CatalogOfferCard(:offer="toCatalogOffer(o)")
template(v-slot:actions)
q-btn(
unelevated,
dense,
no-caps,
color="primary",
icon="fa-solid fa-check",
label="Одобрить",
:loading="approving.has(o.id)",
@click.stop="onApprove(o)"
)
template(v-slot:loading)
div.row.justify-center.q-my-md
q-spinner(color="primary", size="2em")
</template>
<style scoped lang="scss">
.mp-moderation-page {
padding: var(--mp-space-lg);
display: flex;
flex-direction: column;
gap: var(--mp-space-md);
&__header {
display: flex;
align-items: flex-start;
gap: var(--mp-space-md);
}
&__subtitle {
color: var(--mp-on-surface-muted);
max-width: 640px;
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--mp-space-xs);
padding: var(--mp-space-xxl) var(--mp-space-md);
color: var(--mp-on-surface-muted);
}
}
</style>
@@ -0,0 +1 @@
export { default as ChairmanModerationPage } from './ChairmanModerationPage.vue';
@@ -1,10 +1,12 @@
import { Queries } from '@coopenomics/sdk';
import { Mutations, Queries } from '@coopenomics/sdk';
import { client } from 'src/shared/api/client';
import type {
BranchOption,
CatalogSort,
MarketplaceCategoryOfferCount,
MarketplaceCategoryView,
MarketplaceOfferPage,
MarketplaceOrderCreated,
} from '../types';
/**
@@ -66,3 +68,27 @@ export async function fetchCategoryOfferCounts(): Promise<MarketplaceCategoryOff
);
return list;
}
export async function fetchBranchOptions(coopname: string): Promise<BranchOption[]> {
const { [Queries.Marketplace.ListKUDetails.name]: list } = await client.Query(
Queries.Marketplace.ListKUDetails.query,
{ variables: { data: { coopname, onlyActive: true } } },
);
return (list ?? []).map((d) => ({
braname: d.coreBraname,
short_name: d.addressFull || d.coreBraname,
city: null,
}));
}
export async function submitCreateOrder(input: {
offer_id: string;
quantity: number;
delivery_braname: string;
}): Promise<MarketplaceOrderCreated> {
const { [Mutations.Marketplace.CreateOrder.name]: result } = await client.Mutation(
Mutations.Marketplace.CreateOrder.mutation,
{ variables: { input } },
);
return result;
}
@@ -2,7 +2,7 @@
* Story 3.5: типы каталога Стола заказов — берутся из SDK Zeus IOutput
* соответствующих query, чтобы избежать ручного дублирования backend-схемы.
*/
import type { Queries } from '@coopenomics/sdk';
import type { Mutations, Queries } from '@coopenomics/sdk';
export type MarketplaceOfferPage =
Queries.Marketplace.ListCatalog.IOutput['marketplaceListCatalog'];
@@ -15,6 +15,15 @@ export type MarketplaceCategoryView =
export type MarketplaceCategoryOfferCount =
Queries.Marketplace.CategoryOfferCounts.IOutput['marketplaceCategoryOfferCounts'][number];
export type MarketplaceOrderCreated =
Mutations.Marketplace.CreateOrder.IOutput['marketplaceCreateOrder'];
export interface BranchOption {
braname: string;
short_name: string;
city: string | null;
}
export type CatalogSort = 'created_at_desc' | 'price_asc' | 'price_desc';
export interface CatalogFilter {
@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { Notify } from 'quasar';
import {
CatalogOfferCard,
@@ -17,6 +18,7 @@ import type {
MarketplaceCategoryView,
MarketplaceOfferView,
} from '../types';
import OrderCreateDialog from './OrderCreateDialog.vue';
/**
* Story 3.5: каталог Стола заказов на orderer-столе.
@@ -121,12 +123,19 @@ async function onLoadMore(): Promise<void> {
await loadPage(true);
}
const route = useRoute();
const coopname = computed(() => String(route.params.coopname ?? ''));
const orderDialogOpen = ref(false);
const orderDialogOffer = ref<MarketplaceOfferView | null>(null);
function onSelectOffer(offer: MarketplaceOfferView): void {
// Story 4.1 Order-форма; пока — заглушка
Notify.create({
type: 'info',
message: `Создание заказа на Offer ${offer.id} — будет доступно после Эпика 4`,
});
if (!canOrder(offer)) return;
orderDialogOffer.value = offer;
orderDialogOpen.value = true;
}
async function onOrderCreated(): Promise<void> {
await loadPage(false);
}
onMounted(async () => {
@@ -205,6 +214,13 @@ q-page.mp-role-orderer.mp-catalog-page(role="region", aria-label="Каталог
template(v-slot:loading)
div.row.justify-center.q-my-md
q-spinner(color="primary", size="2em")
OrderCreateDialog(
v-model="orderDialogOpen",
:coopname="coopname",
:offer="orderDialogOffer",
@created="onOrderCreated"
)
</template>
<style scoped lang="scss">
@@ -0,0 +1,177 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { Notify } from 'quasar';
import { fetchBranchOptions, submitCreateOrder } from '../api';
import type { BranchOption, MarketplaceOfferView } from '../types';
const props = defineProps<{
modelValue: boolean;
coopname: string;
offer: MarketplaceOfferView | null;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'created'): void;
}>();
const open = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v),
});
const UNIT_LABEL: Record<MarketplaceOfferView['unit_of_measure'], string> = {
piece: 'шт',
kg: 'кг',
liter: 'л',
pack: 'упак',
};
const quantity = ref<number>(1);
const branch = ref<string | null>(null);
const branches = ref<BranchOption[]>([]);
const loadingBranches = ref<boolean>(false);
const submitting = ref<boolean>(false);
const unitLabel = computed(() =>
props.offer ? UNIT_LABEL[props.offer.unit_of_measure] : '',
);
const maxQuantity = computed(() => {
if (!props.offer) return null;
if (props.offer.unlimited_flag) return null;
return props.offer.quantity_available;
});
const totalSum = computed(() => {
if (!props.offer) return 0;
const q = Number(quantity.value) || 0;
return q * Number(props.offer.price_per_unit);
});
const branchOptions = computed(() =>
branches.value.map((b) => ({
label: b.city ? `${b.short_name}${b.city}` : b.short_name,
value: b.braname,
})),
);
const canSubmit = computed(() => {
if (!props.offer || !branch.value) return false;
const q = Number(quantity.value);
if (!Number.isInteger(q) || q < 1) return false;
if (maxQuantity.value !== null && q > maxQuantity.value) return false;
return true;
});
watch(
() => props.modelValue,
async (v) => {
if (!v) return;
quantity.value = 1;
branch.value = null;
if (branches.value.length === 0) {
loadingBranches.value = true;
try {
branches.value = await fetchBranchOptions(props.coopname);
if (branches.value.length === 1) {
branch.value = branches.value[0]!.braname;
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message: `Не удалось загрузить ПВЗ: ${message}` });
} finally {
loadingBranches.value = false;
}
} else if (branches.value.length === 1) {
branch.value = branches.value[0]!.braname;
}
},
);
function extractErrorMessage(e: unknown): string {
if (e instanceof Error && e.message) return e.message;
if (Array.isArray(e) && e.length > 0) return extractErrorMessage(e[0]);
if (e && typeof e === 'object') {
const err = e as Record<string, unknown>;
if (typeof err.message === 'string') return err.message;
const gql = err.graphQLErrors;
if (Array.isArray(gql) && gql.length > 0) return extractErrorMessage(gql[0]);
const resp = err.response as Record<string, unknown> | undefined;
if (resp && Array.isArray(resp.errors) && resp.errors.length > 0) {
return extractErrorMessage(resp.errors[0]);
}
}
try {
return JSON.stringify(e);
} catch {
return String(e);
}
}
async function onSubmit(): Promise<void> {
if (!props.offer || !branch.value) return;
submitting.value = true;
try {
await submitCreateOrder({
offer_id: props.offer.id,
quantity: Number(quantity.value),
delivery_braname: branch.value,
});
Notify.create({ type: 'positive', message: 'Заказ создан' });
emit('created');
open.value = false;
} catch (e) {
Notify.create({ type: 'negative', message: extractErrorMessage(e) });
} finally {
submitting.value = false;
}
}
</script>
<template lang="pug">
q-dialog(v-model="open", :persistent="submitting")
q-card.mp-order-create-dialog(style="min-width: 360px; max-width: 480px")
q-card-section
div.text-h6 Оформление заказа
div.text-subtitle2.text-grey-7(v-if="offer") {{ offer.product_name }}
q-separator
q-card-section.q-gutter-md
q-input(
v-model.number="quantity",
type="number",
:min="1",
:max="maxQuantity ?? undefined",
:label="`Количество (${unitLabel})`",
:hint="maxQuantity !== null ? `Доступно: ${maxQuantity} ${unitLabel}` : 'Без ограничения остатка'",
outlined,
dense
)
q-select(
v-model="branch",
:options="branchOptions",
option-value="value",
option-label="label",
emit-value,
map-options,
outlined,
dense,
label="ПВЗ доставки",
:loading="loadingBranches",
:disable="branchOptions.length === 0"
)
div.text-body2(v-if="offer")
| Цена: {{ Number(offer.price_per_unit).toLocaleString('ru-RU') }} за {{ unitLabel }}
div.text-h6.text-primary(v-if="offer")
| Итого: {{ totalSum.toLocaleString('ru-RU') }}
q-card-actions(align="right")
q-btn(flat, label="Отмена", :disable="submitting", @click="open = false")
q-btn(
unelevated,
color="primary",
label="Подтвердить заказ",
:disable="!canSubmit",
:loading="submitting",
@click="onSubmit"
)
</template>
@@ -0,0 +1,59 @@
import { Mutations, Queries } from '@coopenomics/sdk';
import { client } from 'src/shared/api/client';
import type {
MarketplaceOrderPage,
MarketplaceOrderStatusView,
} from '../../MyOrders/types';
/**
* Эпик 4 / Story 4.5: incoming orders для поставщика.
* Backend Resolver: marketplace-order.resolver.ts → marketplaceListSupplierOrders.
* Read policy: 'Order' 'read:to-self' (RequireMarketplaceAccess).
*
* Возвращает заказы, где supplier_account = текущий пайщик. Reused
* MarketplaceOrderView из MyOrders/types (тот же DTO, только фильтр другой).
*/
export interface ListSupplierOrdersVariables {
statuses?: MarketplaceOrderStatusView[];
orderer_account?: string;
offer_id?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
export async function fetchSupplierOrders(
variables: ListSupplierOrdersVariables = {},
): Promise<MarketplaceOrderPage> {
const { page, limit, sortBy, sortOrder, statuses, orderer_account, offer_id } = variables;
const result = await client.Query(Queries.Marketplace.ListSupplierOrders.query, {
variables: {
input: {
orderer_account,
offer_id,
statuses,
},
options: {
page: page ?? 1,
limit: limit ?? 50,
sortBy: sortBy ?? 'updated_at',
sortOrder: sortOrder ?? 'DESC',
},
},
});
return result[Queries.Marketplace.ListSupplierOrders.name] as unknown as MarketplaceOrderPage;
}
export async function acceptIndividualOrder(order_id: string): Promise<void> {
await client.Mutation(Mutations.Marketplace.AcceptIndividualOrder.mutation, {
variables: { input: { order_id } },
});
}
export async function declineIndividualOrder(order_id: string, reason: string): Promise<void> {
await client.Mutation(Mutations.Marketplace.DeclineIndividualOrder.mutation, {
variables: { input: { order_id, reason } },
});
}
@@ -0,0 +1 @@
export { default as OffererIncomingOrdersPage } from './ui/OffererIncomingOrdersPage.vue';
@@ -0,0 +1,263 @@
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { Dialog, Notify } from 'quasar';
import {
OrderCard,
type Order as OrderCardModel,
type OrderStatus as OrderCardStatus,
} from 'src/widgets/Marketplace/OrderCard';
import { acceptIndividualOrder, declineIndividualOrder, fetchSupplierOrders } from '../api';
import type {
MarketplaceOrderStatusView,
MarketplaceOrderView,
} from '../../MyOrders/types';
/**
* Эпик 4 / Story 4.5: offerer-стол «Входящие заказы».
*
* Поставщик видит заказы, по которым он supplier. Канон `OrderCard` с
* `role='offerer'`. Polling 15s до Subscriptions Story 9.x.
*
* Активные действия (accept/refuse) — в TakeoverDialog'е акцепта партии
* Эпика 5 (страница `OffererSupplyPreparation`); здесь только read-обзор
* с фильтром по статусу.
*/
const PAGE_SIZE = 50;
const POLL_INTERVAL_MS = 15_000;
const items = ref<MarketplaceOrderView[]>([]);
const totalCount = ref(0);
const totalPages = ref(0);
const currentPage = ref(1);
const loading = ref(false);
const statusFilter = ref<MarketplaceOrderStatusView | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
const hasMore = computed(() => currentPage.value < totalPages.value);
const STATUS_FILTER_OPTIONS: Array<{ label: string; value: MarketplaceOrderStatusView | null }> = [
{ label: 'Все', value: null },
{ label: 'Ждут моего акцепта', value: 'ACCEPTED_PENDING_SUPPLIER' },
{ label: 'Индивидуальные ожидающие', value: 'ACCEPTED_PENDING_SUPPLIER_INDIVIDUAL' },
{ label: 'Приняты', value: 'ACCEPTED' },
{ label: 'Поставка готова', value: 'SUPPLY_PREPARED' },
{ label: 'Приняты кооперативом', value: 'ACCEPTED_TO_COOP' },
{ label: 'Получены', value: 'RECEIVED' },
{ label: 'Отменены', value: 'CANCELLED_BY_ORDERER' },
];
const STATUS_TO_CARD: Record<MarketplaceOrderStatusView, OrderCardStatus> = {
ACTIVE: 'placed',
ACCEPTED_PENDING_SUPPLIER: 'placed',
ACCEPTED_PENDING_SUPPLIER_INDIVIDUAL: 'placed',
ACCEPTED: 'paid',
SUPPLY_PREPARED: 'in-delivery',
ACCEPTED_TO_COOP: 'in-delivery',
READY_TO_RECEIVE: 'ready-to-issue',
RECEIVED: 'issued',
RETURNED: 'returned',
CANCELLED_BY_ORDERER: 'cancelled',
CANCELLED_BY_SUPPLIER: 'cancelled',
EXPIRED_NO_THRESHOLD: 'cancelled',
EXPIRED_NO_VOLUME: 'cancelled',
};
const cards = computed<OrderCardModel[]>(() =>
items.value.map((o) => ({
id: o.id,
shortId: o.id.slice(0, 8),
title: o.offer_id,
units: o.quantity,
unitLabel: 'ед.',
totalCost: parseFloat(o.total_cost) || 0,
status: STATUS_TO_CARD[o.status],
createdAt: o.created_at,
pvz: o.delivery_braname,
})),
);
async function load(page: number, append: boolean): Promise<void> {
loading.value = true;
try {
const result = await fetchSupplierOrders({
statuses: statusFilter.value ? [statusFilter.value] : undefined,
page,
limit: PAGE_SIZE,
});
items.value = append ? [...items.value, ...result.items] : result.items;
totalCount.value = result.totalCount;
totalPages.value = result.totalPages;
currentPage.value = result.currentPage;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message });
} finally {
loading.value = false;
}
}
function changeFilter(value: MarketplaceOrderStatusView | null): void {
statusFilter.value = value;
void load(1, false);
}
function loadMore(): void {
if (hasMore.value && !loading.value) {
void load(currentPage.value + 1, true);
}
}
async function onAccept(orderId: string): Promise<void> {
loading.value = true;
try {
await acceptIndividualOrder(orderId);
Notify.create({ type: 'positive', message: 'Заказ принят', timeout: 4000 });
await load(1, false);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message, timeout: 6000 });
} finally {
loading.value = false;
}
}
function onDecline(orderId: string): void {
Dialog.create({
title: 'Отказ от заказа',
message: 'Укажите причину отказа — она будет показана пайщику в его заказе.',
prompt: { model: '', type: 'textarea', isValid: (val: string) => val.trim().length > 0 },
cancel: { label: 'Отмена', flat: true, noCaps: true },
ok: { label: 'Отказать', color: 'negative', noCaps: true },
persistent: true,
}).onOk(async (reason: string) => {
loading.value = true;
try {
await declineIndividualOrder(orderId, reason.trim());
Notify.create({ type: 'info', message: 'Заказ отклонён', timeout: 4000 });
await load(1, false);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message, timeout: 6000 });
} finally {
loading.value = false;
}
});
}
function onCardAction(payload: { key: string; order: OrderCardModel }): void {
const orderId = String(payload.order.id);
if (payload.key === 'accept') {
void onAccept(orderId);
} else if (payload.key === 'decline') {
onDecline(orderId);
}
}
onMounted(async () => {
await load(1, false);
pollTimer = setInterval(() => {
void load(1, false);
}, POLL_INTERVAL_MS);
});
onUnmounted(() => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
});
</script>
<template lang="pug">
q-page.mp-role-offerer.mp-incoming-orders(role="region", aria-label="Входящие заказы поставщика")
div.mp-incoming-orders__header
div
div.text-h5 Входящие заказы
div.text-caption.mp-incoming-orders__subtitle
| Заказы пайщиков, по которым вы выступаете поставщиком. Действия по акцепту партии на странице «Подготовка отгрузки».
q-space
q-btn(flat, dense, round, icon="fa-solid fa-rotate", :loading="loading", @click="load(1, false)", aria-label="Обновить")
q-tabs.mp-incoming-orders__tabs(
:model-value="statusFilter",
inline-label,
align="left",
dense,
no-caps,
@update:model-value="changeFilter"
)
q-tab(
v-for="opt in STATUS_FILTER_OPTIONS",
:key="String(opt.value)",
:name="opt.value",
:label="opt.label"
)
q-inner-loading(:showing="loading && items.length === 0")
q-spinner(color="primary", size="2em")
div.mp-incoming-orders__empty(v-if="!loading && items.length === 0")
q-icon(name="fa-solid fa-inbox", size="48px", color="grey-5")
div.text-subtitle1.q-mt-md Нет заказов в этом фильтре
div.text-caption Когда пайщики оформят заказ на ваше предложение он появится здесь.
div.mp-incoming-orders__grid(v-if="items.length > 0")
OrderCard(
v-for="card in cards",
:key="card.id",
:order="card",
role="offerer",
@action="onCardAction"
)
div.mp-incoming-orders__more(v-if="hasMore")
q-btn(
flat,
no-caps,
:loading="loading",
label="Показать ещё",
@click="loadMore"
)
</template>
<style scoped lang="scss">
.mp-incoming-orders {
padding: var(--mp-space-lg);
display: flex;
flex-direction: column;
gap: var(--mp-space-md);
&__header {
display: flex;
align-items: flex-start;
gap: var(--mp-space-md);
}
&__subtitle {
color: var(--mp-on-surface-muted);
max-width: 720px;
}
&__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--mp-space-md);
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--mp-space-xl) 0;
color: var(--mp-on-surface-muted);
}
&__more {
display: flex;
justify-content: center;
padding: var(--mp-space-md) 0;
}
}
</style>
@@ -0,0 +1,37 @@
import { Queries } from '@coopenomics/sdk';
import { client } from 'src/shared/api/client';
import type { MarketplaceOfferPage } from '../types';
/**
* Эпик 3 / Story 3.4: «Мои предложения» для поставщика.
* Backend Resolver: marketplace-offer.resolver.ts → marketplaceListMyOffers.
* Read policy: ownership проверяется в `MarketplaceOfferService`
* (supplier_account = текущий пайщик).
*
* Возвращает Offer'ы всех 4 статусов: PENDING_MODERATION / ACTIVE / REJECTED /
* WITHDRAWN. Client-side фильтр по статусу — backend пагинация без фильтра.
*/
export interface ListMyOffersVariables {
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
export async function fetchMyOffers(
variables: ListMyOffersVariables = {},
): Promise<MarketplaceOfferPage> {
const { page, limit, sortBy, sortOrder } = variables;
const result = await client.Query(Queries.Marketplace.ListMyOffers.query, {
variables: {
input: {
page: page ?? 1,
limit: limit ?? 50,
sortBy: sortBy ?? 'updated_at',
sortOrder: sortOrder ?? 'DESC',
},
},
});
return result[Queries.Marketplace.ListMyOffers.name] as unknown as MarketplaceOfferPage;
}
@@ -0,0 +1 @@
export { default as OffererMyOffersPage } from './ui/OffererMyOffersPage.vue';
@@ -0,0 +1,55 @@
/**
* Эпик 3 / Story 3.4: типы offerer-стола «Мои предложения».
* Источник истины — backend MarketplaceOfferDTO + MarketplaceOfferStatuses.
*/
export type MarketplaceOfferStatusView =
| 'PENDING_MODERATION'
| 'ACTIVE'
| 'REJECTED'
| 'WITHDRAWN';
export type MarketplaceOfferCycleTypeView =
| 'time_based'
| 'volume_based'
| 'open_subscription'
| 'individual';
export interface MarketplaceOfferView {
id: string;
coopname: string;
supplier_account: string;
vitrine_id: string | null;
product_name: string;
description: string | null;
category_id: string | null;
price_per_unit: string;
unit_of_measure: string;
quantity_available: number;
quantity_blocked: number;
quantity_consumed: number;
unlimited_flag: boolean;
cycle_type: MarketplaceOfferCycleTypeView;
cycle_days: number | null;
target_volume: number | null;
max_wait_days: number | null;
min_threshold: number | null;
warranty_days: number;
barcode_strategy: string;
pack_size: number | null;
status: MarketplaceOfferStatusView;
approved_by: string | null;
approved_at: string | null;
rejected_by: string | null;
rejected_at: string | null;
reject_reason: string | null;
created_at: string;
updated_at: string;
}
export interface MarketplaceOfferPage {
items: MarketplaceOfferView[];
totalCount: number;
totalPages: number;
currentPage: number;
}
@@ -0,0 +1,264 @@
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { Notify } from 'quasar';
import {
CatalogOfferCard,
type CatalogOffer,
type CatalogOfferStatus,
} from 'src/widgets/Marketplace/CatalogOfferCard';
import { fetchMyOffers } from '../api';
import type { MarketplaceOfferStatusView, MarketplaceOfferView } from '../types';
/**
* Эпик 3 / Story 3.4: offerer-стол «Мои предложения».
*
* Поставщик видит свои Offer'ы во всех 4 статусах: PENDING_MODERATION,
* ACTIVE, REJECTED, WITHDRAWN. Канон `CatalogOfferCard` с цветными chip'ами
* для статуса. Client-side фильтр + поиск по названию (backend
* `marketplaceListMyOffers` принимает только пагинацию).
*
* Polling 30s — Offer'ы меняют статус через модерацию председателя
* (`marketplaceApproveOffer` / `marketplaceRejectOffer`).
*/
const PAGE_SIZE = 50;
const POLL_INTERVAL_MS = 30_000;
const items = ref<MarketplaceOfferView[]>([]);
const totalCount = ref(0);
const totalPages = ref(0);
const currentPage = ref(1);
const loading = ref(false);
const statusFilter = ref<MarketplaceOfferStatusView | null>(null);
const search = ref('');
let pollTimer: ReturnType<typeof setInterval> | null = null;
const STATUS_FILTER_OPTIONS: Array<{ label: string; value: MarketplaceOfferStatusView | null }> = [
{ label: 'Все', value: null },
{ label: 'На модерации', value: 'PENDING_MODERATION' },
{ label: 'Опубликованы', value: 'ACTIVE' },
{ label: 'Отклонены', value: 'REJECTED' },
{ label: 'Сняты', value: 'WITHDRAWN' },
];
const STATUS_TO_CARD: Record<MarketplaceOfferStatusView, CatalogOfferStatus> = {
PENDING_MODERATION: 'moderation',
ACTIVE: 'published',
REJECTED: 'paused',
WITHDRAWN: 'completed',
};
const filtered = computed(() => {
let list = items.value;
if (statusFilter.value) {
list = list.filter((o) => o.status === statusFilter.value);
}
if (search.value.trim()) {
const q = search.value.trim().toLowerCase();
list = list.filter((o) => o.product_name.toLowerCase().includes(q));
}
return list;
});
const cards = computed<Array<CatalogOffer & { domainStatus: MarketplaceOfferStatusView; rejectReason: string | null }>>(() =>
filtered.value.map((o) => ({
id: o.id,
title: o.product_name,
description: o.description ?? '',
remainUnits: o.unlimited_flag ? undefined : o.quantity_available - o.quantity_blocked,
unitCost: parseFloat(o.price_per_unit) || 0,
unitLabel: o.unit_of_measure,
status: STATUS_TO_CARD[o.status],
domainStatus: o.status,
rejectReason: o.reject_reason,
})),
);
const counts = computed(() => {
const total = items.value.length;
const active = items.value.filter((o) => o.status === 'ACTIVE').length;
const pending = items.value.filter((o) => o.status === 'PENDING_MODERATION').length;
const rejected = items.value.filter((o) => o.status === 'REJECTED').length;
return { total, active, pending, rejected };
});
const hasMore = computed(() => currentPage.value < totalPages.value);
async function load(page: number, append: boolean): Promise<void> {
loading.value = true;
try {
const result = await fetchMyOffers({ page, limit: PAGE_SIZE });
items.value = append ? [...items.value, ...result.items] : result.items;
totalCount.value = result.totalCount;
totalPages.value = result.totalPages;
currentPage.value = result.currentPage;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message });
} finally {
loading.value = false;
}
}
function loadMore(): void {
if (hasMore.value && !loading.value) {
void load(currentPage.value + 1, true);
}
}
onMounted(async () => {
await load(1, false);
pollTimer = setInterval(() => {
void load(1, false);
}, POLL_INTERVAL_MS);
});
onUnmounted(() => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
});
</script>
<template lang="pug">
q-page.mp-role-offerer.mp-my-offers(role="region", aria-label="Мои предложения")
div.mp-my-offers__header
div
div.text-h5 Мои предложения
div.text-caption.mp-my-offers__subtitle
| Все ваши Offer'ы в кооперативе: на модерации, опубликованные, отклонённые и снятые. Чтобы опубликовать новый — перейдите на «Создать предложение».
q-space
q-btn(flat, dense, round, icon="fa-solid fa-rotate", :loading="loading", @click="load(1, false)", aria-label="Обновить")
div.row.q-col-gutter-md.mp-my-offers__counters
div.col-6.col-md-3
q-card(flat, bordered)
q-card-section
div.text-caption Всего
div.text-h6 {{ counts.total }}
div.col-6.col-md-3
q-card(flat, bordered)
q-card-section
div.text-caption Активных
div.text-h6.text-positive {{ counts.active }}
div.col-6.col-md-3
q-card(flat, bordered)
q-card-section
div.text-caption На модерации
div.text-h6.text-warning {{ counts.pending }}
div.col-6.col-md-3
q-card(flat, bordered)
q-card-section
div.text-caption Отклонены
div.text-h6.text-negative {{ counts.rejected }}
div.row.q-col-gutter-md.items-center
div.col-12.col-md-6
q-input(
v-model="search",
outlined,
dense,
clearable,
placeholder="Поиск по названию",
debounce="200"
)
template(#prepend)
q-icon(name="fa-solid fa-magnifying-glass")
div.col-12.col-md-6
q-btn-toggle(
v-model="statusFilter",
:options="STATUS_FILTER_OPTIONS",
no-caps,
spread,
unelevated,
toggle-color="primary"
)
q-inner-loading(:showing="loading && items.length === 0")
q-spinner(color="primary", size="2em")
div.mp-my-offers__empty(v-if="!loading && filtered.length === 0")
q-icon(name="fa-solid fa-clipboard-list", size="48px", color="grey-5")
div.text-subtitle1.q-mt-md Нет предложений в этом фильтре
div.text-caption Если у вас нет ни одного Offer'а создайте первое на странице «Создать предложение».
div.mp-my-offers__grid(v-if="filtered.length > 0")
div.mp-my-offers__cell(
v-for="card in cards",
:key="card.id"
)
CatalogOfferCard(:offer="card")
div.mp-my-offers__reason(v-if="card.domainStatus === 'REJECTED' && card.rejectReason")
q-icon(name="fa-solid fa-circle-exclamation", color="negative", size="14px")
| {{ card.rejectReason }}
div.mp-my-offers__more(v-if="hasMore")
q-btn(
flat,
no-caps,
:loading="loading",
label="Показать ещё",
@click="loadMore"
)
</template>
<style scoped lang="scss">
.mp-my-offers {
padding: var(--mp-space-lg);
display: flex;
flex-direction: column;
gap: var(--mp-space-md);
&__header {
display: flex;
align-items: flex-start;
gap: var(--mp-space-md);
}
&__subtitle {
color: var(--mp-on-surface-muted);
max-width: 720px;
}
&__counters {
margin-bottom: var(--mp-space-xs);
}
&__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--mp-space-md);
}
&__cell {
display: flex;
flex-direction: column;
gap: var(--mp-space-xs);
}
&__reason {
display: flex;
gap: var(--mp-space-xs);
align-items: center;
color: var(--mp-on-surface-muted);
font-size: 12px;
padding: 0 var(--mp-space-xs);
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--mp-space-xl) 0;
color: var(--mp-on-surface-muted);
}
&__more {
display: flex;
justify-content: center;
padding: var(--mp-space-md) 0;
}
}
</style>
@@ -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
@@ -0,0 +1,35 @@
import { Mutations, Queries } from '@coopenomics/sdk';
import { client } from 'src/shared/api/client';
/**
* Эпик 1 / Story 1.9-1.10: L1 онбординг — приём кооперативом ЦПП «Стол заказов».
* Backend Resolver: marketplace-coop-acceptance.resolver.ts
* (Query.marketplaceCppStatus + Mutation.marketplaceAcceptCpp).
*
* status='active' — кооператив подключил ЦПП; пайщики могут проходить L3 gate.
* status='not_accepted' — расширение ещё не подключено; пайщикам показывается gate.
*/
export type MarketplaceCppStatusView =
Queries.Marketplace.MarketplaceCppStatus.IOutput['marketplaceCppStatus'];
export async function fetchCppStatus(): Promise<MarketplaceCppStatusView> {
const { [Queries.Marketplace.MarketplaceCppStatus.name]: result } = await client.Query(
Queries.Marketplace.MarketplaceCppStatus.query,
{ variables: {} },
);
return result;
}
export interface AcceptCppArgs {
document_registry_id: number;
accepted_by_board_decision_id: string;
}
export async function acceptCpp(args: AcceptCppArgs): Promise<MarketplaceCppStatusView> {
const { [Mutations.Marketplace.MarketplaceAcceptCpp.name]: result } = await client.Mutation(
Mutations.Marketplace.MarketplaceAcceptCpp.mutation,
{ variables: { input: args } },
);
return result;
}
@@ -0,0 +1 @@
export { default as OnboardingCoopAcceptCppPage } from './ui/OnboardingCoopAcceptCppPage.vue';
@@ -0,0 +1,147 @@
<script lang="ts" setup>
import { onMounted, ref, computed } from 'vue';
import { Notify, Dialog } from 'quasar';
import {
acceptCpp,
fetchCppStatus,
type MarketplaceCppStatusView,
} from '../api';
/**
* Эпик 1 / Story 1.9-1.10: L1 — приём кооперативом ЦПП «Стол заказов».
*
* Стол председателя кооператива. Председатель видит статус ЦПП (`active` /
* `not_accepted`) и кнопку «Принять ЦПП Marketplace», если ещё не принято.
*
* После клика — диалог подтверждения с stub `accepted_by_board_decision_id`
* (в MVP — текстовая ссылка на решение совета; полноценная повестка совета —
* FR40 / Эпик 8, Phase 2). На backend срабатывает `marketplaceAcceptCpp`:
* подписывается оферта в `coop_registration_offers_registry`, флаг
* `coopAcceptance.status='active'` сохраняется в конфиге расширения. После
* этого пайщики могут проходить L3 onboarding gate.
*/
const status = ref<MarketplaceCppStatusView | null>(null);
const loading = ref(false);
const accepting = ref(false);
const isActive = computed(() => status.value?.status === 'active');
const chipColor = computed(() => (isActive.value ? 'positive' : 'warning'));
const chipIcon = computed(() => (isActive.value ? 'fa-solid fa-check' : 'fa-solid fa-hourglass-half'));
const chipLabel = computed(() => (isActive.value ? 'Подключено' : 'Не подключено'));
const statusLabel = computed(() =>
isActive.value ? 'active — кооператив подключил ЦПП' : 'not_accepted — расширение не подключено',
);
async function load(): Promise<void> {
loading.value = true;
try {
status.value = await fetchCppStatus();
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message });
} finally {
loading.value = false;
}
}
function onAccept(): void {
Dialog.create({
title: 'Принять ЦПП «Стол заказов»?',
message:
'Подтвердите, что Совет кооператива принял Положение ЦПП «Стол заказов». В MVP — stub решения; полноценная повестка Совета подключится в Эпике 8.',
cancel: { label: 'Отмена', flat: true },
ok: { label: 'Принять', color: 'primary', unelevated: true },
persistent: true,
}).onOk(async () => {
accepting.value = true;
try {
status.value = await acceptCpp({
document_registry_id: 1100,
accepted_by_board_decision_id: `MVP-STUB-${new Date().toISOString().slice(0, 10)}`,
});
Notify.create({
type: 'positive',
message: 'ЦПП Marketplace принято. Пайщики могут начать пользоваться Столом заказов.',
});
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message });
} finally {
accepting.value = false;
}
});
}
onMounted(async () => {
await load();
});
</script>
<template lang="pug">
q-page.mp-role-admin.mp-onboarding-l1(role="region", aria-label="Подключение ЦПП Marketplace")
div.mp-onboarding-l1__header
div
div.text-h5 Подключение ЦПП «Стол заказов»
div.text-caption.mp-onboarding-l1__subtitle
| Целевая Потребительская Программа должна быть принята Советом кооператива, прежде чем пайщики смогут пользоваться Столом заказов.
q-space
q-chip(:color="chipColor", text-color="white")
q-icon(:name="chipIcon", left)
| {{ chipLabel }}
q-inner-loading(:showing="loading && !status")
q-spinner(color="primary", size="2em")
q-card.mp-onboarding-l1__card(v-if="status", flat, bordered)
q-card-section
div.text-subtitle1.q-mb-sm Статус расширения
div.row.q-col-gutter-md
div.col-12.col-md-6
div.text-caption Статус
div.text-body1 {{ statusLabel }}
div.col-12.col-md-6(v-if="status.document_registry_id")
div.text-caption Реестр оферты
div.text-body1 {{ status.document_registry_id }}
div.col-12.col-md-6(v-if="status.accepted_at")
div.text-caption Принято
div.text-body1 {{ status.accepted_at }}
div.col-12.col-md-6(v-if="status.accepted_by_board_decision_id")
div.text-caption Решение Совета
div.text-body1 {{ status.accepted_by_board_decision_id }}
q-card-actions(v-if="!isActive", align="right")
q-btn(
unelevated,
no-caps,
color="primary",
icon="fa-solid fa-handshake",
label="Принять ЦПП Marketplace",
:loading="accepting",
@click="onAccept"
)
</template>
<style scoped lang="scss">
.mp-onboarding-l1 {
padding: var(--mp-space-lg);
display: flex;
flex-direction: column;
gap: var(--mp-space-md);
&__header {
display: flex;
align-items: flex-start;
gap: var(--mp-space-md);
}
&__subtitle {
color: var(--mp-on-surface-muted);
max-width: 640px;
}
&__card {
max-width: 720px;
}
}
</style>
@@ -0,0 +1,76 @@
import { Mutations, Queries } from '@coopenomics/sdk';
import { Cooperative } from 'cooptypes';
import { DigitalDocument } from 'src/shared/lib/document';
import { client } from 'src/shared/api/client';
import { useSessionStore } from 'src/entities/Session/model';
import { useSystemStore } from 'src/entities/System/model';
/**
* Эпик 1 / Story 1.4 + фоллоуап: L3 онбординг пайщика на стол заказов.
*
* - `fetchOnboardingState`: GET state (Story 1.4), сервер берёт username из JWT.
* - `signOnboardingOffer`: фоллоуап, MUT `marketplaceSignOnboardingOffer` —
* генерирует оферту (registry_id=1101), подписывает локальным WIF и
* отправляет в контроллер, который пишет on-chain `wallet::signagree`.
*/
export type MarketplaceOnboardingStateView =
Queries.Marketplace.MarketplaceOnboardingState.IOutput['marketplaceOnboardingState'];
export async function fetchOnboardingState(): Promise<MarketplaceOnboardingStateView> {
const { [Queries.Marketplace.MarketplaceOnboardingState.name]: result } = await client.Query(
Queries.Marketplace.MarketplaceOnboardingState.query,
{},
);
return result;
}
/**
* Подписать оферту ЦПП «Стол заказов» прямо со стола.
*
* Flow:
* 1. Сгенерировать инстанс оферты на backend (registry_id=1101) с уникальным
* marketplace_agreement_number и текущей датой.
* 2. Подписать локальным WIF пайщика (signatureId=1, signer=username).
* 3. Отправить подписанный документ через `marketplaceSignOnboardingOffer`,
* контроллер сам выполнит on-chain `wallet::signagree` от лица coopname.
*
* Возвращает свежее состояние онбординга — UI решает что показать
* (`requires_gate=false, source='agreement_signed'` → редирект на /market).
*/
export async function signOnboardingOffer(): Promise<MarketplaceOnboardingStateView> {
const session = useSessionStore();
const system = useSystemStore();
const username = session.username;
if (!username) throw new Error('Пайщик не авторизован');
const coopname = system.info.coopname;
if (!coopname) throw new Error('Не определён кооператив');
const now = new Date();
// Номер соглашения: краткая отметка времени, уникальная в пределах пайщика.
// Финальный формат может быть переопределён coopname-конвенцией; пока
// используется компактный timestamp + хеш-суффикс для разрешения коллизий.
const marketplace_agreement_number = `MA-${now.getTime()}-${Math.random().toString(36).slice(2, 8)}`;
const pad = (n: number) => String(n).padStart(2, '0');
const marketplace_agreement_created_at = `${pad(now.getDate())}.${pad(now.getMonth() + 1)}.${now.getFullYear()} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
const document = new DigitalDocument();
await document.generate({
registry_id: Cooperative.Registry.MarketplaceOffer.registry_id,
coopname,
username,
marketplace_agreement_number,
marketplace_agreement_created_at,
});
await document.sign(username);
if (!document.signedDocument) {
throw new Error('Не удалось подписать оферту');
}
const { [Mutations.Marketplace.MarketplaceSignOnboardingOffer.name]: result } = await client.Mutation(
Mutations.Marketplace.MarketplaceSignOnboardingOffer.mutation,
{ variables: { input: { document: document.signedDocument } } },
);
return result as MarketplaceOnboardingStateView;
}
@@ -0,0 +1 @@
export { default as OnboardingMemberPickCppPage } from './ui/OnboardingMemberPickCppPage.vue';
@@ -0,0 +1,159 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { Notify } from 'quasar';
import { useRouter } from 'vue-router';
import {
OnboardingCPPGate,
type CPPDocument,
} from 'src/widgets/Marketplace/OnboardingCPPGate';
import {
fetchOnboardingState,
signOnboardingOffer,
type MarketplaceOnboardingStateView,
} from '../api';
/**
* Эпик 1 / Story 1.4 + 1.11: L3 онбординг пайщика — Marketplace gate.
*
* Пайщик попадает сюда, когда `marketplaceOnboardingState.requires_gate === true`
* (см. routing guard /market/*). На странице — canon `OnboardingCPPGate`
* (UX-DR17) со списком документов ЦПП «Стол заказов»: оферта на присоединение
* + Положение ЦПП. Подпись делает core `wallet::signagree` через
* Registrator мастер (платформенный поток, не marketplace).
*
* UI здесь — информационный layer: показывает документы, ведёт пользователя
* в Registrator. После подписи backend пересчитает `requires_gate=false` и
* пайщик попадёт на /market.
*
* Зачем отдельная страница, а не только gate-overlay: чтобы у пайщика была
* прямая ссылка из навигации/уведомлений «подключиться к Marketplace».
*/
const state = ref<MarketplaceOnboardingStateView | null>(null);
const loading = ref(false);
const router = useRouter();
const cppDocuments = computed<CPPDocument[]>(() => {
if (!state.value) return [];
return [
{
id: 'cpp-marketplace-offer',
title: 'Оферта на присоединение к ЦПП «Стол заказов»',
description: `Шаблон документа № ${state.value.template_registry_id} в реестре кооператива.`,
required: true,
locked: false,
},
{
id: 'cpp-marketplace-rules',
title: 'Положение ЦПП «Стол заказов»',
description: 'Правила взаимодействия заказчиков, поставщиков и ПВЗ.',
required: true,
locked: false,
},
];
});
const alreadyDone = computed(() => state.value && !state.value.requires_gate);
async function load(): Promise<void> {
loading.value = true;
try {
state.value = await fetchOnboardingState();
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message });
} finally {
loading.value = false;
}
}
async function onAccept(): Promise<void> {
// L3 подпись прямо со стола: фоллоуап Эпика 1 (mutation
// `marketplaceSignOnboardingOffer`). Frontend рендерит оферту 1101 через
// documentFactory, подписывает локальным WIF, отправляет на backend, а тот
// вызывает on-chain `wallet::signagree` от лица coopname.
// После успешной подписи перезагружаем состояние онбординга — если
// requires_gate=false, ведём пайщика на каталог.
loading.value = true;
try {
state.value = await signOnboardingOffer();
if (state.value && !state.value.requires_gate) {
Notify.create({
type: 'positive',
message: 'Оферта ЦПП «Стол заказов» подписана. Открываем каталог…',
timeout: 1500,
});
setTimeout(() => {
void router.push({ name: 'marketplace-catalog' });
}, 800);
} else {
// Sync ещё не подтянул запись из chain — даём UI шанс перезапросить
// вручную через перезагрузку страницы.
Notify.create({
type: 'info',
message: 'Подпись отправлена. Подтверждение из блокчейна ожидается — обновите страницу через несколько секунд.',
});
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message });
} finally {
loading.value = false;
}
}
function onDecline(): void {
Notify.create({
type: 'warning',
message: 'Без подписи ЦПП Стол заказов недоступен. Вернитесь, когда будете готовы.',
});
void router.push({ name: 'wallet' });
}
onMounted(load);
</script>
<template lang="pug">
q-page.mp-role-orderer.mp-member-cpp(role="region", aria-label="Подключение к Marketplace")
div.text-h5 Подключение к Столу заказов
q-inner-loading(:showing="loading && !state")
q-spinner(color="primary", size="2em")
q-banner(v-if="alreadyDone", rounded, class="bg-positive text-white q-mt-md")
template(#avatar)
q-icon(name="fa-solid fa-circle-check", color="white")
div.text-subtitle2 Вы уже подключены к Marketplace
div.text-body2
| Подпись ЦПП Стола заказов уже выполнена.
template(v-if="state?.completed_at")
| Дата: {{ state.completed_at }}.
| Можете переходить на стол заказчика.
template(#action)
q-btn(flat, color="white", label="К каталогу", @click="router.push({ name: 'marketplace-catalog' })")
OnboardingCPPGate(
v-if="state && state.requires_gate && cppDocuments.length",
title="Стол заказов — пакет ЦПП",
subtitle="Эпик 1 / L3 онбординг пайщика",
lead-text="Ознакомьтесь с офертой и Положением ЦПП «Стол заказов». При нажатии «Подписать» документ будет подписан вашим электронным ключом и отправлен в блокчейн.",
:documents="cppDocuments",
confirm-label="Подписать оферту"
:busy="loading",
@accept="onAccept",
@decline="onDecline"
)
q-banner(v-if="state && !state.requires_gate && !alreadyDone", rounded, class="bg-grey-3 q-mt-md")
div.text-body2 Состояние онбординга получено, но gate не требуется и подпись не зафиксирована возможен временный рассинхрон. Обновите страницу.
</template>
<style scoped lang="scss">
.mp-member-cpp {
padding: var(--mp-space-lg);
display: flex;
flex-direction: column;
gap: var(--mp-space-md);
max-width: 760px;
}
</style>
@@ -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
)
@@ -0,0 +1 @@
export { default as OrdererConsolidatedPage } from './ui/OrdererConsolidatedPage.vue';
@@ -0,0 +1,328 @@
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { Notify } from 'quasar';
import {
OrderCard,
type Order as OrderCardModel,
type OrderStatus as OrderCardStatus,
} from 'src/widgets/Marketplace/OrderCard';
import { fetchMyOrders } from '../../MyOrders/api';
import type {
MarketplaceOrderCycleTypeView,
MarketplaceOrderStatusView,
MarketplaceOrderView,
} from '../../MyOrders/types';
/**
* Эпик 4 / Story 4.4: orderer-стол «Сводный заказ».
*
* В циклах `time_based` / `volume_based` / `open_subscription` Order'ы
* заказчика группируются в партию по `cycle_id`. Эта страница даёт пайщику
* единый обзор: какие у него партии в полёте, сколько заказов в каждой,
* суммарная стоимость, общий этап партии. Канон — `widgets/Marketplace/OrderCard`
* для отдельных заказов внутри партии.
*
* Источник данных — `Queries.Marketplace.ListMyOrders` (reuse MyOrders/api).
* Polling 15s, до Subscriptions Story 9.x.
*/
const POLL_INTERVAL_MS = 15_000;
const PAGE_SIZE = 200;
const items = ref<MarketplaceOrderView[]>([]);
const loading = ref(false);
let pollTimer: ReturnType<typeof setInterval> | null = null;
const CYCLE_TYPE_LABEL: Record<MarketplaceOrderCycleTypeView, string> = {
time_based: 'По времени',
volume_based: 'По объёму',
open_subscription: 'Открытая подписка',
individual: 'Индивидуальный',
};
const CYCLE_TYPE_HINT: Record<MarketplaceOrderCycleTypeView, string> = {
time_based: 'Партия закрывается по таймеру; заказы внутри окна объединяются для одной поставки.',
volume_based: 'Партия закрывается при достижении порога объёма заказов.',
open_subscription: 'Открытый пул заказов; партии формируются по решению поставщика.',
individual: 'Каждый заказ обслуживается поставщиком отдельно.',
};
const STATUS_LABEL: Record<MarketplaceOrderStatusView, string> = {
ACTIVE: 'Ждёт цикла / решения',
ACCEPTED_PENDING_SUPPLIER: 'Ждёт поставщика',
ACCEPTED_PENDING_SUPPLIER_INDIVIDUAL: 'Ждёт поставщика',
ACCEPTED: 'Принят поставщиком',
SUPPLY_PREPARED: 'Поставка готовится',
ACCEPTED_TO_COOP: 'Принят кооперативом',
READY_TO_RECEIVE: 'Готов к выдаче',
RECEIVED: 'Получен',
RETURNED: 'Возвращён',
CANCELLED_BY_ORDERER: 'Отменён заказчиком',
CANCELLED_BY_SUPPLIER: 'Отменён поставщиком',
EXPIRED_NO_THRESHOLD: 'Цикл закрыт без порога',
EXPIRED_NO_VOLUME: 'Цикл закрыт без объёма',
};
const STATUS_TO_CARD: Record<MarketplaceOrderStatusView, OrderCardStatus> = {
ACTIVE: 'placed',
ACCEPTED_PENDING_SUPPLIER: 'placed',
ACCEPTED_PENDING_SUPPLIER_INDIVIDUAL: 'placed',
ACCEPTED: 'paid',
SUPPLY_PREPARED: 'in-delivery',
ACCEPTED_TO_COOP: 'in-delivery',
READY_TO_RECEIVE: 'ready-to-issue',
RECEIVED: 'issued',
RETURNED: 'returned',
CANCELLED_BY_ORDERER: 'cancelled',
CANCELLED_BY_SUPPLIER: 'cancelled',
EXPIRED_NO_THRESHOLD: 'cancelled',
EXPIRED_NO_VOLUME: 'cancelled',
};
// Этап партии = минимальный по STAGE_RANK среди не-отменённых; то есть
// если хотя бы один заказ ещё ждёт цикл, вся партия «Активна».
// Готовый к выдаче переходит в issued только когда все заказы выданы.
const STAGE_RANK: Record<MarketplaceOrderStatusView, number> = {
ACTIVE: 0,
ACCEPTED_PENDING_SUPPLIER: 1,
ACCEPTED_PENDING_SUPPLIER_INDIVIDUAL: 1,
ACCEPTED: 2,
SUPPLY_PREPARED: 3,
ACCEPTED_TO_COOP: 4,
READY_TO_RECEIVE: 5,
RECEIVED: 6,
RETURNED: 7,
CANCELLED_BY_ORDERER: 99,
CANCELLED_BY_SUPPLIER: 99,
EXPIRED_NO_THRESHOLD: 99,
EXPIRED_NO_VOLUME: 99,
};
interface ConsolidatedGroup {
key: string;
cycle_id: string | null;
cycle_type: MarketplaceOrderCycleTypeView;
offer_id: string;
supplier_account: string;
delivery_braname: string;
orders: MarketplaceOrderView[];
totalCost: number;
totalUnits: number;
stageStatus: MarketplaceOrderStatusView;
}
const groups = computed<ConsolidatedGroup[]>(() => {
const buckets = new Map<string, ConsolidatedGroup>();
for (const o of items.value) {
const key = o.cycle_id
? `cycle:${o.cycle_id}`
: `single:${o.id}`;
let g = buckets.get(key);
if (!g) {
g = {
key,
cycle_id: o.cycle_id,
cycle_type: o.cycle_type,
offer_id: o.offer_id,
supplier_account: o.supplier_account,
delivery_braname: o.delivery_braname,
orders: [],
totalCost: 0,
totalUnits: 0,
stageStatus: o.status,
};
buckets.set(key, g);
}
g.orders.push(o);
g.totalCost += parseFloat(o.total_cost) || 0;
g.totalUnits += o.quantity;
// Этап партии = ранг минимального активного (с приоритетом «в работе»).
const candidate = STAGE_RANK[o.status];
const current = STAGE_RANK[g.stageStatus];
if (candidate < 90 && (current >= 90 || candidate < current)) {
g.stageStatus = o.status;
}
}
return [...buckets.values()].sort((a, b) => {
// Группы с cycle_id выше (партии в работе), индивидуальные ниже.
if (a.cycle_id && !b.cycle_id) return -1;
if (!a.cycle_id && b.cycle_id) return 1;
return a.orders[0].created_at < b.orders[0].created_at ? 1 : -1;
});
});
const hasGroups = computed(() => groups.value.length > 0);
const consolidatedCount = computed(
() => groups.value.filter((g) => g.cycle_id !== null).length,
);
function formatCost(value: number): string {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
maximumFractionDigits: 2,
}).format(value);
}
function toCardModel(o: MarketplaceOrderView): OrderCardModel {
return {
id: o.id,
shortId: o.id.slice(0, 8),
title: o.offer_id,
units: o.quantity,
unitLabel: 'ед.',
totalCost: parseFloat(o.total_cost) || 0,
status: STATUS_TO_CARD[o.status],
createdAt: o.created_at,
pvz: o.delivery_braname,
};
}
async function load(): Promise<void> {
loading.value = true;
try {
const result = await fetchMyOrders({
page: 1,
limit: PAGE_SIZE,
sortBy: 'created_at',
sortOrder: 'DESC',
});
items.value = result.items;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message });
} finally {
loading.value = false;
}
}
onMounted(async () => {
await load();
pollTimer = setInterval(() => {
void load();
}, POLL_INTERVAL_MS);
});
onUnmounted(() => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
});
</script>
<template lang="pug">
q-page.mp-role-orderer.mp-consolidated(role="region", aria-label="Сводный заказ")
div.mp-consolidated__header
div
div.text-h5 Сводный заказ
div.text-caption.mp-consolidated__subtitle
| Партии заказов, сгруппированные по циклу. Несколько ваших заказов в одной партии обслуживаются совместно на одном цикле, с одной поставкой.
q-space
q-btn(flat, dense, round, icon="fa-solid fa-rotate", :loading="loading", @click="load", aria-label="Обновить")
q-inner-loading(:showing="loading && items.length === 0")
q-spinner(color="primary", size="2em")
div.mp-consolidated__empty(v-if="!loading && !hasGroups")
q-icon(name="fa-solid fa-box-open", size="48px", color="grey-5")
div.text-subtitle1.q-mt-md У вас ещё нет заказов
div.text-caption Откройте каталог и оформите первый заказ он появится здесь.
div.mp-consolidated__list(v-if="hasGroups")
div.text-caption.mp-consolidated__counter
| Партий в работе: {{ consolidatedCount }} / Всего групп: {{ groups.length }}
q-card.mp-consolidated__group(
v-for="g in groups",
:key="g.key",
flat,
bordered
)
q-card-section.mp-consolidated__group-head
div.row.items-center.q-gutter-md
div
div.text-subtitle1
span(v-if="g.cycle_id") Партия {{ g.cycle_id }}
span(v-else) Индивидуальный заказ {{ g.orders[0].id.slice(0, 8) }}
div.text-caption.mp-consolidated__group-hint
| {{ CYCLE_TYPE_LABEL[g.cycle_type] }} {{ CYCLE_TYPE_HINT[g.cycle_type] }}
q-space
q-chip(square, color="primary", text-color="white", icon="fa-solid fa-layer-group")
| {{ g.orders.length }} заказа(ов)
div.row.q-col-gutter-md.q-mt-sm
div.col-12.col-md-3
div.text-caption Этап партии
div.text-body2 {{ STATUS_LABEL[g.stageStatus] }}
div.col-12.col-md-3
div.text-caption Всего единиц
div.text-body2 {{ g.totalUnits }}
div.col-12.col-md-3
div.text-caption Сумма партии
div.text-body2 {{ formatCost(g.totalCost) }}
div.col-12.col-md-3
div.text-caption ПВЗ доставки
div.text-body2 {{ g.delivery_braname }}
q-separator
q-card-section
div.mp-consolidated__orders
OrderCard(
v-for="o in g.orders",
:key="o.id",
:order="toCardModel(o)",
role="orderer"
)
</template>
<style scoped lang="scss">
.mp-consolidated {
padding: var(--mp-space-lg);
display: flex;
flex-direction: column;
gap: var(--mp-space-md);
&__header {
display: flex;
align-items: flex-start;
gap: var(--mp-space-md);
}
&__subtitle {
color: var(--mp-on-surface-muted);
max-width: 720px;
}
&__counter {
color: var(--mp-on-surface-muted);
margin-bottom: var(--mp-space-sm);
}
&__list {
display: flex;
flex-direction: column;
gap: var(--mp-space-md);
}
&__group-hint {
color: var(--mp-on-surface-muted);
max-width: 640px;
}
&__orders {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--mp-space-md);
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--mp-space-xl) 0;
color: var(--mp-on-surface-muted);
}
}
</style>
@@ -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
@@ -19,6 +19,17 @@ function walletInitTimeoutPromise(): Promise<never> {
});
}
// URL-based fallback на случай если info.coopname ещё не подгрузился
// из getSystemInfo (race с login: init-wallet стартует до того как
// useSystemStore.loadSystemInfo() заполнил info из бэка).
function getCoopnameFromUrl(): string {
if (typeof window === 'undefined') return '';
const src = window.location.hash || window.location.pathname || '';
// hash вида "#/voskhod/..." или path "/voskhod/..."
const m = src.replace(/^#/, '').match(/^\/?([a-z0-9_-]+)\b/i);
return m ? m[1] : '';
}
export function useInitWalletProcess() {
const session = useSessionStore();
const wallet = useWalletStore();
@@ -60,8 +71,12 @@ export function useInitWalletProcess() {
// вернёт пустой кошелёк, а UI начнёт показывать подписи документов,
// которых юзер на этом этапе подписывать не должен.
if (session.isFullyActive) {
// info.coopname может быть undefined если getSystemInfo ещё не
// отдала ответ (race с login). URL-fallback гарантирует, что мы
// не вызовем loadUserWallet({coopname: undefined}) → GraphQL 400.
const coopname = info.coopname || getCoopnameFromUrl();
await wallet.loadUserWallet({
coopname: info.coopname,
coopname,
username: session.username,
});
}
@@ -11,6 +11,14 @@ export function useBranchOverlayProcess() {
const { isVisible } = useSelectBranch()
const checkConditions = () => {
// Harness/docs escape hatch: позволяет снимать скриншоты разделов
// без прохождения процедуры выбора кооп. участка. Включается через
// `localStorage.setItem('harness:noBranchOverlay', '1')` перед навигацией.
if (typeof window !== 'undefined' && window.localStorage?.getItem('harness:noBranchOverlay') === '1') {
isVisible.value = false
return
}
const branched = system.info?.cooperator_account?.is_branched
const participant = account?.account?.participant_account
const noBraname = participant?.braname === ''
@@ -47,6 +47,14 @@ function loadConfigSync(): boolean {
xhr.send();
if (xhr.status === 200) {
// В dev-режиме Vite на несуществующий /config.js отдаёт index.html
// (catch-all для SPA). eval(htmlString) бросает SyntaxError, который
// всплывает как "Quasar boot error" и ломает последующую init-app.
// Запускаем eval только если content-type явно JavaScript.
const ct = (xhr.getResponseHeader('content-type') || '').toLowerCase();
if (!ct.includes('javascript')) {
return false;
}
// Выполняем JavaScript код из ответа
eval(xhr.responseText);
@@ -58,25 +66,26 @@ function loadConfigSync(): boolean {
}
// // Пробуем загрузить резервную конфигурацию
// try {
// console.log('DEBUG: Загружаем резервную конфигурацию синхронно');
// Пробуем резервный config.default.js (для SPA-dev режима, где SSR middleware
// не запущен и нет /config.js — без этого фронт получает пустой BACKEND_URL).
try {
const xhr = new XMLHttpRequest();
xhr.open('GET', `/config.default.js?t=${Date.now()}`, false);
xhr.send();
// const xhr = new XMLHttpRequest();
// xhr.open('GET', '/config.default.js', false);
// xhr.send();
// if (xhr.status === 200) {
// eval(xhr.responseText);
// if (window.__APP_CONFIG__) {
// console.log('DEBUG: config.default.js загружен синхронно');
// return true;
// }
// }
// } catch (error) {
// console.warn('DEBUG: Ошибка загрузки резервной конфигурации:', error);
// }
if (xhr.status === 200) {
const ct = (xhr.getResponseHeader('content-type') || '').toLowerCase();
if (!ct.includes('javascript')) {
return false;
}
eval(xhr.responseText);
if (window.__APP_CONFIG__) {
return true;
}
}
} catch {
// молча — следующая ветка вернёт false
}
return false;
}
@@ -42,7 +42,8 @@
unelevated
color="primary"
:label="confirmLabel"
:disable="!allRequiredAccepted"
:disable="!allRequiredAccepted || busy"
:loading="busy"
@click="$emit('accept', acceptedDocs)"
/>
</q-card-actions>
@@ -67,6 +68,7 @@ const props = defineProps({
leadText: { type: String, default: 'Ознакомьтесь с пакетом документов и подтвердите согласие.' },
documents: { type: Array as PropType<CPPDocument[]>, required: true },
confirmLabel: { type: String, default: 'Принять и продолжить' },
busy: { type: Boolean, default: false },
})
defineEmits<{
+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}`);
+65
View File
@@ -0,0 +1,65 @@
// Диагностика loginAs(chairkrg): почему сценарий после loginAs остаётся
// на /auth/signin. Снимаем подробный лог console+network+state.
import fs from 'node:fs';
import { chromium } from 'playwright';
const BASE = process.env.BASE_URL || 'http://127.0.0.1:3041';
const COOP = 'voskhod';
const fixture = JSON.parse(
fs.readFileSync('/home/admin/mono-ai-4/components/docs-harness/state/participants/chairkrg.json', 'utf8'),
);
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1120, height: 800 }, deviceScaleFactor: 1.25 });
const page = await ctx.newPage();
page.on('console', (msg) => {
console.log(`[console.${msg.type()}]`, msg.text().slice(0, 500));
});
page.on('pageerror', (err) => console.log('[pageerror]', err.message));
page.on('requestfailed', (req) => console.log('[reqfail]', req.url(), req.failure()?.errorText));
page.on('response', (resp) => {
const url = resp.url();
if (url.includes('/v1/graphql') || url.includes('/auth')) {
console.log(`[resp ${resp.status()}]`, url);
}
});
console.log('→ goto signin');
await page.goto(`${BASE}/${COOP}/auth/signin`, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForSelector('button:has-text("Войти")', { timeout: 60000 });
console.log('✓ signin form ready');
await page.locator('label:has-text("электронную почту")').locator('input').fill(fixture.email);
await page.locator('label:has-text("ключ доступа")').locator('input').fill(fixture.wif);
console.log('→ click Войти');
await page.locator('button:has-text("Войти")').click();
// Wait up to 15s and dump location periodically
for (let i = 0; i < 30; i++) {
await page.waitForTimeout(500);
const url = page.url();
if (!/auth\/signin/.test(url)) {
console.log(`✓ left signin → ${url} (after ${(i + 1) * 500}ms)`);
break;
}
if (i === 29) console.log(`✗ still on signin after 15s: ${url}`);
}
// Dump localStorage relevant keys
const lsState = await page.evaluate(() => {
const out = {};
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
out[k] = (localStorage.getItem(k) || '').slice(0, 200);
}
return out;
});
console.log('LOCAL STORAGE KEYS:', Object.keys(lsState));
for (const [k, v] of Object.entries(lsState)) {
if (/(auth|jwt|session|hasCre|user|wif)/i.test(k)) console.log(` ${k}=${v}`);
}
await page.screenshot({ path: '/home/admin/.claude/jobs/5a9b0a35/diag-after-login.png', fullPage: false });
await browser.close();
+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,49 @@
// Сценарий: Совет видит ленту проектов списания скоропорта в read-only.
// Эпик 8 / Story 8.x — обзор проектов в статусах ON_AGENDA / AUTHORIZED /
// EXECUTING / EXECUTED / REJECTED.
//
// Само голосование совета — через core soviet agenda (sov-flow);
// здесь только обзор статусов и сумм потерь. Polling 30s.
//
// Фикстура: chairman кооператива (ant, Иван Иванов) — у него же доступ
// member-совета по marketplace-roles.
import { loginAsChairman, dismissOnboardingDialogs } from '../../../lib/harness.mjs';
export const meta = {
title: 'Повестка совета: проекты списания скоропорта',
docPath: 'new/marketplace/board/agenda-writeoff.md',
assetsDir: 'assets/new/marketplace/board/agenda-writeoff',
role: 'chairman',
};
export default async ({ page, context, shot, env }) => {
await loginAsChairman(page, context);
await dismissOnboardingDialogs(page);
await page.goto(`${env.BASE_URL}/#/${env.COOPNAME}/market/board-writeoff`, {
waitUntil: 'domcontentloaded',
});
await page.waitForSelector('text=Повестка совета', { timeout: 60000 });
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1500);
await dismissOnboardingDialogs(page);
await shot(
page,
'01-overview',
'Лента проектов списания в активных статусах. DRAFT-проекты скрыты — это рабочая зона администратора на /market/writeoffs. Совет видит «На повестке», «Одобрены», «Исполняются», «Исполнены», «Отклонены». Голосование выполняется на core soviet agenda (sov-flow).',
);
// Переключиться на фильтр «На повестке» — самый важный для совета.
const onAgendaTab = page.locator('button:has-text("На повестке"), [role="tab"]:has-text("На повестке")').first();
if (await onAgendaTab.isVisible().catch(() => false)) {
await onAgendaTab.click();
await page.waitForTimeout(700);
await shot(
page,
'02-on-agenda',
'Фильтр «На повестке» — проекты в статусе ON_AGENDA. После подписи Заявления 1106 председателем (через /market/writeoffs) проект отправляется в совет через soviet::createagenda(mktwroff). По решению совета callback marketplace::onmktwoauth переводит проект в AUTHORIZED.',
);
}
};
@@ -0,0 +1,38 @@
// Сценарий: Совет открывает informational placeholder для выплат
// поставщикам. Эпик 5 / Story 5.x — раздел в подготовке.
//
// Текущий backend marketplaceListOutgoingPaymentsAsSupplier отдаёт только
// выплаты текущего пайщика-supplier'а. Для совета нужна доп. policy
// Payment / read:all в marketplace-access-matrix + query
// marketplaceListOutgoingPayments с опц. supplier_account. До этого —
// placeholder со ссылками на нужные файлы.
//
// Фикстура: chairman кооператива (ant).
import { loginAsChairman, dismissOnboardingDialogs } from '../../../lib/harness.mjs';
export const meta = {
title: 'Выплаты поставщикам — обзор совета (placeholder)',
docPath: 'new/marketplace/board/payouts-readonly.md',
assetsDir: 'assets/new/marketplace/board/payouts-readonly',
role: 'chairman',
};
export default async ({ page, context, shot, env }) => {
await loginAsChairman(page, context);
await dismissOnboardingDialogs(page);
await page.goto(`${env.BASE_URL}/#/${env.COOPNAME}/market/payouts`, {
waitUntil: 'domcontentloaded',
});
await page.waitForSelector('text=Выплаты поставщикам', { timeout: 60000 });
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(900);
await dismissOnboardingDialogs(page);
await shot(
page,
'01-placeholder',
'Раздел в подготовке: информационный экран с пояснением, что для совета нужна доп. policy Payment/read:all и query marketplaceListOutgoingPayments. До этого совет видит выплаты через core cassir-стол кооператива. Ссылки на access-matrix и резолвер выплат.',
);
};
@@ -0,0 +1,50 @@
// Сценарий: Сводный стол КУ (председателя кооператива).
// Эпик 6 / Story 6.x — Приёмки, Выдачи, Возвраты по braname.
//
// Замечание: меж-роли «председатель КУ» в desktop matrix нет — роут
// /market-pvz/* открыт только `roles: ['chairman']` (председатель
// кооператива). На текущем MVP председатель сам открывает стол КУ для
// аудита. Отдельный access-уровень «branch chairman» подключится
// в Story 6.x+1 вместе с auto-detect braname через marketplaceWhoAmI.
import { loginAsChairman, dismissOnboardingDialogs } from '../../../lib/harness.mjs';
export const meta = {
title: 'Сводный стол КУ: приёмки, выдачи, возвраты',
docPath: 'new/marketplace/branch-chairman/branch-orders.md',
assetsDir: 'assets/new/marketplace/branch-chairman/branch-orders',
role: 'chairman',
};
export default async ({ page, context, shot, env }) => {
await loginAsChairman(page, context);
await dismissOnboardingDialogs(page);
await page.goto(`${env.BASE_URL}/#/${env.COOPNAME}/market-pvz/branch-orders`, {
waitUntil: 'domcontentloaded',
});
await page.waitForSelector('text=Сводный стол кооперативного участка', { timeout: 60000 });
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1000);
await dismissOnboardingDialogs(page);
await shot(
page,
'01-empty-input',
'Сводный стол КУ до ввода braname: подсказка «Введите ID вашего КУ» + поле ввода. Auto-detect через marketplace_whoami появится на следующем шаге Story 6.x+1 — председатель КУ привязан к одному branch через trustee.',
);
const branameInput = page.locator('input[aria-label*="braname"], label:has-text("braname") input, label:has-text("ID кооперативного участка") input').first();
if (await branameInput.isVisible().catch(() => false)) {
await branameInput.fill('krg');
await page.locator('button:has-text("Загрузить")').click();
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1500);
await dismissOnboardingDialogs(page);
await shot(
page,
'02-loaded-tabs',
'После ввода braname КУ Красногорск (krg) — карта с 3 табами и счётчиками: Приёмки (Эпик 5), Выдачи (Эпик 6), Возвраты (Эпик 7). Polling 20s — обновляет все 3 ленты параллельно через Promise.all.',
);
}
};
@@ -0,0 +1,28 @@
// Сценарий: Стол ПВЗ — «ПВЗ кооператива».
// Список всех пунктов выдачи. Видно председателю КУ (operator/branch-chairman).
// Маршрут /<coopname>/market-pvz/list (workspace market-pvz).
import { cleanViteOverlays, env, loginAsChairman } from '../../../lib/harness.mjs';
export const meta = {
title: 'Стол председателя ПВЗ — список ПВЗ кооператива',
docPath: 'new/marketplace/chairman/branches.md',
assetsDir: 'assets/new/marketplace/chairman/branches',
role: 'chairman',
};
export default async ({ page, shot }) => {
await loginAsChairman(page);
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market-pvz/list`, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(4000);
await cleanViteOverlays(page);
await shot(
page,
'01-pvz-list',
`Список ПВЗ кооператива. URL: \`${page.url()}\`.`,
);
};
@@ -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', 'Развёрнутая карточка КУ Красногорск: реквизиты участка, банковский счёт, карточка председателя; кнопка «удалить» доступна.');
};
@@ -0,0 +1,52 @@
// Сценарий: Председатель настраивает whitelist доступных категорий товаров.
// Эпик 3 / Story 3.x — пустой whitelist означает, что доступен весь
// глобальный каталог; с whitelist'ом — только перечисленные категории.
//
// Backend `available-category-admin.resolver.ts` (@AuthRoles chairman).
// Tree-выбор через `marketplaceGetCategoryTree` подключится на следующем
// шаге; в MVP — диалог с ручным вводом ID через запятую.
//
// Фикстура: chairman кооператива (ant, Иван Иванов).
import { loginAsChairman, dismissOnboardingDialogs } from '../../../lib/harness.mjs';
export const meta = {
title: 'Whitelist категорий товаров кооператива',
docPath: 'new/marketplace/chairman/category-whitelist.md',
assetsDir: 'assets/new/marketplace/chairman/category-whitelist',
role: 'chairman',
};
export default async ({ page, context, shot, env }) => {
await loginAsChairman(page, context);
await dismissOnboardingDialogs(page);
// 1. Открыть страницу доступных категорий
await page.goto(`${env.BASE_URL}/#/${env.COOPNAME}/market/category-whitelist`, {
waitUntil: 'domcontentloaded',
});
await page.waitForSelector('text=Доступные категории', { timeout: 60000 });
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(900);
await dismissOnboardingDialogs(page);
await shot(
page,
'01-overview',
'Страница «Доступные категории»: 4 stat-карточки в шапке — всего записей, число категорий, число типов товаров и состояние whitelist (активен / открыт каталог). Пустой whitelist означает, что пайщикам доступен весь глобальный каталог категорий.',
);
// 2. Открыть диалог добавления категорий
const addBtn = page.locator('button:has-text("Добавить")').first();
if (await addBtn.isVisible().catch(() => false)) {
await addBtn.click();
await page.waitForSelector('text=Добавить категории в whitelist', { timeout: 10000 });
await page.waitForTimeout(500);
await shot(
page,
'02-add-dialog',
'Диалог добавления: в MVP — ручной ввод ID через запятую (1, 7, 15). На следующем шаге Story 3.x подключится tree-выбор через marketplaceGetCategoryTree — выбор по дереву категорий и типов.',
);
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
}
};
@@ -0,0 +1,64 @@
// Сценарий: Стол председателя кооператива → обзорная экскурсия по marketplace URLs.
// Делает login как chairman (`ant`/`ivanov@example.com`), затем напрямую
// goto к каждому marketplace-маршруту и снимает что вышло.
//
// На текущем стенде session.loadComplete зависает на 5с (init-wallet падает),
// поэтому waitForURL после клика «Войти» бесполезен — используем waitForTimeout
// и goto. После клика «Войти» token успевает сохраниться в IndexedDB, что
// позволяет последующим goto'ам захватить authenticated layout (хедер).
import { cleanViteOverlays, env } from '../../../lib/harness.mjs';
export const meta = {
title: 'Стол председателя ПК: экскурсия по marketplace',
docPath: 'new/marketplace/chairman/dashboard.md',
assetsDir: 'assets/new/marketplace/chairman/dashboard',
role: 'chairman',
};
// Маршруты Стола председателя (и встроенные роуты marketplace под /chairman/extensions)
const CHAIRMAN_MENU = [
{ name: '01-onboarding', text: 'Онбординг', caption: 'Онбординг: подписание соглашений и активация кооператива' },
{ name: '02-approvals', text: 'Запросы одобрений', caption: 'Запросы одобрений: входящие действия пайщиков, ждущие подписи председателя' },
{ name: '03-extensions', text: 'Магазин приложений', caption: 'Магазин приложений: установка расширений (Стол заказов, бухгалтерия и т. п.)' },
{ name: '04-system-settings', text: 'Стартовые страницы', caption: 'Стартовые страницы: настройка маршрутов после входа для ролей' },
{ name: '05-members', text: 'Члены совета', caption: 'Члены совета: реестр и роли совета кооператива' },
{ name: '06-branches', text: 'Кооперативные Участки', caption: 'Кооперативные Участки: реестр ПВЗ (Красногорск / Одинцово / Мытищи)' },
{ name: '07-register-payments', text: 'Регистрационные взносы', caption: 'Регистрационные взносы: тарифы вступления и членства' },
{ name: '08-cooperative-key', text: 'Ключ кооператива', caption: 'Ключ кооператива: ротация active-ключа председательской подписи' },
{ name: '09-payment-provider', text: 'Провайдер платежей', caption: 'Провайдер платежей: настройка интеграции для приёма платежей' },
{ name: '10-contacts', text: 'Контакты кооператива', caption: 'Контакты кооператива: реквизиты для писем и счетов' },
];
export default async ({ page, shot, context }) => {
// --- Login (hash router: URL вида #/<coopname>/...)
await page.goto(`${env.BASE_URL}/#/${env.COOPNAME}/auth/signin`, { waitUntil: 'domcontentloaded', timeout: 60000 });
// Harness escape hatch: отключаем branch-overlay чтобы можно было снять скриншоты
// всех разделов Стола председателя без процедуры выбора КУ.
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
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/, { timeout: 30000 });
await page.waitForTimeout(4000);
await cleanViteOverlays(page);
await shot(page, '00-after-login', 'Сразу после клика «Войти» — председатель попадает на онбординг Стола председателя. Слева — меню всех разделов (Онбординг, Запросы одобрений, Магазин приложений и др.).');
// --- Обход меню председателя кликом по пункту, чтобы не триггерить branch-overlay
for (const item of CHAIRMAN_MENU) {
const link = page.locator(`a:has-text("${item.text}"), .q-item:has-text("${item.text}")`).first();
if (!(await link.isVisible().catch(() => false))) {
console.warn(` skip "${item.text}" — не нашли пункт меню`);
continue;
}
await link.click({ trial: false }).catch(() => {});
await page.waitForTimeout(4500);
await cleanViteOverlays(page);
await shot(page, item.name, item.caption);
}
};
@@ -0,0 +1,62 @@
// Сценарий: Стол председателя → Дизайн-система marketplace (Эпик 10).
// Витрина 13 custom-компонентов для утверждения председателем/советом
// до их применения в эпиках 1-9. Не выходит в production-меню пайщикам.
import { cleanViteOverlays, env } from '../../../lib/harness.mjs';
export const meta = {
title: 'Дизайн-система Стола заказов',
docPath: 'new/marketplace/chairman/design-system.md',
assetsDir: 'assets/new/marketplace/chairman/design-system',
role: 'chairman',
};
export default async ({ page, shot }) => {
// Login as chairman через стандартный pattern.
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/auth/signin`, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
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.waitForFunction(
() => !/auth\/signin/.test(window.location.href),
{ timeout: 30000 },
).catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
// Дизайн-система — длинная скроллящаяся страница; снимем 3 проекта
// через window.scrollTo, чтобы пройтись по верху, середине и низу.
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market/design-system`, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(3000);
await cleanViteOverlays(page);
await shot(
page,
'01-top',
'Дизайн-система Стола заказов — верхняя часть витрины компонентов',
);
// Прокрутить вниз на 1 viewport
await page.evaluate(() => window.scrollTo({ top: window.innerHeight, behavior: 'instant' }));
await page.waitForTimeout(1500);
await cleanViteOverlays(page);
await shot(
page,
'02-middle',
'Дизайн-система: средняя часть витрины (после первой прокрутки)',
);
// Прокрутить ещё ниже
await page.evaluate(() => window.scrollTo({ top: window.innerHeight * 2.5, behavior: 'instant' }));
await page.waitForTimeout(1500);
await cleanViteOverlays(page);
await shot(
page,
'03-bottom',
'Дизайн-система: нижняя часть витрины',
);
};
@@ -0,0 +1,28 @@
// Сценарий: admin-стол «Экосистема» (Story 9.4).
// Read-only реестр controller'ов других кооперативов с расширением Стола
// заказов. MVP — заглушка до подключения platform ecosystem_registry.
import { cleanViteOverlays, env, loginAsChairman } from '../../../lib/harness.mjs';
export const meta = {
title: 'Стол председателя — реестр экосистемы',
docPath: 'new/marketplace/chairman/ecosystem.md',
assetsDir: 'assets/new/marketplace/chairman/ecosystem',
role: 'chairman',
};
export default async ({ page, shot }) => {
await loginAsChairman(page);
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market/ecosystem`, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(3000);
await cleanViteOverlays(page);
await shot(
page,
'01-ecosystem',
`Реестр экосистемы кооперативов с расширением Стола заказов. URL: \`${page.url()}\`.`,
);
};
@@ -0,0 +1,50 @@
// Сценарий: Стол председателя → экскурсия по разделам «Стола заказов» (marketplace).
// Авторизуется как chairman (ant) и перебирает все маршруты /market/* и /market-pvz/*,
// используя harness:noBranchOverlay чтобы обойти выбор кооп. участка.
import { cleanViteOverlays, env } from '../../../lib/harness.mjs';
export const meta = {
title: 'Стол председателя: разделы Стола заказов',
docPath: 'new/marketplace/chairman/market-tour.md',
assetsDir: 'assets/new/marketplace/chairman/market-tour',
role: 'chairman',
};
const ROUTES = [
{ name: '01-catalog', path: '/market/catalog', caption: 'Каталог Стола заказов: список предложений товаров и услуг' },
{ name: '02-create-offer', path: '/market/create-offer', caption: 'Форма «Создать предложение» — добавление товара/услуги в каталог' },
{ name: '03-my-orders', path: '/market/my-orders', caption: 'Мои заказы: лента заказов председателя как заказчика' },
{ name: '04-ready-to-receive', path: '/market/ready-to-receive', caption: 'Готовые к выдаче: заказы со статусом READY_TO_RECEIVE на пункте выдачи' },
{ name: '05-returns', path: '/market/returns', caption: 'Возвраты: заявки на возврат товара от заказчиков' },
{ name: '06-writeoffs', path: '/market/writeoffs', caption: 'Списания: запросы на списание со склада, ждущие одобрения совета' },
{ name: '07-warehouse-summary',path: '/market/warehouse-summary', caption: 'Склад кооператива: сводный остаток товаров по всем ПВЗ' },
{ name: '08-ecosystem', path: '/market/ecosystem', caption: 'Реестр экосистемы: участники marketplace (заказчики, поставщики, операторы)' },
{ name: '09-pvz-list', path: '/market-pvz/list', caption: 'Пункты выдачи: список ПВЗ кооператива' },
{ name: '10-pvz-issuance', path: '/market-pvz/issuance', caption: 'Выдача заказов на ПВЗ: реестр товаров, ждущих выдачи заказчику' },
{ name: '11-pvz-returns', path: '/market-pvz/returns', caption: 'Возвраты на ПВЗ: обработка возвратов оператором' },
{ name: '12-pvz-warehouse', path: '/market-pvz/warehouse', caption: 'Склад ПВЗ: текущий остаток у конкретного пункта выдачи' },
];
export default async ({ page, shot, context }) => {
// Login
await page.goto(`${env.BASE_URL}/#/${env.COOPNAME}/auth/signin`, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
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/, { timeout: 30000 });
await page.waitForTimeout(4000);
// Tour по marketplace разделам
for (const route of ROUTES) {
const url = `${env.BASE_URL}/#/${env.COOPNAME}${route.path}`;
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(4500);
await cleanViteOverlays(page);
await shot(page, route.name, `${route.caption} (URL: \`#/${env.COOPNAME}${route.path}\`)`);
}
};
@@ -0,0 +1,62 @@
// Сценарий: admin-стол «Модерация предложений» (Эпик 3 / Story 3.6).
// Председатель видит ленту offer'ов в статусе PENDING_MODERATION,
// поданных поставщиками через `marketplaceCreateOffer`. Одобрение через
// `marketplaceApproveOffer` переводит offer в APPROVED → виден в публичном
// каталоге Story 3.5. Канон UI — CatalogOfferCard (UX-DR10) с slot actions.
import { cleanViteOverlays, env, loginAsChairman } from '../../../lib/harness.mjs';
export const meta = {
title: 'Стол председателя — модерация предложений',
docPath: 'new/marketplace/chairman/offer-moderation.md',
assetsDir: 'assets/new/marketplace/chairman/offer-moderation',
role: 'chairman',
};
export default async ({ page, shot }) => {
await loginAsChairman(page);
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market/moderation`, {
waitUntil: 'domcontentloaded',
timeout: 45000,
});
// Ждём заголовок страницы — надёжнее timeout'а на холодный Vite optimizeDeps.
await page.locator('text="Модерация предложений"').first()
.waitFor({ state: 'visible', timeout: 90000 })
.catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(1500);
await cleanViteOverlays(page);
await shot(
page,
'01-moderation-list',
`Лента offer'ов в статусе PENDING_MODERATION, ожидающих одобрения председателем. URL: \`${page.url()}\`. Каждая карточка — CatalogOfferCard (UX-DR10) со статус-чипом «На модерации» и per-card action «Одобрить».`,
);
// Если в ленте есть хотя бы один offer — открыть диалог подтверждения и снять.
const firstApprove = page.locator('button:has-text("Одобрить")').first();
if (await firstApprove.count()) {
await firstApprove.click();
await page.waitForTimeout(700);
await cleanViteOverlays(page);
await shot(
page,
'02-moderation-confirm-dialog',
'Диалог подтверждения одобрения. По «Одобрить» offer переходит в статус APPROVED и сразу появляется в публичном каталоге Story 3.5. По «Отмена» статус остаётся PENDING_MODERATION.',
);
// Подтвердить одобрение и снять обновлённую ленту.
const okBtn = page.locator('.q-dialog button:has-text("Одобрить")').last();
await okBtn.click();
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1500);
await cleanViteOverlays(page);
await shot(
page,
'03-moderation-after-approve',
'Лента модерации после одобрения. Одобренный offer пропадает из очереди и сразу доступен в публичном каталоге кооператива.',
);
}
};
@@ -0,0 +1,28 @@
// Сценарий: admin-стол «Сводный склад кооператива» (Story 9.2).
// WarehouseSummaryGrid (UX-DR16) поверх marketplace_inventory:
// приход/расход/остаток по ku × sku. Доступ chairman/member.
import { cleanViteOverlays, env, loginAsChairman } from '../../../lib/harness.mjs';
export const meta = {
title: 'Стол председателя — сводный склад',
docPath: 'new/marketplace/board/warehouse-readonly.md',
assetsDir: 'assets/new/marketplace/board/warehouse-readonly',
role: 'chairman',
};
export default async ({ page, shot }) => {
await loginAsChairman(page);
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market/warehouse-summary`, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(3000);
await cleanViteOverlays(page);
await shot(
page,
'01-warehouse-summary-empty',
`Сводный склад кооператива (admin). URL: \`${page.url()}\`.`,
);
};
@@ -0,0 +1,30 @@
// Сценарий: chairman-стол списаний (Story 8.7).
// Председатель собирает черновик проекта списания (вручную или из крон-предложения),
// подписывает Заявление 1106 и отправляет проект в совет через propwroff +
// soviet::createagenda(type=mktwroff). Backend проводит per-item списания
// через execwroff (пары o.mkt.wroff + o.mkt.wroff2).
import { cleanViteOverlays, env, loginAsChairman } from '../../../lib/harness.mjs';
export const meta = {
title: 'Стол председателя — списания скоропорта',
docPath: 'new/marketplace/chairman/writeoff-propose.md',
assetsDir: 'assets/new/marketplace/chairman/writeoff-propose',
role: 'chairman',
};
export default async ({ page, shot }) => {
await loginAsChairman(page);
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market/writeoffs`, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(3000);
await cleanViteOverlays(page);
await shot(
page,
'01-writeoffs-empty',
`Раздел «Списания скоропорта» председателя. URL: \`${page.url()}\`.`,
);
};
@@ -0,0 +1,32 @@
// Сценарий: offerer-стол «Подпись приёмки» (Эпик 5 / Story 5.7).
// Поставщик ставит первую подпись signapl1 на акте приёмки партии ПВЗ.
// После этого ПВЗ закрывает акт signapl2. На пустом стенде ожидающих
// подписи актов нет — показывается заглушка.
import { cleanViteOverlays, env, loginAsChairman } from '../../../lib/harness.mjs';
export const meta = {
title: 'Стол поставщика — подпись приёмки',
docPath: 'new/marketplace/offerer/apl-reception-sign.md',
assetsDir: 'assets/new/marketplace/offerer/apl-reception-sign',
role: 'chairman',
};
export default async ({ page, shot }) => {
await loginAsChairman(page);
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market/apl-receptions`, {
waitUntil: 'domcontentloaded',
timeout: 45000,
});
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(3500);
await cleanViteOverlays(page);
await shot(
page,
'01-apl-receptions-empty',
`Стол «Подпись приёмки» поставщика. URL: \`${page.url()}\`. Empty state: ожидающих первой подписи signapl1 актов приёмки на стенде нет.`,
);
};
@@ -0,0 +1,136 @@
// Сценарий: Поставщик видит входящие заказы по своим Offer'ам.
// Эпик 4 / Story 4.5 — read-обзор заказов где supplier = текущий пайщик.
//
// Канон OrderCard с role='offerer'. Фильтр по статусу через q-tabs.
// Действия по акцепту партии — на отдельной странице «Подготовка отгрузки».
//
// Фикстура: ivanpetrov (как и offer-create.mjs — единая модель пайщика
// для marketplace MVP scenarios).
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loginAs, env, cleanViteOverlays } from '../../../lib/harness.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const loadFixture = (username) =>
JSON.parse(
fs.readFileSync(path.resolve(__dirname, `../../../state/participants/${username}.json`), 'utf8'),
);
async function signAllAgreements(page) {
await page.waitForFunction(
() => !document.body.innerText.includes('Формируем документ'),
{ timeout: 30000 },
).catch(() => {});
await page.waitForTimeout(500);
for (let i = 0; i < 8; i++) {
const clicked = await page.evaluate(() => {
const portals = Array.from(document.querySelectorAll('[id^="q-portal--dialog--"]'))
.filter((p) => getComputedStyle(p).display !== 'none');
if (portals.length === 0) return false;
const top = portals[portals.length - 1];
const btn = Array.from(top.querySelectorAll('button'))
.find((b) => b.textContent?.trim() === 'Подписать' && !b.disabled);
if (!btn) return false;
btn.scrollIntoView({ block: 'center', behavior: 'instant' });
btn.click();
return true;
});
if (!clicked) break;
await page.waitForTimeout(3500);
}
}
export const meta = {
title: 'Входящие заказы поставщика',
docPath: 'new/marketplace/offerer/incoming-orders.md',
assetsDir: 'assets/new/marketplace/offerer/incoming-orders',
role: 'user',
fixture: 'ivanpetrov',
};
export default async ({ page, shot }) => {
const fixture = loadFixture('ivanpetrov');
// Флаг harness:noBranchOverlay читается в watch-branch-overlay при mount'е
// процесса (на первом auth-tick), а не реактивно на изменение localStorage.
// Поэтому ставим его через addInitScript ДО loginAs — иначе оверлей «Выберите
// КУ» рендерится первым и перекрывает страницу incoming-orders.
await page.addInitScript(() => localStorage.setItem('harness:noBranchOverlay', '1'));
await loginAs(page, fixture);
await signAllAgreements(page);
await page.goto(`${env.BASE_URL}/#/${env.COOPNAME}/market/incoming-orders`, {
waitUntil: 'domcontentloaded',
});
await page.waitForSelector('text=Входящие заказы', { timeout: 90000 });
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1500);
await cleanViteOverlays(page);
await shot(
page,
'01-overview',
'Стол поставщика: лента входящих заказов от пайщиков. Фильтр по статусу через табы — «Все», «Ждут моего акцепта», «Индивидуальные ожидающие», «Приняты», «Поставка готова», «Приняты кооперативом», «Получены», «Отменены». Polling 15s.',
);
const pendingTab = page.locator('[role="tab"]:has-text("Ждут моего акцепта"), button:has-text("Ждут моего акцепта")').first();
if (await pendingTab.isVisible().catch(() => false)) {
await pendingTab.click().catch(() => {});
await page.waitForTimeout(900);
await cleanViteOverlays(page);
await shot(
page,
'02-pending-filter',
'Фильтр «Ждут моего акцепта» — заказы в статусе ACCEPTED_PENDING_SUPPLIER, которые поставщику нужно принять или отказаться через TakeoverDialog Эпика 5 на /market/supply-prep.',
);
}
// Шаг 4 magistral II: поставщик акцептует индивидуальный заказ кликом
// «Принять» на карточке. Backend выполняет mutation
// marketplaceAcceptIndividualOrder → on-chain acceptorder → Order
// переходит в ACCEPTED.
const individualTab = page.locator('[role="tab"]:has-text("Индивидуальные ожидающие"), button:has-text("Индивидуальные ожидающие")').first();
if (await individualTab.isVisible().catch(() => false)) {
await individualTab.click().catch(() => {});
// Ждём конкретно карточку заказа или empty-state — спиннер q-inner-loading
// отображается пока fetchSupplierOrders в полёте.
await page.locator('.mp-order-card, .mp-incoming-orders__empty').first()
.waitFor({ state: 'visible', timeout: 30000 })
.catch(() => {});
await page.waitForTimeout(800);
await cleanViteOverlays(page);
await shot(
page,
'03-individual-pending',
'Фильтр «Индивидуальные ожидающие» — заказы со cycle_type=individual в статусе ACCEPTED_PENDING_SUPPLIER_INDIVIDUAL. Поставщик решает по каждому отдельно: «Принять» (mutation marketplaceAcceptIndividualOrder) или «Отказать» с указанием причины.',
);
const acceptBtn = page.locator('button:has-text("Принять")').first();
if (await acceptBtn.isVisible().catch(() => false)) {
await acceptBtn.click();
// Ждём Notify «Заказ принят» (positive). Был false-positive на `Принят`,
// т.к. это часть названия вкладки «Приняты» — её текст уже в DOM до клика.
await page.waitForFunction(
() => {
const notifs = document.querySelectorAll('.q-notification__message');
for (const n of notifs) {
if ((n.textContent || '').includes('Заказ принят')) return true;
}
return false;
},
{ timeout: 25000 },
).catch(() => {});
await page.waitForTimeout(1500);
await cleanViteOverlays(page);
await shot(
page,
'04-after-accept',
'После клика «Принять»: Notify «Заказ принят» (positive), Order переходит в статус ACCEPTED, исчезает из таба «Индивидуальные ожидающие» и появляется в табе «Приняты». Заблокированные средства пайщика остаются под BLOCK до выдачи имущества.',
{ preserveNotifications: true },
);
}
}
};
@@ -0,0 +1,88 @@
// Сценарий: Поставщик видит свои Offer'ы во всех 4 статусах.
// Эпик 3 / Story 3.4 — обзор Предложений поставщика.
//
// Канон CatalogOfferCard, client-side фильтр через q-btn-toggle, поиск
// по названию. Для REJECTED — reject_reason под карточкой.
//
// Фикстура: ivanpetrov (единая модель пайщика для marketplace MVP).
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loginAs, env, cleanViteOverlays } from '../../../lib/harness.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const loadFixture = (username) =>
JSON.parse(
fs.readFileSync(path.resolve(__dirname, `../../../state/participants/${username}.json`), 'utf8'),
);
async function signAllAgreements(page) {
await page.waitForFunction(
() => !document.body.innerText.includes('Формируем документ'),
{ timeout: 30000 },
).catch(() => {});
await page.waitForTimeout(500);
for (let i = 0; i < 8; i++) {
const clicked = await page.evaluate(() => {
const portals = Array.from(document.querySelectorAll('[id^="q-portal--dialog--"]'))
.filter((p) => getComputedStyle(p).display !== 'none');
if (portals.length === 0) return false;
const top = portals[portals.length - 1];
const btn = Array.from(top.querySelectorAll('button'))
.find((b) => b.textContent?.trim() === 'Подписать' && !b.disabled);
if (!btn) return false;
btn.scrollIntoView({ block: 'center', behavior: 'instant' });
btn.click();
return true;
});
if (!clicked) break;
await page.waitForTimeout(3500);
}
}
export const meta = {
// docPath/assetsDir выровнены на existing offers.md (Vue-page = OffererMyOffers,
// но MD-файл уже создан под именем offers.md с прозой из PRD; имя сценария
// оставлено my-offers.mjs для совместимости с command line).
title: 'Мои предложения: каталог Offer\'ов поставщика',
docPath: 'new/marketplace/offerer/offers.md',
assetsDir: 'assets/new/marketplace/offerer/offers',
role: 'user',
fixture: 'ivanpetrov',
};
export default async ({ page, shot }) => {
const fixture = loadFixture('ivanpetrov');
await loginAs(page, fixture);
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
await signAllAgreements(page);
await page.goto(`${env.BASE_URL}/#/${env.COOPNAME}/market/my-offers`, {
waitUntil: 'domcontentloaded',
});
await page.waitForSelector('text=Мои предложения', { timeout: 60000 });
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1500);
await cleanViteOverlays(page);
await shot(
page,
'01-overview',
'Стол поставщика «Мои предложения»: 4 stat-карточки в шапке (всего / активных / на модерации / отклонены), поиск по названию и фильтр по статусу. Карточки CatalogOfferCard со статусом-чипом (Опубликовано / На модерации / Отклонено / Снято).',
);
const pendingBtn = page.locator('button:has-text("На модерации")').first();
if (await pendingBtn.isVisible().catch(() => false)) {
await pendingBtn.click().catch(() => {});
await page.waitForTimeout(700);
await cleanViteOverlays(page);
await shot(
page,
'02-pending-filter',
'Фильтр «На модерации» — Offer\'ы в статусе PENDING_MODERATION ждут решения председателя на /market/moderation. После APPROVED попадают в публичный каталог Story 3.5.',
);
}
};
@@ -0,0 +1,124 @@
// Сценарий: Стол поставщика → создание предложения.
// Пайщик ivanpetrov входит на /market/create-offer, заполняет форму нового
// предложения и отправляет. Снимки: пустая форма → заполненная → каталог с
// новой карточкой.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { cleanViteOverlays, env, loginAs } from '../../../lib/harness.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const loadFixture = (username) =>
JSON.parse(
fs.readFileSync(path.resolve(__dirname, `../../../state/participants/${username}.json`), 'utf8'),
);
export const meta = {
title: 'Стол поставщика: создание предложения',
docPath: 'new/marketplace/offerer/offer-create.md',
assetsDir: 'assets/new/marketplace/offerer/offer-create',
role: 'user',
fixture: 'ivanpetrov',
fixtures: ['ivanpetrov'],
};
async function signAllAgreements(page) {
await page.waitForFunction(
() => !document.body.innerText.includes('Формируем документ'),
{ timeout: 30000 },
).catch(() => {});
await page.waitForTimeout(500);
for (let i = 0; i < 8; i++) {
const clicked = await page.evaluate(() => {
const portals = Array.from(document.querySelectorAll('[id^="q-portal--dialog--"]'))
.filter((p) => getComputedStyle(p).display !== 'none');
if (portals.length === 0) return false;
const top = portals[portals.length - 1];
const btn = Array.from(top.querySelectorAll('button'))
.find((b) => b.textContent?.trim() === 'Подписать' && !b.disabled);
if (!btn) return false;
btn.scrollIntoView({ block: 'center', behavior: 'instant' });
btn.click();
return true;
});
if (!clicked) break;
await page.waitForTimeout(3500);
}
}
export default async ({ page, shot }) => {
const fixture = loadFixture('ivanpetrov');
await loginAs(page, fixture);
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
await signAllAgreements(page);
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.evaluate(() => {
document.querySelectorAll('.q-notification').forEach((n) => n.remove());
});
await page.waitForTimeout(2000);
// --- 01. Пустая форма создания предложения ---
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market/create-offer`, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(3000);
await cleanViteOverlays(page);
await shot(
page,
'01-empty-form',
'Форма «Новое предложение» сразу после открытия: все поля пустые',
);
// --- 02. Форма с заполненными базовыми полями ---
// Заполняем то, что точно есть на форме: название, описание, цена.
// Поля под label'ами — стандартная Quasar-структура.
const setField = async (labelText, value) => {
const loc = page.locator(`label:has-text("${labelText}")`).first();
if (await loc.count() === 0) return false;
// .locator('input,textarea') внутри label
const inputLoc = loc.locator('input, textarea').first();
if (await inputLoc.count() === 0) return false;
await inputLoc.fill(value).catch(() => {});
return true;
};
await setField('Название', 'Берёзовый сок ПК «Восход» (демо)');
await setField('Описание', 'Свежий берёзовый сок, разлив 1 л. Поставка через ПВЗ Красногорск.');
await setField('Цена', '120');
await setField('Доступное количество', '50');
await setField('Гарантия', '7');
await page.waitForTimeout(1000);
await cleanViteOverlays(page);
await shot(
page,
'02-filled-form',
'Та же форма с заполненными полями: название, описание, цена, количество, гарантия',
);
// --- 03. После клика «Создать»: либо успех + редирект, либо валидационная подсветка ---
const submitBtn = page.locator('button:has-text("Создать")').first();
if (await submitBtn.count() > 0) {
await submitBtn.click().catch(() => {});
await page.waitForTimeout(5000);
await cleanViteOverlays(page);
await shot(
page,
'03-after-submit',
'Состояние UI после клика «Создать»: либо подтверждение, либо подсветка незаполненных полей',
);
}
// --- 04. Каталог после создания ---
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market/catalog`, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(3000);
await cleanViteOverlays(page);
await shot(
page,
'04-catalog-after',
'Каталог Стола заказов после создания предложения',
);
};
@@ -0,0 +1,33 @@
// Сценарий: offerer-стол «История выплат» (Эпик 5 / Story 5.9).
// Поставщик видит список MarketplaceOutgoingPaymentRequest по своим
// закрытым актам приёмки — статусы INITIATED / CONFIRMED / FAILED, дата,
// сумма, валюта, банковский референс и назначение. На пустом стенде
// выплат ещё нет, страница показывает empty-state.
import { cleanViteOverlays, env, loginAsChairman } from '../../../lib/harness.mjs';
export const meta = {
title: 'Стол поставщика — история выплат',
docPath: 'new/marketplace/offerer/payments.md',
assetsDir: 'assets/new/marketplace/offerer/payments',
role: 'chairman',
};
export default async ({ page, shot }) => {
await loginAsChairman(page);
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market/payments`, {
waitUntil: 'domcontentloaded',
timeout: 45000,
});
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(3500);
await cleanViteOverlays(page);
await shot(
page,
'01-payments-empty',
`Стол «История выплат» поставщика. URL: \`${page.url()}\`. Empty state: на стенде ещё нет закрытых актов приёмки, поэтому платёжных запросов от кооператива не сформировано.`,
);
};
@@ -0,0 +1,37 @@
// Сценарий: offerer-стол «Подготовка отгрузки» (Эпик 5 / Story 5.5).
// Поставщик видит сводные заказы в статусах CONFIRMED → SHIPPING и
// подтверждает готовность отгрузки. На пустом стенде после reboot:extra
// сводных заказов ещё нет, показывается заглушка.
//
// Логин — за председателем кооператива (ant), как и в других marketplace
// сценариях harness'а: chairman имеет доступ ко всем рабочим столам пайщика
// для целей документации; полная фикстура `sidorov` для multi-account
// прогона потока II будет в магистрали II PLAN.md §9.4.
import { cleanViteOverlays, env, loginAsChairman } from '../../../lib/harness.mjs';
export const meta = {
title: 'Стол поставщика — подготовка отгрузки',
docPath: 'new/marketplace/offerer/shipment-prep.md',
assetsDir: 'assets/new/marketplace/offerer/shipment-prep',
role: 'chairman',
};
export default async ({ page, shot }) => {
await loginAsChairman(page);
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market/supply-prep`, {
waitUntil: 'domcontentloaded',
timeout: 45000,
});
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(3500);
await cleanViteOverlays(page);
await shot(
page,
'01-supply-prep-empty',
`Стол «Подготовка отгрузки» поставщика. URL: \`${page.url()}\`. Empty state: сводных заказов CONFIRMED, ожидающих отгрузки, на стенде нет.`,
);
};
@@ -0,0 +1,52 @@
// Сценарий: Председатель кооператива принимает ЦПП «Стол заказов».
// Эпик 1 / Story 1.9-1.10 — L1 онбординг кооператива.
//
// Председатель видит статус ЦПП (active / not_accepted) и кнопку
// «Принять ЦПП Marketplace». После клика — диалог подтверждения с
// stub `accepted_by_board_decision_id` (в MVP — текстовая ссылка;
// полноценная повестка совета — FR40 / Эпик 8, Phase 2).
//
// Фикстура: chairman кооператива (ant, Иван Иванов).
import { loginAsChairman, dismissOnboardingDialogs } from '../../../lib/harness.mjs';
export const meta = {
title: 'L1 онбординг — приём ЦПП «Стол заказов» кооперативом',
docPath: 'new/marketplace/onboarding/coop-accept-cpp.md',
assetsDir: 'assets/new/marketplace/onboarding/coop-accept-cpp',
role: 'chairman',
};
export default async ({ page, context, shot, env }) => {
await loginAsChairman(page, context);
await dismissOnboardingDialogs(page);
// 1. Открыть страницу подключения ЦПП
await page.goto(`${env.BASE_URL}/#/${env.COOPNAME}/market/onboarding/coop-cpp`, {
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-status',
'Страница подключения ЦПП «Стол заказов»: chip справа показывает текущий статус расширения (Подключено / Не подключено). Карточка под шапкой раскрывает статус, реестр оферты, дату принятия и решение совета.',
);
// 2. Если не принято — открыть диалог подтверждения принятия
const acceptBtn = page.locator('button:has-text("Принять ЦПП Marketplace")');
if (await acceptBtn.isVisible().catch(() => false)) {
await acceptBtn.click();
await page.waitForSelector('text=Принять ЦПП', { timeout: 10000 });
await page.waitForTimeout(600);
await shot(
page,
'02-confirm-dialog',
'Диалог подтверждения принятия ЦПП «Стол заказов». В MVP — stub решения совета; полноценная повестка подключится в Эпике 8 (FR40).',
);
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
}
};
@@ -0,0 +1,146 @@
// Сценарий: Onboarding L3 — гейт первого входа на Стол заказов.
// Цель: показать реальный UX свежей пайщицы Екатерины при первом посещении
// marketplace. Снимаем последовательно: стартовый экран после логина (стек
// диалогов соглашений), попытку открыть /market/catalog до подписания
// соглашений и итоговое состояние после подписания всех соглашений.
//
// Фикстура: ekaterina / Екатерина Александровна Смирнова — создаётся
// автоматически через bin/shoot.mjs (KNOWN_FIXTURES) при первом прогоне.
// Если фикстура уже есть на стенде с раннего сценария — гейт пройти не
// получится (она уже подписала всё). Тогда лучше прогнать с --reboot.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { cleanViteOverlays, env, loginAs } from '../../../lib/harness.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const loadFixture = (username) =>
JSON.parse(
fs.readFileSync(path.resolve(__dirname, `../../../state/participants/${username}.json`), 'utf8'),
);
export const meta = {
title: 'Онбординг L3 — гейт первого входа на Стол заказов',
docPath: 'new/marketplace/onboarding/extension-gate.md',
assetsDir: 'assets/new/marketplace/onboarding/extension-gate',
role: 'user',
fixture: 'ekaterina',
fixtures: ['ekaterina'],
};
// Снимает один диалог подписания: возвращает {present, title} —
// если диалога нет, present=false; если есть — present=true и заголовок диалога.
async function inspectTopDialog(page) {
return page.evaluate(() => {
const portals = Array.from(document.querySelectorAll('[id^="q-portal--dialog--"]'))
.filter((p) => getComputedStyle(p).display !== 'none');
if (portals.length === 0) return { present: false };
const top = portals[portals.length - 1];
// q-card-section с заголовком обычно идёт первой
const titleEl = top.querySelector('.q-card-section .text-h6, .q-card-section h6, .text-h6');
const title = titleEl?.textContent?.trim()
?? top.querySelector('.q-card-section')?.textContent?.trim()?.slice(0, 80)
?? '';
const hasSignButton = Array.from(top.querySelectorAll('button'))
.some((b) => b.textContent?.trim() === 'Подписать' && !b.disabled);
return { present: true, title, hasSignButton };
});
}
// Подписать один верхний диалог; возвращает true если кликнул.
async function signOneDialog(page) {
const clicked = await page.evaluate(() => {
const portals = Array.from(document.querySelectorAll('[id^="q-portal--dialog--"]'))
.filter((p) => getComputedStyle(p).display !== 'none');
if (portals.length === 0) return false;
const top = portals[portals.length - 1];
const btn = Array.from(top.querySelectorAll('button'))
.find((b) => b.textContent?.trim() === 'Подписать' && !b.disabled);
if (!btn) return false;
btn.scrollIntoView({ block: 'center', behavior: 'instant' });
btn.click();
return true;
});
if (clicked) await page.waitForTimeout(3500);
return clicked;
}
export default async ({ page, shot }) => {
const fixture = loadFixture('ekaterina');
await loginAs(page, fixture);
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
// Дать UI собрать первый диалог (формируем документ → готовый PDF).
await page.waitForFunction(
() => !document.body.innerText.includes('Формируем документ'),
{ timeout: 30000 },
).catch(() => {});
await page.waitForTimeout(1500);
await cleanViteOverlays(page);
// --- 01. Стек диалогов сразу после логина новой пайщицы ---
const first = await inspectTopDialog(page);
await shot(
page,
'01-onboarding-stack',
`Стек онбординг-диалогов сразу после первого входа Екатерины. Верхний диалог: «${first.title || 'нет'}»`,
);
// --- 02. Попытка зайти в Стол заказов до подписания каких-либо соглашений ---
// Открываем /market/catalog в новой вкладке такого же контекста через page.goto.
// Если расширение строит гейт — увидим его поверх онбординг-диалогов или
// увидим редирект на /user/wallet с висящим стеком соглашений.
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market/catalog`, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(2500);
await cleanViteOverlays(page);
const second = await inspectTopDialog(page);
await shot(
page,
'02-market-before-signing',
`Попытка открыть /market/catalog ДО подписания соглашений. URL: \`${page.url()}\`. Верхний диалог: «${second.title || 'нет'}»`,
);
// --- 03. Подписываем все доступные диалоги-соглашения (общие + ЦПП Кошелёк) ---
// signAllAgreements: до 8 итераций; каждый клик «Подписать» закрывает
// верхний диалог стека.
let dialogsSigned = 0;
for (let i = 0; i < 8; i++) {
const ok = await signOneDialog(page);
if (!ok) break;
dialogsSigned += 1;
}
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(2000);
await cleanViteOverlays(page);
// --- 04. Каталог Стола заказов после подписания всех соглашений ---
// Если есть отдельный гейт ЦПП Стола заказов — он должен показаться сейчас
// как новый диалог. Если нет — каталог должен открыться.
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market/catalog`, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(3000);
await cleanViteOverlays(page);
const third = await inspectTopDialog(page);
await shot(
page,
'03-market-after-signing',
`Каталог Стола заказов ПОСЛЕ подписания соглашений. URL: \`${page.url()}\`. Подписано диалогов: ${dialogsSigned}. Верхний диалог: «${third.title || 'нет'}»`,
);
// --- 05. Если остался гейт — попробуем подписать его и снять финальное состояние ---
if (third.present && third.hasSignButton) {
await signOneDialog(page);
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(2500);
await cleanViteOverlays(page);
await shot(
page,
'04-market-after-gate-signed',
`Финальное состояние /market/catalog после подписания гейт-оферты ЦПП Стола заказов`,
);
}
};
@@ -0,0 +1,64 @@
// Сценарий: Пайщик-новичок видит L3 gate ЦПП «Стол заказов».
// Эпик 1 / Story 1.4 + 1.11 — onboarding пайщика в Marketplace.
//
// На странице — canon-виджет OnboardingCPPGate (UX-DR17) со списком ЦПП
// документов. Реальная подпись выполняется через core Registrator-мастер
// (wallet::signagree), эта страница только показывает пакет и редиректит
// в мастер.
//
// Фикстура: ivanpetrov (Иван Сергеевич Петров) — может быть уже подписан
// после прошлых сценариев. Если страница покажет «уже подключены» —
// требуется --reboot.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loginAs, env, cleanViteOverlays } from '../../../lib/harness.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const loadFixture = (username) =>
JSON.parse(
fs.readFileSync(path.resolve(__dirname, `../../../state/participants/${username}.json`), 'utf8'),
);
export const meta = {
title: 'L3 онбординг пайщика — gate ЦПП «Стол заказов»',
docPath: 'new/marketplace/onboarding/member-pick-cpp.md',
assetsDir: 'assets/new/marketplace/onboarding/member-pick-cpp',
role: 'user',
fixture: 'ivanpetrov',
};
export default async ({ page, shot }) => {
const fixture = loadFixture('ivanpetrov');
await loginAs(page, fixture);
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
await page.goto(`${env.BASE_URL}/#/${env.COOPNAME}/market/onboarding/member-cpp`, {
waitUntil: 'domcontentloaded',
});
await page.waitForSelector('text=Подключение к Столу заказов', { timeout: 60000 });
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1200);
await cleanViteOverlays(page);
// 1. Initial state — либо gate OnboardingCPPGate, либо баннер «уже подключены»
await shot(
page,
'01-gate-or-done',
'Страница онбординга пайщика в Marketplace: если ЦПП ещё не подписана — canon OnboardingCPPGate с пакетом документов (оферта + Положение); если уже — баннер success с датой принятия и кнопкой к каталогу.',
);
// 2. Если есть кнопка «Перейти к подписанию» — наводим (без клика, чтобы не редиректить)
const acceptBtn = page.locator('button:has-text("Перейти к подписанию")');
if (await acceptBtn.isVisible().catch(() => false)) {
await acceptBtn.hover();
await page.waitForTimeout(400);
await shot(
page,
'02-cta-hover',
'Кнопка «Перейти к подписанию» — переводит пайщика на core Registrator-мастер, где собран шаг подписи документа через wallet::signagree. После подписи backend пересчитает requires_gate=false и пайщик попадёт на /market автоматически.',
);
}
};
@@ -0,0 +1,123 @@
// Сценарий: operator-стол «Приёмка партии» (Эпик 5 / Story 5.6).
// Председатель КУ открывает акт приёмки (registry_id=1102) против
// ожидаемой поставки. После backend-фикса acceptIndividual
// (синтез cycle+shipment) индивидуальные заказы образуют SUPPLY_PREPARED
// shipment вариант A автоматически — оператор открывает АПП по нему.
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { cleanViteOverlays, env, loginAs } from '../../../lib/harness.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const loadFixture = (username) =>
JSON.parse(
fs.readFileSync(path.resolve(__dirname, `../../../state/participants/${username}.json`), 'utf8'),
);
function fetchLatestPreparedShipmentId() {
// Канон: оператор знает id партии из накладной / звонка поставщика.
// Harness достаёт самый свежий SUPPLY_PREPARED shipment, чтобы не тащить
// его через UI поставщика (страница OffererSupplyPreparationPage пока
// read-only, без виджета grouping; production UX-итерация подключит
// ExpeditorGroupingBoard).
try {
const out = execSync(
`docker exec mono-ai-4-postgres-1 psql -U postgres -d voskhod -tA -c "SELECT id FROM marketplace_shipment WHERE status='SUPPLY_PREPARED' ORDER BY created_at DESC LIMIT 1;"`,
{ encoding: 'utf8' },
).trim();
return out || null;
} catch {
return null;
}
}
export const meta = {
title: 'Стол ПВЗ — приёмка партии',
docPath: 'new/marketplace/operator/apl-reception-create.md',
assetsDir: 'assets/new/marketplace/operator/apl-reception-create',
role: 'user',
fixture: 'chairkrg',
};
export default async ({ page, shot }) => {
const fixture = loadFixture('chairkrg');
// Канон: председатель КУ Красногорск получает marketplace-роль
// `operator` (см. marketplace-roles.mapper.ts isKuChairman). У обычного
// operator-пайщика (opkrg) этой роли нет — попытка приёмки от него
// упирается в MarketplaceRoleGuard. Фронт-роутер пропускает по
// core-role `chairman` (chairkrg = branch chairman своего КУ).
await page.addInitScript(() => localStorage.setItem('harness:noBranchOverlay', '1'));
await loginAs(page, fixture);
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market-pvz/reception`, {
waitUntil: 'domcontentloaded',
timeout: 45000,
});
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(2500);
await cleanViteOverlays(page);
await shot(
page,
'01-reception-empty',
'Стол «Приёмка партии» председателя КУ. URL: `' + page.url() + '`. До создания АПП — поля «ID КУ» и «ID партии» пустые, таблица актов внизу пустая.',
);
const shipmentId = fetchLatestPreparedShipmentId();
if (!shipmentId) {
console.warn(' ⚠️ Нет SUPPLY_PREPARED shipment\'ов в БД — сценарий ограничится empty state');
return;
}
// Шаг 5 magistral II: оператор вводит ID партии и создаёт АПП приёмки.
// На UI используется button «Создать АПП»; backend mutation
// marketplaceCreateAplReception проверяет Shipment.status===SUPPLY_PREPARED,
// создаёт акт со status PENDING_SUPPLIER_SIGN и переводит Shipment →
// RECEPTION_IN_PROGRESS.
const shipmentInput = page.locator('label:has-text("ID партии")').locator('input').first();
await shipmentInput.click({ clickCount: 3 });
await shipmentInput.fill(shipmentId);
await page.waitForTimeout(400);
await cleanViteOverlays(page);
await shot(
page,
'02-shipment-id-pasted',
'Оператор КУ вводит ID партии поставщика (`' + shipmentId.slice(0, 8) + '...`). В production-UX этот идентификатор приходит на стойку оператора через сопроводительную ТТН (Вариант Б) или устно от поставщика (Вариант А).',
);
const createBtn = page.locator('button:has-text("Создать АПП")').first();
await createBtn.click();
// Ждём Notify «Акт приёмки создан» (positive).
await page.waitForFunction(
() => {
const notifs = document.querySelectorAll('.q-notification__message');
for (const n of notifs) {
if ((n.textContent || '').includes('Акт приёмки создан')) return true;
}
return false;
},
{ timeout: 30000 },
).catch(() => {});
await page.waitForTimeout(1500);
// Подгружаем список актов в этом КУ (поле «ID кооперативного участка»).
const branameInput = page.locator('label:has-text("ID кооперативного участка")').locator('input').first();
await branameInput.click({ clickCount: 3 });
await branameInput.fill('krg');
await page.waitForTimeout(300);
const loadBtn = page.locator('button:has-text("Загрузить АПП")').first();
await loadBtn.click();
await page.waitForTimeout(2000);
await cleanViteOverlays(page);
await shot(
page,
'03-reception-created',
'После клика «Создать АПП»: Notify «Акт приёмки создан» (positive). Загрузив АПП по `braname=krg`, видим новую запись в таблице — Вариант А, статус PENDING_SUPPLIER_SIGN, сумма по группе. Shipment → RECEPTION_IN_PROGRESS, on-chain `signsupp` ждёт первую подпись поставщика (шаг 6 магистрали II).',
{ preserveNotifications: true },
);
};
@@ -0,0 +1,32 @@
// Сценарий: operator-стол «Маркировка имущества» (Эпик 5 / Story 5.8 + Эпик 6).
// После закрытия акта приёмки оператор клеит EAN-13 на каждую единицу
// через `BarcodeDisplay` (UX-DR12) + фиксирует факт маркировки.
// На пустом стенде — empty state, маркировать нечего.
import { cleanViteOverlays, env, loginAsChairman } from '../../../lib/harness.mjs';
export const meta = {
title: 'Стол ПВЗ — маркировка имущества',
docPath: 'new/marketplace/operator/inventory-label.md',
assetsDir: 'assets/new/marketplace/operator/inventory-label',
role: 'chairman',
};
export default async ({ page, shot }) => {
await loginAsChairman(page);
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market-pvz/labeling`, {
waitUntil: 'domcontentloaded',
timeout: 45000,
});
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(3500);
await cleanViteOverlays(page);
await shot(
page,
'01-labeling-empty',
`Стол «Маркировка имущества» председателя КУ. URL: \`${page.url()}\`. Empty state: имущества, ожидающего маркировки EAN-13, на стенде нет — единицы появляются после закрытия акта приёмки (см. operator/apl-reception-create).`,
);
};
@@ -0,0 +1,29 @@
// Сценарий: operator-стол выдачи заказов (Story 6.6).
// Председатель КУ открывает «Выдачу заказов» на ПВЗ: лента в статусах
// ACCEPTED_TO_COOP (ожидают открытия первой подписью signiss1) и
// READY_TO_RECEIVE (ожидают финальной подписи заказчика signiss2).
import { cleanViteOverlays, env, loginAsChairman } from '../../../lib/harness.mjs';
export const meta = {
title: 'Стол ПВЗ — открытие выдачи',
docPath: 'new/marketplace/branch-chairman/issuance-open.md',
assetsDir: 'assets/new/marketplace/branch-chairman/issuance-open',
role: 'chairman',
};
export default async ({ page, shot }) => {
await loginAsChairman(page);
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market-pvz/issuance`, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(3500);
await cleanViteOverlays(page);
await shot(
page,
'01-issuance-empty',
`«Выдача заказов» председателя КУ. URL: \`${page.url()}\`.`,
);
};
@@ -0,0 +1,28 @@
// Сценарий: operator-стол гарантийных возвратов (Story 7.2-7.4).
// Председатель КУ рассматривает поступившие заявления удалённо,
// приглашает на очный осмотр, принимает или отказывает.
import { cleanViteOverlays, env, loginAsChairman } from '../../../lib/harness.mjs';
export const meta = {
title: 'Стол ПВЗ — гарантийные возвраты',
docPath: 'new/marketplace/branch-chairman/return-approve.md',
assetsDir: 'assets/new/marketplace/branch-chairman/return-approve',
role: 'chairman',
};
export default async ({ page, shot }) => {
await loginAsChairman(page);
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market-pvz/returns`, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(3500);
await cleanViteOverlays(page);
await shot(
page,
'01-operator-returns-empty',
`«Гарантийные возвраты» председателя КУ. URL: \`${page.url()}\`.`,
);
};
@@ -0,0 +1,29 @@
// Сценарий: operator-стол «Склад моего КУ» (Story 9.1).
// Таблица marketplace_inventory отфильтрована по braname; per-row статусы
// (LABELED / ISSUED / RETURNED / WRITTEN_OFF), фильтры по статусу/orderer,
// summary count by status. Backend: Warehouse: ['read:own-KU'].
import { cleanViteOverlays, env, loginAsChairman } from '../../../lib/harness.mjs';
export const meta = {
title: 'Стол ПВЗ — склад моего кооперативного участка',
docPath: 'new/marketplace/operator/inventory-list.md',
assetsDir: 'assets/new/marketplace/operator/inventory-list',
role: 'chairman',
};
export default async ({ page, shot }) => {
await loginAsChairman(page);
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market-pvz/warehouse`, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(3500);
await cleanViteOverlays(page);
await shot(
page,
'01-operator-warehouse',
`«Склад моего КУ» председателя КУ. URL: \`${page.url()}\`.`,
);
};
@@ -0,0 +1,74 @@
// Сценарий: orderer-стол «Каталог витрины» (/market/catalog).
// На стенде после reboot:extra + одобрения председателем offer'а
// «Берёзовый сок ПК «Восход» (демо)» (Story 3.6) каталог содержит
// один CatalogOfferCard (UX-DR10) в статусе «Опубликовано».
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { cleanViteOverlays, env, loginAs } from '../../../lib/harness.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const loadFixture = (username) =>
JSON.parse(
fs.readFileSync(path.resolve(__dirname, `../../../state/participants/${username}.json`), 'utf8'),
);
export const meta = {
title: 'Стол заказчика — каталог витрины',
docPath: 'new/marketplace/orderer/catalog.md',
assetsDir: 'assets/new/marketplace/orderer/catalog',
role: 'user',
fixture: 'ekaterina',
fixtures: ['ekaterina'],
};
async function signAllAgreements(page) {
for (let i = 0; i < 8; i++) {
const clicked = await page.evaluate(() => {
const portals = Array.from(document.querySelectorAll('[id^="q-portal--dialog--"]'))
.filter((p) => getComputedStyle(p).display !== 'none');
if (portals.length === 0) return false;
const top = portals[portals.length - 1];
const btn = Array.from(top.querySelectorAll('button'))
.find((b) => b.textContent?.trim() === 'Подписать' && !b.disabled);
if (!btn) return false;
btn.scrollIntoView({ block: 'center', behavior: 'instant' });
btn.click();
return true;
});
if (!clicked) break;
await page.waitForTimeout(3500);
}
}
export default async ({ page, shot }) => {
const fixture = loadFixture('ekaterina');
await loginAs(page, fixture);
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
await page.waitForFunction(
() => !document.body.innerText.includes('Формируем документ'),
{ timeout: 30000 },
).catch(() => {});
await page.waitForTimeout(1500);
await signAllAgreements(page);
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market/catalog`, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(3000);
await cleanViteOverlays(page);
await signAllAgreements(page);
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1500);
await cleanViteOverlays(page);
await shot(
page,
'01-catalog-with-offer',
`Каталог витрины пайщицы Екатерины. URL: \`${page.url()}\`. В каталоге одна карточка «Берёзовый сок ПК «Восход» (демо)» в статусе «Опубликовано» — offer прошёл модерацию председателем (Story 3.6) и доступен к оформлению. Фильтр-чипы показывают распределение по категориям ЦПП (Овощи и фрукты, Молочные продукты и т.д.).`,
);
};
@@ -0,0 +1,61 @@
// Сценарий: Сводный обзор заказов пайщика-заказчика.
// Эпик 4 / Story 4.4 — заказы группируются по cycle_id (time_based /
// volume_based / open_subscription / individual) с суммарной стоимостью
// партии и общим этапом партии (минимальный активный по STAGE_RANK).
//
// Канон-виджет `OrderCard` для отдельных заказов в партии.
// Polling 15s — обновляет список без перезагрузки.
//
// Фикстура: ivanpetrov (Иван Сергеевич Петров), пайщик-заказчик.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loginAs, env, cleanViteOverlays } from '../../../lib/harness.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const loadFixture = (username) =>
JSON.parse(
fs.readFileSync(path.resolve(__dirname, `../../../state/participants/${username}.json`), 'utf8'),
);
export const meta = {
title: 'Сводный заказ: партии заказчика по cycle_id',
docPath: 'new/marketplace/orderer/consolidated.md',
assetsDir: 'assets/new/marketplace/orderer/consolidated',
role: 'user',
fixture: 'ivanpetrov',
};
export default async ({ page, shot }) => {
const fixture = loadFixture('ivanpetrov');
await loginAs(page, fixture);
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
// 1. Открыть страницу сводного заказа
await page.goto(`${env.BASE_URL}/#/${env.COOPNAME}/market/consolidated`, {
waitUntil: 'domcontentloaded',
});
await page.waitForSelector('text=Сводный заказ', { timeout: 60000 });
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1200);
await cleanViteOverlays(page);
await shot(
page,
'01-overview',
'Сводный обзор партий заказов пайщика-заказчика. Партии с cycle_id выводятся первыми (заказы внутри партии обслуживаются совместно — на одном цикле, с одной поставкой), индивидуальные заказы — отдельной группой. Для каждой партии показан тип цикла, этап партии, суммарная стоимость и ПВЗ доставки.',
);
// 2. Развернуть первую партию (если есть заказы)
const firstGroup = page.locator('.mp-consolidated__group').first();
if (await firstGroup.isVisible().catch(() => false)) {
await firstGroup.scrollIntoViewIfNeeded();
await page.waitForTimeout(400);
await shot(
page,
'02-group-detail',
'Партия развёрнута: внутри — карточки отдельных заказов (канон OrderCard role=orderer). Этап партии = минимальный активный статус среди заказов (если хотя бы один ждёт цикл — вся партия «Активна»).',
);
}
};
@@ -0,0 +1,88 @@
// Сценарий: Стол заказчика → обзорный тур по разделам marketplace глазами
// обычного пайщика (НЕ председателя). Авторизуется как ivanpetrov, проходит
// онбординг подписания соглашений (если не подписан), затем перебирает
// маршруты marketplace, используя harness:noBranchOverlay.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { cleanViteOverlays, env, loginAs } from '../../../lib/harness.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const loadFixture = (username) =>
JSON.parse(
fs.readFileSync(path.resolve(__dirname, `../../../state/participants/${username}.json`), 'utf8'),
);
export const meta = {
title: 'Стол заказчика: разделы Стола заказов глазами пайщика',
docPath: 'new/marketplace/orderer/marketplace-tour.md',
assetsDir: 'assets/new/marketplace/orderer/marketplace-tour',
role: 'user',
fixture: 'ivanpetrov',
fixtures: ['ivanpetrov'],
};
const ROUTES = [
{ name: '01-catalog', path: '/market/catalog', caption: 'Каталог Стола заказов глазами пайщика-заказчика — список доступных предложений товаров и услуг' },
{ name: '02-my-orders', path: '/market/my-orders', caption: 'Мои заказы: лента активных и завершённых заказов пайщика' },
{ name: '03-ready-to-receive', path: '/market/ready-to-receive', caption: 'Готовые к выдаче: товары, доставленные на ПВЗ и ждущие получения' },
{ name: '04-returns', path: '/market/returns', caption: 'Возвраты: заявки на возврат, оформленные пайщиком после получения товара' },
];
// Подписать все онбординг-диалоги пайщика, если они появляются после логина.
// Логика повторяет scenarios/auth/signin.mjs: 4 q-dialog'а со stacked z-index,
// в каждом кнопка «Подписать» внизу скроллящегося контента. Click через DOM,
// потому что Playwright actionability падает на pointer-events внутри документа.
async function signAllAgreements(page) {
await page.waitForFunction(
() => !document.body.innerText.includes('Формируем документ'),
{ timeout: 30000 },
).catch(() => {});
await page.waitForTimeout(500);
for (let i = 0; i < 8; i++) {
const clicked = await page.evaluate(() => {
const portals = Array.from(document.querySelectorAll('[id^="q-portal--dialog--"]'))
.filter((p) => getComputedStyle(p).display !== 'none');
if (portals.length === 0) return false;
const top = portals[portals.length - 1];
const btn = Array.from(top.querySelectorAll('button'))
.find((b) => b.textContent?.trim() === 'Подписать' && !b.disabled);
if (!btn) return false;
btn.scrollIntoView({ block: 'center', behavior: 'instant' });
btn.click();
return true;
});
if (!clicked) break;
await page.waitForTimeout(3500);
}
}
export default async ({ page, shot }) => {
const fixture = loadFixture('ivanpetrov');
await loginAs(page, fixture);
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
await signAllAgreements(page);
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.evaluate(() => {
document.querySelectorAll('.q-notification').forEach((n) => n.remove());
});
await page.waitForTimeout(2000);
await cleanViteOverlays(page);
for (const route of ROUTES) {
const url = `${env.BASE_URL}/${env.COOPNAME}${route.path}`;
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
// Ждём пока SPA-подгрузки прекратятся (GraphQL/REST + chain-rpc).
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
// Дополнительный буфер на анимации/спиннеры.
await page.waitForTimeout(2500);
await cleanViteOverlays(page);
await shot(page, route.name, `${route.caption} (URL: \`/${env.COOPNAME}${route.path}\`)`);
}
};
@@ -0,0 +1,219 @@
// Сценарий: orderer-стол «Оформление заказа», магистраль II «вход в каталог».
//
// Полный путь пайщика после фоллоуапа Эпика 1 (вариант A):
// 1. Логин ekaterina + подпись 4 системных agreements (Wallet ЦПП и т.п.).
// 2. Переход на L3 онбординг marketplace (/market/onboarding/member-cpp).
// 3. Канон-виджет OnboardingCPPGate показывает оферту 1101 + Положение ЦПП;
// клик «Подписать оферту» → mutation `marketplaceSignOnboardingOffer` →
// backend сам делает on-chain `wallet::signagree` (program_id=2,
// draft_id из soviet::coagreements). После успеха —
// requires_gate=false, source='agreement_signed', UI редиректит в catalog.
// 4. В каталоге кликаем «Заказать» на APPROVED Offer'е, заполняем
// OrderCreateDialog (quantity + delivery_braname), submit вызывает
// `marketplaceCreateOrder`. По успеху Notify «Заказ создан».
//
// Это закрытие magistral II: ekaterina впервые проходит весь L3 onboarding
// прямо со «Стола заказов», без редиректа в core Registrator-мастер.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { cleanViteOverlays, env, loginAs } from '../../../lib/harness.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const loadFixture = (username) =>
JSON.parse(
fs.readFileSync(path.resolve(__dirname, `../../../state/participants/${username}.json`), 'utf8'),
);
export const meta = {
title: 'Стол заказчика — оформление заказа',
docPath: 'new/marketplace/orderer/order-create.md',
assetsDir: 'assets/new/marketplace/orderer/order-create',
role: 'user',
fixture: 'ekaterina',
fixtures: ['ekaterina'],
};
async function signAllAgreements(page) {
for (let i = 0; i < 8; i++) {
const clicked = await page.evaluate(() => {
const portals = Array.from(document.querySelectorAll('[id^="q-portal--dialog--"]'))
.filter((p) => getComputedStyle(p).display !== 'none');
if (portals.length === 0) return false;
const top = portals[portals.length - 1];
const btn = Array.from(top.querySelectorAll('button'))
.find((b) => b.textContent?.trim() === 'Подписать' && !b.disabled);
if (!btn) return false;
btn.scrollIntoView({ block: 'center', behavior: 'instant' });
btn.click();
return true;
});
if (!clicked) break;
await page.waitForTimeout(3500);
}
}
export default async ({ page, shot }) => {
const fixture = loadFixture('ekaterina');
await loginAs(page, fixture);
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
await page.waitForFunction(
() => !document.body.innerText.includes('Формируем документ'),
{ timeout: 30000 },
).catch(() => {});
await page.waitForTimeout(1500);
await signAllAgreements(page);
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
// === Шаг 1: L3 онбординг marketplace ===
// Сначала идём на gate-страницу. Если пайщик ещё не подписал ЦПП —
// увидит OnboardingCPPGate с двумя документами. Если уже подписал —
// banner «Вы уже подключены». Сценарий поддерживает оба исхода:
// на свежей цепочке первый прогон снимет полный flow; повторные
// прогоны пропустят подпись (уже зафиксирована on-chain).
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market/onboarding/member-cpp`, {
waitUntil: 'domcontentloaded',
timeout: 45000,
});
// Vite optimizeDeps на холодном старте занимает >20s; ждём отрисовки страницы
// (mp-onboarding-gate / banner / любая q-page) — иначе ловим пустой Quasar
// bootstrap-спиннер и сценарий проваливается на gate.isVisible.
await page.locator('.mp-onboarding-gate, text=Вы уже подключены к Marketplace, .q-page').first()
.waitFor({ state: 'visible', timeout: 90000 })
.catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(1500);
await cleanViteOverlays(page);
await signAllAgreements(page);
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(800);
const gate = page.locator('.mp-onboarding-gate').first();
const alreadyBanner = page.locator('text=Вы уже подключены к Marketplace').first();
// Debug: всегда снимаем member-cpp страницу, чтобы понимать что
// backend вернул в onboarding state, даже если ни gate, ни banner не показались.
await cleanViteOverlays(page);
await shot(
page,
'00-marketplace-onboarding-page',
'Debug: страница `/market/onboarding/member-cpp` после загрузки backend `marketplaceOnboardingState`. Если ниже видим gate с двумя документами — пайщик ещё не подписал; banner «Вы уже подключены» — подписал; «возможен временный рассинхрон» — backend вернул что gate не нужен, но и agreement_signed не зафиксирован.',
);
if (await gate.isVisible().catch(() => false)) {
await cleanViteOverlays(page);
await shot(
page,
'01-marketplace-onboarding-gate',
'L3 gate ЦПП «Стол заказов»: канон-виджет OnboardingCPPGate (UX-DR17) показывает два документа — Оферту на присоединение и Положение ЦПП. Пайщик отмечает обязательные чекбоксы и нажимает «Подписать оферту» — backend выполняет on-chain wallet::signagree (program_id=2) без редиректа в core Registrator-мастер.',
);
// Проставить обязательные чекбоксы (gate проверяет required+allRequiredAccepted).
const checkboxes = gate.locator('.q-checkbox');
const cbCount = await checkboxes.count();
for (let i = 0; i < cbCount; i++) {
const cb = checkboxes.nth(i);
const aria = await cb.getAttribute('aria-checked').catch(() => null);
if (aria !== 'true') {
await cb.click();
await page.waitForTimeout(150);
}
}
await page.waitForTimeout(300);
const signBtn = gate.locator('button:has-text("Подписать оферту")').first();
await signBtn.waitFor({ state: 'visible', timeout: 5000 });
await signBtn.click();
// Ждём либо Notify «Оферта подписана» + редирект на /market/catalog,
// либо banner «уже подключены», либо отрисовку каталога (router push).
await page.waitForFunction(
() => {
const u = window.location.href;
if (u.includes('/market/catalog')) return true;
const txt = document.body.innerText || '';
return txt.includes('Оферта ЦПП') || txt.includes('Вы уже подключены');
},
{ timeout: 30000 },
).catch(() => {});
await page.waitForTimeout(1500);
await cleanViteOverlays(page);
} else if (await alreadyBanner.isVisible().catch(() => false)) {
await cleanViteOverlays(page);
await shot(
page,
'01-marketplace-onboarding-already',
'Пайщик уже прошёл L3 онбординг ЦПП «Стол заказов» (повторный заход): banner «Вы уже подключены к Marketplace» с кнопкой «К каталогу». Backend `marketplaceOnboardingState` отдал requires_gate=false, source=\'agreement_signed\'.',
);
}
// === Шаг 2: переход на каталог ===
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market/catalog`, {
waitUntil: 'domcontentloaded',
timeout: 45000,
});
// Ждём отрисовки каталога (offer-card ИЛИ empty-state) — пустой спиннер == Vite ещё грузит.
await page.locator('button:has-text("Заказать"), .mp-catalog-empty, .q-page').first()
.waitFor({ state: 'visible', timeout: 90000 })
.catch(() => {});
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(2500);
await cleanViteOverlays(page);
await signAllAgreements(page);
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1500);
await cleanViteOverlays(page);
// === Шаг 3: открыть OrderCreateDialog ===
const orderBtn = page.locator('button:has-text("Заказать")').first();
if (!(await orderBtn.count())) {
throw new Error('В каталоге нет CatalogOfferCard с действием «Заказать» — нужен хотя бы один APPROVED offer (chairman/offer-moderation).');
}
await orderBtn.click();
const dialog = page.locator('.mp-order-create-dialog').first();
await dialog.waitFor({ state: 'visible', timeout: 5000 });
await page.waitForTimeout(400);
await cleanViteOverlays(page);
await shot(
page,
'02-order-create-dialog',
'Диалог оформления заказа: q-input «Количество», q-select «ПВЗ доставки», итоговая сумма (цена × количество), кнопки «Отмена» и «Подтвердить заказ». Открывается при клике «Заказать» на карточке APPROVED offer\'а в каталоге.',
);
const qty = dialog.locator('input[type="number"]').first();
await qty.click({ clickCount: 3 });
await qty.fill('2');
await page.waitForTimeout(300);
const branchSelect = dialog.locator('.q-select').first();
await branchSelect.click();
await page.waitForTimeout(400);
const firstOption = page.locator('.q-menu .q-item').first();
await firstOption.waitFor({ state: 'visible', timeout: 5000 });
await firstOption.click();
await page.waitForTimeout(300);
await cleanViteOverlays(page);
await shot(
page,
'03-order-create-filled',
'Форма с количеством 2 и выбранным ПВЗ доставки. Итоговая сумма обновляется немедленно (price_per_unit × quantity). Кнопка «Подтвердить заказ» становится активной, когда оба поля валидны.',
);
// === Шаг 4: submit + Notify «Заказ создан» ===
const confirmBtn = dialog.locator('button:has-text("Подтвердить заказ")').first();
await confirmBtn.click();
await page.locator('.q-notification').first().waitFor({ state: 'visible', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(800);
await shot(
page,
'04-order-created',
'Magistral II разблокирована: после подписи L3-оферты submit `marketplaceCreateOrder` проходит pipeline (createorder → o.mkt.assign → TRANSFER из w.wal.member в w.mkt.member). UI показывает Notify «Заказ создан». В Witkin/MyOrders появляется новый PENDING-заказ.',
{ preserveNotifications: true },
);
};
@@ -0,0 +1,109 @@
// Сценарий: orderer-стол «Мои заказы» (/:coopname/market/my-orders).
// Снимает раздел «Мои заказы» пайщицы Екатерины (Story 4.6): пустой список,
// строку фильтров по статусам, состояние «нет заказов». Цель — задокументировать
// orderer-таблицу на старте стенда, до того как пайщик впервые оформил заказ.
//
// Фикстура: ekaterina (создана сценарием extension-gate; в её state уже
// подписаны общие соглашения, повторного онбординга на стенде не будет).
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { cleanViteOverlays, env, loginAs } from '../../../lib/harness.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const loadFixture = (username) =>
JSON.parse(
fs.readFileSync(path.resolve(__dirname, `../../../state/participants/${username}.json`), 'utf8'),
);
export const meta = {
title: 'Стол заказчика — «Мои заказы» (пустой список)',
docPath: 'new/marketplace/orderer/orders.md',
assetsDir: 'assets/new/marketplace/orderer/orders',
role: 'user',
fixture: 'ekaterina',
fixtures: ['ekaterina'],
};
// Пройти онбординг общих соглашений (4 диалога ЦПП Кошелька + базовые)
async function signAllAgreements(page) {
let dialogsSigned = 0;
for (let i = 0; i < 8; i++) {
const clicked = await page.evaluate(() => {
const portals = Array.from(document.querySelectorAll('[id^="q-portal--dialog--"]'))
.filter((p) => getComputedStyle(p).display !== 'none');
if (portals.length === 0) return false;
const top = portals[portals.length - 1];
const btn = Array.from(top.querySelectorAll('button'))
.find((b) => b.textContent?.trim() === 'Подписать' && !b.disabled);
if (!btn) return false;
btn.scrollIntoView({ block: 'center', behavior: 'instant' });
btn.click();
return true;
});
if (!clicked) break;
dialogsSigned += 1;
await page.waitForTimeout(3500);
}
return dialogsSigned;
}
export default async ({ page, shot }) => {
const fixture = loadFixture('ekaterina');
await loginAs(page, fixture);
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
// Дать платформе собрать первый онбординг-документ (если стенд считает
// ekaterina впервые залогиненной)
await page.waitForFunction(
() => !document.body.innerText.includes('Формируем документ'),
{ timeout: 30000 },
).catch(() => {});
await page.waitForTimeout(1500);
// Если онбординг ещё активен — подписываем все 4 общих соглашения
await signAllAgreements(page);
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
// --- 01. Заходим в /market/my-orders ---
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market/my-orders`, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(3000);
await cleanViteOverlays(page);
// Если каталог встретил гейтом — пробуем подписать ещё раз
await signAllAgreements(page);
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1500);
await cleanViteOverlays(page);
await shot(
page,
'01-my-orders-empty',
`Раздел «Мои заказы» пайщицы Екатерины. URL: \`${page.url()}\`. Заказов ещё нет.`,
);
// --- 02. Раскрытие фильтра по статусу (если есть селект) ---
const filterOpened = await page.evaluate(() => {
const labels = Array.from(document.querySelectorAll('label, .q-field__label'));
const statusLabel = labels.find((l) => /статус/i.test(l.textContent || ''));
if (!statusLabel) return false;
const field = statusLabel.closest('.q-field, .q-select');
if (!field) return false;
field.scrollIntoView({ block: 'center', behavior: 'instant' });
field.click();
return true;
});
if (filterOpened) {
await page.waitForTimeout(800);
await cleanViteOverlays(page);
await shot(
page,
'02-status-filter-open',
`Раскрытый селектор фильтра по статусу заказа: какие статусы доступны orderer'у.`,
);
}
};
@@ -0,0 +1,74 @@
// Сценарий: orderer-стол «Готово к получению» (Story 6.7).
// Лента заказов в статусе READY_TO_RECEIVE — то, что пайщик сейчас может
// забрать на ПВЗ. Снимаем пустое состояние пайщицы Екатерины: оформленных
// заказов нет → лента пуста.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { cleanViteOverlays, env, loginAs } from '../../../lib/harness.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const loadFixture = (username) =>
JSON.parse(
fs.readFileSync(path.resolve(__dirname, `../../../state/participants/${username}.json`), 'utf8'),
);
export const meta = {
title: 'Стол заказчика — «Готово к получению»',
docPath: 'new/marketplace/orderer/ready-to-receive.md',
assetsDir: 'assets/new/marketplace/orderer/ready-to-receive',
role: 'user',
fixture: 'ekaterina',
fixtures: ['ekaterina'],
};
async function signAllAgreements(page) {
for (let i = 0; i < 8; i++) {
const clicked = await page.evaluate(() => {
const portals = Array.from(document.querySelectorAll('[id^="q-portal--dialog--"]'))
.filter((p) => getComputedStyle(p).display !== 'none');
if (portals.length === 0) return false;
const top = portals[portals.length - 1];
const btn = Array.from(top.querySelectorAll('button'))
.find((b) => b.textContent?.trim() === 'Подписать' && !b.disabled);
if (!btn) return false;
btn.scrollIntoView({ block: 'center', behavior: 'instant' });
btn.click();
return true;
});
if (!clicked) break;
await page.waitForTimeout(3500);
}
}
export default async ({ page, shot }) => {
const fixture = loadFixture('ekaterina');
await loginAs(page, fixture);
await page.evaluate(() => localStorage.setItem('harness:noBranchOverlay', '1'));
await page.waitForFunction(
() => !document.body.innerText.includes('Формируем документ'),
{ timeout: 30000 },
).catch(() => {});
await page.waitForTimeout(1500);
await signAllAgreements(page);
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.goto(`${env.BASE_URL}/${env.COOPNAME}/market/ready-to-receive`, { waitUntil: 'domcontentloaded', timeout: 45000 });
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => {});
await page.waitForTimeout(3000);
await cleanViteOverlays(page);
await signAllAgreements(page);
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1500);
await cleanViteOverlays(page);
await shot(
page,
'01-ready-to-receive-empty',
`Раздел «Готово к получению» пайщицы Екатерины. URL: \`${page.url()}\`. Готовых к получению заказов нет.`,
);
};

Some files were not shown because too many files have changed in this diff Show More