[598-51][@ant] fix: показать заказчику и поставщику корректную сумму с членским взносом на всех экранах Стола заказов, добить точечные UI-баги и TS-ошибки — реестр поставщиков и пункты выдачи получили ФИО/наименование вместо голого username, стол администратора и онбординг поставщика поправлены по прошлым замечаниям, докладка у стойки выдачи пропускала обязательную подпись оператора и падала бы на бэке, плюс закрыты оставшиеся unknown/nullable ошибки компиляции desktop
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,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';
|
||||
|
||||
+9
-13
@@ -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)
|
||||
|
||||
+2
-1
@@ -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;
|
||||
}
|
||||
|
||||
+23
-3
@@ -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,
|
||||
|
||||
+6
-1
@@ -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,
|
||||
|
||||
+2
-2
@@ -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 }}
|
||||
|
||||
+1
-1
@@ -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> {
|
||||
|
||||
+2
-2
@@ -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],
|
||||
});
|
||||
|
||||
+2
-1
@@ -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)) {
|
||||
|
||||
+11
-3
@@ -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)
|
||||
|
||||
+2
-1
@@ -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(() => {
|
||||
|
||||
+11
-5
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user