[598-50][@ant] feat: докладка со склада в окне выдачи — плюсик «Со склада» + секция доложенного

Оператор в окне «Открытие выдачи пайщику» кнопкой «Со склада» открывает выбор
опубликованного остатка КУ (StockPickDialog), набирает позиции — они встают
секцией «Доложено со склада» в том же акте и при «Подписать и открыть выдачу»
создаются предложением пайщику (createStockProposal). Зачем: «это не пришло —
возьмите вот это» оформляется в одном окне выдачи, без отдельного экрана докладки.
Инкремент 1 (сторона оператора); две суммы и слияние подписи у пайщика — следом.
This commit is contained in:
ant
2026-06-16 05:25:53 +00:00
parent 91d5f33a79
commit b38773c647
2 changed files with 369 additions and 1 deletions
@@ -3,7 +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 { BaseButton, BaseBadge } from 'src/shared/ui/base';
import { TakeoverDialog } from 'src/widgets/Marketplace/TakeoverDialog';
import { CorrectionTable, type CorrectionRow } from 'src/widgets/Marketplace/CorrectionTable';
import {
@@ -16,8 +16,10 @@ import { marketplaceUnitShort } from 'src/shared/lib/consts/marketplace-units';
import {
getChairmanSignablePayload,
openIssuance,
createStockProposal,
type MarketplaceOrderIssuanceView,
} from '../api';
import StockPickDialog, { type StockPickLine } from './StockPickDialog.vue';
/**
* Story 6.1 / FR21: full-screen takeover для открытия выдачи на ПВЗ —
@@ -64,6 +66,35 @@ const facts = ref<Record<string, FactState>>({});
const previewHtml = ref<string>('');
const previewLoading = ref(false);
const signing = ref(false);
// ── Докладка со склада (requirement 76): оператор добавляет в этот же акт
// опубликованный остаток КУ; пайщику он уйдёт вместе с заказом (принятие и
// подпись — у пайщика). Здесь — только набор корзины сверх заказа. ─────────
const stockPickOpen = ref(false);
const restockLines = ref<StockPickLine[]>([]);
const recipientAccount = computed(() => props.orders[0]?.orderer_account ?? '');
const issueBraname = computed(() => props.orders[0]?.delivery_braname ?? '');
const restockTotal = computed(() =>
restockLines.value
.reduce((sum, l) => sum + l.quantity * Number.parseFloat(l.price_per_unit), 0)
.toFixed(4),
);
function restockLineSum(l: StockPickLine): string {
return (Number.parseFloat(l.price_per_unit) * l.quantity).toFixed(4);
}
function onAddRestock(lines: StockPickLine[]): void {
const map = new Map(restockLines.value.map((l) => [l.offer_id, { ...l }]));
for (const line of lines) {
const existing = map.get(line.offer_id);
if (existing) existing.quantity += line.quantity;
else map.set(line.offer_id, { ...line });
}
restockLines.value = [...map.values()];
}
function removeRestock(offer_id: string): void {
restockLines.value = restockLines.value.filter((l) => l.offer_id !== offer_id);
}
// Единый паттерн «Показать / Скрыть акты»: таблица сверки прячется при показе.
const { showActs, toggleActs, resetActs } = useActsPreview(loadPreview, previewHtml);
@@ -212,6 +243,7 @@ watch(
if (visible && props.orders.length) {
initFacts();
resetActs();
restockLines.value = [];
if (!feePercent.value) {
getMembershipFeePercent()
.then((p) => (feePercent.value = p))
@@ -321,6 +353,22 @@ async function confirm(): Promise<void> {
}
}),
);
// Докладка со склада: создаём предложение тем же действием — пайщику оно
// уйдёт в гейт вместе с заказом (принятие и подпись получения — у пайщика).
if (restockLines.value.length && recipientAccount.value && issueBraname.value) {
try {
await createStockProposal({
braname: issueBraname.value,
member_account: recipientAccount.value,
items: restockLines.value.map((l) => ({ offer_id: l.offer_id, quantity: l.quantity })),
});
restockLines.value = [];
} catch (e) {
FailAlert(e, 'Не удалось доложить со склада');
}
}
signing.value = false;
if (ok > 0) emit('opened');
@@ -367,8 +415,26 @@ TakeoverDialog(
| остаются на складе и в эту выдачу не попадают выдаём частично.
template(v-if="!showActs")
.mp-issue-open-dialog__toolbar
BaseButton(variant="ghost" size="sm" @click="stockPickOpen = true")
template(#icon-left)
q-icon(name="add_shopping_cart" size="16px")
| Со склада
CorrectionTable(:rows="correctionRows" selectable @change="onCorrectionChange" @toggle="onCorrectionToggle")
//- Доложено со склада — позиции, добавленные оператором сверх заказа.
.mp-issue-open-dialog__restock(v-if="restockLines.length")
.mp-issue-open-dialog__restock-head
BaseBadge(variant="info") Доложено со склада
.mp-issue-open-dialog__restock-row(v-for="l in restockLines" :key="l.offer_id")
.mp-issue-open-dialog__restock-info
span.mp-issue-open-dialog__restock-name {{ l.product_name }}
span.mp-issue-open-dialog__restock-meta {{ formatAsset2Digits(l.price_per_unit) }} × {{ l.quantity }}
.mp-issue-open-dialog__restock-right
span.mp-issue-open-dialog__restock-sum {{ formatAsset2Digits(restockLineSum(l)) }}
BaseButton(variant="ghost" size="sm" @click="removeRestock(l.offer_id)")
q-icon(name="close" size="16px")
.mp-issue-open-dialog__totals
.mp-issue-open-dialog__sum
span.mp-issue-open-dialog__sum-label К выдаче ({{ includedCount }} из {{ positionsCount }} позиц.)
@@ -382,6 +448,9 @@ TakeoverDialog(
.mp-issue-open-dialog__sum(v-if="issuanceDiff.surcharge > 0")
span.mp-issue-open-dialog__sum-label Доплата по факту (спишется с паевого)
span.mp-issue-open-dialog__sum-value {{ formatAsset2Digits(issuanceDiff.surcharge.toFixed(4)) }}
.mp-issue-open-dialog__sum(v-if="restockLines.length")
span.mp-issue-open-dialog__sum-label Доложено со склада
span.mp-issue-open-dialog__sum-value {{ formatAsset2Digits(restockTotal) }}
//- Почему кнопка «Подписать и открыть выдачу» недоступна — явно, чтобы
//- оператор не упирался в перечёркнутую кнопку без объяснения.
@@ -391,6 +460,12 @@ TakeoverDialog(
.mp-issue-open-dialog__preview(v-if="showActs", v-html="previewHtml")
StockPickDialog(
v-model="stockPickOpen"
:braname="issueBraname"
@add="onAddRestock"
)
template(#actions="{ cancel: onCancel, confirm: onConfirm }")
BaseButton(variant="ghost" @click="onCancel") Закрыть
BaseButton(
@@ -425,6 +500,64 @@ TakeoverDialog(
line-height: 1.4;
}
&__toolbar {
display: flex;
justify-content: flex-end;
}
&__restock {
display: flex;
flex-direction: column;
gap: var(--p-2, 8px);
padding: var(--p-3, 12px);
border: 1px solid var(--p-info-line, var(--p-line));
border-radius: var(--p-r-md, 12px);
background: var(--p-info-soft);
&-head {
display: flex;
}
&-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--p-2, 8px);
}
&-info {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
&-name {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink);
overflow-wrap: anywhere;
}
&-meta {
font-size: var(--p-fs-meta, 12px);
color: var(--p-ink-3);
font-variant-numeric: tabular-nums;
}
&-right {
display: flex;
align-items: center;
gap: var(--p-2, 8px);
flex: 0 0 auto;
}
&-sum {
font-weight: 600;
color: var(--p-ink);
font-variant-numeric: tabular-nums;
}
}
&__totals {
display: flex;
flex-direction: column;
@@ -0,0 +1,235 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Queries } from '@coopenomics/sdk';
import { client } from 'src/shared/api/client';
import { BaseDialog, BaseButton, BaseBadge } from 'src/shared/ui/base';
import { FailAlert } from 'src/shared/api';
import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits';
/**
* Выбор имущества со склада кооператива для докладки в выдачу («это не пришло —
* возьмите вот это» / магазин без предзаказа). Открывается поверх окна выдачи;
* оператор набирает позиции опубликованного остатка ЭТОГО КУ и добавляет их в
* тот же акт. Создание заказа из остатка и подпись — на стороне окна выдачи,
* здесь только набор корзины.
*/
export interface StockPickLine {
offer_id: string;
product_name: string;
price_per_unit: string;
quantity: number;
}
type CoopStockOffer = {
id: string;
product_name: string;
price_per_unit: string;
quantity_available: number;
stock_braname: string | null;
};
const props = defineProps<{
modelValue: boolean;
braname: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void;
(e: 'add', lines: StockPickLine[]): void;
}>();
const offers = ref<CoopStockOffer[]>([]);
const loading = ref(false);
const quantities = ref<Record<string, number>>({});
const composed = computed(() =>
offers.value
.map((o) => ({ offer: o, quantity: quantities.value[o.id] ?? 0 }))
.filter((l) => l.quantity > 0),
);
const total = computed(() =>
composed.value
.reduce((sum, l) => sum + l.quantity * Number.parseFloat(l.offer.price_per_unit), 0)
.toFixed(4),
);
async function loadOffers(): Promise<void> {
loading.value = true;
try {
const { [Queries.Marketplace.ListCatalog.name]: page } = await client.Query(
Queries.Marketplace.ListCatalog.query,
{ variables: { input: { delivery_braname: props.braname, page: 1, limit: 200 } } },
);
// Докладываем только опубликованный остаток СО СКЛАДА этого КУ.
offers.value = (page.items as CoopStockOffer[]).filter(
(o) => o.stock_braname === props.braname && o.quantity_available > 0,
);
} catch (e) {
FailAlert(e);
} finally {
loading.value = false;
}
}
function bump(offer: CoopStockOffer, delta: number): void {
const current = quantities.value[offer.id] ?? 0;
const next = Math.max(0, Math.min(offer.quantity_available, current + delta));
quantities.value = { ...quantities.value, [offer.id]: next };
}
function confirmAdd(): void {
emit(
'add',
composed.value.map((l) => ({
offer_id: l.offer.id,
product_name: l.offer.product_name,
price_per_unit: l.offer.price_per_unit,
quantity: l.quantity,
})),
);
quantities.value = {};
emit('update:modelValue', false);
}
watch(
() => props.modelValue,
(open) => {
if (open) {
quantities.value = {};
void loadOffers();
}
},
);
</script>
<template lang="pug">
BaseDialog(
:model-value="modelValue"
title="Доложить со склада"
size="md"
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
)
.stock-pick
.stock-pick__intro
| Опубликованный остаток этого пункта выдачи. Наберите позиции они
| добавятся в тот же акт и уйдут пайщику вместе с заказом.
.stock-pick__empty(v-if="!loading && !offers.length")
BaseBadge(variant="neutral") Опубликованного остатка на этом пункте нет
.stock-pick__list(v-else)
.stock-pick__row(v-for="o in offers", :key="o.id")
.stock-pick__info
span.stock-pick__name {{ o.product_name }}
span.stock-pick__meta
| {{ formatAsset2Digits(o.price_per_unit) }} · свободно {{ o.quantity_available }}
.stock-pick__qty
BaseButton(
variant="ghost"
size="sm"
:disabled="!(quantities[o.id] ?? 0)"
@click="bump(o, -1)"
)
q-icon(name="remove", size="16px")
span.stock-pick__count {{ quantities[o.id] ?? 0 }}
BaseButton(
variant="ghost"
size="sm"
:disabled="(quantities[o.id] ?? 0) >= o.quantity_available"
@click="bump(o, 1)"
)
q-icon(name="add", size="16px")
template(#footer)
.stock-pick__foot
span.stock-pick__total(v-if="composed.length")
| Выбрано: {{ formatAsset2Digits(total) }}
q-space
BaseButton(variant="ghost", @click="emit('update:modelValue', false)") Отмена
BaseButton(variant="primary", :disabled="!composed.length", @click="confirmAdd")
template(#icon-left)
q-icon(name="add_shopping_cart", size="16px")
| Добавить в выдачу
</template>
<style scoped lang="scss">
.stock-pick {
display: flex;
flex-direction: column;
gap: var(--p-3, 12px);
&__intro {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink-3);
line-height: 1.4;
}
&__empty {
display: flex;
}
&__list {
display: flex;
flex-direction: column;
gap: var(--p-2, 8px);
max-height: 50vh;
overflow: auto;
}
&__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--p-2, 8px);
padding: var(--p-2, 8px) 0;
border-bottom: 1px solid var(--p-line);
}
&__info {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
&__name {
font-size: var(--p-fs-body-sm, 13px);
color: var(--p-ink);
overflow-wrap: anywhere;
}
&__meta {
font-size: var(--p-fs-meta, 12px);
color: var(--p-ink-3);
font-variant-numeric: tabular-nums;
}
&__qty {
display: flex;
align-items: center;
gap: var(--p-1, 4px);
flex: 0 0 auto;
}
&__count {
min-width: 24px;
text-align: center;
font-variant-numeric: tabular-nums;
color: var(--p-ink);
}
&__foot {
display: flex;
align-items: center;
gap: var(--p-2, 8px);
width: 100%;
}
&__total {
font-weight: 600;
color: var(--p-ink);
font-variant-numeric: tabular-nums;
}
}
</style>