Merge pull request 'fix(marketplace): ownership :own-KU учитывает trustees председателя КУ' (#16) from chore/trustees-ownership into marketplace2

Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
2026-05-21 08:16:53 +00:00
6 changed files with 166 additions and 28 deletions
@@ -95,6 +95,10 @@ import {
} from './services/marketplace-payout-sync.service';
import { MarketplaceOutgoingPaymentResolver } from './resolvers/marketplace-outgoing-payment.resolver';
import { MarketplaceNotificationService } from './services/marketplace-notification.service';
import {
MarketplaceBranchOwnershipService,
MARKETPLACE_BRANCH_OWNERSHIP_SERVICE,
} from './services/marketplace-branch-ownership.service';
import {
MarketplaceIssuanceService,
MARKETPLACE_ISSUANCE_SERVICE,
@@ -288,6 +292,11 @@ import { MarketplaceInventoryEntity } from '../infrastructure/entities/marketpla
// Слушает per-contract event-bus, отправка через Novu без обратного
// влияния на основной flow (INV-12: emit после save в PG).
MarketplaceNotificationService,
{
provide: MARKETPLACE_BRANCH_OWNERSHIP_SERVICE,
useClass: MarketplaceBranchOwnershipService,
},
MarketplaceBranchOwnershipService,
// Story 6.1 / 6.3 / 6.4 — выдача пайщику с двойной подписью АПП
// (signiss1 + signiss2) и тремя ветками сверки факт vs заказ.
{
@@ -24,6 +24,10 @@ import {
MARKETPLACE_APL_RECEPTION_REPOSITORY,
type MarketplaceAplReceptionDomainRepository,
} from '../../domain/repositories/marketplace-apl-reception.repository';
import {
MARKETPLACE_BRANCH_OWNERSHIP_SERVICE,
MarketplaceBranchOwnershipService,
} from '../services/marketplace-branch-ownership.service';
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
import type { DocumentDomainEntity } from '~/domain/document/entity/document-domain.entity';
@@ -44,7 +48,9 @@ export class MarketplaceAplReceptionResolver {
@Inject(MARKETPLACE_APL_RECEPTION_SERVICE)
private readonly service: MarketplaceAplReceptionService,
@Inject(MARKETPLACE_APL_RECEPTION_REPOSITORY)
private readonly receptionRepo: MarketplaceAplReceptionDomainRepository
private readonly receptionRepo: MarketplaceAplReceptionDomainRepository,
@Inject(MARKETPLACE_BRANCH_OWNERSHIP_SERVICE)
private readonly branchOwnership: MarketplaceBranchOwnershipService
) {}
@Mutation(() => MarketplaceAplReceptionResultDTO, {
@@ -155,9 +161,14 @@ export class MarketplaceAplReceptionResolver {
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Receiving', 'create')
async marketplaceListAplReceptionsByBraname(
@CurrentMarketplaceMember() _member: IMarketplaceCurrentMember,
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceListAplReceptionsByBranameInputDTO
): Promise<MarketplaceAplReceptionDTO[]> {
await this.branchOwnership.assertCanActAsBraname(
config.coopname,
member.username,
data.braname
);
const list = await this.receptionRepo.listByBraname(config.coopname, data.braname);
return list.map(toMarketplaceAplReceptionDTO);
}
@@ -1,12 +1,13 @@
import { Inject, Injectable, UseGuards } from '@nestjs/common';
import { ForbiddenException, Inject, Injectable, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import config from '~/config/config';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { CurrentMarketplaceMember } from '../decorators/current-marketplace-member.decorator';
import { RequireMarketplaceAccess } from '../decorators/marketplace-access.decorator';
import { RequireMarketplaceRole } from '../decorators/marketplace-role.decorator';
import { MarketplaceMembershipGuard } from '../guards/marketplace-membership.guard';
import { MarketplaceRoleGuard } from '../guards/marketplace-role.guard';
import type { IMarketplaceCurrentMember } from '../dto/marketplace-current-member.dto';
import type { MarketplaceRole } from '../membership/marketplace-roles.mapper';
import {
MarketplaceInventoryItemDTO,
MarketplaceLabelInventoryInputDTO,
@@ -25,6 +26,11 @@ import {
type MarketplaceInventoryDomainRepository,
type MarketplaceInventoryListFilter,
} from '../../domain/repositories/marketplace-inventory.repository';
import {
MARKETPLACE_BRANCH_OWNERSHIP_SERVICE,
MarketplaceBranchOwnershipService,
} from '../services/marketplace-branch-ownership.service';
import { RequireMarketplaceAccess } from '../decorators/marketplace-access.decorator';
import type {
MarketplaceBarcodeFormat,
MarketplaceBarcodeStrategy,
@@ -38,7 +44,9 @@ export class MarketplaceInventoryResolver {
@Inject(MARKETPLACE_INVENTORY_LABEL_SERVICE)
private readonly labelService: MarketplaceInventoryLabelService,
@Inject(MARKETPLACE_INVENTORY_REPOSITORY)
private readonly inventoryRepo: MarketplaceInventoryDomainRepository
private readonly inventoryRepo: MarketplaceInventoryDomainRepository,
@Inject(MARKETPLACE_BRANCH_OWNERSHIP_SERVICE)
private readonly branchOwnership: MarketplaceBranchOwnershipService
) {}
@Mutation(() => MarketplaceLabelInventoryResultDTO, {
@@ -97,19 +105,41 @@ export class MarketplaceInventoryResolver {
@Query(() => [MarketplaceInventoryItemDTO], {
name: 'marketplaceListInventory',
description: 'Список наклеек инвентаря КУ — для admin-стола склада и операторских разделов.',
description:
'Список наклеек инвентаря КУ: admin/совет видят весь склад кооператива, оператор — только свой участок.',
})
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Warehouse', 'read:own-KU')
@RequireMarketplaceRole('admin', 'board_readonly', 'operator')
async marketplaceListInventory(
@CurrentMarketplaceMember() _member: IMarketplaceCurrentMember,
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data', { nullable: true }) data?: MarketplaceListInventoryInputDTO
): Promise<MarketplaceInventoryItemDTO[]> {
const roles = member.marketplace_roles as MarketplaceRole[];
const isAdmin = roles.includes('admin') || roles.includes('board_readonly');
const isOperator = roles.includes('operator');
let branameFilter = data?.braname;
if (!isAdmin) {
if (!isOperator) {
throw new ForbiddenException('Нет доступа к складу.');
}
if (!branameFilter) {
throw new ForbiddenException(
'Оператор обязан указать кооперативный участок для чтения склада.'
);
}
await this.branchOwnership.assertCanActAsBraname(
config.coopname,
member.username,
branameFilter
);
}
const filter: MarketplaceInventoryListFilter = {
coopname: config.coopname,
order_id: data?.order_id,
shipment_id: data?.shipment_id,
braname: data?.braname,
braname: branameFilter,
status: data?.statuses?.length
? (data.statuses as MarketplaceInventoryStatus[])
: undefined,
@@ -23,6 +23,10 @@ import {
MARKETPLACE_ORDER_REPOSITORY,
type MarketplaceOrderDomainRepository,
} from '../../domain/repositories/marketplace-order.repository';
import {
MARKETPLACE_BRANCH_OWNERSHIP_SERVICE,
MarketplaceBranchOwnershipService,
} from '../services/marketplace-branch-ownership.service';
import { GeneratedDocumentDTO } from '~/application/document/dto/generated-document.dto';
import type { DocumentDomainEntity } from '~/domain/document/entity/document-domain.entity';
@@ -43,7 +47,9 @@ export class MarketplaceIssuanceResolver {
@Inject(MARKETPLACE_ISSUANCE_SERVICE)
private readonly service: MarketplaceIssuanceService,
@Inject(MARKETPLACE_ORDER_REPOSITORY)
private readonly orderRepo: MarketplaceOrderDomainRepository
private readonly orderRepo: MarketplaceOrderDomainRepository,
@Inject(MARKETPLACE_BRANCH_OWNERSHIP_SERVICE)
private readonly branchOwnership: MarketplaceBranchOwnershipService
) {}
@Mutation(() => MarketplaceIssuanceResultDTO, {
@@ -139,8 +145,14 @@ export class MarketplaceIssuanceResolver {
@UseGuards(GqlJwtAuthGuard, MarketplaceMembershipGuard, MarketplaceRoleGuard)
@RequireMarketplaceAccess('Issuance', 'read:own-KU')
async marketplaceListIssuancesByBraname(
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceListIssuancesByBranameInputDTO
): Promise<MarketplaceOrderDTO[]> {
await this.branchOwnership.assertCanActAsBraname(
config.coopname,
member.username,
data.delivery_braname
);
const orders = await this.orderRepo.listForIssuanceByBraname(
config.coopname,
data.delivery_braname
@@ -1,4 +1,4 @@
import { ForbiddenException, Injectable, UseGuards } from '@nestjs/common';
import { ForbiddenException, Inject, Injectable, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import config from '~/config/config';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
@@ -22,6 +22,10 @@ import {
} from '../dto/marketplace-return-claim.dto';
import { MarketplaceReturnClaimService } from '../services/marketplace-return-claim.service';
import { toMarketplaceReturnClaimDTO } from './marketplace-return-claim.mapper';
import {
MARKETPLACE_BRANCH_OWNERSHIP_SERVICE,
MarketplaceBranchOwnershipService,
} from '../services/marketplace-branch-ownership.service';
function toGeneratedDocumentDTO(e: DocumentDomainEntity): GeneratedDocumentDTO {
const dto = new GeneratedDocumentDTO();
@@ -46,7 +50,11 @@ function toGeneratedDocumentDTO(e: DocumentDomainEntity): GeneratedDocumentDTO {
@Resolver()
@Injectable()
export class MarketplaceReturnClaimResolver {
constructor(private readonly service: MarketplaceReturnClaimService) {}
constructor(
private readonly service: MarketplaceReturnClaimService,
@Inject(MARKETPLACE_BRANCH_OWNERSHIP_SERVICE)
private readonly branchOwnership: MarketplaceBranchOwnershipService
) {}
@Query(() => GeneratedDocumentDTO, {
name: 'marketplaceReturnClaimSignablePayload',
@@ -105,6 +113,11 @@ export class MarketplaceReturnClaimResolver {
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceApproveReturnVisitInputDTO
): Promise<MarketplaceReturnClaimResultDTO> {
await this.branchOwnership.assertCanActAsBraname(
config.coopname,
member.username,
data.braname
);
const result = await this.service.approveReturnVisit({
coopname: config.coopname,
chairman_account: member.username,
@@ -127,6 +140,11 @@ export class MarketplaceReturnClaimResolver {
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceRejectReturnRemoteInputDTO
): Promise<MarketplaceReturnClaimResultDTO> {
await this.branchOwnership.assertCanActAsBraname(
config.coopname,
member.username,
data.braname
);
const result = await this.service.rejectReturnRemote({
coopname: config.coopname,
chairman_account: member.username,
@@ -149,6 +167,11 @@ export class MarketplaceReturnClaimResolver {
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceAcceptReturnAtVisitInputDTO
): Promise<MarketplaceReturnClaimResultDTO> {
await this.branchOwnership.assertCanActAsBraname(
config.coopname,
member.username,
data.braname
);
const result = await this.service.acceptReturnAtVisit({
coopname: config.coopname,
chairman_account: member.username,
@@ -173,6 +196,11 @@ export class MarketplaceReturnClaimResolver {
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceRejectReturnAtVisitInputDTO
): Promise<MarketplaceReturnClaimResultDTO> {
await this.branchOwnership.assertCanActAsBraname(
config.coopname,
member.username,
data.braname
);
const result = await this.service.rejectReturnAtVisit({
coopname: config.coopname,
chairman_account: member.username,
@@ -209,15 +237,11 @@ export class MarketplaceReturnClaimResolver {
@CurrentMarketplaceMember() member: IMarketplaceCurrentMember,
@Args('data') data: MarketplaceListReturnClaimsByBranameInputDTO
): Promise<MarketplaceReturnClaimDTO[]> {
// Ownership `:own-KU` — MVP-приближение: член-operator является
// chairman'ом braname если его username совпадает с account КУ
// (соответствует текущему fallback'у marketplace-notification.service.ts).
// Полная интеграция с `coop_ku.chairman_account` — Phase 2.
if (member.username !== data.delivery_braname) {
throw new ForbiddenException(
'Чтение заявлений возможно только для участка, председателем которого вы являетесь.'
);
}
await this.branchOwnership.assertCanActAsBraname(
config.coopname,
member.username,
data.delivery_braname
);
const claims = await this.service.listByDeliveryBraname(
config.coopname,
data.delivery_braname
@@ -236,13 +260,13 @@ export class MarketplaceReturnClaimResolver {
@Args('claim_id') claim_id: string
): Promise<MarketplaceReturnClaimDTO> {
const claim = await this.service.findById(config.coopname, claim_id);
// Ownership-проверка `:own` — matrix capability проверена guard'ом,
// здесь верифицируем что пайщик действительно владелец заявления
// (orderer Order'а либо председатель КУ доставки этого заявления).
const isOwnerOrderer = claim.orderer_account === member.username;
const isChairmanOfDeliveryKu = member.marketplace_roles.includes('operator')
&& claim.delivery_braname === member.username;
if (!isOwnerOrderer && !isChairmanOfDeliveryKu) {
const isOwner = claim.orderer_account === member.username;
const isChairmanOrTrustee = await this.branchOwnership.canActAsBraname(
config.coopname,
member.username,
claim.delivery_braname
);
if (!isOwner && !isChairmanOrTrustee) {
throw new ForbiddenException('Это чужое заявление на возврат.');
}
return this.toClaimDTO(claim);
@@ -0,0 +1,52 @@
import { ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import {
BRANCH_BLOCKCHAIN_PORT,
type BranchBlockchainPort,
} from '~/domain/branch/interfaces/branch-blockchain.port';
export const MARKETPLACE_BRANCH_OWNERSHIP_SERVICE = Symbol(
'MarketplaceBranchOwnershipService'
);
/**
* Проверяет, что аккаунт правомочен оперировать от имени кооперативного участка.
*
* Правомочны: председатель КУ (`branch.trustee`) либо его доверенные лица
* (`branch.trusted[]`, до 3 individual-аккаунтов; см. `branch::addtrusted`).
*
* Branches не реплицируются в Postgres — chain RPC через port. ADR-011
* не нарушается: read-path вычисляет authorization, а не отдаёт прикладную
* сущность пользователю.
*/
@Injectable()
export class MarketplaceBranchOwnershipService {
constructor(
@Inject(BRANCH_BLOCKCHAIN_PORT)
private readonly branchPort: BranchBlockchainPort
) {}
async canActAsBraname(coopname: string, account: string, braname: string): Promise<boolean> {
const branch = await this.branchPort.getBranch(coopname, braname);
if (!branch) return false;
if (branch.trustee === account) return true;
return branch.trusted?.includes(account) ?? false;
}
async assertCanActAsBraname(
coopname: string,
account: string,
braname: string
): Promise<void> {
const branch = await this.branchPort.getBranch(coopname, braname);
if (!branch) {
throw new NotFoundException(`Кооперативный участок ${braname} не найден.`);
}
const allowed =
branch.trustee === account || (branch.trusted?.includes(account) ?? false);
if (!allowed) {
throw new ForbiddenException(
'Действие доступно только председателю кооперативного участка или его доверенному лицу.'
);
}
}
}