Files
tnsu-approval-route/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts
T
2026-08-14 14:51:48 +05:00

136 lines
5.5 KiB
TypeScript

/* eslint-disable @rushstack/no-new-null -- поля "сырых" IPortal*-интерфейсов 1:1 зеркалят nullable JSON-контракт портала api.tnsu.kz */
import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http';
import { Company, Department, Employee, Project } from '../models';
import { ApiClientError } from './httpClient';
// Справочники берём напрямую из api.tnsu.kz — тем же AAD-токеном, что и наш API:
// в appsettings.json бэкенда Entra:Audience и TnsuPortal:ClientId — один и тот же
// GUID, то есть обе службы живут на одной регистрации Entra. Прокси не нужен.
//
// Два отличия от нашего API, из-за которых здесь отдельный клиент:
// 1) портал отдаёт «голый» массив, без конверта { data: ... };
// 2) ключи в PascalCase (Id/Name/CompanyId/Company).
interface IPortalCompany {
Id: string;
Name: string;
}
interface IPortalDepartment {
Id: string;
Name: string;
CompanyId: string;
Company: string;
}
// GET /Project?name=&page=&pageSize= — в отличие от /Dictionary/*, здесь портал
// уже отдаёт camelCase и постраничный конверт (данные идут из своей БД, а не
// из сырых 1С-справочников).
interface IPortalProject {
oid: string;
name: string;
code: string | null;
status: string | null;
}
interface IPortalProjectPage {
items: IPortalProject[];
totalCount: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface IProjectSearchResult {
items: Project[];
hasMore: boolean;
}
// GET /Employee/All — уже camelCase, как /Project (не PascalCase, как /Dictionary/*).
// Строка на каждого сотрудника в каждом его отделе, поэтому один email может
// повторяться несколько раз; сотрудники без email в подборе исполнителя бесполезны.
interface IPortalEmployee {
fio: string;
email: string | null;
position: string | null;
company: string | null;
}
async function getJson<T>(client: AadHttpClient, baseUrl: string, path: string): Promise<T> {
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
const url = new URL(path.replace(/^\//, ''), normalizedBase).toString();
const response: HttpClientResponse = await client.get(url, AadHttpClient.configurations.v1);
const text = await response.text();
if (!response.ok) {
throw new ApiClientError(response.status, [
`Справочник api.tnsu.kz вернул HTTP ${response.status}${text ? `: ${text}` : ''}`
]);
}
try {
return JSON.parse(text) as T;
} catch {
throw new ApiClientError(response.status, ['Справочник api.tnsu.kz вернул не-JSON ответ.']);
}
}
export class PortalDictionaryClient {
constructor(private readonly client: AadHttpClient, private readonly baseUrl: string) {}
public async companies(): Promise<Company[]> {
const raw = await getJson<IPortalCompany[]>(this.client, this.baseUrl, '/Dictionary/Companies');
return (raw ?? []).map(x => ({ id: x.Id, name: x.Name }));
}
public async departments(): Promise<Department[]> {
const raw = await getJson<IPortalDepartment[]>(this.client, this.baseUrl, '/Dictionary/Departments');
return (raw ?? []).map(x => ({
id: x.Id,
name: x.Name,
companyId: x.CompanyId,
company: x.Company
}));
}
public async searchProjects(name: string, page: number, pageSize = 20): Promise<IProjectSearchResult> {
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
if (name.trim()) params.set('name', name.trim());
const raw = await getJson<IPortalProjectPage>(this.client, this.baseUrl, `/Project?${params.toString()}`);
return {
items: (raw.items ?? []).map(x => ({ oid: x.oid, name: x.name, code: x.code, status: x.status })),
hasMore: raw.page < raw.totalPages
};
}
public async employees(): Promise<Employee[]> {
const raw = await getJson<IPortalEmployee[]>(this.client, this.baseUrl, '/Employee/All');
const byEmail = new Map<string, Employee>();
(raw ?? []).forEach(x => {
if (!x.email) return;
const existing = byEmail.get(x.email);
if (!existing) {
byEmail.set(x.email, {
fio: x.fio,
email: x.email,
position: x.position || '',
company: x.company || '',
// Одна строка на каждую компанию, где числится сотрудник (совместители/
// бухгалтерия на несколько юрлиц — не редкость), companies собирает их
// все, а company остаётся первой встреченной (для пикера/отображения,
// где нужна ровно одна строка на человека).
companies: x.company ? [x.company] : []
});
} else {
if (!existing.position && x.position) existing.position = x.position;
if (!existing.company && x.company) existing.company = x.company;
if (x.company && existing.companies.indexOf(x.company) === -1) existing.companies.push(x.company);
}
});
return Array.from(byEmail.values());
}
}