[598-30][@ant] feat: батч-акцепт сводной заявки и триггер open_subscription (SDK + desktop + aggregator) — чтобы поставщик принимал накопленный объём одной кнопкой и запускал цикл подписки

This commit is contained in:
ant
2026-05-25 09:37:39 +00:00
parent 2599795678
commit ee1feb014b
8 changed files with 208 additions and 5 deletions
@@ -489,9 +489,57 @@ export class MarketplaceCycleAggregatorService {
this.logger.log(
`MarketplaceCycleAggregatorService: offer=${offer_id} cycle_type=${cycle_type} → consolidated_request=${cycle.id} status=${status}; assigned ${affected}/${orderIds.length} Order'ов`
);
// open_subscription: триггер поставщика — это одновременно создание и
// акцепт заявки. БД уже помечена ACCEPTED, но on-chain Order остаётся в
// статусе блокировки. Без on-chain `acceptOrder` последующий `signsupp`
// на акте приёмки падает ассертом «Заказ не в статусе акцепта». Для
// time_based/volume_based on-chain accept делает отдельный шаг
// marketplaceAcceptConsolidatedRequest; для triggered-пути выполняем его
// здесь, иначе магистраль open_subscription блокируется на приёмке.
if (triggered) {
await this.acceptPoolOnChain(pool, supplier_account);
}
return cycle;
}
/**
* open_subscription: per-Order on-chain `acceptOrder` (offerer = поставщик).
* Best-effort, симметрично expirePoolOnChain: chain fail одного Order'а
* лог error + продолжаем; Order остаётся ACCEPTED в БД (assignToCycle уже
* прошёл), рассинхрон чинится manual reconciliation / повторным запуском.
*/
private async acceptPoolOnChain(
pool: MarketplaceOrderDomainEntity[],
offerer_account: string
): Promise<{ succeeded: number; failed: number }> {
let succeeded = 0;
let failed = 0;
for (const order of pool) {
try {
await this.chainPort.acceptOrder({
coopname: order.coopname,
offerer: offerer_account,
order_hash: order.order_hash,
});
succeeded++;
} catch (chainErr: any) {
failed++;
this.logger.error(
`MarketplaceCycleAggregatorService.acceptPoolOnChain: chain.acceptOrder упал для Order ${order.id} (order_hash=${order.order_hash}, offerer=${offerer_account}): ${chainErr.message}. Order остаётся ACCEPTED в БД, on-chain accept не прошёл — повтор через manual reconciliation.`,
chainErr.stack
);
}
}
if (failed > 0) {
this.logger.warn(
`MarketplaceCycleAggregatorService.acceptPoolOnChain: завершено для пула из ${pool.length} Order'ов (succeeded=${succeeded}, failed=${failed})`
);
}
return { succeeded, failed };
}
private computeTotalAmount(price_per_unit: string, quantity: number): string {
const price = Number.parseFloat(price_per_unit);
if (!Number.isFinite(price)) return '0.0000';
@@ -57,3 +57,19 @@ export async function declineIndividualOrder(order_id: string, reason: string):
variables: { input: { order_id, reason } },
});
}
/**
* Групповой (volume_based/open_subscription/time_based) акцепт: поставщик
* принимает всю сводную заявку партии разом. request_id = cycle_id заказа.
*/
export async function acceptConsolidatedRequest(request_id: string): Promise<void> {
await client.Mutation(Mutations.Marketplace.AcceptConsolidatedRequest.mutation, {
variables: { input: { request_id } },
});
}
export async function declineConsolidatedRequest(request_id: string, reason: string): Promise<void> {
await client.Mutation(Mutations.Marketplace.DeclineConsolidatedRequest.mutation, {
variables: { input: { request_id, reason } },
});
}
@@ -6,7 +6,13 @@ import {
type Order as OrderCardModel,
type OrderStatus as OrderCardStatus,
} from 'src/widgets/Marketplace/OrderCard';
import { acceptIndividualOrder, declineIndividualOrder, fetchSupplierOrders } from '../api';
import {
acceptConsolidatedRequest,
acceptIndividualOrder,
declineConsolidatedRequest,
declineIndividualOrder,
fetchSupplierOrders,
} from '../api';
import type {
MarketplaceOrderStatusView,
MarketplaceOrderView,
@@ -111,8 +117,21 @@ function loadMore(): void {
async function onAccept(orderId: string): Promise<void> {
loading.value = true;
try {
await acceptIndividualOrder(orderId);
Notify.create({ type: 'positive', message: 'Заказ принят', timeout: 4000 });
// Индивидуальный заказ акцептуется поштучно (order_id); групповой
// (cycle_type volume_based/open_subscription/time_based) — целиком сводной
// заявкой по cycle_id. Иначе acceptIndividualOrder на групповом заказе
// падает: заказ привязан к сводной заявке, а не к индивидуальному циклу.
const order = items.value.find((o) => o.id === orderId);
if (order && order.status === 'ACCEPTED_PENDING_SUPPLIER') {
if (!order.cycle_id) {
throw new Error('У группового заказа отсутствует идентификатор сводной заявки (cycle_id).');
}
await acceptConsolidatedRequest(order.cycle_id);
Notify.create({ type: 'positive', message: 'Сводная заявка партии принята', timeout: 4000 });
} else {
await acceptIndividualOrder(orderId);
Notify.create({ type: 'positive', message: 'Заказ принят', timeout: 4000 });
}
await load(1, false);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
@@ -133,8 +152,17 @@ function onDecline(orderId: string): void {
}).onOk(async (reason: string) => {
loading.value = true;
try {
await declineIndividualOrder(orderId, reason.trim());
Notify.create({ type: 'info', message: 'Заказ отклонён', timeout: 4000 });
const order = items.value.find((o) => o.id === orderId);
if (order && order.status === 'ACCEPTED_PENDING_SUPPLIER') {
if (!order.cycle_id) {
throw new Error('У группового заказа отсутствует идентификатор сводной заявки (cycle_id).');
}
await declineConsolidatedRequest(order.cycle_id, reason.trim());
Notify.create({ type: 'info', message: 'Сводная заявка партии отклонена', timeout: 4000 });
} else {
await declineIndividualOrder(orderId, reason.trim());
Notify.create({ type: 'info', message: 'Заказ отклонён', timeout: 4000 });
}
await load(1, false);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
@@ -0,0 +1,22 @@
import { marketplaceConsolidatedRequestActionResultSelector } from '../../selectors/marketplace/consolidatedRequestSelector'
import { $, type GraphQLTypes, type InputType, type ModelTypes, Selector } from '../../zeus/index'
export const name = 'marketplaceAcceptConsolidatedRequest'
export const mutation = Selector('Mutation')({
[name]: [
{ input: $('input', 'MarketplaceAcceptConsolidatedRequestInput!') },
marketplaceConsolidatedRequestActionResultSelector,
],
})
export interface IInput {
/**
* @private
*/
[key: string]: unknown
input: ModelTypes['MarketplaceAcceptConsolidatedRequestInput']
}
export type IOutput = InputType<GraphQLTypes['Mutation'], typeof mutation>
@@ -0,0 +1,22 @@
import { marketplaceConsolidatedRequestActionResultSelector } from '../../selectors/marketplace/consolidatedRequestSelector'
import { $, type GraphQLTypes, type InputType, type ModelTypes, Selector } from '../../zeus/index'
export const name = 'marketplaceDeclineConsolidatedRequest'
export const mutation = Selector('Mutation')({
[name]: [
{ input: $('input', 'MarketplaceDeclineConsolidatedRequestInput!') },
marketplaceConsolidatedRequestActionResultSelector,
],
})
export interface IInput {
/**
* @private
*/
[key: string]: unknown
input: ModelTypes['MarketplaceDeclineConsolidatedRequestInput']
}
export type IOutput = InputType<GraphQLTypes['Mutation'], typeof mutation>
@@ -12,6 +12,12 @@ export * as CancelOrder from './cancelOrder'
export * as AcceptIndividualOrder from './acceptIndividualOrder'
/** Эпик 4: поставщик отклоняет индивидуальный заказ с указанием причины */
export * as DeclineIndividualOrder from './declineIndividualOrder'
/** Эпик 4: поставщик принимает сводную заявку партии (групповой cycle_type) */
export * as AcceptConsolidatedRequest from './acceptConsolidatedRequest'
/** Эпик 4: поставщик отклоняет сводную заявку партии — заказы отменяются, средства разблокируются */
export * as DeclineConsolidatedRequest from './declineConsolidatedRequest'
/** Эпик 4: поставщик вручную запускает поставку по предложению с открытой подпиской (cycle_type=open_subscription) */
export * as TriggerOpenSubscription from './triggerOpenSubscription'
/** Опубликовать новое предложение поставщика на модерацию */
export * as CreateOffer from './createOffer'
/** Эпик 5: сформировать партии поставки из акцептованной заявки */
@@ -0,0 +1,22 @@
import { marketplaceConsolidatedRequestSelector } from '../../selectors/marketplace/consolidatedRequestSelector'
import { $, type GraphQLTypes, type InputType, type ModelTypes, Selector } from '../../zeus/index'
export const name = 'marketplaceTriggerOpenSubscription'
export const mutation = Selector('Mutation')({
[name]: [
{ input: $('input', 'MarketplaceTriggerOpenSubscriptionInput!') },
marketplaceConsolidatedRequestSelector,
],
})
export interface IInput {
/**
* @private
*/
[key: string]: unknown
input: ModelTypes['MarketplaceTriggerOpenSubscriptionInput']
}
export type IOutput = InputType<GraphQLTypes['Mutation'], typeof mutation>
@@ -0,0 +1,39 @@
import { Selector, type ValueTypes } from '../../zeus/index'
import type { MakeAllFieldsRequired } from '../../utils/MakeAllFieldsRequired'
const rawConsolidatedRequestSelector = {
id: true,
coopname: true,
offer_id: true,
supplier_account: true,
cycle_type: true,
status: true,
total_quantity: true,
total_amount: true,
cycle_started_at: true,
cycle_ended_at: true,
expires_at: true,
accepted_at: true,
declined_at: true,
decline_reason: true,
triggered_by_supplier_at: true,
created_at: true,
updated_at: true,
}
const _validateConsolidatedRequest: MakeAllFieldsRequired<
ValueTypes['MarketplaceConsolidatedRequest']
> = rawConsolidatedRequestSelector
export const marketplaceConsolidatedRequestSelector = Selector('MarketplaceConsolidatedRequest')(
rawConsolidatedRequestSelector,
)
export const marketplaceConsolidatedRequestActionResultSelector = Selector(
'MarketplaceConsolidatedRequestActionResult',
)({
request: rawConsolidatedRequestSelector,
affected_orders: true,
on_chain_succeeded: true,
on_chain_failed: true,
})