[598][@ant] feat(desktop,docs,harness): orderer/order-create — реальный submit через диалог в каталоге

- OrderCreateDialog.vue: q-input «Количество» + q-select «ПВЗ доставки» + расчёт итоговой суммы; submit вызывает Mutations.Marketplace.CreateOrder
- MarketplaceCatalogPage.vue: onSelectOffer открывает диалог вместо stub Notify, по @created — перезагрузка каталога
- API: fetchBranchOptions (Queries.Branches.GetBranches) + submitCreateOrder
- scenario orderer/order-create.mjs: 3 шота — пустой диалог / заполненная форма / Notify «Заказ создан»
- order-create.md: приведена к реальному MVP (убраны выдуманные «акт о паевом взносе», «комиссия», «программный кошелёк» — convention «не упоминать неиспользуемое»)
- PLAN.md §9.10: фиксация развилки §9.9 в пользу (A) UI

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ant
2026-05-22 06:22:24 +00:00
parent a8105eda21
commit 787878dae4
7 changed files with 292 additions and 51 deletions
@@ -1,10 +1,12 @@
import { Queries } from '@coopenomics/sdk';
import { Mutations, Queries } from '@coopenomics/sdk';
import { client } from 'src/shared/api/client';
import type {
BranchOption,
CatalogSort,
MarketplaceCategoryOfferCount,
MarketplaceCategoryView,
MarketplaceOfferPage,
MarketplaceOrderCreated,
} from '../types';
/**
@@ -66,3 +68,27 @@ export async function fetchCategoryOfferCounts(): Promise<MarketplaceCategoryOff
);
return list;
}
export async function fetchBranchOptions(coopname: string): Promise<BranchOption[]> {
const { [Queries.Branches.GetBranches.name]: list } = await client.Query(
Queries.Branches.GetBranches.query,
{ variables: { data: { coopname, braname: null } } },
);
return (list ?? []).map((b) => ({
braname: b.braname,
short_name: b.short_name ?? b.full_name ?? b.braname,
city: b.city ?? null,
}));
}
export async function submitCreateOrder(input: {
offer_id: string;
quantity: number;
delivery_braname: string;
}): Promise<MarketplaceOrderCreated> {
const { [Mutations.Marketplace.CreateOrder.name]: result } = await client.Mutation(
Mutations.Marketplace.CreateOrder.mutation,
{ variables: { input } },
);
return result;
}
@@ -2,7 +2,7 @@
* Story 3.5: типы каталога Стола заказов — берутся из SDK Zeus IOutput
* соответствующих query, чтобы избежать ручного дублирования backend-схемы.
*/
import type { Queries } from '@coopenomics/sdk';
import type { Mutations, Queries } from '@coopenomics/sdk';
export type MarketplaceOfferPage =
Queries.Marketplace.ListCatalog.IOutput['marketplaceListCatalog'];
@@ -15,6 +15,15 @@ export type MarketplaceCategoryView =
export type MarketplaceCategoryOfferCount =
Queries.Marketplace.CategoryOfferCounts.IOutput['marketplaceCategoryOfferCounts'][number];
export type MarketplaceOrderCreated =
Mutations.Marketplace.CreateOrder.IOutput['marketplaceCreateOrder'];
export interface BranchOption {
braname: string;
short_name: string;
city: string | null;
}
export type CatalogSort = 'created_at_desc' | 'price_asc' | 'price_desc';
export interface CatalogFilter {
@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { Notify } from 'quasar';
import {
CatalogOfferCard,
@@ -17,6 +18,7 @@ import type {
MarketplaceCategoryView,
MarketplaceOfferView,
} from '../types';
import OrderCreateDialog from './OrderCreateDialog.vue';
/**
* Story 3.5: каталог Стола заказов на orderer-столе.
@@ -121,12 +123,19 @@ async function onLoadMore(): Promise<void> {
await loadPage(true);
}
const route = useRoute();
const coopname = computed(() => String(route.params.coopname ?? ''));
const orderDialogOpen = ref(false);
const orderDialogOffer = ref<MarketplaceOfferView | null>(null);
function onSelectOffer(offer: MarketplaceOfferView): void {
// Story 4.1 Order-форма; пока — заглушка
Notify.create({
type: 'info',
message: `Создание заказа на Offer ${offer.id} — будет доступно после Эпика 4`,
});
if (!canOrder(offer)) return;
orderDialogOffer.value = offer;
orderDialogOpen.value = true;
}
async function onOrderCreated(): Promise<void> {
await loadPage(false);
}
onMounted(async () => {
@@ -205,6 +214,13 @@ q-page.mp-role-orderer.mp-catalog-page(role="region", aria-label="Каталог
template(v-slot:loading)
div.row.justify-center.q-my-md
q-spinner(color="primary", size="2em")
OrderCreateDialog(
v-model="orderDialogOpen",
:coopname="coopname",
:offer="orderDialogOffer",
@created="onOrderCreated"
)
</template>
<style scoped lang="scss">
@@ -0,0 +1,158 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { Notify } from 'quasar';
import { fetchBranchOptions, submitCreateOrder } from '../api';
import type { BranchOption, MarketplaceOfferView } from '../types';
const props = defineProps<{
modelValue: boolean;
coopname: string;
offer: MarketplaceOfferView | null;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'created'): void;
}>();
const open = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v),
});
const UNIT_LABEL: Record<MarketplaceOfferView['unit_of_measure'], string> = {
piece: 'шт',
kg: 'кг',
liter: 'л',
pack: 'упак',
};
const quantity = ref<number>(1);
const branch = ref<string | null>(null);
const branches = ref<BranchOption[]>([]);
const loadingBranches = ref<boolean>(false);
const submitting = ref<boolean>(false);
const unitLabel = computed(() =>
props.offer ? UNIT_LABEL[props.offer.unit_of_measure] : '',
);
const maxQuantity = computed(() => {
if (!props.offer) return null;
if (props.offer.unlimited_flag) return null;
return props.offer.quantity_available;
});
const totalSum = computed(() => {
if (!props.offer) return 0;
const q = Number(quantity.value) || 0;
return q * Number(props.offer.price_per_unit);
});
const branchOptions = computed(() =>
branches.value.map((b) => ({
label: b.city ? `${b.short_name}${b.city}` : b.short_name,
value: b.braname,
})),
);
const canSubmit = computed(() => {
if (!props.offer || !branch.value) return false;
const q = Number(quantity.value);
if (!Number.isInteger(q) || q < 1) return false;
if (maxQuantity.value !== null && q > maxQuantity.value) return false;
return true;
});
watch(
() => props.modelValue,
async (v) => {
if (!v) return;
quantity.value = 1;
branch.value = null;
if (branches.value.length === 0) {
loadingBranches.value = true;
try {
branches.value = await fetchBranchOptions(props.coopname);
if (branches.value.length === 1) {
branch.value = branches.value[0]!.braname;
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message: `Не удалось загрузить ПВЗ: ${message}` });
} finally {
loadingBranches.value = false;
}
} else if (branches.value.length === 1) {
branch.value = branches.value[0]!.braname;
}
},
);
async function onSubmit(): Promise<void> {
if (!props.offer || !branch.value) return;
submitting.value = true;
try {
await submitCreateOrder({
offer_id: props.offer.id,
quantity: Number(quantity.value),
delivery_braname: branch.value,
});
Notify.create({ type: 'positive', message: 'Заказ создан' });
emit('created');
open.value = false;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
Notify.create({ type: 'negative', message });
} finally {
submitting.value = false;
}
}
</script>
<template lang="pug">
q-dialog(v-model="open", :persistent="submitting")
q-card.mp-order-create-dialog(style="min-width: 360px; max-width: 480px")
q-card-section
div.text-h6 Оформление заказа
div.text-subtitle2.text-grey-7(v-if="offer") {{ offer.product_name }}
q-separator
q-card-section.q-gutter-md
q-input(
v-model.number="quantity",
type="number",
:min="1",
:max="maxQuantity ?? undefined",
:label="`Количество (${unitLabel})`",
:hint="maxQuantity !== null ? `Доступно: ${maxQuantity} ${unitLabel}` : 'Без ограничения остатка'",
outlined,
dense
)
q-select(
v-model="branch",
:options="branchOptions",
option-value="value",
option-label="label",
emit-value,
map-options,
outlined,
dense,
label="ПВЗ доставки",
:loading="loadingBranches",
:disable="branchOptions.length === 0"
)
div.text-body2(v-if="offer")
| Цена: {{ Number(offer.price_per_unit).toLocaleString('ru-RU') }} за {{ unitLabel }}
div.text-h6.text-primary(v-if="offer")
| Итого: {{ totalSum.toLocaleString('ru-RU') }}
q-card-actions(align="right")
q-btn(flat, label="Отмена", :disable="submitting", @click="open = false")
q-btn(
unelevated,
color="primary",
label="Подтвердить заказ",
:disable="!canSubmit",
:loading="submitting",
@click="onSubmit"
)
</template>
@@ -1,6 +1,6 @@
# План E2E-документации Marketplace MVP «Стол заказов»
Последнее обновление: 2026-05-22 (9 страниц реализованы, harness прогнан, PNG установлены, admonition «реализовано»; см. §9 «Текущее состояние»).
Последнее обновление: 2026-05-22 (orderer/order-create переключён со stub Notify на реальный `marketplaceCreateOrder` через диалог в каталоге — см. §9.10).
Цель: ночной прогон harness + проза в `components/docs/docs/new/marketplace/...` + добавление новых разделов в `mkdocs.yml`. Параллельно — UI-багфиксы по факту.
## 0. Терминология (зафиксировать в UI/доках)
@@ -367,6 +367,22 @@ export const meta = {
3. **`branch-chairman/branch-orders` auto-detect braname**: подтянуть `marketplaceWhoAmI` или `marketplace_member_wallet` вместо ручного ввода (председатель КУ привязан к одному branch через trustee).
4. **~~Прогон harness'а~~ ✅ выполнен** — 9 PNG установлены, admonition сняты (коммит `ee06d405136`, 2026-05-22). MD-проза готова.
### 9.10. orderer/order-create — реальный submit (2026-05-22)
Развилка §9.9 решена в пользу (A) — UI. Реализовано:
- **SDK уже был готов** — `Mutations.Marketplace.CreateOrder` существует, никаких новых обёрток не требуется.
- **API-слой** `pages/Marketplace/MarketplaceCatalog/api/index.ts` — добавлены `fetchBranchOptions(coopname)` через `Queries.Branches.GetBranches` и `submitCreateOrder({offer_id, quantity, delivery_braname})` через `Mutations.Marketplace.CreateOrder`.
- **Dialog-компонент** `pages/Marketplace/MarketplaceCatalog/ui/OrderCreateDialog.vue` — q-dialog с двумя полями: q-input «Количество» (с верхней границей по `quantity_available` для не-безлимитных Offer'ов) + q-select «ПВЗ доставки» (автовыбор единственного ПВЗ). Расчёт итоговой суммы — `price_per_unit × quantity`, обновляется реактивно.
- **MarketplaceCatalogPage.vue** — `onSelectOffer` больше не вызывает stub Notify; вместо этого открывает `OrderCreateDialog` с выбранным offer'ом. По `@created` — каталог перезагружается.
- **Сценарий** `orderer/order-create.mjs` переписан под новый UI: 3 шота — пустой диалог / заполненная форма / Notify «Заказ создан».
- **Проза** `docs/new/marketplace/orderer/order-create.md` приведена в соответствие с реальным MVP: убраны выдуманные «акт о паевом взносе», «комиссия кооператива», «срок ожидания партии», «программный кошелёк Стола заказов» — описание только того, что в UI и backend сейчас есть (соответствует convention «не упоминать неиспользуемое»).
**Что разблокирует.** После сабмита Order появляется в Postgres со статусом CREATED. Следующие страницы магистрали II можно снимать с реальными данными: `orderer/orders`, `orderer/consolidated`, `offerer/incoming-orders`.
**Что НЕ покрыто в этой итерации** (отдельный блокер при первом прогоне):
- Если Ekaterina не Membership Стола заказов — backend Membership-guard вернёт ошибку. UI её покажет в Notify. Membership-flow (`OnboardingMemberPickCpp`) уже реализован — нужно прогнать его перед order-create или включить шаг подписания в installExtraData.
### 9.9. Магистраль II — старт (2026-05-22, коммит `2db39b56bfa`)
Первая цепочка через UI выполнена:
@@ -1,9 +1,8 @@
// Сценарий: orderer-стол «Оформление заказа» (Эпик 4 / Story 4.1).
// На текущей реализации `onSelectOffer` в MarketplaceCatalogPage — это
// заглушка: клик по «Заказать» вызывает Quasar Notify с сообщением
// «Создание заказа на Offer X — будет доступно после Эпика 4». Полный
// диалог формы заказа (количество + ПВЗ доставки + подпись Membership)
// подключается следующим UI-PR'ом Эпика 4.
// Сценарий: orderer-стол «Оформление заказа».
// MVP-форма — q-dialog поверх каталога: при клике «Заказать» открывается
// OrderCreateDialog с полями quantity + delivery_braname; submit вызывает
// `marketplaceCreateOrder`. По успеху — Notify «Заказ создан», каталог
// перезагружается, остаток APPROVED Offer'а уменьшается.
import fs from 'node:fs';
import path from 'node:path';
@@ -18,7 +17,7 @@ const loadFixture = (username) =>
);
export const meta = {
title: 'Стол заказчика — оформление заказа (в разработке)',
title: 'Стол заказчика — оформление заказа',
docPath: 'new/marketplace/orderer/order-create.md',
assetsDir: 'assets/new/marketplace/orderer/order-create',
role: 'user',
@@ -71,24 +70,42 @@ export default async ({ page, shot }) => {
await page.waitForTimeout(1500);
await cleanViteOverlays(page);
// Клик «Заказать» на первой карточке — текущая реализация показывает Notify.
const orderBtn = page.locator('button:has-text("Заказать")').first();
const hasOrderBtn = await orderBtn.count();
if (!hasOrderBtn) {
if (!(await orderBtn.count())) {
throw new Error('В каталоге нет CatalogOfferCard с действием «Заказать» — нужен хотя бы один APPROVED offer (chairman/offer-moderation).');
}
await orderBtn.click();
// Ждём появления Quasar Notify (.q-notification) с info-сообщением.
// cleanViteOverlays удаляет .q-notification (см. harness.mjs:320) — поэтому
// НЕ вызываем его перед shot, иначе тост уйдёт за миг до снимка.
await page.locator('.q-notification').first().waitFor({ state: 'visible', timeout: 5000 }).catch(() => {});
await page.waitForTimeout(300);
const dialog = page.locator('.mp-order-create-dialog').first();
await dialog.waitFor({ state: 'visible', timeout: 5000 });
await page.waitForTimeout(400);
await cleanViteOverlays(page);
await shot(
page,
'01-order-create-stub-notify',
`Текущая реализация «Заказать» — заглушка Story 4.1: при клике на карточку offer'а появляется уведомление «Создание заказа на Offer X — будет доступно после Эпика 4». URL: \`${page.url()}\`. Полный диалог формы заказа (количество, ПВЗ доставки, подпись Membership-заявки) подключается следующим UI-PR'ом Эпика 4.`,
'01-order-create-dialog',
'Диалог оформления заказа: q-input «Количество», q-select «ПВЗ доставки», итоговая сумма (цена × количество), кнопки «Отмена» и «Подтвердить заказ». Открывается при клике «Заказать» на карточке APPROVED offer\'а в каталоге.',
);
const qty = dialog.locator('input[type="number"]').first();
await qty.click({ clickCount: 3 });
await qty.fill('2');
await page.waitForTimeout(300);
await cleanViteOverlays(page);
await shot(
page,
'02-order-create-filled',
'Форма с количеством 2 и автоматически выбранным ПВЗ доставки. Итоговая сумма обновляется немедленно (price_per_unit × quantity). Кнопка «Подтвердить заказ» становится активной, когда оба поля валидны.',
);
const confirmBtn = dialog.locator('button:has-text("Подтвердить заказ")').first();
await confirmBtn.click();
await page.locator('.q-notification').first().waitFor({ state: 'visible', timeout: 10000 }).catch(() => {});
await page.waitForTimeout(600);
await shot(
page,
'03-order-create-success',
'Уведомление «Заказ создан» после успешного submit. Диалог закрывается, каталог перезагружается с обновлённым остатком offer\'а. Order появляется в ленте «Мои заказы» в статусе CREATED.',
{ preserveNotifications: true },
);
};
@@ -7,44 +7,43 @@ tags:
Когда пайщик нашёл нужное Предложение в [каталоге][catalog], он переходит к оформлению Заказа.
!!!warning "Story 4.1 — в разработке"
На текущем MVP-стенде форма оформления Заказа ещё не подключена к UI: при клике на кнопку «Заказать» на карточке предложения появляется временное уведомление **«Создание заказа на Offer ... — будет доступно после Эпика 4»**. Описание ниже отражает целевое поведение, которое будет доступно после завершения Эпика 4.
![Диалог оформления заказа поверх каталога](/assets/new/marketplace/orderer/order-create/01-order-create-dialog.png)
![Текущая заглушка: при клике «Заказать» показывается Quasar-уведомление о том, что функционал в разработке](/assets/new/marketplace/orderer/order-create/01-order-create-stub-notify.png)
Оформление работает прямо из каталога: клик «Заказать» на карточке APPROVED Предложения открывает диалог формы — отдельная страница для оформления не нужна.
## Что нужно ввести
На экране оформления заказа три блока:
В диалоге два поля:
1. **Параметры заказа**:
- Количество единиц (целое число; нижняя и верхняя границы заданы поставщиком в Предложении).
- Пункт выдачи — поставщик мог разрешить несколько ПВЗ или зафиксировать один. Если несколько — выбор из списка кооперативных участков.
- Срок ожидания партии — рассчитывается автоматически на основании типа цикла Предложения и текущего наполнения партии.
2. **Расчёт суммы**:
- Цена за единицу × количество = сумма Заказа.
- Комиссия кооператива (фиксированный процент или абсолютная сумма, заданные в положении ЦПП) — отображается отдельной строкой.
- Итоговая сумма к оплате.
3. **Источник средств**:
- На сегодня в MVP используется программный кошелёк Стола заказов (паевой взнос на конкретную закупку).
- Если на программном кошельке не хватает средств — пайщик докладывает с главного Кошелька (отдельная операция, оформляется заявлением на паевой взнос в адрес программы Стола заказов).
- **Количество** — целое число в единицах поставки (`шт`, `кг`, `л`, `упак` — берётся из Предложения). Для Предложений с ограниченным остатком сверху срабатывает граница «Доступно: N» — больше выбрать нельзя; для безлимитных — без верхней границы. Минимум — 1.
- **ПВЗ доставки** — кооперативный участок, куда пайщик хочет получить Заказ. В список попадают все активные ПВЗ кооператива. Если в кооперативе один ПВЗ — он выбран по умолчанию.
## Подпись паевого документа
![Форма с количеством 2 — итоговая сумма обновляется немедленно](/assets/new/marketplace/orderer/order-create/02-order-create-filled.png)
После заполнения параметров формируется **акт о паевом взносе пайщика на закупку** (документ из реестра, registry_id в категории актов приёма-передачи). В превью пайщик видит финальный текст с собственными ФИО, реквизитами, суммой и реквизитами Заказа.
В нижней части диалога — расчёт суммы:
Подпись — стандартная ЭП через приватный ключ. После подписи Заказ попадает в очередь к поставщику в статусе **NEW**.
- Цена за единицу (из Предложения).
- Итого = `цена × количество`. Пересчитывается на каждое изменение количества.
## Что происходит дальше
Кнопка **«Подтвердить заказ»** становится активной, когда оба поля валидны.
После подписи акта:
## Что происходит на сабмите
- Заказ виден в [Моих заказах][orders] со статусом «Создан».
- Поставщик увидит Заказ в своём столе во [Входящих заказах][incoming], где может принять его (accept) или отклонить (decline).
- Когда наберётся минимальный объём партии (для volume-based) или закроется цикл (для time-based), Заказ автоматически включается в [Сводный заказ][consolidated] и уходит поставщику в работу.
При нажатии «Подтвердить заказ» вызывается мутация `marketplaceCreateOrder`. Backend:
## Отмена и редактирование
1. Проверяет, что пайщик — член кооператива со статусом, разрешающим оформление заказов (Membership-guard). Если пайщик ещё не подписал ЦПП Стола заказов, придёт ошибка — её показывает поверх диалога.
2. Создаёт Order в статусе **CREATED** в Postgres, привязанный к Offer'у и ПВЗ.
3. Уменьшает `quantity_available` у Предложения на количество Заказа (для не-безлимитных).
В статусе «Создан» пайщик может отменить Заказ из ленты «Мои заказы». Возвращённые средства автоматически попадают обратно на программный кошелёк. После того как Заказ принят поставщиком, отмена возможна только через возврат имущества — см. [Возврат][return].
![Уведомление «Заказ создан» после успешного submit](/assets/new/marketplace/orderer/order-create/03-order-create-success.png)
По успеху диалог закрывается, каталог перезагружается с обновлённым остатком, и появляется уведомление **«Заказ создан»**. Order попадает в ленту [«Мои заказы»][orders] в статусе CREATED.
## Что дальше
- Поставщик увидит Заказ в столе [Входящих заказов][incoming] — может акцептовать или отказать.
- При наборе минимального объёма или закрытии цикла Заказ автоматически попадает в [Сводный заказ][consolidated] и уходит поставщику в работу.
- До приёма Заказа поставщиком пайщик может отменить его из ленты «Мои заказы». После приёма — только через [возврат][return].
[catalog]: ./catalog.md
[orders]: ./orders.md