[598-33][@ant] feat: backend корзины + checkout_id заказа-агрегата + КУ-фильтр каталога — перевести заказы на оформление через корзину по выбранному пункту выдачи
This commit is contained in:
+2
@@ -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: заказчик закрывает АПП-выдачу финальной подписью
|
||||
|
||||
+56
@@ -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;
|
||||
}
|
||||
+93
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -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')
|
||||
|
||||
+9
@@ -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,
|
||||
|
||||
+18
@@ -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 {}
|
||||
|
||||
+134
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
@@ -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
|
||||
);
|
||||
|
||||
+187
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
+7
@@ -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,
|
||||
|
||||
+67
@@ -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;
|
||||
}
|
||||
}
|
||||
+29
@@ -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;
|
||||
}
|
||||
+1
@@ -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,
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+48
@@ -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>;
|
||||
}
|
||||
+6
@@ -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 {
|
||||
|
||||
+4
@@ -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;
|
||||
}
|
||||
|
||||
+103
@@ -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() });
|
||||
}
|
||||
}
|
||||
+8
@@ -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') {
|
||||
|
||||
+2
@@ -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];
|
||||
|
||||
+39
@@ -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;
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
+11
@@ -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 = акцептован, но в партию не вошёл. Связь позволяет
|
||||
|
||||
+34
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
@@ -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,
|
||||
|
||||
+17
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user