Merge branch 'marketplace2' of ssh://git.coopenomics.world:222/C9S/mono into marketplace2
This commit is contained in:
+9
-9
@@ -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;
|
||||
|
||||
+13
-13
@@ -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;
|
||||
|
||||
+29
-1
@@ -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,
|
||||
|
||||
+5
-5
@@ -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:
|
||||
'Идентификатор связанного платежа в общем реестре кооператива — кассир видит ' +
|
||||
|
||||
+9
-9
@@ -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;
|
||||
|
||||
+13
-13
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
+9
@@ -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(),
|
||||
|
||||
+325
@@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+26
-1
@@ -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
|
||||
);
|
||||
|
||||
+10
-6
@@ -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
|
||||
|
||||
+8
@@ -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;
|
||||
}
|
||||
|
||||
+8
@@ -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;
|
||||
|
||||
+40
-15
@@ -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);
|
||||
}
|
||||
|
||||
+9
@@ -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;
|
||||
|
||||
|
||||
+17
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+9
-1
@@ -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,
|
||||
|
||||
+2
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
+158
-142
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,5 +1,4 @@
|
||||
export { default as init } from './init';
|
||||
export { default as axios } from './axios';
|
||||
export { default as sentry } from './sentry';
|
||||
export { default as map } from './map';
|
||||
export { default as chatwoot } from './chatwoot';
|
||||
|
||||
+9
-13
@@ -14,7 +14,7 @@ import { GeocodeStatus, KuDetailsStatus } from 'src/entities/MarketplaceKUDetail
|
||||
import type { IMarketplaceKUDetails } from 'src/entities/MarketplaceKUDetails'
|
||||
import { BaseBadge, BaseButton, BaseDialog, EmptyState, TableSkeleton } from 'src/shared/ui/base'
|
||||
import type { BaseBadgeVariant, TableSkeletonColumn } from 'src/shared/ui/base'
|
||||
import { PageHint } from 'src/shared/ui/domain'
|
||||
import { IdentityCell, PageHint } from 'src/shared/ui/domain'
|
||||
import { useMarketplaceRealtime } from 'src/shared/lib/marketplace'
|
||||
// Map экспортируется как `Map` — импортируем под алиасом, чтобы не затенять
|
||||
// глобальный `Map` (используется в `rows`).
|
||||
@@ -81,6 +81,10 @@ function statusOf(row: IssuancePointRow): { label: string; variant: BaseBadgeVar
|
||||
return STATUS_LABEL[row.details.status]
|
||||
}
|
||||
|
||||
function branchName(row: IssuancePointRow): string {
|
||||
return row.branch.short_name || row.branch.full_name || ''
|
||||
}
|
||||
|
||||
function addressOf(row: IssuancePointRow): string {
|
||||
return (
|
||||
row.details?.addressFull ||
|
||||
@@ -213,8 +217,10 @@ q-page.admin-pvz
|
||||
tbody
|
||||
tr(v-for='row in rows', :key='row.branch.braname')
|
||||
td.col-ku
|
||||
.admin-pvz__ku-name {{ row.branch.short_name || row.branch.full_name || row.branch.braname }}
|
||||
.admin-pvz__ku-acc {{ row.branch.braname }}
|
||||
IdentityCell(
|
||||
:account-name='row.branch.braname',
|
||||
:full-name='branchName(row)'
|
||||
)
|
||||
td.col-city {{ row.branch.city || '—' }}
|
||||
td.admin-pvz__address {{ addressOf(row) }}
|
||||
td.col-status
|
||||
@@ -334,16 +340,6 @@ q-page.admin-pvz
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
&__ku-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__ku-acc {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--p-ink-3);
|
||||
}
|
||||
|
||||
&__address {
|
||||
color: var(--p-ink-2);
|
||||
}
|
||||
|
||||
@@ -90,8 +90,11 @@ function shortId(id: string | null | undefined): string {
|
||||
function unitLabel(o: AdminOrderView): string {
|
||||
return marketplaceUnitShort(o.unit_of_measure);
|
||||
}
|
||||
function formatTotal(v: string | null | undefined): string {
|
||||
return v ? formatAsset2Digits(String(v)) : '—';
|
||||
// requirement b6: реестр показывает сумму, которую реально заплатил пайщик
|
||||
// (себестоимость + членский взнос, зафиксированный в заказе контрактом) —
|
||||
// не голую себестоимость товара.
|
||||
function formatTotalWithFee(o: AdminOrderView): string {
|
||||
return formatAsset2Digits(o.total_cost_with_fee);
|
||||
}
|
||||
function formatDate(d: unknown): string {
|
||||
if (d === null || d === undefined) return '—';
|
||||
@@ -255,7 +258,7 @@ q-page.admin-orders(role="region", aria-label="Реестр заказов ко
|
||||
q-td {{ ordererTitle(props.row) }}
|
||||
q-td {{ supplierTitle(props.row) }}
|
||||
q-td.text-right {{ props.row.quantity }} {{ unitLabel(props.row) }}
|
||||
q-td.text-right.font-monospace {{ formatTotal(props.row.total_cost) }}
|
||||
q-td.text-right.font-monospace {{ formatTotalWithFee(props.row) }}
|
||||
q-td {{ formatDate(props.row.created_at) }}
|
||||
|
||||
q-tr.q-virtual-scroll--with-prev(
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { BaseCard, BaseButton, BaseChip, EmptyState } from 'src/shared/ui/base';
|
||||
import { KUHeaderBar } from 'src/widgets/Marketplace/KUHeaderBar';
|
||||
import { marketplaceUnitShort } from 'src/shared/lib/consts';
|
||||
import { useMarketplaceRealtime, getMembershipFeePercent } from 'src/shared/lib/marketplace';
|
||||
import { useMarketplaceRealtime, getMembershipFeePercent, applyMembershipFee } from 'src/shared/lib/marketplace';
|
||||
|
||||
/**
|
||||
* Эпик 16 / Story 16.1 + 16.2: страница корзины заказчика и оформление.
|
||||
@@ -35,12 +35,12 @@ const coopname = computed(() => String(route.params.coopname ?? ''));
|
||||
|
||||
const symbol = computed(() => system.governSymbol);
|
||||
|
||||
function unitShort(u: string): string {
|
||||
return marketplaceUnitShort(u as Parameters<typeof marketplaceUnitShort>[0]);
|
||||
function unitShort(u: string | null | undefined): string {
|
||||
return marketplaceUnitShort((u ?? '') as Parameters<typeof marketplaceUnitShort>[0]);
|
||||
}
|
||||
|
||||
function money(value: string | number): string {
|
||||
return Number(value).toLocaleString('ru-RU');
|
||||
function money(value: string | number | null | undefined): string {
|
||||
return Number(value ?? 0).toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
// Низкоуровневый коммит количества (целое ≥ 1). Кламп делает changeQty.
|
||||
@@ -156,8 +156,8 @@ useMarketplaceRealtime(
|
||||
// requirement b6: членский взнос входит в общую стоимость заказа — сводка
|
||||
// показывает его отдельной строкой и итог с учётом взноса.
|
||||
const feePercent = ref(0);
|
||||
const feeAmount = computed(() => (cartStore.totalCost * feePercent.value) / 100);
|
||||
const totalWithFee = computed(() => cartStore.totalCost + feeAmount.value);
|
||||
const totalWithFee = computed(() => applyMembershipFee(Number(cartStore.totalCost), feePercent.value));
|
||||
const feeAmount = computed(() => totalWithFee.value - Number(cartStore.totalCost));
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import { FailAlert, SuccessAlert } from 'src/shared/api';
|
||||
import { useSystemStore } from 'src/entities/System/model';
|
||||
import { useMarketplaceCartStore } from 'src/entities/MarketplaceCart';
|
||||
import { applyMembershipFee } from 'src/shared/lib/marketplace';
|
||||
import { BaseDialog, BaseInput, BaseButton } from 'src/shared/ui/base';
|
||||
import type { MarketplaceOfferView } from '../types';
|
||||
|
||||
@@ -29,10 +30,16 @@ const cartStore = useMarketplaceCartStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const props = defineProps<{
|
||||
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)
|
||||
|
||||
+2
-1
@@ -410,7 +410,8 @@ q-page.catalog(role="region", aria-label="Каталог Стола заказо
|
||||
|
||||
AddToCartDialog(
|
||||
v-model="cartDialogOpen",
|
||||
:offer="cartDialogOffer"
|
||||
:offer="cartDialogOffer",
|
||||
:fee-percent="feePercent"
|
||||
)
|
||||
</template>
|
||||
|
||||
|
||||
@@ -12,5 +12,5 @@ export async function fetchOffer(id: string): Promise<MarketplaceOfferDetailView
|
||||
Queries.Marketplace.GetOffer.query,
|
||||
{ variables: { id } },
|
||||
);
|
||||
return offer;
|
||||
return offer as MarketplaceOfferDetailView | null;
|
||||
}
|
||||
|
||||
+23
-3
@@ -9,7 +9,7 @@ import { OfferGallery } from 'src/widgets/Marketplace/OfferGallery';
|
||||
import { CartHeaderButton } from 'src/widgets/Marketplace/CartHeaderButton';
|
||||
import { marketplaceUnitShort } from 'src/shared/lib/consts';
|
||||
import { marketplaceOfferImageUrls } from 'src/shared/lib/utils';
|
||||
import { useMarketplaceRealtime } from 'src/shared/lib/marketplace';
|
||||
import { useMarketplaceRealtime, getMembershipFeePercent, applyMembershipFee } from 'src/shared/lib/marketplace';
|
||||
import { useMarketplaceCartStore } from 'src/entities/MarketplaceCart';
|
||||
import { useOfferModeration } from 'src/features/Marketplace/OfferModeration';
|
||||
import { fetchCategories } from '../../MarketplaceCatalog/api';
|
||||
@@ -105,9 +105,16 @@ const stockLabel = computed(() => {
|
||||
: `В наличии: ${offer.value.quantity_available} ${unitShort.value}`;
|
||||
});
|
||||
|
||||
// requirement b6: единая ставка членского взноса входит в цену, которую видит
|
||||
// заказчик — как в карточке каталога (CatalogOfferCard). Раздельная
|
||||
// себестоимость без взноса остаётся только на столе поставщика.
|
||||
const feePercent = ref(0);
|
||||
const priceWithFee = computed(() =>
|
||||
offer.value ? applyMembershipFee(Number(offer.value.price_per_unit), feePercent.value) : 0,
|
||||
);
|
||||
const priceLabel = computed(() =>
|
||||
offer.value
|
||||
? `${Number(offer.value.price_per_unit).toLocaleString('ru-RU')} ${system.governSymbol} / ${unitShort.value}`
|
||||
? `${priceWithFee.value.toLocaleString('ru-RU')} ${system.governSymbol} / ${unitShort.value}`
|
||||
: '',
|
||||
);
|
||||
|
||||
@@ -157,6 +164,11 @@ useMarketplaceRealtime(
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
feePercent.value = await getMembershipFeePercent();
|
||||
} catch {
|
||||
// Без ставки показываем цену без строки взноса.
|
||||
}
|
||||
await load();
|
||||
// В режиме заказа нужен текущий КУ (для кнопки «В корзину» и диалога).
|
||||
if (!readonly.value) {
|
||||
@@ -209,6 +221,7 @@ q-page.offer-detail(role="region", aria-label="Описание предложе
|
||||
span {{ offer.supplier_name }}
|
||||
|
||||
.offer-detail__price {{ priceLabel }}
|
||||
.offer-detail__fee-note(v-if="feePercent > 0") Цена с членским взносом {{ feePercent }}%
|
||||
|
||||
BaseButton(
|
||||
v-if="!readonly",
|
||||
@@ -257,7 +270,8 @@ q-page.offer-detail(role="region", aria-label="Описание предложе
|
||||
AddToCartDialog(
|
||||
v-if="!readonly",
|
||||
v-model="cartDialogOpen",
|
||||
:offer="offer"
|
||||
:offer="offer",
|
||||
:fee-percent="feePercent"
|
||||
)
|
||||
</template>
|
||||
|
||||
@@ -333,6 +347,12 @@ q-page.offer-detail(role="region", aria-label="Описание предложе
|
||||
color: var(--p-primary-strong);
|
||||
}
|
||||
|
||||
&__fee-note {
|
||||
font-size: var(--p-fs-body-sm);
|
||||
color: var(--p-ink-3);
|
||||
margin-top: calc(var(--p-1, 4px) * -1);
|
||||
}
|
||||
|
||||
&__sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -143,13 +143,13 @@ function groupByCheckout(list: MarketplaceOrderView[]): OrderGroup[] {
|
||||
}
|
||||
const result: OrderGroup[] = Array.from(map.entries()).map(([key, orders]) => {
|
||||
const first = orders[0];
|
||||
const recent = Math.max(...orders.map((o) => new Date(o.updated_at).getTime()));
|
||||
const recent = Math.max(...orders.map((o) => new Date(String(o.updated_at)).getTime()));
|
||||
return {
|
||||
key,
|
||||
isAggregate: orders.length > 1,
|
||||
orders,
|
||||
count: orders.length,
|
||||
totalCost: orders.reduce((s, o) => s + Number(o.total_cost), 0),
|
||||
totalCost: orders.reduce((s, o) => s + Number(o.total_cost_with_fee), 0),
|
||||
deliveryName: first?.delivery_point_name || first?.delivery_braname || '',
|
||||
createdAt: first?.created_at ?? '',
|
||||
recent,
|
||||
@@ -250,7 +250,7 @@ async function onLoadMore(): Promise<void> {
|
||||
function confirmCancel(order: MarketplaceOrderView): void {
|
||||
Dialog.create({
|
||||
title: 'Отменить заказ?',
|
||||
message: `Заказ № ${order.id.slice(0, 8)} (${order.quantity} ед., ${order.total_cost} ₽) будет отменён. Средства разблокируются на кошельке Стола заказов.`,
|
||||
message: `Заказ № ${order.id.slice(0, 8)} (${order.quantity} ед., ${money(order.total_cost_with_fee)} ₽) будет отменён. Средства разблокируются на кошельке Стола заказов.`,
|
||||
cancel: { label: 'Не отменять', flat: true },
|
||||
ok: { label: 'Отменить заказ', color: 'negative', unelevated: true },
|
||||
persistent: true,
|
||||
|
||||
+6
-1
@@ -122,6 +122,8 @@ interface SupplierParty {
|
||||
orders: MarketplaceOrderView[];
|
||||
totalUnits: number;
|
||||
totalCost: number;
|
||||
/** Сумма членских взносов пайщиков по заказам партии (requirement b6) — справочно, не деньги поставщика. */
|
||||
totalFee: number;
|
||||
/** Целевой минимальный объём поставки на этот КУ (для накопителя). */
|
||||
minVolume: number | null;
|
||||
stageStatus: MarketplaceOrderStatusView;
|
||||
@@ -150,6 +152,7 @@ const parties = computed<SupplierParty[]>(() => {
|
||||
orders: [],
|
||||
totalUnits: 0,
|
||||
totalCost: 0,
|
||||
totalFee: 0,
|
||||
minVolume: collecting
|
||||
? (minVolumeMap.value.get(`${o.offer_id}::${o.delivery_braname}`) ?? null)
|
||||
: null,
|
||||
@@ -160,6 +163,7 @@ const parties = computed<SupplierParty[]>(() => {
|
||||
p.orders.push(o);
|
||||
p.totalUnits += o.quantity;
|
||||
p.totalCost += parseFloat(o.total_cost) || 0;
|
||||
p.totalFee += Number(o.membership_fee ?? 0) || 0;
|
||||
const candidate = STAGE_RANK[o.status];
|
||||
const current = STAGE_RANK[p.stageStatus];
|
||||
if (candidate < 90 && (current >= 90 || candidate < current)) {
|
||||
@@ -340,7 +344,8 @@ q-page.incoming-orders(role='region', aria-label='Входящие заказы
|
||||
:bar-color='barColor(p)',
|
||||
:members='[]',
|
||||
total-label='Итого',
|
||||
:total-value='`${formatCost(p.totalCost)} · ${p.totalUnits} ${p.unitLabel}`'
|
||||
:total-value='`${formatCost(p.totalCost)} · ${p.totalUnits} ${p.unitLabel}`',
|
||||
:total-fee-note='p.totalFee > 0 ? `С учётом взноса пайщиков: ${formatCost(p.totalCost + p.totalFee)}` : undefined'
|
||||
)
|
||||
template(#actions)
|
||||
template(v-if='p.kind === "collecting"')
|
||||
|
||||
@@ -64,7 +64,7 @@ export function buildTtnData(
|
||||
const boxes = unitsPerBox && unitsPerBox > 0 ? Math.ceil(o.quantity / unitsPerBox) : undefined;
|
||||
return {
|
||||
sku: o.offer_id ? String(o.offer_id).slice(0, 8) : '—',
|
||||
title: o.product_name,
|
||||
title: o.product_name || 'Товар по предложению',
|
||||
qty: o.quantity,
|
||||
unit: marketplaceUnitShort(o.unit_of_measure),
|
||||
price: parseFloat(o.price_per_unit) || 0,
|
||||
|
||||
+2
-2
@@ -230,8 +230,8 @@ q-page.mp-role-orderer.mp-member-cpp(role="region", aria-label="Подключе
|
||||
.mp-member-cpp__head-icon
|
||||
q-icon(name="location_on", size="22px")
|
||||
.mp-member-cpp__head-text
|
||||
.text-subtitle1.text-weight-medium Выберите пункт выдачи
|
||||
.text-body2.text-grey-7 Где будете забирать заказы. Каталог отфильтруется под выбранный участок.
|
||||
.text-subtitle1.text-weight-medium Выберите пункт выдачи заказов
|
||||
.text-body2.text-grey-7 Выбирайте участок, где вам удобно забирать заказы, — сменить его можно в любой момент.
|
||||
BaseChip.mp-member-cpp__picked(v-if="selectedName", variant="pos", size="sm")
|
||||
q-icon(name="check", size="14px")
|
||||
| {{ selectedName }}
|
||||
|
||||
+1
-1
@@ -170,7 +170,7 @@ const planSaving = ref(false)
|
||||
function planDueLabel(plan: ExpensePlanView): string {
|
||||
if (plan.priority === Zeus.ExpensePlanPriority.URGENT) return 'Срочный'
|
||||
if (plan.priority === Zeus.ExpensePlanPriority.OPTIONAL) return 'Необязательный'
|
||||
return plan.due_date ? new Date(plan.due_date).toLocaleDateString('ru-RU') : '—'
|
||||
return plan.due_date ? new Date(String(plan.due_date)).toLocaleDateString('ru-RU') : '—'
|
||||
}
|
||||
|
||||
async function onAddPlan(): Promise<void> {
|
||||
|
||||
+2
-2
@@ -258,7 +258,7 @@ function aggregateLines(orders: MarketplaceSupplierPickupOrderView[]): DeliveryL
|
||||
map.set(key, {
|
||||
key,
|
||||
productName: o.product_name || 'Товар по предложению',
|
||||
unit: o.unit_of_measure,
|
||||
unit: o.unit_of_measure ?? '',
|
||||
quantity: qty,
|
||||
boxes,
|
||||
});
|
||||
@@ -382,7 +382,7 @@ function groupOrdersByProduct(orders: MarketplaceSupplierPickupOrderView[]): Pic
|
||||
map.set(key, {
|
||||
key,
|
||||
productName: o.product_name || 'Товар по предложению',
|
||||
unit: o.unit_of_measure,
|
||||
unit: o.unit_of_measure ?? '',
|
||||
orderedTotal: qty,
|
||||
orders: [o],
|
||||
});
|
||||
|
||||
+2
-1
@@ -88,6 +88,7 @@ interface CollectiveParty {
|
||||
orders: MarketplaceOrderView[];
|
||||
/** Свой вклад (сумма собственных заказов в этой партии). */
|
||||
ownUnits: number;
|
||||
/** С учётом членского взноса (requirement b6) — то, что заказчик реально платит. */
|
||||
ownCost: number;
|
||||
/** Накоплено всеми пайщиками (с бэкенда): пул сбора или объём партии. */
|
||||
groupAccumulated: number | null;
|
||||
@@ -127,7 +128,7 @@ const parties = computed<CollectiveParty[]>(() => {
|
||||
}
|
||||
p.orders.push(o);
|
||||
p.ownUnits += o.quantity;
|
||||
p.ownCost += parseFloat(o.total_cost) || 0;
|
||||
p.ownCost += Number(o.total_cost_with_fee);
|
||||
const candidate = STAGE_RANK[o.status];
|
||||
const current = STAGE_RANK[p.stageStatus];
|
||||
if (candidate < 90 && (current >= 90 || candidate < current)) {
|
||||
|
||||
+11
-3
@@ -46,6 +46,14 @@ const status = computed(() => (order.value ? orderStatusDisplay(order.value.stat
|
||||
const unitShort = computed(() => marketplaceUnitShort(order.value?.unit_of_measure));
|
||||
const cancellable = computed(() => order.value?.status === 'ACTIVE');
|
||||
|
||||
// requirement b6: заказчику показываем цену С членским взносом (как в
|
||||
// каталоге) — order.total_cost_with_fee уже посчитан бэком (total_cost +
|
||||
// membership_fee, зафиксированный контрактом по ставке на момент заказа).
|
||||
const totalWithFee = computed(() => (order.value ? Number(order.value.total_cost_with_fee) : 0));
|
||||
const unitPriceWithFee = computed(() =>
|
||||
order.value && order.value.quantity > 0 ? totalWithFee.value / order.value.quantity : 0,
|
||||
);
|
||||
|
||||
const pvzName = computed(() => order.value?.delivery_point_name || order.value?.delivery_braname || '');
|
||||
const pvzAddress = computed(() => order.value?.delivery_point_address || '');
|
||||
|
||||
@@ -129,7 +137,7 @@ function confirmCancel(): void {
|
||||
if (!o) return;
|
||||
Dialog.create({
|
||||
title: 'Отменить заказ?',
|
||||
message: `Заказ № ${o.id.slice(0, 8)} (${o.quantity} ед., ${o.total_cost} ₽) будет отменён. Средства разблокируются на кошельке Стола заказов.`,
|
||||
message: `Заказ № ${o.id.slice(0, 8)} (${o.quantity} ед., ${formatPrice(o.total_cost_with_fee)}) будет отменён. Средства разблокируются на кошельке Стола заказов.`,
|
||||
cancel: { label: 'Не отменять', flat: true },
|
||||
ok: { label: 'Отменить заказ', color: 'negative', unelevated: true },
|
||||
persistent: true,
|
||||
@@ -199,13 +207,13 @@ q-page.order-detail(role="region", aria-label="Заказ")
|
||||
.order-detail__facts
|
||||
.order-detail__fact
|
||||
.order-detail__fact-label Сумма заказа
|
||||
.order-detail__fact-value--money {{ formatPrice(order.total_cost) }}
|
||||
.order-detail__fact-value--money {{ formatPrice(String(totalWithFee)) }}
|
||||
.order-detail__fact
|
||||
.order-detail__fact-label Количество
|
||||
.order-detail__fact-value {{ order.quantity }} {{ unitShort }}
|
||||
.order-detail__fact
|
||||
.order-detail__fact-label Цена за единицу
|
||||
.order-detail__fact-value {{ formatPrice(order.price_per_unit) }}
|
||||
.order-detail__fact-value {{ formatPrice(String(unitPriceWithFee)) }}
|
||||
|
||||
BaseCard.order-detail__card(v-if="pvzName || pvzAddress")
|
||||
template(#head)
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { Zeus } from '@coopenomics/sdk';
|
||||
import { FailAlert, SuccessAlert } from 'src/shared/api';
|
||||
import { useSessionStore } from 'src/entities/Session/model';
|
||||
import { BaseBadge, BaseButton, BaseInput, BaseDialog, EmptyState } from 'src/shared/ui/base';
|
||||
@@ -124,7 +125,7 @@ async function onAdd(): Promise<void> {
|
||||
try {
|
||||
await addSupplier({
|
||||
member_account: addMember.value.trim(),
|
||||
model: 'MEMBERSHIP',
|
||||
model: Zeus.MarketplaceSupplierModel.MEMBERSHIP,
|
||||
contract_number: addNumber.value.trim(),
|
||||
contract_date: addDate.value,
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ div
|
||||
:value='program.key',
|
||||
:title='program.title',
|
||||
:description='program.description',
|
||||
:meta='program.requirements',
|
||||
:meta='program.requirements ?? undefined',
|
||||
@update:model-value='selectProgram(program.key)'
|
||||
)
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ function getEnv(): EnvVars {
|
||||
VAPID_PUBLIC_KEY: '',
|
||||
SENTRY_DSN: '',
|
||||
OPENREPLAY_PROJECT_KEY: '',
|
||||
YANDEX_MAPS_API_KEY: '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export function createEnvObject(): EnvVars {
|
||||
VAPID_PUBLIC_KEY: process.env.VAPID_PUBLIC_KEY as string,
|
||||
SENTRY_DSN: process.env.SENTRY_DSN as string,
|
||||
OPENREPLAY_PROJECT_KEY: process.env.OPENREPLAY_PROJECT_KEY as string,
|
||||
YANDEX_MAPS_API_KEY: process.env.YANDEX_MAPS_API_KEY as string,
|
||||
};
|
||||
|
||||
return envObj;
|
||||
|
||||
@@ -20,6 +20,7 @@ export {
|
||||
export { useActsPreview } from './useActsPreview';
|
||||
export {
|
||||
getMembershipFeePercent,
|
||||
applyMembershipFee,
|
||||
computeIssuanceDiff,
|
||||
type IssuanceDiffLine,
|
||||
} from './membership-fee';
|
||||
|
||||
@@ -13,6 +13,14 @@ export async function getMembershipFeePercent(): Promise<number> {
|
||||
return result.membership_fee_percent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Цена с учётом членского взноса — единая формула для всех экранов заказчика
|
||||
* (каталог, деталь предложения, добавление в корзину, сводка корзины).
|
||||
*/
|
||||
export function applyMembershipFee(price: number, feePercent: number): number {
|
||||
return price * (1 + feePercent / 100);
|
||||
}
|
||||
|
||||
export interface IssuanceDiffLine {
|
||||
/** Сумма позиции по заказу (заблокированный резерв без взноса). */
|
||||
orderedTotal: number;
|
||||
@@ -41,6 +49,5 @@ export function computeIssuanceDiff(
|
||||
if (diff > 0) refund += diff;
|
||||
else if (diff < 0) surcharge += -diff;
|
||||
}
|
||||
const withFee = 1 + feePercent / 100;
|
||||
return { refund: refund * withFee, surcharge: surcharge * withFee };
|
||||
return { refund: applyMembershipFee(refund, feePercent), surcharge: applyMembershipFee(surcharge, feePercent) };
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface GroupableReception {
|
||||
fact_quantity_per_order: ReadonlyArray<{
|
||||
product_name?: string | null;
|
||||
fact_quantity: number | string;
|
||||
unit_of_measure: string;
|
||||
unit_of_measure?: string | null;
|
||||
fact_unit_price?: string | null;
|
||||
}>;
|
||||
}
|
||||
@@ -115,7 +115,7 @@ export function groupAplReceptions<T extends GroupableReception>(
|
||||
lineMap.set(lk, {
|
||||
key: lk,
|
||||
productName: f.product_name || 'Товар по предложению',
|
||||
unit: f.unit_of_measure,
|
||||
unit: f.unit_of_measure ?? '',
|
||||
quantity: qty,
|
||||
amount: qty * price,
|
||||
});
|
||||
|
||||
@@ -75,7 +75,7 @@ const generateQRCode = () => {
|
||||
if (qrElement.value && props.paymentOrder.payment_details?.data) {
|
||||
QRCode.toCanvas(
|
||||
qrElement.value,
|
||||
props.paymentOrder.payment_details.data,
|
||||
props.paymentOrder.payment_details.data as string,
|
||||
{ width: 400 },
|
||||
function (error) {
|
||||
if (error) console.error(error);
|
||||
|
||||
@@ -61,11 +61,13 @@ const props = withDefaults(defineProps<BaseInputProps>(), {
|
||||
autogrow: false,
|
||||
});
|
||||
|
||||
// Quasar QInput форвардит нативные blur/focus как обычный Event, не строго
|
||||
// FocusEvent — типизация здесь под то, что реально приходит из q-input.
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
clear: [];
|
||||
blur: [event: FocusEvent];
|
||||
focus: [event: FocusEvent];
|
||||
blur: [event: Event];
|
||||
focus: [event: Event];
|
||||
}>();
|
||||
|
||||
const autoId = useId();
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, type PropType } from 'vue'
|
||||
import { OfferGallery } from 'src/widgets/Marketplace/OfferGallery'
|
||||
import { applyMembershipFee } from 'src/shared/lib/marketplace'
|
||||
|
||||
export type CatalogOfferStatus = 'draft' | 'published' | 'paused' | 'sold-out' | 'completed' | 'moderation' | 'withdrawn'
|
||||
|
||||
@@ -165,7 +166,7 @@ const unitCostWithFee = computed<number | string>(() => {
|
||||
if (!hasFee.value) return base
|
||||
const n = typeof base === 'number' ? base : Number(base)
|
||||
if (Number.isNaN(n)) return base
|
||||
return n * (1 + props.feePercent / 100)
|
||||
return applyMembershipFee(n, props.feePercent)
|
||||
})
|
||||
|
||||
const feeLabel = computed(() => {
|
||||
|
||||
+17
-11
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
+158
-142
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user