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:
+21
@@ -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,
|
||||
|
||||
+11
-4
@@ -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;
|
||||
|
||||
+25
-2
@@ -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;
|
||||
|
||||
+12
@@ -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 пулу заявки,
|
||||
|
||||
+25
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
+237
-109
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
/** Фактическая выдача после финальной подписи заказчика (заполняется на ПВЗ). */
|
||||
|
||||
Reference in New Issue
Block a user