[598-33][@ant] feat: внести забытый фронт-WIP выдачи/склада и удаление «Сводного стола КУ» — собрать в одном PR #58 для совместного ревью и теста

This commit is contained in:
ant
2026-06-02 10:44:20 +00:00
parent c00f1920c4
commit 7a92f1a47e
20 changed files with 832 additions and 769 deletions
+18 -14
View File
@@ -218,26 +218,30 @@ await sendPOST('/v1/graphql', { query: QUERY, variables });
**ПРИ ЛЮБОЙ ВЁРСТКЕ В `components/desktop/` НЕ ВЫДУМЫВАТЬ СТИЛИ.** Всегда сначала свериться с каноном. Не строй гипотез о цветах/радиусах/типографике/паттернах из памяти — иди и читай.
**Два источника правды (именно в этом порядке):**
**Полная спецификация канона — в skill `/mono-desktop-canon`** (правила обёрток, цвета/токены, иконки, структура страницы, stop-signals). Здесь — короткая выжимка; при расхождении побеждает skill.
1. **HTML-канон дизайн-системы** — единственный SoT для визуала (цвета, токены, радиусы, тени, типографика, патерны компонентов):
**Источник истины — в самом репозитории** (НЕ внешние HTML/прототипы):
`/home/admin/blago/production/shared/MONO Design System.html`
| Что | Путь |
|---|---|
| Токены палитры/типографики/spacing/radii (`--p-*`) | `components/desktop/src/css/mono-platform/tokens.css` |
| Прототип-классы компонентов + typography-утилиты `.t-*` | `components/desktop/src/css/mono-platform/components.css` |
| Оверрайды Quasar под канон | `components/desktop/src/css/mono-platform/quasar-canon.css` |
| Минимальная SCSS-палитра Quasar (`$primary`/`$dark`) | `components/desktop/src/css/quasar.variables.scss` |
| Обёртки / доменные блоки / layout | `components/desktop/src/shared/ui/{base,domain,layout}/` |
| **Живой эталон** (`/_dev/ui` в dev-сборке) | `components/desktop/src/pages/_dev/ui/index.vue` |
Это самодостаточный HTML с всеми CSS-токенами `--p-*`, образцами компонентов (`.base-button`, `.base-card`, `.toast`, `.chip`, `.banner` и т.д.), цветовой палитрой и demo-блоками. **При сомнении в каноне — открыть этот файл и читать.** Не путать с `auth-prototype/index.html`, не путать с `ux-design-specification.md` — они НЕ канон.
2. **Живая реализация канона** в самом репозитории — `/_dev/ui` страница:
`components/desktop/src/pages/_dev/ui/index.vue`
Это уже заверстанная по канону witness-страница со всеми base-/widget-компонентами в действии (BaseButton, BaseInput, BaseCard, BaseChip, EmptyState, Toasts, AppHeader/AppDrawer фрагменты, WalletCard, IdentityPanel, DocumentRow, SignatureCard, ActivityTimeline, KpiCard, VerticalStepper и т.д.). **Когда делаешь новый компонент — сначала посмотри как уже сверстаны соседние в этой странице, используй те же токены / классы / структуру.** Эту страницу можно открыть в браузере (`/_dev/ui` в dev-сборке) — она же служит и для визуальной верификации.
**При сомнении — открыть `tokens.css` и `_dev/ui/index.vue`, смотреть как сделано там.** Внешний `shared/MONO Design System.html` и `auth-prototype/` каноном НЕ являются — это устаревшие прототипы.
**Запреты:**
- Никаких magic-цветов в hex/rgb прямо в `<style>` Vue-файла — только токены `var(--p-*)` (см. `components/desktop/src/css/mono-platform/*.css`).
- Никаких локальных переопределений `.q-btn`/`.q-card`/`.q-notification` в feature-файлах — все Quasar-overrides централизованно в `components/desktop/src/css/mono-platform/quasar-canon.css`.
- Никаких ад-хок размеров — отступы/радиусы/typescale из токенов (`--p-1`..`--p-12`, `--p-r-sm/md/lg`, `--p-fs-h1..body-sm`).
- Экран собирается из готовых компонентов: `shared/ui/base` (вместо сырых `q-input`/`q-btn`/`q-card`/`q-table`/`q-chip`/`q-dialog`/`q-select`), `shared/ui/domain` (WalletCard, DataRow, DocumentRow, IdentityPanel…), `shared/ui/layout` (PageHead, PageTabs, AppHeader/AppDrawer). Голый Quasar — только где обёртки нет (`q-icon`, `q-toggle`, `q-list`, `q-menu`, `q-tooltip`, `q-tabs`, `q-separator`, `q-spinner`, `q-inner-loading`…). Props обёрток не угадывать — читать `*.types.ts` рядом.
- Цвет — только токены `var(--p-*)` (поверхности `--p-surface*`, текст `--p-ink*`, линии `--p-line*`, акцент `--p-primary`, статусы `--p-pos/neg/warn/info`) либо utility-классы/color-props. Никаких сырых hex/rgb. Темы light/dark переключаются через `[data-theme]` на `<html>` — токены следуют сами.
- Spacing/радиусы/типографика — токены `--p-1..--p-10` (4px…72px), `--p-r-sm/md/lg/xl`, `--p-fs-*`/`--p-lh-*` либо классы `.t-*`. Без хардкод-px.
- Иконки — `q-icon(name='…')` именами Material Icons. **FontAwesome (`fa-*`) запрещён** — заменять на Material-эквивалент попутно.
- Запрещённые/удаляемые токены: `--mp-*` (старый `marketplace-tokens.scss`), `--tr-*`, `--ds-*`. Легитимны только `--p-*`, `--q-*` (Quasar автоген), `--prog-*`.
- Никаких локальных переопределений `.q-btn`/`.q-card`/`.q-notification` в feature-файлах — Quasar-overrides централизованы в `quasar-canon.css`.
**Кейс 2026-05-28:** после компакта забыл, что shared/MONO Design System.html — основной SoT, и нашёл «канон» в auth-prototype, который НЕ канон; перевёрстал тосты под нейтральный фон вместо тёмного, пришлось переделывать.
**Кейс 2026-05-28:** при сомнении в каноне пошёл искать его во внешнем HTML/auth-prototype вместо репозитория — перевёрстал не туда, переделывал. SoT — репо (`tokens.css` + `_dev/ui`) и skill `/mono-desktop-canon`, не внешние файлы.
## Frontend desktop — English имена
+15 -28
View File
@@ -25,7 +25,6 @@ import { OnboardingCoopAcceptCppPage } from 'src/pages/Marketplace/OnboardingCoo
import { OrdererConsolidatedPage } from 'src/pages/Marketplace/OrdererConsolidated'
import { OffererIncomingOrdersPage } from 'src/pages/Marketplace/OffererIncomingOrders'
import { OffererMyOffersPage } from 'src/pages/Marketplace/OffererMyOffers'
import { BranchChairmanBranchOrdersPage } from 'src/pages/Marketplace/BranchChairmanBranchOrders'
import { BoardAgendaWriteoffPage } from 'src/pages/Marketplace/BoardAgendaWriteoff'
import { ChairmanCategoryWhitelistPage } from 'src/pages/Marketplace/ChairmanCategoryWhitelist'
import { BoardPayoutsReadonlyPage } from 'src/pages/Marketplace/BoardPayoutsReadonly'
@@ -445,6 +444,21 @@ export default async function (): Promise<IWorkspaceConfig[]> {
agreements: agreementsBase,
},
},
{
// Эпик 9 / Story 9.1: operator-стол «Склад моего КУ». Идёт сразу
// после раскладки/маркировки — принятое имущество разложено и
// видно на складе перед выдачей.
path: 'warehouse',
name: 'marketplace-pvz-warehouse',
component: markRaw(OperatorOwnWarehousePage),
meta: {
title: 'Склад моего КУ',
icon: 'fa-solid fa-boxes-stacked',
requires: 'Warehouse:read:own-KU',
requiresAuth: true,
agreements: agreementsBase,
},
},
{
// Эпик 6 / Story 6.6: operator-стол выдачи имущества пайщику на ПВЗ.
path: 'issuance',
@@ -471,33 +485,6 @@ export default async function (): Promise<IWorkspaceConfig[]> {
agreements: agreementsBase,
},
},
{
// Эпик 6 / Story 6.x: «Сводный стол КУ» — приёмки/выдачи/возвраты
// в одном экране с табами и счётчиками для председателя КУ.
path: 'branch-orders',
name: 'marketplace-pvz-branch-orders',
component: markRaw(BranchChairmanBranchOrdersPage),
meta: {
title: 'Сводный стол КУ',
icon: 'fa-solid fa-list-check',
requires: 'Warehouse:read:own-KU',
requiresAuth: true,
agreements: agreementsBase,
},
},
{
// Эпик 9 / Story 9.1: operator-стол «Склад моего КУ».
path: 'warehouse',
name: 'marketplace-pvz-warehouse',
component: markRaw(OperatorOwnWarehousePage),
meta: {
title: 'Склад моего КУ',
icon: 'fa-solid fa-boxes-stacked',
requires: 'Warehouse:read:own-KU',
requiresAuth: true,
agreements: agreementsBase,
},
},
{
// Управление доверенными лицами КУ: председатель участка (trustee)
// добавляет/снимает доверенных (core addTrustedAccount/
@@ -1 +0,0 @@
export { default as BranchChairmanBranchOrdersPage } from './ui/BranchChairmanBranchOrdersPage.vue';
@@ -1,248 +0,0 @@
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { FailAlert } from 'src/shared/api';
import { OperatorBranchBar, useOperatorBranchStore } from 'src/entities/OperatorBranch';
import { BaseButton, EmptyState } from 'src/shared/ui/base';
import { PageHint } from 'src/shared/ui/domain';
import { listAplReceptionsByBraname } from '../../OperatorReception/api';
import { listIssuancesByBraname } from '../../OperatorIssuance/api';
import { listReturnClaimsByBraname } from '../../OperatorReturnClaims/api';
import type { MarketplaceAplReceptionView } from '../../OffererPendingAplReceptions/api';
import type { MarketplaceOrderIssuanceView } from '../../OperatorIssuance/api';
import type { MarketplaceReturnClaimView } from '../../OperatorReturnClaims/api';
/**
* Эпик 6 / Story 6.x: «Сводный стол КУ» председателя кооперативного участка.
*
* Объединяет три существующих ленты по braname в один экран с табами:
* - Приёмки партий (ListAplReceptionsByBraname, Эпик 5)
* - Выдачи заказов (ListIssuancesByBraname, Эпик 6)
* - Гарантийные возвраты (ListReturnClaimsByBraname, Эпик 7)
*
* Действия (открыть акт приёмки, выдачу, решение по возврату) выполняются
* на соответствующих специализированных страницах /market-pvz/reception,
* /issuance, /returns — здесь только сводный read-обзор для председателя КУ.
*
* Активный КУ берётся из общего контекста оператора (entities/OperatorBranch);
* служебные коды участков в UI не показываются.
*/
const POLL_INTERVAL_MS = 20_000;
// Активный КУ оператора — из общего контекста стола (без ввода кода вручную).
const route = useRoute();
const store = useOperatorBranchStore();
const coopname = computed(() => String(route.params.coopname ?? ''));
const braname = computed(() => store.activeBraname ?? '');
const activeTab = ref<'receptions' | 'issuances' | 'returns'>('receptions');
const receptions = ref<MarketplaceAplReceptionView[]>([]);
const issuances = ref<MarketplaceOrderIssuanceView[]>([]);
const returns = ref<MarketplaceReturnClaimView[]>([]);
const loading = ref(false);
let pollTimer: ReturnType<typeof setInterval> | null = null;
const totals = computed(() => ({
receptions: receptions.value.length,
issuances: issuances.value.length,
returns: returns.value.length,
}));
const hasBranch = computed(() => braname.value.trim().length > 0);
const STATUS_LABEL: Record<string, string> = {
// приёмка партии
PENDING_SUPPLIER_SIGN: 'Ждёт подписи поставщика',
PENDING_CHAIRMAN_RECEPTION_SIGN: 'Ждёт подписи председателя',
// заказ / выдача
ACTIVE: 'Ждёт цикла / решения',
ACCEPTED_PENDING_SUPPLIER: 'Ждёт поставщика',
ACCEPTED_PENDING_SUPPLIER_INDIVIDUAL: 'Ждёт поставщика',
ACCEPTED: 'Принят поставщиком',
SUPPLY_PREPARED: 'Поставка готовится',
ACCEPTED_TO_COOP: 'Принят кооперативом',
READY_TO_RECEIVE: 'Готов к выдаче',
RECEIVED: 'Получен',
RETURNED: 'Возвращён',
CANCELLED: 'Отменён',
CANCELLED_BY_ORDERER: 'Отменён заказчиком',
CANCELLED_BY_SUPPLIER: 'Отменён поставщиком',
// гарантийный возврат
PENDING_CHAIRMAN_REVIEW: 'Ждёт удалённого рассмотрения',
APPROVED_FOR_VISIT: 'Очный визит одобрен',
ACCEPTED_AT_VISIT: 'Возврат принят',
REJECTED_REMOTELY: 'Отказано удалённо',
REJECTED_AT_VISIT: 'Отказано на месте',
};
function statusLabel(v?: string | null): string {
return v ? (STATUS_LABEL[v] ?? v) : '—';
}
async function loadAll(): Promise<void> {
const name = braname.value.trim();
if (!name) return;
loading.value = true;
try {
const [r, i, ret] = await Promise.all([
listAplReceptionsByBraname({ braname: name }),
listIssuancesByBraname({ delivery_braname: name }),
listReturnClaimsByBraname({ delivery_braname: name }),
]);
receptions.value = r;
issuances.value = i;
returns.value = ret;
} catch (e) {
FailAlert(e);
} finally {
loading.value = false;
}
}
watch(braname, () => {
receptions.value = [];
issuances.value = [];
returns.value = [];
void loadAll();
});
onMounted(async () => {
await store.ensureLoaded(coopname.value);
void loadAll();
pollTimer = setInterval(() => {
if (hasBranch.value) void loadAll();
}, POLL_INTERVAL_MS);
});
onUnmounted(() => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
});
function formatDate(value: string | null | undefined): string {
if (!value) return '—';
const d = new Date(value);
return Number.isNaN(d.getTime()) ? String(value) : d.toLocaleString('ru-RU');
}
</script>
<template lang="pug">
q-page.orders(role='region', aria-label='Сводный стол участка')
OperatorBranchBar
EmptyState(
v-if='!store.loading && !store.isOperator',
title='Вы не оператор кооперативного участка',
body='Сводный стол доступен председателю участка и его доверенным лицам.'
)
template(#icon)
q-icon(name='storefront', size='48px')
template(v-else)
PageHint(storage-key='mp:branch-orders:banner-dismissed')
| Все процессы вашего пункта выдачи в одном месте: приёмки партий, выдачи пайщикам,
| гарантийные возвраты. Для действий переходите на специализированные столы.
.orders__toolbar
q-space
BaseButton(variant='ghost', :loading='loading', @click='loadAll')
template(#icon-left)
q-icon(name='refresh', size='18px')
| Обновить
.orders__card
q-tabs(
v-model='activeTab',
inline-label,
align='left',
no-caps,
dense,
indicator-color='primary',
active-color='primary'
)
q-tab(name='receptions', icon='local_shipping')
| Приёмки
q-badge.q-ml-sm(color='primary', :label='String(totals.receptions)')
q-tab(name='issuances', icon='handshake')
| Выдачи
q-badge.q-ml-sm(color='primary', :label='String(totals.issuances)')
q-tab(name='returns', icon='assignment_return')
| Возвраты
q-badge.q-ml-sm(color='primary', :label='String(totals.returns)')
q-separator
q-tab-panels(v-model='activeTab', animated)
q-tab-panel(name='receptions')
EmptyState(
v-if='!loading && receptions.length === 0',
title='Нет активных приёмок',
body='Подтверждённые поставщиком партии появятся здесь.'
)
template(#icon)
q-icon(name='inbox', size='48px')
q-list(v-else, separator)
q-item(v-for='r in receptions', :key='r.id')
q-item-section
q-item-label {{ r.id.slice(0, 8) }} партия {{ r.shipment_id?.slice?.(0, 8) ?? '—' }}
q-item-label(caption) Создана: {{ formatDate(r.created_at) }} / Статус: {{ statusLabel(r.status) }}
q-tab-panel(name='issuances')
EmptyState(
v-if='!loading && issuances.length === 0',
title='Нет заказов на выдачу',
body='Заказы, принятые кооперативом и готовые к выдаче, появятся здесь после маркировки на участке.'
)
template(#icon)
q-icon(name='inventory', size='48px')
q-list(v-else, separator)
q-item(v-for='o in issuances', :key='o.id')
q-item-section
q-item-label {{ o.id.slice(0, 8) }} {{ o.orderer_account }}
q-item-label(caption)
| Кол-во: {{ o.quantity }} ед. / Статус: {{ statusLabel(o.status) }} / Создан: {{ formatDate(o.created_at) }}
q-tab-panel(name='returns')
EmptyState(
v-if='!loading && returns.length === 0',
title='Нет заявлений на возврат',
body='Заявления пайщиков в гарантийный период появятся здесь.'
)
template(#icon)
q-icon(name='assignment_return', size='48px')
q-list(v-else, separator)
q-item(v-for='c in returns', :key='c.id')
q-item-section
q-item-label {{ c.id.slice(0, 8) }} {{ c.orderer_account }}
q-item-label(caption) Статус: {{ statusLabel(c.status) }} / Создано: {{ formatDate(c.created_at) }}
</template>
<style scoped lang="scss">
.orders {
padding: var(--p-6, 24px);
display: flex;
flex-direction: column;
gap: var(--p-4, 16px);
&__toolbar {
display: flex;
align-items: center;
gap: var(--p-3, 12px);
}
&__card {
border: 1px solid var(--p-line);
border-radius: var(--p-r-md, 12px);
background: var(--p-surface);
overflow: hidden;
}
}
@media (max-width: 768px) {
.orders {
padding: var(--p-4, 16px);
}
}
</style>
@@ -246,10 +246,9 @@ q-page.catalog(role="region", aria-label="Каталог Стола заказо
<style scoped lang="scss">
.catalog {
// Меню категорий (PageTabs) — первый блок страницы и должно прижиматься к
// топбару как саб-навигация: верхний отступ страницы гасим (иначе лишний
// зазор «висит» над меню). Контент ниже разводит flex-gap.
padding: 0 var(--p-6, 24px) var(--p-6, 24px);
// Воздух сверху как на столе поставщика — единый канон столов: контент
// (меню/баннер) отделяется от топбара. Контент ниже разводит flex-gap.
padding: var(--p-6, 24px);
display: flex;
flex-direction: column;
gap: var(--p-4, 16px);
@@ -271,7 +270,7 @@ q-page.catalog(role="region", aria-label="Каталог Стола заказо
@media (max-width: 768px) {
.catalog {
padding: 0 var(--p-4, 16px) var(--p-4, 16px);
padding: var(--p-4, 16px);
}
}
</style>
@@ -45,9 +45,10 @@ const activeKey = ref('all');
const hasMore = computed(() => currentPage.value < totalPages.value);
// Финальная подпись получения — диалог прямо из карточки заказа.
// Финальная подпись получения — диалог прямо из карточки заказа, СВОДНЫЙ по
// всем готовым позициям пункта выдачи (пайщик подтверждает получение разом).
const finalizeDialogOpen = ref(false);
const selectedOrder = ref<MarketplaceOrderView | null>(null);
const selectedOrders = ref<MarketplaceOrderView[]>([]);
// Код получения (account-bound QR) — диалогом из шапки, в одном месте.
const receiveDialogOpen = ref(false);
@@ -149,7 +150,13 @@ function confirmCancel(order: MarketplaceOrderView): void {
}
function startFinalize(order: MarketplaceOrderView): void {
selectedOrder.value = order;
// Сводим все готовые к выдаче позиции этого же пункта — пайщик подтверждает
// получение разом, одной подписью по каждой (циклом), не по одной кнопке на
// позицию.
const siblings = items.value.filter(
(o) => o.status === 'READY_TO_RECEIVE' && o.delivery_braname === order.delivery_braname,
);
selectedOrders.value = siblings.length ? siblings : [order];
finalizeDialogOpen.value = true;
}
@@ -232,7 +239,7 @@ q-page.orders(role="region", aria-label="Мои заказы")
OrdererFinalizeIssuanceDialog(
v-model="finalizeDialogOpen",
:order="selectedOrder",
:orders="selectedOrders",
@finalized="onFinalized"
)
@@ -241,9 +248,9 @@ q-page.orders(role="region", aria-label="Мои заказы")
<style scoped lang="scss">
.orders {
// Меню-вкладки (PageTabs) прижимаются к топбару — гасим верхний отступ
// страницы; контент ниже разводит flex-gap.
padding: 0 var(--p-6, 24px) var(--p-6, 24px);
// Воздух сверху как на столе поставщика — единый канон столов. Контент
// ниже разводит flex-gap.
padding: var(--p-6, 24px);
display: flex;
flex-direction: column;
gap: var(--p-4, 16px);
@@ -269,7 +276,7 @@ q-page.orders(role="region", aria-label="Мои заказы")
@media (max-width: 768px) {
.orders {
padding: 0 var(--p-4, 16px) var(--p-4, 16px);
padding: var(--p-4, 16px);
}
}
</style>
@@ -3,7 +3,8 @@ import { computed, ref, watch } from 'vue';
import { Classes } from '@coopenomics/sdk';
import { useGlobalStore } from 'src/shared/store';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { BaseButton, BaseDialog } from 'src/shared/ui/base';
import { BaseBadge, BaseButton, BaseDialog } from 'src/shared/ui/base';
import type { BaseBadgeVariant } from 'src/shared/ui/base';
import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits';
import { marketplaceUnitShort } from 'src/shared/lib/consts/marketplace-units';
import {
@@ -13,22 +14,21 @@ import {
} from '../api';
/**
* Story 6.3: финальная подпись заказчика — подтверждение получения имущества.
* Story 6.3: финальная подпись заказчика — подтверждение получения имущества,
* СВЕДЁННОЕ ПО ВСЕМ ПОЗИЦИЯМ ПУНКТА ВЫДАЧИ. Пайщик видит все готовые к выдаче
* позиции в одной сводной таблице и подтверждает получение ОДНОЙ операцией —
* не жмёт по каждой позиции отдельно.
*
* Заказчик подтверждает получение сам в своём кабинете на своём устройстве
* своим ключом. Он не редактирует факт: количество и стоимость уже
* зафиксированы оператором при открытии выдачи и показаны в таблице только
* для ознакомления. Заказчик может посмотреть акт и поставить финальную
* подпись (signatureId=2) поверх подписи председателя — имущество переходит
* к нему, backend исполняет корректирующие операции (`signiss2`).
*
* Диалог вызывается из карточки заказа «Моих заказов» по действию
* «Подписать и получить» для статуса READY_TO_RECEIVE.
* Факт он не редактирует: количество и стоимость зафиксированы оператором при
* открытии выдачи и показаны только для ознакомления. По кнопке UI ЦИКЛОМ по
* всем позициям накладывает финальную подпись (signatureId=2) поверх подписи
* председателя — документ не перегенерируется, backend исполняет
* корректирующие операции (`signiss2`). Каждая позиция = свой акт.
*/
const props = defineProps<{
modelValue: boolean;
order: MarketplaceOrderIssuanceView | null;
orders: MarketplaceOrderIssuanceView[];
}>();
const emit = defineEmits<{
@@ -41,24 +41,41 @@ const signing = ref(false);
const previewHtml = ref<string>('');
const previewLoading = ref(false);
const unitShort = computed(() =>
props.order ? marketplaceUnitShort(props.order.unit_of_measure) : 'ед.',
);
// Факт зафиксирован оператором при открытии выдачи — берём из заказа.
const factQuantity = computed<number>(() => props.order?.issuance_fact?.actual_quantity ?? props.order?.quantity ?? 0);
const factCost = computed<string>(() => props.order?.issuance_fact?.fact_cost ?? props.order?.total_cost ?? '0');
const diffState = computed<string>(() => props.order?.issuance_fact?.diff_state ?? 'equal');
const diffHint = computed<string>(() => {
if (!props.order) return '';
if (diffState.value === 'equal') return 'Выдаётся ровно по заказу.';
if (diffState.value === 'less') return 'Выдаётся меньше заказа — разница вернётся на ваш паевой остаток.';
return 'Выдаётся больше заказа — разница спишется доплатой с вашего паевого остатка.';
const pointLabel = computed<string>(() => {
const o = props.orders[0];
if (!o) return '';
return (
[o.delivery_point_name, o.delivery_point_address].filter(Boolean).join(' · ') ||
o.delivery_braname
);
});
function unitShort(o: MarketplaceOrderIssuanceView): string {
return marketplaceUnitShort(o.unit_of_measure);
}
function factQty(o: MarketplaceOrderIssuanceView): number {
return o.issuance_fact?.actual_quantity ?? o.quantity ?? 0;
}
function factCost(o: MarketplaceOrderIssuanceView): string {
return o.issuance_fact?.fact_cost ?? o.total_cost ?? '0';
}
// Совокупная сумма к получению по всем позициям.
const totalFactCost = computed<string>(() =>
props.orders.reduce((sum, o) => sum + Number.parseFloat(factCost(o)), 0).toFixed(4),
);
const DIFF_BADGE: Record<string, { label: string; variant: BaseBadgeVariant }> = {
equal: { label: 'по заказу', variant: 'pos' },
less: { label: 'недостача', variant: 'warn' },
more: { label: 'избыток', variant: 'info' },
};
function diffBadge(o: MarketplaceOrderIssuanceView): { label: string; variant: BaseBadgeVariant } {
return DIFF_BADGE[o.issuance_fact?.diff_state ?? 'equal'] ?? DIFF_BADGE.equal;
}
watch(
() => [props.modelValue, props.order?.id],
() => [props.modelValue, props.orders.map((o) => o.id).join(',')],
([visible]) => {
if (visible) previewHtml.value = '';
},
@@ -66,46 +83,64 @@ watch(
);
async function loadPreview(): Promise<void> {
if (!props.order) return;
if (!props.orders.length) return;
previewLoading.value = true;
try {
const aggregate = await getOrdererSignablePayload({ order_id: props.order.id });
previewHtml.value = aggregate.rawDocument.html;
const parts: string[] = [];
for (const o of props.orders) {
const aggregate = await getOrdererSignablePayload({ order_id: o.id });
const title = o.product_name || 'Товар по предложению';
parts.push(`<h4 class="mp-orderer-finalize__act-head">${title}</h4>${aggregate.rawDocument.html}`);
}
previewHtml.value = parts.join('<hr class="mp-orderer-finalize__act-sep" />');
} catch (e) {
FailAlert(e, 'Не удалось загрузить акт выдачи');
FailAlert(e, 'Не удалось загрузить акты выдачи');
} finally {
previewLoading.value = false;
}
}
async function confirm(): Promise<void> {
if (!props.order) return;
if (!props.orders.length) return;
const wif = globalStore.wif?.toString();
if (!wif) {
FailAlert(new Error('Приватный ключ не найден. Войдите в кооператив.'));
return;
}
signing.value = true;
try {
// Backend отдаёт акт, уже подписанный председателем при открытии выдачи
// (signatureId=1). Заказчик накладывает финальную подпись (signatureId=2)
// поверх — документ не перегенерируется.
const aggregate = await getOrdererSignablePayload({ order_id: props.order.id });
const signer = new Classes.Document(wif);
const fullSigned = await signer.signDocument(
aggregate.rawDocument,
globalStore.username,
2,
[aggregate.document],
);
await finalizeIssuance({ order_id: props.order.id, signed_document: fullSigned });
SuccessAlert('Имущество получено. Заказ закрыт.');
emit('finalized');
const signer = new Classes.Document(wif);
const failed: string[] = [];
let ok = 0;
// Цикл по позициям: на каждый акт накладываем финальную подпись (2) поверх
// подписи председателя. Последовательно — без гонок on-chain подписей.
for (const o of props.orders) {
try {
const aggregate = await getOrdererSignablePayload({ order_id: o.id });
const fullSigned = await signer.signDocument(
aggregate.rawDocument,
globalStore.username,
2,
[aggregate.document],
);
await finalizeIssuance({ order_id: o.id, signed_document: fullSigned });
ok += 1;
} catch (e) {
console.error('finalizeIssuance failed for order', o.id, e);
failed.push(o.product_name || o.id.slice(0, 8));
}
}
signing.value = false;
if (ok > 0) emit('finalized');
if (failed.length === 0) {
SuccessAlert(`Имущество получено по ${ok} позиц. Заказы закрыты.`);
emit('update:modelValue', false);
} catch (e) {
FailAlert(e, 'Не удалось подтвердить получение');
} finally {
signing.value = false;
} else {
FailAlert(
new Error(
`Получено ${ok} из ${props.orders.length}. Не удалось: ${failed.join(', ')}. Повторите по оставшимся.`,
),
);
}
}
@@ -121,45 +156,53 @@ BaseDialog(
maximized
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
)
.mp-orderer-finalize(v-if="order")
.text-caption.text-grey
| Пункт выдачи: {{ [order.delivery_point_name, order.delivery_point_address].filter(Boolean).join(' · ') || order.delivery_braname }}
.mp-orderer-finalize(v-if="orders.length")
.mp-orderer-finalize__point
q-icon(name="place" size="16px")
| {{ pointLabel }}
.mp-orderer-finalize__title.text-subtitle1
| {{ order.product_name || 'Товар по предложению' }}
.mp-orderer-finalize__intro
| Сверьте полученное имущество. Количество и стоимость зафиксированы при
| открытии выдачи подтвердите получение по всем позициям одной подписью.
table.mp-orderer-finalize__table
thead
tr
th
th.text-right Заказ
th.text-right К получению
tbody
tr
td Количество
td.text-right {{ order.quantity }} {{ unitShort }}
td.text-right {{ factQuantity }} {{ unitShort }}
tr
td Сумма
td.text-right {{ formatAsset2Digits(order.total_cost) }}
td.text-right {{ formatAsset2Digits(factCost) }}
.table-wrap
.table-scroll
table.table
thead
tr
th Позиция
th.col-num Заказ
th.col-num К получению
th.col-num Сумма
th.col-state Итог
tbody
tr(v-for="o in orders", :key="o.id")
td.mp-orderer-finalize__name {{ o.product_name || 'Товар по предложению' }}
td.col-num {{ o.quantity }} {{ unitShort(o) }}
td.col-num {{ factQty(o) }} {{ unitShort(o) }}
td.col-num {{ formatAsset2Digits(factCost(o)) }}
td.col-state
BaseBadge(:variant="diffBadge(o).variant") {{ diffBadge(o).label }}
q-banner.mp-orderer-finalize__hint(rounded :class="diffState === 'more' ? 'bg-warning text-dark' : (diffState === 'less' ? 'bg-info text-white' : 'bg-positive text-white')")
| {{ diffHint }}
.mp-orderer-finalize__sum
span.mp-orderer-finalize__sum-label Итого к получению
span.mp-orderer-finalize__sum-value {{ formatAsset2Digits(totalFactCost) }}
.mp-orderer-finalize__preview-actions
q-btn(flat no-caps icon="description" label="Показать акт" :loading="previewLoading" @click="loadPreview")
BaseButton(variant="ghost", size="sm", :loading="previewLoading", @click="loadPreview")
template(#icon-left)
q-icon(name="description", size="16px")
| Показать акты
q-card(v-if="previewHtml" flat bordered).mp-orderer-finalize__preview
q-card-section.q-pa-md
div(v-html="previewHtml")
.mp-orderer-finalize__preview(v-if="previewHtml")
div(v-html="previewHtml")
template(#footer)
BaseButton(variant="ghost", :disabled="signing", @click="cancel") Отмена
BaseButton(variant="primary", :loading="signing", @click="confirm")
BaseButton(variant="primary", :loading="signing", :disabled="signing || !orders.length", @click="confirm")
template(#icon-left)
q-icon(name="draw", size="16px")
| Подписать и получить
| Подписать и получить{{ orders.length > 1 ? ` (${orders.length})` : '' }}
</template>
<style scoped lang="scss">
@@ -168,22 +211,54 @@ BaseDialog(
flex-direction: column;
gap: var(--p-3, 12px);
&__table {
width: 100%;
border-collapse: collapse;
&__point {
display: inline-flex;
align-items: center;
gap: var(--p-1, 4px);
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-3);
}
&__intro {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-3);
line-height: 1.4;
}
&__name {
color: var(--p-ink);
overflow-wrap: anywhere;
}
.col-num {
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
th,
td {
padding: var(--p-2, 8px);
border-bottom: 1px solid var(--p-line);
}
.col-state {
text-align: center;
width: 120px;
}
th {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-3);
font-weight: 600;
}
&__sum {
display: flex;
align-items: baseline;
justify-content: flex-end;
gap: var(--p-3, 12px);
}
&__sum-label {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-3);
}
&__sum-value {
font-family: var(--p-mono);
font-weight: 600;
font-size: var(--p-fs-h3, 15px);
color: var(--p-ink);
font-variant-numeric: tabular-nums;
}
&__preview-actions {
@@ -194,6 +269,23 @@ BaseDialog(
&__preview {
max-height: 60vh;
overflow: auto;
border: 1px solid var(--p-line);
border-radius: var(--p-r-md, 12px);
padding: var(--p-4, 16px);
background: var(--p-surface);
}
:deep(.mp-orderer-finalize__act-head) {
font-size: var(--p-fs-body, 14px);
font-weight: 600;
color: var(--p-ink);
margin: 0 0 var(--p-2, 8px);
}
:deep(.mp-orderer-finalize__act-sep) {
border: none;
border-top: 1px solid var(--p-line);
margin: var(--p-4, 16px) 0;
}
}
</style>
@@ -372,7 +372,8 @@ q-page.incoming-orders(role='region', aria-label='Входящие заказы
<style scoped lang="scss">
.incoming-orders {
padding: 0 var(--p-4, 16px) var(--p-6, 24px);
// Воздух сверху как на остальных столах поставщика (OffererMyOffers).
padding: var(--p-6, 24px) var(--p-4, 16px);
&__col {
max-width: 1120px;
@@ -6,6 +6,8 @@ import { useSessionStore } from 'src/entities/Session';
import { BaseBadge, BaseButton, BaseDialog, EmptyState, TableSkeleton } from 'src/shared/ui/base';
import type { BaseBadgeVariant, TableSkeletonColumn } from 'src/shared/ui/base';
import { PageHint } from 'src/shared/ui/domain';
import { EntityIdBadge } from 'src/shared/ui/EntityIdBadge';
import { useMarketplaceKUDetailsStore } from 'src/entities/MarketplaceKUDetails';
import { RefreshButton } from 'src/widgets/Marketplace/RefreshButton';
import { HandoffCodeDialog } from 'src/widgets/Marketplace/HandoffCode';
import { HandoffTokenKind } from 'src/shared/lib/marketplace';
@@ -36,6 +38,24 @@ const route = useRoute();
const session = useSessionStore();
const coopname = computed(() => String(route.params.coopname ?? ''));
// КУ-детали стола — для человекочитаемой колонки «КУ» (наименование + адрес)
// вместо технического braname; резолвим по braname на фронте (партия несёт
// только braname).
const kuStore = useMarketplaceKUDetailsStore();
const kuByBraname = computed(() => {
const m = new Map<string, { name: string; address: string }>();
for (const k of kuStore.details) {
m.set(k.coreBraname, { name: k.name || k.coreBraname, address: k.addressFull ?? '' });
}
return m;
});
function kuName(braname: string): string {
return kuByBraname.value.get(braname)?.name ?? braname;
}
function kuAddr(braname: string): string {
return kuByBraname.value.get(braname)?.address ?? '';
}
const shipments = ref<MarketplaceShipmentView[]>([]);
const acceptedOrders = ref<MarketplaceOrderView[]>([]);
// Заказы сформированных партий (статус SUPPLY_PREPARED) — источник состава ТТН.
@@ -140,6 +160,7 @@ async function load(): Promise<void> {
listShipments(),
fetchSupplierOrders({ statuses: ['ACCEPTED'], limit: PAGE_SIZE }),
fetchSupplierOrders({ statuses: ['SUPPLY_PREPARED'], limit: PAGE_SIZE }),
kuStore.load({ coopname: coopname.value, onlyActive: false }),
]);
shipments.value = shipmentsResult;
acceptedOrders.value = ordersResult.items;
@@ -185,7 +206,7 @@ q-page.offerer-supply
v-if='loading && !shipments.length',
:columns='skeletonColumns',
:rows='6',
min-width="970px"
min-width="1040px"
)
//- Сформированные партии — основной список стола.
@@ -204,8 +225,18 @@ q-page.offerer-supply
th.col-ttn ТТН
tbody
tr(v-for='row in shipments', :key='row.id')
td.col-id {{ row.cycle_id }}
td {{ row.braname }}
td.col-id
EntityIdBadge(
v-if='row.cycle_id',
:raw-id='String(row.cycle_id).slice(0, 8)',
:copy-value='row.cycle_id',
copy-on-click
)
span(v-else)
td
.offerer-supply__ku-text
.offerer-supply__ku-name {{ kuName(row.braname) }}
.offerer-supply__ku-addr(v-if='kuAddr(row.braname)') {{ kuAddr(row.braname) }}
td.col-variant {{ deliveryVariantLabel(row.delivery_variant) }}
td.col-status
BaseBadge(:variant='statusOf(row.status).variant') {{ statusOf(row.status).label }}
@@ -360,7 +391,7 @@ q-page.offerer-supply
}
.table {
table-layout: fixed;
min-width: 970px;
min-width: 1040px;
}
.col-id {
width: 150px;
@@ -11,6 +11,7 @@ export type IListInventoryInput = Queries.Marketplace.ListInventory.IInput['data
export type IAssignShelfInput = Mutations.Marketplace.AssignInventoryShelf.IInput['data']
export type ISplitInventoryInput = Mutations.Marketplace.SplitInventory.IInput['data']
export type IGenerateLabelInput = Mutations.Marketplace.GenerateInventoryLabel.IInput['data']
export type IBindBarcodeInput = Mutations.Marketplace.BindInventoryBarcode.IInput['data']
export async function fetchInventoryByBraname(braname: string): Promise<MarketplaceInventoryItemView[]> {
const { [Queries.Marketplace.ListInventory.name]: result } = await client.Query(
@@ -50,3 +51,13 @@ export async function generateInventoryLabel(
)
return result.inventory as MarketplaceInventoryItemView[]
}
export async function bindInventoryBarcode(
data: IBindBarcodeInput,
): Promise<MarketplaceInventoryItemView[]> {
const { [Mutations.Marketplace.BindInventoryBarcode.name]: result } = await client.Mutation(
Mutations.Marketplace.BindInventoryBarcode.mutation,
{ variables: { data } },
)
return result.inventory as MarketplaceInventoryItemView[]
}
@@ -11,8 +11,8 @@ import { PageHint } from 'src/shared/ui/domain'
import { RefreshButton } from 'src/widgets/Marketplace/RefreshButton'
import {
assignInventoryShelf,
bindInventoryBarcode,
fetchInventoryByBraname,
generateInventoryLabel,
splitInventory,
type MarketplaceInventoryItemView,
} from '../api'
@@ -25,8 +25,11 @@ import {
* - колонки-полки — что лежит на каждой полке;
* - раскладка = перетащить карточку на полку (DnD) или через меню «⋮»;
* - «Разложить» дробит позицию по количеству на несколько полок;
* - «Этикетка» наклеивает штрих-код для поиска сканером (по желанию).
* - маркировка = наклеить заранее напечатанный штрих-код и привязать его
* к позиции сканером (кнопка-штрихкод на карточке).
*
* «Печать этикеток» (в шапке) печатает лист произвольных штрих-кодов — оператор
* режет и наклеивает их на имущество, затем сканирует, чтобы привязать к позиции.
* Цель — при выдаче заказчику за секунды найти, на какой полке лежит заказ.
*/
@@ -45,7 +48,6 @@ const LABELED = Zeus.MarketplaceInventoryStatus.LABELED
const boardItems = computed(() =>
items.value.filter((i) => i.status === RECEIVED || i.status === LABELED),
)
const labeledItems = computed(() => items.value.filter((i) => !!i.barcode_value))
// Полки-плейсхолдеры, созданные оператором, но пока пустые (нет позиций).
// Живут до перезагрузки — после reload остаются лишь полки с позициями.
@@ -165,17 +167,112 @@ function createShelf(): void {
newShelfOpen.value = false
}
// ── Маркировка штрих-кодом ──
async function makeLabel(item: MarketplaceInventoryItemView): Promise<void> {
Loading.show({ message: 'Генерирую этикетку…' })
// ── Генерация произвольного EAN-13 (12 цифр + контрольная) ──
function randomEAN13(): string {
let base = ''
for (let i = 0; i < 12; i++) base += Math.floor(Math.random() * 10).toString()
let sum = 0
for (let i = 0; i < 12; i++) sum += Number(base[i]) * (i % 2 === 0 ? 1 : 3)
const check = (10 - (sum % 10)) % 10
return `${base}${check}`
}
// ── Печать листа произвольных штрих-кодов (для нарезки и наклейки) ──
const printDialogOpen = ref(false)
const printCount = ref<number | null>(24)
function openPrintDialog(): void {
printDialogOpen.value = true
}
// SVG-полосы штрих-кода (тот же псевдо-рендер, что и в BarcodeDisplay) — строкой,
// чтобы печатать изолированный лист в скрытом iframe, а не весь UI приложения.
function barcodeSvg(code: string): string {
const rects: { x: number; w: number }[] = []
let x = 4
for (let i = 0; i < code.length; i++) {
const ch = code.charCodeAt(i)
const blackW = ((ch * 7) % 4) + 1
const gapW = ((ch * 11) % 3) + 1
rects.push({ x, w: blackW })
x += blackW + gapW
if (i % 2 === 0) {
rects.push({ x, w: 1 })
x += 2
}
}
const last = rects[rects.length - 1]
const total = (last ? last.x + last.w : 100) + 8
const bars = rects
.map((b) => `<rect x="${b.x}" y="0" width="${b.w}" height="64" fill="#111"/>`)
.join('')
return `<svg viewBox="0 0 ${total} 64" width="${total}" height="64" role="img" aria-label="Штрих-код ${code}">${bars}</svg>`
}
function doPrint(): void {
const n = Math.trunc(Number(printCount.value) || 0)
if (n < 1) return
const codes = Array.from({ length: n }, () => randomEAN13())
printDialogOpen.value = false
const labels = codes
.map((code) => `<div class="lbl">${barcodeSvg(code)}<div class="code">${code}</div></div>`)
.join('')
const html = `<!doctype html><html lang="ru"><head><meta charset="utf-8"><title>Этикетки</title><style>
@page { size: A4; margin: 8mm; }
* { box-sizing: border-box; }
body { margin: 0; font-family: monospace; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4mm; }
.lbl { border: 1px solid #ddd; border-radius: 6px; padding: 6px; display: flex; flex-direction: column; align-items: center; break-inside: avoid; }
.lbl svg { display: block; max-width: 100%; }
.code { margin-top: 4px; letter-spacing: 2px; font-size: 13px; color: #111; }
</style></head><body><div class="grid">${labels}</div></body></html>`
const iframe = document.createElement('iframe')
iframe.setAttribute('aria-hidden', 'true')
iframe.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0;'
iframe.srcdoc = html
iframe.onload = () => {
const win = iframe.contentWindow
if (!win) return
win.focus()
win.print()
win.addEventListener('afterprint', () => iframe.remove())
window.setTimeout(() => iframe.remove(), 60000)
}
document.body.appendChild(iframe)
}
// ── Привязка штрих-кода к позиции ──
// Камеры на ПВЗ-ноутбуке может не быть; штатный сценарий — USB-сканер «набирает»
// код в поле как клавиатура и жмёт Enter, либо оператор вводит код руками.
const scanDialogOpen = ref(false)
const scanTarget = ref<MarketplaceInventoryItemView | null>(null)
const scanCode = ref('')
const binding = ref(false)
function openScan(item: MarketplaceInventoryItemView): void {
scanTarget.value = item
scanCode.value = ''
scanDialogOpen.value = true
}
async function submitScan(): Promise<void> {
const item = scanTarget.value
const code = scanCode.value.trim()
if (!item || !code || binding.value) return
binding.value = true
try {
await generateInventoryLabel({ inventory_id: item.id })
SuccessAlert('Этикетка сгенерирована — можно печатать')
await bindInventoryBarcode({ inventory_id: item.id, barcode_value: code })
SuccessAlert(`Штрих-код ${code} привязан к позиции`)
scanDialogOpen.value = false
scanTarget.value = null
scanCode.value = ''
await load()
} catch (e) {
FailAlert(e, 'Не удалось сгенерировать этикетку')
FailAlert(e, 'Не удалось привязать штрих-код')
} finally {
Loading.hide()
binding.value = false
}
}
@@ -242,10 +339,6 @@ async function applySplit(): Promise<void> {
}
}
function openPrintWindow(): void {
window.print()
}
watch(braname, () => void load())
onMounted(async () => {
@@ -268,7 +361,7 @@ q-page.place(role='region', aria-label='Склад участка')
template(v-else)
Teleport(to="#header-actions-host", defer)
BaseButton(variant='secondary', size='sm', :disabled='!labeledItems.length', @click='openPrintWindow')
BaseButton(variant='secondary', size='sm', @click='openPrintDialog')
template(#icon-left)
q-icon(name='print', size='16px')
| Печать этикеток
@@ -320,54 +413,57 @@ q-page.place(role='region', aria-label='Склад участка')
.place__card-info
.place__card-name {{ item.product_name_snapshot || 'Товар по предложению' }}
.place__card-meta {{ item.quantity_per_label }} ед. · {{ item.orderer_account_snapshot }}
BaseButton.place__card-menu-btn(variant='ghost', size='sm', icon-only, aria-label='Действия')
template(#icon-left)
q-icon(name='more_vert', size='18px')
q-menu(anchor='bottom right', self='top right')
q-list(dense, style='min-width: 220px')
q-item-label(header) Переложить на полку
q-item(
v-for='name in shelfNames.filter((n) => n !== item.shelf)',
:key='name',
clickable,
v-close-popup,
@click='moveToShelf(item, name)'
)
q-item-section(avatar)
q-icon(name='shelves', size='18px')
q-item-section {{ name }}
q-item(clickable, v-close-popup, @click='openNewShelf')
q-item-section(avatar)
q-icon(name='add', size='18px')
q-item-section На новую полку
q-item(
v-if='item.shelf',
clickable,
v-close-popup,
@click='moveToShelf(item, null)'
)
q-item-section(avatar)
q-icon(name='inbox', size='18px')
q-item-section Снять с полки
q-separator
q-item(
v-if='canRedistribute(item)',
clickable,
v-close-popup,
@click='openSplit(item)'
)
q-item-section(avatar)
q-icon(name='call_split', size='18px')
q-item-section Разложить по количеству
q-item(
v-if='!item.barcode_value',
clickable,
v-close-popup,
@click='makeLabel(item)'
)
q-item-section(avatar)
q-icon(name='qr_code_2', size='18px')
q-item-section Наклеить этикетку
.place__card-actions
BaseButton(
v-if='!item.barcode_value',
variant='ghost',
size='sm',
icon-only,
aria-label='Привязать штрих-код сканером',
@click='openScan(item)'
)
template(#icon-left)
q-icon(name='qr_code_scanner', size='18px')
q-tooltip Привязать штрих-код сканером
BaseButton.place__card-menu-btn(variant='ghost', size='sm', icon-only, aria-label='Действия')
template(#icon-left)
q-icon(name='more_vert', size='18px')
q-menu(anchor='bottom right', self='top right')
q-list(dense, style='min-width: 220px')
q-item-label(header) Переложить на полку
q-item(
v-for='name in shelfNames.filter((n) => n !== item.shelf)',
:key='name',
clickable,
v-close-popup,
@click='moveToShelf(item, name)'
)
q-item-section(avatar)
q-icon(name='shelves', size='18px')
q-item-section {{ name }}
q-item(clickable, v-close-popup, @click='openNewShelf')
q-item-section(avatar)
q-icon(name='add', size='18px')
q-item-section На новую полку
q-item(
v-if='item.shelf',
clickable,
v-close-popup,
@click='moveToShelf(item, null)'
)
q-item-section(avatar)
q-icon(name='inbox', size='18px')
q-item-section Снять с полки
q-separator
q-item(
v-if='canRedistribute(item)',
clickable,
v-close-popup,
@click='openSplit(item)'
)
q-item-section(avatar)
q-icon(name='call_split', size='18px')
q-item-section Разложить по количеству
.place__card-badges
BaseBadge(v-if='item.barcode_value', variant='pos') Промаркировано
@@ -382,15 +478,6 @@ q-page.place(role='region', aria-label='Склад участка')
q-icon(name='add', size='18px')
| Полка
//- Печатный лист этикеток (только при печати; на экране штрих-код в карточке).
.place__grid.print-only(v-if='labeledItems.length')
.place__label(v-for='item in labeledItems', :key='item.id')
.place__label-name.ellipsis {{ item.product_name_snapshot }}
.place__label-meta.ellipsis
| Пайщик: {{ item.orderer_account_snapshot }} · Кол-во: {{ item.quantity_per_label }}
template(v-if='item.shelf') · Полка: {{ item.shelf }}
BarcodeDisplay(v-if='item.barcode_value', :code='item.barcode_value', size='md')
//- Новая полка.
BaseDialog(v-model='newShelfOpen', title='Новая полка', size='sm')
.place__new-shelf
@@ -446,6 +533,42 @@ q-page.place(role='region', aria-label='Склад участка')
template(#footer)
BaseButton(variant='ghost', size='sm', @click='splitDialogOpen = false') Отмена
BaseButton(variant='primary', size='sm', :disabled='!splitValid', @click='applySplit') Разложить
//- Печать листа произвольных штрих-кодов.
BaseDialog(v-model='printDialogOpen', title='Печать этикеток', size='sm')
.place__print-dialog
.place__print-note
| Сколько штрих-кодов напечатать? Распечатайте лист, разрежьте и наклейте
| этикетки на имущество затем привяжите их к позициям сканером.
BaseInput(
v-model.number='printCount',
type='number',
label='Количество этикеток',
min='1',
autofocus,
@keydown.enter='doPrint'
)
template(#footer)
BaseButton(variant='ghost', size='sm', @click='printDialogOpen = false') Отмена
BaseButton(variant='primary', size='sm', :disabled='!printCount || printCount < 1', @click='doPrint') Печать
//- Привязка штрих-кода: USB-сканер набирает код в поле и жмёт Enter, либо ввод руками.
BaseDialog(v-model='scanDialogOpen', title='Привязать штрих-код', size='sm')
.place__scan
.place__scan-note(v-if='scanTarget')
| {{ scanTarget.product_name_snapshot || 'Товар' }} отсканируйте наклеенный
| штрих-код (сканер введёт его сам) или наберите номер вручную и нажмите Enter.
BaseInput(
v-model='scanCode',
label='Штрих-код',
placeholder='4600000000000',
autofocus,
:disable='binding',
@keydown.enter='submitScan'
)
template(#footer)
BaseButton(variant='ghost', size='sm', :disabled='binding', @click='scanDialogOpen = false') Отмена
BaseButton(variant='primary', size='sm', :disabled='!scanCode.trim() || binding', @click='submitScan') Привязать
</template>
<style scoped lang="scss">
@@ -575,12 +698,33 @@ q-page.place(role='region', aria-label='Склад участка')
font-variant-numeric: tabular-nums;
}
&__card-actions {
display: flex;
align-items: center;
gap: var(--p-1, 4px);
flex: 0 0 auto;
}
&__card-badges {
display: flex;
flex-wrap: wrap;
gap: var(--p-1, 4px);
}
&__print-dialog,
&__scan {
display: flex;
flex-direction: column;
gap: var(--p-3, 12px);
padding-top: var(--p-2, 8px);
}
&__print-note,
&__scan-note {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-2);
}
&__new-shelf {
padding-top: var(--p-2, 8px);
}
@@ -632,31 +776,6 @@ q-page.place(role='region', aria-label='Склад участка')
font-weight: 600;
}
}
// Печатный лист этикеток — скрыт на экране, разворачивается только при печати.
&__grid {
display: none;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--p-4, 16px);
}
&__label {
border: 1px solid var(--p-line);
border-radius: var(--p-r-sm, 8px);
padding: var(--p-3, 12px);
background: var(--p-surface);
break-inside: avoid;
}
&__label-name {
font-weight: 600;
}
&__label-meta {
color: var(--p-ink-3);
font-size: var(--p-fs-body-sm);
margin-bottom: var(--p-1, 4px);
}
}
@media (max-width: 768px) {
@@ -668,26 +787,4 @@ q-page.place(role='region', aria-label='Склад участка')
}
}
}
@media print {
.no-print {
display: none !important;
}
.place {
padding: 0;
}
.place__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4mm;
}
.place__label {
page-break-inside: avoid;
width: 100%;
}
@page {
size: A4;
margin: 8mm;
}
}
</style>
@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue';
import { Classes } from '@coopenomics/sdk';
import { useGlobalStore } from 'src/shared/store';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { BaseButton } from 'src/shared/ui/base';
import { TakeoverDialog } from 'src/widgets/Marketplace/TakeoverDialog';
import { CorrectionTable, type CorrectionRow } from 'src/widgets/Marketplace/CorrectionTable';
import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits';
@@ -14,26 +15,29 @@ import {
} from '../api';
/**
* Story 6.1 / FR21: full-screen takeover для открытия выдачи на ПВЗ.
* Story 6.1 / FR21: full-screen takeover для открытия выдачи на ПВЗ
* СГРУППИРОВАННОЙ ПО ПАЙЩИКУ. Пайщик приходит за всем, что ему причитается;
* оператор открывает выдачу ОДНОЙ операцией по всем позициям заказчика.
*
* Открытие выдачи делает оператор КУ. Именно здесь фиксируется факт: оператор
* сверяет привезённое имущество с заказом, взвешивает/пересчитывает и
* корректирует фактически выдаваемое количество. Это количество зашивается в
* подписываемый председателем акт и сохраняется на заказе — финальная подпись
* заказчика факт уже не редактирует.
* Открытие выдачи делает оператор КУ. Здесь фиксируется факт: оператор сверяет
* привезённое имущество с каждым заказом и корректирует фактически выдаваемое
* количество — оно зашивается в подписываемый председателем акт ПО КАЖДОЙ
* позиции и сохраняется на заказе. Финальная подпись заказчика факт не
* редактирует.
*
* Поток:
* 1. Оператор корректирует количество в таблице сверки (предзаполнено заказом).
* 2. По кнопке «Показать акт» можно посмотреть сформированный документ.
* 3. «Открыть выдачу» — UI берёт акт на фактическое количество, подписывает
* его ключом председателя текущей сессии (signatureId=1) и отправляет в
* `openIssuance`. Заказ переходит в «Готово к получению», заказчику —
* уведомление; финальную подпись он поставит сам в своём кабинете.
* 1. Оператор корректирует количество/цену в сводной таблице сверки (строка на
* позицию, предзаполнена заказом).
* 2. «Открыть выдачу» — UI ЦИКЛОМ по всем позициям: формирует акт на
* фактическое количество, подписывает ключом председателя сессии
* (signatureId=1) и отправляет в `openIssuance`. Каждая позиция = свой акт
* (результаты могут различаться). Заказы переходят в «Готово к получению»,
* заказчику — уведомление; финальную подпись он поставит сам.
*/
const props = defineProps<{
modelValue: boolean;
order: MarketplaceOrderIssuanceView | null;
orders: MarketplaceOrderIssuanceView[];
}>();
const emit = defineEmits<{
@@ -43,58 +47,80 @@ const emit = defineEmits<{
const globalStore = useGlobalStore();
const actualQuantity = ref<number>(0);
const actualUnitPrice = ref<number>(0);
// Редактируемый факт по каждой позиции, ключ — id заказа.
interface FactState {
qty: number;
price: number;
}
const facts = ref<Record<string, FactState>>({});
const previewHtml = ref<string>('');
const previewLoading = ref(false);
const signing = ref(false);
const unitShort = computed(() =>
props.order ? marketplaceUnitShort(props.order.unit_of_measure) : 'ед.',
// sku (короткий, отображается в таблице) → id заказа, для onChange-маппинга.
const skuToId = computed<Record<string, string>>(() => {
const m: Record<string, string> = {};
for (const o of props.orders) m[o.id.slice(0, 8)] = o.id;
return m;
});
function initFacts(): void {
const next: Record<string, FactState> = {};
for (const o of props.orders) {
next[o.id] = { qty: o.quantity, price: Number.parseFloat(o.price_per_unit) };
}
facts.value = next;
}
const correctionRows = computed<CorrectionRow[]>(() =>
props.orders.map((o) => {
const f = facts.value[o.id];
return {
sku: o.id.slice(0, 8),
title: o.product_name || 'Товар по предложению',
unit: marketplaceUnitShort(o.unit_of_measure),
expected: o.quantity,
fact: f?.qty ?? o.quantity,
expectedPrice: Number.parseFloat(o.price_per_unit),
factPrice: f?.price ?? Number.parseFloat(o.price_per_unit),
};
}),
);
const factCost = computed<string>(() => {
if (!props.order) return '0';
return (actualQuantity.value * actualUnitPrice.value).toFixed(4);
// Итоговая сумма к выдаче по всем позициям.
const totalFactCost = computed<string>(() => {
let sum = 0;
for (const o of props.orders) {
const f = facts.value[o.id];
if (f) sum += f.qty * f.price;
}
return sum.toFixed(4);
});
const diffState = computed<'equal' | 'less' | 'more'>(() => {
if (!props.order) return 'equal';
if (actualQuantity.value === props.order.quantity) return 'equal';
if (actualQuantity.value < props.order.quantity) return 'less';
return 'more';
});
const positionsCount = computed(() => props.orders.length);
const diffHint = computed<string>(() => {
if (!props.order) return '';
if (diffState.value === 'equal') return 'Факт совпадает с заказом.';
if (diffState.value === 'less')
return `Выдаётся меньше заказа на ${props.order.quantity - actualQuantity.value} ${unitShort.value} — разница вернётся заказчику.`;
return `Выдаётся больше заказа на ${actualQuantity.value - props.order.quantity} ${unitShort.value} — потребуется доплата с паевого заказчика.`;
});
const recipientName = computed(
() => props.orders.find((o) => o.orderer_name)?.orderer_name || props.orders[0]?.orderer_account || '',
);
const correctionRows = computed<CorrectionRow[]>(() => {
if (!props.order) return [];
const orderedPrice = Number.parseFloat(props.order.price_per_unit);
return [
{
sku: props.order.id.slice(0, 8),
title: props.order.product_name || 'Товар по предложению',
unit: unitShort.value,
expected: props.order.quantity,
fact: actualQuantity.value,
expectedPrice: orderedPrice,
factPrice: actualUnitPrice.value,
},
];
});
// Хотя бы одна позиция вышла за пределы заказа — окрашиваем takeover в warning.
const anyOver = computed(() =>
props.orders.some((o) => (facts.value[o.id]?.qty ?? o.quantity) > o.quantity),
);
const allValid = computed(() =>
props.orders.length > 0 &&
props.orders.every((o) => {
const f = facts.value[o.id];
return f && f.qty > 0 && f.price > 0;
}),
);
watch(
() => [props.modelValue, props.order?.id],
() => [props.modelValue, props.orders.map((o) => o.id).join(',')],
([visible]) => {
if (visible && props.order) {
actualQuantity.value = props.order.quantity;
actualUnitPrice.value = Number.parseFloat(props.order.price_per_unit);
if (visible && props.orders.length) {
initFacts();
previewHtml.value = '';
}
},
@@ -102,42 +128,47 @@ watch(
);
function onCorrectionChange(payload: { sku: string; fact: number; factPrice?: number }): void {
actualQuantity.value = Math.max(0, payload.fact);
if (payload.factPrice !== undefined) {
actualUnitPrice.value = Math.max(0, payload.factPrice);
}
// Документ зависит от факта — сбрасываем устаревший превью.
const id = skuToId.value[payload.sku];
if (!id) return;
const f = facts.value[id] ?? { qty: 0, price: 0 };
f.qty = Math.max(0, payload.fact);
if (payload.factPrice !== undefined) f.price = Math.max(0, payload.factPrice);
facts.value = { ...facts.value, [id]: f };
// Акты зависят от факта — сбрасываем устаревший превью.
previewHtml.value = '';
}
async function loadPreview(): Promise<void> {
if (!props.order || actualQuantity.value <= 0 || actualUnitPrice.value <= 0) {
FailAlert(new Error('Сначала укажите фактическое количество и цену больше нуля.'));
if (!allValid.value) {
FailAlert(new Error('Укажите фактическое количество и цену больше нуля по всем позициям.'));
return;
}
previewLoading.value = true;
try {
const doc = await getChairmanSignablePayload({
order_id: props.order.id,
actual_quantity: actualQuantity.value,
actual_unit_price: String(actualUnitPrice.value),
});
previewHtml.value = doc.html;
// Превью всех актов пайщика на одной странице — предварительное ознакомление.
const parts: string[] = [];
for (const o of props.orders) {
const f = facts.value[o.id];
const doc = await getChairmanSignablePayload({
order_id: o.id,
actual_quantity: f.qty,
actual_unit_price: String(f.price),
});
const title = o.product_name || 'Товар по предложению';
parts.push(`<h4 class="mp-issue-open-dialog__act-head">${title}</h4>${doc.html}`);
}
previewHtml.value = parts.join('<hr class="mp-issue-open-dialog__act-sep" />');
} catch (e) {
FailAlert(e, 'Не удалось сформировать акт выдачи');
FailAlert(e, 'Не удалось сформировать акты выдачи');
} finally {
previewLoading.value = false;
}
}
async function confirm(): Promise<void> {
if (!props.order) return;
if (actualQuantity.value <= 0) {
FailAlert(new Error('Фактическое количество должно быть больше нуля.'));
return;
}
if (actualUnitPrice.value <= 0) {
FailAlert(new Error('Фактическая цена за единицу должна быть больше нуля.'));
if (!props.orders.length) return;
if (!allValid.value) {
FailAlert(new Error('Фактическое количество и цена должны быть больше нуля по всем позициям.'));
return;
}
const wifKey = globalStore.wif?.toString();
@@ -146,28 +177,47 @@ async function confirm(): Promise<void> {
return;
}
signing.value = true;
try {
const unitPriceStr = String(actualUnitPrice.value);
const generated = await getChairmanSignablePayload({
order_id: props.order.id,
actual_quantity: actualQuantity.value,
actual_unit_price: unitPriceStr,
});
const docSigner = new Classes.Document(wifKey);
const signed = await docSigner.signDocument(generated, globalStore.username, 1);
await openIssuance({
order_id: props.order.id,
actual_quantity: actualQuantity.value,
actual_unit_price: unitPriceStr,
signed_document: signed,
});
SuccessAlert('Выдача открыта. Ждём подпись заказчика — он подтвердит получение в своём кабинете.');
emit('opened');
const docSigner = new Classes.Document(wifKey);
const failed: string[] = [];
let ok = 0;
// Цикл по позициям: каждая — отдельный акт (председатель → openIssuance).
// Последовательно, чтобы не ловить гонки on-chain подписей.
for (const o of props.orders) {
const f = facts.value[o.id];
const priceStr = String(f.price);
try {
const generated = await getChairmanSignablePayload({
order_id: o.id,
actual_quantity: f.qty,
actual_unit_price: priceStr,
});
const signed = await docSigner.signDocument(generated, globalStore.username, 1);
await openIssuance({
order_id: o.id,
actual_quantity: f.qty,
actual_unit_price: priceStr,
signed_document: signed,
});
ok += 1;
} catch (e) {
console.error('openIssuance failed for order', o.id, e);
failed.push(o.product_name || o.id.slice(0, 8));
}
}
signing.value = false;
if (ok > 0) emit('opened');
if (failed.length === 0) {
SuccessAlert(
`Выдача открыта по ${ok} позиц. — ждём подтверждение заказчика в его кабинете.`,
);
emit('update:modelValue', false);
} catch (e) {
FailAlert(e, 'Не удалось открыть выдачу');
} finally {
signing.value = false;
} else {
FailAlert(
new Error(
`Открыто ${ok} из ${props.orders.length}. Не удалось: ${failed.join(', ')}. Повторите по оставшимся.`,
),
);
}
}
@@ -179,36 +229,53 @@ function cancel(): void {
<template lang="pug">
TakeoverDialog(
:model-value="modelValue"
title="Открытие выдачи заказа"
:lead-text="order ? `Заказ ${order.id.slice(0, 8)} · к выдаче ${formatAsset2Digits(factCost)} ₽` : ''"
:kind="diffState === 'more' ? 'warning' : 'info'"
confirm-label="Подписать и открыть выдачу"
cancel-label="Закрыть"
wide
title="Открытие выдачи пайщику"
:lead-text="recipientName ? `${recipientName} · позиций: ${positionsCount} · к выдаче ${formatAsset2Digits(totalFactCost)} ₽` : ''"
:kind="anyOver ? 'warning' : 'info'"
:loading="signing"
:disable-confirm="actualQuantity <= 0 || actualUnitPrice <= 0 || signing"
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
@confirm="confirm"
@cancel="cancel"
)
template(#default)
.mp-issue-open-dialog
.mp-issue-open-dialog__intro.text-body2.text-grey
| Сверьте привезённое имущество с заказом и укажите фактически выдаваемое количество оно войдёт в акт.
.mp-issue-open-dialog__intro
| Сверьте привезённое имущество с заказами пайщика и укажите фактически
| выдаваемое количество по каждой позиции оно войдёт в соответствующий
| акт. Открытие выдачи сформирует и подпишет акты сразу по всем позициям.
CorrectionTable(:rows="correctionRows" @change="onCorrectionChange")
q-banner.mp-issue-open-dialog__hint(rounded :class="diffState === 'more' ? 'bg-warning text-dark' : (diffState === 'less' ? 'bg-info text-white' : 'bg-positive text-white')")
| {{ diffHint }}
.mp-issue-open-dialog__sum.text-body2
| К выдаче: {{ formatAsset2Digits(factCost) }} ({{ actualQuantity }} {{ unitShort }})
.mp-issue-open-dialog__sum
span.mp-issue-open-dialog__sum-label К выдаче по всем позициям
span.mp-issue-open-dialog__sum-value {{ formatAsset2Digits(totalFactCost) }}
.mp-issue-open-dialog__preview-actions
q-btn(flat no-caps icon="description" label="Показать акт" :loading="previewLoading" @click="loadPreview")
BaseButton(
variant="ghost"
size="sm"
:loading="previewLoading"
@click="loadPreview"
)
template(#icon-left)
q-icon(name="description" size="16px")
| Показать акты
q-card(v-if="previewHtml" flat bordered).mp-issue-open-dialog__preview
q-card-section.q-pa-md
div(v-html="previewHtml")
.mp-issue-open-dialog__preview(v-if="previewHtml")
div(v-html="previewHtml")
template(#actions="{ cancel: onCancel, confirm: onConfirm }")
BaseButton(variant="ghost" @click="onCancel") Закрыть
BaseButton(
variant="primary"
:loading="signing"
:disabled="!allValid || signing"
@click="onConfirm"
)
template(#icon-left)
q-icon(name="draw" size="16px")
| Подписать и открыть выдачу
</template>
<style scoped lang="scss">
@@ -217,7 +284,29 @@ TakeoverDialog(
flex-direction: column;
gap: var(--p-4, 16px);
&__intro {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-3);
line-height: 1.4;
}
&__sum {
display: flex;
align-items: baseline;
justify-content: flex-end;
gap: var(--p-3, 12px);
}
&__sum-label {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-3);
}
&__sum-value {
font-family: var(--p-mono);
font-weight: 600;
font-size: var(--p-fs-h3, 15px);
color: var(--p-ink);
font-variant-numeric: tabular-nums;
}
@@ -229,6 +318,24 @@ TakeoverDialog(
&__preview {
max-height: 60vh;
overflow: auto;
border: 1px solid var(--p-line);
border-radius: var(--p-r-md, 12px);
padding: var(--p-4, 16px);
background: var(--p-surface);
}
// Заголовок акта и разделитель в сводном превью.
:deep(.mp-issue-open-dialog__act-head) {
font-size: var(--p-fs-body, 14px);
font-weight: 600;
color: var(--p-ink);
margin: 0 0 var(--p-2, 8px);
}
:deep(.mp-issue-open-dialog__act-sep) {
border: none;
border-top: 1px solid var(--p-line);
margin: var(--p-4, 16px) 0;
}
}
</style>
@@ -37,18 +37,55 @@ const items = ref<MarketplaceOrderIssuanceView[]>([]);
const loading = ref(false);
const openDialog = ref(false);
const selectedOrder = ref<MarketplaceOrderIssuanceView | null>(null);
// Открываем выдачу СРАЗУ по всем позициям пайщика «к выдаче» — одна операция
// оператора, диалог разносит их по актам циклом.
const selectedOrders = ref<MarketplaceOrderIssuanceView[]>([]);
// Статусы заказа, релевантные выдаче (ожидают открытия или финальной подписи).
const ISSUANCE_STATUSES = ['ACCEPTED_TO_COOP', 'READY_TO_RECEIVE'];
// Строка выдачи: одинаковое имущество (один товар, одна цена за единицу, одна
// стадия) слито в одну строку с просуммированными кол-вом и стоимостью. Разная
// цена за единицу (поставщик менял стоимость) → разные строки.
interface IssuanceLine {
key: string;
name: string;
quantity: number;
unit: MarketplaceOrderIssuanceView['unit_of_measure'];
total: string;
status: string;
}
function mergeLines(orders: MarketplaceOrderIssuanceView[]): IssuanceLine[] {
const map = new Map<string, IssuanceLine>();
for (const o of orders) {
const name = o.product_name || 'Товар по предложению';
const qty = Number.parseFloat(String(o.quantity ?? '0')) || 0;
const total = Number.parseFloat(String(o.total_cost ?? '0')) || 0;
const unitPrice = qty ? total / qty : total;
// Цена за единицу в ключе — разная цена не сливается в одну строку.
const key = `${name}__${o.unit_of_measure ?? ''}__${o.status}__${unitPrice.toFixed(4)}`;
const ex = map.get(key);
if (ex) {
ex.quantity += qty;
ex.total = (Number.parseFloat(ex.total) + total).toFixed(4);
} else {
map.set(key, { key, name, quantity: qty, unit: o.unit_of_measure, total: total.toFixed(4), status: o.status });
}
}
// Гасим float-шум суммирования количеств (0.1+0.2 и т.п.).
for (const l of map.values()) l.quantity = Math.round(l.quantity * 1000) / 1000;
return [...map.values()];
}
// Группировка ленты выдачи по заказчику: карточка получателя со списком единиц,
// разнесённым по стадии (к выдаче / ждут получения).
// разнесённым по стадии (к выдаче / ждут получения), одинаковое — слито.
interface IssuanceGroup {
account: string;
name: string;
toIssue: MarketplaceOrderIssuanceView[];
awaiting: MarketplaceOrderIssuanceView[];
toIssueLines: IssuanceLine[];
awaitingLines: IssuanceLine[];
total: string;
count: number;
}
@@ -63,11 +100,13 @@ const groups = computed<IssuanceGroup[]>(() => {
const out: IssuanceGroup[] = [];
for (const [account, orders] of map) {
const named = orders.find((o) => o.orderer_name);
const toIssue = orders.filter((o) => o.status === 'ACCEPTED_TO_COOP');
out.push({
account,
name: named?.orderer_name || account,
toIssue: orders.filter((o) => o.status === 'ACCEPTED_TO_COOP'),
awaiting: orders.filter((o) => o.status === 'READY_TO_RECEIVE'),
toIssue,
toIssueLines: mergeLines(toIssue),
awaitingLines: mergeLines(orders.filter((o) => o.status === 'READY_TO_RECEIVE')),
total: orders
.reduce((a, o) => a + Number.parseFloat(String(o.total_cost ?? '0')), 0)
.toFixed(4),
@@ -90,8 +129,10 @@ async function load(): Promise<void> {
}
}
function startOpen(item: MarketplaceOrderIssuanceView): void {
selectedOrder.value = item;
function startOpen(orders: MarketplaceOrderIssuanceView[]): void {
const toIssue = orders.filter((o) => o.status === 'ACCEPTED_TO_COOP');
if (!toIssue.length) return;
selectedOrders.value = toIssue;
openDialog.value = true;
}
@@ -105,13 +146,17 @@ const pickupOrders = computed(() =>
(o) => o.orderer_account === pickupAccount.value && ISSUANCE_STATUSES.includes(o.status),
),
);
// Те же позиции, но слитые по одинаковому имуществу — для отображения в диалоге.
const pickupLines = computed(() => mergeLines(pickupOrders.value));
// Сколько позиций пайщика реально можно открыть к выдаче прямо сейчас.
const pickupToIssueCount = computed(
() => pickupOrders.value.filter((o) => o.status === 'ACCEPTED_TO_COOP').length,
);
function startIssuanceStep(order: MarketplaceOrderIssuanceView): void {
if (order.status === 'ACCEPTED_TO_COOP') {
startOpen(order);
} else {
FailAlert(new Error('Выдача уже открыта — ждём подтверждение заказчика.'));
}
// Из QR-резолва: открыть выдачу разом по всем готовым позициям пайщика.
function startPickupIssuance(): void {
pickupDialogOpen.value = false;
startOpen(pickupOrders.value);
}
function onQrScanned(code: string): void {
@@ -162,10 +207,10 @@ q-page.issuance(role='region', aria-label='Выдача заказов')
template(v-else)
PageHint(storage-key='mp:operator-issuance:banner-dismissed')
| Заказы сгруппированы по заказчикам отсканируйте код получателя или
| найдите его карточку и выдайте всё, что ему причитается. «Открыть выдачу»
| оператор подтверждает подписью председателя, дальше заказчик подтвердит
| получение сам в своём кабинете.
| Заказы сгруппированы по заказчикам. Карточки показывают, что кому
| причитается. Открыть выдачу можно только отсканировав QR-код получателя
| («Сканировать QR заказа») так подтверждаем, что пришёл именно он.
| Дальше заказчик подтвердит получение сам в своём кабинете.
.issuance__toolbar
BaseButton(variant='secondary', @click='scanDialogOpen = true')
@@ -184,31 +229,28 @@ q-page.issuance(role='region', aria-label='Выдача заказов')
.issuance__card-ident
span.issuance__card-name {{ g.name }}
AccountBadge(:account-name='g.account', size='sm')
template(#actions)
.issuance__card-meta
BaseBadge(variant='neutral') Позиций: {{ g.count }}
span.issuance__card-total {{ formatAsset2Digits(g.total) }}
//- К выдаче — оператор открывает выдачу.
.issuance__section(v-if='g.toIssue.length')
//- К выдаче — открыть выдачу можно ТОЛЬКО отсканировав QR-код заказчика
//- (кнопка скана в тулбаре). Здесь — только что причитается пайщику.
.issuance__section(v-if='g.toIssueLines.length')
.issuance__section-head К выдаче
.issuance__line(v-for='o in g.toIssue', :key='o.id')
.issuance__line(v-for='line in g.toIssueLines', :key='line.key')
.issuance__line-info
.issuance__line-name {{ o.product_name || 'Товар по предложению' }}
.issuance__line-meta {{ o.quantity }} {{ marketplaceUnitShort(o.unit_of_measure) }} · {{ formatAsset2Digits(String(o.total_cost ?? '')) }}
BaseButton(variant='primary', size='sm', @click='startOpen(o)')
template(#icon-left)
q-icon(name='draw', size='16px')
| Открыть выдачу
.issuance__line-name {{ line.name }}
.issuance__line-meta {{ line.quantity }} {{ marketplaceUnitShort(line.unit) }} · {{ formatAsset2Digits(line.total) }}
//- Ждут получения — выдача открыта, ждём подпись заказчика.
.issuance__section(v-if='g.awaiting.length')
.issuance__section(v-if='g.awaitingLines.length')
.issuance__section-head Ждут получения заказчиком
.issuance__line(v-for='o in g.awaiting', :key='o.id')
.issuance__line(v-for='line in g.awaitingLines', :key='line.key')
.issuance__line-info
.issuance__line-name {{ o.product_name || 'Товар по предложению' }}
.issuance__line-meta {{ o.quantity }} {{ marketplaceUnitShort(o.unit_of_measure) }} · {{ formatAsset2Digits(String(o.total_cost ?? '')) }}
BaseBadge(:variant='orderStatusDisplay(o.status).variant') {{ orderStatusDisplay(o.status).label }}
.issuance__line-name {{ line.name }}
.issuance__line-meta {{ line.quantity }} {{ marketplaceUnitShort(line.unit) }} · {{ formatAsset2Digits(line.total) }}
BaseBadge(:variant='orderStatusDisplay(line.status).variant') {{ orderStatusDisplay(line.status).label }}
//- Итог по заказчику — снизу, под выдачей (не в шапке карточки).
.issuance__card-foot
BaseBadge(variant='neutral') Позиций: {{ g.count }}
span.issuance__card-total {{ formatAsset2Digits(g.total) }}
EmptyState(
v-else,
@@ -220,7 +262,7 @@ q-page.issuance(role='region', aria-label='Выдача заказов')
IssueActOpenDialog(
v-model='openDialog',
:order='selectedOrder',
:orders='selectedOrders',
@opened='onOpened'
)
@@ -234,22 +276,21 @@ q-page.issuance(role='region', aria-label='Выдача заказов')
.issuance__resolve-account {{ pickupAccount }}
.issuance__resolve-hint(v-if='pickupOrders.length') Готовы к выдаче на этом пункте:
.issuance__resolve-empty(v-else) Все заказы этого заказчика уже выданы.
.issuance__resolve-item(v-for='o in pickupOrders', :key='o.id')
.issuance__resolve-item(v-for='line in pickupLines', :key='line.key')
.issuance__resolve-item-info
.issuance__resolve-item-title {{ o.product_name || 'Товар по предложению' }}
.issuance__resolve-item-meta {{ o.quantity }} {{ marketplaceUnitShort(o.unit_of_measure) }} · {{ orderStatusDisplay(o.status).label }}
BaseButton(
v-if='o.status === "ACCEPTED_TO_COOP"',
variant='primary',
size='sm',
@click='startIssuanceStep(o)'
)
template(#icon-left)
q-icon(name='draw', size='16px')
| Открыть
.issuance__await(v-else-if='o.status === "READY_TO_RECEIVE"')
q-icon(name='hourglass_empty', size='16px')
| Ждём заказчика
.issuance__resolve-item-title {{ line.name }}
.issuance__resolve-item-meta {{ line.quantity }} {{ marketplaceUnitShort(line.unit) }} · {{ formatAsset2Digits(line.total) }}
BaseBadge(:variant='orderStatusDisplay(line.status).variant') {{ orderStatusDisplay(line.status).label }}
//- Открываем выдачу разом по всем готовым позициям пайщика — одна операция.
BaseButton(
v-if='pickupToIssueCount',
variant='primary',
@click='startPickupIssuance'
)
template(#icon-left)
q-icon(name='draw', size='16px')
| Открыть выдачу{{ pickupToIssueCount > 1 ? ` (${pickupToIssueCount})` : '' }}
</template>
<style scoped lang="scss">
@@ -307,10 +348,15 @@ q-page.issuance(role='region', aria-label='Выдача заказов')
overflow-wrap: anywhere;
}
&__card-meta {
// Итог по заказчику снизу карточки, под списком выдачи.
&__card-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--p-2, 8px);
margin-top: auto;
padding-top: var(--p-3, 12px);
border-top: 1px solid var(--p-line);
}
&__card-total {
@@ -257,7 +257,8 @@ q-page.collective(role="region", aria-label="Коллективный заказ
<style scoped lang="scss">
.collective {
padding: 0 var(--p-4, 16px) var(--p-6, 24px);
// Воздух сверху как на столе поставщика — единый канон столов.
padding: var(--p-6, 24px) var(--p-4, 16px);
&__col {
max-width: 1120px;
@@ -276,7 +277,7 @@ q-page.collective(role="region", aria-label="Коллективный заказ
@media (max-width: 768px) {
.collective {
padding: 0 var(--p-3, 12px) var(--p-4, 16px);
padding: var(--p-4, 16px) var(--p-3, 12px);
}
}
</style>
@@ -238,7 +238,7 @@ q-page.order-detail(role="region", aria-label="Заказ")
OrdererFinalizeIssuanceDialog(
v-model="finalizeDialogOpen",
:order="order",
:orders="order ? [order] : []",
@finalized="onFinalized"
)
@@ -247,8 +247,8 @@ q-page.order-detail(role="region", aria-label="Заказ")
<style scoped lang="scss">
.order-detail {
// Воздух сверху: отделяем контент от шапки-топбара.
padding: var(--p-4, 16px) var(--p-4, 16px) var(--p-6, 24px);
// Воздух сверху как на столе поставщика — единый канон столов.
padding: var(--p-6, 24px) var(--p-4, 16px);
&__col {
max-width: 760px;
@@ -29,7 +29,7 @@
<q-space />
</div>
<q-card-section class="mp-takeover__body">
<q-card-section class="mp-takeover__body" :class="{ 'mp-takeover__body--wide': wide }">
<div v-if="leadText" :id="leadId" class="mp-takeover__lead">{{ leadText }}</div>
<slot />
</q-card-section>
@@ -69,6 +69,9 @@ const props = defineProps({
confirmLabel: { type: String, default: 'Подтвердить' },
loading: { type: Boolean, default: false },
disableConfirm: { type: Boolean, default: false },
// Широкий контент-контейнер для takeover'ов с таблицами (сводная выдача,
// приёмка): 720px тесно для многоколоночной сверки. Дефолт не меняется.
wide: { type: Boolean, default: false },
})
const emit = defineEmits<{
@@ -165,6 +168,10 @@ function onConfirm() {
max-width: 720px;
margin: 0 auto;
width: 100%;
&--wide {
max-width: 1100px;
}
}
&__lead {
@@ -92,7 +92,6 @@
- Стол председателя КУ:
- Закрывающая подпись приёмки: new/marketplace/branch-chairman/apl-reception-close.md
- Открытие выдачи: new/marketplace/branch-chairman/issuance-open.md
- Заказы участка: new/marketplace/branch-chairman/branch-orders.md
- Обработка возвратов: new/marketplace/branch-chairman/return-approve.md
- Стол председателя кооператива:
- Модерация предложений: new/marketplace/chairman/offer-moderation.md
@@ -138,7 +137,6 @@ components/docs-harness/scenarios/marketplace/
├── branch-chairman/ (фикстура chairkrg)
│ ├── apl-reception-close.mjs
│ ├── issuance-open.mjs
│ ├── branch-orders.mjs
│ └── return-approve.mjs
├── chairman/ (фикстура ant)
│ ├── offer-moderation.mjs
@@ -389,7 +387,6 @@ Magistral II разблокирована тремя коммитами PR #24 (
| `chairman/category-whitelist` | ChairmanCategoryWhitelist.vue | `/market/category-whitelist` | `50538b45d43` |
| `offerer/offers` | OffererMyOffers.vue | `/market/my-offers` | `69d3e79f36c` |
| `offerer/incoming-orders` | OffererIncomingOrders.vue | `/market/incoming-orders` | `69d3e79f36c` |
| `branch-chairman/branch-orders` | BranchChairmanBranchOrders.vue | `/market-pvz/branch-orders` | `d1bebf6f645` |
| `board/agenda-writeoff` | BoardAgendaWriteoff.vue | `/market/board-writeoff` | `2a41354c328` |
| `board/payouts-readonly` | BoardPayoutsReadonly.vue | `/market/payouts` | `261be9a2702` (placeholder, требует Phase 2 backend) |
| `orderer/consolidated` | OrdererConsolidated.vue | `/market/consolidated` | `e40b74fcf91` |
@@ -409,7 +406,6 @@ Magistral II разблокирована тремя коммитами PR #24 (
| `chairman/category-whitelist` | ✅ available-category-admin.resolver.ts | ✅ создано 4 wrappers (Get/Stats/Add/Remove) | Vue с диалогом ID-через-запятую (tree-выбор — следующий шаг) |
| `offerer/offers` | ✅ `marketplaceListMyOffers` (был всё время) | ✅ создан wrapper | Vue с canon `CatalogOfferCard`, client-side фильтр + поиск |
| `offerer/incoming-orders` | ✅ `marketplaceListSupplierOrders` (был всё время) | ✅ создан wrapper | Vue с canon `OrderCard role='offerer'`, фильтр по статусу |
| `branch-chairman/branch-orders` | ✅ 3 existing query (`*ByBraname`) | ✅ reuse существующих | Vue с 3 табами и Promise.all |
| `board/agenda-writeoff` | ✅ `marketplaceListWriteoffProposals` | ✅ reuse через AdminWriteoffs/api | Vue read-only лента, фильтр по статусу |
| `board/payouts-readonly` | ⚠️ требует доп. `Payment / read:all` policy | — | Informational placeholder со ссылками на нужные файлы |
| `orderer/consolidated` | ✅ `marketplaceListMyOrders` + cycle_id | ✅ reuse MyOrders/api | Vue с группировкой по cycle_id |
@@ -420,8 +416,7 @@ Magistral II разблокирована тремя коммитами PR #24 (
1. **`board/payouts-readonly` backend**: расширить `marketplace-access-matrix.ts` `Payment / read:all` и добавить query `marketplaceListOutgoingPayments` (опц. `supplier_account`).
2. **`chairman/category-whitelist` tree-выбор**: подключить `marketplaceGetCategoryTree` через диалог-tree.
3. **`branch-chairman/branch-orders` auto-detect braname**: подтянуть `marketplaceWhoAmI` или `marketplace_member_wallet` вместо ручного ввода (председатель КУ привязан к одному branch через trustee).
4. **~~Прогон harness'а~~ ✅ выполнен** — 9 PNG установлены, admonition сняты (коммит `ee06d405136`, 2026-05-22). MD-проза готова.
3. **~~Прогон harness'а~~ ✅ выполнен** — 9 PNG установлены, admonition сняты (коммит `ee06d405136`, 2026-05-22). MD-проза готова.
### 9.14. Backend-фоллоуап Эпика 1 реализован (вариант A) — magistral II разблокирована (2026-05-22 вечер)
@@ -604,7 +599,6 @@ Empty-state магистраль I покрыта. Магистраль II (ст
- `operator/inventory-label.mjs` — после APP
- `operator/warehouse.mjs` — реальный inventory
- `operator/issuance.mjs` — Order готов к выдаче
- `branch-chairman/branch-orders.mjs` — 3 таба с данными
- `orderer/ready-to-receive.mjs` — Order в RECEIVED
- `offerer/apl-reception-sign.mjs` — first signature
@@ -1,50 +0,0 @@
// Сценарий: Сводный стол КУ (председателя кооператива).
// Эпик 6 / Story 6.x — Приёмки, Выдачи, Возвраты по braname.
//
// Замечание: меж-роли «председатель КУ» в desktop matrix нет — роут
// /market-pvz/* открыт только `roles: ['chairman']` (председатель
// кооператива). На текущем MVP председатель сам открывает стол КУ для
// аудита. Отдельный access-уровень «branch chairman» подключится
// в Story 6.x+1 вместе с auto-detect braname через marketplaceWhoAmI.
import { loginAsChairman, dismissOnboardingDialogs } from '../../../lib/harness.mjs';
export const meta = {
title: 'Сводный стол КУ: приёмки, выдачи, возвраты',
docPath: 'new/marketplace/branch-chairman/branch-orders.md',
assetsDir: 'assets/new/marketplace/branch-chairman/branch-orders',
role: 'chairman',
};
export default async ({ page, context, shot, env }) => {
await loginAsChairman(page, context);
await dismissOnboardingDialogs(page);
await page.goto(`${env.BASE_URL}/#/${env.COOPNAME}/market-pvz/branch-orders`, {
waitUntil: 'domcontentloaded',
});
await page.waitForSelector('text=Сводный стол кооперативного участка', { timeout: 60000 });
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1000);
await dismissOnboardingDialogs(page);
await shot(
page,
'01-empty-input',
'Сводный стол КУ до ввода braname: подсказка «Введите ID вашего КУ» + поле ввода. Auto-detect через marketplace_whoami появится на следующем шаге Story 6.x+1 — председатель КУ привязан к одному branch через trustee.',
);
const branameInput = page.locator('input[aria-label*="braname"], label:has-text("braname") input, label:has-text("ID кооперативного участка") input').first();
if (await branameInput.isVisible().catch(() => false)) {
await branameInput.fill('krg');
await page.locator('button:has-text("Загрузить")').click();
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1500);
await dismissOnboardingDialogs(page);
await shot(
page,
'02-loaded-tabs',
'После ввода braname КУ Красногорск (krg) — карта с 3 табами и счётчиками: Приёмки (Эпик 5), Выдачи (Эпик 6), Возвраты (Эпик 7). Polling 20s — обновляет все 3 ленты параллельно через Promise.all.',
);
}
};
@@ -1,21 +0,0 @@
---
title: Сводный стол кооперативного участка
generated_by: docs-harness
scenario: marketplace/branch-chairman/branch-orders
---
# Сводный стол кооперативного участка
«Сводный стол КУ» собирает в одном месте все процессы участка: приёмки партий от поставщиков, выдачи пайщикам и гарантийные возвраты. Это обзорная панель председателя участка.
## Выбор участка
![Сводный стол до ввода кода: поле «ID кооперативного участка» и подсказка](/assets/new/marketplace/branch-chairman/branch-orders/01-empty-input.png)
Чтобы загрузить данные, председатель вводит **код своего участка** (например, `krg`) и нажимает **«Загрузить»**.
## Три ленты процессов
![После загрузки: вкладки «Приёмки», «Выдачи», «Возвраты» со счётчиками](/assets/new/marketplace/branch-chairman/branch-orders/02-loaded-tabs.png)
После загрузки появляются три вкладки со счётчиками: **Приёмки**, **Выдачи** и **Возвраты**. Каждая показывает активные элементы своего процесса; для действий председатель переходит на специализированные столы Стола ПВЗ. Когда активных элементов нет, вкладка показывает пустое сообщение (например, «Нет активных приёмок» — партии появятся здесь после подтверждения поставщиком).
-1
View File
@@ -209,7 +209,6 @@ nav:
- Стол председателя ПВЗ:
- Открытие выдачи: new/marketplace/branch-chairman/issuance-open.md
- Открытие выдачи (подпись председателя): new/marketplace/branch-chairman/issuance-open-sign.md
- Заказы участка: new/marketplace/branch-chairman/branch-orders.md
- Обработка возвратов: new/marketplace/branch-chairman/return-approve.md
- Стол председателя кооператива (Marketplace):
- Обзор Стола председателя: new/marketplace/chairman/dashboard.md