[598-33][@ant] feat: backend корзины + checkout_id заказа-агрегата + КУ-фильтр каталога — перевести заказы на оформление через корзину по выбранному пункту выдачи

This commit is contained in:
ant
2026-06-02 08:46:32 +00:00
parent 5ba06a1295
commit 16e304fb57
27 changed files with 928 additions and 1 deletions
@@ -33,6 +33,8 @@ export const marketplaceAccessMatrix: Record<MarketplaceRole, Record<string, str
orderer: {
Order: ['create', 'read:own', 'cancel:own'],
Offer: ['read'],
// Эпик 16: заказчик управляет своей корзиной (накопитель перед оформлением).
Cart: ['manage:own'],
KU: ['read'],
Vitrine: ['read'],
// Story 6.3 / FR24: заказчик закрывает АПП-выдачу финальной подписью
@@ -0,0 +1,56 @@
import { Field, InputType, Int } from '@nestjs/graphql';
import { IsInt, IsOptional, IsString, Min } from 'class-validator';
@InputType('MarketplaceAddToCartInput', {
description: 'Добавить позицию в корзину (с привязкой к пункту выдачи).',
})
export class MarketplaceAddToCartInputDTO {
@Field(() => String, { description: 'Идентификатор предложения.' })
@IsString()
public readonly offer_id!: string;
@Field(() => Int, { description: 'Количество единиц (целое, ≥ 1).' })
@IsInt()
@Min(1)
public readonly quantity!: number;
@Field(() => String, {
nullable: true,
description:
'Пункт выдачи (ПВЗ) корзины. Если корзина пуста — задаёт её КУ; если непуста — ' +
'должен совпадать с текущим КУ корзины (один заказ — один КУ).',
})
@IsOptional()
@IsString()
public readonly delivery_braname?: string | null;
}
@InputType('MarketplaceUpdateCartItemInput', {
description: 'Изменить количество позиции в корзине.',
})
export class MarketplaceUpdateCartItemInputDTO {
@Field(() => String, { description: 'Идентификатор предложения позиции.' })
@IsString()
public readonly offer_id!: string;
@Field(() => Int, { description: 'Новое количество единиц (целое, ≥ 1).' })
@IsInt()
@Min(1)
public readonly quantity!: number;
}
@InputType('MarketplaceRemoveFromCartInput', { description: 'Убрать позицию из корзины.' })
export class MarketplaceRemoveFromCartInputDTO {
@Field(() => String, { description: 'Идентификатор предложения позиции.' })
@IsString()
public readonly offer_id!: string;
}
@InputType('MarketplaceSetCartDeliveryPointInput', {
description: 'Сменить пункт выдачи (КУ) корзины.',
})
export class MarketplaceSetCartDeliveryPointInputDTO {
@Field(() => String, { description: 'Имя пункта выдачи (branch.name) нового КУ доставки.' })
@IsString()
public readonly delivery_braname!: string;
}
@@ -0,0 +1,93 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
/**
* Эпик 16: позиция корзины с обогащением для UI. Реквизиты товара
* (название, единица, цена, изображение) подмешиваются на чтении из
* оффера — на самой позиции хранится только offer_id + количество.
*/
@ObjectType('MarketplaceCartItem', { description: 'Позиция корзины заказчика.' })
export class MarketplaceCartItemDTO {
@Field(() => String, { description: 'Идентификатор позиции корзины.' })
public readonly id!: string;
@Field(() => String, { description: 'Идентификатор предложения.' })
public readonly offer_id!: string;
@Field(() => Int, { description: 'Количество единиц в корзине.' })
public readonly quantity!: number;
@Field(() => String, {
nullable: true,
description: 'Название товара из предложения — для отображения в корзине.',
})
public readonly product_name!: string | null;
@Field(() => String, {
nullable: true,
description: 'Единица измерения товара (шт., кг, л, упак.).',
})
public readonly unit_of_measure!: string | null;
@Field(() => String, {
nullable: true,
description: 'Цена за единицу товара на текущий момент.',
})
public readonly price_per_unit!: string | null;
@Field(() => String, {
nullable: true,
description: 'Сумма позиции (цена за единицу × количество).',
})
public readonly line_total!: string | null;
@Field(() => String, {
nullable: true,
description: 'URL обложки товара (если у предложения есть изображение).',
})
public readonly image_url!: string | null;
@Field(() => Boolean, {
description:
'Доступна ли позиция к доставке на текущий пункт выдачи корзины. false — ' +
'товар не возят на выбранный КУ (нужно убрать перед оформлением или сменить КУ).',
})
public readonly available_on_current_ku!: boolean;
constructor(init: Partial<MarketplaceCartItemDTO>) {
Object.assign(this, init);
}
}
@ObjectType('MarketplaceCart', { description: 'Корзина заказчика — накопитель позиций перед оформлением.' })
export class MarketplaceCartDTO {
@Field(() => String, { description: 'Идентификатор корзины.' })
public readonly id!: string;
@Field(() => String, {
nullable: true,
description: 'Пункт выдачи (ПВЗ), к которому привязана корзина; null — пока не выбран.',
})
public readonly delivery_braname!: string | null;
@Field(() => String, {
nullable: true,
description: 'Наименование пункта выдачи (кооперативного участка) — для шапки/корзины.',
})
public readonly delivery_point_name!: string | null;
@Field(() => [MarketplaceCartItemDTO], { description: 'Позиции корзины.' })
public readonly items!: MarketplaceCartItemDTO[];
@Field(() => Int, { description: 'Количество разных позиций (строк) в корзине.' })
public readonly positions_count!: number;
@Field(() => Int, { description: 'Суммарное количество единиц всех позиций.' })
public readonly total_quantity!: number;
@Field(() => String, { description: 'Итоговая сумма корзины (по доступным к доставке позициям).' })
public readonly total_cost!: string;
constructor(init: Partial<MarketplaceCartDTO>) {
Object.assign(this, init);
}
}
@@ -1,5 +1,5 @@
import { Field, InputType, Int, ObjectType } from '@nestjs/graphql';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { PaginationInputDTO } from '~/application/common/dto/pagination.dto';
@InputType('MarketplaceListCatalogInput')
@@ -10,6 +10,16 @@ export class MarketplaceListCatalogInputDTO extends PaginationInputDTO {
@Min(1)
@Max(9)
public category_id?: number | null;
@Field(() => String, {
nullable: true,
description:
'Пункт выдачи (КУ) доставки. Если задан — в каталоге остаются только товары, ' +
'которые возят на этот пункт выдачи (Эпик 16).',
})
@IsOptional()
@IsString()
public delivery_braname?: string | null;
}
@ObjectType('MarketplaceCategoryOfferCount')
@@ -164,6 +164,14 @@ export class MarketplaceOrderDTO {
@Field(() => String, { nullable: true, description: 'Идентификатор партии-накопителя, если заказ присоединён.' })
public readonly cycle_id!: string | null;
@Field(() => String, {
nullable: true,
description:
'Идентификатор заказа заказчика — общий для всех позиций одного оформления корзины на один пункт выдачи. ' +
'Позволяет сгруппировать позиции в один заказ. Пусто для прежних покарточных заказов.',
})
public readonly checkout_id!: string | null;
@Field(() => String, {
nullable: true,
description:
@@ -367,6 +375,7 @@ export function toMarketplaceOrderDTO(
price_per_unit: o.price_per_unit,
total_cost: o.total_cost,
cycle_id: o.cycle_id,
checkout_id: o.checkout_id,
shipment_id: o.shipment_id,
warranty_period_secs: o.warranty_period_secs,
warranty_until: o.warranty_until,
@@ -126,6 +126,12 @@ import { MarketplaceWriteoffCronService } from './services/marketplace-writeoff-
import { MarketplaceWriteoffResolver } from './resolvers/marketplace-writeoff.resolver';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MarketplaceInventoryEntity } from '../infrastructure/entities/marketplace-inventory.entity';
// Эпик 16 — корзина и заказ-агрегат
import { MarketplaceCartResolver } from './resolvers/marketplace-cart.resolver';
import {
MarketplaceCartService,
MARKETPLACE_CART_SERVICE,
} from './services/marketplace-cart.service';
/**
* Модуль приложения marketplace
@@ -195,6 +201,8 @@ import { MarketplaceInventoryEntity } from '../infrastructure/entities/marketpla
MarketplaceOutgoingPaymentResolver,
MarketplaceIssuanceResolver,
MarketplaceReturnClaimResolver,
// Эпик 16 — корзина заказчика
MarketplaceCartResolver,
// Guards (Story 1.3 / Story 1.6)
MarketplaceMembershipGuard,
@@ -341,6 +349,12 @@ import { MarketplaceInventoryEntity } from '../infrastructure/entities/marketpla
MarketplaceWriteoffService,
MarketplaceWriteoffCronService,
MarketplaceWriteoffResolver,
// Эпик 16 — корзина заказчика
{
provide: MARKETPLACE_CART_SERVICE,
useClass: MarketplaceCartService,
},
MarketplaceCartService,
],
exports: [
// Экспортируем сервисы для использования в других модулях
@@ -421,6 +435,10 @@ import { MarketplaceInventoryEntity } from '../infrastructure/entities/marketpla
// Эпик 8
MarketplaceWriteoffService,
MarketplaceWriteoffResolver,
// Эпик 16 — корзина заказчика
MARKETPLACE_CART_SERVICE,
MarketplaceCartService,
MarketplaceCartResolver,
],
})
export class MarketplaceExtensionApplicationModule {}
@@ -0,0 +1,134 @@
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 { MarketplaceCartDTO } from '../dto/marketplace-cart.dto';
import {
MarketplaceAddToCartInputDTO,
MarketplaceRemoveFromCartInputDTO,
MarketplaceSetCartDeliveryPointInputDTO,
MarketplaceUpdateCartItemInputDTO,
} from '../dto/marketplace-cart-input.dto';
import {
MARKETPLACE_CART_SERVICE,
MarketplaceCartService,
} from '../services/marketplace-cart.service';
/**
* Эпик 16: корзина заказчика — точка оформления заказа. Все операции
* приватны для текущего пайщика (orderer): корзина одна на пару
* (coopname, orderer_account).
*/
@Resolver()
@Injectable()
export class MarketplaceCartResolver {
constructor(
@Inject(MARKETPLACE_CART_SERVICE)
private readonly cartService: MarketplaceCartService
) {}
@Query(() => MarketplaceCartDTO, {
name: 'marketplaceGetCart',
description: 'Корзина текущего заказчика (создаётся пустой при первом обращении).',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Cart', 'manage:own')
async marketplaceGetCart(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember
): Promise<MarketplaceCartDTO> {
return this.cartService.getCart({
coopname: config.coopname,
orderer_account: member.username,
});
}
@Mutation(() => MarketplaceCartDTO, {
name: 'marketplaceAddToCart',
description: 'Добавить товар в корзину (с привязкой корзины к пункту выдачи).',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Cart', 'manage:own')
async marketplaceAddToCart(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('input') input: MarketplaceAddToCartInputDTO
): Promise<MarketplaceCartDTO> {
return this.cartService.addToCart(
{ coopname: config.coopname, orderer_account: member.username },
{
offer_id: input.offer_id,
quantity: input.quantity,
delivery_braname: input.delivery_braname ?? null,
}
);
}
@Mutation(() => MarketplaceCartDTO, {
name: 'marketplaceUpdateCartItem',
description: 'Изменить количество позиции в корзине.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Cart', 'manage:own')
async marketplaceUpdateCartItem(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('input') input: MarketplaceUpdateCartItemInputDTO
): Promise<MarketplaceCartDTO> {
return this.cartService.updateItem(
{ coopname: config.coopname, orderer_account: member.username },
{ offer_id: input.offer_id, quantity: input.quantity }
);
}
@Mutation(() => MarketplaceCartDTO, {
name: 'marketplaceRemoveFromCart',
description: 'Убрать позицию из корзины.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Cart', 'manage:own')
async marketplaceRemoveFromCart(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('input') input: MarketplaceRemoveFromCartInputDTO
): Promise<MarketplaceCartDTO> {
return this.cartService.removeItem(
{ coopname: config.coopname, orderer_account: member.username },
input.offer_id
);
}
@Mutation(() => MarketplaceCartDTO, {
name: 'marketplaceClearCart',
description: 'Очистить корзину (убрать все позиции).',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Cart', 'manage:own')
async marketplaceClearCart(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember
): Promise<MarketplaceCartDTO> {
return this.cartService.clear({
coopname: config.coopname,
orderer_account: member.username,
});
}
@Mutation(() => MarketplaceCartDTO, {
name: 'marketplaceSetCartDeliveryPoint',
description: 'Сменить пункт выдачи (КУ) корзины — каталог зависит от выбранного КУ.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Cart', 'manage:own')
async marketplaceSetCartDeliveryPoint(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('input') input: MarketplaceSetCartDeliveryPointInputDTO
): Promise<MarketplaceCartDTO> {
return this.cartService.setDeliveryPoint(
{ coopname: config.coopname, orderer_account: member.username },
input.delivery_braname
);
}
}
@@ -66,6 +66,7 @@ export class MarketplaceCatalogResolver {
status: MarketplaceOfferStatuses.ACTIVE,
category_id: input?.category_id ?? undefined,
available_only: true,
delivery_braname: input?.delivery_braname ?? undefined,
},
pagination
);
@@ -0,0 +1,187 @@
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import {
MARKETPLACE_CART_REPOSITORY,
type MarketplaceCartDomainRepository,
} from '../../domain/repositories/marketplace-cart.repository';
import {
MARKETPLACE_OFFER_REPOSITORY,
type MarketplaceOfferDomainRepository,
} from '../../domain/repositories/marketplace-offer.repository';
import { MarketplaceOfferStatuses } from '../../domain/entities/marketplace-offer.types';
import type { MarketplaceOfferDomainEntity } from '../../domain/entities/marketplace-offer.entity';
import type { MarketplaceCartDomainEntity } from '../../domain/entities/marketplace-cart.entity';
import { MarketplaceCartDTO, MarketplaceCartItemDTO } from '../dto/marketplace-cart.dto';
export const MARKETPLACE_CART_SERVICE = Symbol('MARKETPLACE_CART_SERVICE');
interface CartScope {
coopname: string;
orderer_account: string;
}
/**
* Эпик 16: бизнес-логика корзины заказчика.
*
* Инвариант «один заказ — один КУ» начинается уже в корзине: корзина
* привязана к одному `delivery_braname`; добавить позицию на другой КУ
* нельзя, пока корзина непуста (нужно сменить КУ или очистить). Каталог
* отфильтрован по КУ (Story 16.3), здесь — защитная проверка доступности
* оффера на КУ корзины.
*/
@Injectable()
export class MarketplaceCartService {
constructor(
@Inject(MARKETPLACE_CART_REPOSITORY)
private readonly cartRepo: MarketplaceCartDomainRepository,
@Inject(MARKETPLACE_OFFER_REPOSITORY)
private readonly offerRepo: MarketplaceOfferDomainRepository
) {}
async getCart(scope: CartScope): Promise<MarketplaceCartDTO> {
const cart = await this.cartRepo.getOrCreate(scope.coopname, scope.orderer_account);
return this.buildCartDTO(cart);
}
async addToCart(
scope: CartScope,
input: { offer_id: string; quantity: number; delivery_braname?: string | null }
): Promise<MarketplaceCartDTO> {
if (!Number.isInteger(input.quantity) || input.quantity <= 0) {
throw new BadRequestException('Количество должно быть целым числом больше нуля.');
}
const offer = await this.requireActiveOffer(scope.coopname, input.offer_id);
const cart = await this.cartRepo.getOrCreate(scope.coopname, scope.orderer_account);
// Определяем КУ корзины: при непустой корзине КУ зафиксирован, добавлять
// можно только в его контексте; при пустой — КУ задаётся первым добавлением.
const targetKu = this.resolveTargetDeliveryBraname(cart, input.delivery_braname);
if (targetKu && !this.offerDeliversTo(offer, targetKu)) {
throw new BadRequestException(
'Этот товар не возят на выбранный пункт выдачи. Смените КУ или выберите другой товар.'
);
}
if (targetKu && targetKu !== cart.delivery_braname) {
await this.cartRepo.setDeliveryBraname(cart.id, targetKu);
}
await this.cartRepo.upsertItem(cart.id, scope.coopname, offer.id, input.quantity);
return this.getCart(scope);
}
async updateItem(
scope: CartScope,
input: { offer_id: string; quantity: number }
): Promise<MarketplaceCartDTO> {
if (!Number.isInteger(input.quantity) || input.quantity <= 0) {
throw new BadRequestException('Количество должно быть целым числом больше нуля.');
}
const cart = await this.cartRepo.getOrCreate(scope.coopname, scope.orderer_account);
await this.cartRepo.setItemQuantity(cart.id, input.offer_id, input.quantity);
return this.getCart(scope);
}
async removeItem(scope: CartScope, offer_id: string): Promise<MarketplaceCartDTO> {
const cart = await this.cartRepo.getOrCreate(scope.coopname, scope.orderer_account);
await this.cartRepo.removeItem(cart.id, offer_id);
return this.getCart(scope);
}
async clear(scope: CartScope): Promise<MarketplaceCartDTO> {
const cart = await this.cartRepo.getOrCreate(scope.coopname, scope.orderer_account);
await this.cartRepo.clear(cart.id);
return this.getCart(scope);
}
async setDeliveryPoint(scope: CartScope, delivery_braname: string): Promise<MarketplaceCartDTO> {
if (!delivery_braname) {
throw new BadRequestException('Не указан пункт выдачи.');
}
const cart = await this.cartRepo.getOrCreate(scope.coopname, scope.orderer_account);
await this.cartRepo.setDeliveryBraname(cart.id, delivery_braname);
return this.getCart(scope);
}
// ── private ──
private async requireActiveOffer(
coopname: string,
offer_id: string
): Promise<MarketplaceOfferDomainEntity> {
const offer = await this.offerRepo.findById(offer_id);
if (!offer) {
throw new NotFoundException('Предложение не найдено.');
}
if (offer.coopname !== coopname) {
throw new ForbiddenException('Предложение принадлежит другому кооперативу.');
}
if (offer.status !== MarketplaceOfferStatuses.ACTIVE) {
throw new BadRequestException(
`Предложение не активно (статус «${offer.status}»). В корзину добавить нельзя.`
);
}
return offer;
}
private resolveTargetDeliveryBraname(
cart: MarketplaceCartDomainEntity,
inputKu: string | null | undefined
): string | null {
if (cart.delivery_braname) {
// Корзина уже привязана к КУ: если инпут указывает другой — отказ.
if (inputKu && inputKu !== cart.delivery_braname) {
throw new BadRequestException(
'Корзина привязана к другому пункту выдачи. Смените КУ или очистите корзину перед добавлением.'
);
}
return cart.delivery_braname;
}
return inputKu ?? null;
}
private offerDeliversTo(offer: MarketplaceOfferDomainEntity, braname: string): boolean {
return offer.delivery_points.some((dp) => dp.braname === braname);
}
private async buildCartDTO(cart: MarketplaceCartDomainEntity): Promise<MarketplaceCartDTO> {
const offerIds = cart.items.map((i) => i.offer_id);
const offers = offerIds.length ? await this.offerRepo.findByIds(offerIds) : [];
const offerById = new Map(offers.map((o) => [o.id, o]));
let totalCost = 0;
const items = cart.items.map((item) => {
const offer = offerById.get(item.offer_id) ?? null;
const price = offer ? Number.parseFloat(offer.price_per_unit) : null;
const lineTotalNum = price !== null ? price * item.quantity : null;
const available = offer
? cart.delivery_braname
? this.offerDeliversTo(offer, cart.delivery_braname)
: true
: false;
if (available && lineTotalNum !== null) {
totalCost += lineTotalNum;
}
return new MarketplaceCartItemDTO({
id: item.id,
offer_id: item.offer_id,
quantity: item.quantity,
product_name: offer?.product_name ?? null,
unit_of_measure: offer?.unit_of_measure ?? null,
price_per_unit: offer?.price_per_unit ?? null,
line_total: lineTotalNum !== null ? lineTotalNum.toFixed(4) : null,
image_url: null,
available_on_current_ku: available,
});
});
return new MarketplaceCartDTO({
id: cart.id,
delivery_braname: cart.delivery_braname,
delivery_point_name: null,
items,
positions_count: cart.positions_count,
total_quantity: cart.total_quantity,
total_cost: totalCost.toFixed(4),
});
}
}
@@ -40,6 +40,12 @@ export interface MarketplaceOrderCreateInputDto {
quantity: number;
/** ПВЗ получения (branch.name). Story 2.3 валидирует existence в C++. */
delivery_braname: string;
/**
* Грань «заказ заказчика» (Эпик 16): общий id всех строк одного
* оформления корзины на один КУ. Штампуется checkout-сервисом; для
* legacy покарточного заказа — не передаётся (null).
*/
checkout_id?: string | null;
}
export interface MarketplaceOrderCreateResult {
@@ -201,6 +207,7 @@ export class MarketplaceOrderCreateService {
price_per_unit: offer.price_per_unit,
total_cost: locked_amount,
cycle_id: null,
checkout_id: input.checkout_id ?? null,
warranty_period_secs,
warranty_until: null,
status: MarketplaceOrderStatuses.ACTIVE,
@@ -0,0 +1,67 @@
import type {
MarketplaceCartItemProps,
MarketplaceCartProps,
} from './marketplace-cart.types';
/**
* Эпик 16: позиция корзины — оффер + количество. Денормализованный
* `coopname` повторяет коопнейм корзины (упрощает scoped-запросы).
*/
export class MarketplaceCartItemDomainEntity {
public readonly id: string;
public readonly cart_id: string;
public readonly coopname: string;
public readonly offer_id: string;
public readonly quantity: number;
public readonly created_at: Date;
public readonly updated_at: Date;
constructor(props: MarketplaceCartItemProps) {
this.id = props.id;
this.cart_id = props.cart_id;
this.coopname = props.coopname;
this.offer_id = props.offer_id;
this.quantity = props.quantity;
this.created_at = props.created_at;
this.updated_at = props.updated_at;
}
}
/**
* Эпик 16: корзина заказчика как агрегат позиций. Off-chain. Привязана к
* текущему КУ доставки — оформление (checkout) штампует все строки общим
* `checkout_id` и этим КУ (инвариант «один заказ — один КУ»).
*/
export class MarketplaceCartDomainEntity {
public readonly id: string;
public readonly coopname: string;
public readonly orderer_account: string;
public readonly delivery_braname: string | null;
public readonly items: MarketplaceCartItemDomainEntity[];
public readonly created_at: Date;
public readonly updated_at: Date;
constructor(props: MarketplaceCartProps) {
this.id = props.id;
this.coopname = props.coopname;
this.orderer_account = props.orderer_account;
this.delivery_braname = props.delivery_braname;
this.items = props.items.map((i) => new MarketplaceCartItemDomainEntity(i));
this.created_at = props.created_at;
this.updated_at = props.updated_at;
}
/** Кол-во разных позиций (строк) в корзине. */
public get positions_count(): number {
return this.items.length;
}
/** Суммарное кол-во единиц всех позиций. */
public get total_quantity(): number {
return this.items.reduce((sum, i) => sum + i.quantity, 0);
}
public get is_empty(): boolean {
return this.items.length === 0;
}
}
@@ -0,0 +1,29 @@
/**
* Эпик 16: корзина заказчика — серверный накопитель позиций перед
* оформлением заказа. Off-chain (не синхронизируется с блокчейном):
* корзина — это намерение, заказ появляется только при checkout'е.
*
* Одна корзина на пару (coopname, orderer_account), привязана к текущему
* КУ доставки (`delivery_braname`). Смена КУ меняет контекст каталога —
* позиции, недоступные на новом КУ, помечаются на чтении (см. сервис).
*/
export interface MarketplaceCartItemProps {
id: string;
cart_id: string;
coopname: string;
offer_id: string;
quantity: number;
created_at: Date;
updated_at: Date;
}
export interface MarketplaceCartProps {
id: string;
coopname: string;
orderer_account: string;
/** Текущий КУ доставки корзины (branch.name); null — пока не выбран. */
delivery_braname: string | null;
items: MarketplaceCartItemProps[];
created_at: Date;
updated_at: Date;
}
@@ -15,6 +15,7 @@ function buildProps(overrides: Partial<MarketplaceOrderProps> = {}): Marketplace
price_per_unit: '150.0000',
total_cost: '450.0000',
cycle_id: null,
checkout_id: null,
shipment_id: null,
warranty_period_secs: 7 * 86_400,
warranty_until: null,
@@ -37,6 +37,8 @@ export class MarketplaceOrderDomainEntity implements IBlockchainSynchronizable {
public readonly price_per_unit: string;
public readonly total_cost: string;
public readonly cycle_id: string | null;
/** Грань «заказ заказчика» (Эпик 16): общий id строк одного оформления на один КУ; null = legacy покарточный заказ. */
public readonly checkout_id: string | null;
/** Партия, в которую заказ включён при формировании (null = вне партии). */
public shipment_id: string | null;
public readonly warranty_period_secs: number;
@@ -91,6 +93,7 @@ export class MarketplaceOrderDomainEntity implements IBlockchainSynchronizable {
this.price_per_unit = props.price_per_unit;
this.total_cost = props.total_cost;
this.cycle_id = props.cycle_id;
this.checkout_id = props.checkout_id;
this.shipment_id = props.shipment_id;
this.warranty_period_secs = props.warranty_period_secs;
this.warranty_until = props.warranty_until;
@@ -105,6 +105,8 @@ export interface MarketplaceOrderProps {
price_per_unit: string;
total_cost: string;
cycle_id: string | null;
/** Грань «заказ заказчика» (Эпик 16): общий id строк одного оформления на один КУ; null = legacy покарточный заказ. */
checkout_id: string | null;
/** Партия, в которую заказ включён при формировании (null = вне партии). */
shipment_id: string | null;
warranty_period_secs: number;
@@ -0,0 +1,48 @@
import type { MarketplaceCartDomainEntity } from '../entities/marketplace-cart.entity';
export const MARKETPLACE_CART_REPOSITORY = Symbol('MARKETPLACE_CART_REPOSITORY');
/**
* Эпик 16: репозиторий корзины заказчика. Off-chain CRUD, без интеграции
* с syncer'ом — корзина существует только в PG. Одна корзина на пару
* (coopname, orderer_account) гарантируется уникальным индексом; методы
* чтения/мутации работают через `getOrCreate`.
*/
export interface MarketplaceCartDomainRepository {
/**
* Возвращает корзину заказчика, создавая пустую при первом обращении.
* Всегда с подгруженными позициями.
*/
getOrCreate(coopname: string, orderer_account: string): Promise<MarketplaceCartDomainEntity>;
/** Корзина заказчика с позициями или null, если ещё не создавалась. */
findByOrderer(
coopname: string,
orderer_account: string
): Promise<MarketplaceCartDomainEntity | null>;
/**
* Добавить/долить позицию: если оффер уже в корзине — количество
* суммируется (слияние одинаковых позиций), иначе создаётся строка.
*/
upsertItem(cart_id: string, coopname: string, offer_id: string, quantity: number): Promise<void>;
/** Установить точное количество по офферу (например из инпута корзины). */
setItemQuantity(cart_id: string, offer_id: string, quantity: number): Promise<void>;
/** Убрать позицию из корзины. */
removeItem(cart_id: string, offer_id: string): Promise<void>;
/** Очистить корзину (все позиции). */
clear(cart_id: string): Promise<void>;
/**
* Убрать набор офферов из корзины одним запросом — используется после
* успешного оформления, чтобы вынуть прошедшие позиции (остаток для
* повтора сохраняется).
*/
removeItems(cart_id: string, offer_ids: string[]): Promise<void>;
/** Сменить КУ доставки корзины (контекст каталога). */
setDeliveryBraname(cart_id: string, delivery_braname: string | null): Promise<void>;
}
@@ -20,6 +20,12 @@ export interface OfferListFilter {
status?: MarketplaceOfferStatus | MarketplaceOfferStatus[];
category_id?: number;
available_only?: boolean;
/**
* Эпик 16 (Story 16.3): КУ доставки. Если задан — в каталоге остаются
* только офферы, чей `delivery_points` содержит этот braname (поставщик
* возит на этот пункт выдачи).
*/
delivery_braname?: string;
}
export interface OfferCreateInput {
@@ -25,6 +25,8 @@ export interface MarketplaceOrderCreateInput {
price_per_unit: string;
total_cost: string;
cycle_id: string | null;
/** Грань «заказ заказчика» (Эпик 16): общий id строк одного оформления на один КУ. */
checkout_id: string | null;
warranty_period_secs: number;
warranty_until: Date | null;
status: MarketplaceOrderStatus;
@@ -39,6 +41,8 @@ export interface MarketplaceOrderListFilter {
offer_id?: string;
status?: MarketplaceOrderStatus | MarketplaceOrderStatus[];
cycle_id?: string;
/** Грань «заказ заказчика» (Эпик 16): фильтр строк одного оформления. */
checkout_id?: string;
/** ПВЗ доставки заказа (Story 14.2: express-приёмка ACCEPTED-заказов на КУ). */
delivery_braname?: string;
}
@@ -0,0 +1,103 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { MarketplaceCartDomainEntity } from '../../domain/entities/marketplace-cart.entity';
import type { MarketplaceCartDomainRepository } from '../../domain/repositories/marketplace-cart.repository';
import { MarketplaceCartEntity } from '../entities/marketplace-cart.entity';
import { MarketplaceCartItemEntity } from '../entities/marketplace-cart-item.entity';
import { MarketplaceCartMapper } from '../mappers/marketplace-cart.mapper';
@Injectable()
export class MarketplaceCartRepositoryAdapter implements MarketplaceCartDomainRepository {
constructor(
@InjectRepository(MarketplaceCartEntity, 'marketplace')
private readonly cartRepo: Repository<MarketplaceCartEntity>,
@InjectRepository(MarketplaceCartItemEntity, 'marketplace')
private readonly itemRepo: Repository<MarketplaceCartItemEntity>,
private readonly mapper: MarketplaceCartMapper
) {}
async getOrCreate(
coopname: string,
orderer_account: string
): Promise<MarketplaceCartDomainEntity> {
let cart = await this.cartRepo.findOne({ where: { coopname, orderer_account } });
if (!cart) {
// Идемпотентно: при гонке двух первых обращений уникальный индекс
// (coopname, orderer_account) отсечёт дубль — перечитываем.
try {
cart = await this.cartRepo.save(
this.cartRepo.create({ coopname, orderer_account, delivery_braname: null })
);
} catch {
cart = await this.cartRepo.findOneOrFail({ where: { coopname, orderer_account } });
}
}
return this.loadAggregate(cart);
}
async findByOrderer(
coopname: string,
orderer_account: string
): Promise<MarketplaceCartDomainEntity | null> {
const cart = await this.cartRepo.findOne({ where: { coopname, orderer_account } });
return cart ? this.loadAggregate(cart) : null;
}
async upsertItem(
cart_id: string,
coopname: string,
offer_id: string,
quantity: number
): Promise<void> {
const existing = await this.itemRepo.findOne({ where: { cart_id, offer_id } });
if (existing) {
await this.itemRepo.update({ id: existing.id }, { quantity: existing.quantity + quantity });
} else {
await this.itemRepo.save(
this.itemRepo.create({ cart_id, coopname, offer_id, quantity })
);
}
await this.touchCart(cart_id);
}
async setItemQuantity(cart_id: string, offer_id: string, quantity: number): Promise<void> {
await this.itemRepo.update({ cart_id, offer_id }, { quantity });
await this.touchCart(cart_id);
}
async removeItem(cart_id: string, offer_id: string): Promise<void> {
await this.itemRepo.delete({ cart_id, offer_id });
await this.touchCart(cart_id);
}
async clear(cart_id: string): Promise<void> {
await this.itemRepo.delete({ cart_id });
await this.touchCart(cart_id);
}
async removeItems(cart_id: string, offer_ids: string[]): Promise<void> {
if (offer_ids.length === 0) return;
await this.itemRepo.delete({ cart_id, offer_id: In(offer_ids) });
await this.touchCart(cart_id);
}
async setDeliveryBraname(cart_id: string, delivery_braname: string | null): Promise<void> {
await this.cartRepo.update({ id: cart_id }, { delivery_braname });
}
// ── private ──
private async loadAggregate(cart: MarketplaceCartEntity): Promise<MarketplaceCartDomainEntity> {
const items = await this.itemRepo.find({
where: { cart_id: cart.id },
order: { created_at: 'ASC' },
});
return this.mapper.toDomain(cart, items);
}
/** Обновить updated_at корзины при изменении состава (для индикатора/сортировки). */
private async touchCart(cart_id: string): Promise<void> {
await this.cartRepo.update({ id: cart_id }, { updated_at: new Date() });
}
}
@@ -64,6 +64,14 @@ export class MarketplaceOfferRepositoryAdapter implements MarketplaceOfferDomain
qb.andWhere('(o.unlimited_flag = true OR o.quantity_available > 0)');
}
// Story 16.3: КУ-доступность — оффер виден на КУ, если его delivery_points
// содержит объект с этим braname (jsonb containment @>).
if (filter.delivery_braname) {
qb.andWhere('o.delivery_points @> :dp', {
dp: JSON.stringify([{ braname: filter.delivery_braname }]),
});
}
const sortColumn = MarketplaceOfferRepositoryAdapter.resolveSortColumn(pagination.sortBy);
qb.orderBy(sortColumn, pagination.sortOrder);
if (sortColumn !== 'o.created_at') {
@@ -41,6 +41,7 @@ export class MarketplaceOrderRepositoryAdapter implements MarketplaceOrderDomain
price_per_unit: input.price_per_unit,
total_cost: input.total_cost,
cycle_id: input.cycle_id,
checkout_id: input.checkout_id ?? null,
warranty_period_secs: input.warranty_period_secs,
warranty_until: input.warranty_until,
status: input.status,
@@ -89,6 +90,7 @@ export class MarketplaceOrderRepositoryAdapter implements MarketplaceOrderDomain
if (filter.supplier_account) qb.andWhere('o.supplier_account = :sup', { sup: filter.supplier_account });
if (filter.offer_id) qb.andWhere('o.offer_id = :off', { off: filter.offer_id });
if (filter.cycle_id) qb.andWhere('o.cycle_id = :cid', { cid: filter.cycle_id });
if (filter.checkout_id) qb.andWhere('o.checkout_id = :chid', { chid: filter.checkout_id });
if (filter.delivery_braname) qb.andWhere('o.delivery_braname = :br', { br: filter.delivery_braname });
if (filter.status) {
const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
@@ -0,0 +1,39 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* Эпик 16: позиция корзины (оффер × количество). Уникальный индекс
* (cart_id, offer_id) обеспечивает слияние одинаковых позиций — повторное
* добавление того же оффера доливает количество, а не плодит строки.
* Off-chain, DDL через `synchronize`.
*/
@Entity({ name: 'marketplace_cart_item' })
@Index('IDX_marketplace_cart_item_unique', ['cart_id', 'offer_id'], { unique: true })
export class MarketplaceCartItemEntity {
@PrimaryGeneratedColumn('uuid')
public id!: string;
@Column({ type: 'uuid' })
public cart_id!: string;
@Column({ type: 'varchar', length: 13 })
public coopname!: string;
@Column({ type: 'uuid' })
public offer_id!: string;
@Column({ type: 'integer' })
public quantity!: number;
@CreateDateColumn({ type: 'timestamptz' })
public created_at!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
public updated_at!: Date;
}
@@ -0,0 +1,35 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* Эпик 16: корзина заказчика. Одна на пару (coopname, orderer_account) —
* гарантируется уникальным индексом. Off-chain, DDL через `synchronize`.
*/
@Entity({ name: 'marketplace_cart' })
@Index('IDX_marketplace_cart_orderer_unique', ['coopname', 'orderer_account'], { unique: true })
export class MarketplaceCartEntity {
@PrimaryGeneratedColumn('uuid')
public id!: string;
@Column({ type: 'varchar', length: 13 })
public coopname!: string;
@Column({ type: 'varchar', length: 13 })
public orderer_account!: string;
/** Текущий КУ доставки корзины (branch.name); null — пока не выбран. */
@Column({ type: 'varchar', length: 13, nullable: true })
public delivery_braname!: string | null;
@CreateDateColumn({ type: 'timestamptz' })
public created_at!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
public updated_at!: Date;
}
@@ -36,6 +36,7 @@ import type { ISignedDocumentDomainInterface } from '~/domain/document/interface
@Index(['coopname', 'status', 'created_at'])
@Index(['offer_id', 'status'])
@Index(['coopname', 'shipment_id'])
@Index(['coopname', 'orderer_account', 'checkout_id'])
export class MarketplaceOrderEntity {
@PrimaryGeneratedColumn('uuid')
public id!: string;
@@ -82,6 +83,16 @@ export class MarketplaceOrderEntity {
@Column({ type: 'uuid', nullable: true })
public cycle_id!: string | null;
/**
* Грань «заказ заказчика» (Эпик 16): общий идентификатор всех строк
* одного оформления корзины на один КУ. Штампуется при checkout'е; до
* перехода на корзину (legacy покарточный заказ) — null. Один checkout_id
* = один `delivery_braname` (инвариант «один заказ — один КУ» enforced
* на уровне сервиса оформления).
*/
@Column({ type: 'uuid', nullable: true })
public checkout_id!: string | null;
/**
* Backend-only: партия (`marketplace_shipment.id`), в которую заказ включён
* при формировании. null = акцептован, но в партию не вошёл. Связь позволяет
@@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { MarketplaceCartDomainEntity } from '../../domain/entities/marketplace-cart.entity';
import { MarketplaceCartEntity } from '../entities/marketplace-cart.entity';
import { MarketplaceCartItemEntity } from '../entities/marketplace-cart-item.entity';
/**
* Эпик 16: row → domain для корзины. Позиции собираются отдельной выборкой
* (cart header + items) и склеиваются в доменный агрегат.
*/
@Injectable()
export class MarketplaceCartMapper {
toDomain(
cart: MarketplaceCartEntity,
items: MarketplaceCartItemEntity[]
): MarketplaceCartDomainEntity {
return new MarketplaceCartDomainEntity({
id: cart.id,
coopname: cart.coopname,
orderer_account: cart.orderer_account,
delivery_braname: cart.delivery_braname ?? null,
items: items.map((i) => ({
id: i.id,
cart_id: i.cart_id,
coopname: i.coopname,
offer_id: i.offer_id,
quantity: i.quantity,
created_at: i.created_at,
updated_at: i.updated_at,
})),
created_at: cart.created_at,
updated_at: cart.updated_at,
});
}
}
@@ -23,6 +23,7 @@ export class MarketplaceOrderMapper {
price_per_unit: row.price_per_unit,
total_cost: row.total_cost,
cycle_id: row.cycle_id,
checkout_id: row.checkout_id ?? null,
shipment_id: row.shipment_id ?? null,
warranty_period_secs: row.warranty_period_secs,
warranty_until: row.warranty_until,
@@ -29,6 +29,8 @@ import { MarketplaceOutgoingPaymentRequestEntity } from './entities/marketplace-
import { MarketplaceTtnDocumentEntity } from './entities/marketplace-ttn-document.entity';
import { MarketplaceReturnClaimEntity } from './entities/marketplace-return-claim.entity';
import { MarketplaceWriteoffProposalEntity } from './entities/marketplace-writeoff-proposal.entity';
import { MarketplaceCartEntity } from './entities/marketplace-cart.entity';
import { MarketplaceCartItemEntity } from './entities/marketplace-cart-item.entity';
// Repository adapters
import { CategoryRepositoryAdapter } from './adapters/category-repository.adapter';
@@ -56,6 +58,7 @@ import { MarketplaceOutgoingPaymentRequestRepositoryAdapter } from './adapters/m
import { MarketplaceTtnDocumentRepositoryAdapter } from './adapters/marketplace-ttn-document-repository.adapter';
import { MarketplaceReturnClaimRepositoryAdapter } from './adapters/marketplace-return-claim-repository.adapter';
import { MarketplaceWriteoffProposalRepositoryAdapter } from './adapters/marketplace-writeoff-proposal-repository.adapter';
import { MarketplaceCartRepositoryAdapter } from './adapters/marketplace-cart-repository.adapter';
// Mappers
import { MarketplaceVitrineMapper } from './mappers/marketplace-vitrine.mapper';
@@ -74,6 +77,7 @@ import { MarketplaceOutgoingPaymentRequestMapper } from './mappers/marketplace-o
import { MarketplaceTtnDocumentMapper } from './mappers/marketplace-ttn-document.mapper';
import { MarketplaceReturnClaimMapper } from './mappers/marketplace-return-claim.mapper';
import { MarketplaceWriteoffProposalMapper } from './mappers/marketplace-writeoff-proposal.mapper';
import { MarketplaceCartMapper } from './mappers/marketplace-cart.mapper';
// Repository tokens
import { CATEGORY_DOMAIN_REPOSITORY } from '../domain/repositories/category-domain.repository';
@@ -101,6 +105,7 @@ import { MARKETPLACE_OUTGOING_PAYMENT_REQUEST_REPOSITORY } from '../domain/repos
import { MARKETPLACE_TTN_DOCUMENT_REPOSITORY } from '../domain/repositories/marketplace-ttn-document.repository';
import { MARKETPLACE_RETURN_CLAIM_REPOSITORY } from '../domain/repositories/marketplace-return-claim.repository';
import { MARKETPLACE_WRITEOFF_PROPOSAL_REPOSITORY } from '../domain/repositories/marketplace-writeoff-proposal.repository';
import { MARKETPLACE_CART_REPOSITORY } from '../domain/repositories/marketplace-cart.repository';
import { VaultDomainModule } from '~/domain/vault/vault-domain.module';
@Module({
@@ -141,6 +146,8 @@ import { VaultDomainModule } from '~/domain/vault/vault-domain.module';
MarketplaceTtnDocumentEntity,
MarketplaceReturnClaimEntity,
MarketplaceWriteoffProposalEntity,
MarketplaceCartEntity,
MarketplaceCartItemEntity,
],
synchronize: true,
logging: false,
@@ -174,6 +181,8 @@ import { VaultDomainModule } from '~/domain/vault/vault-domain.module';
MarketplaceTtnDocumentEntity,
MarketplaceReturnClaimEntity,
MarketplaceWriteoffProposalEntity,
MarketplaceCartEntity,
MarketplaceCartItemEntity,
],
'marketplace'
), // Указываем имя подключения
@@ -310,6 +319,12 @@ import { VaultDomainModule } from '~/domain/vault/vault-domain.module';
provide: MARKETPLACE_WRITEOFF_PROPOSAL_REPOSITORY,
useClass: MarketplaceWriteoffProposalRepositoryAdapter,
},
// Эпик 16 — корзина заказчика (off-chain CRUD)
MarketplaceCartMapper,
{
provide: MARKETPLACE_CART_REPOSITORY,
useClass: MarketplaceCartRepositoryAdapter,
},
],
exports: [
CATEGORY_DOMAIN_REPOSITORY,
@@ -345,6 +360,8 @@ import { VaultDomainModule } from '~/domain/vault/vault-domain.module';
MARKETPLACE_RETURN_CLAIM_REPOSITORY,
// Эпик 8 — списание скоропорта
MARKETPLACE_WRITEOFF_PROPOSAL_REPOSITORY,
// Эпик 16 — корзина заказчика
MARKETPLACE_CART_REPOSITORY,
],
})
export class MarketplaceInfrastructureModule {}