[598-51][@ant] fix: показать заказчику и поставщику корректную сумму с членским взносом на всех экранах Стола заказов, добить точечные UI-баги и TS-ошибки — реестр поставщиков и пункты выдачи получили ФИО/наименование вместо голого username, стол администратора и онбординг поставщика поправлены по прошлым замечаниям, докладка у стойки выдачи пропускала обязательную подпись оператора и падала бы на бэке, плюс закрыты оставшиеся unknown/nullable ошибки компиляции desktop

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ant
2026-07-21 08:44:52 +00:00
parent 5dd65c5de7
commit d7f88b661f
35 changed files with 219 additions and 79 deletions
@@ -708,7 +708,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
component: markRaw(AdminOffersPage),
meta: {
title: 'Реестр предложений',
icon: 'storefront',
icon: 'sell',
requires: 'Offer:read:all',
requiresAuth: true,
agreements: agreementsBase,
@@ -209,7 +209,7 @@
EntityIdBadge(
v-if='props.row.processHash'
:rawId='shortHash(props.row.processHash)'
@click='copyFullHash(props.row.processHash)'
@click='copyText(props.row.processHash)'
)
</template>
@@ -26,6 +26,7 @@ export default ssrMiddleware(({ app }) => {
VAPID_PUBLIC_KEY: process.env.VAPID_PUBLIC_KEY as string,
SENTRY_DSN: process.env.SENTRY_DSN as string,
OPENREPLAY_PROJECT_KEY: process.env.OPENREPLAY_PROJECT_KEY as string,
YANDEX_MAPS_API_KEY: process.env.YANDEX_MAPS_API_KEY as string,
};
// Создаем скрипт, который добавит переменные в window.__ENV__
-1
View File
@@ -1,5 +1,4 @@
export { default as init } from './init';
export { default as axios } from './axios';
export { default as sentry } from './sentry';
export { default as map } from './map';
export { default as chatwoot } from './chatwoot';
@@ -14,7 +14,7 @@ import { GeocodeStatus, KuDetailsStatus } from 'src/entities/MarketplaceKUDetail
import type { IMarketplaceKUDetails } from 'src/entities/MarketplaceKUDetails'
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 { IdentityCell, PageHint } from 'src/shared/ui/domain'
import { useMarketplaceRealtime } from 'src/shared/lib/marketplace'
// Map экспортируется как `Map` — импортируем под алиасом, чтобы не затенять
// глобальный `Map` (используется в `rows`).
@@ -81,6 +81,10 @@ function statusOf(row: IssuancePointRow): { label: string; variant: BaseBadgeVar
return STATUS_LABEL[row.details.status]
}
function branchName(row: IssuancePointRow): string {
return row.branch.short_name || row.branch.full_name || ''
}
function addressOf(row: IssuancePointRow): string {
return (
row.details?.addressFull ||
@@ -213,8 +217,10 @@ q-page.admin-pvz
tbody
tr(v-for='row in rows', :key='row.branch.braname')
td.col-ku
.admin-pvz__ku-name {{ row.branch.short_name || row.branch.full_name || row.branch.braname }}
.admin-pvz__ku-acc {{ row.branch.braname }}
IdentityCell(
:account-name='row.branch.braname',
:full-name='branchName(row)'
)
td.col-city {{ row.branch.city || '—' }}
td.admin-pvz__address {{ addressOf(row) }}
td.col-status
@@ -334,16 +340,6 @@ q-page.admin-pvz
font-size: 0.875rem;
}
&__ku-name {
font-weight: 600;
}
&__ku-acc {
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--p-ink-3);
}
&__address {
color: var(--p-ink-2);
}
@@ -90,8 +90,11 @@ function shortId(id: string | null | undefined): string {
function unitLabel(o: AdminOrderView): string {
return marketplaceUnitShort(o.unit_of_measure);
}
function formatTotal(v: string | null | undefined): string {
return v ? formatAsset2Digits(String(v)) : '—';
// requirement b6: реестр показывает сумму, которую реально заплатил пайщик
// (себестоимость + членский взнос, зафиксированный в заказе контрактом) —
// не голую себестоимость товара.
function formatTotalWithFee(o: AdminOrderView): string {
return formatAsset2Digits(o.total_cost_with_fee);
}
function formatDate(d: unknown): string {
if (d === null || d === undefined) return '—';
@@ -255,7 +258,7 @@ q-page.admin-orders(role="region", aria-label="Реестр заказов ко
q-td {{ ordererTitle(props.row) }}
q-td {{ supplierTitle(props.row) }}
q-td.text-right {{ props.row.quantity }} {{ unitLabel(props.row) }}
q-td.text-right.font-monospace {{ formatTotal(props.row.total_cost) }}
q-td.text-right.font-monospace {{ formatTotalWithFee(props.row) }}
q-td {{ formatDate(props.row.created_at) }}
q-tr.q-virtual-scroll--with-prev(
@@ -11,7 +11,7 @@ import {
import { BaseCard, BaseButton, BaseChip, EmptyState } from 'src/shared/ui/base';
import { KUHeaderBar } from 'src/widgets/Marketplace/KUHeaderBar';
import { marketplaceUnitShort } from 'src/shared/lib/consts';
import { useMarketplaceRealtime, getMembershipFeePercent } from 'src/shared/lib/marketplace';
import { useMarketplaceRealtime, getMembershipFeePercent, applyMembershipFee } from 'src/shared/lib/marketplace';
/**
* Эпик 16 / Story 16.1 + 16.2: страница корзины заказчика и оформление.
@@ -35,12 +35,12 @@ const coopname = computed(() => String(route.params.coopname ?? ''));
const symbol = computed(() => system.governSymbol);
function unitShort(u: string): string {
return marketplaceUnitShort(u as Parameters<typeof marketplaceUnitShort>[0]);
function unitShort(u: string | null | undefined): string {
return marketplaceUnitShort((u ?? '') as Parameters<typeof marketplaceUnitShort>[0]);
}
function money(value: string | number): string {
return Number(value).toLocaleString('ru-RU');
function money(value: string | number | null | undefined): string {
return Number(value ?? 0).toLocaleString('ru-RU');
}
// Низкоуровневый коммит количества (целое ≥ 1). Кламп делает changeQty.
@@ -156,8 +156,8 @@ useMarketplaceRealtime(
// requirement b6: членский взнос входит в общую стоимость заказа — сводка
// показывает его отдельной строкой и итог с учётом взноса.
const feePercent = ref(0);
const feeAmount = computed(() => (cartStore.totalCost * feePercent.value) / 100);
const totalWithFee = computed(() => cartStore.totalCost + feeAmount.value);
const totalWithFee = computed(() => applyMembershipFee(Number(cartStore.totalCost), feePercent.value));
const feeAmount = computed(() => totalWithFee.value - Number(cartStore.totalCost));
onMounted(async () => {
try {
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { useSystemStore } from 'src/entities/System/model';
import { useMarketplaceCartStore } from 'src/entities/MarketplaceCart';
import { applyMembershipFee } from 'src/shared/lib/marketplace';
import { BaseDialog, BaseInput, BaseButton } from 'src/shared/ui/base';
import type { MarketplaceOfferView } from '../types';
@@ -29,10 +30,16 @@ const cartStore = useMarketplaceCartStore();
const router = useRouter();
const route = useRoute();
const props = defineProps<{
const props = withDefaults(
defineProps<{
modelValue: boolean;
offer: CartOffer | null;
}>();
// Единая ставка членского взноса кооператива, проценты (requirement b6).
// Цена и итог показываются заказчику с учётом взноса — как в каталоге.
feePercent?: number;
}>(),
{ feePercent: 0 },
);
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
@@ -64,10 +71,14 @@ const maxQuantity = computed(() => {
return props.offer.quantity_available;
});
const totalSum = computed(() => {
const priceWithFee = computed(() => {
if (!props.offer) return 0;
return applyMembershipFee(Number(props.offer.price_per_unit), props.feePercent);
});
const totalSum = computed(() => {
const q = Number(quantity.value) || 0;
return q * Number(props.offer.price_per_unit);
return q * priceWithFee.value;
});
// Сколько этой позиции уже в корзине — подсказка, чтобы заказчик не дублировал.
@@ -148,7 +159,9 @@ BaseDialog(
.add-to-cart__note(v-if="alreadyInCart > 0")
| Уже в корзине: {{ alreadyInCart }} {{ unitLabel }} добавление суммируется.
.add-to-cart__price(v-if="offer")
| Цена: {{ Number(offer.price_per_unit).toLocaleString('ru-RU') }} {{ system.governSymbol }} за {{ unitLabel }}
| Цена: {{ priceWithFee.toLocaleString('ru-RU') }} {{ system.governSymbol }} за {{ unitLabel }}
template(v-if="feePercent > 0")
| (с членским взносом {{ feePercent }}%)
.add-to-cart__total(v-if="offer")
| Итого: {{ totalSum.toLocaleString('ru-RU') }} {{ system.governSymbol }}
template(#footer)
@@ -410,7 +410,8 @@ q-page.catalog(role="region", aria-label="Каталог Стола заказо
AddToCartDialog(
v-model="cartDialogOpen",
:offer="cartDialogOffer"
:offer="cartDialogOffer",
:fee-percent="feePercent"
)
</template>
@@ -12,5 +12,5 @@ export async function fetchOffer(id: string): Promise<MarketplaceOfferDetailView
Queries.Marketplace.GetOffer.query,
{ variables: { id } },
);
return offer;
return offer as MarketplaceOfferDetailView | null;
}
@@ -9,7 +9,7 @@ import { OfferGallery } from 'src/widgets/Marketplace/OfferGallery';
import { CartHeaderButton } from 'src/widgets/Marketplace/CartHeaderButton';
import { marketplaceUnitShort } from 'src/shared/lib/consts';
import { marketplaceOfferImageUrls } from 'src/shared/lib/utils';
import { useMarketplaceRealtime } from 'src/shared/lib/marketplace';
import { useMarketplaceRealtime, getMembershipFeePercent, applyMembershipFee } from 'src/shared/lib/marketplace';
import { useMarketplaceCartStore } from 'src/entities/MarketplaceCart';
import { useOfferModeration } from 'src/features/Marketplace/OfferModeration';
import { fetchCategories } from '../../MarketplaceCatalog/api';
@@ -105,9 +105,16 @@ const stockLabel = computed(() => {
: `В наличии: ${offer.value.quantity_available} ${unitShort.value}`;
});
// requirement b6: единая ставка членского взноса входит в цену, которую видит
// заказчик — как в карточке каталога (CatalogOfferCard). Раздельная
// себестоимость без взноса остаётся только на столе поставщика.
const feePercent = ref(0);
const priceWithFee = computed(() =>
offer.value ? applyMembershipFee(Number(offer.value.price_per_unit), feePercent.value) : 0,
);
const priceLabel = computed(() =>
offer.value
? `${Number(offer.value.price_per_unit).toLocaleString('ru-RU')} ${system.governSymbol} / ${unitShort.value}`
? `${priceWithFee.value.toLocaleString('ru-RU')} ${system.governSymbol} / ${unitShort.value}`
: '',
);
@@ -157,6 +164,11 @@ useMarketplaceRealtime(
);
onMounted(async () => {
try {
feePercent.value = await getMembershipFeePercent();
} catch {
// Без ставки показываем цену без строки взноса.
}
await load();
// В режиме заказа нужен текущий КУ (для кнопки «В корзину» и диалога).
if (!readonly.value) {
@@ -209,6 +221,7 @@ q-page.offer-detail(role="region", aria-label="Описание предложе
span {{ offer.supplier_name }}
.offer-detail__price {{ priceLabel }}
.offer-detail__fee-note(v-if="feePercent > 0") Цена с членским взносом {{ feePercent }}%
BaseButton(
v-if="!readonly",
@@ -257,7 +270,8 @@ q-page.offer-detail(role="region", aria-label="Описание предложе
AddToCartDialog(
v-if="!readonly",
v-model="cartDialogOpen",
:offer="offer"
:offer="offer",
:fee-percent="feePercent"
)
</template>
@@ -333,6 +347,12 @@ q-page.offer-detail(role="region", aria-label="Описание предложе
color: var(--p-primary-strong);
}
&__fee-note {
font-size: var(--p-fs-body-sm);
color: var(--p-ink-3);
margin-top: calc(var(--p-1, 4px) * -1);
}
&__sections {
display: flex;
flex-direction: column;
@@ -143,13 +143,13 @@ function groupByCheckout(list: MarketplaceOrderView[]): OrderGroup[] {
}
const result: OrderGroup[] = Array.from(map.entries()).map(([key, orders]) => {
const first = orders[0];
const recent = Math.max(...orders.map((o) => new Date(o.updated_at).getTime()));
const recent = Math.max(...orders.map((o) => new Date(String(o.updated_at)).getTime()));
return {
key,
isAggregate: orders.length > 1,
orders,
count: orders.length,
totalCost: orders.reduce((s, o) => s + Number(o.total_cost), 0),
totalCost: orders.reduce((s, o) => s + Number(o.total_cost_with_fee), 0),
deliveryName: first?.delivery_point_name || first?.delivery_braname || '',
createdAt: first?.created_at ?? '',
recent,
@@ -250,7 +250,7 @@ async function onLoadMore(): Promise<void> {
function confirmCancel(order: MarketplaceOrderView): void {
Dialog.create({
title: 'Отменить заказ?',
message: `Заказ № ${order.id.slice(0, 8)} (${order.quantity} ед., ${order.total_cost} ₽) будет отменён. Средства разблокируются на кошельке Стола заказов.`,
message: `Заказ № ${order.id.slice(0, 8)} (${order.quantity} ед., ${money(order.total_cost_with_fee)} ₽) будет отменён. Средства разблокируются на кошельке Стола заказов.`,
cancel: { label: 'Не отменять', flat: true },
ok: { label: 'Отменить заказ', color: 'negative', unelevated: true },
persistent: true,
@@ -122,6 +122,8 @@ interface SupplierParty {
orders: MarketplaceOrderView[];
totalUnits: number;
totalCost: number;
/** Сумма членских взносов пайщиков по заказам партии (requirement b6) — справочно, не деньги поставщика. */
totalFee: number;
/** Целевой минимальный объём поставки на этот КУ (для накопителя). */
minVolume: number | null;
stageStatus: MarketplaceOrderStatusView;
@@ -150,6 +152,7 @@ const parties = computed<SupplierParty[]>(() => {
orders: [],
totalUnits: 0,
totalCost: 0,
totalFee: 0,
minVolume: collecting
? (minVolumeMap.value.get(`${o.offer_id}::${o.delivery_braname}`) ?? null)
: null,
@@ -160,6 +163,7 @@ const parties = computed<SupplierParty[]>(() => {
p.orders.push(o);
p.totalUnits += o.quantity;
p.totalCost += parseFloat(o.total_cost) || 0;
p.totalFee += Number(o.membership_fee ?? 0) || 0;
const candidate = STAGE_RANK[o.status];
const current = STAGE_RANK[p.stageStatus];
if (candidate < 90 && (current >= 90 || candidate < current)) {
@@ -340,7 +344,8 @@ q-page.incoming-orders(role='region', aria-label='Входящие заказы
:bar-color='barColor(p)',
:members='[]',
total-label='Итого',
:total-value='`${formatCost(p.totalCost)} · ${p.totalUnits} ${p.unitLabel}`'
:total-value='`${formatCost(p.totalCost)} · ${p.totalUnits} ${p.unitLabel}`',
:total-fee-note='p.totalFee > 0 ? `С учётом взноса пайщиков: ${formatCost(p.totalCost + p.totalFee)}` : undefined'
)
template(#actions)
template(v-if='p.kind === "collecting"')
@@ -64,7 +64,7 @@ export function buildTtnData(
const boxes = unitsPerBox && unitsPerBox > 0 ? Math.ceil(o.quantity / unitsPerBox) : undefined;
return {
sku: o.offer_id ? String(o.offer_id).slice(0, 8) : '—',
title: o.product_name,
title: o.product_name || 'Товар по предложению',
qty: o.quantity,
unit: marketplaceUnitShort(o.unit_of_measure),
price: parseFloat(o.price_per_unit) || 0,
@@ -230,8 +230,8 @@ q-page.mp-role-orderer.mp-member-cpp(role="region", aria-label="Подключе
.mp-member-cpp__head-icon
q-icon(name="location_on", size="22px")
.mp-member-cpp__head-text
.text-subtitle1.text-weight-medium Выберите пункт выдачи
.text-body2.text-grey-7 Где будете забирать заказы. Каталог отфильтруется под выбранный участок.
.text-subtitle1.text-weight-medium Выберите пункт выдачи заказов
.text-body2.text-grey-7 Выбирайте участок, где вам удобно забирать заказы, сменить его можно в любой момент.
BaseChip.mp-member-cpp__picked(v-if="selectedName", variant="pos", size="sm")
q-icon(name="check", size="14px")
| {{ selectedName }}
@@ -170,7 +170,7 @@ const planSaving = ref(false)
function planDueLabel(plan: ExpensePlanView): string {
if (plan.priority === Zeus.ExpensePlanPriority.URGENT) return 'Срочный'
if (plan.priority === Zeus.ExpensePlanPriority.OPTIONAL) return 'Необязательный'
return plan.due_date ? new Date(plan.due_date).toLocaleDateString('ru-RU') : '—'
return plan.due_date ? new Date(String(plan.due_date)).toLocaleDateString('ru-RU') : '—'
}
async function onAddPlan(): Promise<void> {
@@ -258,7 +258,7 @@ function aggregateLines(orders: MarketplaceSupplierPickupOrderView[]): DeliveryL
map.set(key, {
key,
productName: o.product_name || 'Товар по предложению',
unit: o.unit_of_measure,
unit: o.unit_of_measure ?? '',
quantity: qty,
boxes,
});
@@ -382,7 +382,7 @@ function groupOrdersByProduct(orders: MarketplaceSupplierPickupOrderView[]): Pic
map.set(key, {
key,
productName: o.product_name || 'Товар по предложению',
unit: o.unit_of_measure,
unit: o.unit_of_measure ?? '',
orderedTotal: qty,
orders: [o],
});
@@ -88,6 +88,7 @@ interface CollectiveParty {
orders: MarketplaceOrderView[];
/** Свой вклад (сумма собственных заказов в этой партии). */
ownUnits: number;
/** С учётом членского взноса (requirement b6) — то, что заказчик реально платит. */
ownCost: number;
/** Накоплено всеми пайщиками (с бэкенда): пул сбора или объём партии. */
groupAccumulated: number | null;
@@ -127,7 +128,7 @@ const parties = computed<CollectiveParty[]>(() => {
}
p.orders.push(o);
p.ownUnits += o.quantity;
p.ownCost += parseFloat(o.total_cost) || 0;
p.ownCost += Number(o.total_cost_with_fee);
const candidate = STAGE_RANK[o.status];
const current = STAGE_RANK[p.stageStatus];
if (candidate < 90 && (current >= 90 || candidate < current)) {
@@ -46,6 +46,14 @@ const status = computed(() => (order.value ? orderStatusDisplay(order.value.stat
const unitShort = computed(() => marketplaceUnitShort(order.value?.unit_of_measure));
const cancellable = computed(() => order.value?.status === 'ACTIVE');
// requirement b6: заказчику показываем цену С членским взносом (как в
// каталоге) order.total_cost_with_fee уже посчитан бэком (total_cost +
// membership_fee, зафиксированный контрактом по ставке на момент заказа).
const totalWithFee = computed(() => (order.value ? Number(order.value.total_cost_with_fee) : 0));
const unitPriceWithFee = computed(() =>
order.value && order.value.quantity > 0 ? totalWithFee.value / order.value.quantity : 0,
);
const pvzName = computed(() => order.value?.delivery_point_name || order.value?.delivery_braname || '');
const pvzAddress = computed(() => order.value?.delivery_point_address || '');
@@ -129,7 +137,7 @@ function confirmCancel(): void {
if (!o) return;
Dialog.create({
title: 'Отменить заказ?',
message: `Заказ № ${o.id.slice(0, 8)} (${o.quantity} ед., ${o.total_cost}) будет отменён. Средства разблокируются на кошельке Стола заказов.`,
message: `Заказ № ${o.id.slice(0, 8)} (${o.quantity} ед., ${formatPrice(o.total_cost_with_fee)}) будет отменён. Средства разблокируются на кошельке Стола заказов.`,
cancel: { label: 'Не отменять', flat: true },
ok: { label: 'Отменить заказ', color: 'negative', unelevated: true },
persistent: true,
@@ -199,13 +207,13 @@ q-page.order-detail(role="region", aria-label="Заказ")
.order-detail__facts
.order-detail__fact
.order-detail__fact-label Сумма заказа
.order-detail__fact-value--money {{ formatPrice(order.total_cost) }}
.order-detail__fact-value--money {{ formatPrice(String(totalWithFee)) }}
.order-detail__fact
.order-detail__fact-label Количество
.order-detail__fact-value {{ order.quantity }} {{ unitShort }}
.order-detail__fact
.order-detail__fact-label Цена за единицу
.order-detail__fact-value {{ formatPrice(order.price_per_unit) }}
.order-detail__fact-value {{ formatPrice(String(unitPriceWithFee)) }}
BaseCard.order-detail__card(v-if="pvzName || pvzAddress")
template(#head)
@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { Zeus } from '@coopenomics/sdk';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { useSessionStore } from 'src/entities/Session/model';
import { BaseBadge, BaseButton, BaseInput, BaseDialog, EmptyState } from 'src/shared/ui/base';
@@ -124,7 +125,7 @@ async function onAdd(): Promise<void> {
try {
await addSupplier({
member_account: addMember.value.trim(),
model: 'MEMBERSHIP',
model: Zeus.MarketplaceSupplierModel.MEMBERSHIP,
contract_number: addNumber.value.trim(),
contract_date: addDate.value,
});
@@ -15,7 +15,7 @@ div
:value='program.key',
:title='program.title',
:description='program.description',
:meta='program.requirements',
:meta='program.requirements ?? undefined',
@update:model-value='selectProgram(program.key)'
)
@@ -119,6 +119,7 @@ function getEnv(): EnvVars {
VAPID_PUBLIC_KEY: '',
SENTRY_DSN: '',
OPENREPLAY_PROJECT_KEY: '',
YANDEX_MAPS_API_KEY: '',
};
}
@@ -27,6 +27,7 @@ export function createEnvObject(): EnvVars {
VAPID_PUBLIC_KEY: process.env.VAPID_PUBLIC_KEY as string,
SENTRY_DSN: process.env.SENTRY_DSN as string,
OPENREPLAY_PROJECT_KEY: process.env.OPENREPLAY_PROJECT_KEY as string,
YANDEX_MAPS_API_KEY: process.env.YANDEX_MAPS_API_KEY as string,
};
return envObj;
@@ -20,6 +20,7 @@ export {
export { useActsPreview } from './useActsPreview';
export {
getMembershipFeePercent,
applyMembershipFee,
computeIssuanceDiff,
type IssuanceDiffLine,
} from './membership-fee';
@@ -13,6 +13,14 @@ export async function getMembershipFeePercent(): Promise<number> {
return result.membership_fee_percent;
}
/**
* Цена с учётом членского взноса единая формула для всех экранов заказчика
* (каталог, деталь предложения, добавление в корзину, сводка корзины).
*/
export function applyMembershipFee(price: number, feePercent: number): number {
return price * (1 + feePercent / 100);
}
export interface IssuanceDiffLine {
/** Сумма позиции по заказу (заблокированный резерв без взноса). */
orderedTotal: number;
@@ -41,6 +49,5 @@ export function computeIssuanceDiff(
if (diff > 0) refund += diff;
else if (diff < 0) surcharge += -diff;
}
const withFee = 1 + feePercent / 100;
return { refund: refund * withFee, surcharge: surcharge * withFee };
return { refund: applyMembershipFee(refund, feePercent), surcharge: applyMembershipFee(surcharge, feePercent) };
}
@@ -29,7 +29,7 @@ export interface GroupableReception {
fact_quantity_per_order: ReadonlyArray<{
product_name?: string | null;
fact_quantity: number | string;
unit_of_measure: string;
unit_of_measure?: string | null;
fact_unit_price?: string | null;
}>;
}
@@ -115,7 +115,7 @@ export function groupAplReceptions<T extends GroupableReception>(
lineMap.set(lk, {
key: lk,
productName: f.product_name || 'Товар по предложению',
unit: f.unit_of_measure,
unit: f.unit_of_measure ?? '',
quantity: qty,
amount: qty * price,
});
@@ -75,7 +75,7 @@ const generateQRCode = () => {
if (qrElement.value && props.paymentOrder.payment_details?.data) {
QRCode.toCanvas(
qrElement.value,
props.paymentOrder.payment_details.data,
props.paymentOrder.payment_details.data as string,
{ width: 400 },
function (error) {
if (error) console.error(error);
@@ -61,11 +61,13 @@ const props = withDefaults(defineProps<BaseInputProps>(), {
autogrow: false,
});
// Quasar QInput форвардит нативные blur/focus как обычный Event, не строго
// FocusEvent типизация здесь под то, что реально приходит из q-input.
const emit = defineEmits<{
'update:modelValue': [value: string];
clear: [];
blur: [event: FocusEvent];
focus: [event: FocusEvent];
blur: [event: Event];
focus: [event: Event];
}>();
const autoId = useId();
@@ -70,6 +70,7 @@
<script setup lang="ts">
import { computed, type PropType } from 'vue'
import { OfferGallery } from 'src/widgets/Marketplace/OfferGallery'
import { applyMembershipFee } from 'src/shared/lib/marketplace'
export type CatalogOfferStatus = 'draft' | 'published' | 'paused' | 'sold-out' | 'completed' | 'moderation' | 'withdrawn'
@@ -165,7 +166,7 @@ const unitCostWithFee = computed<number | string>(() => {
if (!hasFee.value) return base
const n = typeof base === 'number' ? base : Number(base)
if (Number.isNaN(n)) return base
return n * (1 + props.feePercent / 100)
return applyMembershipFee(n, props.feePercent)
})
const feeLabel = computed(() => {
@@ -7,7 +7,7 @@ import {
signReceptionGroupAsSupplier,
type MarketplaceAplReceptionView,
} from 'src/pages/Marketplace/OffererPendingAplReceptions/api';
import { Classes } from '@coopenomics/sdk';
import { Classes, Zeus } from '@coopenomics/sdk';
import {
listStockProposals,
finalizeStockIssuance,
@@ -96,7 +96,7 @@ async function refresh(source = 'ручной'): Promise<void> {
try {
const [receptions, proposals] = await Promise.all([
listAplReceptionsAsSupplier().catch(() => [] as MarketplaceAplReceptionView[]),
listStockProposals({ statuses: ['PROPOSED'] }).catch(
listStockProposals({ statuses: [Zeus.MarketplaceStockProposalStatus.PROPOSED] }).catch(
() => [] as MarketplaceStockProposalView[],
),
]);
@@ -203,15 +203,21 @@ async function signProposal(task: MarketplaceStockProposalView): Promise<void> {
// строке — документ НЕ перегенерируется, берётся агрегат rawDocument +
// document с подписью оператора (канон 2-подписи).
const order_lines: IStockFinalizeOrderLine[] = await Promise.all(
payload.order_lines.map(async (line) => ({
payload.order_lines.map(async (line) => {
const rawDocument = line.signiss1_aggregate.rawDocument;
if (!rawDocument) {
throw new Error(`Не найден исходный документ АПП-выдачи для заказа ${line.order_hash}`);
}
return {
order_hash: line.order_hash,
signed_signiss2_act: (await signer.signDocument(
line.signiss1_aggregate.rawDocument,
rawDocument,
global.username,
2,
[line.signiss1_aggregate.document],
)) as IStockFinalizeOrderLine['signed_signiss2_act'],
})),
};
}),
);
const { order_ids } = await finalizeStockIssuance(task.id, order_lines, signed_convert);
@@ -24,6 +24,7 @@
<div class="order-card__fact">
<div class="order-card__fact-label">Сумма</div>
<div class="order-card__fact-value order-card__fact-value--money">{{ formatPrice(order.totalCost) }}</div>
<div v-if="order.feeNote" class="order-card__fee-note">{{ order.feeNote }}</div>
</div>
<div class="order-card__fact">
<div class="order-card__fact-label">Кол-во</div>
@@ -87,6 +88,13 @@ export interface Order {
units: number
unitLabel?: string
totalCost: number
/**
* Пояснение под суммой (requirement b6) например «С учётом взноса
* пайщика: 1 300 » на столе поставщика, где `totalCost` его
* себестоимость без взноса. Заказчику не показывается там `totalCost`
* уже включает взнос.
*/
feeNote?: string
/** Card-status — управляет набором действий per-role (ACTIONS_PER_ROLE). */
status: OrderStatus
/** Человекочитаемая подпись доменного статуса для бейджа (из orderStatusDisplay). */
@@ -310,6 +318,12 @@ function formatPrice(v: number) {
}
}
&__fee-note {
font-size: var(--p-fs-body-sm, 12px);
color: var(--p-ink-3);
margin-top: 2px;
}
// ПВЗ отдельный блок с иконкой: наименование КУ (основное) + адрес.
&__pvz {
display: flex;
@@ -38,6 +38,10 @@ export interface OrderCardSource {
quantity: number;
unit_of_measure?: string | null;
total_cost: string;
/** Членский взнос, включённый в стоимость заказа (requirement b6) — on-chain, null до первой sync-дельты. */
membership_fee?: string | null;
/** Готовая сумма total_cost + membership_fee (считает бэк — см. toMarketplaceOrderDTO). */
total_cost_with_fee: string;
status: DomainOrderStatus;
created_at: string | Date;
delivery_braname: string;
@@ -47,17 +51,31 @@ export interface OrderCardSource {
delivery_point_lng?: number | null;
}
export function toOrderCardModel(o: OrderCardSource): Order {
/**
* `role` определяет, что показывает «Сумма» карточки (requirement b6):
* - 'orderer' (по умолчанию) цена С членским взносом, как в каталоге:
* это то, что заказчик реально платит.
* - 'offerer' себестоимость поставщика БЕЗ взноса (его деньги), плюс
* отдельная строка-подсказка со взносом пайщика для справки.
*/
export function toOrderCardModel(o: OrderCardSource, role: 'orderer' | 'offerer' = 'orderer'): Order {
const name = o.delivery_point_name || undefined;
const address = o.delivery_point_address || undefined;
const display = orderStatusDisplay(o.status);
const rawCost = parseFloat(o.total_cost) || 0;
const isOfferer = role === 'offerer';
const feeAmount = Number(o.membership_fee ?? 0);
return {
id: o.id,
shortId: o.id.slice(0, 8),
title: o.product_name || 'Товар по предложению',
units: o.quantity,
unitLabel: marketplaceUnitShort(o.unit_of_measure),
totalCost: parseFloat(o.total_cost) || 0,
totalCost: isOfferer ? rawCost : Number(o.total_cost_with_fee),
feeNote:
isOfferer && feeAmount > 0
? `С учётом взноса пайщика: ${new Intl.NumberFormat('ru-RU').format(Number(o.total_cost_with_fee))}`
: undefined,
status: STATUS_TO_CARD[o.status],
// Бейдж карточки рисуем по доменному статусу (исчерпывающая карта), а не по
// грубому card-status — иначе на карточке два разных текста статуса.
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { Queries } from '@coopenomics/sdk';
import { Classes, Queries, Zeus } from '@coopenomics/sdk';
import { client } from 'src/shared/api/client';
import { useGlobalStore } from 'src/shared/store';
import { BaseBadge, BaseButton } from 'src/shared/ui/base';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { formatAsset2Digits } from 'src/shared/lib/utils/formatAsset2Digits';
@@ -9,10 +10,13 @@ import { useMarketplaceRealtime } from 'src/shared/lib/marketplace';
import {
createStockProposal,
cancelStockProposal,
getStockIssuancePayloads,
listStockProposals,
type MarketplaceStockProposalView,
} from 'src/pages/Marketplace/OperatorIssuance/api';
const globalStore = useGlobalStore();
/**
* Докладка у стойки выдачи (requirement 76): оператор накидывает пайщику
* опубликованный остаток склада своего КУ. Пайщику немедленно уходит
@@ -78,7 +82,7 @@ async function loadProposals(): Promise<void> {
try {
proposals.value = await listStockProposals({
braname: props.braname,
statuses: ['PROPOSED'],
statuses: [Zeus.MarketplaceStockProposalStatus.PROPOSED],
});
} catch {
proposals.value = [];
@@ -92,13 +96,37 @@ function bump(offer: CoopStockOffer, delta: number): void {
}
async function sendProposal(): Promise<void> {
const wifKey = globalStore.wif?.toString();
if (!wifKey) {
FailAlert(new Error('Приватный ключ не найден. Войдите в кооператив.'));
return;
}
sending.value = true;
try {
await createStockProposal({
// Двухфазно (канон см. IssueActOpenDialog.vue): сначала бэк готовит по
// каждой строке order_hash + акт приёма-передачи (signiss1_document),
// затем оператор подписывает его локально своим ключом (первая подпись
// АПП-выдачи) и только с готовой подписью бандл уходит на создание
// предложения. До подписи пайщика в блокчейне ничего не происходит.
const docSigner = new Classes.Document(wifKey);
const payloads = await getStockIssuancePayloads({
braname: props.braname,
member_account: props.memberAccount,
items: composedLines.value.map((l) => ({ offer_id: l.offer.id, quantity: l.quantity })),
});
const items = await Promise.all(
payloads.map(async (p) => ({
offer_id: p.offer_id,
quantity: p.quantity,
order_hash: p.order_hash,
signiss1_act: await docSigner.signDocument(p.signiss1_document, globalStore.username, 1),
})),
);
await createStockProposal({
braname: props.braname,
member_account: props.memberAccount,
items,
});
SuccessAlert('Предложение отправлено — у пайщика всплыло окно решения.');
quantities.value = {};
await loadProposals();
@@ -47,6 +47,12 @@ const props = defineProps<{
totalLabel: string;
/** Значение итога, напр. «1 200 ₽ · 5 кг». */
totalValue: string;
/**
* Пояснение под итогом (requirement b6) например «С учётом взноса
* пайщиков: 1 300 » на столе поставщика, где `totalValue` его
* себестоимость без взноса. У заказчика не передаётся.
*/
totalFeeNote?: string;
/**
* Карточка кликабельна (курсор-указатель + role=button + emit `card-click`).
* У заказчика на «Коллективном заказе» клик ведёт в карточку предложения; у
@@ -115,6 +121,7 @@ function onCardClick(): void {
.supply-party__total
span.t-muted {{ totalLabel }}
span.supply-party__total-val {{ totalValue }}
span.supply-party__total-fee-note(v-if="totalFeeNote") {{ totalFeeNote }}
q-space
slot(name="actions")
</template>
@@ -229,5 +236,10 @@ function onCardClick(): void {
color: var(--p-ink-1);
font-variant-numeric: tabular-nums;
}
&__total-fee-note {
font-size: var(--p-fs-body-sm, 12px);
color: var(--p-ink-3);
}
}
</style>
@@ -115,7 +115,8 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { FailAlert } from 'src/shared/api'
import { copyToClipboard } from 'quasar'
import { FailAlert, SuccessAlert } from 'src/shared/api'
import { EntityIdBadge } from 'src/shared/ui'
import { DocumentRow, type DocumentRowDoc } from 'src/shared/ui/domain/DocumentRow'
import { DocumentViewerDialog } from 'src/shared/ui/domain/DocumentViewerDialog'