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:
@@ -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();
|
||||
|
||||
+5
-3
@@ -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'],
|
||||
},
|
||||
|
||||
+24
@@ -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;
|
||||
}
|
||||
}
|
||||
+47
@@ -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);
|
||||
}
|
||||
}
|
||||
+177
@@ -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 {}
|
||||
+77
@@ -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;
|
||||
}
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
}
|
||||
+41
@@ -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;
|
||||
}
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
+18
-5
@@ -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,
|
||||
|
||||
+85
@@ -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 {}
|
||||
|
||||
+124
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
+148
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
+192
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+51
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+94
@@ -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;
|
||||
}
|
||||
}
|
||||
+24
@@ -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();
|
||||
}
|
||||
}
|
||||
+150
@@ -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}»`;
|
||||
}
|
||||
}
|
||||
}
|
||||
+147
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+291
@@ -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;
|
||||
}
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
}
|
||||
+106
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -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 },
|
||||
];
|
||||
+25
@@ -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);
|
||||
}
|
||||
}
|
||||
+87
@@ -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);
|
||||
}
|
||||
}
|
||||
+42
@@ -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',
|
||||
];
|
||||
+27
@@ -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;
|
||||
}
|
||||
}
|
||||
+29
@@ -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;
|
||||
}
|
||||
}
|
||||
+9
@@ -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>;
|
||||
}
|
||||
+16
@@ -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[]>;
|
||||
}
|
||||
+140
@@ -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>;
|
||||
}
|
||||
+12
@@ -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>;
|
||||
}
|
||||
+22
@@ -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>;
|
||||
}
|
||||
+43
@@ -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']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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));
|
||||
}
|
||||
}
|
||||
+255
@@ -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) };
|
||||
}
|
||||
}
|
||||
+46
@@ -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);
|
||||
}
|
||||
}
|
||||
+62
@@ -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' } });
|
||||
}
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
+103
@@ -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;
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
+40
@@ -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;
|
||||
}
|
||||
+15
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+17
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+38
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+17
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+17
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+65
@@ -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 {}
|
||||
|
||||
+73
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
+40
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
+110
@@ -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');
|
||||
});
|
||||
});
|
||||
+241
@@ -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>
|
||||
+192
@@ -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';
|
||||
Reference in New Issue
Block a user