diff --git a/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs b/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs
index 27f8132..638e23c 100644
--- a/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs
+++ b/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSchemes.cs
@@ -4,9 +4,6 @@ namespace Tnsu.ApprovalRoute.Api.Auth;
public static class AuthSchemes
{
- /// Локальная разработка и тесты — Entra не настроен (см. AuthSetup).
- public const string Dev = "Dev";
-
/// Токен пользователя (делегированный) — обычная работа админки.
public const string Bearer = JwtBearerDefaults.AuthenticationScheme;
diff --git a/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs b/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs
index 5d82424..4ff5509 100644
--- a/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs
+++ b/backend/Tnsu.ApprovalRoute.Api/Auth/AuthSetup.cs
@@ -20,11 +20,11 @@ public static class AuthSetup
var tenantId = configuration["Entra:TenantId"];
var audience = configuration["Entra:Audience"];
- if (!string.IsNullOrWhiteSpace(tenantId) && !string.IsNullOrWhiteSpace(audience))
- AddEntraAuth(services, configuration, tenantId, audience);
- else
- AddDevAuth(services);
+ if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(audience))
+ throw new InvalidOperationException(
+ "Entra:TenantId и Entra:Audience обязательны — локального Dev-режима без них больше нет.");
+ AddEntraAuth(services, configuration, tenantId, audience);
return services;
}
@@ -125,26 +125,4 @@ public static class AuthSetup
options.DefaultPolicy = options.GetPolicy(AuthPolicies.Any)!;
});
}
-
- private static void AddDevAuth(IServiceCollection services)
- {
- services.AddAuthentication(AuthSchemes.Dev)
- .AddScheme(AuthSchemes.Dev, _ => { });
-
- // Те же имена политик, но поверх Dev-схемы: иначе контроллер с
- // [Authorize(Policy = ...)] падал бы локально с «политика не найдена».
- services.AddAuthorization(options =>
- {
- foreach (var name in new[] { AuthPolicies.Delegated, AuthPolicies.AppOnly, AuthPolicies.Any })
- {
- options.AddPolicy(name, policy =>
- {
- policy.AuthenticationSchemes.Add(AuthSchemes.Dev);
- policy.RequireAuthenticatedUser();
- });
- }
-
- options.DefaultPolicy = options.GetPolicy(AuthPolicies.Any)!;
- });
- }
}
diff --git a/backend/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs b/backend/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs
deleted file mode 100644
index 8086a82..0000000
--- a/backend/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-using System.Security.Claims;
-using System.Text.Encodings.Web;
-using Microsoft.AspNetCore.Authentication;
-using Microsoft.Extensions.Options;
-
-namespace Tnsu.ApprovalRoute.Api.Auth;
-
-public sealed class DevAuthHandler(
- IOptionsMonitor options,
- ILoggerFactory logger,
- UrlEncoder encoder,
- IConfiguration configuration)
- : AuthenticationHandler(options, logger, encoder)
-{
- protected override Task HandleAuthenticateAsync()
- {
- var email = Request.Headers["X-Dev-User-Email"].FirstOrDefault()
- ?? configuration["Dev:DefaultUserEmail"]
- ?? "dev@tnsu.kz";
-
- var identity = new ClaimsIdentity([new Claim(ClaimTypes.Email, email)], AuthSchemes.Dev);
- var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), AuthSchemes.Dev);
-
- return Task.FromResult(AuthenticateResult.Success(ticket));
- }
-}
diff --git a/backend/Tnsu.ApprovalRoute.Api/appsettings.Development.json b/backend/Tnsu.ApprovalRoute.Api/appsettings.Development.json
index 5adccfc..52e99be 100644
--- a/backend/Tnsu.ApprovalRoute.Api/appsettings.Development.json
+++ b/backend/Tnsu.ApprovalRoute.Api/appsettings.Development.json
@@ -1,8 +1,4 @@
{
- "Entra": {
- "TenantId": "",
- "Audience": ""
- },
"ConnectionStrings": {
"Postgres": "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"
}
diff --git a/backend/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/Tnsu.ApprovalRoute.Api/appsettings.json
index ef7f102..e01d0f1 100644
--- a/backend/Tnsu.ApprovalRoute.Api/appsettings.json
+++ b/backend/Tnsu.ApprovalRoute.Api/appsettings.json
@@ -13,9 +13,6 @@
"Audience": "9232d9e2-9807-4753-808f-07250e5474b0",
"AllowedAppIds": []
},
- "Dev": {
- "DefaultUserEmail": "dev@tnsu.kz"
- },
"Cors": {
"AllowedOrigins": [
"https://tnsukz.sharepoint.com",
diff --git a/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts b/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts
index 0b826a2..554cd1a 100644
--- a/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts
+++ b/frontend/src/webparts/approvalAdmin/ApprovalAdminWebPart.ts
@@ -1,11 +1,7 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
-import {
- type IPropertyPaneConfiguration,
- PropertyPaneTextField,
- PropertyPaneToggle
-} from '@microsoft/sp-property-pane';
+import { type IPropertyPaneConfiguration, PropertyPaneTextField } from '@microsoft/sp-property-pane';
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import ApprovalAdmin from './components/ApprovalAdmin';
@@ -13,8 +9,6 @@ import { IApprovalAdminProps } from './components/IApprovalAdminProps';
export interface IApprovalAdminWebPartProps {
apiBaseUrl: string;
- useDevAuth: boolean;
- devUserEmail: string;
aadResourceId: string;
portalBaseUrl: string;
}
@@ -25,8 +19,6 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart = React.createElement(ApprovalAdmin, {
context: this.context,
apiBaseUrl: this.properties.apiBaseUrl,
- useDevAuth: this.properties.useDevAuth,
- devUserEmail: this.properties.devUserEmail,
aadResourceId: this.properties.aadResourceId,
portalBaseUrl: this.properties.portalBaseUrl
});
@@ -39,11 +31,6 @@ export default class ApprovalAdminWebPart extends BaseClientSideWebPart {
- if (props.useDevAuth) {
- return new DevApiHttpClient(props.apiBaseUrl, props.devUserEmail || 'dev@tnsu.kz');
- }
const aadClient = await props.context.aadHttpClientFactory.getClient(props.aadResourceId);
return new AadApiHttpClient(aadClient, props.apiBaseUrl);
}
@@ -50,11 +47,8 @@ const ApprovalAdmin: React.FC = props => {
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [props.useDevAuth, props.apiBaseUrl, props.devUserEmail, props.aadResourceId]);
+ }, [props.apiBaseUrl, props.aadResourceId]);
- // Справочники всегда идут через AAD, независимо от Dev-режима нашего API:
- // api.tnsu.kz принимает только настоящий токен, заголовок X-Dev-User-Email
- // ему ничего не говорит.
React.useEffect(() => {
let cancelled = false;
setDictionary(undefined);
diff --git a/frontend/src/webparts/approvalAdmin/components/IApprovalAdminProps.ts b/frontend/src/webparts/approvalAdmin/components/IApprovalAdminProps.ts
index e01a6e5..fdf929a 100644
--- a/frontend/src/webparts/approvalAdmin/components/IApprovalAdminProps.ts
+++ b/frontend/src/webparts/approvalAdmin/components/IApprovalAdminProps.ts
@@ -3,8 +3,6 @@ import { WebPartContext } from '@microsoft/sp-webpart-base';
export interface IApprovalAdminProps {
context: WebPartContext;
apiBaseUrl: string;
- useDevAuth: boolean;
- devUserEmail: string;
aadResourceId: string;
portalBaseUrl: string;
}
diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx
index 046ce26..0d9bd2e 100644
--- a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx
+++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx
@@ -223,7 +223,7 @@ export const RouteStepFormModal: React.FC = ({
// бэкенд ищет по компании инициатора). Найденные в других компаниях
// этому маршруту не подойдут — их надо не прятать, а объяснить, что они не в счёт.
const inRouteCompany = routeCompanyName
- ? positionMatches.exact.filter(e => e.company === routeCompanyName)
+ ? positionMatches.exact.filter(e => e.companies.indexOf(routeCompanyName) !== -1)
: positionMatches.exact;
if (inRouteCompany.length > 0) {
@@ -235,12 +235,18 @@ export const RouteStepFormModal: React.FC = ({
: texts.routeSteps.positionPreviewFound(inRouteCompany.length)}
- {inRouteCompany.map(e => (
- -
- {e.fio}
- {e.company && · {e.company}}
-
- ))}
+ {inRouteCompany.map(e => {
+ // Показываем ИМЕННО ту компанию, по которой сотрудник совпал с
+ // маршрутом — она не обязана быть его company (первой из
+ // справочника), если он числится в нескольких (см. companies).
+ const shownCompany = routeCompanyName ?? e.company;
+ return (
+ -
+ {e.fio}
+ {shownCompany && · {shownCompany}}
+
+ );
+ })}
);
diff --git a/frontend/src/webparts/approvalAdmin/models/dictionaries.ts b/frontend/src/webparts/approvalAdmin/models/dictionaries.ts
index 8b99a3c..566a2f1 100644
--- a/frontend/src/webparts/approvalAdmin/models/dictionaries.ts
+++ b/frontend/src/webparts/approvalAdmin/models/dictionaries.ts
@@ -34,10 +34,17 @@ export interface Employee {
* отображения в списке (сам справочник должностей не показывает). */
position: string;
/**
- * Компания сотрудника — '' если не пришла из справочника. Нужна подсказке
- * «кого найдёт эта должность» в форме шага: бэкенд ищет исполнителя по ПАРЕ
- * (должность, компания инициатора), поэтому одну и ту же должность в разных
- * юрлицах занимают разные люди, и без компании подсказка была бы неоднозначной.
+ * Первая компания сотрудника из справочника ('' если не пришла) — для
+ * отображения в пикере/списках, где на человека нужна ровно одна строка.
*/
company: string;
+ /**
+ * ВСЕ компании, где числится сотрудник — совместители и штат общих служб
+ * (бухгалтерия, HR) на несколько юрлиц не редкость: в api.tnsu.kz это
+ * отдельная строка на каждую компанию, а не одна с списком. Проверять
+ * принадлежность к конкретной компании (маршрут ограничен ею) нужно по
+ * этому полю, а не по company — иначе сотрудник, у которого в справочнике
+ * первой оказалась ДРУГАЯ его компания, ошибочно выглядел бы отсутствующим.
+ */
+ companies: string[];
}
diff --git a/frontend/src/webparts/approvalAdmin/services/httpClient.ts b/frontend/src/webparts/approvalAdmin/services/httpClient.ts
index 6fcacca..399b1e5 100644
--- a/frontend/src/webparts/approvalAdmin/services/httpClient.ts
+++ b/frontend/src/webparts/approvalAdmin/services/httpClient.ts
@@ -116,45 +116,3 @@ export class AadApiHttpClient implements IHttpClient {
.then((r: HttpClientResponse) => parseResponse(r));
}
}
-
-/**
- * Локальная отладка: обычный fetch с заголовком X-Dev-User-Email.
- * Работает только если бэкенд поднят с пустыми Entra:TenantId/Entra:Audience
- * (см. backend/.../Auth/AuthSetup.cs — тогда включается DevAuthHandler).
- */
-export class DevApiHttpClient implements IHttpClient {
- constructor(private readonly baseUrl: string, private readonly devUserEmail: string) {}
-
- public get(path: string, query?: QueryParams): Promise {
- return this.send('GET', path, undefined, query);
- }
-
- public post(path: string, body?: unknown): Promise {
- return this.send('POST', path, body);
- }
-
- public put(path: string, body?: unknown): Promise {
- return this.send('PUT', path, body);
- }
-
- public patch(path: string, body?: unknown): Promise {
- return this.send('PATCH', path, body);
- }
-
- public async delete(path: string): Promise {
- await this.send('DELETE', path);
- }
-
- private async send(method: string, path: string, body?: unknown, query?: QueryParams): Promise {
- const url = buildUrl(this.baseUrl, path, query);
- const response = await fetch(url, {
- method,
- headers: {
- 'Content-Type': 'application/json',
- 'X-Dev-User-Email': this.devUserEmail
- },
- body: body !== undefined ? JSON.stringify(body) : undefined
- });
- return parseResponse(response);
- }
-}
diff --git a/frontend/src/webparts/approvalAdmin/services/portalDictionary.test.ts b/frontend/src/webparts/approvalAdmin/services/portalDictionary.test.ts
new file mode 100644
index 0000000..e83adb1
--- /dev/null
+++ b/frontend/src/webparts/approvalAdmin/services/portalDictionary.test.ts
@@ -0,0 +1,56 @@
+import { AadHttpClient } from '@microsoft/sp-http';
+import { PortalDictionaryClient } from './portalDictionary';
+
+// Мок-клиент вместо настоящего AadHttpClient — employees() дёргает только .get().
+function clientReturning(body: unknown): AadHttpClient {
+ return {
+ get: () =>
+ Promise.resolve({
+ ok: true,
+ text: () => Promise.resolve(JSON.stringify(body))
+ })
+ } as unknown as AadHttpClient;
+}
+
+describe('PortalDictionaryClient.employees', () => {
+ // Регрессия: api.tnsu.kz отдаёт ОТДЕЛЬНУЮ строку на каждую компанию, где
+ // числится сотрудник (совместители/бухгалтерия на несколько юрлиц — не
+ // редкость). Раньше company у дедуплицированной записи оставалась только
+ // первой попавшейся, а остальные компании терялись — сотрудник ошибочно
+ // выглядел отсутствующим в них (см. forms/Clearance-подсказку в RouteStepFormModal).
+ it('строки одного email по разным компаниям схлопываются в одного сотрудника, но все компании сохраняются', async () => {
+ const client = clientReturning([
+ { fio: 'Тәжіғалиев Биғали', email: 'b@ct-global.kz', position: 'Главный бухгалтер', company: 'ТОО ГеоТрек' },
+ { fio: 'Тәжіғалиев Биғали', email: 'b@ct-global.kz', position: 'Главный бухгалтер', company: 'ТОО AFL Engineering' },
+ { fio: 'Тәжіғалиев Биғали', email: 'b@ct-global.kz', position: 'Главный бухгалтер', company: 'ТОО СТ Global' }
+ ]);
+
+ const employees = await new PortalDictionaryClient(client, 'https://api.tnsu.kz').employees();
+
+ expect(employees).toHaveLength(1);
+ expect(employees[0].company).toBe('ТОО ГеоТрек'); // первая — для пикера/отображения
+ expect(employees[0].companies).toEqual(['ТОО ГеоТрек', 'ТОО AFL Engineering', 'ТОО СТ Global']);
+ });
+
+ it('разные email — разные сотрудники, дедупликация их не задевает', async () => {
+ const client = clientReturning([
+ { fio: 'A', email: 'a@tnsu.kz', position: 'Инженер', company: 'Компания А' },
+ { fio: 'B', email: 'b@tnsu.kz', position: 'Инженер', company: 'Компания Б' }
+ ]);
+
+ const employees = await new PortalDictionaryClient(client, 'https://api.tnsu.kz').employees();
+
+ expect(employees.map(e => e.email)).toEqual(['a@tnsu.kz', 'b@tnsu.kz']);
+ expect(employees.map(e => e.companies)).toEqual([['Компания А'], ['Компания Б']]);
+ });
+
+ it('сотрудник без email в строке — пропускается (по нему нельзя резолвить исполнителя)', async () => {
+ const client = clientReturning([
+ { fio: 'Без Email', email: null, position: 'Инженер', company: 'Компания А' }
+ ]);
+
+ const employees = await new PortalDictionaryClient(client, 'https://api.tnsu.kz').employees();
+
+ expect(employees).toEqual([]);
+ });
+});
diff --git a/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts b/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts
index 9f25130..b1c1756 100644
--- a/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts
+++ b/frontend/src/webparts/approvalAdmin/services/portalDictionary.ts
@@ -117,13 +117,17 @@ export class PortalDictionaryClient {
fio: x.fio,
email: x.email,
position: x.position || '',
- company: x.company || ''
+ 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());
diff --git a/frontend/src/webparts/approvalAdmin/utils/employee.test.ts b/frontend/src/webparts/approvalAdmin/utils/employee.test.ts
index 7005da0..a5eb1b3 100644
--- a/frontend/src/webparts/approvalAdmin/utils/employee.test.ts
+++ b/frontend/src/webparts/approvalAdmin/utils/employee.test.ts
@@ -1,9 +1,16 @@
import { Employee } from '../models';
import { findEmployeeByEmail, matchEmployeesByPosition, toEmployeeOption } from './employee';
+// Хелпер вместо повторения company/companies в каждом литерале — в тестах
+// нет нужды в разных компаниях у одного сотрудника (это отдельно проверяется
+// в matchEmployeesByPosition ниже).
+function emp(fio: string, email: string, position: string, company = ''): Employee {
+ return { fio, email, position, company, companies: company ? [company] : [] };
+}
+
const employees: Employee[] = [
- { fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик middle', company: 'ТОО TANSU Construction' },
- { fio: 'Соколов Давид', email: 'sokolov.david@tnsu.kz', position: 'Руководитель отдела', company: 'ТОО TANSU Construction' }
+ emp('Горбенко Павел', 'gorbenko.pavel@tnsu.kz', 'Разработчик middle', 'ТОО TANSU Construction'),
+ emp('Соколов Давид', 'sokolov.david@tnsu.kz', 'Руководитель отдела', 'ТОО TANSU Construction')
];
describe('findEmployeeByEmail', () => {
@@ -37,7 +44,7 @@ describe('toEmployeeOption', () => {
// Должность в справочнике заполнена не у всех — тогда подпись не должна
// начинаться с висящего разделителя.
it('без должности в подписи остаётся только email', () => {
- expect(toEmployeeOption({ fio: 'Без Должности', email: 'nobody@tnsu.kz', position: '', company: '' })).toEqual({
+ expect(toEmployeeOption(emp('Без Должности', 'nobody@tnsu.kz', ''))).toEqual({
value: 'nobody@tnsu.kz',
label: 'Без Должности',
sublabel: 'nobody@tnsu.kz'
@@ -47,12 +54,12 @@ describe('toEmployeeOption', () => {
describe('matchEmployeesByPosition', () => {
const staff: Employee[] = [
- { fio: 'Плотникова Лариса', email: 'p@tnsu.kz', position: 'Генеральный директор', company: 'Компания А' },
- { fio: 'Самаева Асель', email: 's@tnsu.kz', position: 'Директор', company: 'Компания А' },
- { fio: 'Ким Николай', email: 'k@tnsu.kz', position: 'Управляющий директор', company: 'Компания Б' },
- { fio: 'Иванов Иван', email: 'i1@tnsu.kz', position: 'Инженер ПТО', company: 'Компания А' },
- { fio: 'Петров Пётр', email: 'i2@tnsu.kz', position: 'Инженер ПТО', company: 'Компания Б' },
- { fio: 'Без Должности', email: 'n@tnsu.kz', position: '', company: 'Компания А' }
+ emp('Плотникова Лариса', 'p@tnsu.kz', 'Генеральный директор', 'Компания А'),
+ emp('Самаева Асель', 's@tnsu.kz', 'Директор', 'Компания А'),
+ emp('Ким Николай', 'k@tnsu.kz', 'Управляющий директор', 'Компания Б'),
+ emp('Иванов Иван', 'i1@tnsu.kz', 'Инженер ПТО', 'Компания А'),
+ emp('Петров Пётр', 'i2@tnsu.kz', 'Инженер ПТО', 'Компания Б'),
+ emp('Без Должности', 'n@tnsu.kz', '', 'Компания А')
];
it('пустой ввод — ничего не подбирает', () => {
diff --git a/frontend/src/webparts/approvalAdmin/utils/stepExecutor.test.ts b/frontend/src/webparts/approvalAdmin/utils/stepExecutor.test.ts
index 6cd149c..0250f1c 100644
--- a/frontend/src/webparts/approvalAdmin/utils/stepExecutor.test.ts
+++ b/frontend/src/webparts/approvalAdmin/utils/stepExecutor.test.ts
@@ -45,7 +45,13 @@ describe('detectExecutorKind', () => {
describe('describeStepExecutor', () => {
const employees: Employee[] = [
- { fio: 'Горбенко Павел', email: 'gorbenko.pavel@tnsu.kz', position: 'Разработчик', company: 'ТОО TANSU Construction' }
+ {
+ fio: 'Горбенко Павел',
+ email: 'gorbenko.pavel@tnsu.kz',
+ position: 'Разработчик',
+ company: 'ТОО TANSU Construction',
+ companies: ['ТОО TANSU Construction']
+ }
];
it('для email найденного сотрудника показывает ФИО, а должность и email — отдельной строкой', () => {
@@ -57,7 +63,7 @@ describe('describeStepExecutor', () => {
// начинаться с висящего разделителя.
it('для сотрудника без должности в подписи остаётся только email', () => {
const result = describeStepExecutor(step({ executorEmail: 'nopos@tnsu.kz' }), [
- { fio: 'Без Должности', email: 'nopos@tnsu.kz', position: '', company: '' }
+ { fio: 'Без Должности', email: 'nopos@tnsu.kz', position: '', company: '', companies: [] }
]);
expect(result).toEqual({ primary: 'Без Должности', secondary: 'nopos@tnsu.kz' });
});