[598][@ant] feat: фронт списания скоропорта — таблица кандидатов (админ) + подтверждение на столе ПВЗ

Стол администратора: на странице списаний — выделяемая таблица кандидатов
(просроченный скоропорт на складах), «Новый черновик» активна при выделении и
собирает предзаполненный черновик из выбранного. Новая страница стола ПВЗ
marketplace-pvz-writeoffs: группы одобренных советом списаний по КУ оператора,
просмотр позиций и протокола совета, «Подтвердить списание» — подпись Служебной
записки 1111 (confirmwroff). SDK-обёртки запросов/мутации.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ant
2026-06-13 13:38:45 +00:00
parent 02841aba85
commit 725e63dfc1
8 changed files with 461 additions and 5 deletions
@@ -20,6 +20,7 @@ import { OffererSupplyPreparationPage } from 'src/pages/Marketplace/OffererSuppl
import { OffererShipPartyPage } from 'src/pages/Marketplace/OffererShipParty'
import { OffererPaymentHistoryPage } from 'src/pages/Marketplace/OffererPaymentHistory'
import { AdminWriteoffsPage } from 'src/pages/Marketplace/AdminWriteoffs'
import { PvzWriteoffsPage } from 'src/pages/Marketplace/PvzWriteoffs'
import { ChairmanModerationPage } from 'src/pages/Marketplace/ChairmanModeration'
import { AdminOrdersPage } from 'src/pages/Marketplace/AdminOrders'
import { AdminOffersPage } from 'src/pages/Marketplace/AdminOffers'
@@ -517,6 +518,21 @@ export default async function (): Promise<IWorkspaceConfig[]> {
agreements: agreementsBase,
},
},
{
// Эпик 8: operator-стол подтверждения списания со склада. Совет
// одобрил проект списания — председатель КУ подтверждает
// фактическое выбытие имущества, подписав Служебную записку 1111.
path: 'writeoffs',
name: 'marketplace-pvz-writeoffs',
component: markRaw(PvzWriteoffsPage),
meta: {
title: 'Списание со склада',
icon: 'fa-solid fa-trash-can-arrow-up',
requires: 'Writeoff:read:own-KU',
requiresAuth: true,
agreements: agreementsBase,
},
},
{
// Эпик 6 / Story 6.6: operator-стол выдачи имущества пайщику на ПВЗ.
path: 'issuance',
@@ -24,6 +24,17 @@ export type ISubmitWriteoffDraftInput =
export type IWriteoffStatementSignablePayloadInput =
Queries.Marketplace.WriteoffStatementSignablePayload.IInput['data'];
export type MarketplaceWriteoffCandidateView =
Queries.Marketplace.ListWriteoffCandidates.IOutput['marketplaceListWriteoffCandidates'][number];
export async function listWriteoffCandidates(): Promise<MarketplaceWriteoffCandidateView[]> {
const { [Queries.Marketplace.ListWriteoffCandidates.name]: result } = await client.Query(
Queries.Marketplace.ListWriteoffCandidates.query,
{},
);
return result;
}
export async function getOpenWriteoffDraft(): Promise<MarketplaceWriteoffProposalView | null> {
const { [Queries.Marketplace.OpenWriteoffDraft.name]: result } = await client.Query(
Queries.Marketplace.OpenWriteoffDraft.query,
@@ -2,15 +2,18 @@
import { computed, onMounted, ref } from 'vue';
import { debounce } from 'quasar';
import { Zeus } from '@coopenomics/sdk';
import { FailAlert } from 'src/shared/api';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { useMarketplaceRealtime } from 'src/shared/lib/marketplace';
import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits';
import { BaseBadge, BaseButton, BaseCard, CardListSkeleton } from 'src/shared/ui/base';
import type { BaseBadgeVariant } from 'src/shared/ui/base';
import { PageHint } from 'src/shared/ui/domain';
import {
createWriteoffDraft,
getOpenWriteoffDraft,
listWriteoffCandidates,
listWriteoffProposals,
type MarketplaceWriteoffCandidateView,
type MarketplaceWriteoffProposalView,
} from '../api';
import DraftEditorDialog from './DraftEditorDialog.vue';
@@ -39,6 +42,20 @@ const inCouncil = ref<MarketplaceWriteoffProposalView[]>([]);
const archive = ref<MarketplaceWriteoffProposalView[]>([]);
const loading = ref(false);
// Кандидаты на списание — просроченный скоропорт на складах. Председатель
// выделяет позиции и одной кнопкой собирает из них черновик проекта.
const candidates = ref<MarketplaceWriteoffCandidateView[]>([]);
const selectedCandidates = ref<MarketplaceWriteoffCandidateView[]>([]);
const creatingDraft = ref(false);
const candidateColumns = [
{ name: 'braname', align: 'left' as const, label: 'Кооп. участок', field: 'braname' },
{ name: 'asset_title', align: 'left' as const, label: 'Наименование', field: 'asset_title' },
{ name: 'quantity', align: 'right' as const, label: 'Кол-во', field: 'quantity' },
{ name: 'expiry_date', align: 'left' as const, label: 'Срок годности', field: 'expiry_date' },
{ name: 'amount', align: 'right' as const, label: 'Сумма', field: 'amount' },
];
const draftEditorOpen = ref(false);
const submitDialogOpen = ref(false);
const detailsOpen = ref(false);
@@ -47,21 +64,29 @@ const selected = ref<MarketplaceWriteoffProposalView | null>(null);
async function load(): Promise<void> {
loading.value = true;
try {
const [openDraft, councilPage, archivePage] = await Promise.all([
const [openDraft, councilPage, archivePage, candidatesList] = await Promise.all([
getOpenWriteoffDraft(),
listWriteoffProposals({ statuses: [
Zeus.MarketplaceWriteoffProposalStatus.ON_AGENDA,
Zeus.MarketplaceWriteoffProposalStatus.AUTHORIZED,
Zeus.MarketplaceWriteoffProposalStatus.PENDING_CONFIRMATION,
Zeus.MarketplaceWriteoffProposalStatus.EXECUTING,
] }),
listWriteoffProposals({ statuses: [
Zeus.MarketplaceWriteoffProposalStatus.EXECUTED,
Zeus.MarketplaceWriteoffProposalStatus.REJECTED,
] }),
listWriteoffCandidates(),
]);
draft.value = openDraft;
inCouncil.value = councilPage.items;
archive.value = archivePage.items;
candidates.value = candidatesList;
// Позиции, уже попавшие в открытый черновик, из выбора убираем.
if (selectedCandidates.value.length) {
const present = new Set(candidatesList.map((c) => c.inventory_id));
selectedCandidates.value = selectedCandidates.value.filter((c) => present.has(c.inventory_id));
}
} catch (e) {
FailAlert(e, 'Не удалось загрузить проекты списания');
} finally {
@@ -69,6 +94,42 @@ async function load(): Promise<void> {
}
}
function hasAmount(c: MarketplaceWriteoffCandidateView): boolean {
return Number.parseFloat(c.amount) > 0;
}
async function createDraftFromSelection(): Promise<void> {
const picked = selectedCandidates.value.filter(hasAmount);
if (picked.length === 0) {
FailAlert(
new Error('Нет позиций с известной стоимостью'),
'Выделите позиции с ненулевой суммой списания',
);
return;
}
creatingDraft.value = true;
try {
const created = await createWriteoffDraft({
items: picked.map((c) => ({
braname: c.braname,
asset_title: c.asset_title,
quantity: c.quantity,
amount: c.amount,
reason: c.reason,
inventory_id: c.inventory_id,
})),
});
draft.value = created;
selectedCandidates.value = [];
SuccessAlert('Черновик списания собран из выбранных позиций');
void load();
} catch (e) {
FailAlert(e, 'Не удалось создать черновик списания');
} finally {
creatingDraft.value = false;
}
}
function openDetails(proposal: MarketplaceWriteoffProposalView): void {
selected.value = proposal;
detailsOpen.value = true;
@@ -81,6 +142,7 @@ function statusVariant(status: MarketplaceWriteoffProposalView['status']): BaseB
case 'ON_AGENDA':
return 'accent';
case 'AUTHORIZED':
case 'PENDING_CONFIRMATION':
case 'EXECUTING':
return 'info';
case 'EXECUTED':
@@ -99,7 +161,9 @@ function humanStatus(status: MarketplaceWriteoffProposalView['status']): string
case 'ON_AGENDA':
return 'На повестке';
case 'AUTHORIZED':
return 'Утверждено';
return 'Утверждено советом';
case 'PENDING_CONFIRMATION':
return 'Ожидает подтверждения склада';
case 'EXECUTING':
return 'Идёт списание';
case 'EXECUTED':
@@ -155,11 +219,44 @@ q-page.writeoffs(role="region", aria-label="Списания скоропорт
| Сначала собирается черновик списания вручную председателем или автоматически по сроку годности. Затем председатель подписывает заявление и выносит проект на повестку совета. Совет утверждает списание протоколом, после чего имущество списывается со склада.
//- Главное действие страницы — в шапку (канон: CTA в топбаре, dense/primary).
//- Активна только когда выделены позиции-кандидаты; собирает из них черновик.
Teleport(to="#header-actions-host", defer)
BaseButton(v-if="!draft", variant="primary", size="sm", @click="draftEditorOpen = true")
BaseButton(
v-if="!draft",
variant="primary",
size="sm",
:disabled="selectedCandidates.length === 0",
:loading="creatingDraft",
@click="createDraftFromSelection"
)
template(#icon-left)
q-icon(name="add", size="18px")
| Новый черновик
| Новый черновик{{ selectedCandidates.length ? ` (${selectedCandidates.length})` : '' }}
//- Кандидаты на списание: выделяемая таблица просроченного скоропорта.
//- Один черновик за раз — пока есть открытый черновик, таблицу прячем.
BaseCard(v-if="!draft")
.writeoffs__candidates-head
.t-h3 Кандидаты на списание
.t-muted Просроченный скоропорт на складах кооператива. Выделите позиции и нажмите «Новый черновик» в шапке.
q-table.full-width.q-mt-sm(
flat,
:rows="candidates",
:columns="candidateColumns",
row-key="inventory_id",
selection="multiple",
v-model:selected="selectedCandidates",
:loading="loading",
:rows-per-page-options="[0]",
hide-bottom,
no-data-label="Просроченных позиций на складах не найдено"
)
template(#body-cell-quantity="props")
q-td.text-right(:props="props") {{ props.row.quantity }}
template(#body-cell-expiry_date="props")
q-td(:props="props") {{ formatDate(props.row.expiry_date) }}
template(#body-cell-amount="props")
q-td.text-right(:props="props") {{ formatAsset2Digits(props.row.amount) }}
BaseCard(v-if="draft")
.writeoffs__draft
@@ -0,0 +1,40 @@
import { Mutations, Queries, type Types } from '@coopenomics/sdk';
import { client } from 'src/shared/api/client';
export type MarketplaceWriteoffConfirmationGroupView =
Queries.Marketplace.WriteoffPendingConfirmations.IOutput['marketplaceWriteoffPendingConfirmations'][number];
export type MarketplaceWriteoffServiceMemoDocumentView = Types.Document.IGeneratedDocument;
export type IConfirmWriteoffInput = Mutations.Marketplace.ConfirmWriteoff.IInput['data'];
export type IServiceMemoSignablePayloadInput =
Queries.Marketplace.WriteoffServiceMemoSignablePayload.IInput['data'];
export async function listWriteoffPendingConfirmations(): Promise<
MarketplaceWriteoffConfirmationGroupView[]
> {
const { [Queries.Marketplace.WriteoffPendingConfirmations.name]: result } = await client.Query(
Queries.Marketplace.WriteoffPendingConfirmations.query,
{},
);
return result;
}
export async function getWriteoffServiceMemoSignablePayload(
data: IServiceMemoSignablePayloadInput,
): Promise<MarketplaceWriteoffServiceMemoDocumentView> {
const { [Queries.Marketplace.WriteoffServiceMemoSignablePayload.name]: result } =
await client.Query(Queries.Marketplace.WriteoffServiceMemoSignablePayload.query, {
variables: { data },
});
return result;
}
export async function confirmWriteoff(data: IConfirmWriteoffInput): Promise<unknown> {
const { [Mutations.Marketplace.ConfirmWriteoff.name]: result } = await client.Mutation(
Mutations.Marketplace.ConfirmWriteoff.mutation,
{ variables: { data } },
);
return result;
}
@@ -0,0 +1,2 @@
export { PvzWriteoffsPage } from './ui';
export * from './api';
@@ -0,0 +1,108 @@
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { useSessionStore } from 'src/entities/Session';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { DigitalDocument } from 'src/shared/lib/document';
import { BaseButton } from 'src/shared/ui/base';
import {
confirmWriteoff,
getWriteoffServiceMemoSignablePayload,
type MarketplaceWriteoffConfirmationGroupView,
type MarketplaceWriteoffServiceMemoDocumentView,
} from '../api';
/**
* Эпик 8: диалог подтверждения списания председателем кооперативного участка.
* Председатель смотрит preview Служебной записки о списании (registry 1111),
* подписывает приватным ключом — backend выполняет confirmwroff: имущество
* этого КУ фактически выбывает со склада.
*/
const props = defineProps<{
modelValue: boolean;
group: MarketplaceWriteoffConfirmationGroupView | null;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'confirmed'): void;
}>();
const session = useSessionStore();
const previewDoc = ref<MarketplaceWriteoffServiceMemoDocumentView | null>(null);
const loading = ref(false);
const submitting = ref(false);
watch(
() => props.modelValue,
async (open) => {
if (!open || !props.group) {
previewDoc.value = null;
return;
}
loading.value = true;
try {
previewDoc.value = await getWriteoffServiceMemoSignablePayload({
proposal_id: props.group.proposal_id,
braname: props.group.braname,
});
} catch (e) {
FailAlert(e, 'Не удалось сформировать Служебную записку о списании');
emit('update:modelValue', false);
} finally {
loading.value = false;
}
},
);
async function signAndConfirm(): Promise<void> {
if (!previewDoc.value || !props.group) return;
submitting.value = true;
try {
const digital = new DigitalDocument(previewDoc.value);
const signed = await digital.sign(session.username);
await confirmWriteoff({
proposal_id: props.group.proposal_id,
braname: props.group.braname,
signed_memo: signed,
});
SuccessAlert('Списание подтверждено — имущество выбыло со склада');
emit('confirmed');
emit('update:modelValue', false);
} catch (e) {
FailAlert(e, 'Не удалось подтвердить списание');
} finally {
submitting.value = false;
}
}
</script>
<template lang="pug">
q-dialog(
:model-value="modelValue",
@update:model-value="(v) => emit('update:modelValue', v)",
full-width,
persistent
)
q-card
q-card-section.row.items-center
.text-h6 Подтверждение списания со склада
q-space
q-btn(flat, round, icon="close", @click="emit('update:modelValue', false)")
q-card-section(v-if="loading")
q-spinner(size="24px")
span.q-ml-md.text-grey-7 Формируем Служебную записку
q-card-section(v-else-if="previewDoc")
.text-body2.text-grey-7.q-mb-md
| Подписав эту Служебную записку, вы подтверждаете фактическое списание имущества со склада участка «{{ group?.branch_name }}». Имущество выбудет со склада и будет снято с учёта.
q-card(flat, bordered).q-pa-md(style="max-height: 50vh; overflow: auto")
div(v-html="previewDoc.html")
q-card-section.row.items-center.q-gutter-sm(v-if="previewDoc")
q-space
BaseButton(variant="secondary", @click="emit('update:modelValue', false)") Отмена
BaseButton(variant="primary", :loading="submitting", @click="signAndConfirm")
template(#icon-left)
q-icon(name="task_alt", size="18px")
| Подписать и списать
</template>
@@ -0,0 +1,181 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { debounce } from 'quasar';
import { FailAlert } from 'src/shared/api';
import { useMarketplaceRealtime } from 'src/shared/lib/marketplace';
import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits';
import { BaseBadge, BaseButton, BaseCard, CardListSkeleton, EmptyState } from 'src/shared/ui/base';
import { PageHint } from 'src/shared/ui/domain';
import {
listWriteoffPendingConfirmations,
type MarketplaceWriteoffConfirmationGroupView,
} from '../api';
import ConfirmWriteoffDialog from './ConfirmWriteoffDialog.vue';
/**
* Эпик 8: стол ПВЗ — подтверждение списания со склада. Совет одобрил проект
* списания; председатель кооперативного участка видит здесь группы своих
* участков и подтверждает фактическое выбытие имущества со склада, подписывая
* Служебную записку о списании (registry 1111).
*/
const groups = ref<MarketplaceWriteoffConfirmationGroupView[]>([]);
const loading = ref(false);
const confirmOpen = ref(false);
const selectedGroup = ref<MarketplaceWriteoffConfirmationGroupView | null>(null);
const expanded = ref<Record<string, boolean>>({});
const itemColumns = [
{ name: 'asset_title', align: 'left' as const, label: 'Наименование', field: 'asset_title' },
{ name: 'quantity', align: 'right' as const, label: 'Кол-во', field: 'quantity' },
{ name: 'amount', align: 'right' as const, label: 'Сумма', field: 'amount' },
{ name: 'reason', align: 'left' as const, label: 'Причина', field: 'reason' },
];
function groupKey(g: MarketplaceWriteoffConfirmationGroupView): string {
return `${g.proposal_id}:${g.braname}`;
}
async function load(): Promise<void> {
loading.value = true;
try {
groups.value = await listWriteoffPendingConfirmations();
} catch (e) {
FailAlert(e, 'Не удалось загрузить списания на подтверждение');
} finally {
loading.value = false;
}
}
function openConfirm(g: MarketplaceWriteoffConfirmationGroupView): void {
selectedGroup.value = g;
confirmOpen.value = true;
}
function toggleProtocol(g: MarketplaceWriteoffConfirmationGroupView): void {
const k = groupKey(g);
expanded.value[k] = !expanded.value[k];
}
function protocolHtml(g: MarketplaceWriteoffConfirmationGroupView): string | null {
const doc = g.protocol_doc as { html?: string } | null;
return doc?.html ?? null;
}
function onConfirmed(): void {
void load();
}
const reloadLive = debounce(() => {
if (loading.value) return;
void load();
}, 400);
useMarketplaceRealtime(
{ MarketplaceWriteoffStatusChangedEvent: () => reloadLive() },
{ onResync: () => reloadLive() },
);
onMounted(() => {
void load();
});
</script>
<template lang="pug">
q-page.pvz-writeoffs(role="region", aria-label="Списание со склада")
PageHint(storage-key="mp:pvz-writeoffs:banner-dismissed")
| Совет одобрил списание имущества со складов. Подтвердите фактическое списание со склада своего участка для этого подпишите Служебную записку о списании. Только после вашей подписи имущество выбывает со склада.
CardListSkeleton(v-if="loading && !groups.length", :count="2")
.pvz-writeoffs__empty(v-if="!loading && groups.length === 0")
EmptyState(
title="Нет списаний на подтверждение",
body="Когда совет одобрит проект списания по вашему участку, он появится здесь."
)
template(#icon)
q-icon(name="inventory_2", size="48px")
BaseCard(v-for="g in groups", :key="groupKey(g)")
.pvz-writeoffs__card
.pvz-writeoffs__head
div
.t-h3 {{ g.branch_name }}
.t-muted {{ g.items.length }} позиций · {{ formatAsset2Digits(g.total_amount) }}
BaseBadge(variant="info") Ожидает подтверждения
q-table.full-width.q-mt-sm(
flat,
:rows="g.items",
:columns="itemColumns",
row-key="inventory_id",
hide-bottom,
:rows-per-page-options="[0]"
)
template(#body-cell-quantity="props")
q-td.text-right(:props="props") {{ props.row.quantity }}
template(#body-cell-amount="props")
q-td.text-right(:props="props") {{ formatAsset2Digits(props.row.amount) }}
.pvz-writeoffs__actions
BaseButton(variant="ghost", size="sm", @click="toggleProtocol(g)")
template(#icon-left)
q-icon(name="gavel", size="16px")
| {{ expanded[groupKey(g)] ? 'Скрыть протокол' : 'Протокол совета' }}
q-space
BaseButton(variant="primary", @click="openConfirm(g)")
template(#icon-left)
q-icon(name="task_alt", size="16px")
| Подтвердить списание
q-card(v-if="expanded[groupKey(g)]", flat, bordered).q-mt-sm.q-pa-md(
style="max-height: 40vh; overflow: auto"
)
div(v-if="protocolHtml(g)", v-html="protocolHtml(g)")
.t-muted(v-else) Протокол совета недоступен для просмотра.
ConfirmWriteoffDialog(
v-model="confirmOpen",
:group="selectedGroup",
@confirmed="onConfirmed"
)
</template>
<style lang="scss" scoped>
.pvz-writeoffs {
padding: var(--p-6, 24px);
display: flex;
flex-direction: column;
gap: var(--p-4, 16px);
&__card {
display: flex;
flex-direction: column;
gap: var(--p-2, 8px);
}
&__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--p-3, 12px);
}
&__actions {
display: flex;
align-items: center;
gap: var(--p-2, 8px);
margin-top: var(--p-2, 8px);
}
&__empty {
display: flex;
justify-content: center;
}
}
@media (max-width: 768px) {
.pvz-writeoffs {
padding: var(--p-4, 16px);
}
}
</style>
@@ -0,0 +1 @@
export { default as PvzWriteoffsPage } from './PvzWriteoffsPage.vue';