[598-33][@ant] feat: checkout-сервис оформления из корзины — построчное создание заказов с общим checkout_id, предвалидация баланса и сохранение непрошедшего остатка для повтора

This commit is contained in:
ant
2026-06-02 08:52:50 +00:00
parent 16e304fb57
commit e2b42f4b0b
4 changed files with 321 additions and 1 deletions
@@ -0,0 +1,74 @@
import { Field, InputType, Int, ObjectType } from '@nestjs/graphql';
import { IsOptional, IsString } from 'class-validator';
import { MarketplaceOrderDTO } from './marketplace-order.dto';
import { MarketplaceCartDTO } from './marketplace-cart.dto';
@InputType('MarketplaceCheckoutCartInput', {
description: 'Оформить заказ из корзины (или повторить упавший остаток того же заказа).',
})
export class MarketplaceCheckoutCartInputDTO {
@Field(() => String, {
nullable: true,
description:
'Идентификатор заказа для повтора остатка: при частичном сбое прошлого оформления ' +
'передаётся тот же checkout_id, чтобы непрошедшие позиции легли в тот же заказ. ' +
'Пусто — оформляется новый заказ.',
})
@IsOptional()
@IsString()
public readonly checkout_id?: string | null;
}
@ObjectType('MarketplaceCheckoutFailedLine', {
description: 'Позиция корзины, которую не удалось оформить (осталась в корзине для повтора).',
})
export class MarketplaceCheckoutFailedLineDTO {
@Field(() => String, { description: 'Идентификатор предложения непрошедшей позиции.' })
public readonly offer_id!: string;
@Field(() => String, { nullable: true, description: 'Название товара (для отображения).' })
public readonly product_name!: string | null;
@Field(() => Int, { description: 'Количество единиц непрошедшей позиции.' })
public readonly quantity!: number;
@Field(() => String, { description: 'Причина, по которой позиция не оформлена.' })
public readonly reason!: string;
constructor(init: Partial<MarketplaceCheckoutFailedLineDTO>) {
Object.assign(this, init);
}
}
@ObjectType('MarketplaceCheckoutResult', {
description:
'Результат оформления заказа из корзины: общий идентификатор заказа, оформленные позиции ' +
'и непрошедший остаток (если был частичный сбой).',
})
export class MarketplaceCheckoutResultDTO {
@Field(() => String, { description: 'Идентификатор заказа (общий для всех оформленных позиций).' })
public readonly checkout_id!: string;
@Field(() => String, { description: 'Пункт выдачи (КУ) заказа.' })
public readonly delivery_braname!: string;
@Field(() => [MarketplaceOrderDTO], { description: 'Успешно оформленные позиции (строки заказа).' })
public readonly created_orders!: MarketplaceOrderDTO[];
@Field(() => [MarketplaceCheckoutFailedLineDTO], {
description: 'Позиции, которые не удалось оформить — остались в корзине для повтора.',
})
public readonly failed_lines!: MarketplaceCheckoutFailedLineDTO[];
@Field(() => Boolean, {
description: 'true — все позиции оформлены и корзина по этому заказу пуста; false — есть остаток.',
})
public readonly fully_completed!: boolean;
@Field(() => MarketplaceCartDTO, { description: 'Корзина после оформления (с непрошедшим остатком).' })
public readonly cart!: MarketplaceCartDTO;
constructor(init: Partial<MarketplaceCheckoutResultDTO>) {
Object.assign(this, init);
}
}
@@ -132,6 +132,10 @@ import {
MarketplaceCartService,
MARKETPLACE_CART_SERVICE,
} from './services/marketplace-cart.service';
import {
MarketplaceCheckoutService,
MARKETPLACE_CHECKOUT_SERVICE,
} from './services/marketplace-checkout.service';
/**
* Модуль приложения marketplace
@@ -355,6 +359,11 @@ import {
useClass: MarketplaceCartService,
},
MarketplaceCartService,
{
provide: MARKETPLACE_CHECKOUT_SERVICE,
useClass: MarketplaceCheckoutService,
},
MarketplaceCheckoutService,
],
exports: [
// Экспортируем сервисы для использования в других модулях
@@ -438,6 +447,8 @@ import {
// Эпик 16 — корзина заказчика
MARKETPLACE_CART_SERVICE,
MarketplaceCartService,
MARKETPLACE_CHECKOUT_SERVICE,
MarketplaceCheckoutService,
MarketplaceCartResolver,
],
})
@@ -20,6 +20,11 @@ import {
MARKETPLACE_CART_SERVICE,
MarketplaceCartService,
} from '../services/marketplace-cart.service';
import {
MARKETPLACE_CHECKOUT_SERVICE,
MarketplaceCheckoutService,
} from '../services/marketplace-checkout.service';
import { MarketplaceCheckoutCartInputDTO, MarketplaceCheckoutResultDTO } from '../dto/marketplace-checkout.dto';
/**
* Эпик 16: корзина заказчика — точка оформления заказа. Все операции
@@ -31,7 +36,9 @@ import {
export class MarketplaceCartResolver {
constructor(
@Inject(MARKETPLACE_CART_SERVICE)
private readonly cartService: MarketplaceCartService
private readonly cartService: MarketplaceCartService,
@Inject(MARKETPLACE_CHECKOUT_SERVICE)
private readonly checkoutService: MarketplaceCheckoutService
) {}
@Query(() => MarketplaceCartDTO, {
@@ -116,6 +123,24 @@ export class MarketplaceCartResolver {
});
}
@Mutation(() => MarketplaceCheckoutResultDTO, {
name: 'marketplaceCheckoutCart',
description:
'Оформить заказ из корзины: предвалидация баланса, построчное создание заказов с общим ' +
'идентификатором заказа и КУ; непрошедший остаток остаётся в корзине для повтора.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Cart', 'manage:own')
async marketplaceCheckoutCart(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('input', { nullable: true }) input?: MarketplaceCheckoutCartInputDTO
): Promise<MarketplaceCheckoutResultDTO> {
return this.checkoutService.execute(
{ coopname: config.coopname, orderer_account: member.username },
{ checkout_id: input?.checkout_id ?? null }
);
}
@Mutation(() => MarketplaceCartDTO, {
name: 'marketplaceSetCartDeliveryPoint',
description: 'Сменить пункт выдачи (КУ) корзины — каталог зависит от выбранного КУ.',
@@ -0,0 +1,210 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import {
USER_WALLET_REPOSITORY,
type UserWalletRepository,
} from '~/domain/wallet/repositories/user-wallet.repository';
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 {
MARKETPLACE_ORDER_CREATE_SERVICE,
MarketplaceOrderCreateService,
} from './marketplace-order-create.service';
import {
MARKETPLACE_CART_SERVICE,
MarketplaceCartService,
} from './marketplace-cart.service';
import { MarketplaceOrderDTO, toMarketplaceOrderDTO } from '../dto/marketplace-order.dto';
import {
MarketplaceCheckoutFailedLineDTO,
MarketplaceCheckoutResultDTO,
} from '../dto/marketplace-checkout.dto';
export const MARKETPLACE_CHECKOUT_SERVICE = Symbol('MARKETPLACE_CHECKOUT_SERVICE');
interface CheckoutScope {
coopname: string;
orderer_account: string;
}
/** Кошельки, из которых createorder тянет средства под резерв заказа. */
const SPENDABLE_WALLETS: ReadonlyArray<string> = ['w.wal.share', 'w.wal.member'];
/**
* Эпик 16 (Story 16.2): оформление заказа из корзины.
*
* Решение 2026-06-02: НЕ одна блокчейн-транзакция (лимит времени вычисления
* блока не вместит большую корзину; ёмкость плавает от нагрузки сети).
* Поэтому:
* 1. backend предвалидирует достаточность баланса под всю корзину —
* при недостатке заказ не запускается (без частичного списания);
* 2. при достаточном балансе позиции проводятся ПОСТРОЧНО (существующая
* логика Order + блокировки Эпика 4) с общим `checkout_id` и КУ;
* 3. частичный сбой НЕ откатывает заказ целиком — прошедшие позиции
* заказаны и убираются из корзины; непрошедший остаток остаётся в
* корзине, повтор — в рамках того же `checkout_id`.
*
* Надёжный ретрай блокчейн-транзакций до завершения — общая системная
* задача кооператива, вне scope (см. issue эпика).
*/
@Injectable()
export class MarketplaceCheckoutService {
constructor(
@Inject(MARKETPLACE_CART_REPOSITORY)
private readonly cartRepo: MarketplaceCartDomainRepository,
@Inject(MARKETPLACE_OFFER_REPOSITORY)
private readonly offerRepo: MarketplaceOfferDomainRepository,
@Inject(MARKETPLACE_ORDER_CREATE_SERVICE)
private readonly orderCreateService: MarketplaceOrderCreateService,
@Inject(MARKETPLACE_CART_SERVICE)
private readonly cartService: MarketplaceCartService,
@Inject(USER_WALLET_REPOSITORY)
private readonly walletRepo: UserWalletRepository,
private readonly logger: WinstonLoggerService
) {
this.logger.setContext(MarketplaceCheckoutService.name);
}
async execute(
scope: CheckoutScope,
input: { checkout_id?: string | null }
): Promise<MarketplaceCheckoutResultDTO> {
const cart = await this.cartRepo.getOrCreate(scope.coopname, scope.orderer_account);
if (cart.is_empty) {
throw new BadRequestException('Корзина пуста — оформлять нечего.');
}
if (!cart.delivery_braname) {
throw new BadRequestException('Не выбран пункт выдачи (КУ) — выберите КУ перед оформлением.');
}
const deliveryBraname = cart.delivery_braname;
const offers = await this.offerRepo.findByIds(cart.items.map((i) => i.offer_id));
const offerById = new Map(offers.map((o) => [o.id, o]));
// Разделяем позиции на оформляемые (активны + возятся на КУ) и заведомо
// непрошедшие (неактивны/сняты/не возят на этот КУ) — последние не
// запускаем, но сообщаем заказчику.
const payable: Array<{ offer_id: string; quantity: number; offer: MarketplaceOfferDomainEntity }> = [];
const failed: MarketplaceCheckoutFailedLineDTO[] = [];
for (const item of cart.items) {
const offer = offerById.get(item.offer_id) ?? null;
if (!offer || offer.status !== MarketplaceOfferStatuses.ACTIVE) {
failed.push(
new MarketplaceCheckoutFailedLineDTO({
offer_id: item.offer_id,
product_name: offer?.product_name ?? null,
quantity: item.quantity,
reason: 'Предложение больше не активно.',
})
);
continue;
}
if (!offer.delivery_points.some((dp) => dp.braname === deliveryBraname)) {
failed.push(
new MarketplaceCheckoutFailedLineDTO({
offer_id: item.offer_id,
product_name: offer.product_name,
quantity: item.quantity,
reason: 'Товар не возят на выбранный пункт выдачи.',
})
);
continue;
}
payable.push({ offer_id: item.offer_id, quantity: item.quantity, offer });
}
// Предвалидация баланса под всю оформляемую корзину (без частичного списания).
const totalNeeded = payable.reduce(
(sum, l) => sum + this.parseAmount(l.offer.price_per_unit) * l.quantity,
0
);
if (payable.length > 0) {
const available = await this.spendableBalance(scope.coopname, scope.orderer_account);
if (totalNeeded - available > 1e-6) {
throw new BadRequestException(
`Недостаточно средств для оформления: нужно ${totalNeeded.toFixed(4)}, доступно ${available.toFixed(4)}. ` +
'Заказ не запущен — пополните счёт или уберите часть позиций.'
);
}
}
const checkoutId = input.checkout_id ?? randomUUID();
// Построчное оформление: прошедшее остаётся заказанным даже при сбое
// на последующих строках (без отката заказа целиком).
const createdDTOs: MarketplaceOrderDTO[] = [];
const succeededOfferIds: string[] = [];
for (const line of payable) {
try {
const res = await this.orderCreateService.execute({
coopname: scope.coopname,
orderer_account: scope.orderer_account,
offer_id: line.offer_id,
quantity: line.quantity,
delivery_braname: deliveryBraname,
checkout_id: checkoutId,
});
createdDTOs.push(toMarketplaceOrderDTO(res.order));
succeededOfferIds.push(line.offer_id);
} catch (error: any) {
this.logger.warn(
`MarketplaceCheckoutService: строка не оформлена (offer=${line.offer_id}, qty=${line.quantity}, checkout=${checkoutId}): ${error.message}`
);
failed.push(
new MarketplaceCheckoutFailedLineDTO({
offer_id: line.offer_id,
product_name: line.offer.product_name,
quantity: line.quantity,
reason: error?.message ?? 'Не удалось оформить позицию.',
})
);
}
}
if (succeededOfferIds.length > 0) {
await this.cartRepo.removeItems(cart.id, succeededOfferIds);
}
const cartAfter = await this.cartService.getCart(scope);
this.logger.log(
`MarketplaceCheckoutService: checkout=${checkoutId} КУ=${deliveryBraname}; оформлено ${createdDTOs.length}, не прошло ${failed.length}`
);
return new MarketplaceCheckoutResultDTO({
checkout_id: checkoutId,
delivery_braname: deliveryBraname,
created_orders: createdDTOs,
failed_lines: failed,
fully_completed: failed.length === 0,
cart: cartAfter,
});
}
// ── private ──
/** Доступно к расходу = available(w.wal.share) + available(w.wal.member). */
private async spendableBalance(coopname: string, username: string): Promise<number> {
const rows = await this.walletRepo.findByUsername(coopname, username);
return SPENDABLE_WALLETS.reduce((sum, name) => {
const row = rows.find((r) => r.wallet_name === name);
return sum + this.parseAmount(row?.available);
}, 0);
}
/** asset-строка («1000.0000 RUB») или число → float; пусто/невалид → 0. */
private parseAmount(value: string | null | undefined): number {
if (!value) return 0;
const n = Number.parseFloat(value);
return Number.isNaN(n) ? 0 : n;
}
}