[598-50][@ant] feat: докладка со склада в окне выдачи — плюсик «Со склада» + секция доложенного
Оператор в окне «Открытие выдачи пайщику» кнопкой «Со склада» открывает выбор опубликованного остатка КУ (StockPickDialog), набирает позиции — они встают секцией «Доложено со склада» в том же акте и при «Подписать и открыть выдачу» создаются предложением пайщику (createStockProposal). Зачем: «это не пришло — возьмите вот это» оформляется в одном окне выдачи, без отдельного экрана докладки. Инкремент 1 (сторона оператора); две суммы и слияние подписи у пайщика — следом.
This commit is contained in:
+134
-1
@@ -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>
|
||||
Reference in New Issue
Block a user