[598-6][@ant] fix(marketplace): ревью Эпика 3 — каталог через SDK Zeus + модерация FIFO + чистка approve/reject при edit

This commit is contained in:
ant
2026-05-20 14:07:13 +00:00
parent 4877c29274
commit dd862f20ed
8 changed files with 98 additions and 89 deletions
@@ -55,7 +55,9 @@ export class MarketplaceModerationResolver {
page: input?.page ?? 1,
limit: input?.limit ?? 50,
sortBy: input?.sortBy ?? 'created_at',
sortOrder: (input?.sortOrder ?? 'DESC') as 'ASC' | 'DESC',
// FIFO: первой берётся самая старая заявка — модератор не должен
// пропускать давно ждущие офферы из-за свежих.
sortOrder: (input?.sortOrder ?? 'ASC') as 'ASC' | 'DESC',
};
const result = await this.service.listPending(config.coopname, pagination);
return {
@@ -183,9 +183,24 @@ export class MarketplaceOfferService {
});
}
const normalizedPatch: OfferUpdateInput & { status: MarketplaceOfferStatus } = {
const normalizedPatch: OfferUpdateInput & {
status: MarketplaceOfferStatus;
approved_by: string | null;
approved_at: Date | null;
rejected_by: string | null;
rejected_at: Date | null;
reject_reason: string | null;
} = {
...patch,
status: 'PENDING_MODERATION',
// edit повторно отправляет оффер на модерацию — поля прошлых решений
// (approve/reject) не должны утечь в UI как «уже одобрен» / «отклонён
// с прошлой причиной».
approved_by: null,
approved_at: null,
rejected_by: null,
rejected_at: null,
reject_reason: null,
};
if (patch.unlimited_flag === true) {
@@ -1,4 +1,5 @@
import { sendPOST } from 'src/shared/api/axios';
import { Queries } from '@coopenomics/sdk';
import { client } from 'src/shared/api/client';
import type {
CatalogSort,
MarketplaceCategoryOfferCount,
@@ -7,101 +8,56 @@ import type {
} from '../types';
/**
* Story 3.5: raw GraphQL к marketplace-backend.
*
* Техдолг: после `pnpm cooptypes:gen-zeus` (регенерация Zeus в
* `@coopenomics/sdk`) переписать на типизированные
* `Queries.Marketplace.ListCatalog` / `CategoryOfferCounts` /
* `ListCategories` — см. паттерн `entities/MarketplaceKUDetails` после
* 2-го коммита refactor в PR #381.
*
* До тех пор используем `sendPOST('/v1/graphql', { query, variables })`,
* без типов schema.gql на клиенте.
* Story 3.5: типизированные запросы каталога Стола заказов через SDK Zeus.
* Все GraphQL-запросы идут через `@coopenomics/sdk` Queries.Marketplace —
* raw query-строки и `sendPOST('/v1/graphql', ...)` в marketplace запрещены.
*/
const LIST_CATALOG_QUERY = `
query MarketplaceListCatalog($input: MarketplaceListCatalogInput) {
marketplaceListCatalog(input: $input) {
total
items {
id
coopname
supplier_account
vitrine_id
product_name
description
category_id
price_per_unit
unit_of_measure
quantity_available
quantity_blocked
quantity_consumed
unlimited_flag
cycle_type
cycle_days
target_volume
max_wait_days
min_threshold
warranty_days
status
created_at
updated_at
}
}
}
`;
const LIST_CATEGORIES_QUERY = `
query MarketplaceListCategories {
marketplaceListCategories {
id
display_name
sort_order
}
}
`;
const CATEGORY_COUNTS_QUERY = `
query MarketplaceCategoryOfferCounts {
marketplaceCategoryOfferCounts {
category_id
count
}
}
`;
export interface ListCatalogVariables {
category_id?: number | null;
page?: number;
limit?: number;
offset?: number;
sort?: CatalogSort;
}
function mapSortToBackend(sort: CatalogSort | undefined): {
sortBy: string;
sortOrder: 'ASC' | 'DESC';
} {
switch (sort) {
case 'price_asc':
return { sortBy: 'price_per_unit', sortOrder: 'ASC' };
case 'price_desc':
return { sortBy: 'price_per_unit', sortOrder: 'DESC' };
case 'created_at_desc':
default:
return { sortBy: 'created_at', sortOrder: 'DESC' };
}
}
export async function fetchCatalog(
variables: ListCatalogVariables
): Promise<MarketplaceOfferPage> {
const body = await sendPOST('/v1/graphql', {
query: LIST_CATALOG_QUERY,
variables: { input: variables },
const { sortBy, sortOrder } = mapSortToBackend(variables.sort);
const input = {
category_id: variables.category_id ?? null,
page: variables.page ?? 1,
limit: variables.limit ?? 24,
sortBy,
sortOrder,
};
const result = await client.Query(Queries.Marketplace.ListCatalog.query, {
variables: { input },
});
if (body?.errors?.length) {
throw new Error(body.errors[0].message);
}
return body.data.marketplaceListCatalog;
return result[Queries.Marketplace.ListCatalog.name] as MarketplaceOfferPage;
}
export async function fetchCategories(): Promise<MarketplaceCategoryView[]> {
const body = await sendPOST('/v1/graphql', { query: LIST_CATEGORIES_QUERY });
if (body?.errors?.length) {
throw new Error(body.errors[0].message);
}
return body.data.marketplaceListCategories;
const result = await client.Query(Queries.Marketplace.ListCategories.query);
return (result[Queries.Marketplace.ListCategories.name] ?? []) as MarketplaceCategoryView[];
}
export async function fetchCategoryOfferCounts(): Promise<MarketplaceCategoryOfferCount[]> {
const body = await sendPOST('/v1/graphql', { query: CATEGORY_COUNTS_QUERY });
if (body?.errors?.length) {
throw new Error(body.errors[0].message);
}
return body.data.marketplaceCategoryOfferCounts;
const result = await client.Query(Queries.Marketplace.CategoryOfferCounts.query);
return (result[Queries.Marketplace.CategoryOfferCounts.name] ?? []) as MarketplaceCategoryOfferCount[];
}
@@ -31,8 +31,10 @@ export interface MarketplaceOfferView {
}
export interface MarketplaceOfferPage {
total: number;
items: MarketplaceOfferView[];
totalCount: number;
totalPages: number;
currentPage: number;
}
export interface MarketplaceCategoryView {
@@ -37,7 +37,7 @@ const total = ref(0);
const loading = ref(false);
const selectedCategoryId = ref<number>(ALL_KEY);
const sort = ref<CatalogSort>('created_at_desc');
const offset = ref(0);
const currentPage = ref(1);
const hasMore = computed(() => items.value.length < total.value);
@@ -89,11 +89,11 @@ async function loadPage(append: boolean): Promise<void> {
try {
const page = await fetchCatalog({
category_id: selectedCategoryId.value === ALL_KEY ? null : selectedCategoryId.value,
page: currentPage.value,
limit: PAGE_SIZE,
offset: offset.value,
sort: sort.value,
});
total.value = page.total;
total.value = page.totalCount;
items.value = append ? items.value.concat(page.items) : page.items;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
@@ -105,19 +105,19 @@ async function loadPage(append: boolean): Promise<void> {
function selectCategory(id: number): void {
selectedCategoryId.value = id;
offset.value = 0;
currentPage.value = 1;
void loadPage(false);
}
function changeSort(newSort: CatalogSort): void {
sort.value = newSort;
offset.value = 0;
currentPage.value = 1;
void loadPage(false);
}
async function onLoadMore(): Promise<void> {
if (!hasMore.value || loading.value) return;
offset.value = items.value.length;
currentPage.value += 1;
await loadPage(true);
}
@@ -0,0 +1,12 @@
import { type GraphQLTypes, type InputType, Selector } from '../../zeus/index'
export const name = 'marketplaceCategoryOfferCounts'
export const query = Selector('Query')({
[name]: {
category_id: true,
count: true,
},
})
export type IOutput = InputType<GraphQLTypes['Query'], typeof query>
@@ -6,6 +6,10 @@ export * as ListMyOrders from './listMyOrders'
export * as GetOrder from './getOrder'
/** Базовый справочник категорий товаров */
export * as ListCategories from './listCategories'
/** Каталог активных Offer'ов (Story 3.5) */
export * as ListCatalog from './listCatalog'
/** Счётчики активных Offer'ов per category для фильтр-чипов (Story 3.5) */
export * as CategoryOfferCounts from './categoryOfferCounts'
/** Эпик 5: партии поставки кооператива */
export * as ListShipments from './listShipments'
/** Эпик 5: одна партия поставки по идентификатору */
@@ -0,0 +1,18 @@
import { marketplaceOfferSelector } from '../../selectors/marketplace/offerSelector'
import { $, type GraphQLTypes, type InputType, Selector } from '../../zeus/index'
export const name = 'marketplaceListCatalog'
export const query = Selector('Query')({
[name]: [
{ input: $('input', 'MarketplaceListCatalogInput') },
{
items: marketplaceOfferSelector,
totalCount: true,
totalPages: true,
currentPage: true,
},
],
})
export type IOutput = InputType<GraphQLTypes['Query'], typeof query>