refactor(marketplace): Эпик 15 desktop — КУ-чекбоксы + min-объём, batch-accept

- Форма оффера: вместо селектора типа поставки + target_volume — список КУ
  кооператива (BaseCheckbox + min-объём на каждый, через marketplaceListKUDetails);
  убраны cycleTypeOptions/isCollective/onSelectCycle/триггер-кнопка.
- Стол поставщика: accept/decline через единый batch-API (acceptOrdersBatch/
  declineOrdersBatch) по order_id[]; убрана cycle_id-маршрутизация.
- Сводный заказ заказчика, модерация: убраны cycle-лейблы → сводка по КУ.
- Удалены cycle-type-view алиасы, marketplaceCycleLabel; ESLint зелёный.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ant
2026-05-31 18:45:15 +00:00
parent 9fa0bd6d16
commit 664dfe2441
10 changed files with 147 additions and 234 deletions
@@ -4,7 +4,7 @@ import { Dialog } from 'quasar';
import { SuccessAlert, FailAlert } from 'src/shared/api';
import { Queries } from '@coopenomics/sdk';
import { client } from 'src/shared/api/client';
import { marketplaceUnitShort, marketplaceCycleLabel } from 'src/shared/lib/consts';
import { marketplaceUnitShort } from 'src/shared/lib/consts';
import { marketplaceOfferImageUrls } from 'src/shared/lib/utils';
import { BaseButton, EmptyState } from 'src/shared/ui/base';
import { PageHint } from 'src/shared/ui/domain';
@@ -50,6 +50,11 @@ function categoryName(offer: MarketplacePendingOfferView): string | null {
return catId != null ? categoryNames.value[catId] ?? null : null;
}
function deliveryPointsSummary(offer: MarketplacePendingOfferView): string {
const points = offer.delivery_points ?? [];
return points.map((p) => `${p.braname} (от ${p.min_supply_volume})`).join(', ');
}
function toCatalogOffer(offer: MarketplacePendingOfferView): CatalogOffer {
return {
id: offer.id,
@@ -188,9 +193,9 @@ q-page.moderation(role="region", aria-label="Модерация предложе
.moderation__meta-row(v-if="categoryName(o)")
span.moderation__meta-key Категория
span.moderation__meta-val {{ categoryName(o) }}
.moderation__meta-row(v-if="o.cycle_type")
span.moderation__meta-key Тип отсечки
span.moderation__meta-val {{ marketplaceCycleLabel(o.cycle_type) }}
.moderation__meta-row(v-if="o.delivery_points && o.delivery_points.length")
span.moderation__meta-key Участки поставки
span.moderation__meta-val {{ deliveryPointsSummary(o) }}
.moderation__meta-row(v-if="o.warranty_days != null && o.warranty_days > 0")
span.moderation__meta-key Гарантия
span.moderation__meta-val {{ o.warranty_days }} дн.
@@ -64,19 +64,3 @@ export async function withdrawOffer(id: string): Promise<void> {
variables: { input: { id } },
});
}
/**
* Эпик 4 / Story 4.2: поставщик вручную запускает коллективную закупку по
* своему предложению (cycle_type=collective).
*
* Backend Resolver: marketplace-cycle.resolver.ts → marketplaceTriggerCollectiveSupply
* (guard 'Offer' 'update:own'). Нажатие = акцепт всего накопленного пула:
* сервер формирует сводную заявку status=ACCEPTED и принимает заказы разом.
* Ошибки backend (пустой пул, не collective, не ACTIVE, чужой Offer)
* приходят как GraphQL-исключения — пробрасываем их вызывающему компоненту.
*/
export async function triggerCollectiveSupply(offer_id: string): Promise<void> {
await client.Mutation(Mutations.Marketplace.TriggerCollectiveSupply.mutation, {
variables: { input: { offer_id } },
});
}
@@ -1,16 +1,17 @@
/**
* Story 4.7: типы формы публикации Offer'а с cycle_type.
* Эпик 15: типы формы публикации Offer'а. Тип поставки упразднён — управление
* поставкой сведено к набору КУ с минимальным объёмом на каждом
* (`delivery_points`). min_supply_volume = 1 → поставка по одному заказу,
* >1 → накопление партии (мягкий ориентир, не порог).
*
* Источник истины — backend `MarketplaceCreateOfferInputDTO`. Ручная
* типизация — техдолг до регенерации Zeus в `@coopenomics/sdk` (как
* на других страницах Marketplace).
*
* Способ поставки (ревизия L11, 2026-05-31) — два режима:
* individual — каждый заказ обрабатывается отдельно;
* collective — заказы копятся в пул (авто-старт по target_volume либо
* ручной запуск поставщиком).
* Источник истины — backend `MarketplaceCreateOfferInputDTO`.
*/
export type MarketplaceOfferCycleType = 'individual' | 'collective';
export interface MarketplaceOfferDeliveryPoint {
/** Кооперативный участок (ПВЗ), на который поставщик готов везти. */
braname: string;
/** Минимальный объём поставки на этот участок (в единицах товара, ≥ 1). */
min_supply_volume: number;
}
export type MarketplaceUnitOfMeasure = 'piece' | 'kg' | 'liter' | 'pack';
@@ -54,8 +55,7 @@ export interface MarketplaceCreateOfferFormState {
unit_of_measure: MarketplaceUnitOfMeasure;
quantity_available: number | null;
unlimited_flag: boolean;
cycle_type: MarketplaceOfferCycleType;
target_volume: number | null;
delivery_points: MarketplaceOfferDeliveryPoint[];
warranty_days: number;
}
@@ -67,8 +67,7 @@ export interface MarketplaceCreateOfferPayload {
unit_of_measure: MarketplaceUnitOfMeasure;
quantity_available: number | null;
unlimited_flag: boolean;
cycle_type: MarketplaceOfferCycleType;
target_volume: number | null;
delivery_points: MarketplaceOfferDeliveryPoint[];
warranty_days: number;
/**
* Итоговый упорядоченный набор изображений (первый — обложка). При создании —
@@ -89,7 +88,6 @@ export interface MarketplaceCreateOfferResult {
id: string;
status: 'PENDING_MODERATION' | 'ACTIVE' | 'REJECTED' | 'WITHDRAWN';
product_name: string;
cycle_type: MarketplaceOfferCycleType;
}
export interface MarketplaceUpdateOfferPayload extends MarketplaceCreateOfferPayload {
@@ -109,8 +107,7 @@ export interface MarketplaceOfferEditPrefill {
unit_of_measure: string;
quantity_available: number;
unlimited_flag: boolean;
cycle_type: MarketplaceOfferCycleType;
target_volume: number | null;
delivery_points: MarketplaceOfferDeliveryPoint[];
warranty_days: number;
status: 'PENDING_MODERATION' | 'ACTIVE' | 'REJECTED' | 'WITHDRAWN';
reject_reason?: string | null;
@@ -3,18 +3,10 @@ q-page.mp-role-offerer.offer-wizard(role='region', aria-label='Создание
.offer-wizard__col
//- Заголовок страницы — в топбаре (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',
@@ -155,34 +147,29 @@ q-page.mp-role-offerer.offer-wizard(role='region', aria-label='Создание
:rules='[(v) => (v !== null && v >= 0) || "Срок гарантии не может быть отрицательным"]'
)
//- ───────── Шаг 3: Условия поставки ─────────
//- ───────── Шаг 3: КУ поставки и минимальный объём ─────────
.offer-wizard__step(v-else-if='step.key === "supply"')
.offer-wizard__cards
BaseRadioCard(
v-for='opt in cycleTypeOptions',
:key='opt.value',
:model-value='form.cycle_type',
:value='opt.value',
:title='opt.title',
:description='opt.description',
@update:model-value='onSelectCycle(opt.value)'
p.offer-wizard__hint
| Отметьте кооперативные участки, на которые готовы везти, и укажите минимальный объём поставки на каждый. Объём 1 поставка по одному заказу; больше заказы копятся в партию (ориентир для группировки, не жёсткий порог: можно принять и меньше).
.offer-wizard__hint(v-if='kuLoading') Загрузка участков
.offer-wizard__hint(v-else-if='!kuOptions.length') Нет доступных кооперативных участков.
.offer-wizard__ku-row(v-for='ku in kuOptions', :key='ku.braname')
BaseCheckbox(
:model-value='isKuSelected(ku.braname)',
:label='ku.label',
@update:model-value='(v) => toggleKu(ku.braname, v)'
)
//- conditional поля по способу поставки
.offer-wizard__conditional(v-if='isCollective')
q-input(
v-model.number='form.target_volume',
label='Целевой объём для авто-старта (необязательно)',
q-input.offer-wizard__ku-min(
v-if='isKuSelected(ku.braname)',
:model-value='kuMinVolume(ku.braname)',
label='Мин. объём',
type='number',
min='1',
outlined,
dense,
no-error-icon,
reserve-hint-space,
hint='Когда сумма заказов достигнет объёма — поставка стартует автоматически. Пусто — запускаете поставку вручную.'
@update:model-value='(v) => setKuMin(ku.braname, v)'
)
.offer-wizard__hint(v-else)
| Каждый заказ принимается индивидуально, без ожидания набора партии.
//- ───────── Шаг 4: Изображения ─────────
.offer-wizard__step(v-else-if='step.key === "images"')
@@ -268,12 +255,9 @@ q-page.mp-role-offerer.offer-wizard(role='region', aria-label='Создание
section.offer-preview__specs
.offer-preview__specs-title Характеристики
dl.offer-preview__specs-list
.offer-preview__spec
dt Способ поставки
dd {{ selectedCycleTitle }}
.offer-preview__spec(v-if='isCollective && form.target_volume')
dt Целевой объём
dd {{ form.target_volume }} {{ selectedUnitLabel }}
.offer-preview__spec(v-if='form.delivery_points.length')
dt Участки поставки
dd {{ deliveryPointsPreview }}
.offer-preview__spec(v-if='form.warranty_days > 0')
dt Гарантия
dd {{ form.warranty_days }} дн.
@@ -306,10 +290,11 @@ import { useRoute, useRouter } from 'vue-router';
import { VerticalStepper } from 'src/shared/ui/domain/VerticalStepper';
import type { StepperStep } from 'src/shared/ui/domain/VerticalStepper';
import { BaseButton } from 'src/shared/ui/base/BaseButton';
import { BaseRadioCard } from 'src/shared/ui/base/BaseRadioCard';
import { BaseCheckbox } from 'src/shared/ui/base/BaseCheckbox';
import { BaseChip } from 'src/shared/ui/base/BaseChip';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { Queries } from '@coopenomics/sdk';
import { client } from 'src/shared/api/client';
import { useSystemStore } from 'src/entities/System/model';
import { MARKETPLACE_UNIT_OPTIONS } from 'src/shared/lib/consts';
import { fileToBase64, formatAsset2Digits } from 'src/shared/lib/utils';
@@ -317,7 +302,6 @@ import {
createOffer,
fetchCategories,
fetchMyOfferById,
triggerCollectiveSupply,
updateOffer,
withdrawOffer,
} from '../api';
@@ -325,7 +309,6 @@ import type {
MarketplaceCategoryView,
MarketplaceCreateOfferFormState,
MarketplaceCreateOfferPayload,
MarketplaceOfferCycleType,
MarketplaceOfferImageUpload,
MarketplaceUnitOfMeasure,
} from '../types';
@@ -400,10 +383,8 @@ const submitLabel = computed(() => {
// живут на самой странице редактирования — отдельного диалога-просмотра нет.
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' },
@@ -415,14 +396,10 @@ const statusLabel = computed(() => (currentStatus.value ? STATUS_META[currentSta
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 === 'collective'
);
function onWithdraw(): void {
if (!editId.value) return;
@@ -448,30 +425,6 @@ function onWithdraw(): void {
});
}
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 triggerCollectiveSupply(editId.value as string);
SuccessAlert('Поставка запущена: заказы зафиксированы в партию и приняты.');
void router.push({ name: 'marketplace-my-offers' });
} catch (e) {
FailAlert(e, 'Не удалось запустить поставку');
} finally {
triggering.value = false;
}
});
}
// ===== Шаги =====
const steps: StepperStep[] = [
{ key: 'basics', label: 'Товар', description: 'Название, категория, описание' },
@@ -492,23 +445,15 @@ const pricingForm = ref<QForm | null>(null);
// подписи не расходились между созданием оферты, модерацией и «Мои предложения».
const unitOptions = MARKETPLACE_UNIT_OPTIONS;
const cycleTypeOptions: Array<{
value: MarketplaceOfferCycleType;
title: string;
description: string;
}> = [
{
value: 'individual',
title: 'Индивидуально',
description: 'Каждый заказ обрабатывается отдельно, без ожидания набора партии.',
},
{
value: 'collective',
title: 'Коллективная закупка',
description:
'Заказы копятся в общий пул. Старт — автоматически по целевому объёму либо вручную вами.',
},
];
// ===== КУ поставки (Эпик 15) =====
// Список кооперативных участков кооператива для чекбоксов: поставщик отмечает,
// на какие КУ готов везти, и ставит min-объём на каждый.
interface KuOption {
braname: string;
label: string;
}
const kuOptions = ref<KuOption[]>([]);
const kuLoading = ref(false);
const categories = ref<MarketplaceCategoryView[]>([]);
const categoryOptions = computed(() =>
@@ -527,8 +472,7 @@ const form = ref<MarketplaceCreateOfferFormState>({
unit_of_measure: 'piece',
quantity_available: 1,
unlimited_flag: false,
cycle_type: 'individual',
target_volume: null,
delivery_points: [],
warranty_days: 0,
});
@@ -556,18 +500,71 @@ const previewActive = ref(0);
const originalImageKeys = ref<string[]>([]);
let galleryUidSeq = 0;
const isCollective = computed(() => form.value.cycle_type === 'collective');
const selectedCategoryLabel = computed(
() => categoryOptions.value.find((o) => o.value === form.value.category_id)?.label ?? '—'
);
const selectedUnitLabel = computed(
() => unitOptions.find((o) => o.value === form.value.unit_of_measure)?.label ?? ''
);
const selectedCycleTitle = computed(
() => cycleTypeOptions.find((o) => o.value === form.value.cycle_type)?.title ?? '—'
const deliveryPointsPreview = computed(() =>
form.value.delivery_points
.map((d) => {
const label = kuOptions.value.find((k) => k.braname === d.braname)?.label ?? d.braname;
return `${label} (от ${d.min_supply_volume})`;
})
.join(', ')
);
// ===== КУ-хелперы =====
function isKuSelected(braname: string): boolean {
return form.value.delivery_points.some((d) => d.braname === braname);
}
function toggleKu(braname: string, checked: boolean): void {
if (checked) {
if (!isKuSelected(braname)) {
form.value.delivery_points = [
...form.value.delivery_points,
{ braname, min_supply_volume: 1 },
];
}
} else {
form.value.delivery_points = form.value.delivery_points.filter((d) => d.braname !== braname);
}
}
function kuMinVolume(braname: string): number {
return form.value.delivery_points.find((d) => d.braname === braname)?.min_supply_volume ?? 1;
}
function setKuMin(braname: string, value: string | number | null): void {
const n = Math.max(1, Math.floor(Number(value) || 1));
form.value.delivery_points = form.value.delivery_points.map((d) =>
d.braname === braname ? { ...d, min_supply_volume: n } : d
);
}
async function loadKuOptions(): Promise<void> {
kuLoading.value = true;
try {
const result = await client.Query(Queries.Marketplace.ListKUDetails.query, {
variables: { data: {} },
});
const list = (result[Queries.Marketplace.ListKUDetails.name] ?? []) as Array<{
coreBraname: string;
addressFull?: string | null;
status?: string | null;
}>;
kuOptions.value = list
.filter((k) => !k.status || k.status === 'active' || k.status === 'ACTIVE')
.map((k) => ({
braname: k.coreBraname,
label: k.addressFull ? `${k.coreBraname}${k.addressFull}` : k.coreBraname,
}));
} catch (e) {
FailAlert(e, 'Не удалось загрузить кооперативные участки');
} finally {
kuLoading.value = false;
}
}
// Нормализованная (точка-разделитель) цена для отправки и форматирования.
const priceNumberStr = computed(() => form.value.price_per_unit.trim().replace(',', '.'));
const formattedPrice = computed(() =>
@@ -690,15 +687,6 @@ function setCover(index: number): void {
coverIndex.value = index;
}
function onSelectCycle(value: MarketplaceOfferCycleType): void {
form.value.cycle_type = value;
// target_volume имеет смысл только для коллективной закупки (опциональный
// авто-старт). У индивидуальной поставки его нет.
if (value === 'individual') {
form.value.target_volume = null;
}
}
async function onPickFiles(files: readonly File[] | null): Promise<void> {
const list = files ? [...files] : [];
// Берём только ещё не добавленные файлы (по имени) — на случай повторного выбора.
@@ -751,11 +739,12 @@ function markCompleted(key: string): void {
}
function validateSupply(): string | null {
const f = form.value;
// Целевой объём для коллективной закупки необязателен (пусто = ручной запуск),
// но если задан — должен быть положительным.
if (f.cycle_type === 'collective' && f.target_volume != null && f.target_volume < 1) {
return 'Целевой объём должен быть от 1 (или оставьте пустым для ручного запуска).';
const points = form.value.delivery_points;
if (!points.length) {
return 'Отметьте хотя бы один кооперативный участок поставки.';
}
if (points.some((d) => !Number.isInteger(d.min_supply_volume) || d.min_supply_volume < 1)) {
return 'Минимальный объём на каждом участке должен быть целым числом от 1.';
}
return null;
}
@@ -835,8 +824,7 @@ async function onSubmit(): Promise<void> {
unit_of_measure: f.unit_of_measure,
quantity_available: f.unlimited_flag ? null : f.quantity_available,
unlimited_flag: f.unlimited_flag,
cycle_type: f.cycle_type,
target_volume: f.cycle_type === 'collective' ? f.target_volume : null,
delivery_points: f.delivery_points,
warranty_days: f.warranty_days,
images: buildImagesPayload(),
};
@@ -885,8 +873,10 @@ async function prefillForEdit(id: string): Promise<void> {
unit_of_measure: offer.unit_of_measure as MarketplaceUnitOfMeasure,
quantity_available: offer.quantity_available,
unlimited_flag: offer.unlimited_flag,
cycle_type: offer.cycle_type,
target_volume: offer.target_volume,
delivery_points: (offer.delivery_points ?? []).map((d) => ({
braname: d.braname,
min_supply_volume: d.min_supply_volume,
})),
warranty_days: offer.warranty_days,
};
gallery.value = (offer.images ?? [])
@@ -901,7 +891,6 @@ async function prefillForEdit(id: string): Promise<void> {
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, 'Не удалось загрузить предложение');
@@ -916,6 +905,7 @@ onMounted(async () => {
} catch (e) {
FailAlert(e, 'Не удалось загрузить категории');
}
await loadKuOptions();
if (editId.value) {
await prefillForEdit(editId.value);
} else {
@@ -29,5 +29,3 @@ export type MarketplaceOrderPage = Omit<_RawOrderPage, 'items'> & {
* самих заказов. Никаких параллельных строковых union'ов.
*/
export type MarketplaceOrderStatusView = `${MarketplaceOrderView['status']}`;
export type MarketplaceOrderCycleTypeView = `${MarketplaceOrderView['cycle_type']}`;
@@ -50,30 +50,23 @@ export async function fetchSupplierOrders(
return result as MarketplaceOrderPage;
}
export async function acceptIndividualOrder(order_id: string): Promise<void> {
await client.Mutation(Mutations.Marketplace.AcceptIndividualOrder.mutation, {
variables: { input: { order_id } },
});
}
export async function declineIndividualOrder(order_id: string, reason: string): Promise<void> {
await client.Mutation(Mutations.Marketplace.DeclineIndividualOrder.mutation, {
variables: { input: { order_id, reason } },
/**
* Эпик 15: поставщик принимает к поставке выбранные заказы (любое подмножество
* группы offer × КУ) единым массивом. Backend оборачивает их в одну
* партию-накопитель и акцептует on-chain.
*/
export async function acceptOrdersBatch(order_ids: string[]): Promise<void> {
await client.Mutation(Mutations.Marketplace.AcceptOrdersBatch.mutation, {
variables: { input: { order_ids } },
});
}
/**
* Групповой (collective) акцепт: поставщик принимает всю сводную заявку
* партии разом. request_id = cycle_id заказа.
* Эпик 15: поставщик отказывается от выбранных активных заказов массивом;
* средства пайщиков разблокируются.
*/
export async function acceptConsolidatedRequest(request_id: string): Promise<void> {
await client.Mutation(Mutations.Marketplace.AcceptConsolidatedRequest.mutation, {
variables: { input: { request_id } },
});
}
export async function declineConsolidatedRequest(request_id: string, reason: string): Promise<void> {
await client.Mutation(Mutations.Marketplace.DeclineConsolidatedRequest.mutation, {
variables: { input: { request_id, reason } },
export async function declineOrdersBatch(order_ids: string[], reason: string): Promise<void> {
await client.Mutation(Mutations.Marketplace.DeclineOrdersBatch.mutation, {
variables: { input: { order_ids, reason } },
});
}
@@ -13,10 +13,8 @@ import {
} from 'src/widgets/Marketplace/OrderCard';
import { RefreshButton } from 'src/widgets/Marketplace/RefreshButton';
import {
acceptConsolidatedRequest,
acceptIndividualOrder,
declineConsolidatedRequest,
declineIndividualOrder,
acceptOrdersBatch,
declineOrdersBatch,
fetchSupplierOrders,
} from '../api';
import type {
@@ -139,21 +137,10 @@ function loadMore(): void {
async function onAccept(orderId: string): Promise<void> {
loading.value = true;
try {
// Индивидуальный заказ акцептуется поштучно (order_id); групповой
// (cycle_type collective) — целиком сводной заявкой по cycle_id. Иначе
// acceptIndividualOrder на групповом заказе падает: заказ привязан к
// сводной заявке, а не к индивидуальному циклу.
const order = items.value.find((o) => o.id === orderId);
if (order && order.status === 'ACCEPTED_PENDING_SUPPLIER') {
if (!order.cycle_id) {
throw new Error('У группового заказа отсутствует идентификатор сводной заявки (cycle_id).');
}
await acceptConsolidatedRequest(order.cycle_id);
SuccessAlert('Сводная заявка партии принята');
} else {
await acceptIndividualOrder(orderId);
SuccessAlert('Заказ принят');
}
// Эпик 15: единый batch-accept по order_id[]. Здесь принимаем по одному
// (партия из одного); мультивыбор группой (offer × КУ) — фоллоуап UI.
await acceptOrdersBatch([orderId]);
SuccessAlert('Заказ принят к поставке');
await load(1, false);
} catch (e) {
FailAlert(e);
@@ -173,17 +160,8 @@ function onDecline(orderId: string): void {
}).onOk(async (reason: string) => {
loading.value = true;
try {
const order = items.value.find((o) => o.id === orderId);
if (order && order.status === 'ACCEPTED_PENDING_SUPPLIER') {
if (!order.cycle_id) {
throw new Error('У группового заказа отсутствует идентификатор сводной заявки (cycle_id).');
}
await declineConsolidatedRequest(order.cycle_id, reason.trim());
NotifyAlert('Сводная заявка партии отклонена');
} else {
await declineIndividualOrder(orderId, reason.trim());
NotifyAlert('Заказ отклонён');
}
await declineOrdersBatch([orderId], reason.trim());
NotifyAlert('Заказ отклонён');
await load(1, false);
} catch (e) {
FailAlert(e);
@@ -17,5 +17,3 @@ export type MarketplaceOfferView = MarketplaceOfferPage['items'][number];
* самих предложений.
*/
export type MarketplaceOfferStatusView = `${MarketplaceOfferView['status']}`;
export type MarketplaceOfferCycleTypeView = `${MarketplaceOfferView['cycle_type']}`;
@@ -11,7 +11,6 @@ import { EmptyState } from 'src/shared/ui/base';
import { PageHint } from 'src/shared/ui/domain';
import { fetchMyOrders } from '../../MyOrders/api';
import type {
MarketplaceOrderCycleTypeView,
MarketplaceOrderStatusView,
MarketplaceOrderView,
} from '../../MyOrders/types';
@@ -36,17 +35,6 @@ const items = ref<MarketplaceOrderView[]>([]);
const loading = ref(false);
let pollTimer: ReturnType<typeof setInterval> | null = null;
const CYCLE_TYPE_LABEL: Record<MarketplaceOrderCycleTypeView, string> = {
COLLECTIVE: 'Коллективная закупка',
INDIVIDUAL: 'Индивидуальный',
};
const CYCLE_TYPE_HINT: Record<MarketplaceOrderCycleTypeView, string> = {
COLLECTIVE:
'Заказы копятся в общий пул; поставка стартует по достижении целевого объёма либо по решению поставщика.',
INDIVIDUAL: 'Каждый заказ обслуживается поставщиком отдельно.',
};
// Этап партии = минимальный по STAGE_RANK среди не-отменённых; то есть
// если хотя бы один заказ ещё ждёт цикл, вся партия «Активна».
// Готовый к выдаче переходит в issued только когда все заказы выданы.
@@ -67,7 +55,6 @@ const STAGE_RANK: Record<MarketplaceOrderStatusView, number> = {
interface ConsolidatedGroup {
key: string;
cycle_id: string | null;
cycle_type: MarketplaceOrderCycleTypeView;
offer_id: string;
supplier_account: string;
delivery_braname: string;
@@ -88,7 +75,6 @@ const groups = computed<ConsolidatedGroup[]>(() => {
g = {
key,
cycle_id: o.cycle_id ?? null,
cycle_type: o.cycle_type,
offer_id: o.offer_id,
supplier_account: o.supplier_account,
delivery_braname: o.delivery_braname,
@@ -192,7 +178,8 @@ q-page.consolidated(role="region", aria-label="Сводный заказ")
.t-h3
span(v-if="g.cycle_id") Партия {{ g.cycle_id }}
span(v-else) Индивидуальный заказ {{ g.orders[0].id.slice(0, 8) }}
.t-muted.consolidated__group-hint {{ CYCLE_TYPE_LABEL[g.cycle_type] }} {{ CYCLE_TYPE_HINT[g.cycle_type] }}
.t-muted.consolidated__group-hint(v-if="g.cycle_id") Партия-накопитель заказы приняты поставщиком к поставке вместе.
.t-muted.consolidated__group-hint(v-else) Отдельный заказ ещё не принят поставщиком к поставке.
q-space
span.chip.chip--accent
q-icon(name="layers", size="14px")
@@ -44,20 +44,3 @@ export function marketplaceUnitLabel(value: string | null | undefined): string {
if (!value) return 'ед.';
return MARKETPLACE_UNITS.find((u) => u.value === value)?.label ?? value;
}
/**
* Русские подписи способа поставки (cycle_type) — единый источник для всех
* marketplace-экранов (модерация, каталог, создание оферты). Раньше карта
* дублировалась по нескольким страницам (создание оферты, сводный заказ).
* Значения совпадают с backend-enum `MarketplaceOfferCycleType` (два режима).
*/
const MARKETPLACE_CYCLE_LABELS: Record<string, string> = {
individual: 'Индивидуально',
collective: 'Коллективная закупка',
};
/** Русская подпись способа поставки; неизвестное значение возвращается как есть. */
export function marketplaceCycleLabel(value: string | null | undefined): string {
if (!value) return '';
return MARKETPLACE_CYCLE_LABELS[value] ?? value;
}