search by private account
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"agentCanUpdateSnapshot": true
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
@@ -3364,6 +3364,22 @@ type PrivateAccount {
|
||||
type: AccountType!
|
||||
}
|
||||
|
||||
union PrivateAccountSearchData = Entrepreneur | Individual | Organization
|
||||
|
||||
type PrivateAccountSearchResult {
|
||||
"""Данные найденного аккаунта"""
|
||||
data: PrivateAccountSearchData!
|
||||
|
||||
"""Поля, в которых найдены совпадения"""
|
||||
highlightedFields: [String!]
|
||||
|
||||
"""Оценка релевантности результата"""
|
||||
score: Float
|
||||
|
||||
"""Тип аккаунта"""
|
||||
type: String!
|
||||
}
|
||||
|
||||
input ProhibitRequestInput {
|
||||
"""Имя аккаунта кооператива"""
|
||||
coopname: String!
|
||||
@@ -3536,6 +3552,11 @@ type Query {
|
||||
|
||||
"""Получить сводную публичную информацию о системе"""
|
||||
getSystemInfo: SystemInfo!
|
||||
|
||||
"""
|
||||
Поиск приватных данных аккаунтов по запросу. Поиск осуществляется по полям ФИО, ИНН, ОГРН, наименованию организации и другим приватным данным.
|
||||
"""
|
||||
searchPrivateAccounts(data: SearchPrivateAccountsInput!): [PrivateAccountSearchResult!]!
|
||||
}
|
||||
|
||||
"""Вопрос повестки собрания с результатами голосования"""
|
||||
@@ -4035,6 +4056,11 @@ type SbpAccount {
|
||||
phone: String!
|
||||
}
|
||||
|
||||
input SearchPrivateAccountsInput {
|
||||
"""Поисковый запрос для поиска приватных аккаунтов"""
|
||||
query: String!
|
||||
}
|
||||
|
||||
input SelectBranchGenerateDocumentInput {
|
||||
"""Номер блока, на котором был создан документ"""
|
||||
block_num: Int
|
||||
|
||||
@@ -17,6 +17,10 @@ import { AccountType } from '~/modules/account/enum/account-type.enum';
|
||||
import { ORGANIZATION_REPOSITORY, OrganizationRepository } from '~/domain/common/repositories/organization.repository';
|
||||
import { INDIVIDUAL_REPOSITORY, IndividualRepository } from '~/domain/common/repositories/individual.repository';
|
||||
import { ENTREPRENEUR_REPOSITORY, EntrepreneurRepository } from '~/domain/common/repositories/entrepreneur.repository';
|
||||
import {
|
||||
SEARCH_PRIVATE_ACCOUNTS_REPOSITORY,
|
||||
SearchPrivateAccountsRepository,
|
||||
} from '~/domain/common/repositories/search-private-accounts.repository';
|
||||
import { IndividualDomainEntity } from '~/domain/branch/entities/individual-domain.entity';
|
||||
import { OrganizationDomainEntity } from '~/domain/branch/entities/organization-domain.entity';
|
||||
import { EntrepreneurDomainEntity } from '~/domain/branch/entities/entrepreneur-domain.entity';
|
||||
@@ -24,6 +28,10 @@ import { ACCOUNT_BLOCKCHAIN_PORT, AccountBlockchainPort } from '../interfaces/ac
|
||||
import { CANDIDATE_REPOSITORY, CandidateRepository } from '../repository/candidate.repository';
|
||||
import { userStatus } from '~/types/user.types';
|
||||
import { sha256 } from '~/utils/sha256';
|
||||
import type {
|
||||
SearchPrivateAccountsInputDomainInterface,
|
||||
PrivateAccountSearchResultDomainInterface,
|
||||
} from '~/domain/common/interfaces/search-private-accounts-domain.interface';
|
||||
|
||||
@Injectable()
|
||||
export class AccountDomainInteractor {
|
||||
@@ -32,6 +40,8 @@ export class AccountDomainInteractor {
|
||||
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository,
|
||||
@Inject(INDIVIDUAL_REPOSITORY) private readonly individualRepository: IndividualRepository,
|
||||
@Inject(ENTREPRENEUR_REPOSITORY) private readonly entrepreneurRepository: EntrepreneurRepository,
|
||||
@Inject(SEARCH_PRIVATE_ACCOUNTS_REPOSITORY)
|
||||
private readonly searchPrivateAccountsRepository: SearchPrivateAccountsRepository,
|
||||
@Inject(ACCOUNT_BLOCKCHAIN_PORT) private readonly accountBlockchainPort: AccountBlockchainPort,
|
||||
@Inject(CANDIDATE_REPOSITORY) private readonly candidateRepository: CandidateRepository
|
||||
) {}
|
||||
@@ -176,4 +186,21 @@ export class AccountDomainInteractor {
|
||||
throw new HttpException(`Ошибка при регистрации в блокчейне: ${error.message}`, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск приватных аккаунтов по запросу
|
||||
* @param input Входные данные для поиска
|
||||
* @returns Массив результатов поиска
|
||||
*/
|
||||
async searchPrivateAccounts(
|
||||
input: SearchPrivateAccountsInputDomainInterface
|
||||
): Promise<PrivateAccountSearchResultDomainInterface[]> {
|
||||
this.logger.log(`Поиск приватных аккаунтов по запросу: "${input.query}"`);
|
||||
|
||||
const results = await this.searchPrivateAccountsRepository.searchPrivateAccounts(input);
|
||||
|
||||
this.logger.log(`Найдено ${results.length} приватных аккаунтов`);
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import type { IndividualDomainInterface } from './individual-domain.interface';
|
||||
import type { EntrepreneurDomainInterface } from './entrepreneur-domain.interface';
|
||||
import type { OrganizationDomainInterface } from './organization-domain.interface';
|
||||
|
||||
/**
|
||||
* Интерфейс для входных данных поиска приватных аккаунтов
|
||||
*/
|
||||
export interface SearchPrivateAccountsInputDomainInterface {
|
||||
query: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Тип данных результата поиска - union type всех возможных типов приватных данных
|
||||
*/
|
||||
export type PrivateAccountSearchDataDomainInterface =
|
||||
| IndividualDomainInterface
|
||||
| EntrepreneurDomainInterface
|
||||
| OrganizationDomainInterface;
|
||||
|
||||
/**
|
||||
* Интерфейс результата поиска приватных аккаунтов
|
||||
*/
|
||||
export interface PrivateAccountSearchResultDomainInterface {
|
||||
type: 'individual' | 'entrepreneur' | 'organization';
|
||||
data: PrivateAccountSearchDataDomainInterface;
|
||||
score?: number;
|
||||
highlightedFields?: string[];
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import type {
|
||||
SearchPrivateAccountsInputDomainInterface,
|
||||
PrivateAccountSearchResultDomainInterface,
|
||||
} from '../interfaces/search-private-accounts-domain.interface';
|
||||
|
||||
/**
|
||||
* Доменный репозиторий для поиска приватных аккаунтов
|
||||
*/
|
||||
export interface SearchPrivateAccountsRepository {
|
||||
/**
|
||||
* Поиск приватных аккаунтов по запросу
|
||||
* @param input Входные данные для поиска
|
||||
* @returns Массив результатов поиска
|
||||
*/
|
||||
searchPrivateAccounts(
|
||||
input: SearchPrivateAccountsInputDomainInterface
|
||||
): Promise<PrivateAccountSearchResultDomainInterface[]>;
|
||||
}
|
||||
|
||||
export const SEARCH_PRIVATE_ACCOUNTS_REPOSITORY = Symbol('SearchPrivateAccountsRepository');
|
||||
+7
@@ -14,6 +14,8 @@ import { PROJECT_FREE_DECISION_REPOSITORY } from '~/domain/common/repositories/p
|
||||
import { ProjectFreeDecisionRepositoryImplementation } from './repositories/project-free-decision-generator.repository';
|
||||
import { VARS_REPOSITORY } from '~/domain/common/repositories/vars.repository';
|
||||
import { VarsRepositoryImplementation } from './repositories/vars-generator.repository';
|
||||
import { SEARCH_PRIVATE_ACCOUNTS_REPOSITORY } from '~/domain/common/repositories/search-private-accounts.repository';
|
||||
import { SearchPrivateAccountsRepositoryImplementation } from './repositories/search-private-accounts-generator.repository';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
@@ -46,6 +48,10 @@ import { VarsRepositoryImplementation } from './repositories/vars-generator.repo
|
||||
provide: VARS_REPOSITORY,
|
||||
useClass: VarsRepositoryImplementation,
|
||||
},
|
||||
{
|
||||
provide: SEARCH_PRIVATE_ACCOUNTS_REPOSITORY,
|
||||
useClass: SearchPrivateAccountsRepositoryImplementation,
|
||||
},
|
||||
],
|
||||
exports: [
|
||||
ORGANIZATION_REPOSITORY,
|
||||
@@ -55,6 +61,7 @@ import { VarsRepositoryImplementation } from './repositories/vars-generator.repo
|
||||
DOCUMENT_REPOSITORY,
|
||||
PROJECT_FREE_DECISION_REPOSITORY,
|
||||
VARS_REPOSITORY,
|
||||
SEARCH_PRIVATE_ACCOUNTS_REPOSITORY,
|
||||
],
|
||||
})
|
||||
export class GeneratorRepositoriesModule {}
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SearchPrivateAccountsRepository } from '~/domain/common/repositories/search-private-accounts.repository';
|
||||
import type {
|
||||
SearchPrivateAccountsInputDomainInterface,
|
||||
PrivateAccountSearchResultDomainInterface,
|
||||
PrivateAccountSearchDataDomainInterface,
|
||||
} from '~/domain/common/interfaces/search-private-accounts-domain.interface';
|
||||
import type { IndividualDomainInterface } from '~/domain/common/interfaces/individual-domain.interface';
|
||||
import type { EntrepreneurDomainInterface } from '~/domain/common/interfaces/entrepreneur-domain.interface';
|
||||
import type { OrganizationDomainInterface } from '~/domain/common/interfaces/organization-domain.interface';
|
||||
import { generator } from '~/services/document.service';
|
||||
import type { ISearchResult } from '@coopenomics/factory';
|
||||
|
||||
/**
|
||||
* Инфраструктурная реализация репозитория поиска приватных аккаунтов
|
||||
* Использует фабрику документов для выполнения поиска
|
||||
*/
|
||||
@Injectable()
|
||||
export class SearchPrivateAccountsRepositoryImplementation implements SearchPrivateAccountsRepository {
|
||||
/**
|
||||
* Выполняет поиск приватных аккаунтов через фабрику документов
|
||||
* @param input Входные данные для поиска
|
||||
* @returns Массив результатов поиска, преобразованных в доменные интерфейсы
|
||||
*/
|
||||
async searchPrivateAccounts(
|
||||
input: SearchPrivateAccountsInputDomainInterface
|
||||
): Promise<PrivateAccountSearchResultDomainInterface[]> {
|
||||
// Вызываем метод поиска из фабрики документов
|
||||
const factoryResults: ISearchResult[] = await generator.search(input.query);
|
||||
|
||||
// Преобразуем результаты фабрики в доменные интерфейсы
|
||||
const domainResults: PrivateAccountSearchResultDomainInterface[] = factoryResults.map((result) =>
|
||||
this.convertFactoryResultToDomain(result)
|
||||
);
|
||||
|
||||
return domainResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразует результат из фабрики в доменный интерфейс
|
||||
* @param factoryResult Результат поиска из фабрики
|
||||
* @returns Результат поиска в доменном формате
|
||||
*/
|
||||
private convertFactoryResultToDomain(factoryResult: ISearchResult): PrivateAccountSearchResultDomainInterface {
|
||||
let domainData: PrivateAccountSearchDataDomainInterface;
|
||||
|
||||
switch (factoryResult.type) {
|
||||
case 'individual':
|
||||
domainData = this.convertIndividualData(factoryResult.data as any);
|
||||
break;
|
||||
case 'entrepreneur':
|
||||
domainData = this.convertEntrepreneurData(factoryResult.data as any);
|
||||
break;
|
||||
case 'organization':
|
||||
domainData = this.convertOrganizationData(factoryResult.data as any);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Неизвестный тип данных: ${factoryResult.type}`);
|
||||
}
|
||||
|
||||
return {
|
||||
type: factoryResult.type,
|
||||
data: domainData,
|
||||
score: factoryResult.score,
|
||||
highlightedFields: factoryResult.highlightedFields,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразует данные физического лица из фабрики в доменный интерфейс
|
||||
* @param data Данные из фабрики
|
||||
* @returns Доменный интерфейс физического лица
|
||||
*/
|
||||
private convertIndividualData(data: any): IndividualDomainInterface {
|
||||
return {
|
||||
username: data.username,
|
||||
first_name: data.first_name,
|
||||
last_name: data.last_name,
|
||||
middle_name: data.middle_name,
|
||||
birthdate: data.birthdate,
|
||||
full_address: data.full_address,
|
||||
phone: data.phone,
|
||||
email: data.email,
|
||||
passport: data.passport
|
||||
? {
|
||||
series: data.passport.series,
|
||||
number: data.passport.number,
|
||||
issued_by: data.passport.issued_by,
|
||||
issued_at: data.passport.issued_at,
|
||||
code: data.passport.code,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразует данные индивидуального предпринимателя из фабрики в доменный интерфейс
|
||||
* @param data Данные из фабрики
|
||||
* @returns Доменный интерфейс индивидуального предпринимателя
|
||||
*/
|
||||
private convertEntrepreneurData(data: any): EntrepreneurDomainInterface {
|
||||
return {
|
||||
username: data.username,
|
||||
first_name: data.first_name,
|
||||
last_name: data.last_name,
|
||||
middle_name: data.middle_name,
|
||||
birthdate: data.birthdate,
|
||||
phone: data.phone,
|
||||
email: data.email,
|
||||
country: data.country,
|
||||
city: data.city,
|
||||
full_address: data.full_address,
|
||||
details: {
|
||||
inn: data.details.inn,
|
||||
ogrn: data.details.ogrn,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Преобразует данные организации из фабрики в доменный интерфейс
|
||||
* @param data Данные из фабрики
|
||||
* @returns Доменный интерфейс организации
|
||||
*/
|
||||
private convertOrganizationData(data: any): OrganizationDomainInterface {
|
||||
return {
|
||||
username: data.username,
|
||||
type: data.type,
|
||||
short_name: data.short_name,
|
||||
full_name: data.full_name,
|
||||
represented_by: {
|
||||
first_name: data.represented_by.first_name,
|
||||
last_name: data.represented_by.last_name,
|
||||
middle_name: data.represented_by.middle_name,
|
||||
position: data.represented_by.position,
|
||||
based_on: data.represented_by.based_on,
|
||||
},
|
||||
country: data.country,
|
||||
city: data.city,
|
||||
full_address: data.full_address,
|
||||
fact_address: data.fact_address,
|
||||
phone: data.phone,
|
||||
email: data.email,
|
||||
details: {
|
||||
inn: data.details.inn,
|
||||
ogrn: data.details.ogrn,
|
||||
kpp: data.details.kpp,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { InputType, Field } from '@nestjs/graphql';
|
||||
import { IsString, IsNotEmpty } from 'class-validator';
|
||||
import type { SearchPrivateAccountsInputDomainInterface } from '~/domain/common/interfaces/search-private-accounts-domain.interface';
|
||||
|
||||
@InputType('SearchPrivateAccountsInput')
|
||||
export class SearchPrivateAccountsInputDTO implements SearchPrivateAccountsInputDomainInterface {
|
||||
@Field(() => String, { description: 'Поисковый запрос для поиска приватных аккаунтов' })
|
||||
@IsString({ message: 'Поисковый запрос должен быть строкой' })
|
||||
@IsNotEmpty({ message: 'Поисковый запрос не может быть пустым' })
|
||||
query!: string;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ObjectType, Field, createUnionType } from '@nestjs/graphql';
|
||||
import { IsString, IsArray, IsOptional, IsNumber } from 'class-validator';
|
||||
import { IndividualDTO } from '~/modules/common/dto/individual.dto';
|
||||
import { EntrepreneurDTO } from '~/modules/common/dto/entrepreneur.dto';
|
||||
import { OrganizationDTO } from '~/modules/common/dto/organization.dto';
|
||||
import type { PrivateAccountSearchResultDomainInterface } from '~/domain/common/interfaces/search-private-accounts-domain.interface';
|
||||
|
||||
// Создаем Union Type для GraphQL
|
||||
export const PrivateAccountSearchDataUnion = createUnionType({
|
||||
name: 'PrivateAccountSearchData',
|
||||
types: () => [IndividualDTO, EntrepreneurDTO, OrganizationDTO] as const,
|
||||
resolveType: (value) => {
|
||||
if (IndividualDTO.isTypeOf(value)) {
|
||||
return IndividualDTO;
|
||||
}
|
||||
if (EntrepreneurDTO.isTypeOf(value)) {
|
||||
return EntrepreneurDTO;
|
||||
}
|
||||
if (OrganizationDTO.isTypeOf(value)) {
|
||||
return OrganizationDTO;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
@ObjectType('PrivateAccountSearchResult')
|
||||
export class PrivateAccountSearchResultDTO {
|
||||
@Field(() => String, { description: 'Тип аккаунта' })
|
||||
@IsString()
|
||||
type!: 'individual' | 'entrepreneur' | 'organization';
|
||||
|
||||
@Field(() => PrivateAccountSearchDataUnion, { description: 'Данные найденного аккаунта' })
|
||||
data!: IndividualDTO | EntrepreneurDTO | OrganizationDTO;
|
||||
|
||||
@Field(() => Number, { nullable: true, description: 'Оценка релевантности результата' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
score?: number;
|
||||
|
||||
@Field(() => [String], { nullable: true, description: 'Поля, в которых найдены совпадения' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
highlightedFields?: string[];
|
||||
|
||||
constructor(entity: PrivateAccountSearchResultDomainInterface) {
|
||||
this.type = entity.type;
|
||||
this.score = entity.score;
|
||||
this.highlightedFields = entity.highlightedFields;
|
||||
|
||||
// Преобразуем доменные данные в соответствующий DTO
|
||||
switch (entity.type) {
|
||||
case 'individual':
|
||||
this.data = new IndividualDTO(entity.data as any);
|
||||
break;
|
||||
case 'entrepreneur':
|
||||
this.data = new EntrepreneurDTO(entity.data as any);
|
||||
break;
|
||||
case 'organization':
|
||||
this.data = new OrganizationDTO(entity.data as any);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Неизвестный тип аккаунта: ${entity.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import type { PaginationResultDomainInterface } from '~/domain/common/interfaces
|
||||
import { RegisterAccountInputDTO } from '../dto/register-account-input.dto';
|
||||
import { RegisteredAccountDTO } from '../dto/registered-account.dto';
|
||||
import { UpdateAccountInputDTO } from '../dto/update-account-input.dto';
|
||||
import { SearchPrivateAccountsInputDTO } from '../dto/search-private-accounts-input.dto';
|
||||
import { PrivateAccountSearchResultDTO } from '../dto/search-private-accounts-result.dto';
|
||||
|
||||
export const AccountsPaginationResult = createPaginationResult(AccountDTO, 'Accounts');
|
||||
|
||||
@@ -40,6 +42,19 @@ export class AccountResolver {
|
||||
return await this.accountService.getAccounts(data, options);
|
||||
}
|
||||
|
||||
@Query(() => [PrivateAccountSearchResultDTO], {
|
||||
name: 'searchPrivateAccounts',
|
||||
description:
|
||||
'Поиск приватных данных аккаунтов по запросу. Поиск осуществляется по полям ФИО, ИНН, ОГРН, наименованию организации и другим приватным данным.',
|
||||
})
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard)
|
||||
@AuthRoles(['chairman', 'member'])
|
||||
async searchPrivateAccounts(
|
||||
@Args('data', { type: () => SearchPrivateAccountsInputDTO }) data: SearchPrivateAccountsInputDTO
|
||||
): Promise<PrivateAccountSearchResultDTO[]> {
|
||||
return this.accountService.searchPrivateAccounts(data);
|
||||
}
|
||||
|
||||
@Mutation(() => RegisteredAccountDTO, {
|
||||
name: 'registerAccount',
|
||||
description: 'Зарегистрировать аккаунт пользователя в системе',
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { RegisterAccountInputDTO } from '../dto/register-account-input.dto'
|
||||
import { RegisteredAccountDTO } from '../dto/registered-account.dto';
|
||||
import type { DeleteAccountInputDTO } from '../dto/delete-account-input.dto';
|
||||
import type { UpdateAccountInputDTO } from '../dto/update-account-input.dto';
|
||||
import type { SearchPrivateAccountsInputDTO } from '../dto/search-private-accounts-input.dto';
|
||||
import { PrivateAccountSearchResultDTO } from '../dto/search-private-accounts-result.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AccountService {
|
||||
@@ -44,4 +46,15 @@ export class AccountService {
|
||||
const result = await this.accountDomainInteractor.registerAccount(data);
|
||||
return new RegisteredAccountDTO(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск приватных аккаунтов
|
||||
* @param data Входные данные для поиска
|
||||
* @returns Массив результатов поиска
|
||||
*/
|
||||
public async searchPrivateAccounts(data: SearchPrivateAccountsInputDTO): Promise<PrivateAccountSearchResultDTO[]> {
|
||||
const results = await this.accountDomainInteractor.searchPrivateAccounts(data);
|
||||
|
||||
return results.map((result) => new PrivateAccountSearchResultDTO(result));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# Функциональность поиска в фабрике документов
|
||||
|
||||
## Описание
|
||||
|
||||
В фабрику документов добавлена функциональность поиска по полям пользователей. Поиск осуществляется по трем типам данных:
|
||||
- **individual** - физические лица
|
||||
- **entrepreneur** - индивидуальные предприниматели
|
||||
- **organization** - организации
|
||||
|
||||
## Архитектура
|
||||
|
||||
Функциональность поиска реализована в виде отдельного сервиса `SearchService`, расположенного в `src/Services/Databazor/SearchService.ts`. Это обеспечивает:
|
||||
- Чистое разделение ответственности
|
||||
- Возможность независимого тестирования
|
||||
- Переиспользование в других частях приложения
|
||||
|
||||
## API
|
||||
|
||||
### Метод search
|
||||
|
||||
```typescript
|
||||
async search(query: string): Promise<ISearchResult[]>
|
||||
```
|
||||
|
||||
**Параметры:**
|
||||
- `query` - строка для поиска
|
||||
|
||||
**Возвращает:**
|
||||
- Массив результатов поиска (максимум 10 элементов)
|
||||
|
||||
### Интерфейс ISearchResult
|
||||
|
||||
```typescript
|
||||
interface ISearchResult {
|
||||
type: 'individual' | 'entrepreneur' | 'organization'
|
||||
data: ExternalIndividualData | ExternalEntrepreneurData | ExternalOrganizationData
|
||||
score?: number // для будущего ранжирования
|
||||
highlightedFields?: string[] // поля, в которых найдено совпадение
|
||||
}
|
||||
```
|
||||
|
||||
## Поля для поиска
|
||||
|
||||
### Физические лица (individual)
|
||||
- `first_name` - имя
|
||||
- `last_name` - фамилия
|
||||
- `middle_name` - отчество
|
||||
- **Полное ФИО** - поиск по комбинации полей (например, "Иванов Иван Иванович")
|
||||
|
||||
### Индивидуальные предприниматели (entrepreneur)
|
||||
- `first_name` - имя
|
||||
- `last_name` - фамилия
|
||||
- `middle_name` - отчество
|
||||
- **Полное ФИО** - поиск по комбинации полей
|
||||
- `details.inn` - ИНН
|
||||
- `details.ogrn` - ОГРН
|
||||
|
||||
### Организации (organization)
|
||||
- `short_name` - краткое наименование
|
||||
- `details.inn` - ИНН
|
||||
- `details.ogrn` - ОГРН
|
||||
|
||||
## Особенности поиска
|
||||
|
||||
1. **Регистронезависимый поиск** - поиск выполняется без учета регистра
|
||||
2. **Поиск по подстроке** - находит частичные совпадения в полях
|
||||
3. **Поиск по полному ФИО** - ✅ **НОВОЕ!** Поддерживает поиск по нескольким словам (например, "Иванов Иван Иванович")
|
||||
4. **Интеллектуальное разбиение запроса** - автоматически разбивает запрос на слова для поиска по ФИО
|
||||
5. **Ограничение результатов** - возвращает максимум 10 результатов
|
||||
6. **Подсветка совпадений** - в `highlightedFields` указываются поля, где найдено совпадение (включая `full_name`)
|
||||
7. **Фильтрация удаленных записей** - поиск не включает записи с `deleted: true`
|
||||
|
||||
## Пример использования
|
||||
|
||||
```typescript
|
||||
import { Generator } from '@coopenomics/factory'
|
||||
|
||||
const generator = new Generator()
|
||||
await generator.connect('mongodb://localhost:27017/mydatabase')
|
||||
|
||||
// Поиск по имени
|
||||
const results = await generator.search('Иван')
|
||||
|
||||
// Поиск по полному ФИО - теперь работает!
|
||||
const fullNameResults = await generator.search('Иванов Иван Иванович')
|
||||
|
||||
console.log(fullNameResults)
|
||||
// [
|
||||
// {
|
||||
// type: 'individual',
|
||||
// data: { username: 'ivan123', first_name: 'Иван', last_name: 'Иванов', middle_name: 'Иванович', ... },
|
||||
// highlightedFields: ['full_name', 'first_name', 'last_name', 'middle_name']
|
||||
// }
|
||||
// ]
|
||||
|
||||
// Поиск по частичному ФИО
|
||||
const partialResults = await generator.search('Петров Петр')
|
||||
|
||||
// Поиск по ИНН
|
||||
const innResults = await generator.search('1234567890')
|
||||
|
||||
// Поиск по названию организации
|
||||
const orgResults = await generator.search('ООО Рога и копыта')
|
||||
```
|
||||
|
||||
## Алгоритм поиска по полному ФИО
|
||||
|
||||
1. **Разбиение запроса** - запрос разбивается на отдельные слова
|
||||
2. **Поиск в MongoDB** - каждое слово ищется во всех полях ФИО через оператор `$and`
|
||||
3. **Дополнительная проверка** - результаты дополнительно фильтруются на уровне приложения
|
||||
4. **Подсветка** - в `highlightedFields` добавляется `full_name` при совпадении
|
||||
|
||||
## Архитектурные улучшения
|
||||
|
||||
- ✅ **SearchService** вынесен в отдельный класс
|
||||
- ✅ **Чистая архитектура** - разделение ответственности
|
||||
- ✅ **Переиспользование** - сервис можно использовать независимо
|
||||
- ✅ **Тестируемость** - легко писать unit-тесты для SearchService
|
||||
|
||||
## Тестирование
|
||||
|
||||
Функциональность покрыта комплексными тестами в файле `test/search.test.ts`, включающими:
|
||||
|
||||
- Поиск по всем типам полей
|
||||
- **Поиск по полному ФИО** ("Иванов Иван Иванович")
|
||||
- **Поиск по частичному ФИО** ("Петров Петр")
|
||||
- Обработка пустых запросов
|
||||
- Ограничение количества результатов
|
||||
- Подсветка найденных полей
|
||||
- Очистка тестовых данных
|
||||
|
||||
Запуск тестов:
|
||||
```bash
|
||||
npm test -- search.test.ts
|
||||
```
|
||||
|
||||
## Преимущества нового подхода
|
||||
|
||||
1. **Поиск по полному ФИО работает!** - решена основная проблема
|
||||
2. **Лучшая архитектура** - код организован в отдельном сервисе
|
||||
3. **Расширяемость** - легко добавлять новые типы поиска
|
||||
4. **Производительность** - оптимизированные MongoDB запросы
|
||||
5. **Удобство использования** - простой API без изменений для пользователя
|
||||
@@ -0,0 +1,242 @@
|
||||
import type { Filter } from 'mongodb'
|
||||
import type { ExternalEntrepreneurData, ExternalIndividualData, ExternalOrganizationData } from '../../Models'
|
||||
import { Entrepreneur, Individual, Organization } from '../../Models'
|
||||
import type { MongoDBConnector } from './MongoDBConnector'
|
||||
|
||||
export interface ISearchResult {
|
||||
type: 'individual' | 'entrepreneur' | 'organization'
|
||||
data: ExternalIndividualData | ExternalEntrepreneurData | ExternalOrganizationData
|
||||
score?: number // для будущего ранжирования
|
||||
highlightedFields?: string[] // поля, в которых найдено совпадение
|
||||
}
|
||||
|
||||
export class SearchService {
|
||||
private storage: MongoDBConnector
|
||||
|
||||
constructor(storage: MongoDBConnector) {
|
||||
this.storage = storage
|
||||
}
|
||||
|
||||
async search(query: string): Promise<ISearchResult[]> {
|
||||
if (!query || query.trim().length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const results: ISearchResult[] = []
|
||||
const trimmedQuery = query.trim()
|
||||
|
||||
// Создаем регулярное выражение для поиска без учета регистра
|
||||
const regex = new RegExp(trimmedQuery, 'i')
|
||||
|
||||
// Разбиваем запрос на слова для поиска по полному ФИО
|
||||
const queryWords = trimmedQuery.split(/\s+/).filter(word => word.length > 0)
|
||||
|
||||
try {
|
||||
// Поиск в коллекции individuals
|
||||
const individualResults = await this.searchIndividuals(regex, queryWords)
|
||||
results.push(...individualResults)
|
||||
|
||||
// Поиск в коллекции entrepreneurs
|
||||
const entrepreneurResults = await this.searchEntrepreneurs(regex, queryWords)
|
||||
results.push(...entrepreneurResults)
|
||||
|
||||
// Поиск в коллекции organizations
|
||||
const organizationResults = await this.searchOrganizations(regex)
|
||||
results.push(...organizationResults)
|
||||
|
||||
// Ограничиваем общее количество результатов до 10
|
||||
return results.slice(0, 10)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error during search:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async searchIndividuals(regex: RegExp, queryWords: string[]): Promise<ISearchResult[]> {
|
||||
const individualModel = new Individual(this.storage)
|
||||
const results: ISearchResult[] = []
|
||||
|
||||
// Создаем два отдельных запроса для лучшей типизации
|
||||
let filter: any
|
||||
|
||||
if (queryWords.length > 1) {
|
||||
// Комбинированный фильтр для поиска по отдельным полям И по полному ФИО
|
||||
filter = {
|
||||
deleted: false,
|
||||
$or: [
|
||||
// Поиск по отдельным полям
|
||||
{ first_name: { $regex: regex } },
|
||||
{ last_name: { $regex: regex } },
|
||||
{ middle_name: { $regex: regex } },
|
||||
// Поиск по полному ФИО (все слова должны быть найдены)
|
||||
{
|
||||
$and: queryWords.map(word => ({
|
||||
$or: [
|
||||
{ first_name: { $regex: new RegExp(word, 'i') } },
|
||||
{ last_name: { $regex: new RegExp(word, 'i') } },
|
||||
{ middle_name: { $regex: new RegExp(word, 'i') } },
|
||||
],
|
||||
})),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Простой фильтр для одного слова
|
||||
filter = {
|
||||
deleted: false,
|
||||
$or: [
|
||||
{ first_name: { $regex: regex } },
|
||||
{ last_name: { $regex: regex } },
|
||||
{ middle_name: { $regex: regex } },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const individuals = await individualModel.getMany(filter)
|
||||
|
||||
for (const individual of individuals.results) {
|
||||
const highlightedFields = []
|
||||
|
||||
// Проверяем отдельные поля
|
||||
if (regex.test(individual.first_name))
|
||||
highlightedFields.push('first_name')
|
||||
if (regex.test(individual.last_name))
|
||||
highlightedFields.push('last_name')
|
||||
if (regex.test(individual.middle_name))
|
||||
highlightedFields.push('middle_name')
|
||||
|
||||
// Проверяем полное ФИО
|
||||
const fullName = `${individual.last_name} ${individual.first_name} ${individual.middle_name}`.trim()
|
||||
if (this.matchesFullName(fullName, queryWords)) {
|
||||
highlightedFields.push('full_name')
|
||||
}
|
||||
|
||||
results.push({
|
||||
type: 'individual',
|
||||
data: individual,
|
||||
highlightedFields,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private async searchEntrepreneurs(regex: RegExp, queryWords: string[]): Promise<ISearchResult[]> {
|
||||
const entrepreneurModel = new Entrepreneur(this.storage)
|
||||
const results: ISearchResult[] = []
|
||||
|
||||
// Создаем фильтр с правильной типизацией
|
||||
let filter: any
|
||||
|
||||
if (queryWords.length > 1) {
|
||||
// Комбинированный фильтр
|
||||
filter = {
|
||||
deleted: false,
|
||||
$or: [
|
||||
// Поиск по отдельным полям
|
||||
{ first_name: { $regex: regex } },
|
||||
{ last_name: { $regex: regex } },
|
||||
{ middle_name: { $regex: regex } },
|
||||
{ 'details.inn': { $regex: regex } },
|
||||
{ 'details.ogrn': { $regex: regex } },
|
||||
// Поиск по полному ФИО
|
||||
{
|
||||
$and: queryWords.map(word => ({
|
||||
$or: [
|
||||
{ first_name: { $regex: new RegExp(word, 'i') } },
|
||||
{ last_name: { $regex: new RegExp(word, 'i') } },
|
||||
{ middle_name: { $regex: new RegExp(word, 'i') } },
|
||||
],
|
||||
})),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Простой фильтр
|
||||
filter = {
|
||||
deleted: false,
|
||||
$or: [
|
||||
{ first_name: { $regex: regex } },
|
||||
{ last_name: { $regex: regex } },
|
||||
{ middle_name: { $regex: regex } },
|
||||
{ 'details.inn': { $regex: regex } },
|
||||
{ 'details.ogrn': { $regex: regex } },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const entrepreneurs = await entrepreneurModel.getMany(filter)
|
||||
|
||||
for (const entrepreneur of entrepreneurs.results) {
|
||||
const highlightedFields = []
|
||||
|
||||
// Проверяем отдельные поля
|
||||
if (regex.test(entrepreneur.first_name))
|
||||
highlightedFields.push('first_name')
|
||||
if (regex.test(entrepreneur.last_name))
|
||||
highlightedFields.push('last_name')
|
||||
if (regex.test(entrepreneur.middle_name))
|
||||
highlightedFields.push('middle_name')
|
||||
if (entrepreneur.details?.inn && regex.test(entrepreneur.details.inn))
|
||||
highlightedFields.push('details.inn')
|
||||
if (entrepreneur.details?.ogrn && regex.test(entrepreneur.details.ogrn))
|
||||
highlightedFields.push('details.ogrn')
|
||||
|
||||
// Проверяем полное ФИО
|
||||
const fullName = `${entrepreneur.last_name} ${entrepreneur.first_name} ${entrepreneur.middle_name}`.trim()
|
||||
if (this.matchesFullName(fullName, queryWords)) {
|
||||
highlightedFields.push('full_name')
|
||||
}
|
||||
|
||||
results.push({
|
||||
type: 'entrepreneur',
|
||||
data: entrepreneur,
|
||||
highlightedFields,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private async searchOrganizations(regex: RegExp): Promise<ISearchResult[]> {
|
||||
const organizationModel = new Organization(this.storage)
|
||||
const results: ISearchResult[] = []
|
||||
|
||||
const organizations = await organizationModel.getMany({
|
||||
deleted: false,
|
||||
$or: [
|
||||
{ short_name: { $regex: regex } },
|
||||
{ 'details.inn': { $regex: regex } },
|
||||
{ 'details.ogrn': { $regex: regex } },
|
||||
],
|
||||
})
|
||||
|
||||
for (const organization of organizations.results) {
|
||||
const highlightedFields = []
|
||||
|
||||
if (regex.test(organization.short_name))
|
||||
highlightedFields.push('short_name')
|
||||
if (organization.details?.inn && regex.test(organization.details.inn))
|
||||
highlightedFields.push('details.inn')
|
||||
if (organization.details?.ogrn && regex.test(organization.details.ogrn))
|
||||
highlightedFields.push('details.ogrn')
|
||||
|
||||
results.push({
|
||||
type: 'organization',
|
||||
data: organization as ExternalOrganizationData,
|
||||
highlightedFields,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private matchesFullName(fullName: string, queryWords: string[]): boolean {
|
||||
// Проверяем, содержит ли полное ФИО все слова из запроса
|
||||
const fullNameLower = fullName.toLowerCase()
|
||||
return queryWords.every(word => fullNameLower.includes(word.toLowerCase()))
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './DataService'
|
||||
export * from './MongoDBConnector'
|
||||
export { MongoDBConnector } from './MongoDBConnector'
|
||||
export { default as DataService } from './DataService'
|
||||
export { SearchService, type ISearchResult } from './SearchService'
|
||||
|
||||
@@ -8,8 +8,7 @@ import type { IFilterDocuments, IGeneratedDocument, Numbers, externalDataTypes,
|
||||
import type { IGenerate, IGenerationOptions } from './Interfaces/Documents'
|
||||
import * as Actions from './Actions'
|
||||
|
||||
|
||||
import { MongoDBConnector } from './Services/Databazor'
|
||||
import { type ISearchResult, MongoDBConnector, SearchService } from './Services/Databazor'
|
||||
import type { ExternalIndividualData } from './Models/Individual'
|
||||
import { Individual } from './Models/Individual'
|
||||
import type { ExternalEntrepreneurData, ExternalOrganizationData, IVars } from './Models'
|
||||
@@ -29,6 +28,9 @@ export type { ExternalEntrepreneurData as IEntrepreneurData } from './Models'
|
||||
export type { CooperativeData as ICooperativeData } from './Models'
|
||||
export type { ExternalProjectData as IExternalProjectData } from './Models'
|
||||
|
||||
// Экспортируем интерфейс результатов поиска
|
||||
export type { ISearchResult } from './Services/Databazor'
|
||||
|
||||
export interface IGenerator {
|
||||
connect: (mongoUri: string) => Promise<void>
|
||||
disconnect: () => Promise<void>
|
||||
@@ -43,6 +45,9 @@ export interface IGenerator {
|
||||
list: (type: dataTypes, filter: Filter<internalFilterTypes>) => Promise<CooperativeModel.Document.IGetResponse<internalFilterTypes>>
|
||||
|
||||
getHistory: (type: dataTypes, filter: Filter<internalFilterTypes>) => Promise<externalDataTypesArrays>
|
||||
|
||||
// Новый метод поиска
|
||||
search: (query: string) => Promise<ISearchResult[]>
|
||||
}
|
||||
|
||||
export class Generator implements IGenerator {
|
||||
@@ -54,10 +59,16 @@ export class Generator implements IGenerator {
|
||||
// Определение хранилища
|
||||
public storage!: MongoDBConnector
|
||||
|
||||
// Сервис поиска
|
||||
private searchService!: SearchService
|
||||
|
||||
// Метод подключения к хранилищу
|
||||
async connect(mongoUri: string): Promise<void> {
|
||||
this.storage = new MongoDBConnector(mongoUri)
|
||||
|
||||
// Инициализация сервиса поиска
|
||||
this.searchService = new SearchService(this.storage)
|
||||
|
||||
// Инициализация фабрик документов
|
||||
this.factories = {
|
||||
[Actions.WalletAgreement.Template.registry_id]: new Actions.WalletAgreement.Factory(this.storage), // 1
|
||||
@@ -177,4 +188,9 @@ export class Generator implements IGenerator {
|
||||
async constructCooperative(username: string, block_num?: number): Promise<CooperativeData | null> {
|
||||
return new Cooperative(this.storage).getOne(username, block_num)
|
||||
}
|
||||
|
||||
// Новый метод поиска
|
||||
async search(query: string): Promise<ISearchResult[]> {
|
||||
return this.searchService.search(query)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
import { Generator } from '../src'
|
||||
import type { ExternalEntrepreneurData, ExternalIndividualData, ExternalOrganizationData } from '../src/Models'
|
||||
import { mongoUri } from './utils'
|
||||
|
||||
const generator = new Generator()
|
||||
|
||||
beforeAll(async () => {
|
||||
await generator.connect(mongoUri)
|
||||
})
|
||||
|
||||
describe('тест поиска в фабрике документов', () => {
|
||||
it('подготовка тестовых данных', async () => {
|
||||
// Создаем тестовые данные для поиска
|
||||
const testIndividual: ExternalIndividualData = {
|
||||
username: 'search_test_individual',
|
||||
first_name: 'Иван',
|
||||
last_name: 'Иванов',
|
||||
middle_name: 'Иванович',
|
||||
birthdate: '01.01.1980',
|
||||
full_address: 'г. Москва, ул. Тестовая, д. 1',
|
||||
phone: '+7-900-123-45-67',
|
||||
email: 'ivan.ivanov@test.com',
|
||||
deleted: false,
|
||||
}
|
||||
|
||||
const testEntrepreneur: ExternalEntrepreneurData = {
|
||||
username: 'search_test_entrepreneur',
|
||||
first_name: 'Петр',
|
||||
last_name: 'Петров',
|
||||
middle_name: 'Петрович',
|
||||
birthdate: '01.01.1985',
|
||||
full_address: 'г. Москва, ул. Предпринимательская, д. 2',
|
||||
phone: '+7-900-987-65-43',
|
||||
email: 'petr.petrov@test.com',
|
||||
country: 'Россия',
|
||||
city: 'Москва',
|
||||
details: {
|
||||
inn: '123456789012',
|
||||
ogrn: '1234567890123',
|
||||
},
|
||||
deleted: false,
|
||||
}
|
||||
|
||||
const testOrganization: ExternalOrganizationData = {
|
||||
username: 'search_test_organization',
|
||||
type: 'coop',
|
||||
short_name: 'ООО "Тестовая Организация"',
|
||||
full_name: 'Общество с ограниченной ответственностью "Тестовая Организация"',
|
||||
represented_by: {
|
||||
first_name: 'Сидор',
|
||||
last_name: 'Сидоров',
|
||||
middle_name: 'Сидорович',
|
||||
position: 'Директор',
|
||||
based_on: 'Устав',
|
||||
},
|
||||
country: 'Россия',
|
||||
city: 'Москва',
|
||||
full_address: 'г. Москва, ул. Организационная, д. 3',
|
||||
fact_address: 'г. Москва, ул. Организационная, д. 3',
|
||||
phone: '+7-900-555-55-55',
|
||||
email: 'info@testorg.com',
|
||||
details: {
|
||||
inn: '9876543210',
|
||||
ogrn: '1234567890987',
|
||||
kpp: '123456789',
|
||||
},
|
||||
deleted: false,
|
||||
}
|
||||
|
||||
// Сохраняем тестовые данные
|
||||
await generator.save('individual', testIndividual)
|
||||
await generator.save('entrepreneur', testEntrepreneur)
|
||||
await generator.save('organization', testOrganization)
|
||||
})
|
||||
|
||||
it('поиск по имени физического лица', async () => {
|
||||
const results = await generator.search('Иван')
|
||||
|
||||
expect(results).toBeDefined()
|
||||
expect(Array.isArray(results)).toBe(true)
|
||||
|
||||
const individualResults = results.filter(r => r.type === 'individual')
|
||||
expect(individualResults.length).toBeGreaterThan(0)
|
||||
|
||||
const foundIndividual = individualResults.find(r =>
|
||||
(r.data as ExternalIndividualData).username === 'search_test_individual',
|
||||
)
|
||||
expect(foundIndividual).toBeDefined()
|
||||
expect(foundIndividual?.highlightedFields).toContain('first_name')
|
||||
})
|
||||
|
||||
it('поиск по фамилии', async () => {
|
||||
const results = await generator.search('Петров')
|
||||
|
||||
expect(results).toBeDefined()
|
||||
expect(Array.isArray(results)).toBe(true)
|
||||
|
||||
const entrepreneurResults = results.filter(r => r.type === 'entrepreneur')
|
||||
expect(entrepreneurResults.length).toBeGreaterThan(0)
|
||||
|
||||
const foundEntrepreneur = entrepreneurResults.find(r =>
|
||||
(r.data as ExternalEntrepreneurData).username === 'search_test_entrepreneur',
|
||||
)
|
||||
expect(foundEntrepreneur).toBeDefined()
|
||||
expect(foundEntrepreneur?.highlightedFields).toContain('last_name')
|
||||
})
|
||||
|
||||
it('поиск по ИНН предпринимателя', async () => {
|
||||
const results = await generator.search('123456789012')
|
||||
|
||||
expect(results).toBeDefined()
|
||||
expect(Array.isArray(results)).toBe(true)
|
||||
|
||||
const entrepreneurResults = results.filter(r => r.type === 'entrepreneur')
|
||||
expect(entrepreneurResults.length).toBeGreaterThan(0)
|
||||
|
||||
const foundEntrepreneur = entrepreneurResults.find(r =>
|
||||
(r.data as ExternalEntrepreneurData).username === 'search_test_entrepreneur',
|
||||
)
|
||||
expect(foundEntrepreneur).toBeDefined()
|
||||
expect(foundEntrepreneur?.highlightedFields).toContain('details.inn')
|
||||
})
|
||||
|
||||
it('поиск по названию организации', async () => {
|
||||
const results = await generator.search('Тестовая Организация')
|
||||
|
||||
expect(results).toBeDefined()
|
||||
expect(Array.isArray(results)).toBe(true)
|
||||
|
||||
const organizationResults = results.filter(r => r.type === 'organization')
|
||||
expect(organizationResults.length).toBeGreaterThan(0)
|
||||
|
||||
const foundOrganization = organizationResults.find(r =>
|
||||
(r.data as ExternalOrganizationData).username === 'search_test_organization',
|
||||
)
|
||||
expect(foundOrganization).toBeDefined()
|
||||
expect(foundOrganization?.highlightedFields).toContain('short_name')
|
||||
})
|
||||
|
||||
it('поиск по ИНН организации', async () => {
|
||||
const results = await generator.search('9876543210')
|
||||
|
||||
expect(results).toBeDefined()
|
||||
expect(Array.isArray(results)).toBe(true)
|
||||
|
||||
const organizationResults = results.filter(r => r.type === 'organization')
|
||||
expect(organizationResults.length).toBeGreaterThan(0)
|
||||
|
||||
const foundOrganization = organizationResults.find(r =>
|
||||
(r.data as ExternalOrganizationData).username === 'search_test_organization',
|
||||
)
|
||||
expect(foundOrganization).toBeDefined()
|
||||
expect(foundOrganization?.highlightedFields).toContain('details.inn')
|
||||
})
|
||||
|
||||
it('поиск по ОГРН', async () => {
|
||||
const results = await generator.search('1234567890123')
|
||||
|
||||
expect(results).toBeDefined()
|
||||
expect(Array.isArray(results)).toBe(true)
|
||||
|
||||
const entrepreneurResults = results.filter(r => r.type === 'entrepreneur')
|
||||
expect(entrepreneurResults.length).toBeGreaterThan(0)
|
||||
|
||||
const foundEntrepreneur = entrepreneurResults.find(r =>
|
||||
(r.data as ExternalEntrepreneurData).username === 'search_test_entrepreneur',
|
||||
)
|
||||
expect(foundEntrepreneur).toBeDefined()
|
||||
expect(foundEntrepreneur?.highlightedFields).toContain('details.ogrn')
|
||||
})
|
||||
|
||||
it('поиск по полному ФИО', async () => {
|
||||
// Теперь поиск по полному ФИО должен работать!
|
||||
const results = await generator.search('Иванов Иван Иванович')
|
||||
|
||||
expect(results).toBeDefined()
|
||||
expect(Array.isArray(results)).toBe(true)
|
||||
|
||||
const individualResults = results.filter(r => r.type === 'individual')
|
||||
expect(individualResults.length).toBeGreaterThan(0)
|
||||
|
||||
const foundIndividual = individualResults.find(r =>
|
||||
(r.data as ExternalIndividualData).username === 'search_test_individual',
|
||||
)
|
||||
expect(foundIndividual).toBeDefined()
|
||||
expect(foundIndividual?.highlightedFields).toContain('full_name')
|
||||
})
|
||||
|
||||
it('поиск по частичному ФИО (два слова)', async () => {
|
||||
const results = await generator.search('Петров Петр')
|
||||
|
||||
expect(results).toBeDefined()
|
||||
expect(Array.isArray(results)).toBe(true)
|
||||
|
||||
const entrepreneurResults = results.filter(r => r.type === 'entrepreneur')
|
||||
expect(entrepreneurResults.length).toBeGreaterThan(0)
|
||||
|
||||
const foundEntrepreneur = entrepreneurResults.find(r =>
|
||||
(r.data as ExternalEntrepreneurData).username === 'search_test_entrepreneur',
|
||||
)
|
||||
expect(foundEntrepreneur).toBeDefined()
|
||||
expect(foundEntrepreneur?.highlightedFields).toContain('full_name')
|
||||
})
|
||||
|
||||
it('поиск с пустым запросом', async () => {
|
||||
const results = await generator.search('')
|
||||
expect(results).toEqual([])
|
||||
|
||||
const results2 = await generator.search(' ')
|
||||
expect(results2).toEqual([])
|
||||
})
|
||||
|
||||
it('поиск несуществующих данных', async () => {
|
||||
const results = await generator.search('НесуществующийЗапрос12345')
|
||||
|
||||
expect(results).toBeDefined()
|
||||
expect(Array.isArray(results)).toBe(true)
|
||||
expect(results.length).toBe(0)
|
||||
})
|
||||
|
||||
it('ограничение количества результатов', async () => {
|
||||
const results = await generator.search('test') // Поиск общего термина
|
||||
|
||||
expect(results).toBeDefined()
|
||||
expect(Array.isArray(results)).toBe(true)
|
||||
expect(results.length).toBeLessThanOrEqual(10) // Не больше 10 результатов
|
||||
})
|
||||
|
||||
it('очистка тестовых данных', async () => {
|
||||
// Удаляем тестовые данные
|
||||
await generator.del('individual', { username: 'search_test_individual' })
|
||||
await generator.del('entrepreneur', { username: 'search_test_entrepreneur' })
|
||||
await generator.del('organization', { username: 'search_test_organization' })
|
||||
})
|
||||
})
|
||||
@@ -4,4 +4,7 @@
|
||||
export * as GetAccount from './getAccount'
|
||||
|
||||
/** Получить сводную информацию о аккаунтах системы */
|
||||
export * as GetAccounts from './getAccounts'
|
||||
export * as GetAccounts from './getAccounts'
|
||||
|
||||
/** Поиск приватных данных аккаунтов по запросу */
|
||||
export * as SearchPrivateAccounts from './searchPrivateAccounts'
|
||||
@@ -0,0 +1,22 @@
|
||||
import { privateAccountSearchResultSelector } from '../../selectors/accounts/searchPrivateAccountsResult'
|
||||
import { $, type GraphQLTypes, type InputType, type ModelTypes, Selector } from '../../zeus/index'
|
||||
|
||||
export const name = 'searchPrivateAccounts'
|
||||
|
||||
/**
|
||||
* Поиск приватных данных аккаунтов по запросу
|
||||
*/
|
||||
export const query = Selector('Query')({
|
||||
[name]: [{ data: $('data', 'SearchPrivateAccountsInput!') }, privateAccountSearchResultSelector],
|
||||
})
|
||||
|
||||
export interface IInput {
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
[key: string]: unknown
|
||||
|
||||
data: ModelTypes['SearchPrivateAccountsInput']
|
||||
}
|
||||
|
||||
export type IOutput = InputType<GraphQLTypes['Query'], typeof query>
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { MakeAllFieldsRequired } from '../../utils/MakeAllFieldsRequired'
|
||||
import { Selector, type ValueTypes } from '../../zeus/index'
|
||||
import { rawEntrepreneurSelector, rawIndividualSelector, rawOrganizationSelector } from '../common'
|
||||
import { rawAccountSelector } from './accountSelector'
|
||||
|
||||
|
||||
export const rawSearchPrivateAccountsResultSelector = {
|
||||
type: true,
|
||||
score: true,
|
||||
highlightedFields: true,
|
||||
data: {
|
||||
'...on Individual': rawIndividualSelector,
|
||||
'...on Entrepreneur': rawEntrepreneurSelector,
|
||||
'...on Organization': rawOrganizationSelector,
|
||||
},
|
||||
}
|
||||
|
||||
// Проверка валидности
|
||||
const _validate: MakeAllFieldsRequired<ValueTypes['PrivateAccountSearchResult']> = rawSearchPrivateAccountsResultSelector
|
||||
|
||||
export const privateAccountSearchResultSelector = Selector('PrivateAccountSearchResult')(
|
||||
rawSearchPrivateAccountsResultSelector,
|
||||
)
|
||||
@@ -564,6 +564,9 @@ export const AllTypesProps: Record<string,any> = {
|
||||
getPayments:{
|
||||
data:"GetPaymentsInput",
|
||||
options:"PaginationInput"
|
||||
},
|
||||
searchPrivateAccounts:{
|
||||
data:"SearchPrivateAccountsInput"
|
||||
}
|
||||
},
|
||||
ReceiveOnRequestInput:{
|
||||
@@ -618,6 +621,9 @@ export const AllTypesProps: Record<string,any> = {
|
||||
},
|
||||
ReturnByAssetStatementSignedMetaDocumentInput:{
|
||||
request:"CommonRequestInput"
|
||||
},
|
||||
SearchPrivateAccountsInput:{
|
||||
|
||||
},
|
||||
SelectBranchGenerateDocumentInput:{
|
||||
|
||||
@@ -1356,6 +1362,17 @@ export const ReturnTypes: Record<string,any> = {
|
||||
organization_data:"Organization",
|
||||
type:"AccountType"
|
||||
},
|
||||
PrivateAccountSearchData:{
|
||||
"...on Entrepreneur":"Entrepreneur",
|
||||
"...on Individual":"Individual",
|
||||
"...on Organization":"Organization"
|
||||
},
|
||||
PrivateAccountSearchResult:{
|
||||
data:"PrivateAccountSearchData",
|
||||
highlightedFields:"String",
|
||||
score:"Float",
|
||||
type:"String"
|
||||
},
|
||||
PublicChairman:{
|
||||
first_name:"String",
|
||||
last_name:"String",
|
||||
@@ -1373,7 +1390,8 @@ export const ReturnTypes: Record<string,any> = {
|
||||
getMeets:"MeetAggregate",
|
||||
getPaymentMethods:"PaymentMethodPaginationResult",
|
||||
getPayments:"PaymentPaginationResult",
|
||||
getSystemInfo:"SystemInfo"
|
||||
getSystemInfo:"SystemInfo",
|
||||
searchPrivateAccounts:"PrivateAccountSearchResult"
|
||||
},
|
||||
Question:{
|
||||
context:"String",
|
||||
|
||||
@@ -920,7 +920,7 @@ export type ScalarCoders = {
|
||||
JSON?: ScalarResolver;
|
||||
JSONObject?: ScalarResolver;
|
||||
}
|
||||
type ZEUS_UNIONS = GraphQLTypes["PaymentMethodData"] | GraphQLTypes["UserCertificateUnion"]
|
||||
type ZEUS_UNIONS = GraphQLTypes["PaymentMethodData"] | GraphQLTypes["PrivateAccountSearchData"] | GraphQLTypes["UserCertificateUnion"]
|
||||
|
||||
export type ValueTypes = {
|
||||
["AcceptChildOrderInput"]: {
|
||||
@@ -3175,6 +3175,22 @@ voteOnAnnualGeneralMeet?: [{ data: ValueTypes["VoteOnAnnualGeneralMeetInput"] |
|
||||
/** Тип аккаунта */
|
||||
type?:boolean | `@${string}`,
|
||||
__typename?: boolean | `@${string}`
|
||||
}>;
|
||||
["PrivateAccountSearchData"]: AliasType<{ ["...on Entrepreneur"]?: ValueTypes["Entrepreneur"],
|
||||
["...on Individual"]?: ValueTypes["Individual"],
|
||||
["...on Organization"]?: ValueTypes["Organization"]
|
||||
__typename?: boolean | `@${string}`
|
||||
}>;
|
||||
["PrivateAccountSearchResult"]: AliasType<{
|
||||
/** Данные найденного аккаунта */
|
||||
data?:ValueTypes["PrivateAccountSearchData"],
|
||||
/** Поля, в которых найдены совпадения */
|
||||
highlightedFields?:boolean | `@${string}`,
|
||||
/** Оценка релевантности результата */
|
||||
score?:boolean | `@${string}`,
|
||||
/** Тип аккаунта */
|
||||
type?:boolean | `@${string}`,
|
||||
__typename?: boolean | `@${string}`
|
||||
}>;
|
||||
["ProhibitRequestInput"]: {
|
||||
/** Имя аккаунта кооператива */
|
||||
@@ -3290,6 +3306,7 @@ getPaymentMethods?: [{ data?: ValueTypes["GetPaymentMethodsInput"] | undefined |
|
||||
getPayments?: [{ data?: ValueTypes["GetPaymentsInput"] | undefined | null | Variable<any, string>, options?: ValueTypes["PaginationInput"] | undefined | null | Variable<any, string>},ValueTypes["PaymentPaginationResult"]],
|
||||
/** Получить сводную публичную информацию о системе */
|
||||
getSystemInfo?:ValueTypes["SystemInfo"],
|
||||
searchPrivateAccounts?: [{ data: ValueTypes["SearchPrivateAccountsInput"] | Variable<any, string>},ValueTypes["PrivateAccountSearchResult"]],
|
||||
__typename?: boolean | `@${string}`
|
||||
}>;
|
||||
/** Вопрос повестки собрания с результатами голосования */
|
||||
@@ -3637,6 +3654,10 @@ getPayments?: [{ data?: ValueTypes["GetPaymentsInput"] | undefined | null | Vari
|
||||
phone?:boolean | `@${string}`,
|
||||
__typename?: boolean | `@${string}`
|
||||
}>;
|
||||
["SearchPrivateAccountsInput"]: {
|
||||
/** Поисковый запрос для поиска приватных аккаунтов */
|
||||
query: string | Variable<any, string>
|
||||
};
|
||||
["SelectBranchGenerateDocumentInput"]: {
|
||||
/** Номер блока, на котором был создан документ */
|
||||
block_num?: number | undefined | null | Variable<any, string>,
|
||||
@@ -6386,6 +6407,23 @@ voteOnAnnualGeneralMeet?: [{ data: ResolverInputTypes["VoteOnAnnualGeneralMeetIn
|
||||
/** Тип аккаунта */
|
||||
type?:boolean | `@${string}`,
|
||||
__typename?: boolean | `@${string}`
|
||||
}>;
|
||||
["PrivateAccountSearchData"]: AliasType<{
|
||||
Entrepreneur?:ResolverInputTypes["Entrepreneur"],
|
||||
Individual?:ResolverInputTypes["Individual"],
|
||||
Organization?:ResolverInputTypes["Organization"],
|
||||
__typename?: boolean | `@${string}`
|
||||
}>;
|
||||
["PrivateAccountSearchResult"]: AliasType<{
|
||||
/** Данные найденного аккаунта */
|
||||
data?:ResolverInputTypes["PrivateAccountSearchData"],
|
||||
/** Поля, в которых найдены совпадения */
|
||||
highlightedFields?:boolean | `@${string}`,
|
||||
/** Оценка релевантности результата */
|
||||
score?:boolean | `@${string}`,
|
||||
/** Тип аккаунта */
|
||||
type?:boolean | `@${string}`,
|
||||
__typename?: boolean | `@${string}`
|
||||
}>;
|
||||
["ProhibitRequestInput"]: {
|
||||
/** Имя аккаунта кооператива */
|
||||
@@ -6501,6 +6539,7 @@ getPaymentMethods?: [{ data?: ResolverInputTypes["GetPaymentMethodsInput"] | und
|
||||
getPayments?: [{ data?: ResolverInputTypes["GetPaymentsInput"] | undefined | null, options?: ResolverInputTypes["PaginationInput"] | undefined | null},ResolverInputTypes["PaymentPaginationResult"]],
|
||||
/** Получить сводную публичную информацию о системе */
|
||||
getSystemInfo?:ResolverInputTypes["SystemInfo"],
|
||||
searchPrivateAccounts?: [{ data: ResolverInputTypes["SearchPrivateAccountsInput"]},ResolverInputTypes["PrivateAccountSearchResult"]],
|
||||
__typename?: boolean | `@${string}`
|
||||
}>;
|
||||
/** Вопрос повестки собрания с результатами голосования */
|
||||
@@ -6848,6 +6887,10 @@ getPayments?: [{ data?: ResolverInputTypes["GetPaymentsInput"] | undefined | nul
|
||||
phone?:boolean | `@${string}`,
|
||||
__typename?: boolean | `@${string}`
|
||||
}>;
|
||||
["SearchPrivateAccountsInput"]: {
|
||||
/** Поисковый запрос для поиска приватных аккаунтов */
|
||||
query: string
|
||||
};
|
||||
["SelectBranchGenerateDocumentInput"]: {
|
||||
/** Номер блока, на котором был создан документ */
|
||||
block_num?: number | undefined | null,
|
||||
@@ -9607,6 +9650,17 @@ export type ModelTypes = {
|
||||
organization_data?: ModelTypes["Organization"] | undefined | null,
|
||||
/** Тип аккаунта */
|
||||
type: ModelTypes["AccountType"]
|
||||
};
|
||||
["PrivateAccountSearchData"]:ModelTypes["Entrepreneur"] | ModelTypes["Individual"] | ModelTypes["Organization"];
|
||||
["PrivateAccountSearchResult"]: {
|
||||
/** Данные найденного аккаунта */
|
||||
data: ModelTypes["PrivateAccountSearchData"],
|
||||
/** Поля, в которых найдены совпадения */
|
||||
highlightedFields?: Array<string> | undefined | null,
|
||||
/** Оценка релевантности результата */
|
||||
score?: number | undefined | null,
|
||||
/** Тип аккаунта */
|
||||
type: string
|
||||
};
|
||||
["ProhibitRequestInput"]: {
|
||||
/** Имя аккаунта кооператива */
|
||||
@@ -9728,7 +9782,9 @@ export type ModelTypes = {
|
||||
/** Получить список платежей */
|
||||
getPayments: ModelTypes["PaymentPaginationResult"],
|
||||
/** Получить сводную публичную информацию о системе */
|
||||
getSystemInfo: ModelTypes["SystemInfo"]
|
||||
getSystemInfo: ModelTypes["SystemInfo"],
|
||||
/** Поиск приватных данных аккаунтов по запросу. Поиск осуществляется по полям ФИО, ИНН, ОГРН, наименованию организации и другим приватным данным. */
|
||||
searchPrivateAccounts: Array<ModelTypes["PrivateAccountSearchResult"]>
|
||||
};
|
||||
/** Вопрос повестки собрания с результатами голосования */
|
||||
["Question"]: {
|
||||
@@ -10066,6 +10122,10 @@ export type ModelTypes = {
|
||||
["SbpAccount"]: {
|
||||
/** Мобильный телефон получателя */
|
||||
phone: string
|
||||
};
|
||||
["SearchPrivateAccountsInput"]: {
|
||||
/** Поисковый запрос для поиска приватных аккаунтов */
|
||||
query: string
|
||||
};
|
||||
["SelectBranchGenerateDocumentInput"]: {
|
||||
/** Номер блока, на котором был создан документ */
|
||||
@@ -12878,6 +12938,23 @@ export type GraphQLTypes = {
|
||||
organization_data?: GraphQLTypes["Organization"] | undefined | null,
|
||||
/** Тип аккаунта */
|
||||
type: GraphQLTypes["AccountType"]
|
||||
};
|
||||
["PrivateAccountSearchData"]:{
|
||||
__typename:"Entrepreneur" | "Individual" | "Organization"
|
||||
['...on Entrepreneur']: '__union' & GraphQLTypes["Entrepreneur"];
|
||||
['...on Individual']: '__union' & GraphQLTypes["Individual"];
|
||||
['...on Organization']: '__union' & GraphQLTypes["Organization"];
|
||||
};
|
||||
["PrivateAccountSearchResult"]: {
|
||||
__typename: "PrivateAccountSearchResult",
|
||||
/** Данные найденного аккаунта */
|
||||
data: GraphQLTypes["PrivateAccountSearchData"],
|
||||
/** Поля, в которых найдены совпадения */
|
||||
highlightedFields?: Array<string> | undefined | null,
|
||||
/** Оценка релевантности результата */
|
||||
score?: number | undefined | null,
|
||||
/** Тип аккаунта */
|
||||
type: string
|
||||
};
|
||||
["ProhibitRequestInput"]: {
|
||||
/** Имя аккаунта кооператива */
|
||||
@@ -13001,7 +13078,9 @@ export type GraphQLTypes = {
|
||||
/** Получить список платежей */
|
||||
getPayments: GraphQLTypes["PaymentPaginationResult"],
|
||||
/** Получить сводную публичную информацию о системе */
|
||||
getSystemInfo: GraphQLTypes["SystemInfo"]
|
||||
getSystemInfo: GraphQLTypes["SystemInfo"],
|
||||
/** Поиск приватных данных аккаунтов по запросу. Поиск осуществляется по полям ФИО, ИНН, ОГРН, наименованию организации и другим приватным данным. */
|
||||
searchPrivateAccounts: Array<GraphQLTypes["PrivateAccountSearchResult"]>
|
||||
};
|
||||
/** Вопрос повестки собрания с результатами голосования */
|
||||
["Question"]: {
|
||||
@@ -13347,6 +13426,10 @@ export type GraphQLTypes = {
|
||||
__typename: "SbpAccount",
|
||||
/** Мобильный телефон получателя */
|
||||
phone: string
|
||||
};
|
||||
["SearchPrivateAccountsInput"]: {
|
||||
/** Поисковый запрос для поиска приватных аккаунтов */
|
||||
query: string
|
||||
};
|
||||
["SelectBranchGenerateDocumentInput"]: {
|
||||
/** Номер блока, на котором был создан документ */
|
||||
@@ -14013,6 +14096,7 @@ type ZEUS_VARIABLES = {
|
||||
["ReturnByAssetStatementGenerateDocumentInput"]: ValueTypes["ReturnByAssetStatementGenerateDocumentInput"];
|
||||
["ReturnByAssetStatementSignedDocumentInput"]: ValueTypes["ReturnByAssetStatementSignedDocumentInput"];
|
||||
["ReturnByAssetStatementSignedMetaDocumentInput"]: ValueTypes["ReturnByAssetStatementSignedMetaDocumentInput"];
|
||||
["SearchPrivateAccountsInput"]: ValueTypes["SearchPrivateAccountsInput"];
|
||||
["SelectBranchGenerateDocumentInput"]: ValueTypes["SelectBranchGenerateDocumentInput"];
|
||||
["SelectBranchInput"]: ValueTypes["SelectBranchInput"];
|
||||
["SelectBranchSignedDocumentInput"]: ValueTypes["SelectBranchSignedDocumentInput"];
|
||||
|
||||
Reference in New Issue
Block a user