feat(marketplace): коллективный заказ заказчика — прогресс сбора как у поставщика

Стол заказчика «Коллективный заказ» (бывш. «Сводный заказ») теперь зеркалит
карточку поставщика: партии-накопители показывают коллективный прогресс сбора
(накоплено всеми пайщиками / целевой минимум КУ), без ФИО других заказчиков.
КУ выводится человекочитаемым именем (delivery_point_name) вместо служебного
braname.

Бэкенд: Order-DTO обогащается group_accumulated_quantity (сумма ACTIVE по паре
оферта×КУ всех пайщиков) и group_min_volume (целевой минимум КУ) — новый
батч-агрегат sumActiveByOfferBranch + флаг withGroupProgress в display-сервисе.
Заодно ленты заказов (my/supplier) обогащаются именами участников
(withParticipantNames): на столе поставщика «кто заказал» теперь реально
показывает ФИО, а не аккаунт.

Codegen: generate-schema/client + zeus-клиент sdk и controller.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ant
2026-06-01 08:45:04 +00:00
parent b8551e198a
commit 2706a16c46
12 changed files with 370 additions and 116 deletions
@@ -138,6 +138,23 @@ export class MarketplaceOrderDTO {
@Field(() => Int, { description: 'Количество единиц товара в заказе.' })
public readonly quantity!: number;
@Field(() => Int, {
nullable: true,
description:
'Сколько уже накоплено по этому предложению на данном пункте выдачи всеми ' +
'заказчиками на этапе сбора партии (для прогресса коллективного заказа). ' +
'Заполняется только пока заказ копится; после приёма поставщиком — пусто.',
})
public readonly group_accumulated_quantity!: number | null;
@Field(() => Int, {
nullable: true,
description:
'Целевой минимальный объём поставки на этот пункт выдачи — ориентир сбора ' +
'партии (не жёсткий порог). Заполняется только на этапе сбора.',
})
public readonly group_min_volume!: number | null;
@Field(() => String, { description: 'Цена за единицу товара на момент заказа.' })
public readonly price_per_unit!: string;
@@ -321,6 +338,8 @@ export interface MarketplaceOrderDisplayFields {
delivery_point_address?: string | null;
orderer_name?: string | null;
supplier_name?: string | null;
group_accumulated_quantity?: number | null;
group_min_volume?: number | null;
}
export function toMarketplaceOrderDTO(
@@ -343,6 +362,8 @@ export function toMarketplaceOrderDTO(
delivery_point_name: display?.delivery_point_name ?? null,
delivery_point_address: display?.delivery_point_address ?? null,
quantity: o.quantity,
group_accumulated_quantity: display?.group_accumulated_quantity ?? null,
group_min_volume: display?.group_min_volume ?? null,
price_per_unit: o.price_per_unit,
total_cost: o.total_cost,
cycle_id: o.cycle_id,
@@ -183,7 +183,12 @@ export class MarketplaceOrderResolver {
offer_id: input?.offer_id,
status: input?.statuses?.length ? (input.statuses as MarketplaceOrderStatus[]) : undefined,
};
return this.runListQuery(filter, options);
// Стол заказчика: показываем наименование поставщика/ПВЗ и прогресс сбора
// коллективного заказа (сколько накоплено по оферте × КУ всеми пайщиками).
return this.runListQuery(filter, options, {
withParticipantNames: true,
withGroupProgress: true,
});
}
@Query(() => MarketplaceOrderPaginationResultDTO, {
@@ -204,7 +209,8 @@ export class MarketplaceOrderResolver {
offer_id: input?.offer_id,
status: input?.statuses?.length ? (input.statuses as MarketplaceOrderStatus[]) : undefined,
};
return this.runListQuery(filter, options);
// Стол поставщика: показываем «кто заказал» (ФИО/наименование заказчика).
return this.runListQuery(filter, options, { withParticipantNames: true });
}
@Query(() => MarketplaceOrderDTO, {
@@ -235,7 +241,8 @@ export class MarketplaceOrderResolver {
private async runListQuery(
filter: MarketplaceOrderListFilter,
options?: PaginationInputDTO
options?: PaginationInputDTO,
enrichOpts?: { withParticipantNames?: boolean; withGroupProgress?: boolean }
): Promise<MarketplaceOrderPaginationResultDTO> {
const result = await this.orderRepo.list(filter, {
page: options?.page ?? 1,
@@ -243,7 +250,7 @@ export class MarketplaceOrderResolver {
sortBy: options?.sortBy ?? 'updated_at',
sortOrder: options?.sortOrder ?? 'DESC',
});
const displayByOrderId = await this.displayService.enrich(result.items);
const displayByOrderId = await this.displayService.enrich(result.items, enrichOpts);
const dto = new MarketplaceOrderPaginationResultDTO();
dto.items = result.items.map((o) => toOrderDTO(o, displayByOrderId.get(o.id)));
dto.totalCount = result.totalCount;
@@ -22,6 +22,7 @@ import {
type MarketplaceOrderDomainRepository,
} from '../../domain/repositories/marketplace-order.repository';
import type { MarketplaceOrderDomainEntity } from '../../domain/entities/marketplace-order.entity';
import { MarketplaceOrderStatuses } from '../../domain/entities/marketplace-order.types';
import type { MarketplaceOrderDisplayFields } from '../dto/marketplace-order.dto';
export const MARKETPLACE_ORDER_DISPLAY_SERVICE = Symbol('MARKETPLACE_ORDER_DISPLAY_SERVICE');
@@ -65,7 +66,7 @@ export class MarketplaceOrderDisplayService {
*/
async enrich(
orders: MarketplaceOrderDomainEntity[],
opts?: { withParticipantNames?: boolean }
opts?: { withParticipantNames?: boolean; withGroupProgress?: boolean }
): Promise<Map<string, MarketplaceOrderDisplayFields>> {
const result = new Map<string, MarketplaceOrderDisplayFields>();
if (orders.length === 0) return result;
@@ -78,16 +79,34 @@ export class MarketplaceOrderDisplayService {
const accounts = opts?.withParticipantNames
? orders.flatMap((o) => [o.orderer_account, o.supplier_account])
: [];
const [offers, branchByBraname, nameByAccount] = await Promise.all([
const [offers, branchByBraname, nameByAccount, groupSums] = await Promise.all([
this.offerRepo.findByIds(offerIds),
this.resolveBranches(branames),
this.resolveAccountNames(accounts),
// «Сколько накоплено» по парам (offer × КУ) считаем только когда лента
// должна показать прогресс сбора (стол заказчика) — иначе лишний запрос.
opts?.withGroupProgress
? this.orderRepo.sumActiveByOfferBranch(config.coopname, offerIds)
: Promise.resolve([]),
]);
const offerById = new Map(offers.map((offer) => [offer.id, offer]));
// Ключ (offer_id::braname) → накоплено всеми на этапе сбора.
const accumulatedByKey = new Map<string, number>();
for (const g of groupSums) {
accumulatedByKey.set(`${g.offer_id}::${g.delivery_braname}`, g.total);
}
for (const order of orders) {
const offer = offerById.get(order.offer_id);
const branch = branchByBraname.get(order.delivery_braname);
// Прогресс сбора — только для заказов на этапе накопления (ACTIVE). После
// приёма поставщиком сбор завершён, прогресс не показываем (пусто).
const isCollecting =
!!opts?.withGroupProgress && order.status === MarketplaceOrderStatuses.ACTIVE;
const groupKey = `${order.offer_id}::${order.delivery_braname}`;
const minVolume =
offer?.delivery_points?.find((d) => d.braname === order.delivery_braname)
?.min_supply_volume ?? null;
result.set(order.id, {
product_name: offer?.product_name ?? null,
unit_of_measure: offer?.unit_of_measure ?? null,
@@ -95,6 +114,10 @@ export class MarketplaceOrderDisplayService {
delivery_point_address: branch?.address ?? null,
orderer_name: nameByAccount.get(order.orderer_account) ?? null,
supplier_name: nameByAccount.get(order.supplier_account) ?? null,
group_accumulated_quantity: isCollecting
? (accumulatedByKey.get(groupKey) ?? order.quantity)
: null,
group_min_volume: isCollecting ? minVolume : null,
});
}
return result;
@@ -125,6 +125,18 @@ export interface MarketplaceOrderDomainRepository
*/
sumUnassignedActiveByOffer(coopname: string, offer_id: string): Promise<number>;
/**
* Батч «сколько накоплено» по парам (offer × КУ) на этапе сбора: сумма
* quantity всех ACTIVE-заказов (всех пайщиков) каждого предложения в разрезе
* ПВЗ доставки. Нужен, чтобы заказчик в своей ленте видел коллективный
* прогресс сбора партии (а не только свой вклад). Результат — строки
* (offer_id, delivery_braname, total).
*/
sumActiveByOfferBranch(
coopname: string,
offerIds: string[]
): Promise<Array<{ offer_id: string; delivery_braname: string; total: number }>>;
/**
* Story 4.3: все Order'ы, привязанные к заявке `cycle_id`. Используется
* cron'ом expireUnacceptedPending для per-Order unblk пулу заявки,
@@ -295,6 +295,31 @@ export class MarketplaceOrderRepositoryAdapter implements MarketplaceOrderDomain
return Number(raw?.total ?? 0);
}
async sumActiveByOfferBranch(
coopname: string,
offerIds: string[]
): Promise<Array<{ offer_id: string; delivery_braname: string; total: number }>> {
if (offerIds.length === 0) return [];
const rows = await this.repo
.createQueryBuilder('o')
.select('o.offer_id', 'offer_id')
.addSelect('o.delivery_braname', 'delivery_braname')
.addSelect('COALESCE(SUM(o.quantity), 0)', 'total')
.where('o.coopname = :coop AND o.offer_id IN (:...offers) AND o.status = :st', {
coop: coopname,
offers: offerIds,
st: MarketplaceOrderStatuses.ACTIVE,
})
.groupBy('o.offer_id')
.addGroupBy('o.delivery_braname')
.getRawMany<{ offer_id: string; delivery_braname: string; total: string }>();
return rows.map((r) => ({
offer_id: r.offer_id,
delivery_braname: r.delivery_braname,
total: Number(r.total ?? 0),
}));
}
async deleteByBlockNumGreaterThan(blockNum: number): Promise<void> {
await this.repo
.createQueryBuilder()
+2
View File
@@ -4208,6 +4208,8 @@ export const ReturnTypes: Record<string,any> = {
delivery_point_address:"String",
delivery_point_name:"String",
delivery_signer_account:"String",
group_accumulated_quantity:"Int",
group_min_volume:"Int",
id:"String",
issuance_fact:"MarketplaceOrderIssuanceFactSnapshot",
last_status_reason:"String",
+16
View File
@@ -7115,6 +7115,10 @@ export type ValueTypes = {
delivery_point_name?:boolean | `@${string}`,
/** Учётная запись стороны кооператива, поставившей подпись вместе с заказчиком. */
delivery_signer_account?:boolean | `@${string}`,
/** Сколько уже накоплено по этому предложению на данном пункте выдачи всеми заказчиками на этапе сбора партии (для прогресса коллективного заказа). Заполняется только пока заказ копится; после приёма поставщиком — пусто. */
group_accumulated_quantity?:boolean | `@${string}`,
/** Целевой минимальный объём поставки на этот пункт выдачи — ориентир сбора партии (не жёсткий порог). Заполняется только на этапе сбора. */
group_min_volume?:boolean | `@${string}`,
/** Идентификатор заказа. */
id?:boolean | `@${string}`,
/** Фактическая выдача после финальной подписи заказчика (заполняется на ПВЗ). */
@@ -17184,6 +17188,10 @@ export type ResolverInputTypes = {
delivery_point_name?:boolean | `@${string}`,
/** Учётная запись стороны кооператива, поставившей подпись вместе с заказчиком. */
delivery_signer_account?:boolean | `@${string}`,
/** Сколько уже накоплено по этому предложению на данном пункте выдачи всеми заказчиками на этапе сбора партии (для прогресса коллективного заказа). Заполняется только пока заказ копится; после приёма поставщиком — пусто. */
group_accumulated_quantity?:boolean | `@${string}`,
/** Целевой минимальный объём поставки на этот пункт выдачи — ориентир сбора партии (не жёсткий порог). Заполняется только на этапе сбора. */
group_min_volume?:boolean | `@${string}`,
/** Идентификатор заказа. */
id?:boolean | `@${string}`,
/** Фактическая выдача после финальной подписи заказчика (заполняется на ПВЗ). */
@@ -26923,6 +26931,10 @@ export type ModelTypes = {
delivery_point_name?: string | undefined | null,
/** Учётная запись стороны кооператива, поставившей подпись вместе с заказчиком. */
delivery_signer_account?: string | undefined | null,
/** Сколько уже накоплено по этому предложению на данном пункте выдачи всеми заказчиками на этапе сбора партии (для прогресса коллективного заказа). Заполняется только пока заказ копится; после приёма поставщиком — пусто. */
group_accumulated_quantity?: number | undefined | null,
/** Целевой минимальный объём поставки на этот пункт выдачи — ориентир сбора партии (не жёсткий порог). Заполняется только на этапе сбора. */
group_min_volume?: number | undefined | null,
/** Идентификатор заказа. */
id: string,
/** Фактическая выдача после финальной подписи заказчика (заполняется на ПВЗ). */
@@ -37615,6 +37627,10 @@ export type GraphQLTypes = {
delivery_point_name?: string | undefined | null,
/** Учётная запись стороны кооператива, поставившей подпись вместе с заказчиком. */
delivery_signer_account?: string | undefined | null,
/** Сколько уже накоплено по этому предложению на данном пункте выдачи всеми заказчиками на этапе сбора партии (для прогресса коллективного заказа). Заполняется только пока заказ копится; после приёма поставщиком — пусто. */
group_accumulated_quantity?: number | undefined | null,
/** Целевой минимальный объём поставки на этот пункт выдачи — ориентир сбора партии (не жёсткий порог). Заполняется только на этапе сбора. */
group_min_volume?: number | undefined | null,
/** Идентификатор заказа. */
id: string,
/** Фактическая выдача после финальной подписи заказчика (заполняется на ПВЗ). */
@@ -171,7 +171,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
name: 'marketplace-consolidated',
component: markRaw(OrdererConsolidatedPage),
meta: {
title: 'Сводный заказ',
title: 'Коллективный заказ',
icon: 'fa-solid fa-layer-group',
requires: 'Order:create',
requiresAuth: true,
@@ -9,6 +9,7 @@ import {
import { RefreshButton } from 'src/widgets/Marketplace/RefreshButton';
import { EmptyState } from 'src/shared/ui/base';
import { PageHint } from 'src/shared/ui/domain';
import { marketplaceUnitShort } from 'src/shared/lib/consts/marketplace-units';
import { fetchMyOrders } from '../../MyOrders/api';
import type {
MarketplaceOrderStatusView,
@@ -16,16 +17,21 @@ import type {
} from '../../MyOrders/types';
/**
* Эпик 4 / Story 4.4: orderer-стол «Сводный заказ».
* Эпик 4 / Story 4.4: orderer-стол «Коллективный заказ».
*
* В коллективной закупке (`collective`) Order'ы заказчика группируются в
* партию по `cycle_id`. Эта страница даёт пайщику
* единый обзор: какие у него партии в полёте, сколько заказов в каждой,
* суммарная стоимость, общий этап партии. Канон — `widgets/Marketplace/OrderCard`
* для отдельных заказов внутри партии; страница на MONO Platform v2.
* Зеркало стола поставщика «Входящие заказы», но глазами заказчика и без ФИО
* других пайщиков. Заказы заказчика сгруппированы в ПАРТИИ:
* - «накопитель» — собственные активные (ACTIVE) заказы по паре (оферта × КУ)
* копятся к минимальному объёму поставки. Заказчик видит КОЛЛЕКТИВНЫЙ
* прогресс сбора (`group_accumulated_quantity` — накоплено всеми пайщиками,
* `group_min_volume` — целевой минимум КУ) и свой вклад. Это главное, что
* хочет видеть заказчик: насколько собралась его коллективная закупка.
* - «сформированная» — уже принятые поставщиком заказы (по cycle_id): метрики
* этапа + карточки заказов.
*
* Источник данных — `Queries.Marketplace.ListMyOrders` (reuse MyOrders/api).
* Polling 15s, до Subscriptions Story 9.x.
* Источник данных — `Queries.Marketplace.ListMyOrders` (reuse MyOrders/api),
* который видит ТОЛЬКО заказы текущего пайщика. Коллективные суммы (`group_*`)
* приходят с бэкенда уже агрегированными по всем участникам. Polling 15s.
*/
const POLL_INTERVAL_MS = 15_000;
@@ -35,9 +41,7 @@ const items = ref<MarketplaceOrderView[]>([]);
const loading = ref(false);
let pollTimer: ReturnType<typeof setInterval> | null = null;
// Этап партии = минимальный по STAGE_RANK среди не-отменённых; то есть
// если хотя бы один заказ ещё ждёт цикл, вся партия «Активна».
// Готовый к выдаче переходит в issued только когда все заказы выданы.
// Этап сформированной партии = минимальный по рангу среди не-отменённых.
const STAGE_RANK: Record<MarketplaceOrderStatusView, number> = {
ACTIVE: 0,
ACCEPTED_PENDING_SUPPLIER: 1,
@@ -52,62 +56,91 @@ const STAGE_RANK: Record<MarketplaceOrderStatusView, number> = {
CANCELLED_BY_SUPPLIER: 99,
};
interface ConsolidatedGroup {
interface OrdererParty {
key: string;
/** collecting — копится к min (ACTIVE); formed — уже принята (cycle_id). */
kind: 'collecting' | 'formed';
cycle_id: string | null;
offer_id: string;
supplier_account: string;
delivery_braname: string;
productName: string;
pvzName: string;
unitLabel: string;
orders: MarketplaceOrderView[];
totalCost: number;
totalUnits: number;
/** Свой вклад (сумма собственных заказов в этой группе). */
ownUnits: number;
ownCost: number;
/** Накоплено всеми пайщиками на этапе сбора (с бэкенда). */
groupAccumulated: number | null;
/** Целевой минимальный объём поставки на этот КУ. */
groupMinVolume: number | null;
stageStatus: MarketplaceOrderStatusView;
}
const groups = computed<ConsolidatedGroup[]>(() => {
const buckets = new Map<string, ConsolidatedGroup>();
const parties = computed<OrdererParty[]>(() => {
const buckets = new Map<string, OrdererParty>();
for (const o of items.value) {
const key = o.cycle_id
? `cycle:${o.cycle_id}`
: `single:${o.id}`;
let g = buckets.get(key);
if (!g) {
g = {
const collecting = o.status === 'ACTIVE';
const key = collecting
? `collect:${o.offer_id}::${o.delivery_braname}`
: o.cycle_id
? `cycle:${o.cycle_id}`
: `single:${o.id}`;
let p = buckets.get(key);
if (!p) {
p = {
key,
cycle_id: o.cycle_id ?? null,
kind: collecting ? 'collecting' : 'formed',
cycle_id: collecting ? null : (o.cycle_id ?? null),
offer_id: o.offer_id,
supplier_account: o.supplier_account,
delivery_braname: o.delivery_braname,
productName: o.product_name || 'Товар по предложению',
pvzName: o.delivery_point_name || o.delivery_braname,
unitLabel: marketplaceUnitShort(o.unit_of_measure),
orders: [],
totalCost: 0,
totalUnits: 0,
ownUnits: 0,
ownCost: 0,
groupAccumulated: collecting ? (o.group_accumulated_quantity ?? null) : null,
groupMinVolume: collecting ? (o.group_min_volume ?? null) : null,
stageStatus: o.status,
};
buckets.set(key, g);
buckets.set(key, p);
}
g.orders.push(o);
g.totalCost += parseFloat(o.total_cost) || 0;
g.totalUnits += o.quantity;
// Этап партии = ранг минимального активного (с приоритетом «в работе»).
p.orders.push(o);
p.ownUnits += o.quantity;
p.ownCost += parseFloat(o.total_cost) || 0;
const candidate = STAGE_RANK[o.status];
const current = STAGE_RANK[g.stageStatus];
const current = STAGE_RANK[p.stageStatus];
if (candidate < 90 && (current >= 90 || candidate < current)) {
g.stageStatus = o.status;
p.stageStatus = o.status;
}
}
return [...buckets.values()].sort((a, b) => {
// Группы с cycle_id выше (партии в работе), индивидуальные ниже.
if (a.cycle_id && !b.cycle_id) return -1;
if (!a.cycle_id && b.cycle_id) return 1;
// Накопители выше — по ним идёт сбор, заказчику важнее прогресс.
if (a.kind !== b.kind) return a.kind === 'collecting' ? -1 : 1;
return a.orders[0].created_at < b.orders[0].created_at ? 1 : -1;
});
});
const hasGroups = computed(() => groups.value.length > 0);
const consolidatedCount = computed(
() => groups.value.filter((g) => g.cycle_id !== null).length,
const hasParties = computed(() => parties.value.length > 0);
const collectingCount = computed(
() => parties.value.filter((p) => p.kind === 'collecting').length,
);
// Поштучный сбор: min ≤ 1 (или не задан) — прогресс-бар не нужен.
function isPerPiece(p: OrdererParty): boolean {
return p.groupMinVolume == null || p.groupMinVolume <= 1;
}
function progressRatio(p: OrdererParty): number {
if (!p.groupMinVolume || p.groupMinVolume <= 0) return 1;
return Math.min(1, (p.groupAccumulated ?? 0) / p.groupMinVolume);
}
function reachedMin(p: OrdererParty): boolean {
return (
p.groupMinVolume != null && (p.groupAccumulated ?? 0) >= p.groupMinVolume
);
}
function formatCost(value: number): string {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
@@ -149,72 +182,124 @@ onUnmounted(() => {
</script>
<template lang="pug">
q-page.consolidated(role="region", aria-label="Сводный заказ")
q-page.collective(role="region", aria-label="Коллективный заказ")
//- Действие страницы — в шапку, где стоят общие действия (канон Teleport).
Teleport(to="#header-actions-host", defer)
RefreshButton(:loading="loading", @refresh="load")
PageHint(storage-key="mp:consolidated:banner-dismissed")
| Партии заказов, сгруппированные по циклу. Несколько ваших заказов в одной партии обслуживаются совместно на одном цикле, с одной поставкой.
.collective__col
PageHint(storage-key="mp:collective:banner-dismissed")
| Ваши заказы сгруппированы в партии по предложению и кооперативному
| участку. Пока партия копится видно, сколько уже набрано всеми
| пайщиками к минимальному объёму поставки. После приёма поставщиком
| партия переходит в работу.
q-inner-loading(:showing="loading && items.length === 0")
q-spinner(color="primary", size="2em")
q-inner-loading(:showing="loading && items.length === 0")
q-spinner(color="primary", size="2em")
EmptyState(
v-if="!loading && !hasGroups",
title="У вас ещё нет заказов",
body="Откройте каталог и оформите первый заказ — он появится здесь."
)
template(#icon)
q-icon(name="inventory_2", size="48px")
EmptyState(
v-if="!loading && !hasParties",
title="У вас ещё нет заказов",
body="Откройте каталог и оформите первый заказ — он появится здесь."
)
template(#icon)
q-icon(name="inventory_2", size="48px")
.consolidated__list(v-if="hasGroups")
.t-muted.consolidated__counter Партий в работе: {{ consolidatedCount }} / Всего групп: {{ groups.length }}
.collective__list(v-if="hasParties")
.t-muted.collective__counter Партий в сборе: {{ collectingCount }} / Всего партий: {{ parties.length }}
.consolidated__group(v-for="g in groups", :key="g.key")
.consolidated__group-head
.row.items-center.q-gutter-md
div
.t-h3
span(v-if="g.cycle_id") Партия {{ g.cycle_id }}
span(v-else) Индивидуальный заказ {{ g.orders[0].id.slice(0, 8) }}
.t-muted.consolidated__group-hint(v-if="g.cycle_id") Партия-накопитель заказы приняты поставщиком к поставке вместе.
.t-muted.consolidated__group-hint(v-else) Отдельный заказ ещё не принят поставщиком к поставке.
q-space
span.chip.chip--accent
q-icon(name="layers", size="14px")
| {{ g.orders.length }} заказа(ов)
.collective__party(
v-for="p in parties",
:key="p.key",
:class="`collective__party--${p.kind}`"
)
.collective__party-head
.row.items-center.q-gutter-sm.no-wrap
div.col
.t-h3 {{ p.productName }}
.t-muted.collective__party-sub
q-icon(name="place", size="14px")
| КУ «{{ p.pvzName }}»
span.chip.chip--accent
q-icon(name="layers", size="14px")
| {{ p.orders.length }} зак.
.row.q-col-gutter-md.q-mt-sm
.col-12.col-md-3
.t-muted Этап партии
.consolidated__metric-val {{ orderStatusDisplay(g.stageStatus).label }}
.col-12.col-md-3
.t-muted Всего единиц
.consolidated__metric-val {{ g.totalUnits }}
.col-12.col-md-3
.t-muted Сумма партии
.consolidated__metric-val {{ formatCost(g.totalCost) }}
.col-12.col-md-3
.t-muted ПВЗ доставки
.consolidated__metric-val {{ g.delivery_braname }}
//- НАКОПИТЕЛЬ: коллективный прогресс сбора к минимальному объёму.
template(v-if="p.kind === 'collecting'")
.collective__progress(v-if="!isPerPiece(p)")
.collective__progress-row
span Накоплено всеми: {{ p.groupAccumulated ?? 0 }} {{ p.unitLabel }}
span.t-muted цель от {{ p.groupMinVolume }} {{ p.unitLabel }}
q-linear-progress.collective__progress-bar(
:value="progressRatio(p)",
rounded,
size="10px",
:color="reachedMin(p) ? 'positive' : 'primary'",
track-color="grey-3"
)
.t-muted.collective__progress-hint(v-if="reachedMin(p)")
q-icon(name="check_circle", size="14px", color="positive")
| Минимальный объём набран поставщик может принять партию.
.t-muted.collective__progress-hint(v-else)
| Сбор продолжается. Поставщик может принять партию и раньше минимум лишь ориентир.
.consolidated__orders
OrderCard(
v-for="o in g.orders",
:key="o.id",
:order="toOrderCardModel(o)",
role="orderer",
readonly
)
.collective__progress(v-else)
.collective__progress-row
span К поставке: {{ p.groupAccumulated ?? p.ownUnits }} {{ p.unitLabel }}
.t-muted.collective__progress-hint Поштучный сбор поставщик принимает заказы по мере поступления.
.collective__own
span.t-muted Ваш вклад
span.collective__own-val {{ formatCost(p.ownCost) }} · {{ p.ownUnits }} {{ p.unitLabel }}
.collective__cards
OrderCard(
v-for="o in p.orders",
:key="o.id",
:order="toOrderCardModel(o)",
role="orderer",
readonly
)
//- СФОРМИРОВАННАЯ партия: метрики этапа + карточки заказов.
template(v-else)
.row.q-col-gutter-md.collective__metrics
.col-6.col-md-3
.t-muted Этап
.collective__metric-val {{ orderStatusDisplay(p.stageStatus).label }}
.col-6.col-md-3
.t-muted Ваших единиц
.collective__metric-val {{ p.ownUnits }} {{ p.unitLabel }}
.col-6.col-md-3
.t-muted Ваша сумма
.collective__metric-val {{ formatCost(p.ownCost) }}
.col-6.col-md-3
.t-muted Партия
.collective__metric-val
span(v-if="p.cycle_id") {{ p.cycle_id }}
span(v-else) одиночный
.collective__cards
OrderCard(
v-for="o in p.orders",
:key="o.id",
:order="toOrderCardModel(o)",
role="orderer",
readonly
)
</template>
<style scoped lang="scss">
.consolidated {
padding: var(--p-6, 24px);
display: flex;
flex-direction: column;
gap: var(--p-4, 16px);
.collective {
padding: 0 var(--p-4, 16px) var(--p-6, 24px);
&__col {
max-width: 1120px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--p-4, 16px);
}
&__list {
display: flex;
@@ -222,19 +307,63 @@ q-page.consolidated(role="region", aria-label="Сводный заказ")
gap: var(--p-4, 16px);
}
&__group {
&__party {
border: 1px solid var(--p-line);
border-radius: var(--p-r-md, 12px);
background: var(--p-surface);
overflow: hidden;
}
&__group-head {
padding: var(--p-4, 16px);
display: flex;
flex-direction: column;
gap: var(--p-3, 12px);
}
&__group-hint {
max-width: 640px;
&__party-sub {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 2px;
}
&__progress {
display: flex;
flex-direction: column;
gap: var(--p-2, 8px);
}
&__progress-row {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: var(--p-fs-body);
}
&__progress-bar {
border-radius: var(--p-r-sm, 8px);
}
&__progress-hint {
display: inline-flex;
align-items: center;
gap: 4px;
}
&__own {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--p-2, 8px);
padding-top: var(--p-2, 8px);
border-top: 1px solid var(--p-line);
}
&__own-val {
font-size: var(--p-fs-body);
color: var(--p-ink-1);
font-variant-numeric: tabular-nums;
}
&__metrics {
margin-top: 0;
}
&__metric-val {
@@ -243,18 +372,17 @@ q-page.consolidated(role="region", aria-label="Сводный заказ")
margin-top: 2px;
}
&__orders {
&__cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--p-4, 16px);
padding: var(--p-4, 16px);
border-top: 1px solid var(--p-line);
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: var(--p-3, 12px);
margin-top: var(--p-2, 8px);
}
}
@media (max-width: 768px) {
.consolidated {
padding: var(--p-4, 16px);
.collective {
padding: 0 var(--p-3, 12px) var(--p-4, 16px);
}
}
</style>
@@ -46,6 +46,8 @@ const rawOrderSelector = {
delivery_point_name: true,
delivery_point_address: true,
quantity: true,
group_accumulated_quantity: true,
group_min_volume: true,
price_per_unit: true,
total_cost: true,
cycle_id: true,
+2
View File
@@ -4208,6 +4208,8 @@ export const ReturnTypes: Record<string,any> = {
delivery_point_address:"String",
delivery_point_name:"String",
delivery_signer_account:"String",
group_accumulated_quantity:"Int",
group_min_volume:"Int",
id:"String",
issuance_fact:"MarketplaceOrderIssuanceFactSnapshot",
last_status_reason:"String",
+16
View File
@@ -7115,6 +7115,10 @@ export type ValueTypes = {
delivery_point_name?:boolean | `@${string}`,
/** Учётная запись стороны кооператива, поставившей подпись вместе с заказчиком. */
delivery_signer_account?:boolean | `@${string}`,
/** Сколько уже накоплено по этому предложению на данном пункте выдачи всеми заказчиками на этапе сбора партии (для прогресса коллективного заказа). Заполняется только пока заказ копится; после приёма поставщиком — пусто. */
group_accumulated_quantity?:boolean | `@${string}`,
/** Целевой минимальный объём поставки на этот пункт выдачи — ориентир сбора партии (не жёсткий порог). Заполняется только на этапе сбора. */
group_min_volume?:boolean | `@${string}`,
/** Идентификатор заказа. */
id?:boolean | `@${string}`,
/** Фактическая выдача после финальной подписи заказчика (заполняется на ПВЗ). */
@@ -17184,6 +17188,10 @@ export type ResolverInputTypes = {
delivery_point_name?:boolean | `@${string}`,
/** Учётная запись стороны кооператива, поставившей подпись вместе с заказчиком. */
delivery_signer_account?:boolean | `@${string}`,
/** Сколько уже накоплено по этому предложению на данном пункте выдачи всеми заказчиками на этапе сбора партии (для прогресса коллективного заказа). Заполняется только пока заказ копится; после приёма поставщиком — пусто. */
group_accumulated_quantity?:boolean | `@${string}`,
/** Целевой минимальный объём поставки на этот пункт выдачи — ориентир сбора партии (не жёсткий порог). Заполняется только на этапе сбора. */
group_min_volume?:boolean | `@${string}`,
/** Идентификатор заказа. */
id?:boolean | `@${string}`,
/** Фактическая выдача после финальной подписи заказчика (заполняется на ПВЗ). */
@@ -26923,6 +26931,10 @@ export type ModelTypes = {
delivery_point_name?: string | undefined | null,
/** Учётная запись стороны кооператива, поставившей подпись вместе с заказчиком. */
delivery_signer_account?: string | undefined | null,
/** Сколько уже накоплено по этому предложению на данном пункте выдачи всеми заказчиками на этапе сбора партии (для прогресса коллективного заказа). Заполняется только пока заказ копится; после приёма поставщиком — пусто. */
group_accumulated_quantity?: number | undefined | null,
/** Целевой минимальный объём поставки на этот пункт выдачи — ориентир сбора партии (не жёсткий порог). Заполняется только на этапе сбора. */
group_min_volume?: number | undefined | null,
/** Идентификатор заказа. */
id: string,
/** Фактическая выдача после финальной подписи заказчика (заполняется на ПВЗ). */
@@ -37615,6 +37627,10 @@ export type GraphQLTypes = {
delivery_point_name?: string | undefined | null,
/** Учётная запись стороны кооператива, поставившей подпись вместе с заказчиком. */
delivery_signer_account?: string | undefined | null,
/** Сколько уже накоплено по этому предложению на данном пункте выдачи всеми заказчиками на этапе сбора партии (для прогресса коллективного заказа). Заполняется только пока заказ копится; после приёма поставщиком — пусто. */
group_accumulated_quantity?: number | undefined | null,
/** Целевой минимальный объём поставки на этот пункт выдачи — ориентир сбора партии (не жёсткий порог). Заполняется только на этапе сбора. */
group_min_volume?: number | undefined | null,
/** Идентификатор заказа. */
id: string,
/** Фактическая выдача после финальной подписи заказчика (заполняется на ПВЗ). */