feat(marketplace): канон оферт — редактирование изображений, republish, sticky-форма

«Мои предложения»: info-карточка .banner с persist-скрытием, скелетоны вместо
лоудеров, канон-вкладки с deep-link ?status=, кнопка «Создать» в правом верхнем
углу через teleport, republish для WITHDRAWN. Форма создания/редактирования:
единый заголовок (без дублей), один .banner, sticky нижний бар «Отменить/Далее/
Сохранить» фикс-высоты, форматирование цены при префилле. REJECTED правится и
переотправляется через форму с показом причины отклонения.

Изображения: единая галерея — добавление (не замена), удаление и смена обложки
у любой картинки с самого начала. Backend отдаёт/принимает bucket_key (сохранить
существующее) либо base64 (новое); чужой ключ отклоняется. Signed-URL чтения
стабилизирован в окне 300с — не мигает при polling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ant
2026-05-29 14:06:35 +00:00
parent ce2117af25
commit af5c8e33ee
24 changed files with 1306 additions and 550 deletions
+21 -4
View File
@@ -8261,6 +8261,11 @@ type MarketplaceOffer {
}
type MarketplaceOfferImage {
"""
Ключ объекта в хранилище — стабильный идентификатор изображения. Передаётся обратно при редактировании, чтобы сохранить уже загруженное изображение (в отличие от base64 для новых файлов).
"""
bucket_key: String!
"""Является ли изображение обложкой карточки."""
is_cover: Boolean!
@@ -8275,11 +8280,14 @@ type MarketplaceOfferImage {
}
input MarketplaceOfferImageUploadInput {
"""Содержимое изображения, закодированное в base64."""
base64: String!
"""Содержимое нового изображения в base64."""
base64: String
"""MIME-тип изображения (image/jpeg, image/png либо image/webp)."""
mime_type: String!
"""Ключ уже сохранённого изображения — сохранить его в наборе."""
bucket_key: String
"""MIME-тип нового изображения (image/jpeg, image/png либо image/webp)."""
mime_type: String
}
type MarketplaceOfferPaginationResult {
@@ -8650,6 +8658,10 @@ input MarketplaceRemoveFromWhitelistInput {
member_account: String!
}
input MarketplaceRepublishOfferInput {
id: String!
}
type MarketplaceRequest {
"""Артикул товара"""
articleNumber: String!
@@ -10873,6 +10885,11 @@ type Mutation {
"""
marketplaceReplaceAvailableItems(input: ReplaceAvailableItemsInput!): [MarketplaceAvailableCategory!]!
"""
Поставщик возвращает снятый Offer на публикацию (статус → PENDING_MODERATION)
"""
marketplaceRepublishOffer(input: MarketplaceRepublishOfferInput!): MarketplaceOffer!
"""
Повторно запускает геокодинг адреса ПВЗ.
@@ -25,15 +25,27 @@ const UNITS = ['piece', 'kg', 'liter', 'pack'] as const;
@InputType('MarketplaceOfferImageUploadInput')
export class MarketplaceOfferImageUploadInputDTO {
@Field({ description: 'Содержимое изображения, закодированное в base64.' })
// Элемент набора изображений — ЛИБО новый файл (base64 + mime_type), ЛИБО
// ссылка на уже сохранённое изображение по bucket_key (чтобы сохранить его
// при пересборке набора). Порядок в массиве = порядок показа, первый —
// обложка. Согласованность (ровно одно из двух) проверяется в сервисе.
@Field({ nullable: true, description: 'Содержимое нового изображения в base64.' })
@IsOptional()
@IsString()
@IsNotEmpty()
public readonly base64!: string;
public readonly base64?: string;
@Field({ description: 'MIME-тип изображения (image/jpeg, image/png либо image/webp).' })
@Field({ nullable: true, description: 'MIME-тип нового изображения (image/jpeg, image/png либо image/webp).' })
@IsOptional()
@IsString()
@IsNotEmpty()
public readonly mime_type!: string;
public readonly mime_type?: string;
@Field({ nullable: true, description: 'Ключ уже сохранённого изображения — сохранить его в наборе.' })
@IsOptional()
@IsString()
@IsNotEmpty()
public readonly bucket_key?: string;
}
@InputType('MarketplaceCreateOfferInput')
@@ -248,5 +260,12 @@ export class MarketplaceWithdrawOfferInputDTO {
public id!: string;
}
@InputType('MarketplaceRepublishOfferInput')
export class MarketplaceRepublishOfferInputDTO {
@Field(() => String)
@IsString()
public id!: string;
}
@InputType('MarketplaceListMyOffersInput')
export class MarketplaceListMyOffersInputDTO extends PaginationInputDTO {}
@@ -13,6 +13,14 @@ export class MarketplaceOfferImageDTO {
})
public readonly url!: string;
@Field(() => String, {
description:
'Ключ объекта в хранилище — стабильный идентификатор изображения. ' +
'Передаётся обратно при редактировании, чтобы сохранить уже загруженное ' +
'изображение (в отличие от base64 для новых файлов).',
})
public readonly bucket_key!: string;
@Field(() => String, { description: 'MIME-тип изображения.' })
public readonly mime_type!: string;
@@ -28,6 +28,7 @@ export class MarketplaceOfferFieldsResolver {
const url = await this.imagesService.getReadUrl(rec.bucket_key);
return new MarketplaceOfferImageDTO({
url,
bucket_key: rec.bucket_key,
mime_type: rec.mime_type,
sort_order: index,
is_cover: index === 0,
@@ -15,6 +15,7 @@ import {
import {
MarketplaceCreateOfferInputDTO,
MarketplaceListMyOffersInputDTO,
MarketplaceRepublishOfferInputDTO,
MarketplaceUpdateOfferInputDTO,
MarketplaceWithdrawOfferInputDTO,
} from '../dto/marketplace-offer-input.dto';
@@ -135,6 +136,20 @@ export class MarketplaceOfferResolver {
return toDTO(offer);
}
@Mutation(() => MarketplaceOfferDTO, {
name: 'marketplaceRepublishOffer',
description: 'Поставщик возвращает снятый Offer на публикацию (статус → PENDING_MODERATION)',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Offer', 'update:own')
async marketplaceRepublishOffer(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('input') input: MarketplaceRepublishOfferInputDTO
): Promise<MarketplaceOfferDTO> {
const offer = await this.offerService.republish(input.id, member.username);
return toDTO(offer);
}
@Query(() => MarketplaceOfferPaginationResultDTO, {
name: 'marketplaceListMyOffers',
description: 'Список собственных Offer\'ов поставщика (любой статус)',
@@ -52,8 +52,11 @@ export const MARKETPLACE_OFFER_SERVICE = Symbol('MARKETPLACE_OFFER_SERVICE');
* `MarketplaceOfferImage` (ключ bucket'а + sha256) до записи в БД.
*/
export interface MarketplaceOfferImageUpload {
base64: string;
mime_type: string;
/** Новый файл: содержимое в base64. Взаимоисключимо с bucket_key. */
base64?: string;
mime_type?: string;
/** Уже сохранённое изображение: сохранить его в наборе по ключу хранилища. */
bucket_key?: string;
}
export interface OfferCreateRequest {
@@ -84,14 +87,17 @@ export interface OfferCreateRequest {
*
* Lifecycle (AC):
* create → PENDING_MODERATION (rate-limit 10/час на supplier).
* edit → если ACTIVE/PENDING_MODERATION — поля обновляются, status
* сбрасывается в PENDING_MODERATION; REJECTED edit → 403
* (поставщик должен создать новый offer, AC формулировка
* «создаст новый PENDING_MODERATION»); WITHDRAWN edit → 403.
* edit → ACTIVE/PENDING_MODERATION — поля обновляются (значимый контент
* сбрасывает status в PENDING_MODERATION); REJECTED — правка
* устраняет причину отклонения и всегда уходит на повторную
* модерацию (не пересоздаётся); WITHDRAWN edit → 403 (сначала
* republish).
* withdraw→ если ACTIVE/PENDING_MODERATION — status=WITHDRAWN;
* блокируется при наличии незакрытых Order'ов с понятным
* сообщением (заглушка `hasActiveOrders` — реализуется при
* merge Story 4.x, в MVP всегда false).
* republish→ WITHDRAWN → PENDING_MODERATION. Снятие не удаляет данные, поэтому
* вернуть предложение можно без пересоздания — снова на модерацию.
*
* Owner-проверка (`supplier_account == offer.supplier_account`) на уровне
* сервиса — guard даёт capability, а не ownership.
@@ -238,43 +244,72 @@ export class MarketplaceOfferService {
});
}
// Поле-зависимая модерация. Операционные поля (остаток, цена) поставщик
// меняет часто и без участия председателя — их правка НЕ снимает оффер с
// публикации и не шлёт на повторную модерацию. Любое же изменение
// «модерационно-значимого» контента (название, описание, категория,
// фото, единица измерения, условия цикла, гарантия, штрихкод) — то, что
// председатель видит и проверяет, — сбрасывает статус в PENDING_MODERATION.
// requireOwnedEditable пропускает сюда ACTIVE, PENDING_MODERATION и
// REJECTED. Для REJECTED любая правка — это исправление причины отклонения,
// поэтому она всегда уходит на повторную модерацию (даже если тронули
// только цену/остаток): оффер сейчас невидим в каталоге и должен снова
// пройти проверку, чтобы опубликоваться.
const NON_MODERATED_FIELDS: ReadonlyArray<keyof OfferUpdateInput> = [
'price_per_unit',
'quantity_available',
'unlimited_flag',
];
const touchedKeys = (Object.keys(patch) as Array<keyof OfferUpdateInput>).filter(
(k) => patch[k] !== undefined,
);
const moderationSignificantChange =
offer.status === 'REJECTED' ||
images !== undefined ||
touchedKeys.some((k) => !NON_MODERATED_FIELDS.includes(k));
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,
};
status?: MarketplaceOfferStatus;
approved_by?: string | null;
approved_at?: Date | null;
rejected_by?: string | null;
rejected_at?: Date | null;
reject_reason?: string | null;
} = { ...patch };
if (moderationSignificantChange) {
// edit контента повторно отправляет оффер на модерацию — поля прошлых
// решений (approve/reject) не должны утечь в UI как «уже одобрен» /
// «отклонён с прошлой причиной».
normalizedPatch.status = 'PENDING_MODERATION';
normalizedPatch.approved_by = null;
normalizedPatch.approved_at = null;
normalizedPatch.rejected_by = null;
normalizedPatch.rejected_at = null;
normalizedPatch.reject_reason = null;
}
if (patch.unlimited_flag === true) {
normalizedPatch.quantity_available = 0;
}
// Изображения переданы → полностью заменяем набор. Старые объекты
// bucket'а не удаляем (могут шариться по content-hash; orphan-cleanup —
// вне MVP). Новые грузим до записи; при провале записи — чистим их.
let uploaded: MarketplaceOfferImage[] | undefined;
// Изображения переданы → пересобираем набор: элементы с bucket_key —
// сохраняем как есть (пользователь оставил уже загруженное), элементы с
// base64 — грузим как новые. Порядок = порядок показа, первый = обложка.
// Удалённые (не попавшие в набор) объекты bucket'а не подчищаем
// (orphan-cleanup — вне MVP). При провале записи чистим ТОЛЬКО что
// загруженные — сохранённые существующие трогать нельзя.
let newlyUploaded: MarketplaceOfferImage[] = [];
if (images !== undefined) {
uploaded = await this.uploadImages(offer.coopname, supplier_account, images);
normalizedPatch.images = uploaded;
const resolved = await this.resolveImagesForUpdate(offer, supplier_account, images);
newlyUploaded = resolved.newlyUploaded;
normalizedPatch.images = resolved.images;
}
try {
return await this.repo.applyUpdate(offer.id, normalizedPatch);
} catch (e) {
if (uploaded) await this.cleanupImages(uploaded);
if (newlyUploaded.length) await this.cleanupImages(newlyUploaded);
throw e;
}
}
@@ -291,6 +326,30 @@ export class MarketplaceOfferService {
return this.repo.applyUpdate(offer.id, { status: 'WITHDRAWN' });
}
/**
* Вернуть ранее снятое предложение на публикацию: WITHDRAWN →
* PENDING_MODERATION. Снятие не удаляет данные оферты — все поля и
* изображения на месте, поэтому пересоздавать ничего не нужно, достаточно
* снова отправить на модерацию. Доступно только владельцу и только для
* снятого предложения (REJECTED не возвращаем: его отклонил модератор по
* причине — нужна правка, а не повторная отправка тех же данных).
*/
async republish(id: string, supplier_account: string): Promise<MarketplaceOfferDomainEntity> {
const offer = await this.repo.findById(id);
if (!offer) {
throw new NotFoundException('Предложение не найдено.');
}
if (offer.supplier_account !== supplier_account) {
throw new ForbiddenException('Можно изменять только свои предложения.');
}
if (offer.status !== 'WITHDRAWN') {
throw new ForbiddenException(
'Вернуть на публикацию можно только снятое предложение.'
);
}
return this.repo.applyUpdate(offer.id, { status: 'PENDING_MODERATION' });
}
async listMine(
coopname: string,
supplier_account: string,
@@ -483,12 +542,25 @@ export class MarketplaceOfferService {
if (offer.supplier_account !== supplier_account) {
throw new ForbiddenException('Можно изменять только свои предложения.');
}
if (offer.status === 'WITHDRAWN' || offer.status === 'REJECTED') {
const action = op[0] === 'withdraw' ? 'снять' : 'отредактировать';
const statusLabel = offer.status === 'WITHDRAWN' ? 'снято' : 'отклонено';
throw new ForbiddenException(
`Нельзя ${action} предложение, которое уже ${statusLabel}.`
);
if (op[0] === 'withdraw') {
// Снять можно только опубликованное/ожидающее. Уже снятое или
// отклонённое снимать нечего.
if (offer.status === 'WITHDRAWN' || offer.status === 'REJECTED') {
const statusLabel = offer.status === 'WITHDRAWN' ? 'снято' : 'отклонено';
throw new ForbiddenException(
`Нельзя снять предложение, которое уже ${statusLabel}.`
);
}
} else {
// Редактировать можно ACTIVE / PENDING_MODERATION / REJECTED: отклонённое
// правится для устранения причины и переотправки на модерацию (не
// пересоздаётся заново). Снятое сначала возвращают на публикацию
// (republish), затем редактируют.
if (offer.status === 'WITHDRAWN') {
throw new ForbiddenException(
'Нельзя отредактировать снятое предложение. Сначала верните его на публикацию.'
);
}
}
return offer;
}
@@ -517,6 +589,67 @@ export class MarketplaceOfferService {
* (индекс 0 = обложка). При провале загрузки одного из файлов — чистит уже
* загруженные, чтобы не плодить сирот, и пробрасывает понятную ошибку.
*/
/**
* Пересборка набора изображений при редактировании. Каждый элемент — либо
* ссылка на уже сохранённое изображение (bucket_key — оставляем как есть),
* либо новый файл (base64 — грузим). Возвращает финальный упорядоченный
* набор и отдельно перечень только что загруженных (для отката при провале
* записи Offer'а). Чужой/неизвестный bucket_key отклоняем — сослаться можно
* только на изображение текущего предложения.
*/
private async resolveImagesForUpdate(
offer: MarketplaceOfferDomainEntity,
supplier_account: string,
raw: MarketplaceOfferImageUpload[]
): Promise<{ images: MarketplaceOfferImage[]; newlyUploaded: MarketplaceOfferImage[] }> {
if (raw.length > MARKETPLACE_OFFER_MAX_IMAGES) {
throw new BadRequestException(
`Слишком много изображений: максимум ${MARKETPLACE_OFFER_MAX_IMAGES} на одно предложение.`
);
}
const existingByKey = new Map(
(offer.images ?? []).map((img) => [img.bucket_key, img])
);
const images: MarketplaceOfferImage[] = [];
const newlyUploaded: MarketplaceOfferImage[] = [];
try {
for (const item of raw) {
if (item.bucket_key) {
const kept = existingByKey.get(item.bucket_key);
if (!kept) {
throw new BadRequestException(
'Неизвестное изображение: сослаться можно только на собственные изображения этого предложения.'
);
}
images.push(kept);
continue;
}
if (!item.base64) {
throw new BadRequestException('Пустое изображение: отсутствует содержимое файла.');
}
const bytes = Buffer.from(item.base64, 'base64');
if (bytes.length === 0) {
throw new BadRequestException('Не удалось декодировать изображение (пустые данные).');
}
const image = await this.imagesService.putImage({
bytes,
contentType: item.mime_type as string,
coopname: offer.coopname,
ownerAccount: supplier_account,
});
newlyUploaded.push(image);
images.push(image);
}
return { images, newlyUploaded };
} catch (e) {
await this.cleanupImages(newlyUploaded);
if (e instanceof BadRequestException) throw e;
throw new BadRequestException(
`Не удалось загрузить изображение: ${e instanceof Error ? e.message : String(e)}`
);
}
}
private async uploadImages(
coopname: string,
supplier_account: string,
@@ -532,7 +665,7 @@ export class MarketplaceOfferService {
const result: MarketplaceOfferImage[] = [];
try {
for (const file of raw) {
if (!file.base64) {
if (!file.base64 || !file.mime_type) {
throw new BadRequestException('Пустое изображение: отсутствует содержимое файла.');
}
const bytes = Buffer.from(file.base64, 'base64');
@@ -276,8 +276,10 @@ describe('MinioFileStorageAdapter — getReadUrl', () => {
const exp = Number(parsed.searchParams.get('exp'));
const sig = parsed.searchParams.get('sig')!;
const now = Math.floor(Date.now() / 1000);
// exp округляется вверх к границе окна стабилизации (300с): не истёк и не
// дальше ttl+окно. Точную верхнюю границу не фиксируем (зависит от фазы окна).
expect(exp).toBeGreaterThan(now);
expect(exp).toBeLessThanOrEqual(now + 61);
expect(exp - now).toBeLessThanOrEqual(60 + 300 + 2);
expect(sig).toMatch(/^[0-9a-f]{64}$/);
const ok = verifyReadUrl({
@@ -296,8 +298,19 @@ describe('MinioFileStorageAdapter — getReadUrl', () => {
const url = await bucket.getReadUrl('x.jpg');
const exp = Number(new URL(url).searchParams.get('exp'));
const now = Math.floor(Date.now() / 1000);
expect(exp - now).toBeGreaterThanOrEqual(28);
expect(exp - now).toBeLessThanOrEqual(31);
// ttl=30 + округление к окну стабилизации (300с).
expect(exp - now).toBeGreaterThanOrEqual(25);
expect(exp - now).toBeLessThanOrEqual(30 + 300 + 2);
});
it('стабилизирует URL: повторные вызовы для одного ключа в окне дают идентичный URL', async () => {
// Регрессия на «мигающую» картинку: подписанный URL не должен меняться от
// вызова к вызову (иначе у <img> меняется src и браузер перекачивает файл).
const { adapter } = makeAdapter();
const bucket = adapter.getBucket(SPEC);
const first = await bucket.getReadUrl('orders/42/main.jpg', { ttlSeconds: 60 });
const second = await bucket.getReadUrl('orders/42/main.jpg', { ttlSeconds: 60 });
expect(first).toBe(second);
});
it('экранирует ключ посегментно, сохраняя слэши как разделители', async () => {
@@ -32,6 +32,15 @@ import {
} from './file-storage.config';
import { signReadUrl } from './signing';
/**
* Окно стабилизации signed-URL чтения (сек). `exp` округляется к границе окна,
* поэтому повторные `getReadUrl` для одного объекта в пределах окна дают
* идентичный URL. Без этого каждый запрос (например, 30-секундный polling
* списка «Мои предложения») генерировал новый `exp`/`sig` → у `<img>` менялся
* `src` → браузер перекачивал картинку и мигал лоудером.
*/
const READ_URL_STABILIZATION_WINDOW_SECONDS = 300;
/**
* Адаптер `InterFileStoragePort` поверх MinIO/S3. Реализация одна и та же для обоих —
* различается только `endpoint` и `forcePathStyle`. На bootstrap идемпотентно создаёт
@@ -203,7 +212,15 @@ class MinioBucketHandle implements InterFileStorageBucket {
async getReadUrl(key: string, opts?: InterFileStorageGetReadUrlOptions): Promise<string> {
const ttl = opts?.ttlSeconds ?? this.defaultTtlSeconds;
const exp = Math.floor(Date.now() / 1000) + ttl;
// Округляем exp вверх к границе окна стабилизации: один и тот же объект
// при повторных запросах даёт идентичный URL (см.
// READ_URL_STABILIZATION_WINDOW_SECONDS). exp всегда ≥ now+ttl —
// эффективный TTL клиента от ttl до ttl+окно, подпись валидна.
const nowSec = Math.floor(Date.now() / 1000);
const windowStart =
Math.floor(nowSec / READ_URL_STABILIZATION_WINDOW_SECONDS) *
READ_URL_STABILIZATION_WINDOW_SECONDS;
const exp = windowStart + READ_URL_STABILIZATION_WINDOW_SECONDS + ttl;
const sig = signReadUrl({
bucket: this.publicBucketName,
key,
@@ -5,7 +5,8 @@
* - create: статус PENDING_MODERATION; rate-limit 10/час; неизвестная
* категория → 400; валидация product_name/description/unit/cycle/price;
* unlimited_flag=true обнуляет quantity_available;
* - update: ownership check (403 чужому); REJECTED/WITHDRAWN → 403;
* - update: ownership check (403 чужому); WITHDRAWN → 403; REJECTED →
* правка проходит и уходит на повторную модерацию;
* reset status в PENDING_MODERATION; валидация полей;
* - withdraw: ownership check; статус → WITHDRAWN; блок при активных
* ордерах (stub false до Эпика 4);
@@ -362,7 +363,7 @@ describe('MarketplaceOfferService.update', () => {
);
});
it('update WITHDRAWN/REJECTED → 403', async () => {
it('update WITHDRAWN → 403 (сначала republish)', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(makeOffer({ status: 'WITHDRAWN' }));
@@ -373,6 +374,42 @@ describe('MarketplaceOfferService.update', () => {
);
});
it('update REJECTED → правка проходит и уходит на повторную модерацию (status PENDING, причина очищена)', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(
makeOffer({ status: 'REJECTED', reject_reason: 'Плохое фото', rejected_by: 'chairman' })
);
repo.applyUpdate.mockResolvedValue(makeOffer({ status: 'PENDING_MODERATION' }));
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
const result = await service.update('offer-1', 'alice', { product_name: 'Исправлено' });
expect(repo.applyUpdate).toHaveBeenCalledWith(
'offer-1',
expect.objectContaining({
product_name: 'Исправлено',
status: 'PENDING_MODERATION',
reject_reason: null,
rejected_by: null,
})
);
expect(result.status).toBe('PENDING_MODERATION');
});
it('update REJECTED только цены → всё равно уходит на повторную модерацию', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(makeOffer({ status: 'REJECTED' }));
repo.applyUpdate.mockResolvedValue(makeOffer({ status: 'PENDING_MODERATION' }));
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
await service.update('offer-1', 'alice', { price_per_unit: '99.0000' });
expect(repo.applyUpdate).toHaveBeenCalledWith(
'offer-1',
expect.objectContaining({ status: 'PENDING_MODERATION' })
);
});
it('update несуществующего → 404', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
@@ -384,18 +421,21 @@ describe('MarketplaceOfferService.update', () => {
);
});
it('unlimited_flag=true в patch обнуляет quantity_available', async () => {
it('unlimited_flag=true в patch обнуляет quantity_available и НЕ сбрасывает статус (операционное поле)', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE' }));
repo.applyUpdate.mockResolvedValue(makeOffer({ unlimited_flag: true, quantity_available: 0 }));
repo.applyUpdate.mockResolvedValue(makeOffer({ status: 'ACTIVE', unlimited_flag: true, quantity_available: 0 }));
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
await service.update('offer-1', 'alice', { unlimited_flag: true });
expect(repo.applyUpdate).toHaveBeenCalledWith(
'offer-1',
expect.objectContaining({ unlimited_flag: true, quantity_available: 0, status: 'PENDING_MODERATION' })
expect.objectContaining({ unlimited_flag: true, quantity_available: 0 })
);
// unlimited_flag — операционное поле: статус активной оферты не трогаем.
const patchArg = repo.applyUpdate.mock.calls[0][1];
expect(patchArg).not.toHaveProperty('status');
});
it('update с invalid category → 400', async () => {
@@ -410,6 +450,102 @@ describe('MarketplaceOfferService.update', () => {
});
});
describe('MarketplaceOfferService.update — поле-зависимая модерация', () => {
// Операционные поля (цена, остаток, безлимит) поставщик меняет без участия
// председателя: активная оферта остаётся ACTIVE, на повторную модерацию не
// уходит. Контентные поля (название/описание/категория/фото/единица/цикл)
// снова шлют на модерацию.
it('правка только цены на ACTIVE → статус НЕ сбрасывается, approve-поля не трогаются', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE' }));
repo.applyUpdate.mockResolvedValue(makeOffer({ status: 'ACTIVE', price_per_unit: '99.0000' }));
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
const result = await service.update('offer-1', 'alice', { price_per_unit: '99.00' });
expect(result.status).toBe('ACTIVE');
const patchArg = repo.applyUpdate.mock.calls[0][1];
expect(patchArg).not.toHaveProperty('status');
expect(patchArg).not.toHaveProperty('approved_by');
expect(patchArg).not.toHaveProperty('reject_reason');
});
it('правка только количества на ACTIVE → статус НЕ сбрасывается', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE' }));
repo.applyUpdate.mockResolvedValue(makeOffer({ status: 'ACTIVE', quantity_available: 250 }));
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
await service.update('offer-1', 'alice', { quantity_available: 250 });
expect(repo.applyUpdate.mock.calls[0][1]).not.toHaveProperty('status');
});
it('правка цены + количества вместе на ACTIVE → статус НЕ сбрасывается', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE' }));
repo.applyUpdate.mockResolvedValue(makeOffer({ status: 'ACTIVE' }));
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
await service.update('offer-1', 'alice', { price_per_unit: '12.00', quantity_available: 5 });
expect(repo.applyUpdate.mock.calls[0][1]).not.toHaveProperty('status');
});
it('правка описания на ACTIVE → статус сбрасывается в PENDING_MODERATION', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE' }));
repo.applyUpdate.mockResolvedValue(makeOffer({ status: 'PENDING_MODERATION' }));
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
await service.update('offer-1', 'alice', { description: 'Новое описание' });
expect(repo.applyUpdate).toHaveBeenCalledWith(
'offer-1',
expect.objectContaining({ description: 'Новое описание', status: 'PENDING_MODERATION', reject_reason: null })
);
});
it('смена единицы измерения на ACTIVE → статус сбрасывается (контентное поле)', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE' }));
repo.applyUpdate.mockResolvedValue(makeOffer({ status: 'PENDING_MODERATION' }));
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
await service.update('offer-1', 'alice', { unit_of_measure: 'liter' });
expect(repo.applyUpdate.mock.calls[0][1]).toMatchObject({ status: 'PENDING_MODERATION' });
});
it('замена изображений на ACTIVE → статус сбрасывается (контентное изменение)', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
const images = {
putImage: jest.fn().mockResolvedValue({
bucket_key: 'offers/voskhod/alice/h.jpg',
content_hash: 'h',
mime_type: 'image/jpeg',
}),
getReadUrl: jest.fn().mockResolvedValue('https://signed.example/img'),
deleteImage: jest.fn().mockResolvedValue(undefined),
};
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE' }));
repo.applyUpdate.mockResolvedValue(makeOffer({ status: 'PENDING_MODERATION' }));
const service = new MarketplaceOfferService(
repo,
cats,
makeOrderRepo(),
images as unknown as import('~/extensions/marketplace/application/services/marketplace-offer-images.service').MarketplaceOfferImagesService
);
await service.update('offer-1', 'alice', {}, [
{ base64: Buffer.from('a').toString('base64'), mime_type: 'image/jpeg' },
]);
expect(repo.applyUpdate.mock.calls[0][1]).toMatchObject({ status: 'PENDING_MODERATION' });
});
});
describe('MarketplaceOfferService.withdraw', () => {
it('withdraw → status WITHDRAWN', async () => {
const repo = makeOfferRepo();
@@ -467,6 +603,47 @@ describe('MarketplaceOfferService.withdraw', () => {
});
});
describe('MarketplaceOfferService.republish', () => {
it('republish снятого → status PENDING_MODERATION (без пересоздания)', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(makeOffer({ status: 'WITHDRAWN' }));
repo.applyUpdate.mockResolvedValue(makeOffer({ status: 'PENDING_MODERATION' }));
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
const result = await service.republish('offer-1', 'alice');
expect(repo.applyUpdate).toHaveBeenCalledWith('offer-1', { status: 'PENDING_MODERATION' });
expect(result.status).toBe('PENDING_MODERATION');
});
it('republish чужого → 403', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(makeOffer({ supplier_account: 'alice', status: 'WITHDRAWN' }));
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
await expect(service.republish('offer-1', 'mallory')).rejects.toThrow(ForbiddenException);
});
it('republish не-снятого (ACTIVE) → 403', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE' }));
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
await expect(service.republish('offer-1', 'alice')).rejects.toThrow(ForbiddenException);
});
it('republish несуществующего → 404', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
repo.findById.mockResolvedValue(null);
const service = new MarketplaceOfferService(repo, cats, makeOrderRepo(), makeImagesService());
await expect(service.republish('offer-x', 'alice')).rejects.toThrow(NotFoundException);
});
});
describe('MarketplaceOfferService.listMine + getById', () => {
it('listMine делегирует в репозиторий с фильтром supplier_account', async () => {
const repo = makeOfferRepo();
@@ -789,4 +966,72 @@ describe('MarketplaceOfferService — изображения', () => {
).rejects.toThrow('db down');
expect(images.deleteImage).toHaveBeenCalledTimes(1);
});
it('update: сохраняет существующее по bucket_key и добавляет новое base64 (порядок = показ)', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
const images = imagesMock();
const existing = {
bucket_key: 'offers/voskhod/alice/keep.jpg',
content_hash: 'keep',
mime_type: 'image/jpeg',
};
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE', images: [existing] }));
repo.applyUpdate.mockImplementation((_id, patch) => Promise.resolve(makeOffer(patch as object)));
const service = new MarketplaceOfferService(
repo,
cats,
makeOrderRepo(),
images as unknown as import('~/extensions/marketplace/application/services/marketplace-offer-images.service').MarketplaceOfferImagesService
);
const result = await service.update('offer-1', 'alice', {}, [
{ bucket_key: existing.bucket_key },
{ base64: Buffer.from('new').toString('base64'), mime_type: 'image/png' },
]);
// Существующее не перезагружается, новое — одно.
expect(images.putImage).toHaveBeenCalledTimes(1);
expect(result.images).toHaveLength(2);
expect(result.images[0].bucket_key).toBe(existing.bucket_key);
expect(result.images[1].mime_type).toBe('image/png');
});
it('update: удаление существующего = просто не передаём его bucket_key (набор сокращается)', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
const images = imagesMock();
const a = { bucket_key: 'offers/voskhod/alice/a.jpg', content_hash: 'a', mime_type: 'image/jpeg' };
const b = { bucket_key: 'offers/voskhod/alice/b.jpg', content_hash: 'b', mime_type: 'image/jpeg' };
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE', images: [a, b] }));
repo.applyUpdate.mockImplementation((_id, patch) => Promise.resolve(makeOffer(patch as object)));
const service = new MarketplaceOfferService(
repo,
cats,
makeOrderRepo(),
images as unknown as import('~/extensions/marketplace/application/services/marketplace-offer-images.service').MarketplaceOfferImagesService
);
const result = await service.update('offer-1', 'alice', {}, [{ bucket_key: b.bucket_key }]);
expect(result.images).toHaveLength(1);
expect(result.images[0].bucket_key).toBe(b.bucket_key);
expect(images.putImage).not.toHaveBeenCalled();
});
it('update: ссылка на чужой/неизвестный bucket_key → 400', async () => {
const repo = makeOfferRepo();
const cats = makeCategoryRepo();
const images = imagesMock();
repo.findById.mockResolvedValue(makeOffer({ status: 'ACTIVE', images: [] }));
const service = new MarketplaceOfferService(
repo,
cats,
makeOrderRepo(),
images as unknown as import('~/extensions/marketplace/application/services/marketplace-offer-images.service').MarketplaceOfferImagesService
);
await expect(
service.update('offer-1', 'alice', {}, [{ bucket_key: 'offers/voskhod/mallory/stolen.jpg' }])
).rejects.toThrow(BadRequestException);
});
});
+8
View File
@@ -802,6 +802,9 @@ export const AllTypesProps: Record<string,any> = {
},
MarketplaceRemoveFromWhitelistInput:{
},
MarketplaceRepublishOfferInput:{
},
MarketplaceReturnClaimDefectCategory: "enum" as const,
MarketplaceReturnClaimExpectedResolution: "enum" as const,
@@ -1411,6 +1414,9 @@ export const AllTypesProps: Record<string,any> = {
marketplaceReplaceAvailableItems:{
input:"ReplaceAvailableItemsInput"
},
marketplaceRepublishOffer:{
input:"MarketplaceRepublishOfferInput"
},
marketplaceRetryKUGeocode:{
},
@@ -4154,6 +4160,7 @@ export const ReturnTypes: Record<string,any> = {
warranty_days:"Int"
},
MarketplaceOfferImage:{
bucket_key:"String",
is_cover:"Boolean",
mime_type:"String",
sort_order:"Int",
@@ -4761,6 +4768,7 @@ export const ReturnTypes: Record<string,any> = {
marketplaceRemoveAvailableCategoryTypes:"Boolean",
marketplaceRemoveFromWhitelist:"Boolean",
marketplaceReplaceAvailableItems:"MarketplaceAvailableCategory",
marketplaceRepublishOffer:"MarketplaceOffer",
marketplaceRetryKUGeocode:"MarketplaceKUDetails",
marketplaceSetKUStatus:"MarketplaceKUDetails",
marketplaceSignAplReceptionAsChairman:"MarketplaceAplReceptionResult",
+52 -17
View File
@@ -7005,6 +7005,8 @@ export type ValueTypes = {
['...on MarketplaceOffer']?: Omit<ValueTypes["MarketplaceOffer"], "...on MarketplaceOffer">
}>;
["MarketplaceOfferImage"]: AliasType<{
/** Ключ объекта в хранилище — стабильный идентификатор изображения. Передаётся обратно при редактировании, чтобы сохранить уже загруженное изображение (в отличие от base64 для новых файлов). */
bucket_key?:boolean | `@${string}`,
/** Является ли изображение обложкой карточки. */
is_cover?:boolean | `@${string}`,
/** MIME-тип изображения. */
@@ -7017,10 +7019,12 @@ export type ValueTypes = {
['...on MarketplaceOfferImage']?: Omit<ValueTypes["MarketplaceOfferImage"], "...on MarketplaceOfferImage">
}>;
["MarketplaceOfferImageUploadInput"]: {
/** Содержимое изображения, закодированное в base64. */
base64: string | Variable<any, string>,
/** MIME-тип изображения (image/jpeg, image/png либо image/webp). */
mime_type: string | Variable<any, string>
/** Содержимое нового изображения в base64. */
base64?: string | undefined | null | Variable<any, string>,
/** Ключ уже сохранённого изображения — сохранить его в наборе. */
bucket_key?: string | undefined | null | Variable<any, string>,
/** MIME-тип нового изображения (image/jpeg, image/png либо image/webp). */
mime_type?: string | undefined | null | Variable<any, string>
};
["MarketplaceOfferPaginationResult"]: AliasType<{
/** Текущая страница */
@@ -7255,6 +7259,9 @@ export type ValueTypes = {
["MarketplaceRemoveFromWhitelistInput"]: {
/** eosio::name пайщика-поставщика */
member_account: string | Variable<any, string>
};
["MarketplaceRepublishOfferInput"]: {
id: string | Variable<any, string>
};
["MarketplaceRequest"]: AliasType<{
/** Артикул товара */
@@ -8245,6 +8252,7 @@ marketplaceRemoveAvailableCategories?: [{ input: ValueTypes["RemoveAvailableCate
marketplaceRemoveAvailableCategoryTypes?: [{ input: ValueTypes["RemoveAvailableCategoryTypesInput"] | Variable<any, string>},boolean | `@${string}`],
marketplaceRemoveFromWhitelist?: [{ input: ValueTypes["MarketplaceRemoveFromWhitelistInput"] | Variable<any, string>},boolean | `@${string}`],
marketplaceReplaceAvailableItems?: [{ input: ValueTypes["ReplaceAvailableItemsInput"] | Variable<any, string>},ValueTypes["MarketplaceAvailableCategory"]],
marketplaceRepublishOffer?: [{ input: ValueTypes["MarketplaceRepublishOfferInput"] | Variable<any, string>},ValueTypes["MarketplaceOffer"]],
marketplaceRetryKUGeocode?: [{ coopname: string | Variable<any, string>, coreBraname: string | Variable<any, string>},ValueTypes["MarketplaceKUDetails"]],
marketplaceSetKUStatus?: [{ data: ValueTypes["MarketplaceSetKUStatusInput"] | Variable<any, string>},ValueTypes["MarketplaceKUDetails"]],
marketplaceSignAplReceptionAsChairman?: [{ data: ValueTypes["MarketplaceSignAplReceptionInput"] | Variable<any, string>},ValueTypes["MarketplaceAplReceptionResult"]],
@@ -17030,6 +17038,8 @@ export type ResolverInputTypes = {
__typename?: boolean | `@${string}`
}>;
["MarketplaceOfferImage"]: AliasType<{
/** Ключ объекта в хранилище — стабильный идентификатор изображения. Передаётся обратно при редактировании, чтобы сохранить уже загруженное изображение (в отличие от base64 для новых файлов). */
bucket_key?:boolean | `@${string}`,
/** Является ли изображение обложкой карточки. */
is_cover?:boolean | `@${string}`,
/** MIME-тип изображения. */
@@ -17041,10 +17051,12 @@ export type ResolverInputTypes = {
__typename?: boolean | `@${string}`
}>;
["MarketplaceOfferImageUploadInput"]: {
/** Содержимое изображения, закодированное в base64. */
base64: string,
/** MIME-тип изображения (image/jpeg, image/png либо image/webp). */
mime_type: string
/** Содержимое нового изображения в base64. */
base64?: string | undefined | null,
/** Ключ уже сохранённого изображения — сохранить его в наборе. */
bucket_key?: string | undefined | null,
/** MIME-тип нового изображения (image/jpeg, image/png либо image/webp). */
mime_type?: string | undefined | null
};
["MarketplaceOfferPaginationResult"]: AliasType<{
/** Текущая страница */
@@ -17270,6 +17282,9 @@ export type ResolverInputTypes = {
["MarketplaceRemoveFromWhitelistInput"]: {
/** eosio::name пайщика-поставщика */
member_account: string
};
["MarketplaceRepublishOfferInput"]: {
id: string
};
["MarketplaceRequest"]: AliasType<{
/** Артикул товара */
@@ -18232,6 +18247,7 @@ marketplaceRemoveAvailableCategories?: [{ input: ResolverInputTypes["RemoveAvail
marketplaceRemoveAvailableCategoryTypes?: [{ input: ResolverInputTypes["RemoveAvailableCategoryTypesInput"]},boolean | `@${string}`],
marketplaceRemoveFromWhitelist?: [{ input: ResolverInputTypes["MarketplaceRemoveFromWhitelistInput"]},boolean | `@${string}`],
marketplaceReplaceAvailableItems?: [{ input: ResolverInputTypes["ReplaceAvailableItemsInput"]},ResolverInputTypes["MarketplaceAvailableCategory"]],
marketplaceRepublishOffer?: [{ input: ResolverInputTypes["MarketplaceRepublishOfferInput"]},ResolverInputTypes["MarketplaceOffer"]],
marketplaceRetryKUGeocode?: [{ coopname: string, coreBraname: string},ResolverInputTypes["MarketplaceKUDetails"]],
marketplaceSetKUStatus?: [{ data: ResolverInputTypes["MarketplaceSetKUStatusInput"]},ResolverInputTypes["MarketplaceKUDetails"]],
marketplaceSignAplReceptionAsChairman?: [{ data: ResolverInputTypes["MarketplaceSignAplReceptionInput"]},ResolverInputTypes["MarketplaceAplReceptionResult"]],
@@ -26730,7 +26746,9 @@ export type ModelTypes = {
warranty_days: number
};
["MarketplaceOfferImage"]: {
/** Является ли изображение обложкой карточки. */
/** Ключ объекта в хранилище — стабильный идентификатор изображения. Передаётся обратно при редактировании, чтобы сохранить уже загруженное изображение (в отличие от base64 для новых файлов). */
bucket_key: string,
/** Является ли изображение обложкой карточки. */
is_cover: boolean,
/** MIME-тип изображения. */
mime_type: string,
@@ -26740,10 +26758,12 @@ export type ModelTypes = {
url: string
};
["MarketplaceOfferImageUploadInput"]: {
/** Содержимое изображения, закодированное в base64. */
base64: string,
/** MIME-тип изображения (image/jpeg, image/png либо image/webp). */
mime_type: string
/** Содержимое нового изображения в base64. */
base64?: string | undefined | null,
/** Ключ уже сохранённого изображения — сохранить его в наборе. */
bucket_key?: string | undefined | null,
/** MIME-тип нового изображения (image/jpeg, image/png либо image/webp). */
mime_type?: string | undefined | null
};
["MarketplaceOfferPaginationResult"]: {
/** Текущая страница */
@@ -26956,6 +26976,9 @@ export type ModelTypes = {
["MarketplaceRemoveFromWhitelistInput"]: {
/** eosio::name пайщика-поставщика */
member_account: string
};
["MarketplaceRepublishOfferInput"]: {
id: string
};
["MarketplaceRequest"]: {
/** Артикул товара */
@@ -28320,6 +28343,8 @@ export type ModelTypes = {
Требуемые роли: chairman. */
marketplaceReplaceAvailableItems: Array<ModelTypes["MarketplaceAvailableCategory"]>,
/** Поставщик возвращает снятый Offer на публикацию (статус → PENDING_MODERATION) */
marketplaceRepublishOffer: ModelTypes["MarketplaceOffer"],
/** Повторно запускает геокодинг адреса ПВЗ.
Требуемые роли: chairman. */
@@ -37365,6 +37390,8 @@ export type GraphQLTypes = {
};
["MarketplaceOfferImage"]: {
__typename: "MarketplaceOfferImage",
/** Ключ объекта в хранилище — стабильный идентификатор изображения. Передаётся обратно при редактировании, чтобы сохранить уже загруженное изображение (в отличие от base64 для новых файлов). */
bucket_key: string,
/** Является ли изображение обложкой карточки. */
is_cover: boolean,
/** MIME-тип изображения. */
@@ -37376,10 +37403,12 @@ export type GraphQLTypes = {
['...on MarketplaceOfferImage']: Omit<GraphQLTypes["MarketplaceOfferImage"], "...on MarketplaceOfferImage">
};
["MarketplaceOfferImageUploadInput"]: {
/** Содержимое изображения, закодированное в base64. */
base64: string,
/** MIME-тип изображения (image/jpeg, image/png либо image/webp). */
mime_type: string
/** Содержимое нового изображения в base64. */
base64?: string | undefined | null,
/** Ключ уже сохранённого изображения — сохранить его в наборе. */
bucket_key?: string | undefined | null,
/** MIME-тип нового изображения (image/jpeg, image/png либо image/webp). */
mime_type?: string | undefined | null
};
["MarketplaceOfferPaginationResult"]: {
__typename: "MarketplaceOfferPaginationResult",
@@ -37614,6 +37643,9 @@ export type GraphQLTypes = {
["MarketplaceRemoveFromWhitelistInput"]: {
/** eosio::name пайщика-поставщика */
member_account: string
};
["MarketplaceRepublishOfferInput"]: {
id: string
};
["MarketplaceRequest"]: {
__typename: "MarketplaceRequest",
@@ -39042,6 +39074,8 @@ export type GraphQLTypes = {
Требуемые роли: chairman. */
marketplaceReplaceAvailableItems: Array<GraphQLTypes["MarketplaceAvailableCategory"]>,
/** Поставщик возвращает снятый Offer на публикацию (статус → PENDING_MODERATION) */
marketplaceRepublishOffer: GraphQLTypes["MarketplaceOffer"],
/** Повторно запускает геокодинг адреса ПВЗ.
Требуемые роли: chairman. */
@@ -43278,6 +43312,7 @@ type ZEUS_VARIABLES = {
["MarketplaceRejectReturnAtVisitInput"]: ValueTypes["MarketplaceRejectReturnAtVisitInput"];
["MarketplaceRejectReturnRemoteInput"]: ValueTypes["MarketplaceRejectReturnRemoteInput"];
["MarketplaceRemoveFromWhitelistInput"]: ValueTypes["MarketplaceRemoveFromWhitelistInput"];
["MarketplaceRepublishOfferInput"]: ValueTypes["MarketplaceRepublishOfferInput"];
["MarketplaceReturnClaimDefectCategory"]: ValueTypes["MarketplaceReturnClaimDefectCategory"];
["MarketplaceReturnClaimExpectedResolution"]: ValueTypes["MarketplaceReturnClaimExpectedResolution"];
["MarketplaceReturnClaimPhotoUploadInput"]: ValueTypes["MarketplaceReturnClaimPhotoUploadInput"];
@@ -230,6 +230,9 @@ export default async function (): Promise<IWorkspaceConfig[]> {
children: [],
},
{
// Скрыт из меню (hidden) — кнопка «Создать предложение» живёт по
// канону в правом верхнем углу шапки (телепорт через
// useHeaderActions со стола «Мои предложения»).
path: 'create-offer',
name: 'marketplace-create-offer',
component: markRaw(CreateMarketplaceOfferPage),
@@ -239,6 +242,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
requires: 'Offer:read',
requiresAuth: true,
agreements: agreementsBase,
hidden: true,
},
children: [],
},
@@ -53,3 +53,30 @@ export async function fetchMyOfferById(
};
return page.items.find((o) => o.id === id) ?? null;
}
/**
* Поставщик снимает своё предложение с публикации (статус WITHDRAWN).
* Backend: marketplace-offer.resolver.ts marketplaceWithdrawOffer
* (guard 'Offer' 'delete:own', ownership проверяется в сервисе).
*/
export async function withdrawOffer(id: string): Promise<void> {
await client.Mutation(Mutations.Marketplace.WithdrawOffer.mutation, {
variables: { input: { id } },
});
}
/**
* Эпик 4 / Story 4.2: поставщик вручную запускает поставку по своему
* предложению с открытой подпиской (cycle_type=open_subscription).
*
* Backend Resolver: marketplace-cycle.resolver.ts marketplaceTriggerOpenSubscription
* (guard 'Offer' 'update:own'). Нажатие = акцепт всего накопленного пула:
* сервер формирует сводную заявку status=ACCEPTED и принимает заказы разом.
* Ошибки backend (пустой пул, не open_subscription, не ACTIVE, чужой Offer)
* приходят как GraphQL-исключения пробрасываем их вызывающему компоненту.
*/
export async function triggerOpenSubscription(offer_id: string): Promise<void> {
await client.Mutation(Mutations.Marketplace.TriggerOpenSubscription.mutation, {
variables: { input: { offer_id } },
});
}
@@ -13,15 +13,22 @@ export type MarketplaceOfferCycleType =
export type MarketplaceUnitOfMeasure = 'piece' | 'kg' | 'liter' | 'pack';
/** Новое загружаемое изображение (содержимое в base64 + MIME). */
/**
* Элемент набора изображений в payload. ЛИБО новый файл (base64 + mime_type),
* ЛИБО ссылка на уже сохранённое изображение (bucket_key сохранить его в
* наборе). Порядок в массиве = порядок показа, первый обложка.
*/
export interface MarketplaceOfferImageUpload {
base64: string;
mime_type: string;
base64?: string;
mime_type?: string;
bucket_key?: string;
}
/** Уже сохранённое изображение (приходит с backend как подписанный URL). */
export interface MarketplaceOfferImageView {
url: string;
/** Стабильный ключ хранилища — для сохранения изображения при правке набора. */
bucket_key: string;
mime_type: string;
sort_order: number;
is_cover: boolean;
@@ -69,9 +76,10 @@ export interface MarketplaceCreateOfferPayload {
min_threshold: number | null;
warranty_days: number;
/**
* Изображения товара (base64). При создании набор новых файлов (может
* быть пустым). При обновлении если передано, полностью заменяет текущий
* набор; если опущено (undefined) изображения не трогаются.
* Итоговый упорядоченный набор изображений (первый обложка). При создании
* только новые файлы (base64). При обновлении смесь: уже сохранённые
* (bucket_key, остаются) + новые (base64). Если опущено (undefined)
* изображения не трогаются и оффер не уходит на повторную модерацию.
*/
images?: MarketplaceOfferImageUpload[];
}
@@ -113,5 +121,6 @@ export interface MarketplaceOfferEditPrefill {
min_threshold: number | null;
warranty_days: number;
status: 'PENDING_MODERATION' | 'ACTIVE' | 'REJECTED' | 'WITHDRAWN';
reject_reason?: string | null;
images?: MarketplaceOfferImageView[];
}
@@ -1,22 +1,58 @@
<template lang="pug">
q-page.mp-role-offerer.offer-wizard(role='region', aria-label='Создание предложения')
.offer-wizard__col
header.offer-wizard__head
h1.offer-wizard__title {{ pageTitle }}
p.offer-wizard__intro
| Заполните карточку товара по шагам. После публикации предложение
| уходит на модерацию председателю до одобрения оно не появится в каталоге.
//- Заголовок страницы в топбаре (route.meta.title), на странице не
//- дублируется. Верхняя строка режима редактирования: статус слева,
//- операционные действия (снять / запустить поставку) в правом углу.
.offer-wizard__manage(v-if='isEdit && currentStatus')
BaseChip(:variant='statusVariant', size='lg') {{ statusLabel }}
q-space
BaseButton(
v-if='canTrigger',
variant='secondary',
:loading='triggering',
@click='onTrigger'
)
q-icon(name='local_shipping', size='16px')
span.q-ml-sm Запустить поставку
BaseButton(
v-if='canWithdraw',
variant='danger',
:loading='withdrawing',
@click='onWithdraw'
)
q-icon(name='visibility_off', size='16px')
span.q-ml-sm Снять с публикации
q-banner.offer-wizard__notice(v-if='isEdit', rounded, dense)
template(#avatar)
q-icon(name='fa-solid fa-circle-info', color='warning')
| После сохранения предложение снова отправится на модерацию до одобрения
| оно будет недоступно в каталоге.
//- Отклонённая оферта: показываем причину председателя. Поставщик правит
//- карточку и переотправляет пересоздавать заново не нужно.
.banner.banner--neg(v-if='isEdit && currentStatus === "REJECTED"')
q-icon.banner__icon(name='cancel', size='18px')
.banner__body
.text-weight-medium Предложение отклонено модератором
.q-mt-xs(v-if='rejectReason') Причина: {{ rejectReason }}
.q-mt-xs Исправьте указанное и нажмите «Отправить на модерацию» оферта уйдёт на повторную проверку с тем же содержимым.
q-inner-loading(:showing='prefilling')
q-spinner(color='primary', size='2em')
//- Канон-карточка информации (одна на страницу): подсказка по заполнению +
//- правила модерации. Для отклонённой показываем баннер причины выше этот
//- не дублируем.
.banner.banner--info(v-if='currentStatus !== "REJECTED"')
q-icon.banner__icon(name='info', size='18px')
.banner__body {{ infoText }}
//- Скелетон формы на время дозагрузки оферты в режиме редактирования:
//- степпер монтируем только когда данные готовы, чтобы поля не
//- «дозаполнялись» на глазах (без дёргания). В режиме создания prefilling
//- всегда false степпер рендерится сразу.
.offer-wizard__skel(v-if='prefilling')
.skel.skel--title.offer-wizard__skel-title
.skel.skel--text.offer-wizard__skel-line
.skel.skel--text.offer-wizard__skel-line.offer-wizard__skel-line--wide
.skel.skel--text.offer-wizard__skel-line.offer-wizard__skel-line--narrow
.skel.skel--text.offer-wizard__skel-line
VerticalStepper(
v-else,
:steps='steps',
:active-key='activeKey',
:completed='completedKeys',
@@ -198,21 +234,12 @@ q-page.mp-role-offerer.offer-wizard(role='region', aria-label='Создание
.offer-wizard__step(v-else-if='step.key === "images"')
p.offer-wizard__hint
| До {{ MAX_IMAGES }} изображений, каждое до {{ MAX_MB }} МБ (JPEG, PNG или WEBP).
| Нажмите на снимок, чтобы сделать его обложкой карточки.
| Нажмите на снимок, чтобы сделать его обложкой карточки; крестик удалить.
.offer-wizard__existing(v-if='isEdit && existingImages.length && !imageDrafts.length')
.offer-wizard__field-label.offer-wizard__field-label--sub Текущие изображения
.offer-wizard__grid
.offer-wizard__thumb(v-for='(img, i) in existingImages', :key='img.url')
q-img.offer-wizard__img(:src='img.url', ratio='1')
span.offer-wizard__cover(v-if='i === 0') Обложка
p.offer-wizard__hint.offer-wizard__hint--muted
| Загрузка новых изображений заменит текущие.
.offer-wizard__grid(v-if='imageDrafts.length')
.offer-wizard__grid(v-if='gallery.length')
.offer-wizard__thumb(
v-for='(img, i) in imageDrafts',
:key='img.preview_url',
v-for='(img, i) in gallery',
:key='img.uid',
:class='{ "offer-wizard__thumb--cover": i === coverIndex }',
:title='i === coverIndex ? "Это обложка" : "Сделать обложкой"',
role='button',
@@ -220,7 +247,7 @@ q-page.mp-role-offerer.offer-wizard(role='region', aria-label='Создание
@click='setCover(i)',
@keydown.enter='setCover(i)'
)
q-img.offer-wizard__img(:src='img.preview_url', ratio='1')
q-img.offer-wizard__img(:src='img.url', ratio='1')
span.offer-wizard__cover(v-if='i === coverIndex') Обложка
span.offer-wizard__set(v-else) Сделать обложкой
q-btn.offer-wizard__remove(
@@ -240,11 +267,11 @@ q-page.mp-role-offerer.offer-wizard(role='region', aria-label='Создание
dense,
multiple,
accept='image/jpeg,image/png,image/webp',
:disable='imageDrafts.length >= MAX_IMAGES',
:disable='gallery.length >= MAX_IMAGES',
@update:model-value='onPickFiles'
)
template(#prepend)
q-icon(name='fa-solid fa-image')
q-icon(name='image')
//- Шаг 5: Проверка (карточка-предпросмотр)
.offer-wizard__step(v-else-if='step.key === "review"')
@@ -319,13 +346,13 @@ q-page.mp-role-offerer.offer-wizard(role='region', aria-label='Создание
span.q-mr-sm Далее
q-icon(name='arrow_forward', size='16px')
BaseButton(v-else, variant='primary', :loading='submitting', @click='onSubmit')
q-icon(name='fa-solid fa-paper-plane', size='14px')
q-icon(name='send', size='16px')
span.q-ml-sm {{ submitLabel }}
</template>
<script lang="ts" setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { LocalStorage } from 'quasar';
import { Dialog, LocalStorage, Notify } from 'quasar';
import type { QForm } from 'quasar';
import { useRoute, useRouter } from 'vue-router';
import { VerticalStepper } from 'src/shared/ui/domain/VerticalStepper';
@@ -338,15 +365,20 @@ import { FailAlert, SuccessAlert } from 'src/shared/api';
import { useSystemStore } from 'src/entities/System/model';
import { MARKETPLACE_UNIT_OPTIONS } from 'src/shared/lib/consts';
import { fileToBase64, formatAsset2Digits } from 'src/shared/lib/utils';
import { createOffer, fetchCategories, fetchMyOfferById, updateOffer } from '../api';
import {
createOffer,
fetchCategories,
fetchMyOfferById,
triggerOpenSubscription,
updateOffer,
withdrawOffer,
} from '../api';
import type {
MarketplaceCategoryView,
MarketplaceCreateOfferFormState,
MarketplaceCreateOfferPayload,
MarketplaceOfferCycleType,
MarketplaceOfferImageDraft,
MarketplaceOfferImageUpload,
MarketplaceOfferImageView,
MarketplaceUnitOfMeasure,
} from '../types';
@@ -384,6 +416,13 @@ const priceRule = (v: string): true | string =>
/^\d+([.,]\d{1,2})?$/.test((v ?? '').trim()) ||
'Цена — число с двумя знаками после запятой, например 100.50';
// Цена из БД приходит с большей точностью (напр. «100.0000») и не проходит
// priceRule. При prefill приводим к виду поля ввода 2 знака после точки.
function formatPriceForInput(raw: string | number | null | undefined): string {
const n = Number(raw);
return Number.isFinite(n) ? n.toFixed(2) : '';
}
const editId = computed(() => {
const p = route.params.offerId;
return typeof p === 'string' && p ? p : null;
@@ -392,10 +431,101 @@ const isEdit = computed(() => editId.value !== null);
const prefilling = ref(false);
const submitting = ref(false);
const pageTitle = computed(() => (isEdit.value ? 'Редактирование предложения' : 'Новое предложение'));
const submitLabel = computed(() =>
isEdit.value ? 'Сохранить и отправить на модерацию' : 'Опубликовать на модерацию'
// Единая карточка-подсказка: при создании про публикацию и модерацию, при
// редактировании что цена/остаток применяются сразу, а контент уходит на
// повторную модерацию.
const infoText = computed(() =>
isEdit.value
? 'Заполните карточку товара по шагам. Цена и количество применяются сразу. Изменение названия, описания, категории, единицы измерения или фотографий снова отправит предложение на модерацию — до одобрения оно будет недоступно в каталоге.'
: 'Заполните карточку товара по шагам. После публикации предложение уходит на модерацию председателю — до одобрения оно не появится в каталоге.'
);
const submitLabel = computed(() => {
if (!isEdit.value) return 'Опубликовать на модерацию';
// Отклонённую правят, чтобы переотправить подпись честно говорит, что
// сохранение снова отправит оферту на модерацию.
if (currentStatus.value === 'REJECTED') return 'Отправить на модерацию';
return 'Сохранить изменения';
});
// ===== Управление офертой (только режим редактирования) =====
// Текущий статус оферты + операционные действия (снять / запустить поставку)
// живут на самой странице редактирования отдельного диалога-просмотра нет.
type OfferStatus = 'PENDING_MODERATION' | 'ACTIVE' | 'REJECTED' | 'WITHDRAWN';
const currentStatus = ref<OfferStatus | null>(null);
const currentCycleType = ref<MarketplaceOfferCycleType | null>(null);
const rejectReason = ref<string | null>(null);
const withdrawing = ref(false);
const triggering = ref(false);
const STATUS_META: Record<OfferStatus, { label: string; variant: 'neutral' | 'pos' | 'neg' | 'warn' }> = {
PENDING_MODERATION: { label: 'На модерации', variant: 'warn' },
ACTIVE: { label: 'Опубликовано', variant: 'pos' },
REJECTED: { label: 'Отклонено', variant: 'neg' },
WITHDRAWN: { label: 'Снято', variant: 'neutral' },
};
const statusLabel = computed(() => (currentStatus.value ? STATUS_META[currentStatus.value].label : ''));
const statusVariant = computed(() =>
currentStatus.value ? STATUS_META[currentStatus.value].variant : 'neutral'
);
// Снять можно опубликованную или ожидающую модерации; запуск поставки только
// активную оферту с открытой подпиской (см. backend guard'ы).
const canWithdraw = computed(
() => currentStatus.value === 'PENDING_MODERATION' || currentStatus.value === 'ACTIVE'
);
const canTrigger = computed(
() => currentStatus.value === 'ACTIVE' && currentCycleType.value === 'open_subscription'
);
function onWithdraw(): void {
if (!editId.value) return;
Dialog.create({
title: 'Снять предложение с публикации?',
message:
'Предложение перестанет показываться в каталоге и не будет принимать новые ' +
'заказы. Вернуть его можно будет, только создав заново.',
cancel: { label: 'Отмена', flat: true, noCaps: true },
ok: { label: 'Снять с публикации', color: 'negative', unelevated: true, noCaps: true },
persistent: true,
}).onOk(async () => {
withdrawing.value = true;
try {
await withdrawOffer(editId.value as string);
Notify.create({ type: 'positive', message: 'Предложение снято с публикации.' });
void router.push({ name: 'marketplace-my-offers' });
} catch (e) {
FailAlert(e, 'Не удалось снять предложение');
} finally {
withdrawing.value = false;
}
});
}
function onTrigger(): void {
if (!editId.value) return;
Dialog.create({
title: 'Запустить поставку?',
message:
'Все накопленные заказы будут разом зафиксированы в одну партию и приняты ' +
'к поставке. Отменить запуск нельзя.',
cancel: { label: 'Отмена', flat: true, noCaps: true },
ok: { label: 'Запустить поставку', color: 'primary', unelevated: true, noCaps: true },
persistent: true,
}).onOk(async () => {
triggering.value = true;
try {
await triggerOpenSubscription(editId.value as string);
Notify.create({
type: 'positive',
message: 'Поставка запущена: заказы зафиксированы в партию и приняты.',
});
void router.push({ name: 'marketplace-my-offers' });
} catch (e) {
FailAlert(e, 'Не удалось запустить поставку');
} finally {
triggering.value = false;
}
});
}
// ===== Шаги =====
const steps: StepperStep[] = [
@@ -470,11 +600,28 @@ const form = ref<MarketplaceCreateOfferFormState>({
});
// ===== Изображения =====
// Единая галерея: и уже сохранённые (bucket_key + подписанный url), и новые
// (base64 + object-url). Обложку, удаление и добавление можно применять к любому
// элементу с самого начала без обязательной загрузки нового файла.
interface GalleryImage {
uid: string;
url: string;
mime_type: string;
/** Существующее изображение (сохраняем по ключу). */
bucket_key?: string;
/** Новый файл. */
base64?: string;
name?: string;
}
const picked = ref<File[] | null>(null);
const imageDrafts = ref<MarketplaceOfferImageDraft[]>([]);
const existingImages = ref<MarketplaceOfferImageView[]>([]);
const gallery = ref<GalleryImage[]>([]);
const coverIndex = ref(0);
const previewActive = ref(0);
// Ключи исходного набора (в исходном порядке) чтобы при сохранении понять,
// менялись ли изображения, и не гнать оффер на повторную модерацию зря.
const originalImageKeys = ref<string[]>([]);
let galleryUidSeq = 0;
const isTimeBased = computed(() => form.value.cycle_type === 'time_based');
const isVolumeBased = computed(() => form.value.cycle_type === 'volume_based');
@@ -536,11 +683,13 @@ function saveDraft(): void {
completedKeys: completedKeys.value,
coverIndex: coverIndex.value,
};
const images: OfferDraftImage[] = imageDrafts.value.map((d) => ({
base64: d.base64,
mime_type: d.mime_type,
name: d.name,
}));
const images: OfferDraftImage[] = gallery.value
.filter((g) => g.base64)
.map((g) => ({
base64: g.base64 as string,
mime_type: g.mime_type,
name: g.name ?? '',
}));
const totalChars = images.reduce((n, im) => n + im.base64.length, 0);
const payload: OfferDraft =
images.length && totalChars <= MAX_DRAFT_IMAGE_CHARS ? { ...base, images } : base;
@@ -571,11 +720,12 @@ function restoreDraft(): void {
if (Array.isArray(saved.completedKeys)) completedKeys.value = saved.completedKeys;
if (Array.isArray(saved.images) && saved.images.length) {
// object-URL после reload мёртв превью восстанавливаем как data-URL из base64.
imageDrafts.value = saved.images.map((im) => ({
preview_url: `data:${im.mime_type};base64,${im.base64}`,
name: im.name,
gallery.value = saved.images.map((im) => ({
uid: `g${++galleryUidSeq}`,
url: `data:${im.mime_type};base64,${im.base64}`,
base64: im.base64,
mime_type: im.mime_type,
name: im.name,
}));
}
if (typeof saved.coverIndex === 'number') coverIndex.value = saved.coverIndex;
@@ -589,23 +739,17 @@ function clearDraft(): void {
LocalStorage.remove(DRAFT_KEY);
}
// Черновики в порядке «обложка первой» для payload и предпросмотра.
function draftsCoverFirst(): MarketplaceOfferImageDraft[] {
const arr = imageDrafts.value;
// Галерея в порядке «обложка первой» для payload и предпросмотра.
function galleryCoverFirst(): GalleryImage[] {
const arr = gallery.value;
if (!arr.length) return [];
const ci = Math.min(Math.max(coverIndex.value, 0), arr.length - 1);
return [arr[ci], ...arr.filter((_, i) => i !== ci)];
}
const previewImages = computed<Array<{ url: string }>>(() => {
if (imageDrafts.value.length) {
return draftsCoverFirst().map((d) => ({ url: d.preview_url }));
}
if (isEdit.value && existingImages.value.length) {
return existingImages.value.map((img) => ({ url: img.url }));
}
return [];
});
const previewImages = computed<Array<{ url: string }>>(() =>
galleryCoverFirst().map((g) => ({ url: g.url }))
);
function onToggleUnlimited(value: boolean): void {
form.value.unlimited_flag = value;
@@ -641,11 +785,12 @@ function onSelectCycle(value: MarketplaceOfferCycleType): void {
async function onPickFiles(files: readonly File[] | null): Promise<void> {
const list = files ? [...files] : [];
// Берём только ещё не добавленные файлы (по имени) на случай повторного выбора.
// Добавляем (не заменяем) к уже имеющимся; дедуп новых по имени файла.
const fresh = list.filter(
(f) => !imageDrafts.value.some((d) => d.name === f.name && d.base64.length > 0)
(f) => !gallery.value.some((g) => g.base64 && g.name === f.name)
);
for (const file of fresh) {
if (imageDrafts.value.length >= MAX_IMAGES) {
if (gallery.value.length >= MAX_IMAGES) {
FailAlert(new Error(`Можно добавить не более ${MAX_IMAGES} изображений.`));
break;
}
@@ -658,11 +803,12 @@ async function onPickFiles(files: readonly File[] | null): Promise<void> {
continue;
}
const base64 = await fileToBase64(file);
imageDrafts.value.push({
preview_url: URL.createObjectURL(file),
name: file.name,
gallery.value.push({
uid: `g${++galleryUidSeq}`,
url: URL.createObjectURL(file),
base64,
mime_type: file.type,
name: file.name,
});
}
// q-file модель не храним управляем своим списком превью.
@@ -670,13 +816,15 @@ async function onPickFiles(files: readonly File[] | null): Promise<void> {
}
function removeImage(index: number): void {
const [removed] = imageDrafts.value.splice(index, 1);
if (removed) URL.revokeObjectURL(removed.preview_url);
const [removed] = gallery.value.splice(index, 1);
// object-url освобождаем только у новых файлов; подписанный url существующих
// освобождать не нужно.
if (removed?.base64 && removed.url.startsWith('blob:')) URL.revokeObjectURL(removed.url);
// Сдвигаем выбор обложки, чтобы он не «уехал» на чужой снимок.
if (index === coverIndex.value) coverIndex.value = 0;
else if (index < coverIndex.value) coverIndex.value -= 1;
if (coverIndex.value > imageDrafts.value.length - 1) {
coverIndex.value = Math.max(0, imageDrafts.value.length - 1);
if (coverIndex.value > gallery.value.length - 1) {
coverIndex.value = Math.max(0, gallery.value.length - 1);
}
}
@@ -742,13 +890,24 @@ function onCancel(): void {
}
// ===== Сабмит =====
// Менялись ли изображения относительно исходного набора (есть новые файлы,
// удалили существующее или сменили обложку/порядок).
function imagesChanged(): boolean {
if (gallery.value.some((g) => g.base64)) return true;
const cur = galleryCoverFirst().map((g) => g.bucket_key ?? '');
if (cur.length !== originalImageKeys.value.length) return true;
return cur.some((k, i) => k !== originalImageKeys.value[i]);
}
function buildImagesPayload(): MarketplaceOfferImageUpload[] | undefined {
const fresh = draftsCoverFirst().map((d) => ({ base64: d.base64, mime_type: d.mime_type }));
if (isEdit.value) {
// В режиме правки изображения трогаем только если выбраны новые файлы.
return fresh.length ? fresh : undefined;
}
return fresh;
// В режиме правки не трогаем изображения, если они не менялись иначе оффер
// зря уйдёт на повторную модерацию.
if (isEdit.value && !imagesChanged()) return undefined;
return galleryCoverFirst().map((g) =>
g.bucket_key
? { bucket_key: g.bucket_key }
: { base64: g.base64 as string, mime_type: g.mime_type }
);
}
async function onSubmit(): Promise<void> {
@@ -774,8 +933,13 @@ async function onSubmit(): Promise<void> {
submitting.value = true;
try {
if (isEdit.value && editId.value) {
const wasRejected = currentStatus.value === 'REJECTED';
await updateOffer({ id: editId.value, ...payload });
SuccessAlert('Изменения сохранены. Предложение отправлено на повторную модерацию.');
SuccessAlert(
wasRejected
? 'Исправления отправлены на повторную модерацию.'
: 'Изменения сохранены.'
);
void router.push({ name: 'marketplace-my-offers' });
} else {
await createOffer(payload);
@@ -806,7 +970,7 @@ async function prefillForEdit(id: string): Promise<void> {
product_name: offer.product_name,
description: offer.description ?? '',
category_id: offer.category_id != null ? Number(offer.category_id) : null,
price_per_unit: offer.price_per_unit,
price_per_unit: formatPriceForInput(offer.price_per_unit),
unit_of_measure: offer.unit_of_measure as MarketplaceUnitOfMeasure,
quantity_available: offer.quantity_available,
unlimited_flag: offer.unlimited_flag,
@@ -817,9 +981,20 @@ async function prefillForEdit(id: string): Promise<void> {
min_threshold: offer.min_threshold,
warranty_days: offer.warranty_days,
};
existingImages.value = (offer.images ?? [])
gallery.value = (offer.images ?? [])
.slice()
.sort((a, b) => a.sort_order - b.sort_order);
.sort((a, b) => a.sort_order - b.sort_order)
.map((img) => ({
uid: img.bucket_key,
url: img.url,
bucket_key: img.bucket_key,
mime_type: img.mime_type,
}));
originalImageKeys.value = gallery.value.map((g) => g.bucket_key as string);
coverIndex.value = 0;
currentStatus.value = offer.status;
currentCycleType.value = offer.cycle_type;
rejectReason.value = offer.reject_reason ?? null;
} catch (e) {
FailAlert(e, 'Не удалось загрузить предложение');
} finally {
@@ -838,7 +1013,7 @@ onMounted(async () => {
} else {
// Восстанавливаем черновик и подключаем автосохранение (клиент-only).
restoreDraft();
watch([form, activeKey, completedKeys, imageDrafts, coverIndex], scheduleSaveDraft, {
watch([form, activeKey, completedKeys, gallery, coverIndex], scheduleSaveDraft, {
deep: true,
});
}
@@ -846,7 +1021,7 @@ onMounted(async () => {
onBeforeUnmount(() => {
if (saveTimer) clearTimeout(saveTimer);
for (const d of imageDrafts.value) URL.revokeObjectURL(d.preview_url);
for (const g of gallery.value) if (g.url.startsWith('blob:')) URL.revokeObjectURL(g.url);
});
</script>
@@ -862,28 +1037,25 @@ onBeforeUnmount(() => {
gap: var(--p-4, 16px);
}
&__head {
// Скелетон формы на время дозагрузки оферты (режим редактирования).
&__skel {
display: flex;
flex-direction: column;
gap: var(--p-2, 8px);
gap: var(--p-4, 16px);
padding: var(--p-4, 16px) 0;
}
&__title {
margin: 0;
font-size: var(--p-fs-h2, 24px);
font-weight: 600;
color: var(--p-ink);
}
&__skel-title { width: 35%; }
&__intro {
margin: 0;
font-size: var(--p-fs-body-sm, 13px);
line-height: var(--p-lh-body, 1.55);
color: var(--p-ink-2);
}
&__skel-line { width: 100%; }
&__skel-line--wide { width: 80%; }
&__skel-line--narrow { width: 55%; }
&__notice {
background: var(--p-warn-soft, #fff8e1);
&__manage {
display: flex;
align-items: center;
gap: var(--p-3, 12px);
flex-wrap: wrap;
}
&__step {
@@ -1024,11 +1196,24 @@ onBeforeUnmount(() => {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
}
// Навигация формы прибита к низу экрана «Отменить/Назад» и «Далее/
// Сохранить» всегда на виду, без прокрутки до конца. Фон перекрывает контент,
// уезжающий под бар при скролле.
//
// margin-bottom компенсирует нижний padding q-page (--p-6): без этого в конце
// прокрутки бар «приземляется» на величину этого отступа выше, чем стоял
// прижатым, и кажется, что он меняет высоту/дёргается. С компенсацией позиция
// «прижат» совпадает с «в покое» бар не двигается.
&__foot {
position: sticky;
bottom: 0;
z-index: 5;
display: flex;
align-items: center;
gap: var(--p-2, 8px);
padding-top: var(--p-4, 16px);
margin-bottom: calc(-1 * var(--p-6, 24px));
padding: var(--p-3, 12px) 0;
background: var(--p-canvas);
border-top: 1px solid var(--p-line, #e0e0e0);
}
}
@@ -40,28 +40,14 @@ export async function fetchMyOffers(
}
/**
* Эпик 4 / Story 4.2: поставщик вручную запускает поставку по своему
* предложению с открытой подпиской (cycle_type=open_subscription).
*
* Backend Resolver: marketplace-cycle.resolver.ts marketplaceTriggerOpenSubscription
* (guard 'Offer' 'update:own'). Нажатие = акцепт всего накопленного пула:
* сервер формирует сводную заявку status=ACCEPTED и принимает заказы разом.
* Ошибки backend (пустой пул, не open_subscription, не ACTIVE, чужой Offer)
* приходят как GraphQL-исключения пробрасываем их вызывающему компоненту.
* Поставщик возвращает снятое предложение на публикацию (WITHDRAWN
* PENDING_MODERATION). Backend: marketplace-offer.resolver.ts
* marketplaceRepublishOffer (guard 'Offer' 'update:own'). Снятие не удаляет
* данные оферты, поэтому пересоздавать ничего не нужно она снова уходит на
* модерацию с прежним содержимым.
*/
export async function triggerOpenSubscription(offer_id: string): Promise<void> {
await client.Mutation(Mutations.Marketplace.TriggerOpenSubscription.mutation, {
variables: { input: { offer_id } },
});
}
/**
* Поставщик снимает своё предложение с публикации (статус WITHDRAWN).
* Backend: marketplace-offer.resolver.ts marketplaceWithdrawOffer
* (guard 'Offer' 'delete:own', ownership проверяется в сервисе).
*/
export async function withdrawOffer(id: string): Promise<void> {
await client.Mutation(Mutations.Marketplace.WithdrawOffer.mutation, {
export async function republishOffer(id: string): Promise<void> {
await client.Mutation(Mutations.Marketplace.RepublishOffer.mutation, {
variables: { input: { id } },
});
}
@@ -0,0 +1,38 @@
<template lang="pug">
BaseButton(
variant="primary",
size="sm",
:icon-only="isCompact",
aria-label="Создать предложение",
@click="goCreate"
)
template(#icon-left)
q-icon(name="add", size="18px")
| Создать предложение
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useQuasar } from 'quasar';
import { useRouter } from 'vue-router';
import { useSystemStore } from 'src/entities/System/model';
import { BaseButton } from 'src/shared/ui/base';
/**
* Кнопка «Создать предложение» для правого верхнего угла шапки (телепорт через
* useHeaderActions со стола поставщика). На десктопе полная кнопка с
* подписью, на телефоне (xs) сворачивается до иконки `add`.
*/
const $q = useQuasar();
const router = useRouter();
const { info } = useSystemStore();
const isCompact = computed(() => $q.screen.lt.sm);
function goCreate(): void {
void router.push({
name: 'marketplace-create-offer',
params: { coopname: info.coopname },
});
}
</script>
@@ -1,78 +1,113 @@
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { Dialog, Notify } from 'quasar';
import { useRouter } from 'vue-router';
import { Queries } from '@coopenomics/sdk';
import { client } from 'src/shared/api/client';
import { LocalStorage, Notify } from 'quasar';
import { useRoute, useRouter } from 'vue-router';
import { useSystemStore } from 'src/entities/System/model';
import { useHeaderActions } from 'src/shared/hooks';
import { marketplaceUnitShort } from 'src/shared/lib/consts';
import { marketplaceOfferImageUrls } from 'src/shared/lib/utils';
import { BaseButton, BaseCard, BaseInput, EmptyState } from 'src/shared/ui/base';
import CreateOfferHeaderButton from './CreateOfferHeaderButton.vue';
import {
CatalogOfferCard,
type CatalogOffer,
type CatalogOfferStatus,
} from 'src/widgets/Marketplace/CatalogOfferCard';
import {
OfferDetailsDialog,
type OfferDetail,
} from 'src/widgets/Marketplace/OfferDetailsDialog';
import { fetchMyOffers, triggerOpenSubscription, withdrawOffer } from '../api';
import type {
MarketplaceOfferCycleTypeView,
MarketplaceOfferStatusView,
MarketplaceOfferView,
} from '../types';
import { fetchMyOffers, republishOffer } from '../api';
import type { MarketplaceOfferStatusView, MarketplaceOfferView } from '../types';
/**
* Эпик 3 / Story 3.4: offerer-стол «Мои предложения».
*
* Поставщик видит свои Offer'ы во всех 4 статусах: PENDING_MODERATION,
* ACTIVE, REJECTED, WITHDRAWN. Канон `CatalogOfferCard` с цветными chip'ами
* для статуса. Client-side фильтр + поиск по названию (backend
* ACTIVE, REJECTED, WITHDRAWN. Канон `CatalogOfferCard` со статус-чипом.
* Client-side фильтр по статусу + поиск по названию (backend
* `marketplaceListMyOffers` принимает только пагинацию).
*
* Polling 30s Offer'ы меняют статус через модерацию председателя
* (`marketplaceApproveOffer` / `marketplaceRejectOffer`).
* Клик по карточке/изображению редактируемой оферты ведёт сразу на страницу
* редактирования (`marketplace-edit-offer`) там же статус, кнопки «Снять с
* публикации» и «Запустить поставку». Отдельного диалога-просмотра больше нет.
*
* Вёрстка по канону MONO Platform v2: инфо-баннер (`.banner`), дашборд-метрики
* (BaseCard), полноширинный поиск и канон-меню фильтра статусов (`.tabbar`).
* Фильтр deep-linkable через query `?status=` на «На модерации» и т.п. можно
* перейти прямой ссылкой.
*
* Polling 30s Offer'ы меняют статус через модерацию председателя.
* Подписанные URL картинок стабилизированы на бэкенде (окно getReadUrl),
* поэтому при перезагрузке списка изображения не перекачиваются.
*/
const PAGE_SIZE = 50;
const POLL_INTERVAL_MS = 30_000;
// Подсказку-баннер можно скрыть навсегда (запоминаем в LocalStorage).
const BANNER_LS_KEY = 'mp:my-offers:banner-dismissed';
// Кол-во скелетон-карточек на время первичной загрузки.
const SKELETON_COUNT = 8;
// Открыть форму редактирования можно для активной, ожидающей модерации и
// отклонённой оферты. Отклонённую правят, чтобы устранить причину и
// переотправить на модерацию. Снятую (WITHDRAWN) backend на edit не пускает
// её сначала возвращают на публикацию кнопкой «Опубликовать снова».
const EDITABLE_STATUSES: ReadonlyArray<MarketplaceOfferStatusView> = [
'PENDING_MODERATION',
'ACTIVE',
'REJECTED',
];
const router = useRouter();
const route = useRoute();
const { info } = useSystemStore();
const { registerAction } = useHeaderActions();
const items = ref<MarketplaceOfferView[]>([]);
const totalCount = ref(0);
const totalPages = ref(0);
const currentPage = ref(1);
const loading = ref(false);
const statusFilter = ref<MarketplaceOfferStatusView | null>(null);
const search = ref('');
// id предложений, по которым прямо сейчас идёт запуск поставки блокируем
// повторное нажатие кнопки до ответа backend.
const triggeringIds = ref<Set<string>>(new Set());
const withdrawingIds = ref<Set<string>>(new Set());
// Читаем синхронно: если подсказку уже скрыли она не появляется вообще,
// без мигания на onMounted.
const bannerDismissed = ref(LocalStorage.getItem(BANNER_LS_KEY) === true);
let pollTimer: ReturnType<typeof setInterval> | null = null;
// Справочник категорий (id название) для детального просмотра.
const categoryNames = ref<Record<number, string>>({});
// Скелетон показываем только на первичной загрузке (список ещё пуст). При
// polling'е данные обновляются молча без дёргания спиннером.
const showSkeleton = computed(() => loading.value && items.value.length === 0);
// Детальный просмотр предложения клик по карточке открывает модалку
// с полным описанием и действиями (редактировать / снять / запустить).
const detailsOpen = ref(false);
const selectedId = ref<string | null>(null);
const selected = computed<MarketplaceOfferView | null>(
() => items.value.find((o) => o.id === selectedId.value) ?? null,
);
function dismissBanner(): void {
bannerDismissed.value = true;
LocalStorage.set(BANNER_LS_KEY, true);
}
const STATUS_FILTER_OPTIONS: Array<{ label: string; value: MarketplaceOfferStatusView | null }> = [
{ label: 'Все', value: null },
{ label: 'На модерации', value: 'PENDING_MODERATION' },
{ label: 'Опубликованы', value: 'ACTIVE' },
{ label: 'Отклонены', value: 'REJECTED' },
{ label: 'Сняты', value: 'WITHDRAWN' },
// Фильтр статусов = канон-меню `.tabbar`. `slug` стабильный ключ в URL
// (`?status=moderation`), чтобы на любой фильтр можно было перейти ссылкой.
const STATUS_FILTER_OPTIONS: Array<{
label: string;
value: MarketplaceOfferStatusView | null;
slug: string;
}> = [
{ label: 'Все', value: null, slug: 'all' },
{ label: 'На модерации', value: 'PENDING_MODERATION', slug: 'moderation' },
{ label: 'Опубликовано', value: 'ACTIVE', slug: 'published' },
{ label: 'Отклонено', value: 'REJECTED', slug: 'rejected' },
{ label: 'Сняты', value: 'WITHDRAWN', slug: 'withdrawn' },
];
function isActiveTab(value: MarketplaceOfferStatusView | null): boolean {
return statusFilter.value === value;
}
function setFilter(option: (typeof STATUS_FILTER_OPTIONS)[number]): void {
if (statusFilter.value === option.value) return;
statusFilter.value = option.value;
const query = { ...route.query };
if (option.value) query.status = option.slug;
else delete query.status;
void router.replace({ query });
}
const STATUS_TO_CARD: Record<MarketplaceOfferStatusView, CatalogOfferStatus> = {
PENDING_MODERATION: 'moderation',
ACTIVE: 'published',
@@ -92,7 +127,12 @@ const filtered = computed(() => {
return list;
});
const cards = computed<Array<CatalogOffer & { domainStatus: MarketplaceOfferStatusView; cycleType: MarketplaceOfferCycleTypeView; rejectReason: string | null }>>(() =>
type OfferCard = CatalogOffer & {
domainStatus: MarketplaceOfferStatusView;
rejectReason: string | null;
};
const cards = computed<OfferCard[]>(() =>
filtered.value.map((o) => ({
id: o.id,
title: o.product_name,
@@ -103,124 +143,56 @@ const cards = computed<Array<CatalogOffer & { domainStatus: MarketplaceOfferStat
unitLabel: marketplaceUnitShort(o.unit_of_measure),
status: STATUS_TO_CARD[o.status],
domainStatus: o.status,
cycleType: o.cycle_type,
rejectReason: o.reject_reason ?? null,
})),
);
const selectedDetail = computed<OfferDetail | null>(() => {
const o = selected.value;
if (!o) return null;
const catId = o.category_id != null ? Number(o.category_id) : null;
return {
id: o.id,
title: o.product_name,
description: o.description ?? null,
status: STATUS_TO_CARD[o.status],
unitCost: parseFloat(o.price_per_unit) || 0,
unitLabel: marketplaceUnitShort(o.unit_of_measure),
remainUnits: o.unlimited_flag ? undefined : o.quantity_available - o.quantity_blocked,
unlimited: o.unlimited_flag,
categoryName: catId != null ? categoryNames.value[catId] ?? null : null,
cycleType: o.cycle_type,
warrantyDays: o.warranty_days,
rejectReason: o.reject_reason,
};
});
// Поставщик может редактировать предложение во всех статусах кроме снятого.
// Любая правка на бэкенде сбрасывает статус в PENDING_MODERATION (повторная
// модерация в рамках того же предложения) поэтому редактирование доступно
// и на модерации, и у опубликованного, и у отклонённого.
const canEdit = computed(
() => !!selected.value && selected.value.status !== 'WITHDRAWN',
);
const canWithdraw = computed(
() =>
!!selected.value &&
(selected.value.status === 'PENDING_MODERATION' || selected.value.status === 'ACTIVE'),
);
const canTrigger = computed(
() =>
!!selected.value &&
selected.value.status === 'ACTIVE' &&
selected.value.cycle_type === 'open_subscription',
);
function openDetails(id: string | number | undefined): void {
if (id == null) return;
selectedId.value = String(id);
detailsOpen.value = true;
}
function goEdit(): void {
const o = selected.value;
if (!o) return;
detailsOpen.value = false;
void router.push({
name: 'marketplace-edit-offer',
params: { coopname: info.coopname, offerId: o.id },
});
}
function onWithdraw(): void {
const o = selected.value;
if (!o) return;
Dialog.create({
title: 'Снять предложение с публикации?',
message:
`Предложение «${o.product_name}» перестанет показываться в каталоге и не будет ` +
'принимать новые заказы. Это действие можно отменить, только создав предложение заново.',
cancel: { label: 'Отмена', flat: true, noCaps: true },
ok: { label: 'Снять с публикации', color: 'negative', unelevated: true, noCaps: true },
persistent: true,
}).onOk(async () => {
withdrawingIds.value = new Set(withdrawingIds.value).add(o.id);
try {
await withdrawOffer(o.id);
detailsOpen.value = false;
Notify.create({ type: 'positive', message: 'Предложение снято с публикации.' });
await load(1, false);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message });
} finally {
const next = new Set(withdrawingIds.value);
next.delete(o.id);
withdrawingIds.value = next;
}
});
}
async function loadCategories(): Promise<void> {
try {
const { [Queries.Marketplace.ListCategories.name]: cats } = await client.Query(
Queries.Marketplace.ListCategories.query,
);
const map: Record<number, string> = {};
for (const c of cats ?? []) map[Number(c.id)] = c.display_name;
categoryNames.value = map;
} catch {
// Справочник категорий не критичен просто не покажем название.
}
}
const counts = computed(() => {
const counters = computed(() => {
const total = items.value.length;
const active = items.value.filter((o) => o.status === 'ACTIVE').length;
const pending = items.value.filter((o) => o.status === 'PENDING_MODERATION').length;
const rejected = items.value.filter((o) => o.status === 'REJECTED').length;
return { total, active, pending, rejected };
return [
{ label: 'Всего', value: total, cls: '' },
{ label: 'Опубликовано', value: active, cls: 'text-positive' },
{ label: 'На модерации', value: pending, cls: 'text-warning' },
{ label: 'Отклонено', value: rejected, cls: 'text-negative' },
];
});
const hasMore = computed(() => currentPage.value < totalPages.value);
function goCard(card: OfferCard): void {
if (!EDITABLE_STATUSES.includes(card.domainStatus)) return;
void router.push({
name: 'marketplace-edit-offer',
params: { coopname: info.coopname, offerId: String(card.id) },
});
}
const republishing = ref<string | null>(null);
// Снятое предложение возвращается на публикацию без пересоздания backend
// просто меняет статус на PENDING_MODERATION, данные оферты сохранены.
async function onRepublish(card: OfferCard): Promise<void> {
republishing.value = String(card.id);
try {
await republishOffer(String(card.id));
Notify.create({ type: 'positive', message: 'Предложение возвращено на модерацию.' });
await load(1, false);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message });
} finally {
republishing.value = null;
}
}
async function load(page: number, append: boolean): Promise<void> {
loading.value = true;
try {
const result = await fetchMyOffers({ page, limit: PAGE_SIZE });
items.value = append ? [...items.value, ...result.items] : result.items;
totalCount.value = result.totalCount;
totalPages.value = result.totalPages;
currentPage.value = result.currentPage;
} catch (e) {
@@ -237,48 +209,21 @@ function loadMore(): void {
}
}
/**
* Эпик 4 / Story 4.2: ручной запуск поставки по предложению с открытой
* подпиской. Доступен только для ACTIVE-предложений с cycle_type=open_subscription.
* Нажатие = акцепт всего накопленного пула заказов, действие необратимо
* поэтому подтверждаем через диалог. После успеха перезагружаем список
* (остатки и счётчики меняются по итогам формирования партии).
*/
function triggerSupply(offerId: string, title: string): void {
Dialog.create({
title: 'Запустить поставку?',
message:
`Все накопленные заказы по предложению «${title}» будут разом зафиксированы в одну ` +
'партию и приняты к поставке. Отменить запуск нельзя.',
cancel: { label: 'Отмена', flat: true, noCaps: true },
ok: { label: 'Запустить поставку', color: 'primary', unelevated: true, noCaps: true },
persistent: true,
}).onOk(() => {
void runTrigger(offerId);
});
}
async function runTrigger(offerId: string): Promise<void> {
triggeringIds.value = new Set(triggeringIds.value).add(offerId);
try {
await triggerOpenSubscription(offerId);
Notify.create({
type: 'positive',
message: 'Поставка запущена: заказы зафиксированы в партию и приняты.',
});
await load(1, false);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message });
} finally {
const next = new Set(triggeringIds.value);
next.delete(offerId);
triggeringIds.value = next;
}
}
onMounted(async () => {
await Promise.all([load(1, false), loadCategories()]);
// Канон: «Создать предложение» живёт в правом верхнем углу шапки (телепорт),
// а не в меню. useHeaderActions сам снимет кнопку при уходе со страницы.
registerAction({
id: 'marketplace-create-offer',
component: CreateOfferHeaderButton,
order: 10,
});
// Восстанавливаем фильтр из URL поддержка прямых ссылок на статус.
const slug = typeof route.query.status === 'string' ? route.query.status : null;
const fromUrl = STATUS_FILTER_OPTIONS.find((o) => o.slug === slug);
if (fromUrl) statusFilter.value = fromUrl.value;
await load(1, false);
pollTimer = setInterval(() => {
void load(1, false);
}, POLL_INTERVAL_MS);
@@ -293,187 +238,169 @@ onUnmounted(() => {
</script>
<template lang="pug">
q-page.mp-role-offerer.mp-my-offers(role="region", aria-label="Мои предложения")
div.mp-my-offers__header
div
div.text-h5 Мои предложения
div.text-caption.mp-my-offers__subtitle
| Все ваши предложения в кооперативе: на модерации, опубликованные, отклонённые и снятые. Чтобы опубликовать новое перейдите на «Создать предложение».
q-space
q-btn(flat, dense, round, icon="fa-solid fa-rotate", :loading="loading", @click="load(1, false)", aria-label="Обновить")
div.row.q-col-gutter-md.mp-my-offers__counters
div.col-6.col-md-3
q-card(flat, bordered)
q-card-section
div.text-caption Всего
div.text-h6 {{ counts.total }}
div.col-6.col-md-3
q-card(flat, bordered)
q-card-section
div.text-caption Активных
div.text-h6.text-positive {{ counts.active }}
div.col-6.col-md-3
q-card(flat, bordered)
q-card-section
div.text-caption На модерации
div.text-h6.text-warning {{ counts.pending }}
div.col-6.col-md-3
q-card(flat, bordered)
q-card-section
div.text-caption Отклонены
div.text-h6.text-negative {{ counts.rejected }}
div.row.q-col-gutter-md.items-center
div.col-12.col-md-6
q-input(
v-model="search",
outlined,
dense,
clearable,
placeholder="Поиск по названию",
debounce="200"
)
template(#prepend)
q-icon(name="fa-solid fa-magnifying-glass")
div.col-12.col-md-6
q-btn-toggle(
v-model="statusFilter",
:options="STATUS_FILTER_OPTIONS",
no-caps,
spread,
unelevated,
toggle-color="primary"
q-page.my-offers(role="region", aria-label="Мои предложения")
.my-offers__col
.banner.banner--info(v-if="!bannerDismissed")
q-icon.banner__icon(name="info", size="18px")
.banner__body
| Все ваши предложения в кооперативе и их статус. Нажмите на карточку,
| чтобы открыть предложение изменить цену и остаток, отредактировать
| описание или снять с публикации. Цена и количество меняются без
| повторной модерации.
BaseButton.my-offers__banner-close(
variant="ghost",
icon-only,
size="sm",
aria-label="Скрыть подсказку",
@click="dismissBanner"
)
template(#icon-left)
q-icon(name="close", size="16px")
q-inner-loading(:showing="loading && items.length === 0")
q-spinner(color="primary", size="2em")
.row.q-col-gutter-md
.col-6.col-md-3(v-for="kpi in counters", :key="kpi.label")
BaseCard
.text-caption.text-grey-7 {{ kpi.label }}
.text-h5(:class="kpi.cls") {{ kpi.value }}
div.mp-my-offers__empty(v-if="!loading && filtered.length === 0")
q-icon(name="fa-solid fa-clipboard-list", size="48px", color="grey-5")
div.text-subtitle1.q-mt-md Нет предложений в этом фильтре
div.text-caption Если у вас нет ни одного предложения создайте первое на странице «Создать предложение».
BaseInput(v-model="search", placeholder="Поиск по названию", clearable)
template(#prepend)
q-icon(name="search")
div.mp-my-offers__hint(v-if="filtered.length > 0")
q-icon(name="fa-regular fa-hand-pointer", size="14px")
| Нажмите на карточку, чтобы открыть, отредактировать или снять предложение.
nav.tabbar.my-offers__tabs
.tabbar__tabs
button.tab(
v-for="opt in STATUS_FILTER_OPTIONS",
:key="opt.slug",
type="button",
:class="{ 'tab--active': isActiveTab(opt.value) }",
@click="setFilter(opt)"
)
span {{ opt.label }}
.tabbar__actions
BaseButton(
variant="ghost",
icon-only,
aria-label="Обновить",
:loading="loading",
@click="load(1, false)"
)
template(#icon-left)
q-icon(name="refresh", size="20px")
div.mp-my-offers__grid(v-if="filtered.length > 0")
div.mp-my-offers__cell(
v-for="card in cards",
:key="card.id"
//- Скелетон вместо спиннера: каркас карточек проявляется сразу, без
//- дёргания. Только на первичной загрузке polling обновляет молча.
.row.q-col-gutter-md(v-if="showSkeleton")
.col-12.col-sm-6.col-md-4.col-lg-3(v-for="n in SKELETON_COUNT", :key="`skel-${n}`")
.my-offers__skel
.skel.my-offers__skel-media
.skel.skel--title.my-offers__skel-line.my-offers__skel-line--title
.skel.skel--num.my-offers__skel-line.my-offers__skel-line--price
.skel.skel--text.my-offers__skel-line.my-offers__skel-line--cat
EmptyState(
v-if="!loading && filtered.length === 0",
title="Нет предложений в этом фильтре",
body="Если у вас нет ни одного предложения — создайте первое на странице «Создать предложение»."
)
CatalogOfferCard(:offer="card", @click="openDetails(card.id)")
div.mp-my-offers__reason(v-if="card.domainStatus === 'REJECTED' && card.rejectReason")
q-icon(name="fa-solid fa-circle-exclamation", color="negative", size="14px")
| {{ card.rejectReason }}
OfferDetailsDialog(v-model="detailsOpen", :offer="selectedDetail")
template(v-if="selected", v-slot:actions)
q-btn(
v-if="canWithdraw",
flat,
no-caps,
color="negative",
icon="fa-solid fa-eye-slash",
label="Снять",
:loading="withdrawingIds.has(selected.id)",
@click="onWithdraw"
)
q-btn(
v-if="canEdit",
flat,
no-caps,
color="primary",
icon="fa-solid fa-pen",
label="Редактировать",
@click="goEdit"
)
q-btn(
v-if="canTrigger",
unelevated,
no-caps,
color="primary",
icon="fa-solid fa-truck-fast",
label="Запустить поставку",
:loading="triggeringIds.has(selected.id)",
@click="triggerSupply(selected.id, selected.product_name)"
)
template(v-if="filtered.length > 0")
.row.q-col-gutter-md
.col-12.col-sm-6.col-md-4.col-lg-3(v-for="card in cards", :key="card.id")
CatalogOfferCard(:offer="card", @click="goCard(card)")
.my-offers__reason(v-if="card.domainStatus === 'REJECTED' && card.rejectReason")
q-icon(name="error", color="negative", size="16px")
span {{ card.rejectReason }}
.my-offers__action(v-if="card.domainStatus === 'WITHDRAWN'")
BaseButton(
variant="secondary",
size="sm",
block,
:loading="republishing === String(card.id)",
@click="onRepublish(card)"
)
template(#icon-left)
q-icon(name="publish", size="16px")
| Опубликовать снова
div.mp-my-offers__more(v-if="hasMore")
q-btn(
flat,
no-caps,
:loading="loading",
label="Показать ещё",
@click="loadMore"
)
.my-offers__more(v-if="hasMore")
BaseButton(variant="ghost", :loading="loading", @click="loadMore") Показать ещё
</template>
<style scoped lang="scss">
.mp-my-offers {
padding: var(--mp-space-lg);
display: flex;
flex-direction: column;
gap: var(--mp-space-md);
.my-offers {
padding: var(--p-6, 24px) var(--p-4, 16px);
&__header {
display: flex;
align-items: flex-start;
gap: var(--mp-space-md);
}
&__subtitle {
color: var(--mp-on-surface-muted);
max-width: 720px;
}
&__counters {
margin-bottom: var(--mp-space-xs);
}
&__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--mp-space-md);
}
&__cell {
&__col {
max-width: 1120px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--mp-space-xs);
gap: var(--p-4, 16px);
}
// Канон-`.tabbar` рассчитан на полноширинную под-навигацию с большим
// боковым padding'ом; внутри центрированной колонки выравниваем табы по её
// краю, сохраняя нижнюю границу и активный underline.
&__tabs {
:deep(.tabbar__tabs) {
padding: 0;
}
:deep(.tabbar__actions) {
padding-right: 0;
}
}
// Крестик скрытия подсказки прижат к правому краю баннера.
&__banner-close {
flex-shrink: 0;
align-self: flex-start;
margin: -4px -4px 0 0;
}
// Скелетон-карточка повторяет форму CatalogOfferCard: медиа сверху,
// под ней название, цена и категория.
&__skel {
border: 1px solid var(--p-line);
border-radius: var(--p-r-md, 12px);
overflow: hidden;
padding-bottom: var(--p-3, 12px);
}
&__skel-media {
width: 100%;
aspect-ratio: 4 / 3;
border-radius: 0;
}
&__skel-line {
margin-top: var(--p-3, 12px);
margin-left: var(--p-3, 12px);
margin-right: var(--p-3, 12px);
}
&__skel-line--title { width: 70%; }
&__skel-line--price { width: 40%; }
&__skel-line--cat { width: 85%; }
&__reason {
display: flex;
gap: var(--mp-space-xs);
align-items: center;
color: var(--mp-on-surface-muted);
font-size: 12px;
padding: 0 var(--mp-space-xs);
gap: 6px;
margin-top: var(--p-2, 8px);
font-size: var(--p-fs-meta, 12px);
line-height: 1.4;
color: var(--p-ink-2);
}
&__hint {
display: flex;
align-items: center;
gap: var(--mp-space-xs);
color: var(--mp-on-surface-muted);
font-size: 12px;
}
&__empty {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--mp-space-xl) 0;
color: var(--mp-on-surface-muted);
&__action {
margin-top: var(--p-2, 8px);
}
&__more {
display: flex;
justify-content: center;
padding: var(--mp-space-md) 0;
padding: var(--p-4, 16px) 0;
}
}
</style>
@@ -22,11 +22,15 @@
:name="i"
class="mp-catalog-offer-card__slide"
>
<q-img :src="img" :alt="offer.title" fit="cover" />
<!-- no-spinner: подписанный URL стабилизирован на бэкенде (окно
getReadUrl), src не меняется при polling лоудер только мигал бы.
Клик по изображению = клик по карточке (стрелки карусели гасит
@click.stop на самой ленте). -->
<q-img :src="img" :alt="offer.title" fit="cover" no-spinner @click="onClick" />
</q-carousel-slide>
</q-carousel>
<div v-else class="mp-catalog-offer-card__placeholder">
<q-icon name="fa-solid fa-image" size="48px" color="grey-5" />
<q-icon name="image" size="48px" color="grey-5" />
</div>
<span
v-if="status"
@@ -70,3 +70,5 @@ export * as RejectOffer from './rejectOffer'
export * as UpdateOffer from './updateOffer'
/** Поставщик отзывает свой offer (любой статус, кроме APPROVED/REJECTED) */
export * as WithdrawOffer from './withdrawOffer'
/** Поставщик возвращает снятый offer на публикацию (WITHDRAWN → PENDING_MODERATION) */
export * as RepublishOffer from './republishOffer'
@@ -0,0 +1,19 @@
import { marketplaceOfferSelector } from '../../selectors/marketplace/offerSelector'
import { $, type GraphQLTypes, type InputType, type ModelTypes, Selector } from '../../zeus/index'
export const name = 'marketplaceRepublishOffer'
export const mutation = Selector('Mutation')({
[name]: [{ input: $('input', 'MarketplaceRepublishOfferInput!') }, marketplaceOfferSelector],
})
export interface IInput {
/**
* @private
*/
[key: string]: unknown
input: ModelTypes['MarketplaceRepublishOfferInput']
}
export type IOutput = InputType<GraphQLTypes['Mutation'], typeof mutation>
@@ -33,6 +33,7 @@ const rawOfferSelector = {
updated_at: true,
images: {
url: true,
bucket_key: true,
mime_type: true,
sort_order: true,
is_cover: true,
+8
View File
@@ -802,6 +802,9 @@ export const AllTypesProps: Record<string,any> = {
},
MarketplaceRemoveFromWhitelistInput:{
},
MarketplaceRepublishOfferInput:{
},
MarketplaceReturnClaimDefectCategory: "enum" as const,
MarketplaceReturnClaimExpectedResolution: "enum" as const,
@@ -1411,6 +1414,9 @@ export const AllTypesProps: Record<string,any> = {
marketplaceReplaceAvailableItems:{
input:"ReplaceAvailableItemsInput"
},
marketplaceRepublishOffer:{
input:"MarketplaceRepublishOfferInput"
},
marketplaceRetryKUGeocode:{
},
@@ -4154,6 +4160,7 @@ export const ReturnTypes: Record<string,any> = {
warranty_days:"Int"
},
MarketplaceOfferImage:{
bucket_key:"String",
is_cover:"Boolean",
mime_type:"String",
sort_order:"Int",
@@ -4761,6 +4768,7 @@ export const ReturnTypes: Record<string,any> = {
marketplaceRemoveAvailableCategoryTypes:"Boolean",
marketplaceRemoveFromWhitelist:"Boolean",
marketplaceReplaceAvailableItems:"MarketplaceAvailableCategory",
marketplaceRepublishOffer:"MarketplaceOffer",
marketplaceRetryKUGeocode:"MarketplaceKUDetails",
marketplaceSetKUStatus:"MarketplaceKUDetails",
marketplaceSignAplReceptionAsChairman:"MarketplaceAplReceptionResult",
+52 -17
View File
@@ -7005,6 +7005,8 @@ export type ValueTypes = {
['...on MarketplaceOffer']?: Omit<ValueTypes["MarketplaceOffer"], "...on MarketplaceOffer">
}>;
["MarketplaceOfferImage"]: AliasType<{
/** Ключ объекта в хранилище — стабильный идентификатор изображения. Передаётся обратно при редактировании, чтобы сохранить уже загруженное изображение (в отличие от base64 для новых файлов). */
bucket_key?:boolean | `@${string}`,
/** Является ли изображение обложкой карточки. */
is_cover?:boolean | `@${string}`,
/** MIME-тип изображения. */
@@ -7017,10 +7019,12 @@ export type ValueTypes = {
['...on MarketplaceOfferImage']?: Omit<ValueTypes["MarketplaceOfferImage"], "...on MarketplaceOfferImage">
}>;
["MarketplaceOfferImageUploadInput"]: {
/** Содержимое изображения, закодированное в base64. */
base64: string | Variable<any, string>,
/** MIME-тип изображения (image/jpeg, image/png либо image/webp). */
mime_type: string | Variable<any, string>
/** Содержимое нового изображения в base64. */
base64?: string | undefined | null | Variable<any, string>,
/** Ключ уже сохранённого изображения — сохранить его в наборе. */
bucket_key?: string | undefined | null | Variable<any, string>,
/** MIME-тип нового изображения (image/jpeg, image/png либо image/webp). */
mime_type?: string | undefined | null | Variable<any, string>
};
["MarketplaceOfferPaginationResult"]: AliasType<{
/** Текущая страница */
@@ -7255,6 +7259,9 @@ export type ValueTypes = {
["MarketplaceRemoveFromWhitelistInput"]: {
/** eosio::name пайщика-поставщика */
member_account: string | Variable<any, string>
};
["MarketplaceRepublishOfferInput"]: {
id: string | Variable<any, string>
};
["MarketplaceRequest"]: AliasType<{
/** Артикул товара */
@@ -8245,6 +8252,7 @@ marketplaceRemoveAvailableCategories?: [{ input: ValueTypes["RemoveAvailableCate
marketplaceRemoveAvailableCategoryTypes?: [{ input: ValueTypes["RemoveAvailableCategoryTypesInput"] | Variable<any, string>},boolean | `@${string}`],
marketplaceRemoveFromWhitelist?: [{ input: ValueTypes["MarketplaceRemoveFromWhitelistInput"] | Variable<any, string>},boolean | `@${string}`],
marketplaceReplaceAvailableItems?: [{ input: ValueTypes["ReplaceAvailableItemsInput"] | Variable<any, string>},ValueTypes["MarketplaceAvailableCategory"]],
marketplaceRepublishOffer?: [{ input: ValueTypes["MarketplaceRepublishOfferInput"] | Variable<any, string>},ValueTypes["MarketplaceOffer"]],
marketplaceRetryKUGeocode?: [{ coopname: string | Variable<any, string>, coreBraname: string | Variable<any, string>},ValueTypes["MarketplaceKUDetails"]],
marketplaceSetKUStatus?: [{ data: ValueTypes["MarketplaceSetKUStatusInput"] | Variable<any, string>},ValueTypes["MarketplaceKUDetails"]],
marketplaceSignAplReceptionAsChairman?: [{ data: ValueTypes["MarketplaceSignAplReceptionInput"] | Variable<any, string>},ValueTypes["MarketplaceAplReceptionResult"]],
@@ -17030,6 +17038,8 @@ export type ResolverInputTypes = {
__typename?: boolean | `@${string}`
}>;
["MarketplaceOfferImage"]: AliasType<{
/** Ключ объекта в хранилище — стабильный идентификатор изображения. Передаётся обратно при редактировании, чтобы сохранить уже загруженное изображение (в отличие от base64 для новых файлов). */
bucket_key?:boolean | `@${string}`,
/** Является ли изображение обложкой карточки. */
is_cover?:boolean | `@${string}`,
/** MIME-тип изображения. */
@@ -17041,10 +17051,12 @@ export type ResolverInputTypes = {
__typename?: boolean | `@${string}`
}>;
["MarketplaceOfferImageUploadInput"]: {
/** Содержимое изображения, закодированное в base64. */
base64: string,
/** MIME-тип изображения (image/jpeg, image/png либо image/webp). */
mime_type: string
/** Содержимое нового изображения в base64. */
base64?: string | undefined | null,
/** Ключ уже сохранённого изображения — сохранить его в наборе. */
bucket_key?: string | undefined | null,
/** MIME-тип нового изображения (image/jpeg, image/png либо image/webp). */
mime_type?: string | undefined | null
};
["MarketplaceOfferPaginationResult"]: AliasType<{
/** Текущая страница */
@@ -17270,6 +17282,9 @@ export type ResolverInputTypes = {
["MarketplaceRemoveFromWhitelistInput"]: {
/** eosio::name пайщика-поставщика */
member_account: string
};
["MarketplaceRepublishOfferInput"]: {
id: string
};
["MarketplaceRequest"]: AliasType<{
/** Артикул товара */
@@ -18232,6 +18247,7 @@ marketplaceRemoveAvailableCategories?: [{ input: ResolverInputTypes["RemoveAvail
marketplaceRemoveAvailableCategoryTypes?: [{ input: ResolverInputTypes["RemoveAvailableCategoryTypesInput"]},boolean | `@${string}`],
marketplaceRemoveFromWhitelist?: [{ input: ResolverInputTypes["MarketplaceRemoveFromWhitelistInput"]},boolean | `@${string}`],
marketplaceReplaceAvailableItems?: [{ input: ResolverInputTypes["ReplaceAvailableItemsInput"]},ResolverInputTypes["MarketplaceAvailableCategory"]],
marketplaceRepublishOffer?: [{ input: ResolverInputTypes["MarketplaceRepublishOfferInput"]},ResolverInputTypes["MarketplaceOffer"]],
marketplaceRetryKUGeocode?: [{ coopname: string, coreBraname: string},ResolverInputTypes["MarketplaceKUDetails"]],
marketplaceSetKUStatus?: [{ data: ResolverInputTypes["MarketplaceSetKUStatusInput"]},ResolverInputTypes["MarketplaceKUDetails"]],
marketplaceSignAplReceptionAsChairman?: [{ data: ResolverInputTypes["MarketplaceSignAplReceptionInput"]},ResolverInputTypes["MarketplaceAplReceptionResult"]],
@@ -26730,7 +26746,9 @@ export type ModelTypes = {
warranty_days: number
};
["MarketplaceOfferImage"]: {
/** Является ли изображение обложкой карточки. */
/** Ключ объекта в хранилище — стабильный идентификатор изображения. Передаётся обратно при редактировании, чтобы сохранить уже загруженное изображение (в отличие от base64 для новых файлов). */
bucket_key: string,
/** Является ли изображение обложкой карточки. */
is_cover: boolean,
/** MIME-тип изображения. */
mime_type: string,
@@ -26740,10 +26758,12 @@ export type ModelTypes = {
url: string
};
["MarketplaceOfferImageUploadInput"]: {
/** Содержимое изображения, закодированное в base64. */
base64: string,
/** MIME-тип изображения (image/jpeg, image/png либо image/webp). */
mime_type: string
/** Содержимое нового изображения в base64. */
base64?: string | undefined | null,
/** Ключ уже сохранённого изображения — сохранить его в наборе. */
bucket_key?: string | undefined | null,
/** MIME-тип нового изображения (image/jpeg, image/png либо image/webp). */
mime_type?: string | undefined | null
};
["MarketplaceOfferPaginationResult"]: {
/** Текущая страница */
@@ -26956,6 +26976,9 @@ export type ModelTypes = {
["MarketplaceRemoveFromWhitelistInput"]: {
/** eosio::name пайщика-поставщика */
member_account: string
};
["MarketplaceRepublishOfferInput"]: {
id: string
};
["MarketplaceRequest"]: {
/** Артикул товара */
@@ -28320,6 +28343,8 @@ export type ModelTypes = {
Требуемые роли: chairman. */
marketplaceReplaceAvailableItems: Array<ModelTypes["MarketplaceAvailableCategory"]>,
/** Поставщик возвращает снятый Offer на публикацию (статус → PENDING_MODERATION) */
marketplaceRepublishOffer: ModelTypes["MarketplaceOffer"],
/** Повторно запускает геокодинг адреса ПВЗ.
Требуемые роли: chairman. */
@@ -37365,6 +37390,8 @@ export type GraphQLTypes = {
};
["MarketplaceOfferImage"]: {
__typename: "MarketplaceOfferImage",
/** Ключ объекта в хранилище — стабильный идентификатор изображения. Передаётся обратно при редактировании, чтобы сохранить уже загруженное изображение (в отличие от base64 для новых файлов). */
bucket_key: string,
/** Является ли изображение обложкой карточки. */
is_cover: boolean,
/** MIME-тип изображения. */
@@ -37376,10 +37403,12 @@ export type GraphQLTypes = {
['...on MarketplaceOfferImage']: Omit<GraphQLTypes["MarketplaceOfferImage"], "...on MarketplaceOfferImage">
};
["MarketplaceOfferImageUploadInput"]: {
/** Содержимое изображения, закодированное в base64. */
base64: string,
/** MIME-тип изображения (image/jpeg, image/png либо image/webp). */
mime_type: string
/** Содержимое нового изображения в base64. */
base64?: string | undefined | null,
/** Ключ уже сохранённого изображения — сохранить его в наборе. */
bucket_key?: string | undefined | null,
/** MIME-тип нового изображения (image/jpeg, image/png либо image/webp). */
mime_type?: string | undefined | null
};
["MarketplaceOfferPaginationResult"]: {
__typename: "MarketplaceOfferPaginationResult",
@@ -37614,6 +37643,9 @@ export type GraphQLTypes = {
["MarketplaceRemoveFromWhitelistInput"]: {
/** eosio::name пайщика-поставщика */
member_account: string
};
["MarketplaceRepublishOfferInput"]: {
id: string
};
["MarketplaceRequest"]: {
__typename: "MarketplaceRequest",
@@ -39042,6 +39074,8 @@ export type GraphQLTypes = {
Требуемые роли: chairman. */
marketplaceReplaceAvailableItems: Array<GraphQLTypes["MarketplaceAvailableCategory"]>,
/** Поставщик возвращает снятый Offer на публикацию (статус → PENDING_MODERATION) */
marketplaceRepublishOffer: GraphQLTypes["MarketplaceOffer"],
/** Повторно запускает геокодинг адреса ПВЗ.
Требуемые роли: chairman. */
@@ -43278,6 +43312,7 @@ type ZEUS_VARIABLES = {
["MarketplaceRejectReturnAtVisitInput"]: ValueTypes["MarketplaceRejectReturnAtVisitInput"];
["MarketplaceRejectReturnRemoteInput"]: ValueTypes["MarketplaceRejectReturnRemoteInput"];
["MarketplaceRemoveFromWhitelistInput"]: ValueTypes["MarketplaceRemoveFromWhitelistInput"];
["MarketplaceRepublishOfferInput"]: ValueTypes["MarketplaceRepublishOfferInput"];
["MarketplaceReturnClaimDefectCategory"]: ValueTypes["MarketplaceReturnClaimDefectCategory"];
["MarketplaceReturnClaimExpectedResolution"]: ValueTypes["MarketplaceReturnClaimExpectedResolution"];
["MarketplaceReturnClaimPhotoUploadInput"]: ValueTypes["MarketplaceReturnClaimPhotoUploadInput"];