feat(marketplace): Эпик 3 — Витрина: публикация, модерация, каталог — Stories 3.1+3.2+3.3+3.4+3.5 (#382)

* [598-6][@ant] feat: backend whitelist + дефолтная витрина — Story 3.1

Story 3.1 «Управление whitelist поставщиков + дефолтная витрина»:

— Domain entities `MarketplaceVitrineDomainEntity`,
  `MarketplaceWhitelistEntryDomainEntity` (role: 'auto-coop' | 'manual')
  и domain-репозитории `MarketplaceVitrineDomainRepository`,
  `MarketplaceWhitelistDomainRepository` (DIP — реализация в infra).
— TypeORM-entities `marketplace_vitrine` (PK=id, MVP всегда 'default'),
  `marketplace_whitelist` (UUID, UNIQUE(coop, member)). Подключение
  `marketplace` уже работает с `synchronize:true` — DDL автоматический.
— Mappers + adapters; идемпотентные `ensureDefault` / `add(role)`.
— Bootstrap-миграция v3 `marketplaceBootstrapV3Migration`: в afterMigrate
  создаёт `{id:'default', display_name:'Стол заказов'}` + auto-coop
  whitelist entry (FR5 — перепоставка остатков самим коопом).
— `MarketplaceWhitelistService.isOfferer` — источник `context.isOfferer`
  для `mapCoreRolesToMarketplaceRoles`: «открытая витрина» (только
  auto-coop) → true для всех User; «по whitelist» → true только для
  manual-записей. TTL-кеш 60s, инвалидируется при add/remove.
— `MarketplaceVitrineService.getDefault/list` — read-only API
  конфигурации витрины (конструктор кастомных витрин Out-of-MVP).
— GraphQL endpoints:
  `marketplaceListWhitelist` / `marketplaceAddToWhitelist` /
  `marketplaceRemoveFromWhitelist` под `@RequireMarketplaceAccess(
  'Whitelist','manage')` (admin); `marketplaceDefaultVitrine` под
  `'Vitrine','read'` (всем marketplace-ролям). auto-coop неудаляем.
— Access-matrix: добавлен `Vitrine:['read']` для offerer/operator,
  расширены `admin: Offer:['moderate','read']`, `Vitrine:['manage','read']`.
— `MarketplaceMembershipGuard` теперь async, дёргает
  `whitelistService.isOfferer(coopname, username)` для генерации
  marketplace-роли `offerer` на каждый GraphQL-запрос (через cache).
— Регистрация миграции v3 в `extension-domain.module.ts`, новых
  entities/adapters/mappers/repositories в `marketplace-infrastructure
  .module.ts`, сервисов/резолверов в `marketplace-application.module.ts`.

Тесты: 21 unit-кейс (`tests/unit/marketplace/`):
— marketplace-whitelist-service.test.ts (11): list, add, remove
  manual/auto-coop/missing, isOfferer семантика 3-х режимов + TTL-кеш
  + инвалидация на add/remove;
— marketplace-vitrine-service.test.ts (3): getDefault / null / list;
— marketplace-membership-guard.test.ts (7, обновлён под async + DI):
  open vitrine → +offerer, whitelist → no offerer, member/chairman
  роли, status/auth/server-secret bypass.

pnpm tsc проходит чисто на изменённых файлах (предсуществующие ошибки
в `file-storage/` не относятся к Story 3.1). targeted jest зелёный.

Зависимости: Эпик 1 (whitelist использует marketplace-roles.mapper,
access-matrix, MembershipGuard). Не блокирует и не блокируется

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [598-6][@ant] feat: backend Offer CRUD + 10 baseline-категорий — Story 3.2

Story 3.2 «Поставщик публикует и управляет жизненным циклом Offer'а»:

— Domain: `MarketplaceOfferDomainEntity` (полный набор полей под
  Stories 3.2/3.3/3.4 — quantity_available/blocked/consumed,
  cycle_type/days/target_volume/max_wait_days/min_threshold,
  status PENDING_MODERATION→ACTIVE→REJECTED/WITHDRAWN,
  approved_by/at, rejected_by/at, reject_reason);
  `MarketplaceCategoryDomainEntity` (справочник 10 baseline);
  типы `MarketplaceOfferStatus`/`CycleType`/`UnitOfMeasure`.
— TypeORM-entities `marketplace_offer` (UUID, 3 индекса под Stories 3.2/3.5)
  и `marketplace_category` (PK=integer, seed-таблица).
— Repositories: `MarketplaceOfferDomainRepository`
  (findById/list/countByCategory/countRecentCreatedBy/create/applyUpdate)
  + `MarketplaceCategoryDomainRepository` (listBaseline/findById/upsertBaseline).
— Adapters TypeORM-based с QueryBuilder (фильтры по supplier/status/
  category/available_only, сортировки по created_at/price);
  countByCategory считает только ACTIVE + (unlimited OR available>0).
— `MarketplaceOfferService` инкапсулирует AC Story 3.2:
  • create: PENDING_MODERATION + валидация (product_name 1..200,
    description ≤2000, baseline category 1..10, numeric price 0-4 знака,
    cycle/unit enums, quantity invariants);
  • rate-limit: 10 Offer'ов/час на supplier (`countRecentCreatedBy`);
  • update: ownership + reset в PENDING_MODERATION; REJECTED/WITHDRAWN → 403;
  • withdraw: ownership + status WITHDRAWN; stub `hasActiveOrders`=false
    (точка интеграции с Story 4.x order-репозиторием после merge #375);
  • unlimited_flag=true атомарно обнуляет quantity_available.
— `MarketplaceCategoryService.listBaseline` — read для UI (форма
  создания Offer'а + фильтр-чипы Story 3.5).
— GraphQL resolver: `marketplaceCreateOffer` / `marketplaceUpdateOffer` /
  `marketplaceWithdrawOffer` / `marketplaceListMyOffers` —
  `@RequireMarketplaceAccess('Offer','create:own'|'update:own'|'delete:own')`;
  `marketplaceListCategories` — `'Offer','read'`.
— DTO: MarketplaceOfferDTO + MarketplaceOfferPageDTO + MarketplaceCategoryDTO,
  Create/Update/Withdraw/ListMy Input DTOs (class-validator: Min/Max/Matches/
  IsIn для enum-полей).
— Bootstrap-миграция v4 (`marketplace-bootstrap-v4`): идемпотентный upsert
  10 категорий через `categoryRepo.upsertBaseline()`. DDL — synchronize:true.
— Регистрация: новые entities/adapters/mappers/repositories в
  infrastructure.module, services/resolver в application.module,
  миграция v4 в extension-domain.module.

Тесты: 24 unit-кейса в `marketplace-offer-service.test.ts`:
— create (11): happy path + rate-limit + категория вне baseline +
  отсутствующая в БД + product_name пустой/>200 + quantity invariants +
  unlimited_flag → 0 + неверные cycle/unit/price;
— update (6): reset в PENDING_MODERATION + ownership + WITHDRAWN/REJECTED 403
  + 404 + unlimited→0 + invalid category;
— withdraw (5): happy + ownership + уже WITHDRAWN + 404 + sentinel под
  active-orders;
— listMine + getById (2).

`pnpm tsc --noEmit` чисто; jest зелёный.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [598-6][@ant] feat: backend модерация Offer'ов админом — Story 3.3

Story 3.3 «Модерация Offer'ов админом (approve/reject + комментарий)»:

— Domain: `MarketplaceModerationLogDomainEntity` (offer_id, action,
  by_account, reason, created_at; append-only) + repository интерфейс
  `MarketplaceModerationLogDomainRepository.append/listByOffer`.
— TypeORM entity `marketplace_moderation_log` (UUID PK, индексы по
  offer_id+created_at и by_account); adapter + mapper.
— `MarketplaceModerationService`:
  • listPending — `repo.list({status:PENDING_MODERATION}, paging)`;
  • approve(offer_id, admin): PENDING → ACTIVE, approved_by/approved_at,
    очистка rejected_*; append log с action='approve';
    EventEmitter2.emit `marketplace.offer.approved` (порядок save→emit
    соблюдён, INV-12 controller/CLAUDE.md);
  • reject(offer_id, admin, reason): обязательное и trim'нутое reason
    1..1000 char; PENDING → REJECTED + rejected_by/at + reject_reason;
    log с action='reject'; emit `marketplace.offer.rejected`;
  • listLog — append-only история по offer'у;
  • Не-PENDING статус → 409 Conflict; missing → 404; reason invalid → 400.
— GraphQL resolver `marketplace-moderation.resolver.ts`:
  `marketplaceListPendingOffers` / `marketplaceApproveOffer` /
  `marketplaceRejectOffer` / `marketplaceListModerationLog` — все под
  `@RequireMarketplaceAccess('Offer','moderate')` (только admin).
— DTO: Approve/Reject/ListPending Input + ModerationLogEntry Output.
— Подключение в infrastructure + application модули; EventEmitter2 уже
  в core-ApplicationModule (через `@nestjs/event-emitter`).

Тесты: 10 unit-кейсов в `marketplace-moderation-service.test.ts`:
— approve (3): happy + 409 на не-PENDING + 404;
— reject (5): happy + empty reason 400 + >1000 chars 400 + 409 на
  уже-rejected + trim применяется к reason;
— listPending+listLog (2): корректная делегация с filter/paging.

`pnpm tsc --noEmit` чисто; jest зелёный.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [598-6][@ant] feat: backend counters available/blocked/consumed — Story 3.4

Story 3.4 «Backend ведёт available/blocked count Offer'ов»:

— Domain контракт `MarketplaceOfferDomainRepository` расширен 3-мя
  атомарными дельтами: `applyBlockDelta` / `applyUnblockDelta` /
  `applyConsumeDelta`. Возвращают `OfferCountersDeltaResult` —
  discriminated `{ok, reason?, offer?}` с reason'ами `insufficient_*`,
  `offer_not_active`, `offer_not_found`.
— Adapter реализован одним SQL UPDATE с WHERE-CAS-условием и RETURNING:
  • block: `quantity_blocked+=K`, `available -= K` (если не unlimited);
    WHERE status=ACTIVE AND (unlimited OR available>=K);
  • unblock: `quantity_blocked-=K`, `available += K` (если не unlimited);
    WHERE blocked>=K;
  • consume: `quantity_blocked-=K`, `quantity_consumed+=K`;
    WHERE blocked>=K.
  0 affected rows → fallback findOne + классификация причины. Атомарность
  обеспечивается одним SQL-statement'ом, гонки между параллельными
  Order-блокировками не разрушают инвариант.
— `MarketplaceOfferCountersService` — точка интеграции с Эпиком 4
  (`o.mkt.block/unblock/consume` canonical из PR #375): order-side
  syncer вызывает `onOrderBlocked/Unblocked/Consumed/Adjusted` внутри
  `dispatch` после `save` Order'а до `emit pubsub` (INV-12, см.
  controller/CLAUDE.md). Валидация qty > 0 integer; перевод reason →
  NestJS exception (404/400). EventEmitter2 emit
  `marketplace.offer.counters.changed` после успешной операции — Story
  3.5 каталог и offerer-«Активность» подписываются (Phase 2 GraphQL Sub).
— `onOrderAdjusted(qty_diff)` (FR23 «факт меньше заказа») семантически
  делегирует в `onOrderUnblocked`.

Инвариант (для не-unlimited):
  `available + blocked + consumed = lifetime_published`
поддерживается дельтами (изменение суммы 0 на каждом методе).

Подключение в `marketplace-application.module.ts`. Существующие моки в
тестах 3.2 / 3.3 расширены под новый интерфейс репозитория.

Тесты: 11 unit-кейсов в `marketplace-offer-counters-service.test.ts`:
— happy paths (4: block / unblock / consume / adjusted-delegate);
— qty validation (1: 0 / negative / non-integer);
— классификация reason → exception (4: insufficient_available/blocked,
  not_active, not_found);
— emit skip on error (1);
— math invariant (1): block 10 → unblock 3 → consume 7, net 0.

Атомарность SQL CAS-update — интеграционный уровень
(testcontainers PG, after merge Эпика 4). Точка интеграции зафиксирована
интерфейсом.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [598-6][@ant] feat: каталог Offer'ов на orderer-столе — Story 3.5

Story 3.5 «Каталог Offer'ов с фильтром-чипами по 10 категориям»:

Backend:
— `marketplaceListCatalog(input?: ListCatalogInput)`: только ACTIVE +
  (unlimited OR available>0), фильтр `category_id`, paging limit/offset
  (default 24/0), sort `created_at_desc` (default) | `price_asc` |
  `price_desc`. Доступ `@RequireMarketplaceAccess('Offer','read')` —
  всем marketplace-ролям.
— `marketplaceCategoryOfferCounts`: счётчики активных Offer'ов per
  category для фильтр-чипов. Гарантированно возвращает все 10
  baseline-категорий (даже count=0) — UX-DR10 рисует полный набор чипов.
— DTO: `MarketplaceListCatalogInput` (class-validator: category_id
  1..10, sort IsIn, paging Min/Max), `MarketplaceCategoryOfferCount`.
— Резолвер `MarketplaceCatalogResolver` подключён в application-module.

Frontend (FSD slim — отдельный pages/Marketplace/MarketplaceCatalog):
— `types.ts` — ручная типизация MarketplaceOfferView /
  CategoryOfferCount / CatalogSort / CatalogFilter (техдолг: после
  cooptypes:gen-zeus переписать на `Queries.Marketplace.*` из SDK,
  паттерн из PR #381).
— `api/index.ts` — raw GraphQL через `sendPOST('/v1/graphql')`
  (`fetchCatalog` / `fetchCategories` / `fetchCategoryOfferCounts`),
  обработка `body.errors` как throw.
— `ui/CatalogOfferCard.vue` (UX-DR10 — карточка):
  product_name (≤2 строки ellipsis), supplier, цена-за-единицу с
  правильным unit-label (шт/кг/л/упак), quantity_label («Без
  ограничений» или «Доступно: N ед»), cycle-label per cycle_type
  с правильными формулировками AC, warranty-чип, кнопка «Заказать»
  (disabled при отсутствии остатка); aria-label, ellipsis-2-lines.
— `ui/MarketplaceCatalogPage.vue`:
  • горизонтальная полоса фильтр-чипов «Все» + 10 категорий с
    counter-badge per чип (`scroll-x` под mobile);
  • сортировка через q-select (3 опции);
  • responsive grid (`col-12 col-sm-6 col-md-4 col-lg-3`), pagination
    24/страницу через `q-infinite-scroll`;
  • EmptyState компонент при пустом результате (фильтр / каталог);
  • aria-label на регион/чипы/карточки;
  • Order-форма (Эпик 4 Story 4.1) — пока Notify-stub с указанием
    точки интеграции.

`install.ts`:
— `defaultRoute: 'marketplace-catalog'` (новая страница);
— route `/:coopname/market/catalog` → `MarketplaceCatalogPage`;
— legacy `marketplace-showcase` помечен `hidden:true` (donor-страница
  на старой Marketplace архитектуре, см. project_stol_zakazov_mvp
  решение 2026-05-12 «donor переписывается без переходников»;
  переписывается в Phase 2 / последующем PR Stories 3.2 frontend).

vue-tsc: на новых файлах ошибок нет (`MapIterator` итерация заменена
на `Array.from(...).reduce`). Известные baseline-ошибки `Cannot find
module 'src/...'` во всех `extensions/*/install.ts` (включая
`branch`, `capital`, `chairman` — pre-existing).

Тесты: backend-резолвер — thin forward в `offerRepo.list` /
`countByCategory`, оба метода покрыты на adapter-уровне (тесты
catalog filtering — интеграционный testcontainers PG); карточная
логика label-форматтеров — компонентный тест Phase 2.

Limitations:
— Order-форма stub (Эпик 4);
— Zeus типы в SDK — техдолг (как в PR #381 marketplace KU details);
— WCAG 2.1 AA полный audit — Phase 2 (Эпик 10 Story 10.2);
— Donor-страницы Marketplace/Showcase, CreateParentOffer,
  UserParentOffers, Moderation не переписаны под новые GraphQL
  мутации Stories 3.2/3.3 — это работа отдельного UI-PR (текущий
  backend mutations работают, donor-UI закрыт legacy путём).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [598-6][@ant] chore: BC-sync integration seam — onOrderRolledBack + OrderSync scaffolding

Закрытие разрыва «где живёт blockchain-synchronization для Эпика 3»,
явный seam в коде вместо разбросанных JSDoc-комментариев.

Контекст: ревью пользователя 2026-05-15 — Эпик 3 был доставлен как
backend-only, и Story 3.4 counters стояли как stub-callback без явной
sync-точки. Слабая фиксация интеграции (только JSDoc) — исправляется.

Source-of-truth паттерна (ARCH-документы blago):
— `13-platforma-tsifrovogo-kooperativa/components/14-versiya-3/
   requirements/c3-arch-sinkhronizatsiya-uzla-s-blokcheynom-v1.md`
   (ADR-001 .. ADR-012);
— `4f-arch-integratsiya-kontrollera-s-parser2-v1.md` (DEC-T01..T12
   ParserClient).

Спецификация интеграции (для Эпика 4):
— `_bmad-output/implementation-artifacts/spec-3-4-bc-integration.md`
  (проект `1-prilozhenie-stol-zakazov`, компонент `3-minimalnyy-produkt`)
  — фиксирует contract Эпик 3 ↔ Эпик 4: какие методы counters-сервиса
  дёргаются на какие canonical actions (#375 PR), какой
  subscriptionId / consumerName / startFromBlock для ParserClient,
  какой ForkRegistry handler, чек-лист what Эпик 4 должен реализовать
  (composite-entity Order, mapper, repository, sync-service,
  subscription, side-effect injection, fork handler, write-mutation
  pool, GraphQL Subscription).

Изменения в коде:

1. `MarketplaceOfferDomainRepository.applyRollbackDelta(offer_id, qty)`
   — новый contract-метод для ADR-005 ForkRegistry handler'а. Реализован
   в TypeORM-adapter'е через SQL UPDATE **без** CAS-проверки
   `blocked>=qty` (rollback может приходить когда счётчик уже в
   consumed-состоянии — это ожидаемо при катастрофе fork-вне-Rollback-
   Horizon; manual reconciliation FR12 ARCH-sync).

2. `MarketplaceOfferCountersService.onOrderRolledBack(offer_id, qty)`
   — публичный метод target-сервиса. Дёргается из
   `MarketplaceOrderSyncService.handleFork` для каждого откатываемого
   Order'а в block-состоянии. Emit `marketplace.offer.counters.changed`
   с op='rollback' (Story 3.5 каталог-subscription и offerer-«Активность»
   получают update).

3. `extensions/marketplace/sync/marketplace-order-sync.service.ts` —
   **scaffolding** `MarketplaceOrderSyncService`. Skeleton-класс с
   тремя методами (`start`, `dispatch`, `handleFork`) — все throw
   `'NOT IMPLEMENTED — Эпик 4'`. В JSDoc — полный контракт:
   — `@DomainKey({primary:'id', sync:'order_id'})`,
   — `@SyncBehaviour({forkPolicy:'rollback-via-versions', dlq:true})`,
   — `@Versioned({strategy:'entity_versions'})`,
   — subscription `controller-${coopname}` `primary` `last_known`,
   — мапинг `p.mkt.supply.createorder` → `onOrderBlocked`,
     `cancelorder`/`expireorder`/`declineorder` → `onOrderUnblocked`,
     `consume`+`consume2` → `onOrderConsumed`,
     FR23 → `onOrderAdjusted`,
   — fork handler → `onOrderRolledBack` per Order;
   — ссылка на spec-3-4-bc-integration.md.

   Это фиксирует **место в коде** для Эпика 4 — а не «JSDoc в любом
   из 5 файлов»; чек-лист реализации виден один в одном файле.

4. Тесты:
   — `applyRollbackDelta` добавлен в моки `MarketplaceOfferDomainRepository`
     во всех 3-х existing test-файлах (offer-service, moderation-service,
     offer-counters-service);
   — новый кейс в `marketplace-offer-counters-service.test.ts`:
     `onOrderRolledBack (ADR-005 ForkRegistry handler) → applyRollbackDelta
     + emit op:rollback` — 12-й тест в этом файле, 67-й в Эпике 3.

`pnpm tsc` чисто; `pnpm jest tests/unit/marketplace/marketplace-offer-
counters-service.test.ts` — 12/12 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* [598-6][@ant] refactor: review feedback PR #382 — coopname, общая пагинация, RU-сообщения, продовольственные категории

— coopname вместо cooperative_id во всём marketplace (поле, колонки, индексы, UNIQUE constraint, DTO, resolvers, миграции, spec); blockchain-aligned именование как в capital/reports.
— общий PaginationInputDTO/createPaginationResult/PaginationResultDomainInterface как в payment-methods и documents; MarketplaceOfferPaginationResultDTO заменил MarketplaceOfferPageDTO; List*InputDTO наследуют PaginationInputDTO с page/limit/sortBy/sortOrder.
— сообщения ошибок на пользовательский русский без терминов available/blocked/offer/withdraw; пользователь видит понятное «Недостаточно свободного количества», «Предложение неактивно», «Можно изменять только свои предложения».
— baseline-категории: 8 продовольственных (овощи/фрукты, молочные, мясо/птица, рыба/морепродукты, хлеб/выпечка, бакалея, напитки, готовая еда) + «Прочее»; удалены услуги ремонта/доставки, хозяйственные, стройматериалы и прочие непродовольственные (вне MVP по PRD 3.2.7).

---------

Co-authored-by: coopops <coopos@coopenomics.world>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Ant
2026-05-15 13:55:12 +05:00
committed by GitHub
parent a02e0fe0bf
commit 173014f712
65 changed files with 5065 additions and 42 deletions
@@ -13,6 +13,8 @@ import { chatcoopStatePgV4Migration } from '~/extensions/chatcoop/migrations/cha
import { chatcoopMessageHistoryIngestCursorV5Migration } from '~/extensions/chatcoop/migrations/chatcoop-message-history-ingest-cursor-v5.migration';
import { marketplaceBootstrapV1Migration } from '~/extensions/marketplace/migrations/marketplace-bootstrap-v1.migration';
import { marketplaceBootstrapV2Migration } from '~/extensions/marketplace/migrations/marketplace-bootstrap-v2.migration';
import { marketplaceBootstrapV3Migration } from '~/extensions/marketplace/migrations/marketplace-bootstrap-v3.migration';
import { marketplaceBootstrapV4Migration } from '~/extensions/marketplace/migrations/marketplace-bootstrap-v4.migration';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { ExtensionsModule } from '~/extensions/extensions.module';
@@ -58,6 +60,8 @@ export class ExtensionDomainModule {
this.migrationService.registerMigration(chatcoopMessageHistoryIngestCursorV5Migration);
this.migrationService.registerMigration(marketplaceBootstrapV1Migration);
this.migrationService.registerMigration(marketplaceBootstrapV2Migration);
this.migrationService.registerMigration(marketplaceBootstrapV3Migration);
this.migrationService.registerMigration(marketplaceBootstrapV4Migration);
// Устанавливаем расширения по умолчанию
await this.extensionInteractor.installDefaultApps();
@@ -37,22 +37,24 @@ export const marketplaceAccessMatrix: Record<MarketplaceRole, Record<string, str
Vitrine: ['read'],
},
offerer: {
Offer: ['create:own', 'update:own', 'delete:own'],
Offer: ['create:own', 'update:own', 'delete:own', 'read'],
Order: ['read:to-self'],
Shipment: ['create:own'],
KU: ['read'],
Vitrine: ['read'],
},
operator: {
Receiving: ['create', 'sign:first'],
Issuance: ['create', 'sign:first'],
Warehouse: ['read:own-KU'],
KU: ['read:own-KU'],
Vitrine: ['read'],
},
admin: {
Offer: ['moderate'],
Offer: ['moderate', 'read'],
KU: ['manage'],
Whitelist: ['manage'],
Vitrine: ['manage'],
Vitrine: ['manage', 'read'],
Warehouse: ['read:all'],
Extension: ['configure'],
},
@@ -0,0 +1,24 @@
import { Field, InputType, Int, ObjectType } from '@nestjs/graphql';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
import { PaginationInputDTO } from '~/application/common/dto/pagination.dto';
@InputType('MarketplaceListCatalogInput')
export class MarketplaceListCatalogInputDTO extends PaginationInputDTO {
@Field(() => Int, { nullable: true, description: 'category_id 1..9; null = «Все»' })
@IsOptional()
@IsInt()
@Min(1)
@Max(9)
public category_id?: number | null;
}
@ObjectType('MarketplaceCategoryOfferCount')
export class MarketplaceCategoryOfferCountDTO {
@Field(() => Int) public readonly category_id!: number;
@Field(() => Int) public readonly count!: number;
constructor(init: { category_id: number; count: number }) {
this.category_id = init.category_id;
this.count = init.count;
}
}
@@ -0,0 +1,47 @@
import { Field, InputType, ObjectType } from '@nestjs/graphql';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { PaginationInputDTO } from '~/application/common/dto/pagination.dto';
@InputType('MarketplaceListPendingOffersInput')
export class MarketplaceListPendingOffersInputDTO extends PaginationInputDTO {}
@InputType('MarketplaceApproveOfferInput')
export class MarketplaceApproveOfferInputDTO {
@Field(() => String)
@IsString()
public offer_id!: string;
}
@InputType('MarketplaceRejectOfferInput')
export class MarketplaceRejectOfferInputDTO {
@Field(() => String)
@IsString()
public offer_id!: string;
@Field(() => String, { description: 'Причина отказа (≤1000)' })
@IsNotEmpty()
@MaxLength(1000)
public reason!: string;
}
@ObjectType('MarketplaceModerationLogEntry')
export class MarketplaceModerationLogEntryDTO {
@Field(() => String) public readonly id!: string;
@Field(() => String) public readonly offer_id!: string;
@Field(() => String, { description: 'approve | reject' })
public readonly action!: string;
@Field(() => String) public readonly by_account!: string;
@Field(() => String, { nullable: true }) public readonly reason!: string | null;
@Field(() => Date) public readonly created_at!: Date;
constructor(init: {
id: string;
offer_id: string;
action: string;
by_account: string;
reason: string | null;
created_at: Date;
}) {
Object.assign(this, init);
}
}
@@ -0,0 +1,177 @@
import { Field, InputType, Int } from '@nestjs/graphql';
import {
IsBoolean,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
} from 'class-validator';
import { PaginationInputDTO } from '~/application/common/dto/pagination.dto';
const CYCLE_TYPES = ['time_based', 'volume_based', 'open_subscription', 'individual'] as const;
const UNITS = ['piece', 'kg', 'liter', 'pack'] as const;
@InputType('MarketplaceCreateOfferInput')
export class MarketplaceCreateOfferInputDTO {
@Field(() => String)
@IsNotEmpty()
@MaxLength(200)
public product_name!: string;
@Field(() => String, { nullable: true })
@IsOptional()
@MaxLength(2000)
public description!: string | null;
@Field(() => Int)
@IsInt()
@Min(1)
@Max(9)
public category_id!: number;
@Field(() => String, { description: 'Цена за единицу (numeric как string, до 4 знаков)' })
@Matches(/^\d+(\.\d{1,4})?$/)
public price_per_unit!: string;
@Field(() => String, { description: 'piece | kg | liter | pack' })
@IsIn(UNITS as unknown as string[])
public unit_of_measure!: 'piece' | 'kg' | 'liter' | 'pack';
@Field(() => Int, { nullable: true })
@IsOptional()
@IsInt()
@Min(0)
public quantity_available!: number | null;
@Field(() => Boolean)
@IsBoolean()
public unlimited_flag!: boolean;
@Field(() => String, { description: 'time_based | volume_based | open_subscription | individual' })
@IsIn(CYCLE_TYPES as unknown as string[])
public cycle_type!: 'time_based' | 'volume_based' | 'open_subscription' | 'individual';
@Field(() => Int, { nullable: true })
@IsOptional()
@IsInt()
@Min(1)
public cycle_days!: number | null;
@Field(() => Int, { nullable: true })
@IsOptional()
@IsInt()
@Min(1)
public target_volume!: number | null;
@Field(() => Int, { nullable: true })
@IsOptional()
@IsInt()
@Min(1)
public max_wait_days!: number | null;
@Field(() => Int, { nullable: true })
@IsOptional()
@IsInt()
@Min(0)
public min_threshold!: number | null;
@Field(() => Int)
@IsInt()
@Min(0)
public warranty_days!: number;
}
@InputType('MarketplaceUpdateOfferInput')
export class MarketplaceUpdateOfferInputDTO {
@Field(() => String)
@IsString()
public id!: string;
@Field(() => String, { nullable: true })
@IsOptional()
@MaxLength(200)
public product_name?: string;
@Field(() => String, { nullable: true })
@IsOptional()
@MaxLength(2000)
public description?: string | null;
@Field(() => Int, { nullable: true })
@IsOptional()
@IsInt()
@Min(1)
@Max(9)
public category_id?: number;
@Field(() => String, { nullable: true })
@IsOptional()
@Matches(/^\d+(\.\d{1,4})?$/)
public price_per_unit?: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsIn(UNITS as unknown as string[])
public unit_of_measure?: 'piece' | 'kg' | 'liter' | 'pack';
@Field(() => Int, { nullable: true })
@IsOptional()
@IsInt()
@Min(0)
public quantity_available?: number;
@Field(() => Boolean, { nullable: true })
@IsOptional()
@IsBoolean()
public unlimited_flag?: boolean;
@Field(() => String, { nullable: true })
@IsOptional()
@IsIn(CYCLE_TYPES as unknown as string[])
public cycle_type?: 'time_based' | 'volume_based' | 'open_subscription' | 'individual';
@Field(() => Int, { nullable: true })
@IsOptional()
@IsInt()
@Min(1)
public cycle_days?: number | null;
@Field(() => Int, { nullable: true })
@IsOptional()
@IsInt()
@Min(1)
public target_volume?: number | null;
@Field(() => Int, { nullable: true })
@IsOptional()
@IsInt()
@Min(1)
public max_wait_days?: number | null;
@Field(() => Int, { nullable: true })
@IsOptional()
@IsInt()
@Min(0)
public min_threshold?: number | null;
@Field(() => Int, { nullable: true })
@IsOptional()
@IsInt()
@Min(0)
public warranty_days?: number;
}
@InputType('MarketplaceWithdrawOfferInput')
export class MarketplaceWithdrawOfferInputDTO {
@Field(() => String)
@IsString()
public id!: string;
}
@InputType('MarketplaceListMyOffersInput')
export class MarketplaceListMyOffersInputDTO extends PaginationInputDTO {}
@@ -0,0 +1,77 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { createPaginationResult } from '~/application/common/dto/pagination.dto';
@ObjectType('MarketplaceOffer')
export class MarketplaceOfferDTO {
@Field(() => String) public readonly id!: string;
@Field(() => String) public readonly coopname!: string;
@Field(() => String) public readonly supplier_account!: string;
@Field(() => String) public readonly vitrine_id!: string;
@Field(() => String) public readonly product_name!: string;
@Field(() => String, { nullable: true }) public readonly description!: string | null;
@Field(() => Int) public readonly category_id!: number;
@Field(() => String, { description: 'Цена за единицу (numeric как string)' })
public readonly price_per_unit!: string;
@Field(() => String, { description: 'piece | kg | liter | pack' })
public readonly unit_of_measure!: string;
@Field(() => Int) public readonly quantity_available!: number;
@Field(() => Int) public readonly quantity_blocked!: number;
@Field(() => Int) public readonly quantity_consumed!: number;
@Field(() => Boolean) public readonly unlimited_flag!: boolean;
@Field(() => String, { description: 'time_based | volume_based | open_subscription | individual' })
public readonly cycle_type!: string;
@Field(() => Int, { nullable: true }) public readonly cycle_days!: number | null;
@Field(() => Int, { nullable: true }) public readonly target_volume!: number | null;
@Field(() => Int, { nullable: true }) public readonly max_wait_days!: number | null;
@Field(() => Int, { nullable: true }) public readonly min_threshold!: number | null;
@Field(() => Int) public readonly warranty_days!: number;
@Field(() => String, {
description: 'PENDING_MODERATION | ACTIVE | REJECTED | WITHDRAWN',
})
public readonly status!: string;
@Field(() => String, { nullable: true }) public readonly approved_by!: string | null;
@Field(() => Date, { nullable: true }) public readonly approved_at!: Date | null;
@Field(() => String, { nullable: true }) public readonly rejected_by!: string | null;
@Field(() => Date, { nullable: true }) public readonly rejected_at!: Date | null;
@Field(() => String, { nullable: true }) public readonly reject_reason!: string | null;
@Field(() => Date) public readonly created_at!: Date;
@Field(() => Date) public readonly updated_at!: Date;
constructor(init: Partial<MarketplaceOfferDTO>) {
Object.assign(this, init);
}
}
@ObjectType('MarketplaceOfferPaginationResult')
export class MarketplaceOfferPaginationResultDTO extends createPaginationResult(
MarketplaceOfferDTO,
'MarketplaceOffer'
) {}
@ObjectType('MarketplaceCategory')
export class MarketplaceCategoryDTO {
@Field(() => Int) public readonly id!: number;
@Field(() => String) public readonly display_name!: string;
@Field(() => Int) public readonly sort_order!: number;
@Field(() => Boolean) public readonly mvp_baseline!: boolean;
constructor(init: {
id: number;
display_name: string;
sort_order: number;
mvp_baseline: boolean;
}) {
this.id = init.id;
this.display_name = init.display_name;
this.sort_order = init.sort_order;
this.mvp_baseline = init.mvp_baseline;
}
}
@@ -0,0 +1,38 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('MarketplaceVitrine')
export class MarketplaceVitrineDTO {
@Field(() => String, { description: 'Идентификатор витрины (MVP всегда "default")' })
public readonly id!: string;
@Field(() => String, { description: 'eosio::name кооператива-владельца витрины' })
public readonly coopname!: string;
@Field(() => String, { description: 'Отображаемое имя витрины' })
public readonly display_name!: string;
@Field(() => Boolean, { description: 'Дефолтная витрина кооператива (MVP — всегда true)' })
public readonly is_default!: boolean;
@Field(() => Date)
public readonly created_at!: Date;
@Field(() => Date)
public readonly updated_at!: Date;
constructor(init: {
id: string;
coopname: string;
display_name: string;
is_default: boolean;
created_at: Date;
updated_at: Date;
}) {
this.id = init.id;
this.coopname = init.coopname;
this.display_name = init.display_name;
this.is_default = init.is_default;
this.created_at = init.created_at;
this.updated_at = init.updated_at;
}
}
@@ -0,0 +1,41 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('MarketplaceWhitelistEntry')
export class MarketplaceWhitelistEntryDTO {
@Field(() => String, { description: 'UUID записи whitelist' })
public readonly id!: string;
@Field(() => String, { description: 'eosio::name кооператива' })
public readonly coopname!: string;
@Field(() => String, { description: 'eosio::name пайщика-поставщика' })
public readonly member_account!: string;
@Field(() => String, {
description:
'auto-coop — сам кооператив (неудаляема, FR5); manual — добавлен админом',
})
public readonly role!: string;
@Field(() => String, { nullable: true, description: 'Кто добавил (eosio::name админа)' })
public readonly added_by!: string | null;
@Field(() => Date)
public readonly added_at!: Date;
constructor(init: {
id: string;
coopname: string;
member_account: string;
role: string;
added_by: string | null;
added_at: Date;
}) {
this.id = init.id;
this.coopname = init.coopname;
this.member_account = init.member_account;
this.role = init.role;
this.added_by = init.added_by;
this.added_at = init.added_at;
}
}
@@ -0,0 +1,20 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, Matches, MaxLength } from 'class-validator';
@InputType('MarketplaceAddToWhitelistInput')
export class MarketplaceAddToWhitelistInputDTO {
@Field(() => String, { description: 'eosio::name пайщика-поставщика (3-12 chars, [.12345abcdefghijklmnopqrstuvwxyz])' })
@IsNotEmpty()
@MaxLength(13)
@Matches(/^[.1-5a-z]{1,12}$/, { message: 'Некорректный eosio::name (только [.1-5a-z], до 12 символов)' })
public member_account!: string;
}
@InputType('MarketplaceRemoveFromWhitelistInput')
export class MarketplaceRemoveFromWhitelistInputDTO {
@Field(() => String, { description: 'eosio::name пайщика-поставщика' })
@IsNotEmpty()
@MaxLength(13)
@Matches(/^[.1-5a-z]{1,12}$/, { message: 'Некорректный eosio::name' })
public member_account!: string;
}
@@ -1,4 +1,4 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
import { CanActivate, ExecutionContext, ForbiddenException, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import config from '~/config/config';
@@ -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_WHITELIST_SERVICE,
type MarketplaceWhitelistService,
} from '../services/marketplace-whitelist.service';
/**
* Guard расширения marketplace (Стол заказов, Story 1.3).
@@ -26,7 +30,12 @@ import { mapCoreRolesToMarketplaceRoles } from '../membership/marketplace-roles.
*/
@Injectable()
export class MarketplaceMembershipGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
constructor(
@Inject(MARKETPLACE_WHITELIST_SERVICE)
private readonly whitelistService: MarketplaceWhitelistService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const ctx = GqlExecutionContext.create(context);
const gqlContext = ctx.getContext();
const request = gqlContext.req;
@@ -46,9 +55,13 @@ export class MarketplaceMembershipGuard implements CanActivate {
}
const coreRoles = mapUserRoleToCoreRoles(user.role);
// Story 1.6: context-флаги (isOfferer/isKuChairman) пока всегда false —
// источники whitelist и КУ-председательства реализуются в Эпиках 2/3.
const marketplaceRoles = mapCoreRolesToMarketplaceRoles(coreRoles, {});
// 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);
const marketplaceRoles = mapCoreRolesToMarketplaceRoles(coreRoles, {
isOfferer,
});
const currentMember: IMarketplaceCurrentMember = {
username: user.username,
@@ -10,12 +10,41 @@ import { MarketplaceOnboardingResolver } from './resolvers/marketplace-onboardin
import { MarketplaceMemberWalletResolver } from './resolvers/marketplace-member-wallet.resolver';
import { MarketplaceCoopAcceptanceResolver } from './resolvers/marketplace-coop-acceptance.resolver';
import { MarketplaceRegistrationOfferResolver } from './resolvers/marketplace-registration-offer.resolver';
import { MarketplaceWhitelistResolver } from './resolvers/marketplace-whitelist.resolver';
import { MarketplaceVitrineResolver } from './resolvers/marketplace-vitrine.resolver';
import { MarketplaceOfferResolver } from './resolvers/marketplace-offer.resolver';
import { MarketplaceModerationResolver } from './resolvers/marketplace-moderation.resolver';
import { MarketplaceCatalogResolver } from './resolvers/marketplace-catalog.resolver';
import { MarketplaceMembershipGuard } from './guards/marketplace-membership.guard';
import { MarketplaceRoleGuard } from './guards/marketplace-role.guard';
import { MarketplaceOnboardingService } from './onboarding/marketplace-onboarding.service';
import { MarketplaceCoopAcceptanceService } from './coop-acceptance/marketplace-coop-acceptance.service';
import { CategoryTreeService, CATEGORY_TREE_SERVICE } from './services/category-tree.service';
import { KuDetailsService } from './services/ku-details.service';
import {
MarketplaceWhitelistService,
MARKETPLACE_WHITELIST_SERVICE,
} from './services/marketplace-whitelist.service';
import {
MarketplaceVitrineService,
MARKETPLACE_VITRINE_SERVICE,
} from './services/marketplace-vitrine.service';
import {
MarketplaceOfferService,
MARKETPLACE_OFFER_SERVICE,
} from './services/marketplace-offer.service';
import {
MarketplaceCategoryService,
MARKETPLACE_CATEGORY_SERVICE,
} from './services/marketplace-category.service';
import {
MarketplaceModerationService,
MARKETPLACE_MODERATION_SERVICE,
} from './services/marketplace-moderation.service';
import {
MarketplaceOfferCountersService,
MARKETPLACE_OFFER_COUNTERS_SERVICE,
} from './services/marketplace-offer-counters.service';
/**
* Модуль приложения marketplace
@@ -35,6 +64,11 @@ import { KuDetailsService } from './services/ku-details.service';
MarketplaceMemberWalletResolver,
MarketplaceCoopAcceptanceResolver,
MarketplaceRegistrationOfferResolver,
MarketplaceWhitelistResolver,
MarketplaceVitrineResolver,
MarketplaceOfferResolver,
MarketplaceModerationResolver,
MarketplaceCatalogResolver,
// Guards (Story 1.3 / Story 1.6)
MarketplaceMembershipGuard,
@@ -49,6 +83,40 @@ import { KuDetailsService } from './services/ku-details.service';
KuDetailsService,
MarketplaceOnboardingService,
MarketplaceCoopAcceptanceService,
// Story 3.1
{
provide: MARKETPLACE_WHITELIST_SERVICE,
useClass: MarketplaceWhitelistService,
},
MarketplaceWhitelistService,
{
provide: MARKETPLACE_VITRINE_SERVICE,
useClass: MarketplaceVitrineService,
},
MarketplaceVitrineService,
// Story 3.2
{
provide: MARKETPLACE_OFFER_SERVICE,
useClass: MarketplaceOfferService,
},
MarketplaceOfferService,
{
provide: MARKETPLACE_CATEGORY_SERVICE,
useClass: MarketplaceCategoryService,
},
MarketplaceCategoryService,
// Story 3.3
{
provide: MARKETPLACE_MODERATION_SERVICE,
useClass: MarketplaceModerationService,
},
MarketplaceModerationService,
// Story 3.4
{
provide: MARKETPLACE_OFFER_COUNTERS_SERVICE,
useClass: MarketplaceOfferCountersService,
},
MarketplaceOfferCountersService,
],
exports: [
// Экспортируем сервисы для использования в других модулях
@@ -58,6 +126,18 @@ import { KuDetailsService } from './services/ku-details.service';
KuDetailsService,
MarketplaceOnboardingService,
MarketplaceCoopAcceptanceService,
MARKETPLACE_WHITELIST_SERVICE,
MarketplaceWhitelistService,
MARKETPLACE_VITRINE_SERVICE,
MarketplaceVitrineService,
MARKETPLACE_OFFER_SERVICE,
MarketplaceOfferService,
MARKETPLACE_CATEGORY_SERVICE,
MarketplaceCategoryService,
MARKETPLACE_MODERATION_SERVICE,
MarketplaceModerationService,
MARKETPLACE_OFFER_COUNTERS_SERVICE,
MarketplaceOfferCountersService,
// Экспортируем резолверы для регистрации в GraphQL
CategoryTreeResolver,
@@ -70,6 +150,11 @@ import { KuDetailsService } from './services/ku-details.service';
MarketplaceMemberWalletResolver,
MarketplaceCoopAcceptanceResolver,
MarketplaceRegistrationOfferResolver,
MarketplaceWhitelistResolver,
MarketplaceVitrineResolver,
MarketplaceOfferResolver,
MarketplaceModerationResolver,
MarketplaceCatalogResolver,
],
})
export class MarketplaceExtensionApplicationModule {}
@@ -0,0 +1,124 @@
import { Inject, Injectable, UseGuards } from '@nestjs/common';
import { Args, Query, Resolver } from '@nestjs/graphql';
import config from '~/config/config';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { RequireMarketplaceAccess } from '../decorators/marketplace-access.decorator';
import {
MarketplaceCategoryOfferCountDTO,
MarketplaceListCatalogInputDTO,
} from '../dto/marketplace-catalog.dto';
import {
MarketplaceOfferDTO,
MarketplaceOfferPaginationResultDTO,
} from '../dto/marketplace-offer.dto';
import { MarketplaceMembershipGuard } from '../guards/marketplace-membership.guard';
import { MarketplaceRoleGuard } from '../guards/marketplace-role.guard';
import {
MARKETPLACE_OFFER_REPOSITORY,
type MarketplaceOfferDomainRepository,
} from '../../domain/repositories/marketplace-offer.repository';
import { MARKETPLACE_FOOD_CATEGORIES } from '../../domain/entities/marketplace-category.entity';
import type { MarketplaceOfferDomainEntity } from '../../domain/entities/marketplace-offer.entity';
function toOfferDTO(o: MarketplaceOfferDomainEntity): MarketplaceOfferDTO {
return new MarketplaceOfferDTO({
id: o.id,
coopname: o.coopname,
supplier_account: o.supplier_account,
vitrine_id: o.vitrine_id,
product_name: o.product_name,
description: o.description,
category_id: o.category_id,
price_per_unit: o.price_per_unit,
unit_of_measure: o.unit_of_measure,
quantity_available: o.quantity_available,
quantity_blocked: o.quantity_blocked,
quantity_consumed: o.quantity_consumed,
unlimited_flag: o.unlimited_flag,
cycle_type: o.cycle_type,
cycle_days: o.cycle_days,
target_volume: o.target_volume,
max_wait_days: o.max_wait_days,
min_threshold: o.min_threshold,
warranty_days: o.warranty_days,
status: o.status,
approved_by: o.approved_by,
approved_at: o.approved_at,
rejected_by: o.rejected_by,
rejected_at: o.rejected_at,
reject_reason: o.reject_reason,
created_at: o.created_at,
updated_at: o.updated_at,
});
}
/**
* Story 3.5: каталог активных Offer'ов с фильтром по 10 кооп-категориям.
*
* `marketplaceListCatalog` — только ACTIVE + (unlimited OR available>0),
* фильтр по category_id, сортировка (default created_at DESC, опции по
* цене). Доступ — `Offer:read` всем marketplace-ролям (orderer/offerer/
* operator/admin).
*
* `marketplaceCategoryOfferCounts` — кол-во активных Offer'ов в каждой
* категории (для счётчиков фильтр-чипов).
*/
@Resolver()
@Injectable()
export class MarketplaceCatalogResolver {
constructor(
@Inject(MARKETPLACE_OFFER_REPOSITORY)
private readonly offerRepo: MarketplaceOfferDomainRepository
) {}
@Query(() => MarketplaceOfferPaginationResultDTO, {
name: 'marketplaceListCatalog',
description: 'Каталог активных Offer\'ов (ACTIVE + available, single vitrine MVP)',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Offer', 'read')
async marketplaceListCatalog(
@Args('input', { nullable: true }) input?: MarketplaceListCatalogInputDTO
): Promise<MarketplaceOfferPaginationResultDTO> {
const pagination = {
page: input?.page ?? 1,
limit: input?.limit ?? 24,
sortBy: input?.sortBy ?? 'created_at',
sortOrder: (input?.sortOrder ?? 'DESC') as 'ASC' | 'DESC',
};
const result = await this.offerRepo.list(
{
coopname: config.coopname,
status: 'ACTIVE',
category_id: input?.category_id ?? undefined,
available_only: true,
},
pagination
);
return {
items: result.items.map(toOfferDTO),
totalCount: result.totalCount,
totalPages: result.totalPages,
currentPage: result.currentPage,
};
}
@Query(() => [MarketplaceCategoryOfferCountDTO], {
name: 'marketplaceCategoryOfferCounts',
description: 'Счётчики активных Offer\'ов per category — для фильтр-чипов Story 3.5',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Offer', 'read')
async marketplaceCategoryOfferCounts(): Promise<MarketplaceCategoryOfferCountDTO[]> {
const map = await this.offerRepo.countByCategory(config.coopname);
return MARKETPLACE_FOOD_CATEGORIES.map(
(c) =>
new MarketplaceCategoryOfferCountDTO({
category_id: c.id,
count: map.get(c.id) ?? 0,
})
);
}
}
@@ -0,0 +1,148 @@
import { Inject, Injectable, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import config from '~/config/config';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { CurrentMarketplaceMember } from '../decorators/current-marketplace-member.decorator';
import { RequireMarketplaceAccess } from '../decorators/marketplace-access.decorator';
import {
MarketplaceOfferDTO,
MarketplaceOfferPaginationResultDTO,
} from '../dto/marketplace-offer.dto';
import {
MarketplaceApproveOfferInputDTO,
MarketplaceListPendingOffersInputDTO,
MarketplaceModerationLogEntryDTO,
MarketplaceRejectOfferInputDTO,
} from '../dto/marketplace-moderation.dto';
import type { IMarketplaceCurrentMember } from '../dto/marketplace-current-member.dto';
import { MarketplaceMembershipGuard } from '../guards/marketplace-membership.guard';
import { MarketplaceRoleGuard } from '../guards/marketplace-role.guard';
import {
MARKETPLACE_MODERATION_SERVICE,
MarketplaceModerationService,
} from '../services/marketplace-moderation.service';
import type { MarketplaceOfferDomainEntity } from '../../domain/entities/marketplace-offer.entity';
function toOfferDTO(o: MarketplaceOfferDomainEntity): MarketplaceOfferDTO {
return new MarketplaceOfferDTO({
id: o.id,
coopname: o.coopname,
supplier_account: o.supplier_account,
vitrine_id: o.vitrine_id,
product_name: o.product_name,
description: o.description,
category_id: o.category_id,
price_per_unit: o.price_per_unit,
unit_of_measure: o.unit_of_measure,
quantity_available: o.quantity_available,
quantity_blocked: o.quantity_blocked,
quantity_consumed: o.quantity_consumed,
unlimited_flag: o.unlimited_flag,
cycle_type: o.cycle_type,
cycle_days: o.cycle_days,
target_volume: o.target_volume,
max_wait_days: o.max_wait_days,
min_threshold: o.min_threshold,
warranty_days: o.warranty_days,
status: o.status,
approved_by: o.approved_by,
approved_at: o.approved_at,
rejected_by: o.rejected_by,
rejected_at: o.rejected_at,
reject_reason: o.reject_reason,
created_at: o.created_at,
updated_at: o.updated_at,
});
}
/**
* Story 3.3: модерация Offer'ов админом (Chairman marketplace-role admin).
*
* Все 3 операции под `@RequireMarketplaceAccess('Offer','moderate')` —
* матрица отдаёт это только admin.
*/
@Resolver()
@Injectable()
export class MarketplaceModerationResolver {
constructor(
@Inject(MARKETPLACE_MODERATION_SERVICE)
private readonly service: MarketplaceModerationService
) {}
@Query(() => MarketplaceOfferPaginationResultDTO, {
name: 'marketplaceListPendingOffers',
description: 'Список Offer\'ов на модерации (admin)',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Offer', 'moderate')
async marketplaceListPendingOffers(
@Args('input', { nullable: true }) input?: MarketplaceListPendingOffersInputDTO
): Promise<MarketplaceOfferPaginationResultDTO> {
const pagination = {
page: input?.page ?? 1,
limit: input?.limit ?? 50,
sortBy: input?.sortBy ?? 'created_at',
sortOrder: (input?.sortOrder ?? 'DESC') as 'ASC' | 'DESC',
};
const result = await this.service.listPending(config.coopname, pagination);
return {
items: result.items.map(toOfferDTO),
totalCount: result.totalCount,
totalPages: result.totalPages,
currentPage: result.currentPage,
};
}
@Mutation(() => MarketplaceOfferDTO, {
name: 'marketplaceApproveOffer',
description: 'Одобрить Offer (status → ACTIVE) (admin)',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Offer', 'moderate')
async marketplaceApproveOffer(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('input') input: MarketplaceApproveOfferInputDTO
): Promise<MarketplaceOfferDTO> {
const offer = await this.service.approve(input.offer_id, member.username);
return toOfferDTO(offer);
}
@Mutation(() => MarketplaceOfferDTO, {
name: 'marketplaceRejectOffer',
description: 'Отклонить Offer с причиной (status → REJECTED) (admin)',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Offer', 'moderate')
async marketplaceRejectOffer(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('input') input: MarketplaceRejectOfferInputDTO
): Promise<MarketplaceOfferDTO> {
const offer = await this.service.reject(input.offer_id, member.username, input.reason);
return toOfferDTO(offer);
}
@Query(() => [MarketplaceModerationLogEntryDTO], {
name: 'marketplaceListModerationLog',
description: 'История решений модерации по Offer\'у (admin)',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Offer', 'moderate')
async marketplaceListModerationLog(
@Args('offer_id') offer_id: string
): Promise<MarketplaceModerationLogEntryDTO[]> {
const entries = await this.service.listLog(offer_id);
return entries.map(
(e) =>
new MarketplaceModerationLogEntryDTO({
id: e.id,
offer_id: e.offer_id,
action: e.action,
by_account: e.by_account,
reason: e.reason,
created_at: e.created_at,
})
);
}
}
@@ -0,0 +1,192 @@
import { Inject, Injectable, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import config from '~/config/config';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { CurrentMarketplaceMember } from '../decorators/current-marketplace-member.decorator';
import { RequireMarketplaceAccess } from '../decorators/marketplace-access.decorator';
import {
MarketplaceCategoryDTO,
MarketplaceOfferDTO,
MarketplaceOfferPaginationResultDTO,
} from '../dto/marketplace-offer.dto';
import {
MarketplaceCreateOfferInputDTO,
MarketplaceListMyOffersInputDTO,
MarketplaceUpdateOfferInputDTO,
MarketplaceWithdrawOfferInputDTO,
} from '../dto/marketplace-offer-input.dto';
import type { IMarketplaceCurrentMember } from '../dto/marketplace-current-member.dto';
import { MarketplaceMembershipGuard } from '../guards/marketplace-membership.guard';
import { MarketplaceRoleGuard } from '../guards/marketplace-role.guard';
import {
MARKETPLACE_OFFER_SERVICE,
MarketplaceOfferService,
} from '../services/marketplace-offer.service';
import {
MARKETPLACE_CATEGORY_SERVICE,
MarketplaceCategoryService,
} from '../services/marketplace-category.service';
import type { MarketplaceOfferDomainEntity } from '../../domain/entities/marketplace-offer.entity';
function toDTO(o: MarketplaceOfferDomainEntity): MarketplaceOfferDTO {
return new MarketplaceOfferDTO({
id: o.id,
coopname: o.coopname,
supplier_account: o.supplier_account,
vitrine_id: o.vitrine_id,
product_name: o.product_name,
description: o.description,
category_id: o.category_id,
price_per_unit: o.price_per_unit,
unit_of_measure: o.unit_of_measure,
quantity_available: o.quantity_available,
quantity_blocked: o.quantity_blocked,
quantity_consumed: o.quantity_consumed,
unlimited_flag: o.unlimited_flag,
cycle_type: o.cycle_type,
cycle_days: o.cycle_days,
target_volume: o.target_volume,
max_wait_days: o.max_wait_days,
min_threshold: o.min_threshold,
warranty_days: o.warranty_days,
status: o.status,
approved_by: o.approved_by,
approved_at: o.approved_at,
rejected_by: o.rejected_by,
rejected_at: o.rejected_at,
reject_reason: o.reject_reason,
created_at: o.created_at,
updated_at: o.updated_at,
});
}
/**
* Story 3.2 + 3.5: GraphQL для Offer'ов и категорий.
*
* Story 3.2 операции — capability `Offer:create:own/update:own/delete:own`
* (offerer); ownership проверяется в `MarketplaceOfferService` (supplier_account
* == currentMember.username).
*
* `marketplaceListCategories` — `Offer:read` всем marketplace-ролям
* (категорий справочник нужен и заказчику для фильтра-чипов Story 3.5).
*/
@Resolver()
@Injectable()
export class MarketplaceOfferResolver {
constructor(
@Inject(MARKETPLACE_OFFER_SERVICE)
private readonly offerService: MarketplaceOfferService,
@Inject(MARKETPLACE_CATEGORY_SERVICE)
private readonly categoryService: MarketplaceCategoryService
) {}
@Query(() => [MarketplaceCategoryDTO], {
name: 'marketplaceListCategories',
description: 'Baseline-категории Стола заказов (Story 3.2/3.5) — 8 продовольственных + «Прочее»',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Offer', 'read')
async marketplaceListCategories(): Promise<MarketplaceCategoryDTO[]> {
const cats = await this.categoryService.listBaseline();
return cats.map(
(c) =>
new MarketplaceCategoryDTO({
id: c.id,
display_name: c.display_name,
sort_order: c.sort_order,
mvp_baseline: c.mvp_baseline,
})
);
}
@Mutation(() => MarketplaceOfferDTO, {
name: 'marketplaceCreateOffer',
description: 'Поставщик публикует Offer (статус → PENDING_MODERATION)',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Offer', 'create:own')
async marketplaceCreateOffer(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('input') input: MarketplaceCreateOfferInputDTO
): Promise<MarketplaceOfferDTO> {
const offer = await this.offerService.create({
coopname: config.coopname,
supplier_account: member.username,
vitrine_id: 'default',
product_name: input.product_name,
description: input.description ?? null,
category_id: input.category_id,
price_per_unit: input.price_per_unit,
unit_of_measure: input.unit_of_measure,
quantity_available: input.quantity_available ?? null,
unlimited_flag: input.unlimited_flag,
cycle_type: input.cycle_type,
cycle_days: input.cycle_days ?? null,
target_volume: input.target_volume ?? null,
max_wait_days: input.max_wait_days ?? null,
min_threshold: input.min_threshold ?? null,
warranty_days: input.warranty_days,
});
return toDTO(offer);
}
@Mutation(() => MarketplaceOfferDTO, {
name: 'marketplaceUpdateOffer',
description: 'Поставщик правит свой Offer — статус сбрасывается в PENDING_MODERATION',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Offer', 'update:own')
async marketplaceUpdateOffer(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('input') input: MarketplaceUpdateOfferInputDTO
): Promise<MarketplaceOfferDTO> {
const { id, ...patch } = input;
const offer = await this.offerService.update(id, member.username, patch);
return toDTO(offer);
}
@Mutation(() => MarketplaceOfferDTO, {
name: 'marketplaceWithdrawOffer',
description: 'Поставщик снимает свой Offer (статус → WITHDRAWN)',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Offer', 'delete:own')
async marketplaceWithdrawOffer(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('input') input: MarketplaceWithdrawOfferInputDTO
): Promise<MarketplaceOfferDTO> {
const offer = await this.offerService.withdraw(input.id, member.username);
return toDTO(offer);
}
@Query(() => MarketplaceOfferPaginationResultDTO, {
name: 'marketplaceListMyOffers',
description: 'Список собственных Offer\'ов поставщика (любой статус)',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Offer', 'create:own')
async marketplaceListMyOffers(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('input', { nullable: true }) input?: MarketplaceListMyOffersInputDTO
): Promise<MarketplaceOfferPaginationResultDTO> {
const pagination = {
page: input?.page ?? 1,
limit: input?.limit ?? 50,
sortBy: input?.sortBy ?? 'created_at',
sortOrder: (input?.sortOrder ?? 'DESC') as 'ASC' | 'DESC',
};
const result = await this.offerService.listMine(
config.coopname,
member.username,
pagination
);
return {
items: result.items.map(toDTO),
totalCount: result.totalCount,
totalPages: result.totalPages,
currentPage: result.currentPage,
};
}
}
@@ -0,0 +1,51 @@
import { Inject, Injectable, NotFoundException, UseGuards } from '@nestjs/common';
import { Query, Resolver } from '@nestjs/graphql';
import config from '~/config/config';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { RequireMarketplaceAccess } from '../decorators/marketplace-access.decorator';
import { MarketplaceVitrineDTO } from '../dto/marketplace-vitrine.dto';
import { MarketplaceMembershipGuard } from '../guards/marketplace-membership.guard';
import { MarketplaceRoleGuard } from '../guards/marketplace-role.guard';
import {
MARKETPLACE_VITRINE_SERVICE,
MarketplaceVitrineService,
} from '../services/marketplace-vitrine.service';
/**
* Story 3.1: чтение дефолтной витрины. MVP — одна витрина на кооператив;
* `Vitrine:['read']` всем marketplace-ролям (orderer/offerer/operator/admin),
* мутации (manage) — только admin (не реализованы в MVP, Phase 2).
*/
@Resolver()
@Injectable()
export class MarketplaceVitrineResolver {
constructor(
@Inject(MARKETPLACE_VITRINE_SERVICE)
private readonly service: MarketplaceVitrineService
) {}
@Query(() => MarketplaceVitrineDTO, {
name: 'marketplaceDefaultVitrine',
description: 'Дефолтная витрина кооператива (MVP — единственная)',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Vitrine', 'read')
async marketplaceDefaultVitrine(): Promise<MarketplaceVitrineDTO> {
const v = await this.service.getDefault(config.coopname);
if (!v) {
throw new NotFoundException(
'Дефолтная витрина не найдена — расширение marketplace ещё не bootstrap-нуло данные (нужна миграция v3)'
);
}
return new MarketplaceVitrineDTO({
id: v.id,
coopname: v.coopname,
display_name: v.display_name,
is_default: v.is_default,
created_at: v.created_at,
updated_at: v.updated_at,
});
}
}
@@ -0,0 +1,94 @@
import { Inject, Injectable, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import config from '~/config/config';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { CurrentMarketplaceMember } from '../decorators/current-marketplace-member.decorator';
import { RequireMarketplaceAccess } from '../decorators/marketplace-access.decorator';
import type { IMarketplaceCurrentMember } from '../dto/marketplace-current-member.dto';
import {
MarketplaceAddToWhitelistInputDTO,
MarketplaceRemoveFromWhitelistInputDTO,
} from '../dto/marketplace-whitelist-input.dto';
import { MarketplaceWhitelistEntryDTO } from '../dto/marketplace-whitelist-entry.dto';
import { MarketplaceMembershipGuard } from '../guards/marketplace-membership.guard';
import { MarketplaceRoleGuard } from '../guards/marketplace-role.guard';
import {
MARKETPLACE_WHITELIST_SERVICE,
MarketplaceWhitelistService,
} from '../services/marketplace-whitelist.service';
/**
* Story 3.1: GraphQL endpoints управления whitelist поставщиков.
*
* Все 3 операции под `@RequireMarketplaceAccess('Whitelist', 'manage')` —
* matrix admin'у даёт `Whitelist: ['manage']`, остальным — нет (403).
*/
@Resolver()
@Injectable()
export class MarketplaceWhitelistResolver {
constructor(
@Inject(MARKETPLACE_WHITELIST_SERVICE)
private readonly service: MarketplaceWhitelistService
) {}
@Query(() => [MarketplaceWhitelistEntryDTO], {
name: 'marketplaceListWhitelist',
description: 'Список пайщиков-поставщиков, допущенных к публикации оферт',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Whitelist', 'manage')
async marketplaceListWhitelist(): Promise<MarketplaceWhitelistEntryDTO[]> {
const entries = await this.service.list(config.coopname);
return entries.map(
(e) =>
new MarketplaceWhitelistEntryDTO({
id: e.id,
coopname: e.coopname,
member_account: e.member_account,
role: e.role,
added_by: e.added_by,
added_at: e.added_at,
})
);
}
@Mutation(() => MarketplaceWhitelistEntryDTO, {
name: 'marketplaceAddToWhitelist',
description: 'Добавить пайщика в whitelist поставщиков (admin)',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Whitelist', 'manage')
async marketplaceAddToWhitelist(
@CurrentMarketplaceMember() currentMember: IMarketplaceCurrentMember,
@Args('input') input: MarketplaceAddToWhitelistInputDTO
): Promise<MarketplaceWhitelistEntryDTO> {
const entry = await this.service.addToWhitelist(
config.coopname,
input.member_account,
currentMember.username
);
return new MarketplaceWhitelistEntryDTO({
id: entry.id,
coopname: entry.coopname,
member_account: entry.member_account,
role: entry.role,
added_by: entry.added_by,
added_at: entry.added_at,
});
}
@Mutation(() => Boolean, {
name: 'marketplaceRemoveFromWhitelist',
description: 'Удалить пайщика из whitelist (admin); auto-coop запись неудаляема',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Whitelist', 'manage')
async marketplaceRemoveFromWhitelist(
@Args('input') input: MarketplaceRemoveFromWhitelistInputDTO
): Promise<boolean> {
await this.service.removeFromWhitelist(config.coopname, input.member_account);
return true;
}
}
@@ -0,0 +1,24 @@
import { Inject, Injectable } from '@nestjs/common';
import {
MARKETPLACE_CATEGORY_REPOSITORY,
type MarketplaceCategoryDomainRepository,
} from '../../domain/repositories/marketplace-category.repository';
import type { MarketplaceCategoryDomainEntity } from '../../domain/entities/marketplace-category.entity';
export const MARKETPLACE_CATEGORY_SERVICE = Symbol('MARKETPLACE_CATEGORY_SERVICE');
/**
* Story 3.5: чтение 10 baseline-категорий для фильтр-чипов и формы
* создания Offer'а (Story 3.2).
*/
@Injectable()
export class MarketplaceCategoryService {
constructor(
@Inject(MARKETPLACE_CATEGORY_REPOSITORY)
private readonly repo: MarketplaceCategoryDomainRepository
) {}
async listBaseline(): Promise<MarketplaceCategoryDomainEntity[]> {
return this.repo.listBaseline();
}
}
@@ -0,0 +1,150 @@
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
MARKETPLACE_OFFER_REPOSITORY,
type MarketplaceOfferDomainRepository,
} from '../../domain/repositories/marketplace-offer.repository';
import {
MARKETPLACE_MODERATION_LOG_REPOSITORY,
type MarketplaceModerationLogDomainRepository,
} from '../../domain/repositories/marketplace-moderation-log.repository';
import type { MarketplaceOfferDomainEntity } from '../../domain/entities/marketplace-offer.entity';
import type { MarketplaceModerationLogDomainEntity } from '../../domain/entities/marketplace-moderation-log.entity';
import type {
PaginationInputDomainInterface,
PaginationResultDomainInterface,
} from '~/domain/common/interfaces/pagination.interface';
export const MARKETPLACE_MODERATION_SERVICE = Symbol('MARKETPLACE_MODERATION_SERVICE');
/**
* Story 3.3: модерация Offer'ов админом.
*
* Транзакционно: applyUpdate(offer) → append(log); порядок save→emit pubsub
* соответствует INV-12 controller/CLAUDE.md (никогда emit до save).
*
* Pubsub-канал per-extension: `marketplace.offer.{approved|rejected}` —
* под GraphQL Subscription / in-app notifications (UX-DR38) listener'ы.
* Сам emit сейчас идёт через `EventEmitter2` (core-bus); конкретная
* подписка/Subscription resolver — Phase 2 (notifications не входят в
* scope Эпика 3 / тек. PR).
*/
@Injectable()
export class MarketplaceModerationService {
public static readonly MAX_REJECT_REASON_LEN = 1000;
public static readonly EVENT_APPROVED = 'marketplace.offer.approved';
public static readonly EVENT_REJECTED = 'marketplace.offer.rejected';
constructor(
@Inject(MARKETPLACE_OFFER_REPOSITORY)
private readonly offerRepo: MarketplaceOfferDomainRepository,
@Inject(MARKETPLACE_MODERATION_LOG_REPOSITORY)
private readonly logRepo: MarketplaceModerationLogDomainRepository,
private readonly eventBus: EventEmitter2
) {}
async listPending(
coopname: string,
pagination: PaginationInputDomainInterface
): Promise<PaginationResultDomainInterface<MarketplaceOfferDomainEntity>> {
return this.offerRepo.list({ coopname, status: 'PENDING_MODERATION' }, pagination);
}
async approve(offer_id: string, admin_account: string): Promise<MarketplaceOfferDomainEntity> {
const offer = await this.requirePending(offer_id);
const updated = await this.offerRepo.applyUpdate(offer.id, {
status: 'ACTIVE',
approved_by: admin_account,
approved_at: new Date(),
rejected_by: null,
rejected_at: null,
reject_reason: null,
});
await this.logRepo.append({
offer_id: offer.id,
action: 'approve',
by_account: admin_account,
reason: null,
});
this.eventBus.emit(MarketplaceModerationService.EVENT_APPROVED, {
offer_id: offer.id,
supplier_account: offer.supplier_account,
approved_by: admin_account,
});
return updated;
}
async reject(
offer_id: string,
admin_account: string,
reason: string
): Promise<MarketplaceOfferDomainEntity> {
const trimmed = reason?.trim();
if (!trimmed) {
throw new BadRequestException('Укажите причину отклонения предложения.');
}
if (trimmed.length > MarketplaceModerationService.MAX_REJECT_REASON_LEN) {
throw new BadRequestException(
`Причина отклонения слишком длинная (максимум ${MarketplaceModerationService.MAX_REJECT_REASON_LEN} символов).`
);
}
const offer = await this.requirePending(offer_id);
const now = new Date();
const updated = await this.offerRepo.applyUpdate(offer.id, {
status: 'REJECTED',
rejected_by: admin_account,
rejected_at: now,
reject_reason: trimmed,
});
await this.logRepo.append({
offer_id: offer.id,
action: 'reject',
by_account: admin_account,
reason: trimmed,
});
this.eventBus.emit(MarketplaceModerationService.EVENT_REJECTED, {
offer_id: offer.id,
supplier_account: offer.supplier_account,
rejected_by: admin_account,
reason: trimmed,
});
return updated;
}
async listLog(offer_id: string): Promise<MarketplaceModerationLogDomainEntity[]> {
return this.logRepo.listByOffer(offer_id);
}
private async requirePending(offer_id: string): Promise<MarketplaceOfferDomainEntity> {
const offer = await this.offerRepo.findById(offer_id);
if (!offer) {
throw new NotFoundException('Предложение не найдено.');
}
if (offer.status !== 'PENDING_MODERATION') {
const statusLabel = MarketplaceModerationService.translateStatus(offer.status);
throw new ConflictException(
`Это предложение уже ${statusLabel} — модерация недоступна.`
);
}
return offer;
}
private static translateStatus(status: string): string {
switch (status) {
case 'ACTIVE':
return 'одобрено';
case 'REJECTED':
return 'отклонено';
case 'WITHDRAWN':
return 'снято с публикации';
default:
return `в статусе «${status}»`;
}
}
}
@@ -0,0 +1,147 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
MARKETPLACE_OFFER_REPOSITORY,
type MarketplaceOfferDomainRepository,
} from '../../domain/repositories/marketplace-offer.repository';
import type { MarketplaceOfferDomainEntity } from '../../domain/entities/marketplace-offer.entity';
export const MARKETPLACE_OFFER_COUNTERS_SERVICE = Symbol('MARKETPLACE_OFFER_COUNTERS_SERVICE');
/**
* Story 3.4: backend ведёт `quantity_available` / `quantity_blocked` /
* `quantity_consumed` на Offer'е при операциях с Order'ом.
*
* Точка интеграции с Эпиком 4 (`o.mkt.block` / `o.mkt.unblock` /
* `o.mkt.consume`+`o.mkt.consume2` canonical actions из PR #375):
* order-side syncer вызывает эти методы внутри `dispatch` после `save`
* сущности Order и до `emit pubsub` (см. controller/CLAUDE.md
* Dispatch pipeline, INV-12). Атомарность через SQL UPDATE с CAS-условием
* — гонки между параллельными Order-блокировками не разрушают инвариант.
*
* Инвариант (для не-unlimited Offer'ов):
* available + blocked + consumed == lifetime_published
* (lifetime_published — изначальное `quantity_available` при `create`).
* Инвариант поддерживается дельтами: блок −A +B, разблок +A −B,
* consume −B +C — изменение суммы 0.
*
* `EventEmitter2` пингует канал `marketplace.offer.counters.changed`
* после успешной операции — Story 3.5 каталог / offerer-вкладка
* «Активность» подписываются (Phase 2 GraphQL subscription).
*/
@Injectable()
export class MarketplaceOfferCountersService {
public static readonly EVENT_CHANGED = 'marketplace.offer.counters.changed';
constructor(
@Inject(MARKETPLACE_OFFER_REPOSITORY)
private readonly repo: MarketplaceOfferDomainRepository,
private readonly eventBus: EventEmitter2
) {}
async onOrderBlocked(offer_id: string, qty: number): Promise<MarketplaceOfferDomainEntity> {
this.assertPositive(qty);
const result = await this.repo.applyBlockDelta(offer_id, qty);
if (!result.ok || !result.offer) {
this.throwForReason(result.reason, offer_id, 'block', qty);
}
this.emit(result.offer!, 'block', qty);
return result.offer!;
}
async onOrderUnblocked(offer_id: string, qty: number): Promise<MarketplaceOfferDomainEntity> {
this.assertPositive(qty);
const result = await this.repo.applyUnblockDelta(offer_id, qty);
if (!result.ok || !result.offer) {
this.throwForReason(result.reason, offer_id, 'unblock', qty);
}
this.emit(result.offer!, 'unblock', qty);
return result.offer!;
}
async onOrderConsumed(offer_id: string, qty: number): Promise<MarketplaceOfferDomainEntity> {
this.assertPositive(qty);
const result = await this.repo.applyConsumeDelta(offer_id, qty);
if (!result.ok || !result.offer) {
this.throwForReason(result.reason, offer_id, 'consume', qty);
}
this.emit(result.offer!, 'consume', qty);
return result.offer!;
}
/**
* Корректировка «факт меньше заказа» (FR23) — разница K возвращается
* на available. Семантически = unblock на разницу.
*/
async onOrderAdjusted(offer_id: string, qty_diff: number): Promise<MarketplaceOfferDomainEntity> {
return this.onOrderUnblocked(offer_id, qty_diff);
}
/**
* Fork rollback (ADR-005): Order в block-состоянии откатывается
* через `restoreFromVersions`. Counter возвращается без CAS-проверки.
*
* Дёргается из ForkRegistry handler'а `MarketplaceOrderSyncService`
* (см. scaffolding `marketplace-order-syncer.service.ts` и
* spec-3-4-bc-integration.md секция 2.5). Каждый Order, который
* был в `quantity_blocked` Offer'а на момент rollback'а, должен
* вызвать `onOrderRolledBack(offer_id, qty)` — иначе counters
* разъедутся с реальностью.
*/
async onOrderRolledBack(offer_id: string, qty: number): Promise<MarketplaceOfferDomainEntity> {
this.assertPositive(qty);
const result = await this.repo.applyRollbackDelta(offer_id, qty);
if (!result.ok || !result.offer) {
this.throwForReason(result.reason, offer_id, 'rollback', qty);
}
this.emit(result.offer!, 'rollback', qty);
return result.offer!;
}
private assertPositive(qty: number): void {
if (!Number.isInteger(qty) || qty <= 0) {
throw new BadRequestException('Количество должно быть целым числом больше нуля.');
}
}
private throwForReason(
reason: string | undefined,
_offer_id: string,
op: string,
qty: number
): never {
switch (reason) {
case 'offer_not_found':
throw new NotFoundException('Предложение не найдено.');
case 'offer_not_active':
throw new BadRequestException(
'Предложение неактивно — операция с количеством запрещена.'
);
case 'insufficient_available':
throw new BadRequestException(
`Недостаточно свободного количества в предложении: требуется ${qty}.`
);
case 'insufficient_blocked':
throw new BadRequestException(
`Недостаточно зарезервированного количества для операции: требуется ${qty}.`
);
default:
throw new BadRequestException(
`Не удалось выполнить операцию «${op}» на ${qty}. Попробуйте обновить страницу.`
);
}
}
private emit(offer: MarketplaceOfferDomainEntity, op: string, qty: number): void {
this.eventBus.emit(MarketplaceOfferCountersService.EVENT_CHANGED, {
offer_id: offer.id,
supplier_account: offer.supplier_account,
op,
qty,
quantity_available: offer.quantity_available,
quantity_blocked: offer.quantity_blocked,
quantity_consumed: offer.quantity_consumed,
unlimited_flag: offer.unlimited_flag,
});
}
}
@@ -0,0 +1,291 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
MARKETPLACE_OFFER_REPOSITORY,
type MarketplaceOfferDomainRepository,
type OfferCreateInput,
type OfferUpdateInput,
} from '../../domain/repositories/marketplace-offer.repository';
import {
MARKETPLACE_CATEGORY_REPOSITORY,
type MarketplaceCategoryDomainRepository,
} from '../../domain/repositories/marketplace-category.repository';
import type { MarketplaceOfferDomainEntity } from '../../domain/entities/marketplace-offer.entity';
import type {
MarketplaceOfferCycleType,
MarketplaceOfferStatus,
MarketplaceUnitOfMeasure,
} from '../../domain/entities/marketplace-offer.types';
import {
MARKETPLACE_OFFER_CYCLE_TYPES,
MARKETPLACE_UNITS_OF_MEASURE,
} from '../../domain/entities/marketplace-offer.types';
import type {
PaginationInputDomainInterface,
PaginationResultDomainInterface,
} from '~/domain/common/interfaces/pagination.interface';
export const MARKETPLACE_OFFER_SERVICE = Symbol('MARKETPLACE_OFFER_SERVICE');
export interface OfferCreateRequest {
coopname: string;
supplier_account: string;
vitrine_id: string;
product_name: string;
description: string | null;
category_id: number;
price_per_unit: string;
unit_of_measure: MarketplaceUnitOfMeasure;
quantity_available: number | null;
unlimited_flag: boolean;
cycle_type: MarketplaceOfferCycleType;
cycle_days: number | null;
target_volume: number | null;
max_wait_days: number | null;
min_threshold: number | null;
warranty_days: number;
}
/**
* Story 3.2: жизненный цикл Offer'а у поставщика.
*
* Lifecycle (AC):
* create → PENDING_MODERATION (rate-limit 10/час на supplier).
* edit → если ACTIVE/PENDING_MODERATION — поля обновляются, status
* сбрасывается в PENDING_MODERATION; REJECTED edit → 403
* (поставщик должен создать новый offer, AC формулировка
* «создаст новый PENDING_MODERATION»); WITHDRAWN edit → 403.
* withdraw→ если ACTIVE/PENDING_MODERATION — status=WITHDRAWN;
* блокируется при наличии незакрытых Order'ов с понятным
* сообщением (заглушка `hasActiveOrders` — реализуется при
* merge Story 4.x, в MVP всегда false).
*
* Owner-проверка (`supplier_account == offer.supplier_account`) на уровне
* сервиса — guard даёт capability, а не ownership.
*/
@Injectable()
export class MarketplaceOfferService {
public static readonly RATE_LIMIT_PER_HOUR = 10;
public static readonly RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
public static readonly MAX_PRODUCT_NAME_LEN = 200;
public static readonly MAX_DESCRIPTION_LEN = 2000;
public static readonly BASELINE_CATEGORY_IDS = [1, 2, 3, 4, 5, 6, 7, 8, 9];
constructor(
@Inject(MARKETPLACE_OFFER_REPOSITORY)
private readonly repo: MarketplaceOfferDomainRepository,
@Inject(MARKETPLACE_CATEGORY_REPOSITORY)
private readonly categoryRepo: MarketplaceCategoryDomainRepository
) {}
async create(input: OfferCreateRequest): Promise<MarketplaceOfferDomainEntity> {
this.validateCreateInput(input);
await this.ensureCategoryExists(input.category_id);
await this.assertRateLimit(input.supplier_account);
const dbInput: OfferCreateInput = {
coopname: input.coopname,
supplier_account: input.supplier_account,
vitrine_id: input.vitrine_id,
product_name: input.product_name.trim(),
description: input.description?.trim() ?? null,
category_id: input.category_id,
price_per_unit: input.price_per_unit,
unit_of_measure: input.unit_of_measure,
quantity_available: input.unlimited_flag ? 0 : (input.quantity_available ?? 0),
unlimited_flag: input.unlimited_flag,
cycle_type: input.cycle_type,
cycle_days: input.cycle_days,
target_volume: input.target_volume,
max_wait_days: input.max_wait_days,
min_threshold: input.min_threshold,
warranty_days: input.warranty_days,
};
return this.repo.create(dbInput);
}
async update(
id: string,
supplier_account: string,
patch: OfferUpdateInput
): Promise<MarketplaceOfferDomainEntity> {
const offer = await this.requireOwnedEditable(id, supplier_account, ['edit']);
if (patch.product_name !== undefined) {
this.assertProductName(patch.product_name);
}
if (patch.description !== undefined) {
this.assertDescription(patch.description);
}
if (patch.category_id !== undefined) {
await this.ensureCategoryExists(patch.category_id);
}
if (patch.cycle_type !== undefined) {
this.assertCycleType(patch.cycle_type);
}
if (patch.unit_of_measure !== undefined) {
this.assertUnit(patch.unit_of_measure);
}
if (patch.warranty_days !== undefined && patch.warranty_days < 0) {
throw new BadRequestException('Срок гарантии не может быть отрицательным.');
}
const normalizedPatch: OfferUpdateInput & { status: MarketplaceOfferStatus } = {
...patch,
status: 'PENDING_MODERATION',
};
if (patch.unlimited_flag === true) {
normalizedPatch.quantity_available = 0;
}
return this.repo.applyUpdate(offer.id, normalizedPatch);
}
async withdraw(id: string, supplier_account: string): Promise<MarketplaceOfferDomainEntity> {
const offer = await this.requireOwnedEditable(id, supplier_account, ['withdraw']);
if (await this.hasActiveOrders(offer.id)) {
throw new ConflictException(
'Нельзя снять предложение: по нему есть незакрытые заказы. Сначала отмените или закройте их.'
);
}
return this.repo.applyUpdate(offer.id, { status: 'WITHDRAWN' });
}
async listMine(
coopname: string,
supplier_account: string,
pagination: PaginationInputDomainInterface
): Promise<PaginationResultDomainInterface<MarketplaceOfferDomainEntity>> {
return this.repo.list({ coopname, supplier_account }, pagination);
}
async getById(id: string): Promise<MarketplaceOfferDomainEntity | null> {
return this.repo.findById(id);
}
private validateCreateInput(input: OfferCreateRequest): void {
this.assertProductName(input.product_name);
if (input.description !== null) this.assertDescription(input.description);
this.assertUnit(input.unit_of_measure);
this.assertCycleType(input.cycle_type);
if (!input.unlimited_flag) {
if (input.quantity_available === null || input.quantity_available < 0) {
throw new BadRequestException(
'Укажите количество товара (целое неотрицательное число) или включите «без ограничения».'
);
}
}
if (input.warranty_days < 0) {
throw new BadRequestException('Срок гарантии не может быть отрицательным.');
}
if (typeof input.price_per_unit !== 'string' || !/^\d+(\.\d{1,4})?$/.test(input.price_per_unit)) {
throw new BadRequestException(
'Цена должна быть числом с не более чем четырьмя знаками после запятой (например, «100.50»).'
);
}
}
private assertProductName(name: string): void {
const trimmed = name?.trim() ?? '';
if (!trimmed) {
throw new BadRequestException('Укажите название товара.');
}
if (trimmed.length > MarketplaceOfferService.MAX_PRODUCT_NAME_LEN) {
throw new BadRequestException(
`Название товара слишком длинное (максимум ${MarketplaceOfferService.MAX_PRODUCT_NAME_LEN} символов).`
);
}
}
private assertDescription(description: string | null): void {
if (description === null) return;
if (description.length > MarketplaceOfferService.MAX_DESCRIPTION_LEN) {
throw new BadRequestException(
`Описание слишком длинное (максимум ${MarketplaceOfferService.MAX_DESCRIPTION_LEN} символов).`
);
}
}
private assertCycleType(t: MarketplaceOfferCycleType): void {
if (!MARKETPLACE_OFFER_CYCLE_TYPES.includes(t)) {
throw new BadRequestException('Выбран недопустимый тип цикла поставки.');
}
}
private assertUnit(u: MarketplaceUnitOfMeasure): void {
if (!MARKETPLACE_UNITS_OF_MEASURE.includes(u)) {
throw new BadRequestException('Выбрана недопустимая единица измерения.');
}
}
private async ensureCategoryExists(category_id: number): Promise<void> {
if (!MarketplaceOfferService.BASELINE_CATEGORY_IDS.includes(category_id)) {
throw new BadRequestException(
`Выбрана недопустимая категория (${category_id}). Допустимы значения от 1 до 9.`
);
}
const category = await this.categoryRepo.findById(category_id);
if (!category) {
throw new BadRequestException(
`Категория с номером ${category_id} не найдена в справочнике. Обратитесь к администратору.`
);
}
}
private async assertRateLimit(supplier_account: string): Promise<void> {
const count = await this.repo.countRecentCreatedBy(
supplier_account,
MarketplaceOfferService.RATE_LIMIT_WINDOW_MS
);
if (count >= MarketplaceOfferService.RATE_LIMIT_PER_HOUR) {
throw new BadRequestException(
`Превышен лимит создания предложений: не более ${MarketplaceOfferService.RATE_LIMIT_PER_HOUR} в час на одного поставщика. Попробуйте позже.`
);
}
}
private async requireOwnedEditable(
id: string,
supplier_account: string,
op: ['edit' | 'withdraw']
): Promise<MarketplaceOfferDomainEntity> {
const offer = await this.repo.findById(id);
if (!offer) {
throw new NotFoundException('Предложение не найдено.');
}
if (offer.supplier_account !== supplier_account) {
throw new ForbiddenException('Можно изменять только свои предложения.');
}
if (offer.status === 'WITHDRAWN' || offer.status === 'REJECTED') {
const action = op[0] === 'withdraw' ? 'снять' : 'отредактировать';
const statusLabel = offer.status === 'WITHDRAWN' ? 'снято' : 'отклонено';
throw new ForbiddenException(
`Нельзя ${action} предложение, которое уже ${statusLabel}.`
);
}
return offer;
}
/**
* Заглушка-точка интеграции с Эпиком 4 (PR #375 canonical actions
* `o.mkt.assign/block/unblock`). До мерджа Эпика 4 в marketplace2
* Order'ов нет — withdraw разрешён всегда. Story 4.x перепишет на
* `OrderRepository.countActiveByOffer(offer_id)`.
*/
private async hasActiveOrders(_offer_id: string): Promise<boolean> {
return false;
}
}
@@ -0,0 +1,31 @@
import { Inject, Injectable } from '@nestjs/common';
import {
MARKETPLACE_VITRINE_REPOSITORY,
type MarketplaceVitrineDomainRepository,
} from '../../domain/repositories/marketplace-vitrine.repository';
import type { MarketplaceVitrineDomainEntity } from '../../domain/entities/marketplace-vitrine.entity';
export const MARKETPLACE_VITRINE_SERVICE = Symbol('MARKETPLACE_VITRINE_SERVICE');
/**
* Story 3.1: чтение конфигурации витрины.
*
* Запись (ensureDefault) выполняется только в bootstrap-v3 afterMigrate;
* через GraphQL пользователю недоступно создавать/переименовывать витрины
* в MVP (конструктор кастомных витрин — Phase 2).
*/
@Injectable()
export class MarketplaceVitrineService {
constructor(
@Inject(MARKETPLACE_VITRINE_REPOSITORY)
private readonly repo: MarketplaceVitrineDomainRepository
) {}
async getDefault(coopname: string): Promise<MarketplaceVitrineDomainEntity | null> {
return this.repo.findDefault(coopname);
}
async list(coopname: string): Promise<MarketplaceVitrineDomainEntity[]> {
return this.repo.list(coopname);
}
}
@@ -0,0 +1,106 @@
import { ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import {
MARKETPLACE_WHITELIST_REPOSITORY,
type MarketplaceWhitelistDomainRepository,
} from '../../domain/repositories/marketplace-whitelist.repository';
import type { MarketplaceWhitelistEntryDomainEntity } from '../../domain/entities/marketplace-whitelist-entry.entity';
export const MARKETPLACE_WHITELIST_SERVICE = Symbol('MARKETPLACE_WHITELIST_SERVICE');
interface IIsOffererCacheEntry {
result: boolean;
expires_at: number;
}
/**
* Story 3.1: управление whitelist пайщиков-поставщиков + источник
* `context.isOfferer` для `mapCoreRolesToMarketplaceRoles` (Story 1.6).
*
* Семантика:
* - `auto-coop` запись неудаляема (FR5 — перепоставка остатков самим
* коопом);
* - если whitelist содержит только `auto-coop` → «открытая витрина»,
* `isOfferer` = true для всех `User`;
* - если есть хотя бы одна `manual` запись → «по whitelist»,
* `isOfferer` = true только для записанных.
*
* `isOfferer` дёргается из `MarketplaceMembershipGuard` на каждый GraphQL-
* запрос пайщика — кешируется in-memory с TTL `IS_OFFERER_CACHE_TTL_MS`
* (по умолчанию 60 сек), чтобы не превратить guard в N запросов/секунду.
* Инвалидируется явно в `addToWhitelist` / `removeFromWhitelist`.
*/
@Injectable()
export class MarketplaceWhitelistService {
private static readonly IS_OFFERER_CACHE_TTL_MS = 60_000;
private readonly isOffererCache = new Map<string, IIsOffererCacheEntry>();
constructor(
@Inject(MARKETPLACE_WHITELIST_REPOSITORY)
private readonly repo: MarketplaceWhitelistDomainRepository
) {}
async list(coopname: string): Promise<MarketplaceWhitelistEntryDomainEntity[]> {
return this.repo.list(coopname);
}
async addToWhitelist(
coopname: string,
member_account: string,
added_by: string
): Promise<MarketplaceWhitelistEntryDomainEntity> {
const entry = await this.repo.add(coopname, member_account, 'manual', added_by);
this.invalidateCache(coopname);
return entry;
}
async removeFromWhitelist(coopname: string, member_account: string): Promise<void> {
const existing = await this.repo.findByMember(coopname, member_account);
if (!existing) {
throw new NotFoundException(
`Пайщик ${member_account} не найден в списке поставщиков.`
);
}
if (existing.role === 'auto-coop') {
throw new ForbiddenException(
'Запись о самом кооперативе нельзя удалить — она нужна для перепоставки остатков от лица кооператива.'
);
}
await this.repo.remove(coopname, member_account);
this.invalidateCache(coopname);
}
/**
* Источник `context.isOfferer` для marketplace-roles.mapper. Возвращает
* true, если пайщик может публиковать оферы в текущей конфигурации
* витрины (см. семантику в JSDoc класса).
*/
async isOfferer(coopname: string, member_account: string): Promise<boolean> {
const cacheKey = `${coopname}::${member_account}`;
const now = Date.now();
const cached = this.isOffererCache.get(cacheKey);
if (cached && cached.expires_at > now) return cached.result;
const manualCount = await this.repo.countManual(coopname);
let result: boolean;
if (manualCount === 0) {
result = true;
} else {
const entry = await this.repo.findByMember(coopname, member_account);
result = entry !== null && entry.role === 'manual';
}
this.isOffererCache.set(cacheKey, {
result,
expires_at: now + MarketplaceWhitelistService.IS_OFFERER_CACHE_TTL_MS,
});
return result;
}
private invalidateCache(coopname: string): void {
for (const key of Array.from(this.isOffererCache.keys())) {
if (key.startsWith(`${coopname}::`)) {
this.isOffererCache.delete(key);
}
}
}
}
@@ -0,0 +1,44 @@
/**
* Story 3.2 / 3.5: справочник baseline-категорий Стола заказов.
*
* MVP — фокус на продовольственных товарах (стресс-тест через скоропорт,
* см. PRD «Стола заказов» и архитектурный документ L7). 8 продовольственных
* подкатегорий + одна общая категория «Прочее». Категория «Услуги»
* выведена из MVP по требованию правовой проработки (PRD пункт 3.2.7).
* Конструктор кастомных категорий кооперативом — Phase 2.
*/
export class MarketplaceCategoryDomainEntity {
public readonly id!: number;
public readonly display_name!: string;
public readonly sort_order!: number;
public readonly mvp_baseline!: boolean;
constructor(init: {
id: number;
display_name: string;
sort_order: number;
mvp_baseline: boolean;
}) {
this.id = init.id;
this.display_name = init.display_name;
this.sort_order = init.sort_order;
this.mvp_baseline = init.mvp_baseline;
}
}
export const MARKETPLACE_FOOD_CATEGORIES: ReadonlyArray<{
id: number;
slug: string;
display_name: string;
sort_order: number;
}> = [
{ id: 1, slug: 'vegetables_fruits', display_name: 'Овощи и фрукты', sort_order: 1 },
{ id: 2, slug: 'dairy', display_name: 'Молочные продукты', sort_order: 2 },
{ id: 3, slug: 'meat_poultry', display_name: 'Мясо и птица', sort_order: 3 },
{ id: 4, slug: 'fish_seafood', display_name: 'Рыба и морепродукты', sort_order: 4 },
{ id: 5, slug: 'bakery', display_name: 'Хлеб и выпечка', sort_order: 5 },
{ id: 6, slug: 'grocery', display_name: 'Бакалея (крупы, мука, масло)', sort_order: 6 },
{ id: 7, slug: 'beverages', display_name: 'Напитки', sort_order: 7 },
{ id: 8, slug: 'ready_meals', display_name: 'Готовая еда', sort_order: 8 },
{ id: 9, slug: 'other', display_name: 'Прочее', sort_order: 9 },
];
@@ -0,0 +1,25 @@
/**
* Story 3.3: domain entity записи журнала модерации.
* Append-only, без обновления.
*/
export type MarketplaceModerationAction = 'approve' | 'reject';
export class MarketplaceModerationLogDomainEntity {
public readonly id!: string;
public readonly offer_id!: string;
public readonly action!: MarketplaceModerationAction;
public readonly by_account!: string;
public readonly reason!: string | null;
public readonly created_at!: Date;
constructor(init: {
id: string;
offer_id: string;
action: MarketplaceModerationAction;
by_account: string;
reason: string | null;
created_at: Date;
}) {
Object.assign(this, init);
}
}
@@ -0,0 +1,87 @@
import type {
MarketplaceOfferCycleType,
MarketplaceOfferStatus,
MarketplaceUnitOfMeasure,
} from './marketplace-offer.types';
/**
* Story 3.2: domain entity Offer'а.
*
* Offer — pure db-сущность Стола заказов (не реплицируется в blockchain
* 1:1). On-chain представления появляются на уровне Order'а (Эпик 4
* через `o.mkt.assign/block/unblock` из PR #375). Поля
* `quantity_blocked`/`quantity_consumed` подготовлены под Story 3.4
* counters; `approved_by`/`approved_at`/`rejected_by`/`rejected_at`/
* `reject_reason` — под Story 3.3 модерацию.
*
* Поля `cycle_days`/`target_volume`/`max_wait_days`/`min_threshold`
* заполняются опционально в зависимости от `cycle_type` — валидация на
* уровне `MarketplaceOfferService.create/update`, не на entity (immutable
* snapshot).
*/
export class MarketplaceOfferDomainEntity {
public readonly id!: string;
public readonly coopname!: string;
public readonly supplier_account!: string;
public readonly vitrine_id!: string;
public readonly product_name!: string;
public readonly description!: string | null;
public readonly category_id!: number;
public readonly price_per_unit!: string;
public readonly unit_of_measure!: MarketplaceUnitOfMeasure;
public readonly quantity_available!: number;
public readonly quantity_blocked!: number;
public readonly quantity_consumed!: number;
public readonly unlimited_flag!: boolean;
public readonly cycle_type!: MarketplaceOfferCycleType;
public readonly cycle_days!: number | null;
public readonly target_volume!: number | null;
public readonly max_wait_days!: number | null;
public readonly min_threshold!: number | null;
public readonly warranty_days!: number;
public readonly status!: MarketplaceOfferStatus;
public readonly approved_by!: string | null;
public readonly approved_at!: Date | null;
public readonly rejected_by!: string | null;
public readonly rejected_at!: Date | null;
public readonly reject_reason!: string | null;
public readonly created_at!: Date;
public readonly updated_at!: Date;
constructor(init: {
id: string;
coopname: string;
supplier_account: string;
vitrine_id: string;
product_name: string;
description: string | null;
category_id: number;
price_per_unit: string;
unit_of_measure: MarketplaceUnitOfMeasure;
quantity_available: number;
quantity_blocked: number;
quantity_consumed: number;
unlimited_flag: boolean;
cycle_type: MarketplaceOfferCycleType;
cycle_days: number | null;
target_volume: number | null;
max_wait_days: number | null;
min_threshold: number | null;
warranty_days: number;
status: MarketplaceOfferStatus;
approved_by: string | null;
approved_at: Date | null;
rejected_by: string | null;
rejected_at: Date | null;
reject_reason: string | null;
created_at: Date;
updated_at: Date;
}) {
Object.assign(this, init);
}
}
@@ -0,0 +1,42 @@
/**
* Story 3.2 / 3.3 / 3.4: типы Offer'а.
*
* Source-of-truth для статуса offer'а (Story 3.2 lifecycle + Story 3.3
* модерация), типа цикла (Story 4.7 — выбор отсечки заказов; в MVP
* Offer хранит cycle-type, Story 4.x — собственно агрегация заказов),
* единицы измерения (Story 3.2 поле формы).
*
* Категории — справочник `marketplace_category` (seed 10 baseline, Story
* 3.5 фильтр-чипы; конструктор кастомных — Out-of-MVP).
*/
export type MarketplaceOfferStatus = 'PENDING_MODERATION' | 'ACTIVE' | 'REJECTED' | 'WITHDRAWN';
export const MARKETPLACE_OFFER_STATUSES: MarketplaceOfferStatus[] = [
'PENDING_MODERATION',
'ACTIVE',
'REJECTED',
'WITHDRAWN',
];
export type MarketplaceOfferCycleType =
| 'time_based'
| 'volume_based'
| 'open_subscription'
| 'individual';
export const MARKETPLACE_OFFER_CYCLE_TYPES: MarketplaceOfferCycleType[] = [
'time_based',
'volume_based',
'open_subscription',
'individual',
];
export type MarketplaceUnitOfMeasure = 'piece' | 'kg' | 'liter' | 'pack';
export const MARKETPLACE_UNITS_OF_MEASURE: MarketplaceUnitOfMeasure[] = [
'piece',
'kg',
'liter',
'pack',
];
@@ -0,0 +1,27 @@
/**
* Story 3.1: domain entity витрины. Конфигурация без on-chain — только db.
*/
export class MarketplaceVitrineDomainEntity {
public readonly id!: string;
public readonly coopname!: string;
public readonly display_name!: string;
public readonly is_default!: boolean;
public readonly created_at!: Date;
public readonly updated_at!: Date;
constructor(init: {
id: string;
coopname: string;
display_name: string;
is_default: boolean;
created_at: Date;
updated_at: Date;
}) {
this.id = init.id;
this.coopname = init.coopname;
this.display_name = init.display_name;
this.is_default = init.is_default;
this.created_at = init.created_at;
this.updated_at = init.updated_at;
}
}
@@ -0,0 +1,29 @@
export type MarketplaceWhitelistRole = 'auto-coop' | 'manual';
/**
* Story 3.1: domain entity записи whitelist. Конфигурация без on-chain.
*/
export class MarketplaceWhitelistEntryDomainEntity {
public readonly id!: string;
public readonly coopname!: string;
public readonly member_account!: string;
public readonly role!: MarketplaceWhitelistRole;
public readonly added_by!: string | null;
public readonly added_at!: Date;
constructor(init: {
id: string;
coopname: string;
member_account: string;
role: MarketplaceWhitelistRole;
added_by: string | null;
added_at: Date;
}) {
this.id = init.id;
this.coopname = init.coopname;
this.member_account = init.member_account;
this.role = init.role;
this.added_by = init.added_by;
this.added_at = init.added_at;
}
}
@@ -0,0 +1,9 @@
import type { MarketplaceCategoryDomainEntity } from '../entities/marketplace-category.entity';
export const MARKETPLACE_CATEGORY_REPOSITORY = Symbol('MARKETPLACE_CATEGORY_REPOSITORY');
export interface MarketplaceCategoryDomainRepository {
listBaseline(): Promise<MarketplaceCategoryDomainEntity[]>;
findById(id: number): Promise<MarketplaceCategoryDomainEntity | null>;
upsertBaseline(): Promise<void>;
}
@@ -0,0 +1,16 @@
import type {
MarketplaceModerationLogDomainEntity,
MarketplaceModerationAction,
} from '../entities/marketplace-moderation-log.entity';
export const MARKETPLACE_MODERATION_LOG_REPOSITORY = Symbol('MARKETPLACE_MODERATION_LOG_REPOSITORY');
export interface MarketplaceModerationLogDomainRepository {
append(input: {
offer_id: string;
action: MarketplaceModerationAction;
by_account: string;
reason: string | null;
}): Promise<MarketplaceModerationLogDomainEntity>;
listByOffer(offer_id: string): Promise<MarketplaceModerationLogDomainEntity[]>;
}
@@ -0,0 +1,140 @@
import type {
MarketplaceOfferDomainEntity,
} from '../entities/marketplace-offer.entity';
import type { MarketplaceOfferStatus } from '../entities/marketplace-offer.types';
import type {
PaginationInputDomainInterface,
PaginationResultDomainInterface,
} from '~/domain/common/interfaces/pagination.interface';
export const MARKETPLACE_OFFER_REPOSITORY = Symbol('MARKETPLACE_OFFER_REPOSITORY');
export interface OfferListFilter {
coopname: string;
supplier_account?: string;
status?: MarketplaceOfferStatus | MarketplaceOfferStatus[];
category_id?: number;
available_only?: boolean;
}
export interface OfferCreateInput {
coopname: string;
supplier_account: string;
vitrine_id: string;
product_name: string;
description: string | null;
category_id: number;
price_per_unit: string;
unit_of_measure: 'piece' | 'kg' | 'liter' | 'pack';
quantity_available: number;
unlimited_flag: boolean;
cycle_type: 'time_based' | 'volume_based' | 'open_subscription' | 'individual';
cycle_days: number | null;
target_volume: number | null;
max_wait_days: number | null;
min_threshold: number | null;
warranty_days: number;
}
export interface OfferUpdateInput {
product_name?: string;
description?: string | null;
category_id?: number;
price_per_unit?: string;
unit_of_measure?: 'piece' | 'kg' | 'liter' | 'pack';
quantity_available?: number;
unlimited_flag?: boolean;
cycle_type?: 'time_based' | 'volume_based' | 'open_subscription' | 'individual';
cycle_days?: number | null;
target_volume?: number | null;
max_wait_days?: number | null;
min_threshold?: number | null;
warranty_days?: number;
}
/**
* Story 3.4 — атомарные дельты counters Offer'а.
*
* Каждый из методов выполняется одним SQL UPDATE с returning, чтобы:
* (а) избежать race condition между read-modify-write при параллельных
* Order-блокировках одного Offer'а;
* (б) проверить инварианты в WHERE и вернуть 0 affected rows если
* операция нарушила бы инвариант (caller получит OfferCountersError).
*
* При `unlimited_flag=true` `quantity_available` не изменяется (offer не
* ограничен по количеству) — только `quantity_blocked` инкрементируется
* при block / decrement при unblock|consume.
*/
export type OfferCountersErrorReason =
| 'insufficient_available'
| 'insufficient_blocked'
| 'offer_not_active'
| 'offer_not_found';
export interface OfferCountersDeltaResult {
ok: boolean;
reason?: OfferCountersErrorReason;
offer?: MarketplaceOfferDomainEntity;
}
export interface MarketplaceOfferDomainRepository {
findById(id: string): Promise<MarketplaceOfferDomainEntity | null>;
list(
filter: OfferListFilter,
pagination: PaginationInputDomainInterface
): Promise<PaginationResultDomainInterface<MarketplaceOfferDomainEntity>>;
countByCategory(coopname: string): Promise<Map<number, number>>;
countRecentCreatedBy(supplier_account: string, sinceMs: number): Promise<number>;
create(input: OfferCreateInput): Promise<MarketplaceOfferDomainEntity>;
applyUpdate(
id: string,
patch: OfferUpdateInput & {
status?: MarketplaceOfferStatus;
approved_by?: string | null;
approved_at?: Date | null;
rejected_by?: string | null;
rejected_at?: Date | null;
reject_reason?: string | null;
}
): Promise<MarketplaceOfferDomainEntity>;
/**
* Order создан → блокировать K единиц. Атомарно:
* - quantity_blocked += K;
* - quantity_available -= K (если не unlimited);
* - требование: status='ACTIVE' AND (unlimited OR available >= K).
*/
applyBlockDelta(offer_id: string, qty: number): Promise<OfferCountersDeltaResult>;
/**
* Order отменён / цикл expire / поставщик отказался → возврат
* K единиц в available. Атомарно:
* - quantity_blocked -= K;
* - quantity_available += K (если не unlimited);
* - требование: blocked >= K.
*/
applyUnblockDelta(offer_id: string, qty: number): Promise<OfferCountersDeltaResult>;
/**
* Выдача пайщику (consum/consum2) → K единиц перемещаются
* blocked → consumed. Атомарно:
* - quantity_blocked -= K;
* - quantity_consumed += K;
* - требование: blocked >= K.
*/
applyConsumeDelta(offer_id: string, qty: number): Promise<OfferCountersDeltaResult>;
/**
* Fork rollback (ADR-005): Order был в block-состоянии и откатывается
* `restoreFromVersions`. Counter в Offer'е возвращается **без
* CAS-проверки** `blocked>=qty` — rollback может приходить когда
* сама блокировка уже была списана (Order успел уйти в consumed),
* и тогда мы выйдем в отрицательные значения (ожидаемо при
* катастрофе fork-вне-Rollback-Horizon из ADR-005; fix через manual
* reconciliation, FR12 ARCH-sync).
*
* Дёргается из ForkRegistry handler'а `MarketplaceOrderSyncService`.
* См. spec-3-4-bc-integration.md секция 3.1.
*/
applyRollbackDelta(offer_id: string, qty: number): Promise<OfferCountersDeltaResult>;
}
@@ -0,0 +1,12 @@
import type { MarketplaceVitrineDomainEntity } from '../entities/marketplace-vitrine.entity';
export const MARKETPLACE_VITRINE_REPOSITORY = Symbol('MARKETPLACE_VITRINE_REPOSITORY');
export interface MarketplaceVitrineDomainRepository {
findDefault(coopname: string): Promise<MarketplaceVitrineDomainEntity | null>;
list(coopname: string): Promise<MarketplaceVitrineDomainEntity[]>;
ensureDefault(
coopname: string,
display_name: string
): Promise<MarketplaceVitrineDomainEntity>;
}
@@ -0,0 +1,22 @@
import type {
MarketplaceWhitelistEntryDomainEntity,
MarketplaceWhitelistRole,
} from '../entities/marketplace-whitelist-entry.entity';
export const MARKETPLACE_WHITELIST_REPOSITORY = Symbol('MARKETPLACE_WHITELIST_REPOSITORY');
export interface MarketplaceWhitelistDomainRepository {
list(coopname: string): Promise<MarketplaceWhitelistEntryDomainEntity[]>;
findByMember(
coopname: string,
member_account: string
): Promise<MarketplaceWhitelistEntryDomainEntity | null>;
add(
coopname: string,
member_account: string,
role: MarketplaceWhitelistRole,
added_by: string | null
): Promise<MarketplaceWhitelistEntryDomainEntity>;
remove(coopname: string, member_account: string): Promise<void>;
countManual(coopname: string): Promise<number>;
}
@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import type { MarketplaceCategoryDomainRepository } from '../../domain/repositories/marketplace-category.repository';
import {
MARKETPLACE_FOOD_CATEGORIES,
MarketplaceCategoryDomainEntity,
} from '../../domain/entities/marketplace-category.entity';
import { MarketplaceCategoryEntity } from '../entities/marketplace-category.entity';
import { MarketplaceCategoryMapper } from '../mappers/marketplace-category.mapper';
@Injectable()
export class MarketplaceCategoryRepositoryAdapter
implements MarketplaceCategoryDomainRepository
{
constructor(
@InjectRepository(MarketplaceCategoryEntity, 'marketplace')
private readonly repo: Repository<MarketplaceCategoryEntity>,
private readonly mapper: MarketplaceCategoryMapper
) {}
async listBaseline(): Promise<MarketplaceCategoryDomainEntity[]> {
const rows = await this.repo.find({
where: { mvp_baseline: true },
order: { sort_order: 'ASC' },
});
return rows.map((r) => this.mapper.toDomain(r));
}
async findById(id: number): Promise<MarketplaceCategoryDomainEntity | null> {
const row = await this.repo.findOne({ where: { id } });
return row ? this.mapper.toDomain(row) : null;
}
async upsertBaseline(): Promise<void> {
for (const c of MARKETPLACE_FOOD_CATEGORIES) {
await this.repo.upsert(
{ id: c.id, display_name: c.display_name, sort_order: c.sort_order, mvp_baseline: true },
['id']
);
}
}
}
@@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import type { MarketplaceModerationLogDomainRepository } from '../../domain/repositories/marketplace-moderation-log.repository';
import type {
MarketplaceModerationLogDomainEntity,
MarketplaceModerationAction,
} from '../../domain/entities/marketplace-moderation-log.entity';
import { MarketplaceModerationLogEntity } from '../entities/marketplace-moderation-log.entity';
import { MarketplaceModerationLogMapper } from '../mappers/marketplace-moderation-log.mapper';
@Injectable()
export class MarketplaceModerationLogRepositoryAdapter
implements MarketplaceModerationLogDomainRepository
{
constructor(
@InjectRepository(MarketplaceModerationLogEntity, 'marketplace')
private readonly repo: Repository<MarketplaceModerationLogEntity>,
private readonly mapper: MarketplaceModerationLogMapper
) {}
async append(input: {
offer_id: string;
action: MarketplaceModerationAction;
by_account: string;
reason: string | null;
}): Promise<MarketplaceModerationLogDomainEntity> {
const row = this.repo.create({
offer_id: input.offer_id,
action: input.action,
by_account: input.by_account,
reason: input.reason,
});
const saved = await this.repo.save(row);
return this.mapper.toDomain(saved);
}
async listByOffer(offer_id: string): Promise<MarketplaceModerationLogDomainEntity[]> {
const rows = await this.repo.find({
where: { offer_id },
order: { created_at: 'ASC' },
});
return rows.map((r) => this.mapper.toDomain(r));
}
}
@@ -0,0 +1,255 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, MoreThanOrEqual, Repository } from 'typeorm';
import type {
MarketplaceOfferDomainRepository,
OfferCountersDeltaResult,
OfferCreateInput,
OfferListFilter,
OfferUpdateInput,
} from '../../domain/repositories/marketplace-offer.repository';
import type { MarketplaceOfferDomainEntity } from '../../domain/entities/marketplace-offer.entity';
import type { MarketplaceOfferStatus } from '../../domain/entities/marketplace-offer.types';
import type {
PaginationInputDomainInterface,
PaginationResultDomainInterface,
} from '~/domain/common/interfaces/pagination.interface';
import { MarketplaceOfferEntity } from '../entities/marketplace-offer.entity';
import { MarketplaceOfferMapper } from '../mappers/marketplace-offer.mapper';
@Injectable()
export class MarketplaceOfferRepositoryAdapter implements MarketplaceOfferDomainRepository {
constructor(
@InjectRepository(MarketplaceOfferEntity, 'marketplace')
private readonly repo: Repository<MarketplaceOfferEntity>,
private readonly mapper: MarketplaceOfferMapper
) {}
async findById(id: string): Promise<MarketplaceOfferDomainEntity | null> {
const row = await this.repo.findOne({ where: { id } });
return row ? this.mapper.toDomain(row) : null;
}
async list(
filter: OfferListFilter,
pagination: PaginationInputDomainInterface
): Promise<PaginationResultDomainInterface<MarketplaceOfferDomainEntity>> {
const qb = this.repo
.createQueryBuilder('o')
.where('o.coopname = :coop', { coop: filter.coopname });
if (filter.supplier_account) {
qb.andWhere('o.supplier_account = :supplier', { supplier: filter.supplier_account });
}
if (filter.status) {
const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
qb.andWhere('o.status IN (:...statuses)', { statuses });
}
if (filter.category_id !== undefined) {
qb.andWhere('o.category_id = :cat', { cat: filter.category_id });
}
if (filter.available_only) {
qb.andWhere('(o.unlimited_flag = true OR o.quantity_available > 0)');
}
const sortColumn = MarketplaceOfferRepositoryAdapter.resolveSortColumn(pagination.sortBy);
qb.orderBy(sortColumn, pagination.sortOrder);
if (sortColumn !== 'o.created_at') {
qb.addOrderBy('o.created_at', 'DESC');
}
const { page, limit } = pagination;
qb.skip((page - 1) * limit).take(limit);
const [rows, totalCount] = await qb.getManyAndCount();
return {
items: rows.map((r) => this.mapper.toDomain(r)),
totalCount,
totalPages: Math.ceil(totalCount / limit),
currentPage: page,
};
}
private static resolveSortColumn(sortBy: string | undefined): string {
switch (sortBy) {
case 'price_per_unit':
case 'price':
return 'o.price_per_unit';
case 'product_name':
return 'o.product_name';
case 'created_at':
default:
return 'o.created_at';
}
}
async countByCategory(coopname: string): Promise<Map<number, number>> {
const rows = await this.repo
.createQueryBuilder('o')
.select('o.category_id', 'category_id')
.addSelect('COUNT(*)', 'cnt')
.where('o.coopname = :coop', { coop: coopname })
.andWhere('o.status = :s', { s: 'ACTIVE' as MarketplaceOfferStatus })
.andWhere('(o.unlimited_flag = true OR o.quantity_available > 0)')
.groupBy('o.category_id')
.getRawMany<{ category_id: string; cnt: string }>();
const result = new Map<number, number>();
for (const r of rows) {
result.set(Number(r.category_id), Number(r.cnt));
}
return result;
}
async countRecentCreatedBy(supplier_account: string, sinceMs: number): Promise<number> {
const since = new Date(Date.now() - sinceMs);
return this.repo.count({
where: { supplier_account, created_at: MoreThanOrEqual(since) },
});
}
async create(input: OfferCreateInput): Promise<MarketplaceOfferDomainEntity> {
const row = this.repo.create({
coopname: input.coopname,
supplier_account: input.supplier_account,
vitrine_id: input.vitrine_id,
product_name: input.product_name,
description: input.description,
category_id: input.category_id,
price_per_unit: input.price_per_unit,
unit_of_measure: input.unit_of_measure,
quantity_available: input.unlimited_flag ? 0 : input.quantity_available,
quantity_blocked: 0,
quantity_consumed: 0,
unlimited_flag: input.unlimited_flag,
cycle_type: input.cycle_type,
cycle_days: input.cycle_days,
target_volume: input.target_volume,
max_wait_days: input.max_wait_days,
min_threshold: input.min_threshold,
warranty_days: input.warranty_days,
status: 'PENDING_MODERATION',
});
const saved = await this.repo.save(row);
return this.mapper.toDomain(saved);
}
async applyUpdate(
id: string,
patch: OfferUpdateInput & {
status?: MarketplaceOfferStatus;
approved_by?: string | null;
approved_at?: Date | null;
rejected_by?: string | null;
rejected_at?: Date | null;
reject_reason?: string | null;
}
): Promise<MarketplaceOfferDomainEntity> {
await this.repo.update({ id }, patch as Record<string, unknown>);
const row = await this.repo.findOneOrFail({ where: { id } });
return this.mapper.toDomain(row);
}
async applyBlockDelta(offer_id: string, qty: number): Promise<OfferCountersDeltaResult> {
if (qty <= 0) return { ok: false, reason: 'insufficient_available' };
const result = await this.repo.query(
`UPDATE marketplace_offer
SET quantity_blocked = quantity_blocked + $2,
quantity_available = CASE
WHEN unlimited_flag THEN quantity_available
ELSE quantity_available - $2
END,
updated_at = NOW()
WHERE id = $1
AND status = 'ACTIVE'
AND (unlimited_flag = true OR quantity_available >= $2)
RETURNING *`,
[offer_id, qty]
);
return this.interpretDelta(result, offer_id, 'insufficient_available');
}
async applyUnblockDelta(offer_id: string, qty: number): Promise<OfferCountersDeltaResult> {
if (qty <= 0) return { ok: false, reason: 'insufficient_blocked' };
const result = await this.repo.query(
`UPDATE marketplace_offer
SET quantity_blocked = quantity_blocked - $2,
quantity_available = CASE
WHEN unlimited_flag THEN quantity_available
ELSE quantity_available + $2
END,
updated_at = NOW()
WHERE id = $1
AND quantity_blocked >= $2
RETURNING *`,
[offer_id, qty]
);
return this.interpretDelta(result, offer_id, 'insufficient_blocked');
}
async applyConsumeDelta(offer_id: string, qty: number): Promise<OfferCountersDeltaResult> {
if (qty <= 0) return { ok: false, reason: 'insufficient_blocked' };
const result = await this.repo.query(
`UPDATE marketplace_offer
SET quantity_blocked = quantity_blocked - $2,
quantity_consumed = quantity_consumed + $2,
updated_at = NOW()
WHERE id = $1
AND quantity_blocked >= $2
RETURNING *`,
[offer_id, qty]
);
return this.interpretDelta(result, offer_id, 'insufficient_blocked');
}
async applyRollbackDelta(offer_id: string, qty: number): Promise<OfferCountersDeltaResult> {
if (qty <= 0) return { ok: false, reason: 'insufficient_blocked' };
// ADR-005: rollback без CAS — counter может уйти в отрицательное
// значение при rollback Order'а, который уже перешёл в consumed.
// Это ожидаемо при катастрофе fork-вне-Rollback-Horizon; fix через
// manual reconciliation (FR12 ARCH-sync).
const result = await this.repo.query(
`UPDATE marketplace_offer
SET quantity_blocked = quantity_blocked - $2,
quantity_available = CASE
WHEN unlimited_flag THEN quantity_available
ELSE quantity_available + $2
END,
updated_at = NOW()
WHERE id = $1
RETURNING *`,
[offer_id, qty]
);
return this.interpretDelta(result, offer_id, 'insufficient_blocked');
}
/**
* pg native driver через TypeORM возвращает результат `query` для UPDATE
* RETURNING как `[rows, count]` массив — нормализуем.
*/
private async interpretDelta(
result: unknown,
offer_id: string,
failureReason: 'insufficient_available' | 'insufficient_blocked'
): Promise<OfferCountersDeltaResult> {
const rows = Array.isArray(result) ? (result[0] ?? result) : [];
const updatedRow = Array.isArray(rows) && rows.length > 0 ? rows[0] : null;
if (!updatedRow) {
const existing = await this.repo.findOne({ where: { id: offer_id } });
if (!existing) return { ok: false, reason: 'offer_not_found' };
if (failureReason === 'insufficient_available' && existing.status !== 'ACTIVE') {
return { ok: false, reason: 'offer_not_active' };
}
return { ok: false, reason: failureReason };
}
// pg возвращает column-by-column как plain object; нормализуем через
// повторный findOne для прохода mapper (timestamp coercion, типизация).
const offer = await this.repo.findOneOrFail({ where: { id: offer_id } });
return { ok: true, offer: this.mapper.toDomain(offer) };
}
}
@@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import type { MarketplaceVitrineDomainRepository } from '../../domain/repositories/marketplace-vitrine.repository';
import { MarketplaceVitrineDomainEntity } from '../../domain/entities/marketplace-vitrine.entity';
import { MarketplaceVitrineEntity } from '../entities/marketplace-vitrine.entity';
import { MarketplaceVitrineMapper } from '../mappers/marketplace-vitrine.mapper';
@Injectable()
export class MarketplaceVitrineRepositoryAdapter implements MarketplaceVitrineDomainRepository {
constructor(
@InjectRepository(MarketplaceVitrineEntity, 'marketplace')
private readonly repo: Repository<MarketplaceVitrineEntity>,
private readonly mapper: MarketplaceVitrineMapper
) {}
async findDefault(coopname: string): Promise<MarketplaceVitrineDomainEntity | null> {
const row = await this.repo.findOne({ where: { coopname, is_default: true } });
return row ? this.mapper.toDomain(row) : null;
}
async list(coopname: string): Promise<MarketplaceVitrineDomainEntity[]> {
const rows = await this.repo.find({
where: { coopname },
order: { created_at: 'ASC' },
});
return rows.map((r) => this.mapper.toDomain(r));
}
async ensureDefault(
coopname: string,
display_name: string
): Promise<MarketplaceVitrineDomainEntity> {
const existing = await this.repo.findOne({ where: { coopname, is_default: true } });
if (existing) return this.mapper.toDomain(existing);
const row = this.repo.create({
id: 'default',
coopname,
display_name,
is_default: true,
});
const saved = await this.repo.save(row);
return this.mapper.toDomain(saved);
}
}
@@ -0,0 +1,62 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import type { MarketplaceWhitelistDomainRepository } from '../../domain/repositories/marketplace-whitelist.repository';
import {
MarketplaceWhitelistEntryDomainEntity,
type MarketplaceWhitelistRole,
} from '../../domain/entities/marketplace-whitelist-entry.entity';
import { MarketplaceWhitelistEntity } from '../entities/marketplace-whitelist.entity';
import { MarketplaceWhitelistMapper } from '../mappers/marketplace-whitelist.mapper';
@Injectable()
export class MarketplaceWhitelistRepositoryAdapter implements MarketplaceWhitelistDomainRepository {
constructor(
@InjectRepository(MarketplaceWhitelistEntity, 'marketplace')
private readonly repo: Repository<MarketplaceWhitelistEntity>,
private readonly mapper: MarketplaceWhitelistMapper
) {}
async list(coopname: string): Promise<MarketplaceWhitelistEntryDomainEntity[]> {
const rows = await this.repo.find({
where: { coopname },
order: { added_at: 'ASC' },
});
return rows.map((r) => this.mapper.toDomain(r));
}
async findByMember(
coopname: string,
member_account: string
): Promise<MarketplaceWhitelistEntryDomainEntity | null> {
const row = await this.repo.findOne({ where: { coopname, member_account } });
return row ? this.mapper.toDomain(row) : null;
}
async add(
coopname: string,
member_account: string,
role: MarketplaceWhitelistRole,
added_by: string | null
): Promise<MarketplaceWhitelistEntryDomainEntity> {
const existing = await this.repo.findOne({ where: { coopname, member_account } });
if (existing) return this.mapper.toDomain(existing);
const row = this.repo.create({
coopname,
member_account,
role,
added_by,
});
const saved = await this.repo.save(row);
return this.mapper.toDomain(saved);
}
async remove(coopname: string, member_account: string): Promise<void> {
await this.repo.delete({ coopname, member_account });
}
async countManual(coopname: string): Promise<number> {
return this.repo.count({ where: { coopname, role: 'manual' } });
}
}
@@ -0,0 +1,21 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
/**
* Story 3.2/3.5: справочник 10 baseline-категорий Стола заказов.
* Сидируется bootstrap-v4 миграцией (`marketplace-bootstrap-v4`).
*/
@Entity({ name: 'marketplace_category' })
@Index(['sort_order'])
export class MarketplaceCategoryEntity {
@PrimaryColumn({ type: 'integer' })
public id!: number;
@Column({ type: 'varchar', length: 200 })
public display_name!: string;
@Column({ type: 'integer' })
public sort_order!: number;
@Column({ type: 'boolean', default: true })
public mvp_baseline!: boolean;
}
@@ -0,0 +1,27 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
/**
* Story 3.3: журнал решений модерации Offer'ов. Append-only.
*/
@Entity({ name: 'marketplace_moderation_log' })
@Index(['offer_id', 'created_at'])
@Index(['by_account'])
export class MarketplaceModerationLogEntity {
@PrimaryGeneratedColumn('uuid')
public id!: string;
@Column({ type: 'uuid' })
public offer_id!: string;
@Column({ type: 'varchar', length: 16 })
public action!: 'approve' | 'reject';
@Column({ type: 'varchar', length: 13 })
public by_account!: string;
@Column({ type: 'varchar', length: 1000, nullable: true })
public reason!: string | null;
@CreateDateColumn({ type: 'timestamptz' })
public created_at!: Date;
}
@@ -0,0 +1,103 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* Story 3.2: Offer Стола заказов. Pure db (не on-chain).
* Поля под Story 3.3/3.4 проставлены сразу — миграция расширения едина.
*
* Hot-path индексы для каталога (Story 3.5): `(coopname, status,
* category_id)` — фильтр-чипы; `(supplier_account, status)` — «мои оферы».
*/
@Entity({ name: 'marketplace_offer' })
@Index(['coopname', 'status', 'category_id'])
@Index(['supplier_account', 'status'])
@Index(['status', 'created_at'])
export class MarketplaceOfferEntity {
@PrimaryGeneratedColumn('uuid')
public id!: string;
@Column({ type: 'varchar', length: 13 })
public coopname!: string;
@Column({ type: 'varchar', length: 13 })
public supplier_account!: string;
@Column({ type: 'varchar', length: 64 })
public vitrine_id!: string;
@Column({ type: 'varchar', length: 200 })
public product_name!: string;
@Column({ type: 'varchar', length: 2000, nullable: true })
public description!: string | null;
@Column({ type: 'integer' })
public category_id!: number;
// numeric → string в TypeORM (precision deliberately, не плодим float)
@Column({ type: 'numeric', precision: 18, scale: 4 })
public price_per_unit!: string;
@Column({ type: 'varchar', length: 16 })
public unit_of_measure!: 'piece' | 'kg' | 'liter' | 'pack';
@Column({ type: 'integer', default: 0 })
public quantity_available!: number;
@Column({ type: 'integer', default: 0 })
public quantity_blocked!: number;
@Column({ type: 'integer', default: 0 })
public quantity_consumed!: number;
@Column({ type: 'boolean', default: false })
public unlimited_flag!: boolean;
@Column({ type: 'varchar', length: 32 })
public cycle_type!: 'time_based' | 'volume_based' | 'open_subscription' | 'individual';
@Column({ type: 'integer', nullable: true })
public cycle_days!: number | null;
@Column({ type: 'integer', nullable: true })
public target_volume!: number | null;
@Column({ type: 'integer', nullable: true })
public max_wait_days!: number | null;
@Column({ type: 'integer', nullable: true })
public min_threshold!: number | null;
@Column({ type: 'integer', default: 0 })
public warranty_days!: number;
@Column({ type: 'varchar', length: 32 })
public status!: 'PENDING_MODERATION' | 'ACTIVE' | 'REJECTED' | 'WITHDRAWN';
@Column({ type: 'varchar', length: 13, nullable: true })
public approved_by!: string | null;
@Column({ type: 'timestamptz', nullable: true })
public approved_at!: Date | null;
@Column({ type: 'varchar', length: 13, nullable: true })
public rejected_by!: string | null;
@Column({ type: 'timestamptz', nullable: true })
public rejected_at!: Date | null;
@Column({ type: 'varchar', length: 1000, nullable: true })
public reject_reason!: string | null;
@CreateDateColumn({ type: 'timestamptz' })
public created_at!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
public updated_at!: Date;
}
@@ -0,0 +1,31 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn, UpdateDateColumn } from 'typeorm';
/**
* Story 3.1: витрина Стола заказов.
*
* В MVP — одна дефолтная запись `{id:'default', is_default:true}` на
* coopname; конструктор кастомных витрин Out-of-MVP. Записи живут
* как конфигурация (нет on-chain представления), `synchronize:true` в
* `MarketplaceInfrastructureModule` создаёт таблицу.
*/
@Entity({ name: 'marketplace_vitrine' })
@Index(['coopname', 'is_default'])
export class MarketplaceVitrineEntity {
@PrimaryColumn({ type: 'varchar', length: 64 })
public id!: string;
@Column({ type: 'varchar', length: 13 })
public coopname!: string;
@Column({ type: 'varchar', length: 200 })
public display_name!: string;
@Column({ type: 'boolean', default: false })
public is_default!: boolean;
@CreateDateColumn({ type: 'timestamptz' })
public created_at!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
public updated_at!: Date;
}
@@ -0,0 +1,40 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, Unique } from 'typeorm';
/**
* Story 3.1: whitelist пайщиков-поставщиков.
*
* Записи:
* `role='auto-coop'` — сам кооператив (неудаляемая, ставится bootstrap-v3
* afterMigrate, нужна для FR5 — перепоставка остатков самим коопом);
* `role='manual'` — пайщик, добавленный админом mutation'ом
* `marketplaceAddToWhitelist`.
*
* Семантика whitelist (Story 3.1 + Story 1.6):
* - если в whitelist есть хотя бы одна `manual`-запись → витрина «по
* whitelist», marketplace-role `offerer` выдаётся только пайщикам из
* whitelist;
* - если whitelist содержит только `auto-coop` → витрина «открытая»,
* `offerer` выдаётся всем `User`.
*/
@Entity({ name: 'marketplace_whitelist' })
@Unique('uq_marketplace_whitelist_member', ['coopname', 'member_account'])
@Index(['coopname'])
export class MarketplaceWhitelistEntity {
@PrimaryGeneratedColumn('uuid')
public id!: string;
@Column({ type: 'varchar', length: 13 })
public coopname!: string;
@Column({ type: 'varchar', length: 13 })
public member_account!: string;
@Column({ type: 'varchar', length: 32 })
public role!: 'auto-coop' | 'manual';
@Column({ type: 'varchar', length: 13, nullable: true })
public added_by!: string | null;
@CreateDateColumn({ type: 'timestamptz' })
public added_at!: Date;
}
@@ -0,0 +1,15 @@
import { Injectable } from '@nestjs/common';
import { MarketplaceCategoryDomainEntity } from '../../domain/entities/marketplace-category.entity';
import { MarketplaceCategoryEntity } from '../entities/marketplace-category.entity';
@Injectable()
export class MarketplaceCategoryMapper {
toDomain(row: MarketplaceCategoryEntity): MarketplaceCategoryDomainEntity {
return new MarketplaceCategoryDomainEntity({
id: row.id,
display_name: row.display_name,
sort_order: row.sort_order,
mvp_baseline: row.mvp_baseline,
});
}
}
@@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common';
import { MarketplaceModerationLogDomainEntity } from '../../domain/entities/marketplace-moderation-log.entity';
import { MarketplaceModerationLogEntity } from '../entities/marketplace-moderation-log.entity';
@Injectable()
export class MarketplaceModerationLogMapper {
toDomain(row: MarketplaceModerationLogEntity): MarketplaceModerationLogDomainEntity {
return new MarketplaceModerationLogDomainEntity({
id: row.id,
offer_id: row.offer_id,
action: row.action,
by_account: row.by_account,
reason: row.reason,
created_at: row.created_at,
});
}
}
@@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { MarketplaceOfferDomainEntity } from '../../domain/entities/marketplace-offer.entity';
import { MarketplaceOfferEntity } from '../entities/marketplace-offer.entity';
@Injectable()
export class MarketplaceOfferMapper {
toDomain(row: MarketplaceOfferEntity): MarketplaceOfferDomainEntity {
return new MarketplaceOfferDomainEntity({
id: row.id,
coopname: row.coopname,
supplier_account: row.supplier_account,
vitrine_id: row.vitrine_id,
product_name: row.product_name,
description: row.description,
category_id: row.category_id,
price_per_unit: row.price_per_unit,
unit_of_measure: row.unit_of_measure,
quantity_available: row.quantity_available,
quantity_blocked: row.quantity_blocked,
quantity_consumed: row.quantity_consumed,
unlimited_flag: row.unlimited_flag,
cycle_type: row.cycle_type,
cycle_days: row.cycle_days,
target_volume: row.target_volume,
max_wait_days: row.max_wait_days,
min_threshold: row.min_threshold,
warranty_days: row.warranty_days,
status: row.status,
approved_by: row.approved_by,
approved_at: row.approved_at,
rejected_by: row.rejected_by,
rejected_at: row.rejected_at,
reject_reason: row.reject_reason,
created_at: row.created_at,
updated_at: row.updated_at,
});
}
}
@@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common';
import { MarketplaceVitrineDomainEntity } from '../../domain/entities/marketplace-vitrine.entity';
import { MarketplaceVitrineEntity } from '../entities/marketplace-vitrine.entity';
@Injectable()
export class MarketplaceVitrineMapper {
toDomain(row: MarketplaceVitrineEntity): MarketplaceVitrineDomainEntity {
return new MarketplaceVitrineDomainEntity({
id: row.id,
coopname: row.coopname,
display_name: row.display_name,
is_default: row.is_default,
created_at: row.created_at,
updated_at: row.updated_at,
});
}
}
@@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common';
import { MarketplaceWhitelistEntryDomainEntity } from '../../domain/entities/marketplace-whitelist-entry.entity';
import { MarketplaceWhitelistEntity } from '../entities/marketplace-whitelist.entity';
@Injectable()
export class MarketplaceWhitelistMapper {
toDomain(row: MarketplaceWhitelistEntity): MarketplaceWhitelistEntryDomainEntity {
return new MarketplaceWhitelistEntryDomainEntity({
id: row.id,
coopname: row.coopname,
member_account: row.member_account,
role: row.role,
added_by: row.added_by,
added_at: row.added_at,
});
}
}
@@ -14,6 +14,11 @@ import { RequestEntity } from './entities/request.entity';
import { RequestAttributeValueEntity } from './entities/request-attribute-value.entity';
import { RequestImageEntity } from './entities/request-image.entity';
import { KuDetailsTypeormEntity } from './entities/ku-details.entity';
import { MarketplaceVitrineEntity } from './entities/marketplace-vitrine.entity';
import { MarketplaceWhitelistEntity } from './entities/marketplace-whitelist.entity';
import { MarketplaceCategoryEntity } from './entities/marketplace-category.entity';
import { MarketplaceOfferEntity } from './entities/marketplace-offer.entity';
import { MarketplaceModerationLogEntity } from './entities/marketplace-moderation-log.entity';
// Repository adapters
import { CategoryRepositoryAdapter } from './adapters/category-repository.adapter';
@@ -25,6 +30,18 @@ import { AvailableCategoryRepositoryAdapter } from './adapters/available-categor
import { RequestRepositoryAdapter } from './adapters/request-repository.adapter';
import { KuDetailsRepositoryAdapter } from './adapters/ku-details-repository.adapter';
import { geocoderPortFactory } from './adapters/geocoder.factory';
import { MarketplaceVitrineRepositoryAdapter } from './adapters/marketplace-vitrine-repository.adapter';
import { MarketplaceWhitelistRepositoryAdapter } from './adapters/marketplace-whitelist-repository.adapter';
import { MarketplaceCategoryRepositoryAdapter } from './adapters/marketplace-category-repository.adapter';
import { MarketplaceOfferRepositoryAdapter } from './adapters/marketplace-offer-repository.adapter';
import { MarketplaceModerationLogRepositoryAdapter } from './adapters/marketplace-moderation-log-repository.adapter';
// Mappers
import { MarketplaceVitrineMapper } from './mappers/marketplace-vitrine.mapper';
import { MarketplaceWhitelistMapper } from './mappers/marketplace-whitelist.mapper';
import { MarketplaceCategoryMapper } from './mappers/marketplace-category.mapper';
import { MarketplaceOfferMapper } from './mappers/marketplace-offer.mapper';
import { MarketplaceModerationLogMapper } from './mappers/marketplace-moderation-log.mapper';
// Repository tokens
import { CATEGORY_DOMAIN_REPOSITORY } from '../domain/repositories/category-domain.repository';
@@ -36,6 +53,11 @@ import { AVAILABLE_CATEGORY_DOMAIN_REPOSITORY } from '../domain/repositories/ava
import { REQUEST_DOMAIN_REPOSITORY } from '../domain/repositories/request-domain.repository';
import { KU_DETAILS_DOMAIN_REPOSITORY } from '../domain/repositories/ku-details-domain.repository';
import { GEOCODER_PORT } from '../domain/ports/geocoder.port';
import { MARKETPLACE_VITRINE_REPOSITORY } from '../domain/repositories/marketplace-vitrine.repository';
import { MARKETPLACE_WHITELIST_REPOSITORY } from '../domain/repositories/marketplace-whitelist.repository';
import { MARKETPLACE_CATEGORY_REPOSITORY } from '../domain/repositories/marketplace-category.repository';
import { MARKETPLACE_OFFER_REPOSITORY } from '../domain/repositories/marketplace-offer.repository';
import { MARKETPLACE_MODERATION_LOG_REPOSITORY } from '../domain/repositories/marketplace-moderation-log.repository';
@Module({
imports: [
@@ -60,6 +82,11 @@ import { GEOCODER_PORT } from '../domain/ports/geocoder.port';
RequestAttributeValueEntity,
RequestImageEntity,
KuDetailsTypeormEntity,
MarketplaceVitrineEntity,
MarketplaceWhitelistEntity,
MarketplaceCategoryEntity,
MarketplaceOfferEntity,
MarketplaceModerationLogEntity,
],
synchronize: true,
logging: false,
@@ -78,6 +105,11 @@ import { GEOCODER_PORT } from '../domain/ports/geocoder.port';
RequestAttributeValueEntity,
RequestImageEntity,
KuDetailsTypeormEntity,
MarketplaceVitrineEntity,
MarketplaceWhitelistEntity,
MarketplaceCategoryEntity,
MarketplaceOfferEntity,
MarketplaceModerationLogEntity,
],
'marketplace'
), // Указываем имя подключения
@@ -119,6 +151,34 @@ import { GEOCODER_PORT } from '../domain/ports/geocoder.port';
provide: GEOCODER_PORT,
useFactory: geocoderPortFactory,
},
// Story 3.1
MarketplaceVitrineMapper,
MarketplaceWhitelistMapper,
{
provide: MARKETPLACE_VITRINE_REPOSITORY,
useClass: MarketplaceVitrineRepositoryAdapter,
},
{
provide: MARKETPLACE_WHITELIST_REPOSITORY,
useClass: MarketplaceWhitelistRepositoryAdapter,
},
// Story 3.2
MarketplaceCategoryMapper,
MarketplaceOfferMapper,
{
provide: MARKETPLACE_CATEGORY_REPOSITORY,
useClass: MarketplaceCategoryRepositoryAdapter,
},
{
provide: MARKETPLACE_OFFER_REPOSITORY,
useClass: MarketplaceOfferRepositoryAdapter,
},
// Story 3.3
MarketplaceModerationLogMapper,
{
provide: MARKETPLACE_MODERATION_LOG_REPOSITORY,
useClass: MarketplaceModerationLogRepositoryAdapter,
},
],
exports: [
CATEGORY_DOMAIN_REPOSITORY,
@@ -130,6 +190,11 @@ import { GEOCODER_PORT } from '../domain/ports/geocoder.port';
REQUEST_DOMAIN_REPOSITORY,
KU_DETAILS_DOMAIN_REPOSITORY,
GEOCODER_PORT,
MARKETPLACE_VITRINE_REPOSITORY,
MARKETPLACE_WHITELIST_REPOSITORY,
MARKETPLACE_CATEGORY_REPOSITORY,
MARKETPLACE_OFFER_REPOSITORY,
MARKETPLACE_MODERATION_LOG_REPOSITORY,
],
})
export class MarketplaceInfrastructureModule {}
@@ -0,0 +1,73 @@
import type {
IExtensionSchemaMigration,
ExtensionSchemaMigrationAfterContext,
} from '~/domain/extension/services/extension-schema-migration.service';
import { defaultConfig, IConfig } from '../types';
import config from '~/config/config';
import {
MARKETPLACE_VITRINE_REPOSITORY,
type MarketplaceVitrineDomainRepository,
} from '../domain/repositories/marketplace-vitrine.repository';
import {
MARKETPLACE_WHITELIST_REPOSITORY,
type MarketplaceWhitelistDomainRepository,
} from '../domain/repositories/marketplace-whitelist.repository';
/**
* Bootstrap-миграция v3 расширения `market` (Story 3.1).
*
* Конфиг (`IConfig`) не меняется — это исключительно data-bootstrap:
* 1. создаёт дефолтную витрину `{id:'default', is_default:true,
* display_name:'Стол заказов'}` для текущего кооператива;
* 2. добавляет в whitelist неудаляемую запись `{member_account=coopname,
* role:'auto-coop'}` — сам кооператив всегда может публиковать
* оферы для перепоставки собственных остатков (FR5).
*
* Идемпотентна: оба repository-вызова `ensureDefault`/`add` no-op'ят при
* существующей записи. Миграция повторно безопасна.
*
* Внимание: таблицы `marketplace_vitrine`/`marketplace_whitelist`
* создаются TypeORM-`synchronize:true` в `MarketplaceInfrastructureModule`
* автоматически при старте — отдельный DDL не требуется.
*/
export const marketplaceBootstrapV3Migration: IExtensionSchemaMigration<Partial<IConfig>, IConfig> = {
extensionName: 'market',
version: 3,
migrate(oldConfig, def) {
return { ...def, ...oldConfig };
},
async afterMigrate(ctx: ExtensionSchemaMigrationAfterContext) {
const coopname = config.coopname;
if (!coopname) {
ctx.logWarn('[BOOTSTRAP_V3] config.coopname не задан — data-bootstrap пропущен');
return;
}
try {
const vitrineRepo = ctx.resolve<MarketplaceVitrineDomainRepository>(
MARKETPLACE_VITRINE_REPOSITORY
);
const whitelistRepo = ctx.resolve<MarketplaceWhitelistDomainRepository>(
MARKETPLACE_WHITELIST_REPOSITORY
);
const vitrine = await vitrineRepo.ensureDefault(coopname, 'Стол заказов');
ctx.logInfo(
`[BOOTSTRAP_V3] дефолтная витрина: id=${vitrine.id} coopname=${vitrine.coopname}`
);
const autoCoop = await whitelistRepo.add(coopname, coopname, 'auto-coop', null);
ctx.logInfo(
`[BOOTSTRAP_V3] auto-coop запись whitelist: id=${autoCoop.id} member=${autoCoop.member_account}`
);
} catch (error: unknown) {
ctx.logError(
'[BOOTSTRAP_V3] ошибка data-bootstrap (вероятно DI не зарегистрирован) — миграция повторится при следующем старте',
error
);
throw error;
}
},
};
@@ -0,0 +1,40 @@
import type {
IExtensionSchemaMigration,
ExtensionSchemaMigrationAfterContext,
} from '~/domain/extension/services/extension-schema-migration.service';
import { defaultConfig, IConfig } from '../types';
import {
MARKETPLACE_CATEGORY_REPOSITORY,
type MarketplaceCategoryDomainRepository,
} from '../domain/repositories/marketplace-category.repository';
/**
* Bootstrap-миграция v4 расширения `market` (Story 3.2).
*
* Идемпотентный data-bootstrap: сидирует 9 baseline-категорий
* (8 продовольственных + «Прочее») Стола заказов (`marketplace_category`
* table). Конфиг не меняется — категории живут в собственной таблице.
*
* DDL `marketplace_category` создаётся TypeORM `synchronize:true`.
*/
export const marketplaceBootstrapV4Migration: IExtensionSchemaMigration<Partial<IConfig>, IConfig> = {
extensionName: 'market',
version: 4,
migrate(oldConfig, def) {
return { ...def, ...oldConfig };
},
async afterMigrate(ctx: ExtensionSchemaMigrationAfterContext) {
try {
const categoryRepo = ctx.resolve<MarketplaceCategoryDomainRepository>(
MARKETPLACE_CATEGORY_REPOSITORY
);
await categoryRepo.upsertBaseline();
ctx.logInfo('[BOOTSTRAP_V4] 9 baseline-категорий marketplace upsert-нуты');
} catch (error: unknown) {
ctx.logError('[BOOTSTRAP_V4] ошибка upsert категорий — миграция повторится при следующем старте', error);
throw error;
}
},
};
@@ -0,0 +1,110 @@
import { Inject, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import {
MARKETPLACE_OFFER_COUNTERS_SERVICE,
MarketplaceOfferCountersService,
} from '../application/services/marketplace-offer-counters.service';
/**
* Scaffolding: `MarketplaceOrderSyncService`.
*
* **STATUS: NOT IMPLEMENTED** — заглушка точки интеграции для Эпика 4.
*
* Эта точка фиксирует **место в коде**, где живёт on-chain → PG
* репликация on-chain сущности `marketplace::orders` (из PR #375
* Story 11.1 Ledger2 marketplace canonical actions), и где вызывается
* `MarketplaceOfferCountersService` Story 3.4 как side-effect внутри
* dispatch pipeline.
*
* **Без этого класса** интеграция Эпика 3 ↔ Эпика 4 размазана по
* JSDoc-комментариям других файлов. С этим классом — в коде есть
* единое место, к которому Эпик 4 добавит реальную реализацию.
*
* Спецификация интеграции: `_bmad-output/implementation-artifacts/
* spec-3-4-bc-integration.md` (проект `1-prilozhenie-stol-zakazov`,
* компонент `3-minimalnyy-produkt`). Source-of-truth паттерна:
* `13-platforma-tsifrovogo-kooperativa/components/14-versiya-3/
* requirements/c3-arch-sinkhronizatsiya-uzla-s-blokcheynom-v1.md`
* (ADR-001 .. ADR-012) + `4f-arch-integratsiya-kontrollera-s-parser2-v1.md`.
*
* Зависимости, которые Эпик 4 должен добавить:
* - `@DomainKey({primary:'id', sync:'order_id'})` декоратор;
* - `@SyncBehaviour({forkPolicy:'rollback-via-versions', dlq:true})`;
* - `@Versioned({strategy:'entity_versions'})`;
* - extends `AbstractEntitySyncService<MarketplaceOrderDomainEntity, ...>`;
* - регистрация через `MarketplaceContractSyncModule.forEntity(...)`;
* - subscription к `marketplace::orders` через ParserClient с
* `subscriptionId="controller-${coopname}"`, `consumerName="primary"`,
* `startFromBlock:'last_known'` (DEC-T01..T12 из `4f-arch-parser2`);
* - `ForkRegistry.register(this.handleFork.bind(this))` в onInit.
*
* Dispatch pipeline (ADR-002 + controller/CLAUDE.md INV-12):
* 1. dedup check (event_id) → return если уже обработан;
* 2. save sync: `mapper.toDomain(delta)` → `repo.upsert`;
* 3. dedup.mark;
* 4. wake waiters (sync_key + block_num) — для write-mutation pool ADR-012;
* 5. **side-effect: вызов counters-сервиса в зависимости от action_name**
* (см. таблицу ниже);
* 6. emit internal bus `delta::marketplace::orders` immediate;
* 7. emit pubsub `MarketplaceOrderUpdated` (домен-entity из PG).
*
* Маппинг canonical actions → counters методы:
* - `p.mkt.supply.createorder` (o.mkt.assign + o.mkt.block) →
* `offerCounters.onOrderBlocked(offer_id, qty)`;
* - `p.mkt.supply.cancelorder` (FR12) / `expireorder` (FR14) /
* `declineorder` (FR16) (o.mkt.unblock) →
* `offerCounters.onOrderUnblocked(offer_id, qty)`;
* - `p.mkt.supply.consume`+`consume2` (Эпик 6 выдача пайщику,
* o.mkt.consume) → `offerCounters.onOrderConsumed(offer_id, qty)`;
* - корректировка «факт меньше заказа» (FR23) →
* `offerCounters.onOrderAdjusted(offer_id, qty_diff)`.
*
* Fork rollback (ADR-005):
* - `handleFork(blockNum)`: удалить `orders WHERE block_num > N` +
* restoreFromVersions; для каждого удалённого/откатанного Order'а
* в block-состоянии вызвать `offerCounters.onOrderRolledBack(
* offer_id, qty)` (без CAS-проверки, ADR-005 политика «frozen past
* with Rollback Horizon»).
*/
@Injectable()
export class MarketplaceOrderSyncService {
constructor(
@Inject(MARKETPLACE_OFFER_COUNTERS_SERVICE)
protected readonly offerCounters: MarketplaceOfferCountersService,
protected readonly eventBus: EventEmitter2,
protected readonly logger: WinstonLoggerService
) {
this.logger.setContext(MarketplaceOrderSyncService.name);
}
/**
* Подписаться на marketplace::orders через ParserClient.
* Реализация — Эпик 4.
*/
async start(): Promise<void> {
throw new Error(
'MarketplaceOrderSyncService.start: NOT IMPLEMENTED — будет реализовано в Эпике 4 после merge PR #375. См. spec-3-4-bc-integration.md.'
);
}
/**
* Dispatch одной delta-event'и из marketplace::orders.
* Реализация — Эпик 4.
*/
async dispatch(_event: unknown): Promise<void> {
throw new Error(
'MarketplaceOrderSyncService.dispatch: NOT IMPLEMENTED — будет реализовано в Эпике 4. См. spec-3-4-bc-integration.md секция 2.4.'
);
}
/**
* Fork rollback handler — регистрируется в ForkRegistry.onInit.
* Реализация — Эпик 4.
*/
async handleFork(_blockNum: bigint): Promise<void> {
throw new Error(
'MarketplaceOrderSyncService.handleFork: NOT IMPLEMENTED — будет реализовано в Эпике 4. См. spec-3-4-bc-integration.md секция 2.5.'
);
}
}
@@ -1,24 +1,28 @@
/**
* Unit-тесты MarketplaceMembershipGuard (Story 1.3).
* Unit-тесты MarketplaceMembershipGuard.
*
* Story 1.3 (auth/active/server-secret) + Story 3.1 (isOfferer source — whitelist).
*
* Покрывают AC:
* (a) пайщик с user.role='user', status=active → guard пропускает, в
* request.currentMember / ctx.currentMember лежит { core_roles:['User'],
* marketplace_roles:[] };
* (b) member и chairman дают расширенный core_roles;
* (c) status != active → ForbiddenException (HTTP 403 «Доступ только для
* пайщиков кооператива»);
* (d) no user → UnauthorizedException (HTTP 401);
* (e) server-secret bypass — guard true, currentMember не выставлен.
* (a) пайщик с user.role='user', status=active, whitelist пуст → guard
* пропускает, marketplace_roles=['orderer','offerer'] (open vitrine);
* (b) пайщик с user.role='user', whitelist непустой и пайщика там нет →
* marketplace_roles=['orderer'] (без offerer);
* (c) member и chairman дают расширенный core_roles + marketplace_roles;
* (d) status != active → ForbiddenException (HTTP 403);
* (e) no user → UnauthorizedException (HTTP 401);
* (f) server-secret bypass — guard true, currentMember не выставлен.
*/
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { MarketplaceMembershipGuard } from '~/extensions/marketplace/application/guards/marketplace-membership.guard';
import type { MarketplaceWhitelistService } from '~/extensions/marketplace/application/services/marketplace-whitelist.service';
jest.mock('~/config/config', () => ({
__esModule: true,
default: {
server_secret: 'test-secret',
coopname: 'voskhod',
},
}));
@@ -37,67 +41,88 @@ function makeCtx(req: any) {
};
}
function makeWhitelistService(isOffererResult: boolean): MarketplaceWhitelistService {
return {
isOfferer: jest.fn().mockResolvedValue(isOffererResult),
} as unknown as MarketplaceWhitelistService;
}
describe('MarketplaceMembershipGuard', () => {
let guard: MarketplaceMembershipGuard;
beforeEach(() => {
guard = new MarketplaceMembershipGuard();
});
it('user.role=user, status=active → пропускает, ctx [User] + marketplace_roles=[orderer]', () => {
it('user.role=user, status=active, whitelist пуст → ctx [User] + marketplace_roles [orderer, offerer]', async () => {
const guard = new MarketplaceMembershipGuard(makeWhitelistService(true));
const req = { user: { username: 'alice', role: 'user', status: 'active' }, headers: {} };
const ctx = makeCtx(req);
expect(guard.canActivate(ctx as any)).toBe(true);
await expect(guard.canActivate(ctx as any)).resolves.toBe(true);
expect((ctx as any)._gqlCtx.currentMember).toEqual({
username: 'alice',
core_roles: ['User'],
marketplace_roles: ['orderer'],
marketplace_roles: ['orderer', 'offerer'],
});
expect(req).toHaveProperty('currentMember');
});
it('user.role=member, status=active → core_roles [User, Member] + marketplace_roles [orderer, board_readonly]', () => {
it('user.role=user, whitelist непустой и пайщика там нет → marketplace_roles [orderer] (без offerer)', async () => {
const guard = new MarketplaceMembershipGuard(makeWhitelistService(false));
const req = { user: { username: 'alice', 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']);
});
it('user.role=member, status=active → core_roles [User, Member] + marketplace_roles [orderer, offerer, board_readonly]', async () => {
const guard = new MarketplaceMembershipGuard(makeWhitelistService(true));
const req = { user: { username: 'bob', role: 'member', status: 'active' }, headers: {} };
const ctx = makeCtx(req);
expect(guard.canActivate(ctx as any)).toBe(true);
await expect(guard.canActivate(ctx as any)).resolves.toBe(true);
expect((ctx as any)._gqlCtx.currentMember.core_roles).toEqual(['User', 'Member']);
expect((ctx as any)._gqlCtx.currentMember.marketplace_roles).toEqual([
'orderer',
'offerer',
'board_readonly',
]);
});
it('user.role=chairman, status=active → core_roles [User, Member, Chairman] + marketplace_roles полный набор', () => {
it('user.role=chairman, status=active → marketplace_roles полный набор', async () => {
const guard = new MarketplaceMembershipGuard(makeWhitelistService(true));
const req = { user: { username: 'chair', role: 'chairman', status: 'active' }, headers: {} };
const ctx = makeCtx(req);
expect(guard.canActivate(ctx as any)).toBe(true);
await expect(guard.canActivate(ctx as any)).resolves.toBe(true);
expect((ctx as any)._gqlCtx.currentMember.core_roles).toEqual(['User', 'Member', 'Chairman']);
expect((ctx as any)._gqlCtx.currentMember.marketplace_roles).toEqual([
'orderer',
'offerer',
'board_readonly',
'admin',
'board',
]);
});
it('status != active → 403 Forbidden «Доступ только для пайщиков кооператива»', () => {
it('status != active → 403 Forbidden «Доступ только для пайщиков кооператива»', async () => {
const guard = new MarketplaceMembershipGuard(makeWhitelistService(false));
const req = { user: { username: 'alice', role: 'user', status: '4_Registered' }, headers: {} };
const ctx = makeCtx(req);
expect(() => guard.canActivate(ctx as any)).toThrow(ForbiddenException);
expect(() => guard.canActivate(ctx as any)).toThrow('Доступ только для пайщиков кооператива');
await expect(guard.canActivate(ctx as any)).rejects.toThrow(ForbiddenException);
await expect(guard.canActivate(ctx as any)).rejects.toThrow(
'Доступ только для пайщиков кооператива'
);
});
it('нет user (нет JWT) → 401 Unauthorized', () => {
it('нет user (нет JWT) → 401 Unauthorized', async () => {
const guard = new MarketplaceMembershipGuard(makeWhitelistService(false));
const req = { headers: {} };
const ctx = makeCtx(req);
expect(() => guard.canActivate(ctx as any)).toThrow(UnauthorizedException);
await expect(guard.canActivate(ctx as any)).rejects.toThrow(UnauthorizedException);
});
it('server-secret bypass → true, currentMember не выставляется', () => {
it('server-secret bypass → true, currentMember не выставляется, whitelist не дёргается', async () => {
const ws = makeWhitelistService(true);
const guard = new MarketplaceMembershipGuard(ws);
const req = { headers: { 'server-secret': 'test-secret' } };
const ctx = makeCtx(req);
expect(guard.canActivate(ctx as any)).toBe(true);
await expect(guard.canActivate(ctx as any)).resolves.toBe(true);
expect((ctx as any)._gqlCtx.currentMember).toBeUndefined();
expect(ws.isOfferer).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,252 @@
/**
* Unit-тесты MarketplaceModerationService (Story 3.3).
*
* Покрывают AC:
* - approve: PENDING → ACTIVE, approved_by/at заполнены, лог append,
* событие EVENT_APPROVED отправлено;
* - reject: PENDING → REJECTED + reason, лог append, EVENT_REJECTED;
* - reject без reason → 400;
* - reject слишком длинный reason → 400;
* - approve/reject Offer'а не в PENDING → 409 Conflict;
* - approve/reject несуществующего → 404;
* - listPending → repo.list с filter status=PENDING_MODERATION + paging;
* - listLog → repo.listByOffer.
*/
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MarketplaceModerationService } from '~/extensions/marketplace/application/services/marketplace-moderation.service';
import { MarketplaceOfferDomainEntity } from '~/extensions/marketplace/domain/entities/marketplace-offer.entity';
import { MarketplaceModerationLogDomainEntity } from '~/extensions/marketplace/domain/entities/marketplace-moderation-log.entity';
import type { MarketplaceOfferDomainRepository } from '~/extensions/marketplace/domain/repositories/marketplace-offer.repository';
import type { MarketplaceModerationLogDomainRepository } from '~/extensions/marketplace/domain/repositories/marketplace-moderation-log.repository';
import type { MarketplaceOfferStatus } from '~/extensions/marketplace/domain/entities/marketplace-offer.types';
const COOP = 'voskhod';
function makeOffer(overrides: Partial<MarketplaceOfferDomainEntity> = {}): MarketplaceOfferDomainEntity {
return new MarketplaceOfferDomainEntity({
id: 'offer-1',
coopname: COOP,
supplier_account: 'alice',
vitrine_id: 'default',
product_name: 'Картофель',
description: null,
category_id: 1,
price_per_unit: '50.0000',
unit_of_measure: 'kg',
quantity_available: 100,
quantity_blocked: 0,
quantity_consumed: 0,
unlimited_flag: false,
cycle_type: 'time_based',
cycle_days: 7,
target_volume: null,
max_wait_days: null,
min_threshold: null,
warranty_days: 0,
status: 'PENDING_MODERATION',
approved_by: null,
approved_at: null,
rejected_by: null,
rejected_at: null,
reject_reason: null,
created_at: new Date('2026-05-15T12:00:00Z'),
updated_at: new Date('2026-05-15T12:00:00Z'),
...overrides,
});
}
function makeLog(overrides: Partial<MarketplaceModerationLogDomainEntity> = {}): MarketplaceModerationLogDomainEntity {
return new MarketplaceModerationLogDomainEntity({
id: 'log-1',
offer_id: 'offer-1',
action: 'approve',
by_account: 'chair',
reason: null,
created_at: new Date(),
...overrides,
});
}
function makeOfferRepo(): jest.Mocked<MarketplaceOfferDomainRepository> {
return {
findById: jest.fn(),
list: jest.fn(),
countByCategory: jest.fn(),
countRecentCreatedBy: jest.fn(),
create: jest.fn(),
applyUpdate: jest.fn(),
applyBlockDelta: jest.fn(),
applyUnblockDelta: jest.fn(),
applyConsumeDelta: jest.fn(),
applyRollbackDelta: jest.fn(),
};
}
function makeLogRepo(): jest.Mocked<MarketplaceModerationLogDomainRepository> {
return {
append: jest.fn(),
listByOffer: jest.fn(),
};
}
function makeEventBus(): EventEmitter2 {
return { emit: jest.fn() } as unknown as EventEmitter2;
}
describe('MarketplaceModerationService.approve', () => {
it('PENDING → ACTIVE + approved_by/at + log append + event emit', async () => {
const offerRepo = makeOfferRepo();
const logRepo = makeLogRepo();
const bus = makeEventBus();
offerRepo.findById.mockResolvedValue(makeOffer());
offerRepo.applyUpdate.mockResolvedValue(
makeOffer({ status: 'ACTIVE' as MarketplaceOfferStatus, approved_by: 'chair' })
);
logRepo.append.mockResolvedValue(makeLog());
const service = new MarketplaceModerationService(offerRepo, logRepo, bus);
const updated = await service.approve('offer-1', 'chair');
expect(updated.status).toBe('ACTIVE');
expect(offerRepo.applyUpdate).toHaveBeenCalledWith(
'offer-1',
expect.objectContaining({
status: 'ACTIVE',
approved_by: 'chair',
rejected_by: null,
reject_reason: null,
})
);
expect(logRepo.append).toHaveBeenCalledWith({
offer_id: 'offer-1',
action: 'approve',
by_account: 'chair',
reason: null,
});
expect(bus.emit).toHaveBeenCalledWith(
MarketplaceModerationService.EVENT_APPROVED,
expect.objectContaining({ offer_id: 'offer-1', supplier_account: 'alice', approved_by: 'chair' })
);
});
it('approve Offer\'а в ACTIVE → 409 Conflict', async () => {
const offerRepo = makeOfferRepo();
offerRepo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE' as MarketplaceOfferStatus }));
const service = new MarketplaceModerationService(offerRepo, makeLogRepo(), makeEventBus());
await expect(service.approve('offer-1', 'chair')).rejects.toThrow(ConflictException);
});
it('approve несуществующего → 404', async () => {
const offerRepo = makeOfferRepo();
offerRepo.findById.mockResolvedValue(null);
const service = new MarketplaceModerationService(offerRepo, makeLogRepo(), makeEventBus());
await expect(service.approve('offer-x', 'chair')).rejects.toThrow(NotFoundException);
});
});
describe('MarketplaceModerationService.reject', () => {
it('PENDING → REJECTED + reason + log + event', async () => {
const offerRepo = makeOfferRepo();
const logRepo = makeLogRepo();
const bus = makeEventBus();
offerRepo.findById.mockResolvedValue(makeOffer());
offerRepo.applyUpdate.mockResolvedValue(
makeOffer({
status: 'REJECTED' as MarketplaceOfferStatus,
rejected_by: 'chair',
reject_reason: 'Не соответствует политике',
})
);
logRepo.append.mockResolvedValue(makeLog({ action: 'reject', reason: 'Не соответствует политике' }));
const service = new MarketplaceModerationService(offerRepo, logRepo, bus);
const updated = await service.reject('offer-1', 'chair', 'Не соответствует политике');
expect(updated.status).toBe('REJECTED');
expect(updated.reject_reason).toBe('Не соответствует политике');
expect(logRepo.append).toHaveBeenCalledWith(
expect.objectContaining({ action: 'reject', reason: 'Не соответствует политике' })
);
expect(bus.emit).toHaveBeenCalledWith(
MarketplaceModerationService.EVENT_REJECTED,
expect.objectContaining({ offer_id: 'offer-1', reason: 'Не соответствует политике' })
);
});
it('reject без reason → 400', async () => {
const service = new MarketplaceModerationService(
makeOfferRepo(),
makeLogRepo(),
makeEventBus()
);
await expect(service.reject('offer-1', 'chair', '')).rejects.toThrow(BadRequestException);
await expect(service.reject('offer-1', 'chair', ' ')).rejects.toThrow(BadRequestException);
});
it('reject reason > 1000 → 400', async () => {
const service = new MarketplaceModerationService(
makeOfferRepo(),
makeLogRepo(),
makeEventBus()
);
await expect(service.reject('offer-1', 'chair', 'x'.repeat(1001))).rejects.toThrow(
BadRequestException
);
});
it('reject Offer\'а уже REJECTED → 409', async () => {
const offerRepo = makeOfferRepo();
offerRepo.findById.mockResolvedValue(makeOffer({ status: 'REJECTED' as MarketplaceOfferStatus }));
const service = new MarketplaceModerationService(offerRepo, makeLogRepo(), makeEventBus());
await expect(service.reject('offer-1', 'chair', 'reason')).rejects.toThrow(ConflictException);
});
it('reject reason обрезается trim перед записью', async () => {
const offerRepo = makeOfferRepo();
const logRepo = makeLogRepo();
offerRepo.findById.mockResolvedValue(makeOffer());
offerRepo.applyUpdate.mockResolvedValue(makeOffer({ status: 'REJECTED' as MarketplaceOfferStatus }));
logRepo.append.mockResolvedValue(makeLog({ action: 'reject', reason: 'reason' }));
const service = new MarketplaceModerationService(offerRepo, logRepo, makeEventBus());
await service.reject('offer-1', 'chair', ' reason ');
expect(offerRepo.applyUpdate).toHaveBeenCalledWith(
'offer-1',
expect.objectContaining({ reject_reason: 'reason' })
);
});
});
describe('MarketplaceModerationService.listPending + listLog', () => {
it('listPending: filter status=PENDING_MODERATION, paging', async () => {
const offerRepo = makeOfferRepo();
offerRepo.list.mockResolvedValue({ items: [], totalCount: 0, totalPages: 0, currentPage: 1 });
const service = new MarketplaceModerationService(offerRepo, makeLogRepo(), makeEventBus());
await service.listPending(COOP, {
page: 1,
limit: 25,
sortBy: 'created_at',
sortOrder: 'DESC',
});
expect(offerRepo.list).toHaveBeenCalledWith(
{ coopname: COOP, status: 'PENDING_MODERATION' },
{ page: 1, limit: 25, sortBy: 'created_at', sortOrder: 'DESC' }
);
});
it('listLog → logRepo.listByOffer', async () => {
const logRepo = makeLogRepo();
logRepo.listByOffer.mockResolvedValue([makeLog()]);
const service = new MarketplaceModerationService(makeOfferRepo(), logRepo, makeEventBus());
const result = await service.listLog('offer-1');
expect(result).toHaveLength(1);
expect(logRepo.listByOffer).toHaveBeenCalledWith('offer-1');
});
});
@@ -0,0 +1,241 @@
/**
* Unit-тесты MarketplaceOfferCountersService (Story 3.4).
*
* Тесты покрывают service-уровень (валидация qty + интерпретация
* результата от репозитория + emit на bus). Атомарность SQL UPDATE
* с CAS-условием — тестируется на интеграционном уровне (Эпик 4,
* after merge #375 + testcontainers PG).
*
* Покрывают AC:
* - onOrderBlocked happy → возвращает обновлённый Offer + emit;
* - onOrderUnblocked happy;
* - onOrderConsumed happy;
* - onOrderAdjusted делегирует в unblock;
* - qty <= 0 → 400 BadRequest;
* - insufficient_available → 400 BadRequest;
* - insufficient_blocked → 400;
* - offer_not_active → 400;
* - offer_not_found → 404.
*/
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { MarketplaceOfferCountersService } from '~/extensions/marketplace/application/services/marketplace-offer-counters.service';
import { MarketplaceOfferDomainEntity } from '~/extensions/marketplace/domain/entities/marketplace-offer.entity';
import type { MarketplaceOfferDomainRepository } from '~/extensions/marketplace/domain/repositories/marketplace-offer.repository';
function makeOffer(overrides: Partial<MarketplaceOfferDomainEntity> = {}): MarketplaceOfferDomainEntity {
return new MarketplaceOfferDomainEntity({
id: 'offer-1',
coopname: 'voskhod',
supplier_account: 'alice',
vitrine_id: 'default',
product_name: 'Картофель',
description: null,
category_id: 1,
price_per_unit: '50.0000',
unit_of_measure: 'kg',
quantity_available: 90,
quantity_blocked: 10,
quantity_consumed: 0,
unlimited_flag: false,
cycle_type: 'time_based',
cycle_days: 7,
target_volume: null,
max_wait_days: null,
min_threshold: null,
warranty_days: 0,
status: 'ACTIVE',
approved_by: 'chair',
approved_at: new Date(),
rejected_by: null,
rejected_at: null,
reject_reason: null,
created_at: new Date(),
updated_at: new Date(),
...overrides,
});
}
function makeRepo(): jest.Mocked<MarketplaceOfferDomainRepository> {
return {
findById: jest.fn(),
list: jest.fn(),
countByCategory: jest.fn(),
countRecentCreatedBy: jest.fn(),
create: jest.fn(),
applyUpdate: jest.fn(),
applyBlockDelta: jest.fn(),
applyUnblockDelta: jest.fn(),
applyConsumeDelta: jest.fn(),
applyRollbackDelta: jest.fn(),
};
}
function makeBus(): EventEmitter2 {
return { emit: jest.fn() } as unknown as EventEmitter2;
}
describe('MarketplaceOfferCountersService', () => {
it('onOrderBlocked: happy → returns offer + emit changed', async () => {
const repo = makeRepo();
const bus = makeBus();
repo.applyBlockDelta.mockResolvedValue({ ok: true, offer: makeOffer({ quantity_blocked: 15 }) });
const service = new MarketplaceOfferCountersService(repo, bus);
const result = await service.onOrderBlocked('offer-1', 5);
expect(repo.applyBlockDelta).toHaveBeenCalledWith('offer-1', 5);
expect(result.quantity_blocked).toBe(15);
expect(bus.emit).toHaveBeenCalledWith(
MarketplaceOfferCountersService.EVENT_CHANGED,
expect.objectContaining({ offer_id: 'offer-1', op: 'block', qty: 5 })
);
});
it('onOrderUnblocked: happy', async () => {
const repo = makeRepo();
repo.applyUnblockDelta.mockResolvedValue({
ok: true,
offer: makeOffer({ quantity_available: 95, quantity_blocked: 5 }),
});
const service = new MarketplaceOfferCountersService(repo, makeBus());
const result = await service.onOrderUnblocked('offer-1', 5);
expect(repo.applyUnblockDelta).toHaveBeenCalledWith('offer-1', 5);
expect(result.quantity_available).toBe(95);
});
it('onOrderConsumed: happy', async () => {
const repo = makeRepo();
repo.applyConsumeDelta.mockResolvedValue({
ok: true,
offer: makeOffer({ quantity_blocked: 5, quantity_consumed: 5 }),
});
const service = new MarketplaceOfferCountersService(repo, makeBus());
const result = await service.onOrderConsumed('offer-1', 5);
expect(repo.applyConsumeDelta).toHaveBeenCalledWith('offer-1', 5);
expect(result.quantity_consumed).toBe(5);
});
it('onOrderRolledBack (ADR-005 ForkRegistry handler) → applyRollbackDelta + emit op:rollback', async () => {
const repo = makeRepo();
const bus = makeBus();
repo.applyRollbackDelta.mockResolvedValue({
ok: true,
offer: makeOffer({ quantity_available: 95, quantity_blocked: 5 }),
});
const service = new MarketplaceOfferCountersService(repo, bus);
const result = await service.onOrderRolledBack('offer-1', 5);
expect(repo.applyRollbackDelta).toHaveBeenCalledWith('offer-1', 5);
expect(result.quantity_available).toBe(95);
expect(bus.emit).toHaveBeenCalledWith(
MarketplaceOfferCountersService.EVENT_CHANGED,
expect.objectContaining({ op: 'rollback', qty: 5 })
);
});
it('onOrderAdjusted делегирует в onOrderUnblocked', async () => {
const repo = makeRepo();
repo.applyUnblockDelta.mockResolvedValue({
ok: true,
offer: makeOffer({ quantity_available: 92, quantity_blocked: 8 }),
});
const service = new MarketplaceOfferCountersService(repo, makeBus());
await service.onOrderAdjusted('offer-1', 2);
expect(repo.applyUnblockDelta).toHaveBeenCalledWith('offer-1', 2);
});
it('qty <= 0 → 400 BadRequest', async () => {
const repo = makeRepo();
const service = new MarketplaceOfferCountersService(repo, makeBus());
await expect(service.onOrderBlocked('offer-1', 0)).rejects.toThrow(BadRequestException);
await expect(service.onOrderBlocked('offer-1', -1)).rejects.toThrow(BadRequestException);
await expect(service.onOrderBlocked('offer-1', 1.5)).rejects.toThrow(BadRequestException);
expect(repo.applyBlockDelta).not.toHaveBeenCalled();
});
it('insufficient_available → 400', async () => {
const repo = makeRepo();
repo.applyBlockDelta.mockResolvedValue({ ok: false, reason: 'insufficient_available' });
const service = new MarketplaceOfferCountersService(repo, makeBus());
await expect(service.onOrderBlocked('offer-1', 100)).rejects.toThrow(BadRequestException);
await expect(service.onOrderBlocked('offer-1', 100)).rejects.toThrow(
/Недостаточно свободного количества/
);
});
it('insufficient_blocked → 400', async () => {
const repo = makeRepo();
repo.applyUnblockDelta.mockResolvedValue({ ok: false, reason: 'insufficient_blocked' });
const service = new MarketplaceOfferCountersService(repo, makeBus());
await expect(service.onOrderUnblocked('offer-1', 100)).rejects.toThrow(BadRequestException);
await expect(service.onOrderUnblocked('offer-1', 100)).rejects.toThrow(
/Недостаточно зарезервированного количества/
);
});
it('offer_not_active → 400 (например, WITHDRAWN)', async () => {
const repo = makeRepo();
repo.applyBlockDelta.mockResolvedValue({ ok: false, reason: 'offer_not_active' });
const service = new MarketplaceOfferCountersService(repo, makeBus());
await expect(service.onOrderBlocked('offer-1', 5)).rejects.toThrow(BadRequestException);
await expect(service.onOrderBlocked('offer-1', 5)).rejects.toThrow(/Предложение неактивно/);
});
it('offer_not_found → 404', async () => {
const repo = makeRepo();
repo.applyBlockDelta.mockResolvedValue({ ok: false, reason: 'offer_not_found' });
const service = new MarketplaceOfferCountersService(repo, makeBus());
await expect(service.onOrderBlocked('ghost', 5)).rejects.toThrow(NotFoundException);
});
it('emit не вызывается при ошибке', async () => {
const repo = makeRepo();
const bus = makeBus();
repo.applyBlockDelta.mockResolvedValue({ ok: false, reason: 'insufficient_available' });
const service = new MarketplaceOfferCountersService(repo, bus);
await expect(service.onOrderBlocked('offer-1', 5)).rejects.toThrow();
expect(bus.emit).not.toHaveBeenCalled();
});
});
describe('MarketplaceOfferCountersService invariant (для не-unlimited Offer\'ов)', () => {
// Service-уровневый smoke-тест: при последовательности block→unblock→consume
// суммарное изменение available+blocked+consumed = 0 (см. mathematical
// formula в JSDoc сервиса). Реальный invariant проверяется в SQL CAS
// на интеграционном уровне.
it('block 10 + unblock 3 + consume 7 → net 0 (math invariant)', async () => {
const repo = makeRepo();
repo.applyBlockDelta.mockResolvedValue({
ok: true,
offer: makeOffer({ quantity_available: 80, quantity_blocked: 20, quantity_consumed: 0 }),
});
repo.applyUnblockDelta.mockResolvedValue({
ok: true,
offer: makeOffer({ quantity_available: 83, quantity_blocked: 17, quantity_consumed: 0 }),
});
repo.applyConsumeDelta.mockResolvedValue({
ok: true,
offer: makeOffer({ quantity_available: 83, quantity_blocked: 10, quantity_consumed: 7 }),
});
const service = new MarketplaceOfferCountersService(repo, makeBus());
const o1 = await service.onOrderBlocked('offer-1', 10);
const o2 = await service.onOrderUnblocked('offer-1', 3);
const o3 = await service.onOrderConsumed('offer-1', 7);
// lifetime_published = 100 (initial available 90 + blocked 10 в моках).
expect(o1.quantity_available + o1.quantity_blocked + o1.quantity_consumed).toBe(100);
expect(o2.quantity_available + o2.quantity_blocked + o2.quantity_consumed).toBe(100);
expect(o3.quantity_available + o3.quantity_blocked + o3.quantity_consumed).toBe(100);
});
});
@@ -0,0 +1,410 @@
/**
* Unit-тесты MarketplaceOfferService (Story 3.2).
*
* Покрывают AC:
* - create: статус PENDING_MODERATION; rate-limit 10/час; неизвестная
* категория → 400; валидация product_name/description/unit/cycle/price;
* unlimited_flag=true обнуляет quantity_available;
* - update: ownership check (403 чужому); REJECTED/WITHDRAWN → 403;
* reset status в PENDING_MODERATION; валидация полей;
* - withdraw: ownership check; статус → WITHDRAWN; блок при активных
* ордерах (stub false до Эпика 4);
* - listMine + getById — thin delegate.
*/
import { BadRequestException, ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
import {
MarketplaceOfferService,
type OfferCreateRequest,
} from '~/extensions/marketplace/application/services/marketplace-offer.service';
import { MarketplaceOfferDomainEntity } from '~/extensions/marketplace/domain/entities/marketplace-offer.entity';
import { MarketplaceCategoryDomainEntity } from '~/extensions/marketplace/domain/entities/marketplace-category.entity';
import type { MarketplaceOfferDomainRepository } from '~/extensions/marketplace/domain/repositories/marketplace-offer.repository';
import type { MarketplaceCategoryDomainRepository } from '~/extensions/marketplace/domain/repositories/marketplace-category.repository';
const COOP = 'voskhod';
function makeOffer(overrides: Partial<MarketplaceOfferDomainEntity> = {}): MarketplaceOfferDomainEntity {
return new MarketplaceOfferDomainEntity({
id: 'offer-1',
coopname: COOP,
supplier_account: 'alice',
vitrine_id: 'default',
product_name: 'Картофель',
description: null,
category_id: 1,
price_per_unit: '50.0000',
unit_of_measure: 'kg',
quantity_available: 100,
quantity_blocked: 0,
quantity_consumed: 0,
unlimited_flag: false,
cycle_type: 'time_based',
cycle_days: 7,
target_volume: null,
max_wait_days: null,
min_threshold: null,
warranty_days: 0,
status: 'PENDING_MODERATION',
approved_by: null,
approved_at: null,
rejected_by: null,
rejected_at: null,
reject_reason: null,
created_at: new Date('2026-05-15T12:00:00Z'),
updated_at: new Date('2026-05-15T12:00:00Z'),
...overrides,
});
}
function makeOfferRepo(): jest.Mocked<MarketplaceOfferDomainRepository> {
return {
findById: jest.fn(),
list: jest.fn(),
countByCategory: jest.fn(),
countRecentCreatedBy: jest.fn(),
create: jest.fn(),
applyUpdate: jest.fn(),
applyBlockDelta: jest.fn(),
applyUnblockDelta: jest.fn(),
applyConsumeDelta: jest.fn(),
applyRollbackDelta: jest.fn(),
};
}
function makeCategoryRepo(): jest.Mocked<MarketplaceCategoryDomainRepository> {
const repo = {
listBaseline: jest.fn(),
findById: jest.fn(),
upsertBaseline: jest.fn(),
};
repo.findById.mockResolvedValue(
new MarketplaceCategoryDomainEntity({
id: 1,
display_name: 'Продовольственные товары',
sort_order: 1,
mvp_baseline: true,
})
);
return repo;
}
function baseCreateRequest(overrides: Partial<OfferCreateRequest> = {}): OfferCreateRequest {
return {
coopname: COOP,
supplier_account: 'alice',
vitrine_id: 'default',
product_name: 'Картофель',
description: null,
category_id: 1,
price_per_unit: '50.0000',
unit_of_measure: 'kg',
quantity_available: 100,
unlimited_flag: false,
cycle_type: 'time_based',
cycle_days: 7,
target_volume: null,
max_wait_days: null,
min_threshold: null,
warranty_days: 0,
...overrides,
};
}
describe('MarketplaceOfferService.create', () => {
it('создаёт Offer со статусом PENDING_MODERATION', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.countRecentCreatedBy.mockResolvedValue(0);
repo.create.mockResolvedValue(makeOffer());
const service = new MarketplaceOfferService(repo, cats);
const offer = await service.create(baseCreateRequest());
expect(offer.status).toBe('PENDING_MODERATION');
expect(repo.create).toHaveBeenCalled();
});
it('rate-limit: на 10-м offer'+'е за час → 400', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.countRecentCreatedBy.mockResolvedValue(MarketplaceOfferService.RATE_LIMIT_PER_HOUR);
const service = new MarketplaceOfferService(repo, cats);
await expect(service.create(baseCreateRequest())).rejects.toThrow(BadRequestException);
expect(repo.create).not.toHaveBeenCalled();
});
it('категория вне baseline → 400', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.countRecentCreatedBy.mockResolvedValue(0);
const service = new MarketplaceOfferService(repo, cats);
await expect(service.create(baseCreateRequest({ category_id: 99 }))).rejects.toThrow(
BadRequestException
);
});
it('категория есть в baseline но отсутствует в БД → 400 (миграция не выполнилась)', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
cats.findById.mockResolvedValueOnce(null);
repo.countRecentCreatedBy.mockResolvedValue(0);
const service = new MarketplaceOfferService(repo, cats);
await expect(service.create(baseCreateRequest({ category_id: 5 }))).rejects.toThrow(
BadRequestException
);
});
it('product_name пустой → 400', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.countRecentCreatedBy.mockResolvedValue(0);
const service = new MarketplaceOfferService(repo, cats);
await expect(service.create(baseCreateRequest({ product_name: ' ' }))).rejects.toThrow(
BadRequestException
);
});
it('product_name > 200 → 400', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.countRecentCreatedBy.mockResolvedValue(0);
const service = new MarketplaceOfferService(repo, cats);
await expect(
service.create(baseCreateRequest({ product_name: 'x'.repeat(201) }))
).rejects.toThrow(BadRequestException);
});
it('quantity_available=null при unlimited_flag=false → 400', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.countRecentCreatedBy.mockResolvedValue(0);
const service = new MarketplaceOfferService(repo, cats);
await expect(
service.create(baseCreateRequest({ quantity_available: null, unlimited_flag: false }))
).rejects.toThrow(BadRequestException);
});
it('unlimited_flag=true обнуляет quantity_available и пропускает валидацию', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.countRecentCreatedBy.mockResolvedValue(0);
repo.create.mockResolvedValue(makeOffer({ unlimited_flag: true, quantity_available: 0 }));
const service = new MarketplaceOfferService(repo, cats);
await service.create(
baseCreateRequest({ unlimited_flag: true, quantity_available: null })
);
expect(repo.create).toHaveBeenCalledWith(
expect.objectContaining({ unlimited_flag: true, quantity_available: 0 })
);
});
it('некорректный cycle_type → 400', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.countRecentCreatedBy.mockResolvedValue(0);
const service = new MarketplaceOfferService(repo, cats);
await expect(
service.create(baseCreateRequest({ cycle_type: 'weird' as any }))
).rejects.toThrow(BadRequestException);
});
it('некорректный unit_of_measure → 400', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.countRecentCreatedBy.mockResolvedValue(0);
const service = new MarketplaceOfferService(repo, cats);
await expect(
service.create(baseCreateRequest({ unit_of_measure: 'tonne' as any }))
).rejects.toThrow(BadRequestException);
});
it('некорректный price_per_unit (не numeric) → 400', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.countRecentCreatedBy.mockResolvedValue(0);
const service = new MarketplaceOfferService(repo, cats);
await expect(
service.create(baseCreateRequest({ price_per_unit: 'abc' }))
).rejects.toThrow(BadRequestException);
});
});
describe('MarketplaceOfferService.update', () => {
it('update своего offer'+'а сбрасывает статус в PENDING_MODERATION', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE' }));
repo.applyUpdate.mockResolvedValue(makeOffer({ status: 'PENDING_MODERATION' }));
const service = new MarketplaceOfferService(repo, cats);
const result = await service.update('offer-1', 'alice', { product_name: 'Молоко' });
expect(repo.applyUpdate).toHaveBeenCalledWith(
'offer-1',
expect.objectContaining({ product_name: 'Молоко', status: 'PENDING_MODERATION' })
);
expect(result.status).toBe('PENDING_MODERATION');
});
it('update чужого offer'+'а → 403', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(makeOffer({ supplier_account: 'alice' }));
const service = new MarketplaceOfferService(repo, cats);
await expect(service.update('offer-1', 'mallory', { product_name: 'X' })).rejects.toThrow(
ForbiddenException
);
});
it('update WITHDRAWN/REJECTED → 403', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(makeOffer({ status: 'WITHDRAWN' }));
const service = new MarketplaceOfferService(repo, cats);
await expect(service.update('offer-1', 'alice', { product_name: 'X' })).rejects.toThrow(
ForbiddenException
);
});
it('update несуществующего → 404', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(null);
const service = new MarketplaceOfferService(repo, cats);
await expect(service.update('offer-x', 'alice', { product_name: 'X' })).rejects.toThrow(
NotFoundException
);
});
it('unlimited_flag=true в patch обнуляет quantity_available', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE' }));
repo.applyUpdate.mockResolvedValue(makeOffer({ unlimited_flag: true, quantity_available: 0 }));
const service = new MarketplaceOfferService(repo, cats);
await service.update('offer-1', 'alice', { unlimited_flag: true });
expect(repo.applyUpdate).toHaveBeenCalledWith(
'offer-1',
expect.objectContaining({ unlimited_flag: true, quantity_available: 0, status: 'PENDING_MODERATION' })
);
});
it('update с invalid category → 400', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE' }));
const service = new MarketplaceOfferService(repo, cats);
await expect(service.update('offer-1', 'alice', { category_id: 99 })).rejects.toThrow(
BadRequestException
);
});
});
describe('MarketplaceOfferService.withdraw', () => {
it('withdraw → status WITHDRAWN', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE' }));
repo.applyUpdate.mockResolvedValue(makeOffer({ status: 'WITHDRAWN' }));
const service = new MarketplaceOfferService(repo, cats);
const result = await service.withdraw('offer-1', 'alice');
expect(repo.applyUpdate).toHaveBeenCalledWith('offer-1', { status: 'WITHDRAWN' });
expect(result.status).toBe('WITHDRAWN');
});
it('withdraw чужого → 403', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(makeOffer({ supplier_account: 'alice', status: 'ACTIVE' }));
const service = new MarketplaceOfferService(repo, cats);
await expect(service.withdraw('offer-1', 'mallory')).rejects.toThrow(ForbiddenException);
});
it('withdraw уже WITHDRAWN → 403', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(makeOffer({ status: 'WITHDRAWN' }));
const service = new MarketplaceOfferService(repo, cats);
await expect(service.withdraw('offer-1', 'alice')).rejects.toThrow(ForbiddenException);
});
it('withdraw несуществующего → 404', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(null);
const service = new MarketplaceOfferService(repo, cats);
await expect(service.withdraw('offer-x', 'alice')).rejects.toThrow(NotFoundException);
});
it('withdraw блокируется на active orders (sentinel test: stub возвращает false в MVP)', async () => {
// В MVP `hasActiveOrders` всегда false (Эпик 4 не смержен). Этот кейс
// фиксирует контракт: 409 Conflict ожидается *когда* реализация перепишется.
// Здесь просто проверяем, что в MVP withdraw проходит.
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE' }));
repo.applyUpdate.mockResolvedValue(makeOffer({ status: 'WITHDRAWN' }));
const service = new MarketplaceOfferService(repo, cats);
await expect(service.withdraw('offer-1', 'alice')).resolves.toBeDefined();
// ConflictException заводится при `hasActiveOrders === true` — будет
// покрыт интеграционным тестом после merge Story 4.x.
expect(ConflictException).toBeDefined();
});
});
describe('MarketplaceOfferService.listMine + getById', () => {
it('listMine делегирует в репозиторий с фильтром supplier_account', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.list.mockResolvedValue({
items: [makeOffer()],
totalCount: 1,
totalPages: 1,
currentPage: 1,
});
const service = new MarketplaceOfferService(repo, cats);
const result = await service.listMine(COOP, 'alice', {
page: 1,
limit: 50,
sortBy: 'created_at',
sortOrder: 'DESC',
});
expect(repo.list).toHaveBeenCalledWith(
{ coopname: COOP, supplier_account: 'alice' },
{ page: 1, limit: 50, sortBy: 'created_at', sortOrder: 'DESC' }
);
expect(result.totalCount).toBe(1);
expect(result.totalPages).toBe(1);
expect(result.currentPage).toBe(1);
});
it('getById → findById', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(makeOffer());
const service = new MarketplaceOfferService(repo, cats);
const result = await service.getById('offer-1');
expect(result?.id).toBe('offer-1');
});
});
@@ -0,0 +1,60 @@
/**
* Unit-тесты MarketplaceVitrineService (Story 3.1).
*
* Покрывают AC:
* - getDefault возвращает запись, если есть; null если нет;
* - list возвращает все витрины кооператива (в MVP всегда одна).
*/
import { MarketplaceVitrineService } from '~/extensions/marketplace/application/services/marketplace-vitrine.service';
import { MarketplaceVitrineDomainEntity } from '~/extensions/marketplace/domain/entities/marketplace-vitrine.entity';
import type { MarketplaceVitrineDomainRepository } from '~/extensions/marketplace/domain/repositories/marketplace-vitrine.repository';
const COOP = 'voskhod';
function makeVitrine(id: string, isDefault: boolean): MarketplaceVitrineDomainEntity {
return new MarketplaceVitrineDomainEntity({
id,
coopname: COOP,
display_name: 'Стол заказов',
is_default: isDefault,
created_at: new Date('2026-05-15T12:00:00Z'),
updated_at: new Date('2026-05-15T12:00:00Z'),
});
}
function makeRepo(): jest.Mocked<MarketplaceVitrineDomainRepository> {
return {
findDefault: jest.fn(),
list: jest.fn(),
ensureDefault: jest.fn(),
};
}
describe('MarketplaceVitrineService', () => {
it('getDefault возвращает запись из репозитория', async () => {
const repo = makeRepo();
repo.findDefault.mockResolvedValue(makeVitrine('default', true));
const service = new MarketplaceVitrineService(repo);
const result = await service.getDefault(COOP);
expect(result?.id).toBe('default');
expect(result?.is_default).toBe(true);
});
it('getDefault возвращает null если витрины нет', async () => {
const repo = makeRepo();
repo.findDefault.mockResolvedValue(null);
const service = new MarketplaceVitrineService(repo);
await expect(service.getDefault(COOP)).resolves.toBeNull();
});
it('list возвращает все витрины кооператива', async () => {
const repo = makeRepo();
repo.list.mockResolvedValue([makeVitrine('default', true)]);
const service = new MarketplaceVitrineService(repo);
const result = await service.list(COOP);
expect(result).toHaveLength(1);
});
});
@@ -0,0 +1,161 @@
/**
* Unit-тесты MarketplaceWhitelistService (Story 3.1).
*
* Покрывают AC:
* - listWhitelist возвращает все записи (auto-coop + manual);
* - addToWhitelist создаёт role='manual', проставляет added_by;
* - removeFromWhitelist удаляет manual-запись и инвалидирует кеш;
* - removeFromWhitelist на auto-coop → 403 Forbidden (FR5);
* - removeFromWhitelist на отсутствующего → 404 NotFound;
* - isOfferer семантика «открытая витрина» (whitelist только auto-coop)
* → true для всех;
* - isOfferer семантика «по whitelist» → true только для записанных;
* - isOfferer cache hit без обращения к репозиторию.
*/
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { MarketplaceWhitelistService } from '~/extensions/marketplace/application/services/marketplace-whitelist.service';
import { MarketplaceWhitelistEntryDomainEntity } from '~/extensions/marketplace/domain/entities/marketplace-whitelist-entry.entity';
import type { MarketplaceWhitelistDomainRepository } from '~/extensions/marketplace/domain/repositories/marketplace-whitelist.repository';
const COOP = 'voskhod';
function makeEntry(member: string, role: 'auto-coop' | 'manual'): MarketplaceWhitelistEntryDomainEntity {
return new MarketplaceWhitelistEntryDomainEntity({
id: `id-${member}`,
coopname: COOP,
member_account: member,
role,
added_by: role === 'auto-coop' ? null : 'admin',
added_at: new Date('2026-05-15T12:00:00Z'),
});
}
function makeRepo(): jest.Mocked<MarketplaceWhitelistDomainRepository> {
return {
list: jest.fn(),
findByMember: jest.fn(),
add: jest.fn(),
remove: jest.fn(),
countManual: jest.fn(),
};
}
describe('MarketplaceWhitelistService', () => {
it('list возвращает все записи', async () => {
const repo = makeRepo();
repo.list.mockResolvedValue([makeEntry(COOP, 'auto-coop'), makeEntry('alice', 'manual')]);
const service = new MarketplaceWhitelistService(repo);
const result = await service.list(COOP);
expect(result).toHaveLength(2);
expect(result.map((e) => e.member_account)).toEqual([COOP, 'alice']);
});
it('addToWhitelist создаёт manual-запись с added_by', async () => {
const repo = makeRepo();
repo.add.mockResolvedValue(makeEntry('bob', 'manual'));
const service = new MarketplaceWhitelistService(repo);
const entry = await service.addToWhitelist(COOP, 'bob', 'admin');
expect(entry.member_account).toBe('bob');
expect(entry.role).toBe('manual');
expect(repo.add).toHaveBeenCalledWith(COOP, 'bob', 'manual', 'admin');
});
it('removeFromWhitelist удаляет manual-запись', async () => {
const repo = makeRepo();
repo.findByMember.mockResolvedValue(makeEntry('bob', 'manual'));
const service = new MarketplaceWhitelistService(repo);
await service.removeFromWhitelist(COOP, 'bob');
expect(repo.remove).toHaveBeenCalledWith(COOP, 'bob');
});
it('removeFromWhitelist на auto-coop → 403 (FR5)', async () => {
const repo = makeRepo();
repo.findByMember.mockResolvedValue(makeEntry(COOP, 'auto-coop'));
const service = new MarketplaceWhitelistService(repo);
await expect(service.removeFromWhitelist(COOP, COOP)).rejects.toThrow(ForbiddenException);
expect(repo.remove).not.toHaveBeenCalled();
});
it('removeFromWhitelist на отсутствующего → 404 NotFound', async () => {
const repo = makeRepo();
repo.findByMember.mockResolvedValue(null);
const service = new MarketplaceWhitelistService(repo);
await expect(service.removeFromWhitelist(COOP, 'ghost')).rejects.toThrow(NotFoundException);
});
it('isOfferer: открытая витрина (только auto-coop) → true для всех', async () => {
const repo = makeRepo();
repo.countManual.mockResolvedValue(0);
const service = new MarketplaceWhitelistService(repo);
await expect(service.isOfferer(COOP, 'random_user')).resolves.toBe(true);
expect(repo.findByMember).not.toHaveBeenCalled();
});
it('isOfferer: whitelist непустой, пайщик внутри → true', async () => {
const repo = makeRepo();
repo.countManual.mockResolvedValue(2);
repo.findByMember.mockResolvedValue(makeEntry('alice', 'manual'));
const service = new MarketplaceWhitelistService(repo);
await expect(service.isOfferer(COOP, 'alice')).resolves.toBe(true);
});
it('isOfferer: whitelist непустой, пайщик снаружи → false', async () => {
const repo = makeRepo();
repo.countManual.mockResolvedValue(2);
repo.findByMember.mockResolvedValue(null);
const service = new MarketplaceWhitelistService(repo);
await expect(service.isOfferer(COOP, 'random_user')).resolves.toBe(false);
});
it('isOfferer: второй вызов того же пайщика — cache hit, repo не дёргается', async () => {
const repo = makeRepo();
repo.countManual.mockResolvedValue(0);
const service = new MarketplaceWhitelistService(repo);
await service.isOfferer(COOP, 'alice');
await service.isOfferer(COOP, 'alice');
expect(repo.countManual).toHaveBeenCalledTimes(1);
});
it('addToWhitelist инвалидирует кеш isOfferer', async () => {
const repo = makeRepo();
repo.countManual.mockResolvedValueOnce(0);
repo.add.mockResolvedValue(makeEntry('alice', 'manual'));
repo.countManual.mockResolvedValueOnce(1);
repo.findByMember.mockResolvedValue(null);
const service = new MarketplaceWhitelistService(repo);
// Прогрев кеша: открытая витрина → true
await expect(service.isOfferer(COOP, 'random_user')).resolves.toBe(true);
// Добавление manual-записи (alice) → инвалидирует кеш
await service.addToWhitelist(COOP, 'alice', 'admin');
// После инвалидации семантика «по whitelist» → false для random_user
await expect(service.isOfferer(COOP, 'random_user')).resolves.toBe(false);
});
it('removeFromWhitelist инвалидирует кеш isOfferer', async () => {
const repo = makeRepo();
// Прогрев isOfferer: countManual=1 + findByMember(random_user)=null → false.
// Затем remove('alice'): findByMember(alice)=manual-entry, repo.remove() ok.
// Второй isOfferer: countManual=0 → семантика «открытая витрина» → true,
// findByMember не дёргается (короткое замыкание).
repo.countManual.mockResolvedValueOnce(1).mockResolvedValueOnce(0);
repo.findByMember
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(makeEntry('alice', 'manual'));
const service = new MarketplaceWhitelistService(repo);
await expect(service.isOfferer(COOP, 'random_user')).resolves.toBe(false);
await service.removeFromWhitelist(COOP, 'alice');
await expect(service.isOfferer(COOP, 'random_user')).resolves.toBe(true);
});
});
@@ -1,5 +1,6 @@
import { markRaw } from 'vue'
import { ShowcasePage } from 'src/pages/Marketplace/Showcase'
import { MarketplaceCatalogPage } from 'src/pages/Marketplace/MarketplaceCatalog'
import { CreateParentOfferPage } from 'src/pages/Marketplace/CreateParentOffer'
import { UserParentOffersPage } from 'src/pages/Marketplace/UserParentOffers'
import { UserSuppliesListPage } from 'src/pages/Marketplace/UserSuppliesList'
@@ -18,7 +19,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
extension_name: 'market',
title: 'Стол заказов',
icon: 'fa-solid fa-shop',
defaultRoute: 'marketplace-showcase',
defaultRoute: 'marketplace-catalog',
routes: [
{
meta: {
@@ -30,16 +31,29 @@ export default async function (): Promise<IWorkspaceConfig[]> {
name: 'market',
children: [
{
path: 'showcase',
name: 'marketplace-showcase',
component: markRaw(ShowcasePage),
path: 'catalog',
name: 'marketplace-catalog',
component: markRaw(MarketplaceCatalogPage),
meta: {
title: 'Витрина',
title: 'Каталог',
icon: 'fa-solid fa-store',
roles: [],
requiresAuth: true,
agreements: agreementsBase,
},
},
{
path: 'showcase',
name: 'marketplace-showcase',
component: markRaw(ShowcasePage),
meta: {
title: 'Витрина (legacy donor — переписывается в Phase 2)',
icon: 'fa-solid fa-store',
roles: [],
requiresAuth: true,
agreements: agreementsBase,
hidden: true,
},
children: [
{
path: ':id',
@@ -0,0 +1,107 @@
import { sendPOST } from 'src/shared/api/axios';
import type {
CatalogSort,
MarketplaceCategoryOfferCount,
MarketplaceCategoryView,
MarketplaceOfferPage,
} from '../types';
/**
* Story 3.5: raw GraphQL к marketplace-backend.
*
* Техдолг: после `pnpm cooptypes:gen-zeus` (регенерация Zeus в
* `@coopenomics/sdk`) переписать на типизированные
* `Queries.Marketplace.ListCatalog` / `CategoryOfferCounts` /
* `ListCategories` — см. паттерн `entities/MarketplaceKUDetails` после
* 2-го коммита refactor в PR #381.
*
* До тех пор используем `sendPOST('/v1/graphql', { query, variables })`,
* без типов schema.gql на клиенте.
*/
const LIST_CATALOG_QUERY = `
query MarketplaceListCatalog($input: MarketplaceListCatalogInput) {
marketplaceListCatalog(input: $input) {
total
items {
id
coopname
supplier_account
vitrine_id
product_name
description
category_id
price_per_unit
unit_of_measure
quantity_available
quantity_blocked
quantity_consumed
unlimited_flag
cycle_type
cycle_days
target_volume
max_wait_days
min_threshold
warranty_days
status
created_at
updated_at
}
}
}
`;
const LIST_CATEGORIES_QUERY = `
query MarketplaceListCategories {
marketplaceListCategories {
id
display_name
sort_order
}
}
`;
const CATEGORY_COUNTS_QUERY = `
query MarketplaceCategoryOfferCounts {
marketplaceCategoryOfferCounts {
category_id
count
}
}
`;
export interface ListCatalogVariables {
category_id?: number | null;
limit?: number;
offset?: number;
sort?: CatalogSort;
}
export async function fetchCatalog(
variables: ListCatalogVariables
): Promise<MarketplaceOfferPage> {
const body = await sendPOST('/v1/graphql', {
query: LIST_CATALOG_QUERY,
variables: { input: variables },
});
if (body?.errors?.length) {
throw new Error(body.errors[0].message);
}
return body.data.marketplaceListCatalog;
}
export async function fetchCategories(): Promise<MarketplaceCategoryView[]> {
const body = await sendPOST('/v1/graphql', { query: LIST_CATEGORIES_QUERY });
if (body?.errors?.length) {
throw new Error(body.errors[0].message);
}
return body.data.marketplaceListCategories;
}
export async function fetchCategoryOfferCounts(): Promise<MarketplaceCategoryOfferCount[]> {
const body = await sendPOST('/v1/graphql', { query: CATEGORY_COUNTS_QUERY });
if (body?.errors?.length) {
throw new Error(body.errors[0].message);
}
return body.data.marketplaceCategoryOfferCounts;
}
@@ -0,0 +1,2 @@
export * from './ui';
export * from './types';
@@ -0,0 +1,54 @@
/**
* Story 3.5: типы каталога Стола заказов.
* Источник истины — backend `MarketplaceOfferDTO` / `MarketplaceCategoryDTO`.
* Ручная типизация — техдолг до регенерации Zeus в `@coopenomics/sdk`
* (см. PR #381 marketplace KU details — там же временно raw GraphQL,
* пока Zeus не подтянул новые типы).
*/
export interface MarketplaceOfferView {
id: string;
coopname: string;
supplier_account: string;
vitrine_id: string;
product_name: string;
description: string | null;
category_id: number;
price_per_unit: string;
unit_of_measure: 'piece' | 'kg' | 'liter' | 'pack';
quantity_available: number;
quantity_blocked: number;
quantity_consumed: number;
unlimited_flag: boolean;
cycle_type: 'time_based' | 'volume_based' | 'open_subscription' | 'individual';
cycle_days: number | null;
target_volume: number | null;
max_wait_days: number | null;
min_threshold: number | null;
warranty_days: number;
status: 'PENDING_MODERATION' | 'ACTIVE' | 'REJECTED' | 'WITHDRAWN';
created_at: string;
updated_at: string;
}
export interface MarketplaceOfferPage {
total: number;
items: MarketplaceOfferView[];
}
export interface MarketplaceCategoryView {
id: number;
display_name: string;
sort_order: number;
}
export interface MarketplaceCategoryOfferCount {
category_id: number;
count: number;
}
export type CatalogSort = 'created_at_desc' | 'price_asc' | 'price_desc';
export interface CatalogFilter {
category_id: number | null;
sort: CatalogSort;
}
@@ -0,0 +1,100 @@
<script lang="ts" setup>
import { computed } from 'vue';
import type { MarketplaceOfferView } from '../types';
const props = defineProps<{ offer: MarketplaceOfferView }>();
const emit = defineEmits<{ (e: 'select', offer: MarketplaceOfferView): void }>();
const unitLabel = computed(() => {
switch (props.offer.unit_of_measure) {
case 'piece':
return 'шт';
case 'kg':
return 'кг';
case 'liter':
return 'л';
case 'pack':
return 'упак';
default:
return props.offer.unit_of_measure;
}
});
const quantityLabel = computed(() => {
if (props.offer.unlimited_flag) return 'Без ограничений';
return `Доступно: ${props.offer.quantity_available} ${unitLabel.value}`;
});
const cycleLabel = computed(() => {
switch (props.offer.cycle_type) {
case 'time_based':
return props.offer.cycle_days
? `Циклически (раз в ${props.offer.cycle_days} дн.)`
: 'Циклически';
case 'volume_based': {
const target = props.offer.target_volume ?? 0;
const accumulated = props.offer.quantity_blocked;
const waitDays = props.offer.max_wait_days
? ` либо через ${props.offer.max_wait_days} дн.`
: '';
return `По набору объёма (${accumulated} / ${target}${waitDays})`;
}
case 'open_subscription':
return props.offer.cycle_days
? `Подписка (отмена через ${props.offer.cycle_days} дн.)`
: 'Подписка';
case 'individual':
return 'Индивидуально, без отсечки';
default:
return props.offer.cycle_type;
}
});
const warrantyLabel = computed(() =>
props.offer.warranty_days > 0 ? `Гарантия: ${props.offer.warranty_days} дн.` : null
);
const priceLabel = computed(() => {
const price = Number(props.offer.price_per_unit);
return `${price.toLocaleString('ru-RU', { maximumFractionDigits: 2 })} ₽ / ${unitLabel.value}`;
});
</script>
<template lang="pug">
q-card.catalog-offer-card.q-pa-md(
:aria-label="`Оффер ${offer.product_name}`",
flat,
bordered
)
div.row.items-center.q-mb-sm
q-chip(square, dense, color="primary", text-color="white") {{ offer.category_id }}
q-space
q-chip.text-caption(v-if="warrantyLabel", outline, dense) {{ warrantyLabel }}
div.text-h6.q-mb-xs.ellipsis-2-lines {{ offer.product_name }}
div.text-caption.text-grey-7.q-mb-sm Поставщик: {{ offer.supplier_account }}
div.text-body1.text-weight-medium {{ priceLabel }}
div.text-caption.q-mb-xs {{ quantityLabel }}
div.text-caption.text-grey-8 {{ cycleLabel }}
q-card-actions(align="right")
q-btn(
:label="'Заказать'",
color="primary",
unelevated,
:disable="!offer.unlimited_flag && offer.quantity_available <= 0",
@click="emit('select', offer)"
)
</template>
<style scoped>
.catalog-offer-card {
height: 100%;
display: flex;
flex-direction: column;
}
.ellipsis-2-lines {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
@@ -0,0 +1,192 @@
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue';
import { Notify } from 'quasar';
import {
fetchCatalog,
fetchCategories,
fetchCategoryOfferCounts,
} from '../api';
import type {
CatalogSort,
MarketplaceCategoryOfferCount,
MarketplaceCategoryView,
MarketplaceOfferView,
} from '../types';
import CatalogOfferCard from './CatalogOfferCard.vue';
/**
* Story 3.5: каталог Стола заказов на orderer-столе.
*
* AC:
* - grid карточек (UX-DR10) с пагинацией 24/страницу (infinite-load
* через `q-infinite-scroll`);
* - фильтр-чипы по 10 baseline-категориям с counter'ами (single-select
* в MVP, «Все» — снимает фильтр);
* - сортировки created_at_desc (default) / price_asc / price_desc;
* - responsive: mobile 1 колонка, desktop 3-4 (UX-DR22, DR31);
* - EmptyState при пустом каталоге / пустом фильтре;
* - PENDING_MODERATION / REJECTED / WITHDRAWN не показываются (backend
* фильтрует status=ACTIVE + (unlimited OR available>0)).
*
* Order-форма (Эпик 4 Story 4.1) пока stub: alert(); подменится на
* `router.push({name:'marketplace-create-order', params:{offer_id}})`.
*/
const ALL_KEY = -1 as number;
const PAGE_SIZE = 24;
const categories = ref<MarketplaceCategoryView[]>([]);
const counts = ref<Map<number, number>>(new Map());
const items = ref<MarketplaceOfferView[]>([]);
const total = ref(0);
const loading = ref(false);
const selectedCategoryId = ref<number>(ALL_KEY);
const sort = ref<CatalogSort>('created_at_desc');
const offset = ref(0);
const hasMore = computed(() => items.value.length < total.value);
async function loadCategories(): Promise<void> {
const [cats, cc] = await Promise.all([fetchCategories(), fetchCategoryOfferCounts()]);
categories.value = cats;
const map = new Map<number, number>();
for (const c of cc as MarketplaceCategoryOfferCount[]) map.set(c.category_id, c.count);
counts.value = map;
}
async function loadPage(append: boolean): Promise<void> {
loading.value = true;
try {
const page = await fetchCatalog({
category_id: selectedCategoryId.value === ALL_KEY ? null : selectedCategoryId.value,
limit: PAGE_SIZE,
offset: offset.value,
sort: sort.value,
});
total.value = page.total;
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;
}
}
function selectCategory(id: number): void {
selectedCategoryId.value = id;
offset.value = 0;
void loadPage(false);
}
function changeSort(newSort: CatalogSort): void {
sort.value = newSort;
offset.value = 0;
void loadPage(false);
}
async function onLoadMore(): Promise<void> {
if (!hasMore.value || loading.value) return;
offset.value = items.value.length;
await loadPage(true);
}
function onSelectOffer(offer: MarketplaceOfferView): void {
// Story 4.1 Order-форма; пока — заглушка
Notify.create({
type: 'info',
message: `Создание заказа на Offer ${offer.id} — будет доступно после Эпика 4`,
});
}
const totalActiveCount = computed(() =>
Array.from(counts.value.values()).reduce((acc, v) => acc + v, 0)
);
const sortOptions: Array<{ label: string; value: CatalogSort }> = [
{ label: 'Свежие сначала', value: 'created_at_desc' },
{ label: 'Цена ↑', value: 'price_asc' },
{ label: 'Цена ↓', value: 'price_desc' },
];
watch(
() => sort.value,
() => void 0
);
onMounted(async () => {
await loadCategories();
await loadPage(false);
});
</script>
<template lang="pug">
q-page.q-pa-md(role="region", aria-label="Каталог Стола заказов")
div.row.items-center.q-mb-md
div.text-h5 Каталог
q-space
q-select.col-auto(
v-model="sort",
:options="sortOptions",
option-value="value",
option-label="label",
emit-value,
map-options,
dense,
outlined,
style="min-width: 180px",
label="Сортировка",
@update:model-value="changeSort(sort)"
)
div.row.q-gutter-sm.q-mb-md.scroll-x(role="tablist", aria-label="Фильтр по категориям")
q-chip(
:selected="selectedCategoryId === ALL_KEY",
clickable,
color="grey-3",
text-color="dark",
:selected-color="'primary'",
:aria-selected="selectedCategoryId === ALL_KEY",
@click="selectCategory(ALL_KEY)"
)
span Все
q-badge.q-ml-xs(color="grey-7") {{ totalActiveCount }}
q-chip(
v-for="cat in categories",
:key="cat.id",
:selected="selectedCategoryId === cat.id",
clickable,
color="grey-3",
text-color="dark",
:selected-color="'primary'",
:aria-selected="selectedCategoryId === cat.id",
@click="selectCategory(cat.id)"
)
span {{ cat.display_name }}
q-badge.q-ml-xs(color="grey-7") {{ counts.get(cat.id) ?? 0 }}
q-inner-loading(:showing="loading && items.length === 0")
q-spinner(color="primary", size="2em")
div(v-if="!loading && items.length === 0").text-center.q-pa-xl.text-grey-7
q-icon(name="fa-regular fa-circle-question", size="3em").q-mb-sm
div.text-subtitle1 Ничего не найдено
div.text-caption
template(v-if="selectedCategoryId !== ALL_KEY") Попробуйте сменить категорию
template(v-else) В каталоге пока нет активных оферт
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="o", @select="onSelectOffer")
template(v-slot:loading)
div.row.justify-center.q-my-md
q-spinner(color="primary", size="2em")
</template>
<style scoped>
.scroll-x {
overflow-x: auto;
flex-wrap: nowrap;
}
</style>
@@ -0,0 +1 @@
export { default as MarketplaceCatalogPage } from './MarketplaceCatalogPage.vue';