[598-33][@ant] feat: checkout-сервис оформления из корзины — построчное создание заказов с общим checkout_id, предвалидация баланса и сохранение непрошедшего остатка для повтора
This commit is contained in:
+74
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
+26
-1
@@ -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: 'Сменить пункт выдачи (КУ) корзины — каталог зависит от выбранного КУ.',
|
||||
|
||||
+210
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user