[598-33][@ant] feat: внести забытый фронт-WIP выдачи/склада и удаление «Сводного стола КУ» — собрать в одном PR #58 для совместного ревью и теста
This commit is contained in:
@@ -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 имена
|
||||
|
||||
|
||||
@@ -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';
|
||||
-248
@@ -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>
|
||||
+4
-5
@@ -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>
|
||||
|
||||
+184
-92
@@ -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>
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
+35
-4
@@ -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[]
|
||||
}
|
||||
|
||||
+216
-119
@@ -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>
|
||||
|
||||
+218
-111
@@ -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>
|
||||
|
||||
+99
-53
@@ -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 {
|
||||
|
||||
+3
-2
@@ -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>
|
||||
|
||||
+3
-3
@@ -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
|
||||
---
|
||||
|
||||
# Сводный стол кооперативного участка
|
||||
|
||||
«Сводный стол КУ» собирает в одном месте все процессы участка: приёмки партий от поставщиков, выдачи пайщикам и гарантийные возвраты. Это обзорная панель председателя участка.
|
||||
|
||||
## Выбор участка
|
||||
|
||||

|
||||
|
||||
Чтобы загрузить данные, председатель вводит **код своего участка** (например, `krg`) и нажимает **«Загрузить»**.
|
||||
|
||||
## Три ленты процессов
|
||||
|
||||

|
||||
|
||||
После загрузки появляются три вкладки со счётчиками: **Приёмки**, **Выдачи** и **Возвраты**. Каждая показывает активные элементы своего процесса; для действий председатель переходит на специализированные столы Стола ПВЗ. Когда активных элементов нет, вкладка показывает пустое сообщение (например, «Нет активных приёмок» — партии появятся здесь после подтверждения поставщиком).
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user