[598-8][@ant] feat: добавить cycle-type-aware агрегацию Order'ов в консолидированную заявку — закрыть Story 4.2 Эпика 4

Backend-only (Locked Decision L10): on-chain представления заявки НЕТ;
агрегация целиком в PG. Order'ы привязываются через
marketplace_order.cycle_id.

Поведение per cycle_type (Locked Decision L11):

- time_based: cron `@Cron(EVERY_5_MINUTES)` сканирует ACTIVE Offer'ы
  с cycle_type='time_based' + cycle_days; для каждого находит cycle_start
  как min(blocked_at) среди unassigned active Order'ов; если now >=
  cycle_start + cycle_days решает:
    sum >= min_threshold (или null) → consolidated_request status=
    'PENDING_SUPPLIER_ACCEPT' + expires_at = now + 48ч + assignToCycle
    Orders → 'ACCEPTED_PENDING_SUPPLIER';
    sum < min_threshold → consolidated_request status='EXPIRED_NO_THRESHOLD'
    БЕЗ assignToCycle (Story 4.3 unblk per-Order'ам пула).

- volume_based: метод `evaluateVolumeBasedAfterCreate` вызывается
  СИНХРОННО из `MarketplaceOrderCreateService.applyCycleTypeHook` после
  persist нового Order'а; если sum >= target_volume → instant
  consolidated_request status='PENDING_SUPPLIER_ACCEPT'. Cron-fallback
  `aggregateVolumeBasedExpired` (EVERY_HOUR) — если объём не накоплен
  и now > cycle_start + max_wait_days → consolidated_request status=
  'EXPIRED_NO_VOLUME' (Story 4.3 unblk).

- open_subscription: backend НЕ создаёт автоматически. Mutation
  `marketplaceTriggerOpenSubscription` (Offer:update:own); поставщик
  жмёт «Запустить» → consolidated_request status='ACCEPTED' сразу,
  Orders пула → 'ACCEPTED' (нажатие = акцепт всего пула).

- individual: backend НЕ агрегирует. `applyCycleTypeHook` в
  OrderCreateService делает Order.status: ACTIVE →
  'ACCEPTED_PENDING_SUPPLIER_INDIVIDUAL' сразу после persist.

Backend (controller/extensions/marketplace):
- domain/entities/marketplace-consolidated-request.{types,entity}.ts +
  spec (4 сценария: PENDING/ACCEPTED/terminal-таблица/open_subscription
  triggered).
- domain/repositories/marketplace-consolidated-request.repository.ts +
  infrastructure (TypeORM entity + mapper + adapter; индексы под
  offerer-стол / pre-aggregator idempotency / cron-scan).
- application/services/marketplace-cycle-aggregator.service.ts —
  @Cron(EVERY_5_MINUTES) aggregateTimeBased + @Cron(EVERY_HOUR)
  aggregateVolumeBasedExpired + evaluateVolumeBasedAfterCreate +
  triggerOpenSubscription. ScheduleModule.forRoot() добавлен в
  MarketplaceExtensionApplicationModule imports.
- application/resolvers/marketplace-cycle.resolver.ts — Mutation
  marketplaceTriggerOpenSubscription + Query marketplaceListConsolidatedRequests
  (Offer:update:own / Offer:read access).
- Расширены MarketplaceOrderDomainRepository (findUnassignedActiveByOffer
  / assignToCycle / sumUnassignedActiveByOffer) и
  MarketplaceOfferDomainRepository (listAllActiveTimeBased /
  listAllActiveVolumeBased).
- MarketplaceOrderCreateService.applyCycleTypeHook — per-cycle_type
  backend hook сразу после persist Order'а; для individual instant
  transition status, для volume_based — instant аггрегация при пороге.

Tests (10 сценариев):
- consolidated-request.entity.spec: PENDING/ACCEPTED/4 terminal/
  open_subscription triggered.
- cycle-aggregator.spec: aggregateTimeBased (3: достигнут + sum >= threshold;
  достигнут + sum < threshold; не достигнут), evaluateVolumeBasedAfterCreate
  (2: ниже target / достигнут), triggerOpenSubscription (4: happy /
  ForbiddenException не владелец / BadRequestException неправильный
  cycle_type / BadRequestException пустой пул).
- OrderCreateService spec обновлён под новый constructor signature
  (cycleAggregator dependency).

Не входит в Story 4.2 (отложено):
- UI для offerer-стола «Консолидированные заявки» (Story 4.5 supplier
  accept/decline UI) — там же добавится списочный widget на канон
  дизайн-системы.
- Mutation marketplaceAcceptConsolidatedRequest / DeclineConsolidatedRequest —
  Story 4.5.
- Auto-decline по expires_at (PENDING > 48ч) — Story 4.3 cron-cleanup.
- Persist полей `acceptance_window_hours` в config кооператива (сейчас
  захардкожено 48ч) — Story 9.x.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
coopops
2026-05-15 12:16:08 +00:00
parent 2a87c12d92
commit 0ba2d51214
20 changed files with 1514 additions and 3 deletions
@@ -0,0 +1,43 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { createPaginationResult } from '~/application/common/dto/pagination.dto';
@ObjectType('MarketplaceConsolidatedRequest')
export class MarketplaceConsolidatedRequestDTO {
@Field(() => String) public readonly id!: string;
@Field(() => String) public readonly coopname!: string;
@Field(() => String) public readonly offer_id!: string;
@Field(() => String) public readonly supplier_account!: string;
@Field(() => String, { description: 'time_based | volume_based | open_subscription | individual' })
public readonly cycle_type!: string;
@Field(() => Int) public readonly total_quantity!: number;
@Field(() => String, { description: 'Сумма заявки (numeric как string).' })
public readonly total_amount!: string;
@Field(() => String, {
description:
'PENDING_SUPPLIER_ACCEPT | ACCEPTED | DECLINED_BY_SUPPLIER | EXPIRED_NO_RESPONSE | EXPIRED_NO_THRESHOLD | EXPIRED_NO_VOLUME',
})
public readonly status!: string;
@Field(() => Date) public readonly cycle_started_at!: Date;
@Field(() => Date, { nullable: true }) public readonly cycle_ended_at!: Date | null;
@Field(() => Date, { nullable: true }) public readonly expires_at!: Date | null;
@Field(() => Date, { nullable: true }) public readonly accepted_at!: Date | null;
@Field(() => Date, { nullable: true }) public readonly declined_at!: Date | null;
@Field(() => String, { nullable: true }) public readonly decline_reason!: string | null;
@Field(() => Date, { nullable: true }) public readonly triggered_by_supplier_at!: Date | null;
@Field(() => Date) public readonly created_at!: Date;
@Field(() => Date) public readonly updated_at!: Date;
constructor(init: Partial<MarketplaceConsolidatedRequestDTO>) {
Object.assign(this, init);
}
}
@ObjectType('MarketplaceConsolidatedRequestPaginationResult')
export class MarketplaceConsolidatedRequestPaginationResultDTO extends createPaginationResult(
MarketplaceConsolidatedRequestDTO,
'MarketplaceConsolidatedRequest'
) {}
@@ -0,0 +1,25 @@
import { Field, InputType, Int } from '@nestjs/graphql';
@InputType('MarketplaceTriggerOpenSubscriptionInput')
export class MarketplaceTriggerOpenSubscriptionInputDTO {
@Field(() => String, { description: 'UUID Offer\'а с cycle_type=open_subscription.' })
public readonly offer_id!: string;
}
@InputType('MarketplaceListConsolidatedRequestsInput')
export class MarketplaceListConsolidatedRequestsInputDTO {
@Field(() => String, { nullable: true, description: 'UUID Offer\'а для фильтра.' })
public readonly offer_id?: string;
@Field(() => String, { nullable: true, description: 'Один из supplier-статусов (см. MarketplaceConsolidatedRequest.status).' })
public readonly status?: string;
@Field(() => Int, { defaultValue: 1 })
public readonly page!: number;
@Field(() => Int, { defaultValue: 50 })
public readonly limit!: number;
@Field(() => String, { defaultValue: 'DESC' })
public readonly sortOrder!: 'ASC' | 'DESC';
}
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { MarketplaceExtensionDomainModule } from '../domain/marketplace-domain.module';
import { CategoryTreeResolver } from './resolvers/category-tree.resolver';
import { AttributeResolver } from './resolvers/attribute.resolver';
@@ -16,6 +17,7 @@ import { MarketplaceOfferResolver } from './resolvers/marketplace-offer.resolver
import { MarketplaceModerationResolver } from './resolvers/marketplace-moderation.resolver';
import { MarketplaceCatalogResolver } from './resolvers/marketplace-catalog.resolver';
import { MarketplaceOrderResolver } from './resolvers/marketplace-order.resolver';
import { MarketplaceCycleResolver } from './resolvers/marketplace-cycle.resolver';
import { MarketplaceMembershipGuard } from './guards/marketplace-membership.guard';
import { MarketplaceRoleGuard } from './guards/marketplace-role.guard';
import { MarketplaceOnboardingService } from './onboarding/marketplace-onboarding.service';
@@ -51,13 +53,24 @@ import {
MARKETPLACE_ORDER_CREATE_SERVICE,
} from './services/marketplace-order-create.service';
import { MarketplaceOrderSyncService } from '../sync/marketplace-order-sync.service';
import {
MarketplaceCycleAggregatorService,
MARKETPLACE_CYCLE_AGGREGATOR_SERVICE,
} from './services/marketplace-cycle-aggregator.service';
/**
* Модуль приложения marketplace
* Содержит GraphQL резолверы и сервисы приложения
*/
@Module({
imports: [MarketplaceExtensionDomainModule],
imports: [
MarketplaceExtensionDomainModule,
// Story 4.2: @Cron в MarketplaceCycleAggregatorService (time_based aggregator
// каждые 5 минут + volume_based expire каждый час). ScheduleModule.forRoot()
// идемпотентен — если AppModule тоже инициализирует его, NestJS использует
// singleton SchedulerRegistry.
ScheduleModule.forRoot(),
],
providers: [
// GraphQL резолверы
CategoryTreeResolver,
@@ -76,6 +89,7 @@ import { MarketplaceOrderSyncService } from '../sync/marketplace-order-sync.serv
MarketplaceModerationResolver,
MarketplaceCatalogResolver,
MarketplaceOrderResolver,
MarketplaceCycleResolver,
// Guards (Story 1.3 / Story 1.6)
MarketplaceMembershipGuard,
@@ -131,6 +145,12 @@ import { MarketplaceOrderSyncService } from '../sync/marketplace-order-sync.serv
},
MarketplaceOrderCreateService,
MarketplaceOrderSyncService,
// Story 4.2
{
provide: MARKETPLACE_CYCLE_AGGREGATOR_SERVICE,
useClass: MarketplaceCycleAggregatorService,
},
MarketplaceCycleAggregatorService,
],
exports: [
// Экспортируем сервисы для использования в других модулях
@@ -170,11 +190,15 @@ import { MarketplaceOrderSyncService } from '../sync/marketplace-order-sync.serv
MarketplaceModerationResolver,
MarketplaceCatalogResolver,
MarketplaceOrderResolver,
MarketplaceCycleResolver,
// Экспортируем сервисы Story 4.1 для использования в follow-up Stories Эпика 4
MARKETPLACE_ORDER_CREATE_SERVICE,
MarketplaceOrderCreateService,
MarketplaceOrderSyncService,
// Story 4.2
MARKETPLACE_CYCLE_AGGREGATOR_SERVICE,
MarketplaceCycleAggregatorService,
],
})
export class MarketplaceExtensionApplicationModule {}
@@ -0,0 +1,126 @@
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 { MarketplaceMembershipGuard } from '../guards/marketplace-membership.guard';
import { MarketplaceRoleGuard } from '../guards/marketplace-role.guard';
import type { IMarketplaceCurrentMember } from '../dto/marketplace-current-member.dto';
import {
MarketplaceConsolidatedRequestDTO,
MarketplaceConsolidatedRequestPaginationResultDTO,
} from '../dto/marketplace-consolidated-request.dto';
import {
MarketplaceListConsolidatedRequestsInputDTO,
MarketplaceTriggerOpenSubscriptionInputDTO,
} from '../dto/marketplace-trigger-open-subscription-input.dto';
import {
MARKETPLACE_CYCLE_AGGREGATOR_SERVICE,
MarketplaceCycleAggregatorService,
} from '../services/marketplace-cycle-aggregator.service';
import {
MARKETPLACE_CONSOLIDATED_REQUEST_REPOSITORY,
type MarketplaceConsolidatedRequestDomainRepository,
} from '../../domain/repositories/marketplace-consolidated-request.repository';
import type { MarketplaceConsolidatedRequestDomainEntity } from '../../domain/entities/marketplace-consolidated-request.entity';
import type {
MarketplaceConsolidatedRequestStatus,
} from '../../domain/entities/marketplace-consolidated-request.types';
function toDTO(r: MarketplaceConsolidatedRequestDomainEntity): MarketplaceConsolidatedRequestDTO {
return new MarketplaceConsolidatedRequestDTO({
id: r.id,
coopname: r.coopname,
offer_id: r.offer_id,
supplier_account: r.supplier_account,
cycle_type: r.cycle_type,
total_quantity: r.total_quantity,
total_amount: r.total_amount,
status: r.status,
cycle_started_at: r.cycle_started_at,
cycle_ended_at: r.cycle_ended_at,
expires_at: r.expires_at,
accepted_at: r.accepted_at,
declined_at: r.declined_at,
decline_reason: r.decline_reason,
triggered_by_supplier_at: r.triggered_by_supplier_at,
created_at: r.created_at,
updated_at: r.updated_at,
});
}
/**
* Story 4.2: GraphQL для консолидированной заявки.
*
* - `marketplaceTriggerOpenSubscription` — поставщик жмёт «Запустить
* поставку сейчас» по open_subscription Offer'у (Order'ы сразу ACCEPTED).
* - `marketplaceListConsolidatedRequests` — список заявок для offerer-стола
* (Story 4.5 UI accept/decline по batch).
*
* Access-matrix: trigger требует `Offer:update:own` (поставщик-владелец);
* list — `Offer:read` (видят все marketplace-роли в рамках кооператива).
*/
@Resolver()
@Injectable()
export class MarketplaceCycleResolver {
constructor(
@Inject(MARKETPLACE_CYCLE_AGGREGATOR_SERVICE)
private readonly aggregator: MarketplaceCycleAggregatorService,
@Inject(MARKETPLACE_CONSOLIDATED_REQUEST_REPOSITORY)
private readonly cycleRepo: MarketplaceConsolidatedRequestDomainRepository
) {}
@Mutation(() => MarketplaceConsolidatedRequestDTO, {
name: 'marketplaceTriggerOpenSubscription',
description:
'Story 4.2: поставщик жмёт «Запустить поставку сейчас» по open_subscription Offer\'у. Создаёт consolidated_request status=ACCEPTED, Order\'ы пула → ACCEPTED.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Offer', 'update:own')
async marketplaceTriggerOpenSubscription(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('input') input: MarketplaceTriggerOpenSubscriptionInputDTO
): Promise<MarketplaceConsolidatedRequestDTO> {
const cycle = await this.aggregator.triggerOpenSubscription(
config.coopname,
input.offer_id,
member.username
);
return toDTO(cycle);
}
@Query(() => MarketplaceConsolidatedRequestPaginationResultDTO, {
name: 'marketplaceListConsolidatedRequests',
description:
'Story 4.2/4.5: список консолидированных заявок (для offerer-стола per-status и orderer-стола трассировки).',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Offer', 'read')
async marketplaceListConsolidatedRequests(
@Args('input', { nullable: true }) input?: MarketplaceListConsolidatedRequestsInputDTO
): Promise<MarketplaceConsolidatedRequestPaginationResultDTO> {
const pagination = {
page: input?.page ?? 1,
limit: input?.limit ?? 50,
sortBy: 'updated_at',
sortOrder: (input?.sortOrder ?? 'DESC') as 'ASC' | 'DESC',
};
const result = await this.cycleRepo.list(
{
coopname: config.coopname,
offer_id: input?.offer_id,
status: input?.status as MarketplaceConsolidatedRequestStatus | undefined,
},
pagination
);
return {
items: result.items.map(toDTO),
totalCount: result.totalCount,
totalPages: result.totalPages,
currentPage: result.currentPage,
};
}
}
@@ -0,0 +1,260 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { MarketplaceCycleAggregatorService } from './marketplace-cycle-aggregator.service';
import type { MarketplaceOfferDomainEntity } from '../../domain/entities/marketplace-offer.entity';
import type { MarketplaceOrderDomainEntity } from '../../domain/entities/marketplace-order.entity';
import type { MarketplaceOfferDomainRepository } from '../../domain/repositories/marketplace-offer.repository';
import type { MarketplaceOrderDomainRepository } from '../../domain/repositories/marketplace-order.repository';
import type { MarketplaceConsolidatedRequestDomainRepository } from '../../domain/repositories/marketplace-consolidated-request.repository';
function buildOffer(overrides: Partial<MarketplaceOfferDomainEntity> = {}): MarketplaceOfferDomainEntity {
return {
id: 'offer-vb',
coopname: 'voskhod',
supplier_account: 'supplier1',
price_per_unit: '100.0000',
cycle_type: 'time_based',
cycle_days: 7,
target_volume: null,
max_wait_days: null,
min_threshold: null,
status: 'ACTIVE',
unlimited_flag: false,
quantity_available: 100,
quantity_blocked: 0,
quantity_consumed: 0,
...overrides,
} as MarketplaceOfferDomainEntity;
}
function buildOrder(overrides: Partial<MarketplaceOrderDomainEntity> = {}): MarketplaceOrderDomainEntity {
return {
id: `order-${Math.random().toString(36).slice(2, 9)}`,
quantity: 5,
coopname: 'voskhod',
offer_id: 'offer-vb',
status: 'ACTIVE',
cycle_id: null,
blocked_at: new Date('2026-05-01T10:00:00Z'),
created_at: new Date('2026-05-01T10:00:00Z'),
...overrides,
} as MarketplaceOrderDomainEntity;
}
function buildMocks() {
const offerRepo: jest.Mocked<MarketplaceOfferDomainRepository> = {
findById: jest.fn(),
listAllActiveTimeBased: jest.fn(),
listAllActiveVolumeBased: jest.fn(),
} as unknown as jest.Mocked<MarketplaceOfferDomainRepository>;
const orderRepo: jest.Mocked<MarketplaceOrderDomainRepository> = {
findUnassignedActiveByOffer: jest.fn(),
sumUnassignedActiveByOffer: jest.fn(),
assignToCycle: jest.fn(),
} as unknown as jest.Mocked<MarketplaceOrderDomainRepository>;
const cycleRepo: jest.Mocked<MarketplaceConsolidatedRequestDomainRepository> = {
create: jest.fn(),
applyStatusTransition: jest.fn(),
} as unknown as jest.Mocked<MarketplaceConsolidatedRequestDomainRepository>;
const logger = {
setContext: jest.fn(),
debug: jest.fn(),
log: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
} as any;
return { offerRepo, orderRepo, cycleRepo, logger };
}
describe('MarketplaceCycleAggregatorService', () => {
let mocks: ReturnType<typeof buildMocks>;
let service: MarketplaceCycleAggregatorService;
beforeEach(() => {
mocks = buildMocks();
service = new MarketplaceCycleAggregatorService(
mocks.offerRepo,
mocks.orderRepo,
mocks.cycleRepo,
mocks.logger
);
});
describe('aggregateTimeBased', () => {
it('создаёт PENDING_SUPPLIER_ACCEPT при достижении cycle_end и sum >= min_threshold', async () => {
const cycleStart = new Date('2026-05-01T00:00:00Z');
const offer = buildOffer({
id: 'offer-tb',
cycle_type: 'time_based',
cycle_days: 7,
min_threshold: 10,
});
const pool = [
buildOrder({ id: 'o1', offer_id: 'offer-tb', quantity: 6, blocked_at: cycleStart }),
buildOrder({ id: 'o2', offer_id: 'offer-tb', quantity: 5, blocked_at: cycleStart }),
];
mocks.offerRepo.listAllActiveTimeBased.mockResolvedValue([offer]);
mocks.orderRepo.findUnassignedActiveByOffer.mockResolvedValue(pool);
mocks.cycleRepo.create.mockImplementation(async (input) =>
({ ...input, id: 'cycle-1', created_at: new Date(), updated_at: new Date() } as any)
);
mocks.orderRepo.assignToCycle.mockResolvedValue(2);
// Симулируем that aggregateTimeBased вызывается ПОЗЖЕ cycle_end:
jest.useFakeTimers().setSystemTime(new Date('2026-05-10T12:00:00Z'));
try {
await service.aggregateTimeBased();
} finally {
jest.useRealTimers();
}
expect(mocks.cycleRepo.create).toHaveBeenCalledTimes(1);
const cycleArg = mocks.cycleRepo.create.mock.calls[0][0];
expect(cycleArg.status).toBe('PENDING_SUPPLIER_ACCEPT');
expect(cycleArg.total_quantity).toBe(11);
expect(cycleArg.total_amount).toBe('1100.0000');
expect(mocks.orderRepo.assignToCycle).toHaveBeenCalledWith(
['o1', 'o2'],
'cycle-1',
'ACCEPTED_PENDING_SUPPLIER'
);
});
it('создаёт EXPIRED_NO_THRESHOLD при cycle_end и sum < min_threshold (без assignToCycle)', async () => {
const cycleStart = new Date('2026-05-01T00:00:00Z');
const offer = buildOffer({
id: 'offer-tb',
cycle_type: 'time_based',
cycle_days: 7,
min_threshold: 20,
});
const pool = [
buildOrder({ id: 'o1', offer_id: 'offer-tb', quantity: 3, blocked_at: cycleStart }),
];
mocks.offerRepo.listAllActiveTimeBased.mockResolvedValue([offer]);
mocks.orderRepo.findUnassignedActiveByOffer.mockResolvedValue(pool);
mocks.cycleRepo.create.mockResolvedValue({ id: 'cycle-x' } as any);
jest.useFakeTimers().setSystemTime(new Date('2026-05-10T12:00:00Z'));
try {
await service.aggregateTimeBased();
} finally {
jest.useRealTimers();
}
expect(mocks.cycleRepo.create.mock.calls[0][0].status).toBe('EXPIRED_NO_THRESHOLD');
expect(mocks.orderRepo.assignToCycle).not.toHaveBeenCalled();
});
it('не делает ничего пока cycle_end не достигнут', async () => {
const cycleStart = new Date('2026-05-01T00:00:00Z');
const offer = buildOffer({ id: 'offer-tb', cycle_type: 'time_based', cycle_days: 7 });
const pool = [buildOrder({ id: 'o1', offer_id: 'offer-tb', quantity: 5, blocked_at: cycleStart })];
mocks.offerRepo.listAllActiveTimeBased.mockResolvedValue([offer]);
mocks.orderRepo.findUnassignedActiveByOffer.mockResolvedValue(pool);
jest.useFakeTimers().setSystemTime(new Date('2026-05-03T12:00:00Z')); // только 2 дня прошло
try {
await service.aggregateTimeBased();
} finally {
jest.useRealTimers();
}
expect(mocks.cycleRepo.create).not.toHaveBeenCalled();
});
});
describe('evaluateVolumeBasedAfterCreate', () => {
it('возвращает null если sum < target_volume', async () => {
mocks.orderRepo.sumUnassignedActiveByOffer.mockResolvedValue(7);
const result = await service.evaluateVolumeBasedAfterCreate(
'voskhod',
'offer-vb',
'supplier1',
10,
'100.0000'
);
expect(result).toBeNull();
expect(mocks.cycleRepo.create).not.toHaveBeenCalled();
});
it('создаёт PENDING_SUPPLIER_ACCEPT и assignToCycle когда sum >= target_volume', async () => {
mocks.orderRepo.sumUnassignedActiveByOffer.mockResolvedValue(12);
mocks.orderRepo.findUnassignedActiveByOffer.mockResolvedValue([
buildOrder({ id: 'o1', offer_id: 'offer-vb', quantity: 7 }),
buildOrder({ id: 'o2', offer_id: 'offer-vb', quantity: 5 }),
]);
mocks.cycleRepo.create.mockImplementation(async (input) =>
({ ...input, id: 'cycle-vb' } as any)
);
const result = await service.evaluateVolumeBasedAfterCreate(
'voskhod',
'offer-vb',
'supplier1',
10,
'100.0000'
);
expect(result).not.toBeNull();
const cycleArg = mocks.cycleRepo.create.mock.calls[0][0];
expect(cycleArg.status).toBe('PENDING_SUPPLIER_ACCEPT');
expect(cycleArg.cycle_type).toBe('volume_based');
expect(cycleArg.total_quantity).toBe(12);
expect(mocks.orderRepo.assignToCycle).toHaveBeenCalledWith(
['o1', 'o2'],
'cycle-vb',
'ACCEPTED_PENDING_SUPPLIER'
);
});
});
describe('triggerOpenSubscription', () => {
it('создаёт ACCEPTED + Orders → ACCEPTED при ручном запуске поставщиком', async () => {
const offer = buildOffer({
id: 'offer-os',
cycle_type: 'open_subscription',
supplier_account: 'supplier1',
});
mocks.offerRepo.findById.mockResolvedValue(offer);
const pool = [
buildOrder({ id: 'o1', offer_id: 'offer-os', quantity: 3 }),
buildOrder({ id: 'o2', offer_id: 'offer-os', quantity: 4 }),
];
mocks.orderRepo.findUnassignedActiveByOffer.mockResolvedValue(pool);
mocks.cycleRepo.create.mockImplementation(async (input) =>
({ ...input, id: 'cycle-os' } as any)
);
const result = await service.triggerOpenSubscription('voskhod', 'offer-os', 'supplier1');
expect(result).toBeDefined();
const cycleArg = mocks.cycleRepo.create.mock.calls[0][0];
expect(cycleArg.status).toBe('ACCEPTED');
expect(cycleArg.cycle_type).toBe('open_subscription');
expect(cycleArg.triggered_by_supplier_at).toBeInstanceOf(Date);
expect(mocks.orderRepo.assignToCycle).toHaveBeenCalledWith(['o1', 'o2'], 'cycle-os', 'ACCEPTED');
});
it('Forbidden если запускает не владелец Offer\'а', async () => {
mocks.offerRepo.findById.mockResolvedValue(buildOffer({ cycle_type: 'open_subscription', supplier_account: 'other-supplier' }));
await expect(service.triggerOpenSubscription('voskhod', 'offer-os', 'supplier1')).rejects.toThrow(ForbiddenException);
});
it('BadRequest для cycle_type != open_subscription', async () => {
mocks.offerRepo.findById.mockResolvedValue(buildOffer({ cycle_type: 'time_based', supplier_account: 'supplier1' }));
await expect(service.triggerOpenSubscription('voskhod', 'offer-tb', 'supplier1')).rejects.toThrow(BadRequestException);
});
it('BadRequest при пустом пуле', async () => {
mocks.offerRepo.findById.mockResolvedValue(buildOffer({ cycle_type: 'open_subscription', supplier_account: 'supplier1' }));
mocks.orderRepo.findUnassignedActiveByOffer.mockResolvedValue([]);
await expect(service.triggerOpenSubscription('voskhod', 'offer-os', 'supplier1')).rejects.toThrow(/Пул заказов пуст/);
});
});
});
@@ -0,0 +1,361 @@
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import {
MARKETPLACE_OFFER_REPOSITORY,
type MarketplaceOfferDomainRepository,
} from '../../domain/repositories/marketplace-offer.repository';
import {
MARKETPLACE_ORDER_REPOSITORY,
type MarketplaceOrderDomainRepository,
} from '../../domain/repositories/marketplace-order.repository';
import {
MARKETPLACE_CONSOLIDATED_REQUEST_REPOSITORY,
type MarketplaceConsolidatedRequestDomainRepository,
} from '../../domain/repositories/marketplace-consolidated-request.repository';
import type { MarketplaceConsolidatedRequestDomainEntity } from '../../domain/entities/marketplace-consolidated-request.entity';
import type { MarketplaceOrderDomainEntity } from '../../domain/entities/marketplace-order.entity';
/**
* Story 4.2: cycle-type-aware агрегация Order'ов в `marketplace_consolidated_request`.
*
* Backend-only (Locked Decision L10): on-chain представления заявки НЕТ;
* агрегация полностью в PG. Order'ы привязываются к заявке через
* `marketplace_order.cycle_id`.
*
* Поведение per cycle_type:
*
* - **time_based**: cron-метод `aggregateTimeBased` (раз в 5 минут)
* для каждого Offer'а с time_based находит «текущий пул»
* (unassigned active Orders) → если `now >= cycle_start + cycle_days`
* решает:
* sum >= min_threshold (или min_threshold null) → consolidated_request
* status='PENDING_SUPPLIER_ACCEPT', expires_at = now + acceptance_window;
* sum < min_threshold → consolidated_request status='EXPIRED_NO_THRESHOLD'
* без assignToCycle (Story 4.3 unblk per-Order'ам пула).
*
* - **volume_based**: метод `evaluateVolumeBasedAfterCreate(offer_id)`
* вызывается СИНХРОННО из `MarketplaceOrderCreateService` после persist
* нового Order'а; если sum >= target_volume → instant
* consolidated_request status='PENDING_SUPPLIER_ACCEPT'.
* Cron-fallback `aggregateVolumeBasedExpired` (раз в час) — если объём
* не накоплен и истёк `cycle_start + max_wait_days` →
* consolidated_request status='EXPIRED_NO_VOLUME' (Story 4.3 unblk).
*
* - **open_subscription**: backend НЕ создаёт автоматически. Метод
* `triggerOpenSubscription(offer_id, supplier_account)` вызывается из
* Resolver когда поставщик жмёт «Запустить поставку сейчас» →
* instant consolidated_request status='ACCEPTED'.
*
* - **individual**: backend НЕ агрегирует Order'ы в заявку. Метод
* `applyIndividualPending(orderId)` вызывается из `OrderCreateService`
* сразу после persist — Order.status: ACTIVE → ACCEPTED_PENDING_SUPPLIER_INDIVIDUAL.
*
* Acceptance window (для time/volume) сейчас захардкожена 48ч; в Story 9.x
* вынесем в конфиг кооператива.
*/
@Injectable()
export class MarketplaceCycleAggregatorService {
private static readonly ACCEPTANCE_WINDOW_HOURS = 48;
private static readonly DEFAULT_ASSET_DECIMALS = 4;
constructor(
@Inject(MARKETPLACE_OFFER_REPOSITORY)
private readonly offerRepo: MarketplaceOfferDomainRepository,
@Inject(MARKETPLACE_ORDER_REPOSITORY)
private readonly orderRepo: MarketplaceOrderDomainRepository,
@Inject(MARKETPLACE_CONSOLIDATED_REQUEST_REPOSITORY)
private readonly cycleRepo: MarketplaceConsolidatedRequestDomainRepository,
private readonly logger: WinstonLoggerService
) {
this.logger.setContext(MarketplaceCycleAggregatorService.name);
}
/**
* Cron-агрегатор time_based циклов. Запускается раз в 5 минут;
* сканирует ACTIVE Offer'ы с `cycle_type='time_based'` + cycle_days
* заполнен, для каждого проверяет `now >= cycle_start + cycle_days`.
*/
@Cron(CronExpression.EVERY_5_MINUTES, { name: 'marketplace.cycle.aggregateTimeBased' })
async aggregateTimeBased(): Promise<void> {
try {
const now = new Date();
const offers = await this.offerRepo.listAllActiveTimeBased();
this.logger.debug(
`MarketplaceCycleAggregatorService.aggregateTimeBased: сканирую ${offers.length} ACTIVE time_based Offer'ов`
);
for (const offer of offers) {
await this.processTimeBasedOffer(offer.id, offer.coopname, offer.supplier_account, offer.cycle_days, offer.min_threshold, offer.price_per_unit, now);
}
} catch (error: any) {
this.logger.error(
`MarketplaceCycleAggregatorService.aggregateTimeBased: общая ошибка cron-цикла — ${error.message}`,
error.stack
);
}
}
private async processTimeBasedOffer(
offer_id: string,
coopname: string,
supplier_account: string,
cycle_days: number | null,
min_threshold: number | null,
price_per_unit: string,
now: Date
): Promise<MarketplaceConsolidatedRequestDomainEntity | null> {
if (!cycle_days) return null;
const pool = await this.orderRepo.findUnassignedActiveByOffer(coopname, offer_id);
if (pool.length === 0) return null;
const cycle_start = pool.reduce<Date>((min, o) => {
const t = o.blocked_at ?? o.created_at;
return t < min ? t : min;
}, pool[0].blocked_at ?? pool[0].created_at);
const cycle_end = new Date(cycle_start.getTime() + cycle_days * 86_400_000);
if (now < cycle_end) return null;
const total_quantity = pool.reduce((sum, o) => sum + o.quantity, 0);
// sum < min_threshold → terminal EXPIRED_NO_THRESHOLD (Story 4.3 unblk)
if (min_threshold != null && total_quantity < min_threshold) {
const expired = await this.cycleRepo.create({
coopname,
offer_id,
supplier_account,
cycle_type: 'time_based',
total_quantity,
total_amount: this.computeTotalAmount(price_per_unit, total_quantity),
status: 'EXPIRED_NO_THRESHOLD',
cycle_started_at: cycle_start,
cycle_ended_at: cycle_end,
expires_at: null,
triggered_by_supplier_at: null,
});
this.logger.log(
`MarketplaceCycleAggregatorService: offer=${offer_id} time_based ЗАКРЫТ без порога (sum=${total_quantity} < threshold=${min_threshold}) — request ${expired.id}, Story 4.3 unblk`
);
return expired;
}
// sum >= min_threshold (или null) → консолидированная заявка
return await this.formConsolidatedRequest(
coopname,
offer_id,
supplier_account,
'time_based',
total_quantity,
price_per_unit,
cycle_start,
cycle_end,
pool,
now
);
}
/**
* Story 4.2: volume_based instant trigger. Вызывается из
* `MarketplaceOrderCreateService` сразу после persist нового Order'а.
* Если sum активного пула >= target_volume — formConsolidatedRequest.
*/
async evaluateVolumeBasedAfterCreate(
coopname: string,
offer_id: string,
supplier_account: string,
target_volume: number | null,
price_per_unit: string
): Promise<MarketplaceConsolidatedRequestDomainEntity | null> {
if (!target_volume) return null;
const sum = await this.orderRepo.sumUnassignedActiveByOffer(coopname, offer_id);
if (sum < target_volume) return null;
const pool = await this.orderRepo.findUnassignedActiveByOffer(coopname, offer_id);
if (pool.length === 0) return null;
const cycle_start = pool.reduce<Date>((min, o) => {
const t = o.blocked_at ?? o.created_at;
return t < min ? t : min;
}, pool[0].blocked_at ?? pool[0].created_at);
const now = new Date();
return await this.formConsolidatedRequest(
coopname,
offer_id,
supplier_account,
'volume_based',
sum,
price_per_unit,
cycle_start,
null,
pool,
now
);
}
/**
* Story 4.2: cron-fallback volume_based expire — sum < target_volume
* + now > cycle_start + max_wait_days. Раз в час сканирует Offer'ы.
*/
@Cron(CronExpression.EVERY_HOUR, { name: 'marketplace.cycle.aggregateVolumeBasedExpired' })
async aggregateVolumeBasedExpired(): Promise<void> {
try {
const now = new Date();
const offers = await this.offerRepo.listAllActiveVolumeBased();
this.logger.debug(
`MarketplaceCycleAggregatorService.aggregateVolumeBasedExpired: сканирую ${offers.length} ACTIVE volume_based Offer'ов`
);
for (const offer of offers) {
if (!offer.max_wait_days) continue;
const pool = await this.orderRepo.findUnassignedActiveByOffer(offer.coopname, offer.id);
if (pool.length === 0) continue;
const cycle_start = pool.reduce<Date>((min, o) => {
const t = o.blocked_at ?? o.created_at;
return t < min ? t : min;
}, pool[0].blocked_at ?? pool[0].created_at);
const deadline = new Date(cycle_start.getTime() + offer.max_wait_days * 86_400_000);
if (now < deadline) continue;
const total_quantity = pool.reduce((sum, o) => sum + o.quantity, 0);
if (offer.target_volume == null || total_quantity >= offer.target_volume) continue;
const expired = await this.cycleRepo.create({
coopname: offer.coopname,
offer_id: offer.id,
supplier_account: offer.supplier_account,
cycle_type: 'volume_based',
total_quantity,
total_amount: this.computeTotalAmount(offer.price_per_unit, total_quantity),
status: 'EXPIRED_NO_VOLUME',
cycle_started_at: cycle_start,
cycle_ended_at: deadline,
expires_at: null,
triggered_by_supplier_at: null,
});
this.logger.log(
`MarketplaceCycleAggregatorService: offer=${offer.id} volume_based ЗАКРЫТ без объёма (sum=${total_quantity} < target=${offer.target_volume}) — request ${expired.id}, Story 4.3 unblk`
);
}
} catch (error: any) {
this.logger.error(
`MarketplaceCycleAggregatorService.aggregateVolumeBasedExpired: общая ошибка — ${error.message}`,
error.stack
);
}
}
/**
* Story 4.2: поставщик жмёт «Запустить поставку сейчас» по open_subscription.
* Создаёт consolidated_request status='ACCEPTED' сразу (нажатие = акцепт
* всего пула, refund-кнопка не показывается).
*/
async triggerOpenSubscription(
coopname: string,
offer_id: string,
requestor_account: string
): Promise<MarketplaceConsolidatedRequestDomainEntity> {
const offer = await this.offerRepo.findById(offer_id);
if (!offer) throw new NotFoundException('Предложение не найдено.');
if (offer.coopname !== coopname) {
throw new ForbiddenException('Предложение принадлежит другому кооперативу.');
}
if (offer.status !== 'ACTIVE') {
throw new BadRequestException(
`Предложение в статусе «${offer.status}» — запуск поставки запрещён.`
);
}
if (offer.cycle_type !== 'open_subscription') {
throw new BadRequestException(
`Запуск поставки доступен только для cycle_type='open_subscription'; этот Offer — «${offer.cycle_type}».`
);
}
if (offer.supplier_account !== requestor_account) {
throw new ForbiddenException('Запустить поставку может только поставщик-владелец Offer\'а.');
}
const pool = await this.orderRepo.findUnassignedActiveByOffer(coopname, offer_id);
if (pool.length === 0) {
throw new BadRequestException('Пул заказов пуст — запускать нечего.');
}
const cycle_start = pool.reduce<Date>((min, o) => {
const t = o.blocked_at ?? o.created_at;
return t < min ? t : min;
}, pool[0].blocked_at ?? pool[0].created_at);
const total_quantity = pool.reduce((sum, o) => sum + o.quantity, 0);
const now = new Date();
return await this.formConsolidatedRequest(
coopname,
offer_id,
offer.supplier_account,
'open_subscription',
total_quantity,
offer.price_per_unit,
cycle_start,
null,
pool,
now,
{ triggered: true }
);
}
// ── private ──────────────────────────────────────────────────────
/**
* Создание consolidated_request + bulk assignToCycle всем Order'ам пула.
* Status:
* - time_based / volume_based → PENDING_SUPPLIER_ACCEPT + expires_at = now + 48ч;
* - open_subscription (triggered: true) → ACCEPTED + Order.status → ACCEPTED.
*/
private async formConsolidatedRequest(
coopname: string,
offer_id: string,
supplier_account: string,
cycle_type: MarketplaceOrderDomainEntity['cycle_type'],
total_quantity: number,
price_per_unit: string,
cycle_started_at: Date,
cycle_ended_at: Date | null,
pool: MarketplaceOrderDomainEntity[],
now: Date,
options: { triggered?: boolean } = {}
): Promise<MarketplaceConsolidatedRequestDomainEntity> {
const triggered = options.triggered ?? false;
const status = triggered ? 'ACCEPTED' : 'PENDING_SUPPLIER_ACCEPT';
const orderStatus = triggered ? 'ACCEPTED' : 'ACCEPTED_PENDING_SUPPLIER';
const expires_at = triggered
? null
: new Date(now.getTime() + MarketplaceCycleAggregatorService.ACCEPTANCE_WINDOW_HOURS * 3_600_000);
const cycle = await this.cycleRepo.create({
coopname,
offer_id,
supplier_account,
cycle_type,
total_quantity,
total_amount: this.computeTotalAmount(price_per_unit, total_quantity),
status,
cycle_started_at,
cycle_ended_at,
expires_at,
triggered_by_supplier_at: triggered ? now : null,
});
const orderIds = pool.map((o) => o.id);
const affected = await this.orderRepo.assignToCycle(orderIds, cycle.id, orderStatus);
this.logger.log(
`MarketplaceCycleAggregatorService: offer=${offer_id} cycle_type=${cycle_type} → consolidated_request=${cycle.id} status=${status}; assigned ${affected}/${orderIds.length} Order'ов`
);
return cycle;
}
private computeTotalAmount(price_per_unit: string, quantity: number): string {
const price = Number.parseFloat(price_per_unit);
if (!Number.isFinite(price)) return '0.0000';
return (price * quantity).toFixed(MarketplaceCycleAggregatorService.DEFAULT_ASSET_DECIMALS);
}
}
export const MARKETPLACE_CYCLE_AGGREGATOR_SERVICE = Symbol(
'MARKETPLACE_CYCLE_AGGREGATOR_SERVICE'
);
@@ -6,6 +6,7 @@ import type { MarketplaceOfferDomainRepository } from '../../domain/repositories
import type { MarketplaceOrderDomainRepository } from '../../domain/repositories/marketplace-order.repository';
import type { MarketplaceOfferCountersService } from './marketplace-offer-counters.service';
import type { MarketplaceCanonicalBlockchainPort } from '../../domain/ports/marketplace-canonical-blockchain.port';
import type { MarketplaceCycleAggregatorService } from './marketplace-cycle-aggregator.service';
function buildOffer(overrides: Partial<MarketplaceOfferDomainEntity> = {}): MarketplaceOfferDomainEntity {
return {
@@ -58,6 +59,13 @@ function buildMocks() {
createOrder: jest.fn(),
} as unknown as jest.Mocked<MarketplaceCanonicalBlockchainPort>;
const cycleAggregator: jest.Mocked<MarketplaceCycleAggregatorService> = {
evaluateVolumeBasedAfterCreate: jest.fn().mockResolvedValue(null),
triggerOpenSubscription: jest.fn(),
aggregateTimeBased: jest.fn(),
aggregateVolumeBasedExpired: jest.fn(),
} as unknown as jest.Mocked<MarketplaceCycleAggregatorService>;
const logger = {
setContext: jest.fn(),
debug: jest.fn(),
@@ -67,7 +75,7 @@ function buildMocks() {
info: jest.fn(),
} as any;
return { offerRepo, orderRepo, counters, chainPort, logger };
return { offerRepo, orderRepo, counters, chainPort, cycleAggregator, logger };
}
describe('MarketplaceOrderCreateService', () => {
@@ -81,6 +89,7 @@ describe('MarketplaceOrderCreateService', () => {
mocks.orderRepo,
mocks.counters,
mocks.chainPort,
mocks.cycleAggregator,
mocks.logger
);
});
@@ -17,6 +17,10 @@ import {
MARKETPLACE_CANONICAL_BLOCKCHAIN_PORT,
type MarketplaceCanonicalBlockchainPort,
} from '../../domain/ports/marketplace-canonical-blockchain.port';
import {
MARKETPLACE_CYCLE_AGGREGATOR_SERVICE,
type MarketplaceCycleAggregatorService,
} from './marketplace-cycle-aggregator.service';
import type { MarketplaceOrderDomainEntity } from '../../domain/entities/marketplace-order.entity';
import type { MarketplaceOrderCreateTxSnapshot } from '../../domain/entities/marketplace-order.types';
@@ -87,6 +91,8 @@ export class MarketplaceOrderCreateService {
private readonly offerCounters: MarketplaceOfferCountersService,
@Inject(MARKETPLACE_CANONICAL_BLOCKCHAIN_PORT)
private readonly chainPort: MarketplaceCanonicalBlockchainPort,
@Inject(MARKETPLACE_CYCLE_AGGREGATOR_SERVICE)
private readonly cycleAggregator: MarketplaceCycleAggregatorService,
private readonly logger: WinstonLoggerService
) {
this.logger.setContext(MarketplaceOrderCreateService.name);
@@ -176,7 +182,7 @@ export class MarketplaceOrderCreateService {
signed_at: new Date().toISOString(),
};
const order = await this.orderRepo.persistAfterBlock({
let order = await this.orderRepo.persistAfterBlock({
coopname: input.coopname,
order_hash,
orderer_account: input.orderer_account,
@@ -200,9 +206,61 @@ export class MarketplaceOrderCreateService {
`MarketplaceOrderCreateService: Order ${order.id} (hash=${order_hash}) создан для ${input.orderer_account}; offer=${offer.id}, qty=${input.quantity}, total=${blocked_amount}; tx=${txHash}`
);
// Story 4.2: per-cycle_type hook сразу после persist.
order = await this.applyCycleTypeHook(order, offer);
return { order, tx_snapshot: create_tx };
}
/**
* Story 4.2: применить per-cycle_type backend hook сразу после persist
* нового Order'а (Story 4.1).
*
* - `individual` → Order.status: ACTIVE → ACCEPTED_PENDING_SUPPLIER_INDIVIDUAL.
* - `volume_based` → evaluate threshold (sum vs target_volume); если
* достигнут — formConsolidatedRequest сразу + Order вместе с пулом →
* ACCEPTED_PENDING_SUPPLIER.
* - `time_based` / `open_subscription` → ничего (Order ждёт cron / manual trigger).
*
* Hook не критичный — при ошибке логируем и возвращаем Order как есть
* (Story 4.3 cron-fallback подберёт).
*/
private async applyCycleTypeHook(
order: MarketplaceOrderDomainEntity,
offer: { id: string; coopname: string; supplier_account: string; cycle_type: string; target_volume: number | null; price_per_unit: string }
): Promise<MarketplaceOrderDomainEntity> {
try {
if (offer.cycle_type === 'individual') {
return await this.orderRepo.applyStatusTransition(
order.id,
'ACCEPTED_PENDING_SUPPLIER_INDIVIDUAL',
'individual cycle_type — ожидание per-Order акцепта поставщика'
);
}
if (offer.cycle_type === 'volume_based') {
const cycle = await this.cycleAggregator.evaluateVolumeBasedAfterCreate(
offer.coopname,
offer.id,
offer.supplier_account,
offer.target_volume,
offer.price_per_unit
);
if (cycle) {
// assignToCycle уже изменил status в БД на ACCEPTED_PENDING_SUPPLIER —
// перечитываем актуальное состояние.
const refreshed = await this.orderRepo.findById(order.id);
if (refreshed) return refreshed;
}
}
return order;
} catch (error: any) {
this.logger.warn(
`MarketplaceOrderCreateService.applyCycleTypeHook: hook упал для Order ${order.id} (cycle_type=${offer.cycle_type}): ${error.message}; Order остаётся в ACTIVE, cron подберёт`
);
return order;
}
}
// ── private ──────────────────────────────────────────────────────
private validateInput(input: MarketplaceOrderCreateInputDto): void {
@@ -0,0 +1,70 @@
import { MarketplaceConsolidatedRequestDomainEntity } from './marketplace-consolidated-request.entity';
import type { MarketplaceConsolidatedRequestProps } from './marketplace-consolidated-request.types';
function buildProps(
overrides: Partial<MarketplaceConsolidatedRequestProps> = {}
): MarketplaceConsolidatedRequestProps {
return {
id: 'cycle-uuid-1',
coopname: 'voskhod',
offer_id: 'offer-uuid-1',
supplier_account: 'supplier1',
cycle_type: 'time_based',
total_quantity: 25,
total_amount: '2500.0000',
status: 'PENDING_SUPPLIER_ACCEPT',
cycle_started_at: new Date('2026-05-01T00:00:00Z'),
cycle_ended_at: new Date('2026-05-08T00:00:00Z'),
expires_at: new Date('2026-05-10T00:00:00Z'),
accepted_at: null,
declined_at: null,
decline_reason: null,
triggered_by_supplier_at: null,
created_at: new Date('2026-05-08T00:00:00Z'),
updated_at: new Date('2026-05-08T00:00:00Z'),
...overrides,
};
}
describe('MarketplaceConsolidatedRequestDomainEntity', () => {
it('PENDING_SUPPLIER_ACCEPT — is_pending true, is_terminal false', () => {
const r = new MarketplaceConsolidatedRequestDomainEntity(buildProps());
expect(r.is_pending).toBe(true);
expect(r.is_terminal).toBe(false);
});
it('ACCEPTED — is_pending false, is_terminal true', () => {
const r = new MarketplaceConsolidatedRequestDomainEntity(
buildProps({ status: 'ACCEPTED', accepted_at: new Date() })
);
expect(r.is_pending).toBe(false);
expect(r.is_terminal).toBe(true);
});
it.each([
'DECLINED_BY_SUPPLIER',
'EXPIRED_NO_RESPONSE',
'EXPIRED_NO_THRESHOLD',
'EXPIRED_NO_VOLUME',
] as const)('is_terminal true для %s', (status) => {
const r = new MarketplaceConsolidatedRequestDomainEntity(buildProps({ status }));
expect(r.is_terminal).toBe(true);
expect(r.is_pending).toBe(false);
});
it('open_subscription с triggered_by_supplier_at — фиксируется как поле', () => {
const triggered = new Date('2026-05-08T12:00:00Z');
const r = new MarketplaceConsolidatedRequestDomainEntity(
buildProps({
cycle_type: 'open_subscription',
status: 'ACCEPTED',
cycle_ended_at: null,
expires_at: null,
accepted_at: triggered,
triggered_by_supplier_at: triggered,
})
);
expect(r.triggered_by_supplier_at).toEqual(triggered);
expect(r.cycle_type).toBe('open_subscription');
});
});
@@ -0,0 +1,68 @@
import type {
MarketplaceConsolidatedRequestProps,
MarketplaceConsolidatedRequestStatus,
} from './marketplace-consolidated-request.types';
import type { MarketplaceOrderCycleType } from './marketplace-order.types';
/**
* Story 4.2: домен консолидированной заявки. Backend-only (Locked
* Decision L10): on-chain представления НЕТ, агрегация per cycle_type
* целиком в PG.
*
* Order'ы связаны через `marketplace_order.cycle_id = consolidated_request.id`.
* Терминальный переход → Order'ы внутри получают соответствующий status
* (см. Story 4.5 supplier accept/decline и Story 4.3 expire-handlers).
*/
export class MarketplaceConsolidatedRequestDomainEntity {
public readonly id: string;
public readonly coopname: string;
public readonly offer_id: string;
public readonly supplier_account: string;
public readonly cycle_type: MarketplaceOrderCycleType;
public readonly total_quantity: number;
public readonly total_amount: string;
public status: MarketplaceConsolidatedRequestStatus;
public readonly cycle_started_at: Date;
public readonly cycle_ended_at: Date | null;
public readonly expires_at: Date | null;
public accepted_at: Date | null;
public declined_at: Date | null;
public decline_reason: string | null;
public readonly triggered_by_supplier_at: Date | null;
public readonly created_at: Date;
public updated_at: Date;
constructor(props: MarketplaceConsolidatedRequestProps) {
this.id = props.id;
this.coopname = props.coopname;
this.offer_id = props.offer_id;
this.supplier_account = props.supplier_account;
this.cycle_type = props.cycle_type;
this.total_quantity = props.total_quantity;
this.total_amount = props.total_amount;
this.status = props.status;
this.cycle_started_at = props.cycle_started_at;
this.cycle_ended_at = props.cycle_ended_at;
this.expires_at = props.expires_at;
this.accepted_at = props.accepted_at;
this.declined_at = props.declined_at;
this.decline_reason = props.decline_reason;
this.triggered_by_supplier_at = props.triggered_by_supplier_at;
this.created_at = props.created_at;
this.updated_at = props.updated_at;
}
public get is_pending(): boolean {
return this.status === 'PENDING_SUPPLIER_ACCEPT';
}
public get is_terminal(): boolean {
return (
this.status === 'ACCEPTED' ||
this.status === 'DECLINED_BY_SUPPLIER' ||
this.status === 'EXPIRED_NO_RESPONSE' ||
this.status === 'EXPIRED_NO_THRESHOLD' ||
this.status === 'EXPIRED_NO_VOLUME'
);
}
}
@@ -0,0 +1,45 @@
import type { MarketplaceOrderCycleType } from './marketplace-order.types';
/**
* Story 4.2: жизненный цикл консолидированной заявки `marketplace_consolidated_request`.
*
* - `PENDING_SUPPLIER_ACCEPT` — заявка сформирована (time-based / volume-based),
* ждём accept/decline поставщика в течение `acceptance_window_hours`.
* - `ACCEPTED` — поставщик акцептовал (или open_subscription триггернут самим
* поставщиком). Order'ы внутри переходят в `ACCEPTED` (Story 4.5).
* - `DECLINED_BY_SUPPLIER` — поставщик отказался; Order'ы внутри получают
* o.mkt.unblk + status `CANCELLED_BY_SUPPLIER` (Story 4.5).
* - `EXPIRED_NO_RESPONSE` — `expires_at` истёк без ответа поставщика →
* auto-decline в Story 4.3.
* - `EXPIRED_NO_THRESHOLD` (time_based) / `EXPIRED_NO_VOLUME` (volume_based) —
* цикл закрыт до формирования заявки; Story 4.3 unblk per-Order'ам пула.
* Записывается как terminal `marketplace_consolidated_request` без
* Order'ов (для audit-trail), либо вовсе не создаётся — решение в pre-aggregator.
*/
export type MarketplaceConsolidatedRequestStatus =
| 'PENDING_SUPPLIER_ACCEPT'
| 'ACCEPTED'
| 'DECLINED_BY_SUPPLIER'
| 'EXPIRED_NO_RESPONSE'
| 'EXPIRED_NO_THRESHOLD'
| 'EXPIRED_NO_VOLUME';
export interface MarketplaceConsolidatedRequestProps {
id: string;
coopname: string;
offer_id: string;
supplier_account: string;
cycle_type: MarketplaceOrderCycleType;
total_quantity: number;
total_amount: string;
status: MarketplaceConsolidatedRequestStatus;
cycle_started_at: Date;
cycle_ended_at: Date | null;
expires_at: Date | null;
accepted_at: Date | null;
declined_at: Date | null;
decline_reason: string | null;
triggered_by_supplier_at: Date | null;
created_at: Date;
updated_at: Date;
}
@@ -0,0 +1,66 @@
import type { MarketplaceConsolidatedRequestDomainEntity } from '../entities/marketplace-consolidated-request.entity';
import type {
MarketplaceConsolidatedRequestStatus,
} from '../entities/marketplace-consolidated-request.types';
import type { MarketplaceOrderCycleType } from '../entities/marketplace-order.types';
import type {
PaginationInputDomainInterface,
PaginationResultDomainInterface,
} from '~/domain/common/interfaces/pagination.interface';
export const MARKETPLACE_CONSOLIDATED_REQUEST_REPOSITORY = Symbol(
'MARKETPLACE_CONSOLIDATED_REQUEST_REPOSITORY'
);
export interface MarketplaceConsolidatedRequestCreateInput {
coopname: string;
offer_id: string;
supplier_account: string;
cycle_type: MarketplaceOrderCycleType;
total_quantity: number;
total_amount: string;
status: MarketplaceConsolidatedRequestStatus;
cycle_started_at: Date;
cycle_ended_at: Date | null;
expires_at: Date | null;
triggered_by_supplier_at: Date | null;
}
export interface MarketplaceConsolidatedRequestListFilter {
coopname: string;
offer_id?: string;
supplier_account?: string;
status?: MarketplaceConsolidatedRequestStatus | MarketplaceConsolidatedRequestStatus[];
cycle_type?: MarketplaceOrderCycleType;
}
export interface MarketplaceConsolidatedRequestDomainRepository {
create(
input: MarketplaceConsolidatedRequestCreateInput
): Promise<MarketplaceConsolidatedRequestDomainEntity>;
findById(id: string): Promise<MarketplaceConsolidatedRequestDomainEntity | null>;
/**
* Активные time_based циклы у которых `cycle_ended_at < now` — кандидаты
* на закрытие cron'ом (Story 4.2 / Story 4.3).
*/
findExpiredTimeBased(now: Date): Promise<MarketplaceConsolidatedRequestDomainEntity[]>;
/**
* Активные volume_based заявки + параллельно volume-based Order'ы которые
* висят в `ACTIVE` за пределом `max_wait_days` (Story 4.3 cleanup).
*/
findExpiredAwaitingResponse(now: Date): Promise<MarketplaceConsolidatedRequestDomainEntity[]>;
list(
filter: MarketplaceConsolidatedRequestListFilter,
pagination: PaginationInputDomainInterface
): Promise<PaginationResultDomainInterface<MarketplaceConsolidatedRequestDomainEntity>>;
applyStatusTransition(
id: string,
newStatus: MarketplaceConsolidatedRequestStatus,
options?: { decline_reason?: string | null }
): Promise<MarketplaceConsolidatedRequestDomainEntity>;
}
@@ -137,4 +137,19 @@ export interface MarketplaceOfferDomainRepository {
* См. spec-3-4-bc-integration.md секция 3.1.
*/
applyRollbackDelta(offer_id: string, qty: number): Promise<OfferCountersDeltaResult>;
// ── Story 4.2: scan ACTIVE Offer'ов per cycle_type для cron-агрегатора ──
/**
* Все ACTIVE Offer'ы с cycle_type='time_based'. Используется cron'ом
* `MarketplaceCycleAggregatorService.aggregateTimeBased` (раз в 5 мин).
*/
listAllActiveTimeBased(): Promise<MarketplaceOfferDomainEntity[]>;
/**
* Все ACTIVE Offer'ы с cycle_type='volume_based' и заполненным
* `max_wait_days`. Используется cron'ом `aggregateVolumeBasedExpired`
* (раз в час) для unblk пула при истечении.
*/
listAllActiveVolumeBased(): Promise<MarketplaceOfferDomainEntity[]>;
}
@@ -75,4 +75,35 @@ export interface MarketplaceOrderDomainRepository
newStatus: MarketplaceOrderStatus,
reason: string | null
): Promise<MarketplaceOrderDomainEntity>;
// ── Story 4.2: cycle-aggregation queries ──────────────────────────
/**
* Story 4.2: незаагрегированные ACTIVE Order'ы конкретного Offer'а
* (cycle_id IS NULL AND status='ACTIVE'). Это «текущий пул» Offer'а
* для time_based/volume_based/open_subscription cycle_type.
*/
findUnassignedActiveByOffer(
coopname: string,
offer_id: string
): Promise<MarketplaceOrderDomainEntity[]>;
/**
* Story 4.2: bulk-привязка Order'ов к консолидированной заявке.
* Используется при формировании консолидированной заявки (time_based
* cron / volume_based threshold / open_subscription manual trigger).
* Атомарно меняет status (ACTIVE → ACCEPTED_PENDING_SUPPLIER /
* ACCEPTED).
*/
assignToCycle(
orderIds: string[],
cycle_id: string,
newStatus: MarketplaceOrderStatus
): Promise<number>;
/**
* Story 4.2: сумма quantity активного пула Offer'а (для volume_based
* threshold check после persist нового Order'а).
*/
sumUnassignedActiveByOffer(coopname: string, offer_id: string): Promise<number>;
}
@@ -0,0 +1,116 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { LessThan, Repository } from 'typeorm';
import { MarketplaceConsolidatedRequestDomainEntity } from '../../domain/entities/marketplace-consolidated-request.entity';
import type {
MarketplaceConsolidatedRequestCreateInput,
MarketplaceConsolidatedRequestDomainRepository,
MarketplaceConsolidatedRequestListFilter,
} from '../../domain/repositories/marketplace-consolidated-request.repository';
import type { MarketplaceConsolidatedRequestStatus } from '../../domain/entities/marketplace-consolidated-request.types';
import type {
PaginationInputDomainInterface,
PaginationResultDomainInterface,
} from '~/domain/common/interfaces/pagination.interface';
import { MarketplaceConsolidatedRequestEntity } from '../entities/marketplace-consolidated-request.entity';
import { MarketplaceConsolidatedRequestMapper } from '../mappers/marketplace-consolidated-request.mapper';
@Injectable()
export class MarketplaceConsolidatedRequestRepositoryAdapter
implements MarketplaceConsolidatedRequestDomainRepository
{
constructor(
@InjectRepository(MarketplaceConsolidatedRequestEntity, 'marketplace')
private readonly repo: Repository<MarketplaceConsolidatedRequestEntity>,
private readonly mapper: MarketplaceConsolidatedRequestMapper
) {}
async create(
input: MarketplaceConsolidatedRequestCreateInput
): Promise<MarketplaceConsolidatedRequestDomainEntity> {
const row = this.repo.create({
coopname: input.coopname,
offer_id: input.offer_id,
supplier_account: input.supplier_account,
cycle_type: input.cycle_type,
total_quantity: input.total_quantity,
total_amount: input.total_amount,
status: input.status,
cycle_started_at: input.cycle_started_at,
cycle_ended_at: input.cycle_ended_at,
expires_at: input.expires_at,
accepted_at: input.status === 'ACCEPTED' ? new Date() : null,
declined_at: null,
decline_reason: null,
triggered_by_supplier_at: input.triggered_by_supplier_at,
});
const saved = await this.repo.save(row);
return this.mapper.toDomain(saved);
}
async findById(id: string): Promise<MarketplaceConsolidatedRequestDomainEntity | null> {
const row = await this.repo.findOne({ where: { id } });
return row ? this.mapper.toDomain(row) : null;
}
async findExpiredTimeBased(now: Date): Promise<MarketplaceConsolidatedRequestDomainEntity[]> {
const rows = await this.repo.find({
where: {
cycle_type: 'time_based',
status: 'PENDING_SUPPLIER_ACCEPT',
cycle_ended_at: LessThan(now),
},
});
return rows.map((r) => this.mapper.toDomain(r));
}
async findExpiredAwaitingResponse(
now: Date
): Promise<MarketplaceConsolidatedRequestDomainEntity[]> {
const rows = await this.repo
.createQueryBuilder('r')
.where('r.status = :s', { s: 'PENDING_SUPPLIER_ACCEPT' as MarketplaceConsolidatedRequestStatus })
.andWhere('r.expires_at IS NOT NULL AND r.expires_at < :now', { now })
.getMany();
return rows.map((r) => this.mapper.toDomain(r));
}
async list(
filter: MarketplaceConsolidatedRequestListFilter,
pagination: PaginationInputDomainInterface
): Promise<PaginationResultDomainInterface<MarketplaceConsolidatedRequestDomainEntity>> {
const qb = this.repo.createQueryBuilder('r').where('r.coopname = :coop', { coop: filter.coopname });
if (filter.offer_id) qb.andWhere('r.offer_id = :off', { off: filter.offer_id });
if (filter.supplier_account) qb.andWhere('r.supplier_account = :sup', { sup: filter.supplier_account });
if (filter.cycle_type) qb.andWhere('r.cycle_type = :ct', { ct: filter.cycle_type });
if (filter.status) {
const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
qb.andWhere('r.status IN (:...statuses)', { statuses });
}
qb.orderBy('r.updated_at', pagination.sortOrder ?? 'DESC');
qb.skip((pagination.page - 1) * pagination.limit).take(pagination.limit);
const [rows, totalCount] = await qb.getManyAndCount();
return {
items: rows.map((r) => this.mapper.toDomain(r)),
totalCount,
totalPages: Math.ceil(totalCount / pagination.limit),
currentPage: pagination.page,
};
}
async applyStatusTransition(
id: string,
newStatus: MarketplaceConsolidatedRequestStatus,
options: { decline_reason?: string | null } = {}
): Promise<MarketplaceConsolidatedRequestDomainEntity> {
const patch: Partial<MarketplaceConsolidatedRequestEntity> = { status: newStatus };
if (newStatus === 'ACCEPTED') patch.accepted_at = new Date();
else if (newStatus === 'DECLINED_BY_SUPPLIER' || newStatus === 'EXPIRED_NO_RESPONSE') {
patch.declined_at = new Date();
patch.decline_reason = options.decline_reason ?? null;
}
await this.repo.update({ id }, patch as Record<string, unknown>);
const row = await this.repo.findOneOrFail({ where: { id } });
return this.mapper.toDomain(row);
}
}
@@ -205,6 +205,28 @@ export class MarketplaceOfferRepositoryAdapter implements MarketplaceOfferDomain
return this.interpretDelta(result, offer_id, 'insufficient_blocked');
}
// ── Story 4.2: cycle-type scans ──────────────────────────────────
async listAllActiveTimeBased(): Promise<MarketplaceOfferDomainEntity[]> {
const rows = await this.repo
.createQueryBuilder('o')
.where("o.status = 'ACTIVE'")
.andWhere("o.cycle_type = 'time_based'")
.andWhere('o.cycle_days IS NOT NULL')
.getMany();
return rows.map((r) => this.mapper.toDomain(r));
}
async listAllActiveVolumeBased(): Promise<MarketplaceOfferDomainEntity[]> {
const rows = await this.repo
.createQueryBuilder('o')
.where("o.status = 'ACTIVE'")
.andWhere("o.cycle_type = 'volume_based'")
.andWhere('o.max_wait_days IS NOT NULL')
.getMany();
return rows.map((r) => this.mapper.toDomain(r));
}
async applyRollbackDelta(offer_id: string, qty: number): Promise<OfferCountersDeltaResult> {
if (qty <= 0) return { ok: false, reason: 'insufficient_blocked' };
// ADR-005: rollback без CAS — counter может уйти в отрицательное
@@ -194,6 +194,56 @@ export class MarketplaceOrderRepositoryAdapter implements MarketplaceOrderDomain
);
}
// ── Story 4.2: cycle-aggregation queries ──────────────────────────
async findUnassignedActiveByOffer(
coopname: string,
offer_id: string
): Promise<MarketplaceOrderDomainEntity[]> {
const rows = await this.repo
.createQueryBuilder('o')
.where('o.coopname = :coop AND o.offer_id = :off AND o.status = :st AND o.cycle_id IS NULL', {
coop: coopname,
off: offer_id,
st: 'ACTIVE',
})
.orderBy('o.blocked_at', 'ASC')
.getMany();
return rows.map((r) => this.mapper.toDomain(r));
}
async assignToCycle(
orderIds: string[],
cycle_id: string,
newStatus: MarketplaceOrderStatus
): Promise<number> {
if (orderIds.length === 0) return 0;
const result = await this.repo
.createQueryBuilder()
.update(MarketplaceOrderEntity)
.set({
cycle_id,
status: newStatus,
accepted_at: newStatus === 'ACCEPTED' ? new Date() : undefined,
})
.where('id IN (:...ids) AND cycle_id IS NULL', { ids: orderIds })
.execute();
return result.affected ?? 0;
}
async sumUnassignedActiveByOffer(coopname: string, offer_id: string): Promise<number> {
const raw = await this.repo
.createQueryBuilder('o')
.select('COALESCE(SUM(o.quantity), 0)', 'total')
.where('o.coopname = :coop AND o.offer_id = :off AND o.status = :st AND o.cycle_id IS NULL', {
coop: coopname,
off: offer_id,
st: 'ACTIVE',
})
.getRawOne<{ total: string }>();
return Number(raw?.total ?? 0);
}
async deleteByBlockNumGreaterThan(blockNum: number): Promise<void> {
await this.repo
.createQueryBuilder()
@@ -0,0 +1,81 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import type {
MarketplaceConsolidatedRequestStatus,
} from '../../domain/entities/marketplace-consolidated-request.types';
import type { MarketplaceOrderCycleType } from '../../domain/entities/marketplace-order.types';
/**
* Story 4.2: TypeORM-сущность консолидированной заявки. Backend-only
* (L10) — связь с Order'ами через `marketplace_order.cycle_id = id`.
*
* Hot-path индексы:
* - `(coopname, supplier_account, status)` — offerer-стол «Консолидированные
* заявки» (Story 4.5 supplier accept/decline UI);
* - `(coopname, offer_id, status)` — pre-aggregator проверка «нет ли
* уже активной заявки» (idempotency cron-цикла);
* - `(cycle_type, status, expires_at)` — cron-scan активных Pending по
* истечению (Story 4.3 expire-handler).
*/
@Entity({ name: 'marketplace_consolidated_request' })
@Index(['coopname', 'supplier_account', 'status'])
@Index(['coopname', 'offer_id', 'status'])
@Index(['cycle_type', 'status', 'expires_at'])
export class MarketplaceConsolidatedRequestEntity {
@PrimaryGeneratedColumn('uuid')
public id!: string;
@Column({ type: 'varchar', length: 13 })
public coopname!: string;
@Column({ type: 'uuid' })
public offer_id!: string;
@Column({ type: 'varchar', length: 13 })
public supplier_account!: string;
@Column({ type: 'varchar', length: 32 })
public cycle_type!: MarketplaceOrderCycleType;
@Column({ type: 'integer' })
public total_quantity!: number;
@Column({ type: 'numeric', precision: 24, scale: 4 })
public total_amount!: string;
@Column({ type: 'varchar', length: 32 })
public status!: MarketplaceConsolidatedRequestStatus;
@Column({ type: 'timestamptz' })
public cycle_started_at!: Date;
@Column({ type: 'timestamptz', nullable: true })
public cycle_ended_at!: Date | null;
@Column({ type: 'timestamptz', nullable: true })
public expires_at!: Date | null;
@Column({ type: 'timestamptz', nullable: true })
public accepted_at!: Date | null;
@Column({ type: 'timestamptz', nullable: true })
public declined_at!: Date | null;
@Column({ type: 'varchar', length: 500, nullable: true })
public decline_reason!: string | null;
@Column({ type: 'timestamptz', nullable: true })
public triggered_by_supplier_at!: Date | null;
@CreateDateColumn({ type: 'timestamptz' })
public created_at!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
public updated_at!: Date;
}
@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { MarketplaceConsolidatedRequestDomainEntity } from '../../domain/entities/marketplace-consolidated-request.entity';
import { MarketplaceConsolidatedRequestEntity } from '../entities/marketplace-consolidated-request.entity';
@Injectable()
export class MarketplaceConsolidatedRequestMapper {
toDomain(row: MarketplaceConsolidatedRequestEntity): MarketplaceConsolidatedRequestDomainEntity {
return new MarketplaceConsolidatedRequestDomainEntity({
id: row.id,
coopname: row.coopname,
offer_id: row.offer_id,
supplier_account: row.supplier_account,
cycle_type: row.cycle_type,
total_quantity: row.total_quantity,
total_amount: row.total_amount,
status: row.status,
cycle_started_at: row.cycle_started_at,
cycle_ended_at: row.cycle_ended_at,
expires_at: row.expires_at,
accepted_at: row.accepted_at,
declined_at: row.declined_at,
decline_reason: row.decline_reason,
triggered_by_supplier_at: row.triggered_by_supplier_at,
created_at: row.created_at,
updated_at: row.updated_at,
});
}
}
@@ -20,6 +20,7 @@ import { MarketplaceCategoryEntity } from './entities/marketplace-category.entit
import { MarketplaceOfferEntity } from './entities/marketplace-offer.entity';
import { MarketplaceModerationLogEntity } from './entities/marketplace-moderation-log.entity';
import { MarketplaceOrderEntity } from './entities/marketplace-order.entity';
import { MarketplaceConsolidatedRequestEntity } from './entities/marketplace-consolidated-request.entity';
// Repository adapters
import { CategoryRepositoryAdapter } from './adapters/category-repository.adapter';
@@ -38,6 +39,7 @@ import { MarketplaceOfferRepositoryAdapter } from './adapters/marketplace-offer-
import { MarketplaceModerationLogRepositoryAdapter } from './adapters/marketplace-moderation-log-repository.adapter';
import { MarketplaceOrderRepositoryAdapter } from './adapters/marketplace-order-repository.adapter';
import { MarketplaceCanonicalBlockchainAdapter } from './adapters/marketplace-canonical-blockchain.adapter';
import { MarketplaceConsolidatedRequestRepositoryAdapter } from './adapters/marketplace-consolidated-request-repository.adapter';
// Mappers
import { MarketplaceVitrineMapper } from './mappers/marketplace-vitrine.mapper';
@@ -47,6 +49,7 @@ import { MarketplaceOfferMapper } from './mappers/marketplace-offer.mapper';
import { MarketplaceModerationLogMapper } from './mappers/marketplace-moderation-log.mapper';
import { MarketplaceOrderMapper } from './mappers/marketplace-order.mapper';
import { MarketplaceOrderDeltaMapper } from './mappers/marketplace-order-delta.mapper';
import { MarketplaceConsolidatedRequestMapper } from './mappers/marketplace-consolidated-request.mapper';
// Repository tokens
import { CATEGORY_DOMAIN_REPOSITORY } from '../domain/repositories/category-domain.repository';
@@ -65,6 +68,7 @@ import { MARKETPLACE_OFFER_REPOSITORY } from '../domain/repositories/marketplace
import { MARKETPLACE_MODERATION_LOG_REPOSITORY } from '../domain/repositories/marketplace-moderation-log.repository';
import { MARKETPLACE_ORDER_REPOSITORY } from '../domain/repositories/marketplace-order.repository';
import { MARKETPLACE_CANONICAL_BLOCKCHAIN_PORT } from '../domain/ports/marketplace-canonical-blockchain.port';
import { MARKETPLACE_CONSOLIDATED_REQUEST_REPOSITORY } from '../domain/repositories/marketplace-consolidated-request.repository';
@Module({
imports: [
@@ -95,6 +99,7 @@ import { MARKETPLACE_CANONICAL_BLOCKCHAIN_PORT } from '../domain/ports/marketpla
MarketplaceOfferEntity,
MarketplaceModerationLogEntity,
MarketplaceOrderEntity,
MarketplaceConsolidatedRequestEntity,
],
synchronize: true,
logging: false,
@@ -119,6 +124,7 @@ import { MARKETPLACE_CANONICAL_BLOCKCHAIN_PORT } from '../domain/ports/marketpla
MarketplaceOfferEntity,
MarketplaceModerationLogEntity,
MarketplaceOrderEntity,
MarketplaceConsolidatedRequestEntity,
],
'marketplace'
), // Указываем имя подключения
@@ -199,6 +205,12 @@ import { MARKETPLACE_CANONICAL_BLOCKCHAIN_PORT } from '../domain/ports/marketpla
provide: MARKETPLACE_CANONICAL_BLOCKCHAIN_PORT,
useClass: MarketplaceCanonicalBlockchainAdapter,
},
// Story 4.2
MarketplaceConsolidatedRequestMapper,
{
provide: MARKETPLACE_CONSOLIDATED_REQUEST_REPOSITORY,
useClass: MarketplaceConsolidatedRequestRepositoryAdapter,
},
],
exports: [
CATEGORY_DOMAIN_REPOSITORY,
@@ -218,6 +230,7 @@ import { MARKETPLACE_CANONICAL_BLOCKCHAIN_PORT } from '../domain/ports/marketpla
MARKETPLACE_ORDER_REPOSITORY,
MARKETPLACE_CANONICAL_BLOCKCHAIN_PORT,
MarketplaceOrderDeltaMapper,
MARKETPLACE_CONSOLIDATED_REQUEST_REPOSITORY,
],
})
export class MarketplaceInfrastructureModule {}