Merge branch 'marketplace2' of ssh://git.coopenomics.world:222/C9S/mono into marketplace2
Typecheck / desktop (pull_request) Successful in 17m6s
Typecheck / controller (pull_request) Successful in 14m20s

This commit is contained in:
ant
2026-07-21 08:54:27 +00:00
61 changed files with 1146 additions and 657 deletions
@@ -1,4 +1,4 @@
import { Field, ID, InputType, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
import { Field, InputType, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
import { ArrayMinSize, IsArray, IsInt, IsNotEmpty, IsOptional, IsString, Min, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import type { MarketplaceAplReceptionDomainEntity } from '../../domain/entities/marketplace-apl-reception.entity';
@@ -29,7 +29,7 @@ registerEnumType(MarketplaceAplReceptionStatusEnum, {
@ObjectType('MarketplaceAplReceptionFactEntry')
export class MarketplaceAplReceptionFactEntryDTO {
@Field(() => ID)
@Field(() => String)
order_id!: string;
@Field(() => Int, { description: 'Фактически принятое количество (для расхождений Варианта Б).' })
@@ -56,7 +56,7 @@ export class MarketplaceAplReceptionFactEntryDTO {
@InputType('MarketplaceAplReceptionFactEntryInput')
export class MarketplaceAplReceptionFactEntryInputDTO {
@Field(() => ID)
@Field(() => String)
@IsString()
@IsNotEmpty()
order_id!: string;
@@ -77,16 +77,16 @@ export class MarketplaceAplReceptionFactEntryInputDTO {
@ObjectType('MarketplaceAplReception')
export class MarketplaceAplReceptionDTO {
@Field(() => ID)
@Field(() => String)
id!: string;
@Field(() => String)
coopname!: string;
@Field(() => ID, { description: 'Партия поставки, по которой формируется приёмка.' })
@Field(() => String, { description: 'Партия поставки, по которой формируется приёмка.' })
shipment_id!: string;
@Field(() => ID, { description: 'Консолидированная заявка.' })
@Field(() => String, { description: 'Консолидированная заявка.' })
cycle_id!: string;
@Field(() => String, { description: 'КУ-получатель партии.' })
@@ -154,7 +154,7 @@ export class MarketplaceAplReceptionDTO {
@InputType('MarketplaceCreateAplReceptionInput')
export class MarketplaceCreateAplReceptionInputDTO {
@Field(() => ID, { description: 'Партия поставки в статусе SUPPLY_PREPARED.' })
@Field(() => String, { description: 'Партия поставки в статусе SUPPLY_PREPARED.' })
@IsString()
@IsNotEmpty()
shipment_id!: string;
@@ -173,7 +173,7 @@ export class MarketplaceCreateAplReceptionInputDTO {
@InputType('MarketplaceSignAplReceptionInput')
export class MarketplaceSignAplReceptionInputDTO {
@Field(() => ID, { description: 'Идентификатор акта приёмки.' })
@Field(() => String, { description: 'Идентификатор акта приёмки.' })
@IsString()
@IsNotEmpty()
apl_reception_id!: string;
@@ -191,7 +191,7 @@ export class MarketplaceSignAplReceptionInputDTO {
@InputType('MarketplaceAplReceptionByIdInput')
export class MarketplaceAplReceptionByIdInputDTO {
@Field(() => ID, { description: 'Идентификатор акта приёмки.' })
@Field(() => String, { description: 'Идентификатор акта приёмки.' })
@IsString()
@IsNotEmpty()
apl_reception_id!: string;
@@ -1,4 +1,4 @@
import { Field, ID, InputType, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
import { Field, InputType, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsArray,
@@ -61,7 +61,7 @@ registerEnumType(MarketplaceInventoryStatusEnum, {
@ObjectType('MarketplaceInventoryItem')
export class MarketplaceInventoryItemDTO {
@Field(() => ID)
@Field(() => String)
id!: string;
@Field(() => String, { description: 'Кооператив, на складе которого лежит имущество.' })
@@ -76,10 +76,10 @@ export class MarketplaceInventoryItemDTO {
@Field(() => MarketplaceBarcodeFormatEnum, { nullable: true })
barcode_format!: MarketplaceBarcodeFormatEnum | null;
@Field(() => ID, { description: 'Заказ, к которому относится позиция.' })
@Field(() => String, { description: 'Заказ, к которому относится позиция.' })
order_id!: string;
@Field(() => ID, { description: 'Партия поставки, в составе которой имущество получено.' })
@Field(() => String, { description: 'Партия поставки, в составе которой имущество получено.' })
shipment_id!: string;
@Field(() => String, { description: 'КУ-получатель имущества.' })
@@ -159,13 +159,13 @@ export class MarketplaceInventoryItemDTO {
})
arrival_price!: string | null;
@Field(() => ID, {
@Field(() => String, {
nullable: true,
description: 'Предложение кооператива, которым остаток опубликован в каталоге. Пусто — не опубликован.',
})
published_offer_id!: string | null;
@Field(() => ID, {
@Field(() => String, {
nullable: true,
description: 'Заказ со склада кооператива, под который позиция зарезервирована. Пусто — свободна.',
})
@@ -180,7 +180,7 @@ export class MarketplaceInventoryItemDTO {
@InputType('MarketplaceGenerateInventoryLabelInput')
export class MarketplaceGenerateInventoryLabelInputDTO {
@Field(() => ID, { description: 'Позиция склада, на которую наклеивается штрих-код.' })
@Field(() => String, { description: 'Позиция склада, на которую наклеивается штрих-код.' })
@IsString()
@IsNotEmpty()
inventory_id!: string;
@@ -196,7 +196,7 @@ export class MarketplaceGenerateInventoryLabelInputDTO {
@InputType('MarketplaceBindInventoryBarcodeInput')
export class MarketplaceBindInventoryBarcodeInputDTO {
@Field(() => ID, { description: 'Позиция склада, к которой привязывается отсканированный штрих-код.' })
@Field(() => String, { description: 'Позиция склада, к которой привязывается отсканированный штрих-код.' })
@IsString()
@IsNotEmpty()
inventory_id!: string;
@@ -220,7 +220,7 @@ export class MarketplaceBindInventoryBarcodeInputDTO {
@InputType('MarketplaceAssignInventoryShelfInput')
export class MarketplaceAssignInventoryShelfInputDTO {
@Field(() => ID, { description: 'Позиция склада, для которой назначается полка.' })
@Field(() => String, { description: 'Позиция склада, для которой назначается полка.' })
@IsString()
@IsNotEmpty()
inventory_id!: string;
@@ -236,7 +236,7 @@ export class MarketplaceAssignInventoryShelfInputDTO {
@InputType('MarketplaceClearInventoryLabelInput')
export class MarketplaceClearInventoryLabelInputDTO {
@Field(() => ID, { description: 'Позиция склада, с которой снимается штрих-код (для переклейки).' })
@Field(() => String, { description: 'Позиция склада, с которой снимается штрих-код (для переклейки).' })
@IsString()
@IsNotEmpty()
inventory_id!: string;
@@ -260,7 +260,7 @@ export class MarketplaceInventorySplitEntryInputDTO {
@InputType('MarketplaceSplitInventoryInput')
export class MarketplaceSplitInventoryInputDTO {
@Field(() => ID, { description: 'Позиция склада, которую раскладывают по нескольким полкам.' })
@Field(() => String, { description: 'Позиция склада, которую раскладывают по нескольким полкам.' })
@IsString()
@IsNotEmpty()
inventory_id!: string;
@@ -284,12 +284,12 @@ export class MarketplaceInventoryMutationResultDTO {
@InputType('MarketplaceListInventoryInput')
export class MarketplaceListInventoryInputDTO {
@Field(() => ID, { nullable: true, description: 'Фильтр по заказу.' })
@Field(() => String, { nullable: true, description: 'Фильтр по заказу.' })
@IsOptional()
@IsString()
order_id?: string;
@Field(() => ID, { nullable: true, description: 'Фильтр по партии поставки.' })
@Field(() => String, { nullable: true, description: 'Фильтр по партии поставки.' })
@IsOptional()
@IsString()
shipment_id?: string;
@@ -188,9 +188,24 @@ export class MarketplaceOrderDTO {
@Field(() => String, { description: 'Цена за единицу товара на момент заказа.' })
public readonly price_per_unit!: string;
@Field(() => String, { description: 'Общая сумма заказа.' })
@Field(() => String, { description: 'Общая сумма заказа (стоимость имущества без членского взноса).' })
public readonly total_cost!: string;
@Field(() => String, {
nullable: true,
description:
'Членский взнос, включённый в стоимость заказа, по ставке на момент оформления. ' +
'Заказчик платит total_cost + membership_fee; пусто — заказ ещё не подтверждён блокчейном.',
})
public readonly membership_fee!: string | null;
@Field(() => String, {
description:
'Полная сумма к оплате заказчиком: total_cost + membership_fee. Готовое значение — ' +
'клиенту не нужно складывать поля самому.',
})
public readonly total_cost_with_fee!: string;
@Field(() => String, { nullable: true, description: 'Идентификатор партии-накопителя, если заказ присоединён.' })
public readonly cycle_id!: string | null;
@@ -370,6 +385,17 @@ export interface MarketplaceOrderDisplayFields {
warehouse_shelves?: string[] | null;
}
/**
* `total_cost`/`membership_fee` — numeric(_, 4) колонки (scale колонки, не
* MARKETPLACE_ASSET_CONFIG.decimals — блокчейн-символ здесь ни при чём,
* складываются уже персистентные PG-значения). Фиксированные 4 знака держат
* результат чистым от плавающей точки на типичных суммах; конечное округление
* до валюты — на клиенте при отображении (formatAsset2Digits и т.п.).
*/
function sumOrderAmounts(total_cost: string, membership_fee: string | null): string {
return (Number.parseFloat(total_cost) + Number.parseFloat(membership_fee ?? '0')).toFixed(4);
}
export function toMarketplaceOrderDTO(
o: MarketplaceOrderDomainEntity,
display?: MarketplaceOrderDisplayFields
@@ -398,6 +424,8 @@ export function toMarketplaceOrderDTO(
group_min_volume: display?.group_min_volume ?? null,
price_per_unit: o.price_per_unit,
total_cost: o.total_cost,
membership_fee: o.membership_fee,
total_cost_with_fee: sumOrderAmounts(o.total_cost, o.membership_fee),
cycle_id: o.cycle_id,
checkout_id: o.checkout_id,
shipment_id: o.shipment_id,
@@ -1,4 +1,4 @@
import { Field, ID, InputType, ObjectType, registerEnumType } from '@nestjs/graphql';
import { Field, InputType, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IsArray, IsEnum, IsOptional, IsString } from 'class-validator';
import type { MarketplaceOutgoingPaymentRequestDomainEntity } from '../../domain/entities/marketplace-outgoing-payment-request.entity';
@@ -50,7 +50,7 @@ export class MarketplaceListOutgoingPaymentsFilterInputDTO {
@ObjectType('MarketplaceOutgoingPaymentRequest')
export class MarketplaceOutgoingPaymentRequestDTO {
@Field(() => ID)
@Field(() => String)
id!: string;
@Field(() => String)
@@ -61,10 +61,10 @@ export class MarketplaceOutgoingPaymentRequestDTO {
})
order_hash!: string;
@Field(() => ID, { description: 'Идентификатор заказа в каталоге поставок.' })
@Field(() => String, { description: 'Идентификатор заказа в каталоге поставок.' })
order_id!: string;
@Field(() => ID, { description: 'Акт приёмки, по которому возникло обязательство.' })
@Field(() => String, { description: 'Акт приёмки, по которому возникло обязательство.' })
apl_reception_id!: string;
@Field(() => String, { description: 'Аккаунт поставщика — получатель выплаты.' })
@@ -103,7 +103,7 @@ export class MarketplaceOutgoingPaymentRequestDTO {
})
payout_tx_hash!: string | null;
@Field(() => ID, {
@Field(() => String, {
nullable: true,
description:
'Идентификатор связанного платежа в общем реестре кооператива — кассир видит ' +
@@ -1,4 +1,4 @@
import { Field, ID, Int, InputType, ObjectType, registerEnumType } from '@nestjs/graphql';
import { Field, Int, InputType, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IsArray, IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, Min, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import type { MarketplaceShipmentDomainEntity } from '../../domain/entities/marketplace-shipment.entity';
@@ -38,7 +38,7 @@ registerEnumType(MarketplaceShipmentStatusEnum, {
// Задаётся при формировании партии; число коробок выводится из количества.
@ObjectType('MarketplaceShipmentLinePackaging')
export class MarketplaceShipmentLinePackagingDTO {
@Field(() => ID, { description: 'Заказ партии, к которому относится упаковка.' })
@Field(() => String, { description: 'Заказ партии, к которому относится упаковка.' })
order_id!: string;
@Field(() => Int, { description: 'Сколько единиц имущества в одной коробке.' })
units_per_box!: number;
@@ -75,7 +75,7 @@ export class MarketplaceShipmentTTNDataDTO {
// (для каждого заказа: сколько единиц в коробке).
@InputType('MarketplaceShipmentLinePackagingInput')
export class MarketplaceShipmentLinePackagingInputDTO {
@Field(() => ID, { description: 'Заказ партии, к которому относится упаковка.' })
@Field(() => String, { description: 'Заказ партии, к которому относится упаковка.' })
@IsString()
@IsNotEmpty()
order_id!: string;
@@ -131,13 +131,13 @@ export class MarketplaceShipmentTTNDataInputDTO {
@ObjectType('MarketplaceShipment')
export class MarketplaceShipmentDTO {
@Field(() => ID)
@Field(() => String)
id!: string;
@Field(() => String, { description: 'Кооператив, в котором сформирована партия.' })
coopname!: string;
@Field(() => ID, { description: 'Идентификатор консолидированной заявки.' })
@Field(() => String, { description: 'Идентификатор консолидированной заявки.' })
cycle_id!: string;
@Field(() => String, { description: 'Account поставщика-владельца Offer\'ов.' })
@@ -195,7 +195,7 @@ export class MarketplaceShipmentGroupInputDTO {
@IsEnum(MarketplaceShipmentDeliveryVariantEnum)
delivery_variant!: MarketplaceShipmentDeliveryVariantEnum;
@Field(() => [ID], {
@Field(() => [String], {
nullable: true,
description:
'Подмножество заказов этого КУ, реально погружаемых в партию (частичная отгрузка). ' +
@@ -218,7 +218,7 @@ export class MarketplaceShipmentGroupInputDTO {
@InputType('MarketplaceCreateShipmentInput')
export class MarketplaceCreateShipmentInputDTO {
@Field(() => ID, { description: 'Идентификатор консолидированной заявки в статусе ACCEPTED.' })
@Field(() => String, { description: 'Идентификатор консолидированной заявки в статусе ACCEPTED.' })
@IsString()
@IsNotEmpty()
cycle_id!: string;
@@ -245,7 +245,7 @@ export class MarketplaceCreateShipmentResultDTO {
@InputType('MarketplaceGetShipmentInput')
export class MarketplaceGetShipmentInputDTO {
@Field(() => ID, { description: 'Идентификатор партии поставки.' })
@Field(() => String, { description: 'Идентификатор партии поставки.' })
@IsString()
@IsNotEmpty()
shipment_id!: string;
@@ -253,7 +253,7 @@ export class MarketplaceGetShipmentInputDTO {
@InputType('MarketplaceListShipmentsInput')
export class MarketplaceListShipmentsInputDTO {
@Field(() => ID, { nullable: true, description: 'Фильтр по консолидированной заявке.' })
@Field(() => String, { nullable: true, description: 'Фильтр по консолидированной заявке.' })
@IsOptional()
@IsString()
cycle_id?: string;
@@ -1,4 +1,4 @@
import { Field, ID, InputType, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
import { Field, InputType, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
ArrayNotEmpty,
@@ -31,7 +31,7 @@ registerEnumType(MarketplaceStockProposalStatusEnum, {
@InputType('MarketplacePublishStockInput')
export class MarketplacePublishStockInputDTO {
@Field(() => [ID], { description: 'Позиции свободного остатка склада для публикации в каталог.' })
@Field(() => [String], { description: 'Позиции свободного остатка склада для публикации в каталог.' })
@IsArray()
@ArrayNotEmpty()
@IsString({ each: true })
@@ -49,7 +49,7 @@ export class MarketplacePublishStockInputDTO {
@InputType('MarketplaceUnpublishStockInput')
export class MarketplaceUnpublishStockInputDTO {
@Field(() => [ID], { description: 'Опубликованные позиции остатка, снимаемые с витрины.' })
@Field(() => [String], { description: 'Опубликованные позиции остатка, снимаемые с витрины.' })
@IsArray()
@ArrayNotEmpty()
@IsString({ each: true })
@@ -64,7 +64,7 @@ export class MarketplaceUnpublishStockResultDTO {
@InputType('MarketplaceStockProposalItemInput')
export class MarketplaceStockProposalItemInputDTO {
@Field(() => ID, { description: 'Предложение кооператива из опубликованного остатка.' })
@Field(() => String, { description: 'Предложение кооператива из опубликованного остатка.' })
@IsString()
@IsNotEmpty()
offer_id!: string;
@@ -99,7 +99,7 @@ export class MarketplaceStockIssuancePrepareInputDTO {
@ObjectType('MarketplaceStockIssuanceOperatorLine')
export class MarketplaceStockIssuanceOperatorLineDTO {
@Field(() => ID, { description: 'Предложение кооператива из остатка.' })
@Field(() => String, { description: 'Предложение кооператива из остатка.' })
offer_id!: string;
@Field(() => Int, { description: 'Количество единиц.' })
@@ -122,7 +122,7 @@ export class MarketplaceStockIssuanceOperatorLineDTO {
@InputType('MarketplaceCreateStockProposalLineInput')
export class MarketplaceCreateStockProposalLineInputDTO {
@Field(() => ID, { description: 'Предложение кооператива из остатка.' })
@Field(() => String, { description: 'Предложение кооператива из остатка.' })
@IsString()
@IsNotEmpty()
offer_id!: string;
@@ -147,7 +147,7 @@ export class MarketplaceCreateStockProposalLineInputDTO {
@InputType('MarketplaceCreateOrderProposalLineInput')
export class MarketplaceCreateOrderProposalLineInputDTO {
@Field(() => ID, { description: 'Существующий заказ пайщика к выдаче этим бандлом.' })
@Field(() => String, { description: 'Существующий заказ пайщика к выдаче этим бандлом.' })
@IsString()
@IsNotEmpty()
order_id!: string;
@@ -205,7 +205,7 @@ export class MarketplaceCreateStockProposalInputDTO {
@InputType('MarketplaceResolveStockProposalInput')
export class MarketplaceResolveStockProposalInputDTO {
@Field(() => ID, { description: 'Предложение со склада кооператива.' })
@Field(() => String, { description: 'Предложение со склада кооператива.' })
@IsString()
@IsNotEmpty()
proposal_id!: string;
@@ -296,7 +296,7 @@ export class MarketplaceFinalizeStockIssuanceInputDTO extends MarketplaceResolve
@InputType('MarketplaceCancelStockOrderInput')
export class MarketplaceCancelStockOrderInputDTO {
@Field(() => ID, { description: 'Заказ со склада кооператива, отменяемый до открытия выдачи.' })
@Field(() => String, { description: 'Заказ со склада кооператива, отменяемый до открытия выдачи.' })
@IsString()
@IsNotEmpty()
order_id!: string;
@@ -325,7 +325,7 @@ export class MarketplaceListStockProposalsInputDTO {
@ObjectType('MarketplaceStockProposalItem')
export class MarketplaceStockProposalItemDTO {
@Field(() => ID, { description: 'Предложение кооператива из остатка.' })
@Field(() => String, { description: 'Предложение кооператива из остатка.' })
offer_id!: string;
@Field(() => Int, { description: 'Предложенное количество единиц.' })
@@ -340,7 +340,7 @@ export class MarketplaceStockProposalItemDTO {
@ObjectType('MarketplaceStockProposal')
export class MarketplaceStockProposalDTO {
@Field(() => ID)
@Field(() => String)
id!: string;
@Field(() => String, { description: 'Кооперативный участок, со склада которого предложено имущество.' })
@@ -361,7 +361,7 @@ export class MarketplaceStockProposalDTO {
@Field(() => String, { description: 'Итоговая сумма предложения.' })
total_cost!: string;
@Field(() => [ID], { description: 'Заказы, созданные при принятии предложения.' })
@Field(() => [String], { description: 'Заказы, созданные при принятии предложения.' })
created_order_ids!: string[];
@Field(() => Date, { nullable: true, description: 'Момент решения по предложению.' })
@@ -376,7 +376,7 @@ export class MarketplaceStockProposalAcceptResultDTO {
@Field(() => MarketplaceStockProposalDTO)
proposal!: MarketplaceStockProposalDTO;
@Field(() => [ID], { description: 'Созданные заказы со склада кооператива.' })
@Field(() => [String], { description: 'Созданные заказы со склада кооператива.' })
order_ids!: string[];
}
@@ -123,6 +123,13 @@ function buildMocks() {
resolvePayoutMethod: jest.fn().mockResolvedValue(null),
} as any;
// Реквизиты договора поставщика для назначения платежа в initiatePayouts
// (см. MarketplaceSupplierRegistryService.findByMember). По умолчанию —
// «нет записи»: buildPayoutPurpose падает на fallback-текст без реквизитов.
const supplierRegistry = {
findByMember: jest.fn().mockResolvedValue(null),
} as any;
const supplierActionService = {
declineOrdersAtReception: jest.fn().mockResolvedValue([]),
} as any;
@@ -147,6 +154,7 @@ function buildMocks() {
inventoryRepo,
coreGateway,
supplierSettings,
supplierRegistry,
supplierActionService,
documentDomainService,
logger,
@@ -166,6 +174,7 @@ function buildService(mocks: ReturnType<typeof buildMocks>): MarketplaceAplRecep
{ symbol: 'RUB', decimals: 4 },
mocks.coreGateway,
mocks.supplierSettings,
mocks.supplierRegistry,
mocks.supplierActionService,
mocks.documentDomainService,
new EventEmitter2(),
@@ -0,0 +1,325 @@
/**
* Unit-тесты MarketplaceSupplierRegistryService.
*
* Заменяет удалённый MarketplaceWhitelistService (PR #152, [598-51]) — тот
* тестировал упразднённую концепцию whitelist (открытая витрина / по списку),
* которой в коде больше нет. Реестр поставщиков — другая модель: заявка →
* рассмотрение → APPROVED/REJECTED, допуск офферера строго по статусу.
*
* Покрывают:
* - isOfferer: сам кооператив всегда true (без похода в репозиторий),
* APPROVED → true, PENDING/REJECTED/нет записи → false, cache hit;
* - requestMembership: создание PENDING, идемпотентность повторной заявки,
* конфликт на уже APPROVED, переоткрытие REJECTED с новыми реквизитами;
* - addSupplier: upsert (create при отсутствии / patch при наличии) сразу в APPROVED;
* - approve/reject: патчат статус, инвалидируют isOfferer-кеш;
* - switchModel: SHARE — заглушка (ConflictException), MEMBERSHIP — сброс в PENDING.
*/
import { ConflictException } from '@nestjs/common';
import { MarketplaceSupplierRegistryService } from './marketplace-supplier-registry.service';
import { MarketplaceSupplierDomainEntity } from '../../domain/entities/marketplace-supplier.entity';
import {
MarketplaceSupplierModel,
MarketplaceSupplierStatus,
} from '../../domain/entities/marketplace-supplier.types';
import type { MarketplaceSupplierDomainRepository } from '../../domain/repositories/marketplace-supplier.repository';
const COOP = 'voskhod';
function makeSupplier(
member: string,
status: MarketplaceSupplierStatus,
overrides: Partial<{
model: MarketplaceSupplierModel;
contract_number: string | null;
contract_date: string | null;
reviewed_by: string | null;
reviewed_at: Date | null;
}> = {}
): MarketplaceSupplierDomainEntity {
return new MarketplaceSupplierDomainEntity({
id: `id-${member}`,
coopname: COOP,
member_account: member,
model: overrides.model ?? MarketplaceSupplierModel.MEMBERSHIP,
status,
contract_number: overrides.contract_number ?? '17/2026',
contract_date: overrides.contract_date ?? '2026-05-01',
contract_document_url: null,
requested_by: member,
requested_at: new Date('2026-05-01T00:00:00Z'),
reviewed_by: overrides.reviewed_by ?? null,
reviewed_at: overrides.reviewed_at ?? null,
});
}
function makeRepo(): jest.Mocked<MarketplaceSupplierDomainRepository> {
return {
list: jest.fn(),
findByMember: jest.fn(),
create: jest.fn(),
patch: jest.fn(),
remove: jest.fn(),
};
}
function makeEventBus() {
return { emit: jest.fn() } as unknown as import('@nestjs/event-emitter').EventEmitter2;
}
describe('MarketplaceSupplierRegistryService', () => {
describe('isOfferer', () => {
it('сам кооператив → true без обращения к репозиторию (перепоставка остатков FR5)', async () => {
const repo = makeRepo();
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
await expect(service.isOfferer(COOP, COOP)).resolves.toBe(true);
expect(repo.findByMember).not.toHaveBeenCalled();
});
it('запись APPROVED → true', async () => {
const repo = makeRepo();
repo.findByMember.mockResolvedValue(makeSupplier('alice', MarketplaceSupplierStatus.APPROVED));
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
await expect(service.isOfferer(COOP, 'alice')).resolves.toBe(true);
});
it('запись PENDING → false (ещё не одобрен)', async () => {
const repo = makeRepo();
repo.findByMember.mockResolvedValue(makeSupplier('alice', MarketplaceSupplierStatus.PENDING));
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
await expect(service.isOfferer(COOP, 'alice')).resolves.toBe(false);
});
it('записи нет → false', async () => {
const repo = makeRepo();
repo.findByMember.mockResolvedValue(null);
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
await expect(service.isOfferer(COOP, 'ghost')).resolves.toBe(false);
});
it('второй вызов того же пайщика — cache hit, repo не дёргается повторно', async () => {
const repo = makeRepo();
repo.findByMember.mockResolvedValue(makeSupplier('alice', MarketplaceSupplierStatus.APPROVED));
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
await service.isOfferer(COOP, 'alice');
await service.isOfferer(COOP, 'alice');
expect(repo.findByMember).toHaveBeenCalledTimes(1);
});
});
describe('requestMembership', () => {
it('без существующей записи → создаёт PENDING и эмитит уведомление председателю', async () => {
const repo = makeRepo();
repo.findByMember.mockResolvedValue(null);
repo.create.mockResolvedValue(makeSupplier('bob', MarketplaceSupplierStatus.PENDING));
const eventBus = makeEventBus();
const service = new MarketplaceSupplierRegistryService(repo, eventBus);
const result = await service.requestMembership(COOP, 'bob', '17/2026', '2026-05-01');
expect(result.status).toBe(MarketplaceSupplierStatus.PENDING);
expect(repo.create).toHaveBeenCalledWith(
expect.objectContaining({
coopname: COOP,
member_account: 'bob',
status: MarketplaceSupplierStatus.PENDING,
contract_number: '17/2026',
contract_date: '2026-05-01',
})
);
expect(eventBus.emit).toHaveBeenCalledTimes(1);
});
it('уже поданная PENDING-заявка → идемпотентна, возвращает существующую без повторной записи', async () => {
const repo = makeRepo();
const pending = makeSupplier('bob', MarketplaceSupplierStatus.PENDING);
repo.findByMember.mockResolvedValue(pending);
const eventBus = makeEventBus();
const service = new MarketplaceSupplierRegistryService(repo, eventBus);
const result = await service.requestMembership(COOP, 'bob', '17/2026', '2026-05-01');
expect(result).toBe(pending);
expect(repo.create).not.toHaveBeenCalled();
expect(repo.patch).not.toHaveBeenCalled();
expect(eventBus.emit).not.toHaveBeenCalled();
});
it('уже APPROVED → 409 Conflict, повторная заявка запрещена', async () => {
const repo = makeRepo();
repo.findByMember.mockResolvedValue(makeSupplier('bob', MarketplaceSupplierStatus.APPROVED));
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
await expect(service.requestMembership(COOP, 'bob', '17/2026', '2026-05-01')).rejects.toThrow(
ConflictException
);
expect(repo.patch).not.toHaveBeenCalled();
});
it('REJECTED → переоткрывает заявку в PENDING с новыми реквизитами договора', async () => {
const repo = makeRepo();
repo.findByMember.mockResolvedValue(makeSupplier('bob', MarketplaceSupplierStatus.REJECTED));
repo.patch.mockResolvedValue(
makeSupplier('bob', MarketplaceSupplierStatus.PENDING, {
contract_number: '18/2026',
contract_date: '2026-06-01',
})
);
const eventBus = makeEventBus();
const service = new MarketplaceSupplierRegistryService(repo, eventBus);
const result = await service.requestMembership(COOP, 'bob', '18/2026', '2026-06-01');
expect(repo.patch).toHaveBeenCalledWith(
COOP,
'bob',
expect.objectContaining({
status: MarketplaceSupplierStatus.PENDING,
contract_number: '18/2026',
contract_date: '2026-06-01',
reviewed_by: null,
reviewed_at: null,
})
);
expect(result.contract_number).toBe('18/2026');
expect(eventBus.emit).toHaveBeenCalledTimes(1);
});
});
describe('addSupplier (путь 2 — прямое добавление администратором)', () => {
it('без существующей записи → создаёт сразу APPROVED', async () => {
const repo = makeRepo();
repo.findByMember.mockResolvedValue(null);
repo.create.mockResolvedValue(makeSupplier('carl', MarketplaceSupplierStatus.APPROVED));
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
const result = await service.addSupplier(
COOP,
'carl',
MarketplaceSupplierModel.MEMBERSHIP,
'19/2026',
'2026-06-10',
'chairman1'
);
expect(result.status).toBe(MarketplaceSupplierStatus.APPROVED);
expect(repo.create).toHaveBeenCalledWith(
expect.objectContaining({ status: MarketplaceSupplierStatus.APPROVED, requested_by: 'chairman1', reviewed_by: 'chairman1' })
);
expect(repo.patch).not.toHaveBeenCalled();
});
it('с существующей записью → upsert через patch в APPROVED', async () => {
const repo = makeRepo();
repo.findByMember.mockResolvedValue(makeSupplier('carl', MarketplaceSupplierStatus.REJECTED));
repo.patch.mockResolvedValue(makeSupplier('carl', MarketplaceSupplierStatus.APPROVED));
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
await service.addSupplier(COOP, 'carl', MarketplaceSupplierModel.MEMBERSHIP, '20/2026', '2026-06-11', 'chairman1');
expect(repo.patch).toHaveBeenCalledWith(
COOP,
'carl',
expect.objectContaining({ status: MarketplaceSupplierStatus.APPROVED, reviewed_by: 'chairman1' })
);
expect(repo.create).not.toHaveBeenCalled();
});
it('инвалидирует isOfferer-кеш кооператива', async () => {
const repo = makeRepo();
repo.findByMember.mockResolvedValueOnce(null); // прогрев кеша isOfferer('dave') → false
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
await expect(service.isOfferer(COOP, 'dave')).resolves.toBe(false);
repo.findByMember.mockResolvedValueOnce(null); // addSupplier сам ищет существующую запись
repo.create.mockResolvedValue(makeSupplier('dave', MarketplaceSupplierStatus.APPROVED));
await service.addSupplier(COOP, 'dave', MarketplaceSupplierModel.MEMBERSHIP, '21/2026', '2026-06-12', 'chairman1');
repo.findByMember.mockResolvedValueOnce(makeSupplier('dave', MarketplaceSupplierStatus.APPROVED));
await expect(service.isOfferer(COOP, 'dave')).resolves.toBe(true);
// Без инвалидации второй вызов взял бы старое значение из кеша (false) без похода в repo.
expect(repo.findByMember).toHaveBeenCalledTimes(3);
});
});
describe('approve / reject', () => {
it('approve патчит статус в APPROVED', async () => {
const repo = makeRepo();
repo.patch.mockResolvedValue(makeSupplier('eve', MarketplaceSupplierStatus.APPROVED));
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
const result = await service.approve(COOP, 'eve', 'chairman1');
expect(result.status).toBe(MarketplaceSupplierStatus.APPROVED);
expect(repo.patch).toHaveBeenCalledWith(
COOP,
'eve',
expect.objectContaining({ status: MarketplaceSupplierStatus.APPROVED, reviewed_by: 'chairman1' })
);
});
it('reject патчит статус в REJECTED', async () => {
const repo = makeRepo();
repo.patch.mockResolvedValue(makeSupplier('eve', MarketplaceSupplierStatus.REJECTED));
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
const result = await service.reject(COOP, 'eve', 'chairman1');
expect(result.status).toBe(MarketplaceSupplierStatus.REJECTED);
expect(repo.patch).toHaveBeenCalledWith(
COOP,
'eve',
expect.objectContaining({ status: MarketplaceSupplierStatus.REJECTED, reviewed_by: 'chairman1' })
);
});
});
describe('switchModel', () => {
it('SHARE — заглушка, 409 Conflict, репозиторий не трогается', async () => {
const repo = makeRepo();
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
await expect(
service.switchModel(COOP, 'alice', MarketplaceSupplierModel.SHARE, null, null)
).rejects.toThrow(ConflictException);
expect(repo.patch).not.toHaveBeenCalled();
});
it('MEMBERSHIP — сбрасывает запись в PENDING с новым договором', async () => {
const repo = makeRepo();
repo.patch.mockResolvedValue(
makeSupplier('alice', MarketplaceSupplierStatus.PENDING, {
model: MarketplaceSupplierModel.MEMBERSHIP,
})
);
const service = new MarketplaceSupplierRegistryService(repo, makeEventBus());
const result = await service.switchModel(
COOP,
'alice',
MarketplaceSupplierModel.MEMBERSHIP,
'22/2026',
'2026-06-15'
);
expect(result.status).toBe(MarketplaceSupplierStatus.PENDING);
expect(repo.patch).toHaveBeenCalledWith(
COOP,
'alice',
expect.objectContaining({
model: MarketplaceSupplierModel.MEMBERSHIP,
status: MarketplaceSupplierStatus.PENDING,
contract_number: '22/2026',
contract_date: '2026-06-15',
reviewed_by: null,
reviewed_at: null,
})
);
});
});
});
@@ -87,6 +87,17 @@ function buildMocks() {
applyStatusTransition: jest.fn(),
} as unknown as jest.Mocked<MarketplaceInventoryDomainRepository>;
// Обогащение позиций списания названием товара/заказа и адресом КУ
// (см. MarketplaceOrderDisplayService.resolveBranchDisplay) — по умолчанию
// «нет данных», тесты бизнес-логики списания эти поля не проверяют.
const orderRepo = { findById: jest.fn().mockResolvedValue(null) } as any;
const offerRepo = { findById: jest.fn().mockResolvedValue(null) } as any;
const orderDisplay = {
resolveBranchDisplay: jest
.fn()
.mockResolvedValue({ name: null, address: null, lat: null, lng: null }),
} as any;
const chainPort: jest.Mocked<MarketplaceCanonicalBlockchainPort> = {
propWroff: jest.fn().mockResolvedValue({ transaction: { id: 'tx-prop' } } as any),
execWroff: jest.fn().mockResolvedValue({ transaction: { id: 'tx-exec' } } as any),
@@ -105,16 +116,30 @@ function buildMocks() {
error: jest.fn(),
} as any;
return { repo, inventoryRepo, chainPort, assetConfig, documentDomainService, eventBus, logger };
return {
repo,
inventoryRepo,
orderRepo,
offerRepo,
chainPort,
assetConfig,
documentDomainService,
orderDisplay,
eventBus,
logger,
};
}
function buildService(mocks: ReturnType<typeof buildMocks>): MarketplaceWriteoffService {
return new MarketplaceWriteoffService(
mocks.repo,
mocks.inventoryRepo,
mocks.orderRepo,
mocks.offerRepo,
mocks.chainPort,
mocks.assetConfig,
mocks.documentDomainService,
mocks.orderDisplay,
mocks.eventBus,
mocks.logger
);
@@ -14,6 +14,7 @@ function buildProps(overrides: Partial<MarketplaceOrderProps> = {}): Marketplace
quantity: 3,
price_per_unit: '150.0000',
total_cost: '450.0000',
membership_fee: null,
cycle_id: null,
checkout_id: null,
shipment_id: null,
@@ -87,6 +88,7 @@ describe('MarketplaceOrderDomainEntity', () => {
order_hash: 'c'.repeat(64), // другой hash
on_chain_id: '99',
status: 'ACTIVE',
membership_fee: null,
},
1_000_000,
true
@@ -106,6 +108,7 @@ describe('MarketplaceOrderDomainEntity', () => {
order_hash: 'a'.repeat(64),
on_chain_id: '101',
status: 'ACCEPTED',
membership_fee: null,
},
2_000_000,
true
@@ -164,7 +167,7 @@ describe('MarketplaceOrderDomainEntity', () => {
const order = new MarketplaceOrderDomainEntity(buildProps({ status: 'ACTIVE' }));
order.updateFromBlockchain(
{ order_hash: 'a'.repeat(64), on_chain_id: '10', status: 'ACCEPTED' },
{ order_hash: 'a'.repeat(64), on_chain_id: '10', status: 'ACCEPTED', membership_fee: null },
2_000_000,
true
);
@@ -178,7 +181,7 @@ describe('MarketplaceOrderDomainEntity', () => {
);
order.updateFromBlockchain(
{ order_hash: 'a'.repeat(64), on_chain_id: '11', status: 'ACTIVE' },
{ order_hash: 'a'.repeat(64), on_chain_id: '11', status: 'ACTIVE', membership_fee: null },
2_100_000,
true
);
@@ -192,7 +195,7 @@ describe('MarketplaceOrderDomainEntity', () => {
const order = new MarketplaceOrderDomainEntity(buildProps({ status: 'ACCEPTED' }));
order.updateFromBlockchain(
{ order_hash: 'a'.repeat(64), on_chain_id: '12', status: 'ACTIVE' },
{ order_hash: 'a'.repeat(64), on_chain_id: '12', status: 'ACTIVE', membership_fee: null },
2_200_000,
true
);
@@ -204,7 +207,7 @@ describe('MarketplaceOrderDomainEntity', () => {
const order = new MarketplaceOrderDomainEntity(buildProps({ status: 'ACCEPTED' }));
order.updateFromBlockchain(
{ order_hash: 'a'.repeat(64), on_chain_id: '13', status: 'CANCELLED_BY_ORDERER' },
{ order_hash: 'a'.repeat(64), on_chain_id: '13', status: 'CANCELLED_BY_ORDERER', membership_fee: null },
2_300_000,
false
);
@@ -224,7 +227,7 @@ describe('MarketplaceOrderDomainEntity', () => {
);
order.updateFromBlockchain(
{ order_hash: 'a'.repeat(64), on_chain_id: '20', status: 'ACTIVE' },
{ order_hash: 'a'.repeat(64), on_chain_id: '20', status: 'ACTIVE', membership_fee: null },
2_500_000,
false
);
@@ -237,7 +240,7 @@ describe('MarketplaceOrderDomainEntity', () => {
const order = new MarketplaceOrderDomainEntity(buildProps({ status: 'RETURNED' }));
order.updateFromBlockchain(
{ order_hash: 'a'.repeat(64), on_chain_id: '21', status: 'RECEIVED' },
{ order_hash: 'a'.repeat(64), on_chain_id: '21', status: 'RECEIVED', membership_fee: null },
2_600_000,
false
);
@@ -255,6 +258,7 @@ describe('MarketplaceOrderDomainEntity', () => {
order_hash: 'a'.repeat(64),
on_chain_id: '14',
status: 'ACCEPTED_PENDING_SUPPLIER_INDIVIDUAL',
membership_fee: null,
},
2_400_000,
true
@@ -36,6 +36,8 @@ export class MarketplaceOrderDomainEntity implements IBlockchainSynchronizable {
public readonly quantity: number;
public readonly price_per_unit: string;
public readonly total_cost: string;
/** Членский взнос, включённый в стоимость заказа — on-chain mirror (см. MarketplaceOrderProps). */
public membership_fee: string | null;
public readonly cycle_id: string | null;
/** Грань «заказ заказчика» (Эпик 16): общий id строк одного оформления на один КУ; null = legacy покарточный заказ. */
public readonly checkout_id: string | null;
@@ -92,6 +94,7 @@ export class MarketplaceOrderDomainEntity implements IBlockchainSynchronizable {
this.quantity = props.quantity;
this.price_per_unit = props.price_per_unit;
this.total_cost = props.total_cost;
this.membership_fee = props.membership_fee;
this.cycle_id = props.cycle_id;
this.checkout_id = props.checkout_id;
this.shipment_id = props.shipment_id;
@@ -157,6 +160,9 @@ export class MarketplaceOrderDomainEntity implements IBlockchainSynchronizable {
this.on_chain_id = blockchainData.on_chain_id;
this.on_chain_block_num = blockNum;
this.on_chain_present = present;
// membership_fee — immutable on-chain snapshot (контракт пишет его один
// раз при createorder), безусловный overwrite идемпотентен.
this.membership_fee = blockchainData.membership_fee;
// Forward-only guard. Backend опережает цепь на нескольких переходах
// «прямого пути»: cycle-hook / синтез индивидуальной заявки переводят
// Order в ACCEPTED_PENDING_SUPPLIER(_INDIVIDUAL) и далее ACCEPTED /
@@ -279,4 +285,6 @@ export interface MarketplaceOrderBlockchainData {
order_hash: string;
on_chain_id: string;
status: MarketplaceOrderStatus;
/** Членский взнос из on-chain `order` row (requirement b6), null — старая строка без поля. */
membership_fee: string | null;
}
@@ -104,6 +104,14 @@ export interface MarketplaceOrderProps {
quantity: number;
price_per_unit: string;
total_cost: string;
/**
* Членский взнос, включённый в стоимость заказа (requirement b6). On-chain
* mirror — контракт сам считает его при `createorder` по ставке на момент
* заказа; backend не пересчитывает. Null до первой sync-дельты (см.
* `MarketplaceOrderDomainEntity.updateFromBlockchain`) и у заказов,
* созданных до появления поля на контракте (`binary_extension`).
*/
membership_fee: string | null;
cycle_id: string | null;
/** Грань «заказ заказчика» (Эпик 16): общий id строк одного оформления на один КУ; null = legacy покарточный заказ. */
checkout_id: string | null;
@@ -6,7 +6,10 @@ import {
MARKETPLACE_ORDER_STATUS_CHANGED_EVENT,
type MarketplaceOrderStatusChangedEvent,
} from '../../application/events/marketplace-notification.events';
import { MarketplaceOrderDomainEntity } from '../../domain/entities/marketplace-order.entity';
import {
MarketplaceOrderDomainEntity,
type MarketplaceOrderBlockchainData,
} from '../../domain/entities/marketplace-order.entity';
import type {
MarketplaceOrderCreateInput,
MarketplaceOrderDomainRepository,
@@ -226,7 +229,9 @@ export class MarketplaceOrderRepositoryAdapter implements MarketplaceOrderDomain
* out-of-band on-chain транзакций. По умолчанию — no-op с warn-логом.
*/
async createIfNotExists(
blockchainData: { order_hash: string; on_chain_id: string; status: MarketplaceOrderStatus },
// Типизировано полным MarketplaceOrderBlockchainData (не ad-hoc inline-типом) —
// иначе новое on-chain-поле (как membership_fee) молча не долетает и сюда.
blockchainData: MarketplaceOrderBlockchainData,
blockNum: number,
present = true
): Promise<MarketplaceOrderDomainEntity> {
@@ -236,6 +241,7 @@ export class MarketplaceOrderRepositoryAdapter implements MarketplaceOrderDomain
existing.on_chain_block_num = blockNum;
existing.on_chain_present = present;
existing.status = blockchainData.status;
existing.membership_fee = blockchainData.membership_fee;
return this.persistDomain(existing);
}
// Out-of-band on-chain Order: оставляем минимальный stub-row,
@@ -488,22 +494,41 @@ export class MarketplaceOrderRepositoryAdapter implements MarketplaceOrderDomain
// ── private ──
/**
* Поля, которые может изменить `entity.updateFromBlockchain(...)` (sync-flow)
* или ручная мутация в `createIfNotExists`. Именованный Pick-тип — не просто
* документация: если `updateFromBlockchain` начнёт писать новое on-chain
* поле, а сюда его забудут добавить, объект `patch` ниже не покроет тип и
* TS не даст скомпилироваться. Раньше (до фикса membership_fee)
* `repo.update()` принимал произвольный `QueryDeepPartialEntity` без такой
* проверки — поле молча терялось при sync.
*/
private async persistDomain(
entity: MarketplaceOrderDomainEntity
): Promise<MarketplaceOrderDomainEntity> {
await this.repo.update(
{ id: entity.id },
{
status: entity.status,
last_status_reason: entity.last_status_reason,
accepted_at: entity.accepted_at,
received_at: entity.received_at,
cancelled_at: entity.cancelled_at,
on_chain_id: entity.on_chain_id,
on_chain_block_num: entity.on_chain_block_num,
on_chain_present: entity.on_chain_present,
}
);
const patch: Pick<
MarketplaceOrderEntity,
| 'status'
| 'last_status_reason'
| 'accepted_at'
| 'received_at'
| 'cancelled_at'
| 'on_chain_id'
| 'on_chain_block_num'
| 'on_chain_present'
| 'membership_fee'
> = {
status: entity.status,
last_status_reason: entity.last_status_reason,
accepted_at: entity.accepted_at,
received_at: entity.received_at,
cancelled_at: entity.cancelled_at,
on_chain_id: entity.on_chain_id,
on_chain_block_num: entity.on_chain_block_num,
on_chain_present: entity.on_chain_present,
membership_fee: entity.membership_fee,
};
await this.repo.update({ id: entity.id }, patch);
const row = await this.repo.findOneOrFail({ where: { id: entity.id } });
return this.mapper.toDomain(row);
}
@@ -183,6 +183,15 @@ export class MarketplaceOrderEntity {
@Column({ type: 'boolean', default: false })
public on_chain_present!: boolean;
/**
* Членский взнос, включённый в стоимость заказа (requirement b6). Контракт
* сам считает и пишет его в `order` row при `createorder` — это on-chain
* mirror, не backend-вычисление (см. domain MarketplaceOrderBlockchainData).
* Null до первой sync-дельты и у заказов, созданных до появления поля.
*/
@Column({ type: 'numeric', precision: 24, scale: 4, nullable: true })
public membership_fee!: string | null;
@CreateDateColumn({ type: 'timestamptz' })
public created_at!: Date;
@@ -33,6 +33,10 @@ import {
* содержит — их выставляет только backend (cycle-hook / app-слой), и они
* никогда не приходят дельтой. Поэтому STATUS_MAP содержит ровно on-chain
* значения; `cancelled` обрабатывается отдельно (см. KNOWN_UNMAPPED_STATUSES).
*
* requirement b6: `membership_fee` контракт считает и пишет в `order` row
* САМ (createorder.cpp, по ставке `mkt_config_singleton` на момент создания
* заказа) — backend его не вычисляет, только зеркалит через эту дельту.
*/
@Injectable()
export class MarketplaceOrderDeltaMapper extends AbstractBlockchainDeltaMapper<
@@ -91,6 +95,7 @@ export class MarketplaceOrderDeltaMapper extends AbstractBlockchainDeltaMapper<
order_hash: value.hash,
on_chain_id: value.id.toString(),
status,
membership_fee: MarketplaceOrderDeltaMapper.parseAssetAmount(value.membership_fee),
};
} catch (error: any) {
this.logger.error(
@@ -122,4 +127,16 @@ export class MarketplaceOrderDeltaMapper extends AbstractBlockchainDeltaMapper<
getSupportedTableNames(): string[] {
return ['orders'];
}
/**
* `membership_fee` on-chain — `binary_extension<asset>` вида `"45.0000 RUB"`
* (может отсутствовать у строк, созданных до requirement b6). Храним в БД
* как чистую numeric-строку без символа — тот же формат, что у
* `total_cost`/`price_per_unit` (см. `computeTotalCostAmount`).
*/
private static parseAssetAmount(asset: string | undefined): string | null {
if (!asset) return null;
const amount = asset.split(' ')[0];
return Number.isNaN(Number(amount)) ? null : amount;
}
}
@@ -22,6 +22,7 @@ export class MarketplaceOrderMapper {
quantity: row.quantity,
price_per_unit: row.price_per_unit,
total_cost: row.total_cost,
membership_fee: row.membership_fee ?? null,
cycle_id: row.cycle_id,
checkout_id: row.checkout_id ?? null,
shipment_id: row.shipment_id ?? null,
@@ -24,10 +24,18 @@ const makeResolver = (ownBranames: string[]) => {
const kuChairmanService = {
listBranamesForMember: jest.fn().mockResolvedValue(ownBranames),
} as any;
// Обогащение результата ФИО заказчиков и реквизитами КУ (см. resolver:
// marketplaceListInventory зовёт их сразу после inventoryRepo.list) —
// тесты скоупинга их содержимое не проверяют, только факт вызова list().
const orderDisplay = {
resolveAccountNames: jest.fn().mockResolvedValue(new Map()),
enrichByOrderIds: jest.fn().mockResolvedValue(new Map()),
} as any;
const resolver = new MarketplaceInventoryResolver(
labelService,
inventoryRepo,
kuChairmanService
kuChairmanService,
orderDisplay
);
return { resolver, inventoryRepo, kuChairmanService };
};
@@ -1,12 +1,13 @@
/**
* Unit-тесты MarketplaceMembershipGuard.
*
* Story 1.3 (auth/active/server-secret) + Story 3.1 (isOfferer source — whitelist).
* Story 1.3 (auth/active/server-secret) + isOfferer source — реестр поставщиков
* (MarketplaceSupplierRegistryService, заменил whitelist в PR #152).
*
* Покрывают AC:
* (a) пайщик с user.role='user', status=active, whitelist пуст → guard
* пропускает, marketplace_roles=['orderer','offerer'] (open vitrine);
* (b) пайщик с user.role='user', whitelist непустой и пайщика там нет
* (a) пайщик с user.role='user', status=active, isOfferer=true → guard
* пропускает, marketplace_roles=['orderer','offerer'];
* (b) пайщик с user.role='user', isOfferer=false
* marketplace_roles=['orderer'] (без offerer);
* (c) member и chairman дают расширенный core_roles + marketplace_roles;
* (d) status != active → ForbiddenException (HTTP 403);
@@ -17,7 +18,7 @@ import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { MarketplaceMembershipGuard } from '~/extensions/marketplace/application/guards/marketplace-membership.guard';
import type { MarketplaceKuChairmanService } from '~/extensions/marketplace/application/services/marketplace-ku-chairman.service';
import type { MarketplaceWhitelistService } from '~/extensions/marketplace/application/services/marketplace-whitelist.service';
import type { MarketplaceSupplierRegistryService } from '~/extensions/marketplace/application/services/marketplace-supplier-registry.service';
jest.mock('~/config/config', () => ({
__esModule: true,
@@ -42,10 +43,10 @@ function makeCtx(req: any) {
};
}
function makeWhitelistService(isOffererResult: boolean): MarketplaceWhitelistService {
function makeSupplierRegistryService(isOffererResult: boolean): MarketplaceSupplierRegistryService {
return {
isOfferer: jest.fn().mockResolvedValue(isOffererResult),
} as unknown as MarketplaceWhitelistService;
} as unknown as MarketplaceSupplierRegistryService;
}
function makeKuChairmanService(isKuChairmanResult: boolean): MarketplaceKuChairmanService {
@@ -55,8 +56,8 @@ function makeKuChairmanService(isKuChairmanResult: boolean): MarketplaceKuChairm
}
describe('MarketplaceMembershipGuard', () => {
it('user.role=user, status=active, whitelist пуст → ctx [User] + marketplace_roles [orderer, offerer]', async () => {
const guard = new MarketplaceMembershipGuard(makeWhitelistService(true), makeKuChairmanService(false));
it('user.role=user, status=active, isOfferer=true → ctx [User] + marketplace_roles [orderer, offerer]', async () => {
const guard = new MarketplaceMembershipGuard(makeSupplierRegistryService(true), makeKuChairmanService(false));
const req = { user: { username: 'alice', role: 'user', status: 'active' }, headers: {} };
const ctx = makeCtx(req);
@@ -69,8 +70,8 @@ describe('MarketplaceMembershipGuard', () => {
expect(req).toHaveProperty('currentMember');
});
it('user.role=user, whitelist непустой и пайщика там нет → marketplace_roles [orderer] (без offerer)', async () => {
const guard = new MarketplaceMembershipGuard(makeWhitelistService(false), makeKuChairmanService(false));
it('user.role=user, isOfferer=false → marketplace_roles [orderer] (без offerer)', async () => {
const guard = new MarketplaceMembershipGuard(makeSupplierRegistryService(false), makeKuChairmanService(false));
const req = { user: { username: 'alice', role: 'user', status: 'active' }, headers: {} };
const ctx = makeCtx(req);
@@ -79,7 +80,7 @@ describe('MarketplaceMembershipGuard', () => {
});
it('user.role=member, status=active → core_roles [User, Member] + marketplace_roles [orderer, offerer, board_readonly]', async () => {
const guard = new MarketplaceMembershipGuard(makeWhitelistService(true), makeKuChairmanService(false));
const guard = new MarketplaceMembershipGuard(makeSupplierRegistryService(true), makeKuChairmanService(false));
const req = { user: { username: 'bob', role: 'member', status: 'active' }, headers: {} };
const ctx = makeCtx(req);
await expect(guard.canActivate(ctx as any)).resolves.toBe(true);
@@ -92,7 +93,7 @@ describe('MarketplaceMembershipGuard', () => {
});
it('user.role=chairman, status=active → marketplace_roles полный набор', async () => {
const guard = new MarketplaceMembershipGuard(makeWhitelistService(true), makeKuChairmanService(false));
const guard = new MarketplaceMembershipGuard(makeSupplierRegistryService(true), makeKuChairmanService(false));
const req = { user: { username: 'chair', role: 'chairman', status: 'active' }, headers: {} };
const ctx = makeCtx(req);
await expect(guard.canActivate(ctx as any)).resolves.toBe(true);
@@ -107,7 +108,7 @@ describe('MarketplaceMembershipGuard', () => {
});
it('status != active → 403 Forbidden «Доступ только для пайщиков кооператива»', async () => {
const guard = new MarketplaceMembershipGuard(makeWhitelistService(false), makeKuChairmanService(false));
const guard = new MarketplaceMembershipGuard(makeSupplierRegistryService(false), makeKuChairmanService(false));
const req = { user: { username: 'alice', role: 'user', status: '4_Registered' }, headers: {} };
const ctx = makeCtx(req);
await expect(guard.canActivate(ctx as any)).rejects.toThrow(ForbiddenException);
@@ -117,14 +118,14 @@ describe('MarketplaceMembershipGuard', () => {
});
it('нет user (нет JWT) → 401 Unauthorized', async () => {
const guard = new MarketplaceMembershipGuard(makeWhitelistService(false), makeKuChairmanService(false));
const guard = new MarketplaceMembershipGuard(makeSupplierRegistryService(false), makeKuChairmanService(false));
const req = { headers: {} };
const ctx = makeCtx(req);
await expect(guard.canActivate(ctx as any)).rejects.toThrow(UnauthorizedException);
});
it('server-secret bypass → true, currentMember не выставляется, whitelist/branches не дёргаются', async () => {
const ws = makeWhitelistService(true);
it('server-secret bypass → true, currentMember не выставляется, supplierRegistry/kuChairman не дёргаются', async () => {
const ws = makeSupplierRegistryService(true);
const ku = makeKuChairmanService(false);
const guard = new MarketplaceMembershipGuard(ws, ku);
const req = { headers: { 'server-secret': 'test-secret' } };
@@ -136,7 +137,7 @@ describe('MarketplaceMembershipGuard', () => {
});
it('isKuChairman=true → marketplace_roles содержит operator', async () => {
const guard = new MarketplaceMembershipGuard(makeWhitelistService(false), makeKuChairmanService(true));
const guard = new MarketplaceMembershipGuard(makeSupplierRegistryService(false), makeKuChairmanService(true));
const req = { user: { username: 'chairkrg', role: 'user', status: 'active' }, headers: {} };
const ctx = makeCtx(req);
await expect(guard.canActivate(ctx as any)).resolves.toBe(true);
@@ -43,6 +43,8 @@ function makeOffer(overrides: Partial<MarketplaceOfferDomainEntity> = {}): Marke
warranty_days: 0,
barcode_strategy: 'PER_ORDER',
pack_size: null,
stock_braname: null,
stock_origin_offer_id: null,
images: [],
status: 'PENDING_MODERATION',
approved_by: null,
@@ -43,6 +43,8 @@ function makeOffer(overrides: Partial<MarketplaceOfferDomainEntity> = {}): Marke
warranty_days: 0,
barcode_strategy: 'PER_ORDER',
pack_size: null,
stock_braname: null,
stock_origin_offer_id: null,
images: [],
status: 'ACTIVE',
approved_by: 'chair',
@@ -1,161 +0,0 @@
/**
* Unit-тесты MarketplaceWhitelistService (Story 3.1).
*
* Покрывают AC:
* - listWhitelist возвращает все записи (auto-coop + manual);
* - addToWhitelist создаёт role='manual', проставляет added_by;
* - removeFromWhitelist удаляет manual-запись и инвалидирует кеш;
* - removeFromWhitelist на auto-coop → 403 Forbidden (FR5);
* - removeFromWhitelist на отсутствующего → 404 NotFound;
* - isOfferer семантика «открытая витрина» (whitelist только auto-coop)
* → true для всех;
* - isOfferer семантика «по whitelist» → true только для записанных;
* - isOfferer cache hit без обращения к репозиторию.
*/
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { MarketplaceWhitelistService } from '~/extensions/marketplace/application/services/marketplace-whitelist.service';
import { MarketplaceWhitelistEntryDomainEntity } from '~/extensions/marketplace/domain/entities/marketplace-whitelist-entry.entity';
import type { MarketplaceWhitelistDomainRepository } from '~/extensions/marketplace/domain/repositories/marketplace-whitelist.repository';
const COOP = 'voskhod';
function makeEntry(member: string, role: 'auto-coop' | 'manual'): MarketplaceWhitelistEntryDomainEntity {
return new MarketplaceWhitelistEntryDomainEntity({
id: `id-${member}`,
coopname: COOP,
member_account: member,
role,
added_by: role === 'auto-coop' ? null : 'admin',
added_at: new Date('2026-05-15T12:00:00Z'),
});
}
function makeRepo(): jest.Mocked<MarketplaceWhitelistDomainRepository> {
return {
list: jest.fn(),
findByMember: jest.fn(),
add: jest.fn(),
remove: jest.fn(),
countManual: jest.fn(),
};
}
describe('MarketplaceWhitelistService', () => {
it('list возвращает все записи', async () => {
const repo = makeRepo();
repo.list.mockResolvedValue([makeEntry(COOP, 'auto-coop'), makeEntry('alice', 'manual')]);
const service = new MarketplaceWhitelistService(repo);
const result = await service.list(COOP);
expect(result).toHaveLength(2);
expect(result.map((e) => e.member_account)).toEqual([COOP, 'alice']);
});
it('addToWhitelist создаёт manual-запись с added_by', async () => {
const repo = makeRepo();
repo.add.mockResolvedValue(makeEntry('bob', 'manual'));
const service = new MarketplaceWhitelistService(repo);
const entry = await service.addToWhitelist(COOP, 'bob', 'admin');
expect(entry.member_account).toBe('bob');
expect(entry.role).toBe('manual');
expect(repo.add).toHaveBeenCalledWith(COOP, 'bob', 'manual', 'admin');
});
it('removeFromWhitelist удаляет manual-запись', async () => {
const repo = makeRepo();
repo.findByMember.mockResolvedValue(makeEntry('bob', 'manual'));
const service = new MarketplaceWhitelistService(repo);
await service.removeFromWhitelist(COOP, 'bob');
expect(repo.remove).toHaveBeenCalledWith(COOP, 'bob');
});
it('removeFromWhitelist на auto-coop → 403 (FR5)', async () => {
const repo = makeRepo();
repo.findByMember.mockResolvedValue(makeEntry(COOP, 'auto-coop'));
const service = new MarketplaceWhitelistService(repo);
await expect(service.removeFromWhitelist(COOP, COOP)).rejects.toThrow(ForbiddenException);
expect(repo.remove).not.toHaveBeenCalled();
});
it('removeFromWhitelist на отсутствующего → 404 NotFound', async () => {
const repo = makeRepo();
repo.findByMember.mockResolvedValue(null);
const service = new MarketplaceWhitelistService(repo);
await expect(service.removeFromWhitelist(COOP, 'ghost')).rejects.toThrow(NotFoundException);
});
it('isOfferer: открытая витрина (только auto-coop) → true для всех', async () => {
const repo = makeRepo();
repo.countManual.mockResolvedValue(0);
const service = new MarketplaceWhitelistService(repo);
await expect(service.isOfferer(COOP, 'random_user')).resolves.toBe(true);
expect(repo.findByMember).not.toHaveBeenCalled();
});
it('isOfferer: whitelist непустой, пайщик внутри → true', async () => {
const repo = makeRepo();
repo.countManual.mockResolvedValue(2);
repo.findByMember.mockResolvedValue(makeEntry('alice', 'manual'));
const service = new MarketplaceWhitelistService(repo);
await expect(service.isOfferer(COOP, 'alice')).resolves.toBe(true);
});
it('isOfferer: whitelist непустой, пайщик снаружи → false', async () => {
const repo = makeRepo();
repo.countManual.mockResolvedValue(2);
repo.findByMember.mockResolvedValue(null);
const service = new MarketplaceWhitelistService(repo);
await expect(service.isOfferer(COOP, 'random_user')).resolves.toBe(false);
});
it('isOfferer: второй вызов того же пайщика — cache hit, repo не дёргается', async () => {
const repo = makeRepo();
repo.countManual.mockResolvedValue(0);
const service = new MarketplaceWhitelistService(repo);
await service.isOfferer(COOP, 'alice');
await service.isOfferer(COOP, 'alice');
expect(repo.countManual).toHaveBeenCalledTimes(1);
});
it('addToWhitelist инвалидирует кеш isOfferer', async () => {
const repo = makeRepo();
repo.countManual.mockResolvedValueOnce(0);
repo.add.mockResolvedValue(makeEntry('alice', 'manual'));
repo.countManual.mockResolvedValueOnce(1);
repo.findByMember.mockResolvedValue(null);
const service = new MarketplaceWhitelistService(repo);
// Прогрев кеша: открытая витрина → true
await expect(service.isOfferer(COOP, 'random_user')).resolves.toBe(true);
// Добавление manual-записи (alice) → инвалидирует кеш
await service.addToWhitelist(COOP, 'alice', 'admin');
// После инвалидации семантика «по whitelist» → false для random_user
await expect(service.isOfferer(COOP, 'random_user')).resolves.toBe(false);
});
it('removeFromWhitelist инвалидирует кеш isOfferer', async () => {
const repo = makeRepo();
// Прогрев isOfferer: countManual=1 + findByMember(random_user)=null → false.
// Затем remove('alice'): findByMember(alice)=manual-entry, repo.remove() ok.
// Второй isOfferer: countManual=0 → семантика «открытая витрина» → true,
// findByMember не дёргается (короткое замыкание).
repo.countManual.mockResolvedValueOnce(1).mockResolvedValueOnce(0);
repo.findByMember
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(makeEntry('alice', 'manual'));
const service = new MarketplaceWhitelistService(repo);
await expect(service.isOfferer(COOP, 'random_user')).resolves.toBe(false);
await service.removeFromWhitelist(COOP, 'alice');
await expect(service.isOfferer(COOP, 'random_user')).resolves.toBe(true);
});
});
+23 -21
View File
@@ -4847,13 +4847,13 @@ export const ReturnTypes: Record<string,any> = {
coopname:"String",
created_at:"DateTime",
created_by_operator_account:"String",
cycle_id:"ID",
cycle_id:"String",
expeditor_data:"MarketplaceShipmentTTNData",
fact_quantity_per_order:"MarketplaceAplReceptionFactEntry",
id:"ID",
id:"String",
offerer_account:"String",
offerer_name:"String",
shipment_id:"ID",
shipment_id:"String",
status:"MarketplaceAplReceptionStatus",
supplier_signed_at:"DateTime",
supplier_signsupp_tx_hash:"String",
@@ -4865,7 +4865,7 @@ export const ReturnTypes: Record<string,any> = {
MarketplaceAplReceptionFactEntry:{
fact_quantity:"Int",
fact_unit_price:"String",
order_id:"ID",
order_id:"String",
product_name:"String",
unit_of_measure:"String"
},
@@ -5116,21 +5116,21 @@ export const ReturnTypes: Record<string,any> = {
delivery_point_address:"String",
delivery_point_name:"String",
expiry_date:"DateTime",
id:"ID",
id:"String",
labeled_at:"DateTime",
labeled_by_operator_account:"String",
order_id:"ID",
order_id:"String",
orderer_account_snapshot:"String",
orderer_name:"String",
ownership:"MarketplaceInventoryOwnership",
product_name_snapshot:"String",
published_offer_id:"ID",
published_offer_id:"String",
quantity_per_label:"Int",
received_at:"DateTime",
received_by_operator_account:"String",
reserved_order_id:"ID",
reserved_order_id:"String",
shelf:"String",
shipment_id:"ID",
shipment_id:"String",
status:"MarketplaceInventoryStatus",
unit_of_measure:"String",
updated_at:"DateTime"
@@ -5263,6 +5263,7 @@ export const ReturnTypes: Record<string,any> = {
id:"String",
issuance_fact:"MarketplaceOrderIssuanceFactSnapshot",
last_status_reason:"String",
membership_fee:"String",
offer_hash:"String",
offer_id:"String",
order_hash:"String",
@@ -5280,6 +5281,7 @@ export const ReturnTypes: Record<string,any> = {
supplier_account:"String",
supplier_name:"String",
total_cost:"String",
total_cost_with_fee:"String",
unit_of_measure:"String",
updated_at:"DateTime",
warehouse_quantity:"Int",
@@ -5317,15 +5319,15 @@ export const ReturnTypes: Record<string,any> = {
},
MarketplaceOutgoingPaymentRequest:{
amount:"String",
apl_reception_id:"ID",
apl_reception_id:"String",
completed_at:"DateTime",
coopname:"String",
core_payment_id:"ID",
core_payment_id:"String",
created_at:"DateTime",
decline_reason:"String",
id:"ID",
id:"String",
order_hash:"String",
order_id:"ID",
order_id:"String",
payee_account:"String",
payout_destination:"String",
payout_tx_hash:"String",
@@ -5507,9 +5509,9 @@ export const ReturnTypes: Record<string,any> = {
braname:"String",
coopname:"String",
created_at:"DateTime",
cycle_id:"ID",
cycle_id:"String",
delivery_variant:"MarketplaceShipmentDeliveryVariant",
id:"ID",
id:"String",
offerer_account:"String",
status:"MarketplaceShipmentStatus",
total_amount:"String",
@@ -5519,7 +5521,7 @@ export const ReturnTypes: Record<string,any> = {
updated_at:"DateTime"
},
MarketplaceShipmentLinePackaging:{
order_id:"ID",
order_id:"String",
units_per_box:"Int"
},
MarketplaceShipmentTTNData:{
@@ -5544,7 +5546,7 @@ export const ReturnTypes: Record<string,any> = {
order_lines:"MarketplaceStockAcceptOrderLine"
},
MarketplaceStockIssuanceOperatorLine:{
offer_id:"ID",
offer_id:"String",
order_hash:"String",
product_name:"String",
quantity:"Int",
@@ -5554,8 +5556,8 @@ export const ReturnTypes: Record<string,any> = {
MarketplaceStockProposal:{
braname:"String",
created_at:"DateTime",
created_order_ids:"ID",
id:"ID",
created_order_ids:"String",
id:"String",
items:"MarketplaceStockProposalItem",
member_account:"String",
operator_account:"String",
@@ -5564,7 +5566,7 @@ export const ReturnTypes: Record<string,any> = {
total_cost:"String"
},
MarketplaceStockProposalAcceptResult:{
order_ids:"ID",
order_ids:"String",
proposal:"MarketplaceStockProposal"
},
MarketplaceStockProposalCreatedEvent:{
@@ -5572,7 +5574,7 @@ export const ReturnTypes: Record<string,any> = {
proposal_id:"String"
},
MarketplaceStockProposalItem:{
offer_id:"ID",
offer_id:"String",
product_name:"String",
quantity:"Int",
unit_price:"String"
File diff suppressed because it is too large Load Diff
@@ -708,7 +708,7 @@ export default async function (): Promise<IWorkspaceConfig[]> {
component: markRaw(AdminOffersPage),
meta: {
title: 'Реестр предложений',
icon: 'storefront',
icon: 'sell',
requires: 'Offer:read:all',
requiresAuth: true,
agreements: agreementsBase,
@@ -209,7 +209,7 @@
EntityIdBadge(
v-if='props.row.processHash'
:rawId='shortHash(props.row.processHash)'
@click='copyFullHash(props.row.processHash)'
@click='copyText(props.row.processHash)'
)
</template>
@@ -26,6 +26,7 @@ export default ssrMiddleware(({ app }) => {
VAPID_PUBLIC_KEY: process.env.VAPID_PUBLIC_KEY as string,
SENTRY_DSN: process.env.SENTRY_DSN as string,
OPENREPLAY_PROJECT_KEY: process.env.OPENREPLAY_PROJECT_KEY as string,
YANDEX_MAPS_API_KEY: process.env.YANDEX_MAPS_API_KEY as string,
};
// Создаем скрипт, который добавит переменные в window.__ENV__
-1
View File
@@ -1,5 +1,4 @@
export { default as init } from './init';
export { default as axios } from './axios';
export { default as sentry } from './sentry';
export { default as map } from './map';
export { default as chatwoot } from './chatwoot';
@@ -14,7 +14,7 @@ import { GeocodeStatus, KuDetailsStatus } from 'src/entities/MarketplaceKUDetail
import type { IMarketplaceKUDetails } from 'src/entities/MarketplaceKUDetails'
import { BaseBadge, BaseButton, BaseDialog, EmptyState, TableSkeleton } from 'src/shared/ui/base'
import type { BaseBadgeVariant, TableSkeletonColumn } from 'src/shared/ui/base'
import { PageHint } from 'src/shared/ui/domain'
import { IdentityCell, PageHint } from 'src/shared/ui/domain'
import { useMarketplaceRealtime } from 'src/shared/lib/marketplace'
// Map экспортируется как `Map` — импортируем под алиасом, чтобы не затенять
// глобальный `Map` (используется в `rows`).
@@ -81,6 +81,10 @@ function statusOf(row: IssuancePointRow): { label: string; variant: BaseBadgeVar
return STATUS_LABEL[row.details.status]
}
function branchName(row: IssuancePointRow): string {
return row.branch.short_name || row.branch.full_name || ''
}
function addressOf(row: IssuancePointRow): string {
return (
row.details?.addressFull ||
@@ -213,8 +217,10 @@ q-page.admin-pvz
tbody
tr(v-for='row in rows', :key='row.branch.braname')
td.col-ku
.admin-pvz__ku-name {{ row.branch.short_name || row.branch.full_name || row.branch.braname }}
.admin-pvz__ku-acc {{ row.branch.braname }}
IdentityCell(
:account-name='row.branch.braname',
:full-name='branchName(row)'
)
td.col-city {{ row.branch.city || '—' }}
td.admin-pvz__address {{ addressOf(row) }}
td.col-status
@@ -334,16 +340,6 @@ q-page.admin-pvz
font-size: 0.875rem;
}
&__ku-name {
font-weight: 600;
}
&__ku-acc {
font-family: var(--font-mono);
font-size: 0.8125rem;
color: var(--p-ink-3);
}
&__address {
color: var(--p-ink-2);
}
@@ -90,8 +90,11 @@ function shortId(id: string | null | undefined): string {
function unitLabel(o: AdminOrderView): string {
return marketplaceUnitShort(o.unit_of_measure);
}
function formatTotal(v: string | null | undefined): string {
return v ? formatAsset2Digits(String(v)) : '—';
// requirement b6: реестр показывает сумму, которую реально заплатил пайщик
// (себестоимость + членский взнос, зафиксированный в заказе контрактом) —
// не голую себестоимость товара.
function formatTotalWithFee(o: AdminOrderView): string {
return formatAsset2Digits(o.total_cost_with_fee);
}
function formatDate(d: unknown): string {
if (d === null || d === undefined) return '—';
@@ -255,7 +258,7 @@ q-page.admin-orders(role="region", aria-label="Реестр заказов ко
q-td {{ ordererTitle(props.row) }}
q-td {{ supplierTitle(props.row) }}
q-td.text-right {{ props.row.quantity }} {{ unitLabel(props.row) }}
q-td.text-right.font-monospace {{ formatTotal(props.row.total_cost) }}
q-td.text-right.font-monospace {{ formatTotalWithFee(props.row) }}
q-td {{ formatDate(props.row.created_at) }}
q-tr.q-virtual-scroll--with-prev(
@@ -11,7 +11,7 @@ import {
import { BaseCard, BaseButton, BaseChip, EmptyState } from 'src/shared/ui/base';
import { KUHeaderBar } from 'src/widgets/Marketplace/KUHeaderBar';
import { marketplaceUnitShort } from 'src/shared/lib/consts';
import { useMarketplaceRealtime, getMembershipFeePercent } from 'src/shared/lib/marketplace';
import { useMarketplaceRealtime, getMembershipFeePercent, applyMembershipFee } from 'src/shared/lib/marketplace';
/**
* Эпик 16 / Story 16.1 + 16.2: страница корзины заказчика и оформление.
@@ -35,12 +35,12 @@ const coopname = computed(() => String(route.params.coopname ?? ''));
const symbol = computed(() => system.governSymbol);
function unitShort(u: string): string {
return marketplaceUnitShort(u as Parameters<typeof marketplaceUnitShort>[0]);
function unitShort(u: string | null | undefined): string {
return marketplaceUnitShort((u ?? '') as Parameters<typeof marketplaceUnitShort>[0]);
}
function money(value: string | number): string {
return Number(value).toLocaleString('ru-RU');
function money(value: string | number | null | undefined): string {
return Number(value ?? 0).toLocaleString('ru-RU');
}
// Низкоуровневый коммит количества (целое ≥ 1). Кламп делает changeQty.
@@ -156,8 +156,8 @@ useMarketplaceRealtime(
// requirement b6: членский взнос входит в общую стоимость заказа — сводка
// показывает его отдельной строкой и итог с учётом взноса.
const feePercent = ref(0);
const feeAmount = computed(() => (cartStore.totalCost * feePercent.value) / 100);
const totalWithFee = computed(() => cartStore.totalCost + feeAmount.value);
const totalWithFee = computed(() => applyMembershipFee(Number(cartStore.totalCost), feePercent.value));
const feeAmount = computed(() => totalWithFee.value - Number(cartStore.totalCost));
onMounted(async () => {
try {
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { useSystemStore } from 'src/entities/System/model';
import { useMarketplaceCartStore } from 'src/entities/MarketplaceCart';
import { applyMembershipFee } from 'src/shared/lib/marketplace';
import { BaseDialog, BaseInput, BaseButton } from 'src/shared/ui/base';
import type { MarketplaceOfferView } from '../types';
@@ -29,10 +30,16 @@ const cartStore = useMarketplaceCartStore();
const router = useRouter();
const route = useRoute();
const props = defineProps<{
modelValue: boolean;
offer: CartOffer | null;
}>();
const props = withDefaults(
defineProps<{
modelValue: boolean;
offer: CartOffer | null;
// Единая ставка членского взноса кооператива, проценты (requirement b6).
// Цена и итог показываются заказчику с учётом взноса — как в каталоге.
feePercent?: number;
}>(),
{ feePercent: 0 },
);
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
@@ -64,10 +71,14 @@ const maxQuantity = computed(() => {
return props.offer.quantity_available;
});
const totalSum = computed(() => {
const priceWithFee = computed(() => {
if (!props.offer) return 0;
return applyMembershipFee(Number(props.offer.price_per_unit), props.feePercent);
});
const totalSum = computed(() => {
const q = Number(quantity.value) || 0;
return q * Number(props.offer.price_per_unit);
return q * priceWithFee.value;
});
// Сколько этой позиции уже в корзине — подсказка, чтобы заказчик не дублировал.
@@ -148,7 +159,9 @@ BaseDialog(
.add-to-cart__note(v-if="alreadyInCart > 0")
| Уже в корзине: {{ alreadyInCart }} {{ unitLabel }} добавление суммируется.
.add-to-cart__price(v-if="offer")
| Цена: {{ Number(offer.price_per_unit).toLocaleString('ru-RU') }} {{ system.governSymbol }} за {{ unitLabel }}
| Цена: {{ priceWithFee.toLocaleString('ru-RU') }} {{ system.governSymbol }} за {{ unitLabel }}
template(v-if="feePercent > 0")
| (с членским взносом {{ feePercent }}%)
.add-to-cart__total(v-if="offer")
| Итого: {{ totalSum.toLocaleString('ru-RU') }} {{ system.governSymbol }}
template(#footer)
@@ -410,7 +410,8 @@ q-page.catalog(role="region", aria-label="Каталог Стола заказо
AddToCartDialog(
v-model="cartDialogOpen",
:offer="cartDialogOffer"
:offer="cartDialogOffer",
:fee-percent="feePercent"
)
</template>
@@ -12,5 +12,5 @@ export async function fetchOffer(id: string): Promise<MarketplaceOfferDetailView
Queries.Marketplace.GetOffer.query,
{ variables: { id } },
);
return offer;
return offer as MarketplaceOfferDetailView | null;
}
@@ -9,7 +9,7 @@ import { OfferGallery } from 'src/widgets/Marketplace/OfferGallery';
import { CartHeaderButton } from 'src/widgets/Marketplace/CartHeaderButton';
import { marketplaceUnitShort } from 'src/shared/lib/consts';
import { marketplaceOfferImageUrls } from 'src/shared/lib/utils';
import { useMarketplaceRealtime } from 'src/shared/lib/marketplace';
import { useMarketplaceRealtime, getMembershipFeePercent, applyMembershipFee } from 'src/shared/lib/marketplace';
import { useMarketplaceCartStore } from 'src/entities/MarketplaceCart';
import { useOfferModeration } from 'src/features/Marketplace/OfferModeration';
import { fetchCategories } from '../../MarketplaceCatalog/api';
@@ -105,9 +105,16 @@ const stockLabel = computed(() => {
: `В наличии: ${offer.value.quantity_available} ${unitShort.value}`;
});
// requirement b6: единая ставка членского взноса входит в цену, которую видит
// заказчик — как в карточке каталога (CatalogOfferCard). Раздельная
// себестоимость без взноса остаётся только на столе поставщика.
const feePercent = ref(0);
const priceWithFee = computed(() =>
offer.value ? applyMembershipFee(Number(offer.value.price_per_unit), feePercent.value) : 0,
);
const priceLabel = computed(() =>
offer.value
? `${Number(offer.value.price_per_unit).toLocaleString('ru-RU')} ${system.governSymbol} / ${unitShort.value}`
? `${priceWithFee.value.toLocaleString('ru-RU')} ${system.governSymbol} / ${unitShort.value}`
: '',
);
@@ -157,6 +164,11 @@ useMarketplaceRealtime(
);
onMounted(async () => {
try {
feePercent.value = await getMembershipFeePercent();
} catch {
// Без ставки показываем цену без строки взноса.
}
await load();
// В режиме заказа нужен текущий КУ (для кнопки «В корзину» и диалога).
if (!readonly.value) {
@@ -209,6 +221,7 @@ q-page.offer-detail(role="region", aria-label="Описание предложе
span {{ offer.supplier_name }}
.offer-detail__price {{ priceLabel }}
.offer-detail__fee-note(v-if="feePercent > 0") Цена с членским взносом {{ feePercent }}%
BaseButton(
v-if="!readonly",
@@ -257,7 +270,8 @@ q-page.offer-detail(role="region", aria-label="Описание предложе
AddToCartDialog(
v-if="!readonly",
v-model="cartDialogOpen",
:offer="offer"
:offer="offer",
:fee-percent="feePercent"
)
</template>
@@ -333,6 +347,12 @@ q-page.offer-detail(role="region", aria-label="Описание предложе
color: var(--p-primary-strong);
}
&__fee-note {
font-size: var(--p-fs-body-sm);
color: var(--p-ink-3);
margin-top: calc(var(--p-1, 4px) * -1);
}
&__sections {
display: flex;
flex-direction: column;
@@ -143,13 +143,13 @@ function groupByCheckout(list: MarketplaceOrderView[]): OrderGroup[] {
}
const result: OrderGroup[] = Array.from(map.entries()).map(([key, orders]) => {
const first = orders[0];
const recent = Math.max(...orders.map((o) => new Date(o.updated_at).getTime()));
const recent = Math.max(...orders.map((o) => new Date(String(o.updated_at)).getTime()));
return {
key,
isAggregate: orders.length > 1,
orders,
count: orders.length,
totalCost: orders.reduce((s, o) => s + Number(o.total_cost), 0),
totalCost: orders.reduce((s, o) => s + Number(o.total_cost_with_fee), 0),
deliveryName: first?.delivery_point_name || first?.delivery_braname || '',
createdAt: first?.created_at ?? '',
recent,
@@ -250,7 +250,7 @@ async function onLoadMore(): Promise<void> {
function confirmCancel(order: MarketplaceOrderView): void {
Dialog.create({
title: 'Отменить заказ?',
message: `Заказ № ${order.id.slice(0, 8)} (${order.quantity} ед., ${order.total_cost} ₽) будет отменён. Средства разблокируются на кошельке Стола заказов.`,
message: `Заказ № ${order.id.slice(0, 8)} (${order.quantity} ед., ${money(order.total_cost_with_fee)} ₽) будет отменён. Средства разблокируются на кошельке Стола заказов.`,
cancel: { label: 'Не отменять', flat: true },
ok: { label: 'Отменить заказ', color: 'negative', unelevated: true },
persistent: true,
@@ -122,6 +122,8 @@ interface SupplierParty {
orders: MarketplaceOrderView[];
totalUnits: number;
totalCost: number;
/** Сумма членских взносов пайщиков по заказам партии (requirement b6) — справочно, не деньги поставщика. */
totalFee: number;
/** Целевой минимальный объём поставки на этот КУ (для накопителя). */
minVolume: number | null;
stageStatus: MarketplaceOrderStatusView;
@@ -150,6 +152,7 @@ const parties = computed<SupplierParty[]>(() => {
orders: [],
totalUnits: 0,
totalCost: 0,
totalFee: 0,
minVolume: collecting
? (minVolumeMap.value.get(`${o.offer_id}::${o.delivery_braname}`) ?? null)
: null,
@@ -160,6 +163,7 @@ const parties = computed<SupplierParty[]>(() => {
p.orders.push(o);
p.totalUnits += o.quantity;
p.totalCost += parseFloat(o.total_cost) || 0;
p.totalFee += Number(o.membership_fee ?? 0) || 0;
const candidate = STAGE_RANK[o.status];
const current = STAGE_RANK[p.stageStatus];
if (candidate < 90 && (current >= 90 || candidate < current)) {
@@ -340,7 +344,8 @@ q-page.incoming-orders(role='region', aria-label='Входящие заказы
:bar-color='barColor(p)',
:members='[]',
total-label='Итого',
:total-value='`${formatCost(p.totalCost)} · ${p.totalUnits} ${p.unitLabel}`'
:total-value='`${formatCost(p.totalCost)} · ${p.totalUnits} ${p.unitLabel}`',
:total-fee-note='p.totalFee > 0 ? `С учётом взноса пайщиков: ${formatCost(p.totalCost + p.totalFee)}` : undefined'
)
template(#actions)
template(v-if='p.kind === "collecting"')
@@ -64,7 +64,7 @@ export function buildTtnData(
const boxes = unitsPerBox && unitsPerBox > 0 ? Math.ceil(o.quantity / unitsPerBox) : undefined;
return {
sku: o.offer_id ? String(o.offer_id).slice(0, 8) : '—',
title: o.product_name,
title: o.product_name || 'Товар по предложению',
qty: o.quantity,
unit: marketplaceUnitShort(o.unit_of_measure),
price: parseFloat(o.price_per_unit) || 0,
@@ -230,8 +230,8 @@ q-page.mp-role-orderer.mp-member-cpp(role="region", aria-label="Подключе
.mp-member-cpp__head-icon
q-icon(name="location_on", size="22px")
.mp-member-cpp__head-text
.text-subtitle1.text-weight-medium Выберите пункт выдачи
.text-body2.text-grey-7 Где будете забирать заказы. Каталог отфильтруется под выбранный участок.
.text-subtitle1.text-weight-medium Выберите пункт выдачи заказов
.text-body2.text-grey-7 Выбирайте участок, где вам удобно забирать заказы, сменить его можно в любой момент.
BaseChip.mp-member-cpp__picked(v-if="selectedName", variant="pos", size="sm")
q-icon(name="check", size="14px")
| {{ selectedName }}
@@ -170,7 +170,7 @@ const planSaving = ref(false)
function planDueLabel(plan: ExpensePlanView): string {
if (plan.priority === Zeus.ExpensePlanPriority.URGENT) return 'Срочный'
if (plan.priority === Zeus.ExpensePlanPriority.OPTIONAL) return 'Необязательный'
return plan.due_date ? new Date(plan.due_date).toLocaleDateString('ru-RU') : '—'
return plan.due_date ? new Date(String(plan.due_date)).toLocaleDateString('ru-RU') : '—'
}
async function onAddPlan(): Promise<void> {
@@ -258,7 +258,7 @@ function aggregateLines(orders: MarketplaceSupplierPickupOrderView[]): DeliveryL
map.set(key, {
key,
productName: o.product_name || 'Товар по предложению',
unit: o.unit_of_measure,
unit: o.unit_of_measure ?? '',
quantity: qty,
boxes,
});
@@ -382,7 +382,7 @@ function groupOrdersByProduct(orders: MarketplaceSupplierPickupOrderView[]): Pic
map.set(key, {
key,
productName: o.product_name || 'Товар по предложению',
unit: o.unit_of_measure,
unit: o.unit_of_measure ?? '',
orderedTotal: qty,
orders: [o],
});
@@ -88,6 +88,7 @@ interface CollectiveParty {
orders: MarketplaceOrderView[];
/** Свой вклад (сумма собственных заказов в этой партии). */
ownUnits: number;
/** С учётом членского взноса (requirement b6) — то, что заказчик реально платит. */
ownCost: number;
/** Накоплено всеми пайщиками (с бэкенда): пул сбора или объём партии. */
groupAccumulated: number | null;
@@ -127,7 +128,7 @@ const parties = computed<CollectiveParty[]>(() => {
}
p.orders.push(o);
p.ownUnits += o.quantity;
p.ownCost += parseFloat(o.total_cost) || 0;
p.ownCost += Number(o.total_cost_with_fee);
const candidate = STAGE_RANK[o.status];
const current = STAGE_RANK[p.stageStatus];
if (candidate < 90 && (current >= 90 || candidate < current)) {
@@ -46,6 +46,14 @@ const status = computed(() => (order.value ? orderStatusDisplay(order.value.stat
const unitShort = computed(() => marketplaceUnitShort(order.value?.unit_of_measure));
const cancellable = computed(() => order.value?.status === 'ACTIVE');
// requirement b6: заказчику показываем цену С членским взносом (как в
// каталоге) order.total_cost_with_fee уже посчитан бэком (total_cost +
// membership_fee, зафиксированный контрактом по ставке на момент заказа).
const totalWithFee = computed(() => (order.value ? Number(order.value.total_cost_with_fee) : 0));
const unitPriceWithFee = computed(() =>
order.value && order.value.quantity > 0 ? totalWithFee.value / order.value.quantity : 0,
);
const pvzName = computed(() => order.value?.delivery_point_name || order.value?.delivery_braname || '');
const pvzAddress = computed(() => order.value?.delivery_point_address || '');
@@ -129,7 +137,7 @@ function confirmCancel(): void {
if (!o) return;
Dialog.create({
title: 'Отменить заказ?',
message: `Заказ № ${o.id.slice(0, 8)} (${o.quantity} ед., ${o.total_cost}) будет отменён. Средства разблокируются на кошельке Стола заказов.`,
message: `Заказ № ${o.id.slice(0, 8)} (${o.quantity} ед., ${formatPrice(o.total_cost_with_fee)}) будет отменён. Средства разблокируются на кошельке Стола заказов.`,
cancel: { label: 'Не отменять', flat: true },
ok: { label: 'Отменить заказ', color: 'negative', unelevated: true },
persistent: true,
@@ -199,13 +207,13 @@ q-page.order-detail(role="region", aria-label="Заказ")
.order-detail__facts
.order-detail__fact
.order-detail__fact-label Сумма заказа
.order-detail__fact-value--money {{ formatPrice(order.total_cost) }}
.order-detail__fact-value--money {{ formatPrice(String(totalWithFee)) }}
.order-detail__fact
.order-detail__fact-label Количество
.order-detail__fact-value {{ order.quantity }} {{ unitShort }}
.order-detail__fact
.order-detail__fact-label Цена за единицу
.order-detail__fact-value {{ formatPrice(order.price_per_unit) }}
.order-detail__fact-value {{ formatPrice(String(unitPriceWithFee)) }}
BaseCard.order-detail__card(v-if="pvzName || pvzAddress")
template(#head)
@@ -1,5 +1,6 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { Zeus } from '@coopenomics/sdk';
import { FailAlert, SuccessAlert } from 'src/shared/api';
import { useSessionStore } from 'src/entities/Session/model';
import { BaseBadge, BaseButton, BaseInput, BaseDialog, EmptyState } from 'src/shared/ui/base';
@@ -124,7 +125,7 @@ async function onAdd(): Promise<void> {
try {
await addSupplier({
member_account: addMember.value.trim(),
model: 'MEMBERSHIP',
model: Zeus.MarketplaceSupplierModel.MEMBERSHIP,
contract_number: addNumber.value.trim(),
contract_date: addDate.value,
});
@@ -15,7 +15,7 @@ div
:value='program.key',
:title='program.title',
:description='program.description',
:meta='program.requirements',
:meta='program.requirements ?? undefined',
@update:model-value='selectProgram(program.key)'
)
@@ -119,6 +119,7 @@ function getEnv(): EnvVars {
VAPID_PUBLIC_KEY: '',
SENTRY_DSN: '',
OPENREPLAY_PROJECT_KEY: '',
YANDEX_MAPS_API_KEY: '',
};
}
@@ -27,6 +27,7 @@ export function createEnvObject(): EnvVars {
VAPID_PUBLIC_KEY: process.env.VAPID_PUBLIC_KEY as string,
SENTRY_DSN: process.env.SENTRY_DSN as string,
OPENREPLAY_PROJECT_KEY: process.env.OPENREPLAY_PROJECT_KEY as string,
YANDEX_MAPS_API_KEY: process.env.YANDEX_MAPS_API_KEY as string,
};
return envObj;
@@ -20,6 +20,7 @@ export {
export { useActsPreview } from './useActsPreview';
export {
getMembershipFeePercent,
applyMembershipFee,
computeIssuanceDiff,
type IssuanceDiffLine,
} from './membership-fee';
@@ -13,6 +13,14 @@ export async function getMembershipFeePercent(): Promise<number> {
return result.membership_fee_percent;
}
/**
* Цена с учётом членского взноса единая формула для всех экранов заказчика
* (каталог, деталь предложения, добавление в корзину, сводка корзины).
*/
export function applyMembershipFee(price: number, feePercent: number): number {
return price * (1 + feePercent / 100);
}
export interface IssuanceDiffLine {
/** Сумма позиции по заказу (заблокированный резерв без взноса). */
orderedTotal: number;
@@ -41,6 +49,5 @@ export function computeIssuanceDiff(
if (diff > 0) refund += diff;
else if (diff < 0) surcharge += -diff;
}
const withFee = 1 + feePercent / 100;
return { refund: refund * withFee, surcharge: surcharge * withFee };
return { refund: applyMembershipFee(refund, feePercent), surcharge: applyMembershipFee(surcharge, feePercent) };
}
@@ -29,7 +29,7 @@ export interface GroupableReception {
fact_quantity_per_order: ReadonlyArray<{
product_name?: string | null;
fact_quantity: number | string;
unit_of_measure: string;
unit_of_measure?: string | null;
fact_unit_price?: string | null;
}>;
}
@@ -115,7 +115,7 @@ export function groupAplReceptions<T extends GroupableReception>(
lineMap.set(lk, {
key: lk,
productName: f.product_name || 'Товар по предложению',
unit: f.unit_of_measure,
unit: f.unit_of_measure ?? '',
quantity: qty,
amount: qty * price,
});
@@ -75,7 +75,7 @@ const generateQRCode = () => {
if (qrElement.value && props.paymentOrder.payment_details?.data) {
QRCode.toCanvas(
qrElement.value,
props.paymentOrder.payment_details.data,
props.paymentOrder.payment_details.data as string,
{ width: 400 },
function (error) {
if (error) console.error(error);
@@ -61,11 +61,13 @@ const props = withDefaults(defineProps<BaseInputProps>(), {
autogrow: false,
});
// Quasar QInput форвардит нативные blur/focus как обычный Event, не строго
// FocusEvent типизация здесь под то, что реально приходит из q-input.
const emit = defineEmits<{
'update:modelValue': [value: string];
clear: [];
blur: [event: FocusEvent];
focus: [event: FocusEvent];
blur: [event: Event];
focus: [event: Event];
}>();
const autoId = useId();
@@ -70,6 +70,7 @@
<script setup lang="ts">
import { computed, type PropType } from 'vue'
import { OfferGallery } from 'src/widgets/Marketplace/OfferGallery'
import { applyMembershipFee } from 'src/shared/lib/marketplace'
export type CatalogOfferStatus = 'draft' | 'published' | 'paused' | 'sold-out' | 'completed' | 'moderation' | 'withdrawn'
@@ -165,7 +166,7 @@ const unitCostWithFee = computed<number | string>(() => {
if (!hasFee.value) return base
const n = typeof base === 'number' ? base : Number(base)
if (Number.isNaN(n)) return base
return n * (1 + props.feePercent / 100)
return applyMembershipFee(n, props.feePercent)
})
const feeLabel = computed(() => {
@@ -7,7 +7,7 @@ import {
signReceptionGroupAsSupplier,
type MarketplaceAplReceptionView,
} from 'src/pages/Marketplace/OffererPendingAplReceptions/api';
import { Classes } from '@coopenomics/sdk';
import { Classes, Zeus } from '@coopenomics/sdk';
import {
listStockProposals,
finalizeStockIssuance,
@@ -96,7 +96,7 @@ async function refresh(source = 'ручной'): Promise<void> {
try {
const [receptions, proposals] = await Promise.all([
listAplReceptionsAsSupplier().catch(() => [] as MarketplaceAplReceptionView[]),
listStockProposals({ statuses: ['PROPOSED'] }).catch(
listStockProposals({ statuses: [Zeus.MarketplaceStockProposalStatus.PROPOSED] }).catch(
() => [] as MarketplaceStockProposalView[],
),
]);
@@ -203,15 +203,21 @@ async function signProposal(task: MarketplaceStockProposalView): Promise<void> {
// строке — документ НЕ перегенерируется, берётся агрегат rawDocument +
// document с подписью оператора (канон 2-подписи).
const order_lines: IStockFinalizeOrderLine[] = await Promise.all(
payload.order_lines.map(async (line) => ({
order_hash: line.order_hash,
signed_signiss2_act: (await signer.signDocument(
line.signiss1_aggregate.rawDocument,
global.username,
2,
[line.signiss1_aggregate.document],
)) as IStockFinalizeOrderLine['signed_signiss2_act'],
})),
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(
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'
@@ -54,6 +54,8 @@ export const rawOrderSelector = {
group_min_volume: true,
price_per_unit: true,
total_cost: true,
membership_fee: true,
total_cost_with_fee: true,
cycle_id: true,
checkout_id: true,
shipment_id: true,
+23 -21
View File
@@ -4847,13 +4847,13 @@ export const ReturnTypes: Record<string,any> = {
coopname:"String",
created_at:"DateTime",
created_by_operator_account:"String",
cycle_id:"ID",
cycle_id:"String",
expeditor_data:"MarketplaceShipmentTTNData",
fact_quantity_per_order:"MarketplaceAplReceptionFactEntry",
id:"ID",
id:"String",
offerer_account:"String",
offerer_name:"String",
shipment_id:"ID",
shipment_id:"String",
status:"MarketplaceAplReceptionStatus",
supplier_signed_at:"DateTime",
supplier_signsupp_tx_hash:"String",
@@ -4865,7 +4865,7 @@ export const ReturnTypes: Record<string,any> = {
MarketplaceAplReceptionFactEntry:{
fact_quantity:"Int",
fact_unit_price:"String",
order_id:"ID",
order_id:"String",
product_name:"String",
unit_of_measure:"String"
},
@@ -5116,21 +5116,21 @@ export const ReturnTypes: Record<string,any> = {
delivery_point_address:"String",
delivery_point_name:"String",
expiry_date:"DateTime",
id:"ID",
id:"String",
labeled_at:"DateTime",
labeled_by_operator_account:"String",
order_id:"ID",
order_id:"String",
orderer_account_snapshot:"String",
orderer_name:"String",
ownership:"MarketplaceInventoryOwnership",
product_name_snapshot:"String",
published_offer_id:"ID",
published_offer_id:"String",
quantity_per_label:"Int",
received_at:"DateTime",
received_by_operator_account:"String",
reserved_order_id:"ID",
reserved_order_id:"String",
shelf:"String",
shipment_id:"ID",
shipment_id:"String",
status:"MarketplaceInventoryStatus",
unit_of_measure:"String",
updated_at:"DateTime"
@@ -5263,6 +5263,7 @@ export const ReturnTypes: Record<string,any> = {
id:"String",
issuance_fact:"MarketplaceOrderIssuanceFactSnapshot",
last_status_reason:"String",
membership_fee:"String",
offer_hash:"String",
offer_id:"String",
order_hash:"String",
@@ -5280,6 +5281,7 @@ export const ReturnTypes: Record<string,any> = {
supplier_account:"String",
supplier_name:"String",
total_cost:"String",
total_cost_with_fee:"String",
unit_of_measure:"String",
updated_at:"DateTime",
warehouse_quantity:"Int",
@@ -5317,15 +5319,15 @@ export const ReturnTypes: Record<string,any> = {
},
MarketplaceOutgoingPaymentRequest:{
amount:"String",
apl_reception_id:"ID",
apl_reception_id:"String",
completed_at:"DateTime",
coopname:"String",
core_payment_id:"ID",
core_payment_id:"String",
created_at:"DateTime",
decline_reason:"String",
id:"ID",
id:"String",
order_hash:"String",
order_id:"ID",
order_id:"String",
payee_account:"String",
payout_destination:"String",
payout_tx_hash:"String",
@@ -5507,9 +5509,9 @@ export const ReturnTypes: Record<string,any> = {
braname:"String",
coopname:"String",
created_at:"DateTime",
cycle_id:"ID",
cycle_id:"String",
delivery_variant:"MarketplaceShipmentDeliveryVariant",
id:"ID",
id:"String",
offerer_account:"String",
status:"MarketplaceShipmentStatus",
total_amount:"String",
@@ -5519,7 +5521,7 @@ export const ReturnTypes: Record<string,any> = {
updated_at:"DateTime"
},
MarketplaceShipmentLinePackaging:{
order_id:"ID",
order_id:"String",
units_per_box:"Int"
},
MarketplaceShipmentTTNData:{
@@ -5544,7 +5546,7 @@ export const ReturnTypes: Record<string,any> = {
order_lines:"MarketplaceStockAcceptOrderLine"
},
MarketplaceStockIssuanceOperatorLine:{
offer_id:"ID",
offer_id:"String",
order_hash:"String",
product_name:"String",
quantity:"Int",
@@ -5554,8 +5556,8 @@ export const ReturnTypes: Record<string,any> = {
MarketplaceStockProposal:{
braname:"String",
created_at:"DateTime",
created_order_ids:"ID",
id:"ID",
created_order_ids:"String",
id:"String",
items:"MarketplaceStockProposalItem",
member_account:"String",
operator_account:"String",
@@ -5564,7 +5566,7 @@ export const ReturnTypes: Record<string,any> = {
total_cost:"String"
},
MarketplaceStockProposalAcceptResult:{
order_ids:"ID",
order_ids:"String",
proposal:"MarketplaceStockProposal"
},
MarketplaceStockProposalCreatedEvent:{
@@ -5572,7 +5574,7 @@ export const ReturnTypes: Record<string,any> = {
proposal_id:"String"
},
MarketplaceStockProposalItem:{
offer_id:"ID",
offer_id:"String",
product_name:"String",
quantity:"Int",
unit_price:"String"
File diff suppressed because it is too large Load Diff