[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:
@@ -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) })
|
||||
+187
-1010
File diff suppressed because it is too large
Load Diff
+15
-5
@@ -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 = {
|
||||
|
||||
+13
@@ -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,
|
||||
|
||||
+7
-4
@@ -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;
|
||||
|
||||
+31
-12
@@ -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);
|
||||
|
||||
+142
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+128
@@ -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);
|
||||
}
|
||||
}
|
||||
+54
-32
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('Резервная конфигурация загружена');
|
||||
|
||||
@@ -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
-1
@@ -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';
|
||||
+191
@@ -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';
|
||||
+70
@@ -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';
|
||||
+226
@@ -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';
|
||||
+204
@@ -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';
|
||||
+176
@@ -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 {
|
||||
|
||||
+21
-5
@@ -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';
|
||||
+263
@@ -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>
|
||||
+11
-8
@@ -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
|
||||
|
||||
+11
-8
@@ -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
|
||||
|
||||
+11
-8
@@ -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';
|
||||
+147
@@ -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';
|
||||
+159
@@ -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>
|
||||
+11
-8
@@ -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
|
||||
|
||||
+10
-7
@@ -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
|
||||
|
||||
+6
-4
@@ -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")
|
||||
|
||||
+6
-4
@@ -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';
|
||||
+328
@@ -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>
|
||||
+10
-7
@@ -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<{
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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();
|
||||
@@ -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
Reference in New Issue
Block a user