[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:
+43
@@ -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'
|
||||
) {}
|
||||
+25
@@ -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';
|
||||
}
|
||||
+25
-1
@@ -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 {}
|
||||
|
||||
+126
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+260
@@ -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(/Пул заказов пуст/);
|
||||
});
|
||||
});
|
||||
});
|
||||
+361
@@ -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'
|
||||
);
|
||||
+10
-1
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
+59
-1
@@ -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 {
|
||||
|
||||
+70
@@ -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');
|
||||
});
|
||||
});
|
||||
+68
@@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
+45
@@ -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;
|
||||
}
|
||||
+66
@@ -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>;
|
||||
}
|
||||
+15
@@ -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[]>;
|
||||
}
|
||||
|
||||
+31
@@ -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>;
|
||||
}
|
||||
|
||||
+116
@@ -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);
|
||||
}
|
||||
}
|
||||
+22
@@ -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 может уйти в отрицательное
|
||||
|
||||
+50
@@ -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()
|
||||
|
||||
+81
@@ -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;
|
||||
}
|
||||
+28
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+13
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user