From 6a0110105460c50ff3487b85c8680b72076e1ece Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 12 Oct 2026 16:18:51 +0500 Subject: [PATCH 01/13] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D1=83=20?= =?UTF-8?q?=C2=AB=D0=92=D1=8B=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=80=D1=88=D1=80=D1=83=D1=82=D0=BE=D0=B2=C2=BB=20=D0=B8?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=B5=D1=81=D1=82=D0=B8=20?= =?UTF-8?q?=C2=AB=D0=94=D0=BE=D1=81=D1=82=D1=83=D0=BF=20=D0=BA=20=D0=B7?= =?UTF-8?q?=D0=B0=D1=8F=D0=B2=D0=BA=D0=B0=D0=BC=C2=BB=20=D0=B2=D0=BD=D0=B8?= =?UTF-8?q?=D0=B7=20=D0=BC=D0=B5=D0=BD=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Выбранные типы заявок выгружаются в xlsx (лист на тип: №, роль, исполнитель, условие) через POST /api/reports/routes. Пункт меню стоит после «Проверки маршрута», «Доступ к заявкам» — последним. Co-Authored-By: Claude Sonnet 5.5 --- .../Controllers/ReportsController.cs | 23 +++ .../Reports/RouteExportWorkbook.cs | 143 ++++++++++++++++++ .../approvalAdmin/components/app/AdminApp.tsx | 7 +- .../approvalAdmin/components/app/types.ts | 1 + .../components/screens/RoutesExportScreen.tsx | 116 ++++++++++++++ .../webparts/approvalAdmin/models/index.ts | 1 + .../approvalAdmin/models/routeExport.ts | 24 +++ .../src/webparts/approvalAdmin/routing.ts | 2 +- .../webparts/approvalAdmin/services/api.ts | 7 + .../approvalAdmin/services/httpClient.ts | 17 +++ frontend/src/webparts/approvalAdmin/texts.ts | 19 +++ .../approvalAdmin/utils/routeExport.ts | 71 +++++++++ 12 files changed, 428 insertions(+), 3 deletions(-) create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs create mode 100644 frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx create mode 100644 frontend/src/webparts/approvalAdmin/models/routeExport.ts create mode 100644 frontend/src/webparts/approvalAdmin/utils/routeExport.ts diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs index f25a271..f2992f6 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs @@ -51,4 +51,27 @@ public sealed class ReportsController(VacancyReportService vacancies) : Controll return File(file, XlsxContentType); } + + /// + /// Выгрузка маршрутов согласования выбранных типов заявок в xlsx (лист на тип). + /// POST, потому что строки собирает админка — у неё уже есть справочники + /// сотрудников и подписи шагов (см. RouteExportWorkbook). + /// + [HttpPost("routes")] + [Produces(XlsxContentType)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public IActionResult Routes([FromBody] RouteExportRequest request) + { + if (request.Sheets is not { Count: > 0 }) + return BadRequest(new { errors = new[] { "Не выбрано ни одного типа заявки." } }); + + var file = RouteExportWorkbook.Build(request); + + var disposition = new ContentDispositionHeaderValue("attachment"); + disposition.SetHttpFileName(RouteExportWorkbook.FileName); + Response.Headers.ContentDisposition = disposition.ToString(); + + return File(file, XlsxContentType); + } } diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs new file mode 100644 index 0000000..c7cfc4b --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs @@ -0,0 +1,143 @@ +using NPOI.SS.UserModel; +using NPOI.SS.Util; +using NPOI.XSSF.UserModel; + +namespace Tnsu.ApprovalRoute.Application.Reports; + +/// Строка выгрузки маршрута: шаг в понятном человеку виде (подписи уже собрала админка). +public sealed record RouteExportRow(int Number, string Role, string Executor, string Condition); + +/// +/// Один маршрут типа заявки. Заголовок задан, когда у типа несколько маршрутов +/// (по компании/отделу) и их нужно различать на одном листе; у единственного +/// маршрута он пуст — лист выглядит как в образце «Маршруты согласования». +/// +public sealed record RouteExportSection(string? Title, IReadOnlyList Rows); + +/// Лист выгрузки — один тип заявки. +public sealed record RouteExportSheet(string Name, IReadOnlyList Sections); + +public sealed record RouteExportRequest(IReadOnlyList Sheets); + +/// +/// Выгрузка маршрутов согласования в xlsx — по образцу, который HR ведёт руками: +/// лист на тип заявки, колонки «№ / Роль / Исполнитель / Условие». +/// +/// Подписи (роль, ФИО исполнителя, текст условия) приходят готовыми из админки: +/// там уже есть справочники сотрудников и логика описания шага, дублировать их +/// на бэкенде незачем. Здесь только вёрстка файла. +/// +public static class RouteExportWorkbook +{ + private const int MaxSheetNameLength = 31; + private const string InvalidSheetNameChars = "[]:*?/\\"; + + private static readonly (string Title, double Width)[] Columns = + [ + (" ", 6.0), + ("Роль ", 41.1), + ("Исполнитель ", 42.2), + ("Условие ", 66.2), + ]; + + public const string FileName = "Маршруты согласования.xlsx"; + + public static byte[] Build(RouteExportRequest request) + { + var workbook = new XSSFWorkbook(); + + var headerStyle = CreateStyle(workbook, bold: true); + var cellStyle = CreateStyle(workbook, bold: false); + var titleStyle = CreateStyle(workbook, bold: true, border: false); + + var usedNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var sheetData in request.Sheets) + { + var sheet = workbook.CreateSheet(UniqueSheetName(sheetData.Name, usedNames)); + for (var i = 0; i < Columns.Length; i++) + sheet.SetColumnWidth(i, (int)(Columns[i].Width * 256)); + + var rowIndex = 0; + foreach (var section in sheetData.Sections) + { + if (rowIndex > 0) rowIndex++; // пустая строка между маршрутами + + if (!string.IsNullOrWhiteSpace(section.Title)) + { + var titleRow = sheet.CreateRow(rowIndex); + var titleCell = titleRow.CreateCell(0); + titleCell.SetCellValue(section.Title); + titleCell.CellStyle = titleStyle; + sheet.AddMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, Columns.Length - 1)); + rowIndex++; + } + + var header = sheet.CreateRow(rowIndex++); + for (var i = 0; i < Columns.Length; i++) + { + var cell = header.CreateCell(i); + cell.SetCellValue(Columns[i].Title); + cell.CellStyle = headerStyle; + } + + foreach (var row in section.Rows) + { + var excelRow = sheet.CreateRow(rowIndex++); + var numberCell = excelRow.CreateCell(0); + numberCell.SetCellValue(row.Number); + numberCell.CellStyle = cellStyle; + WriteText(excelRow, 1, row.Role, cellStyle); + WriteText(excelRow, 2, row.Executor, cellStyle); + WriteText(excelRow, 3, row.Condition, cellStyle); + } + } + } + + using var stream = new MemoryStream(); + workbook.Write(stream, leaveOpen: true); + return stream.ToArray(); + } + + private static void WriteText(IRow row, int column, string value, ICellStyle style) + { + var cell = row.CreateCell(column); + cell.SetCellValue(value); + cell.CellStyle = style; + } + + private static ICellStyle CreateStyle(IWorkbook workbook, bool bold, bool border = true) + { + var font = workbook.CreateFont(); + font.IsBold = bold; + + var style = workbook.CreateCellStyle(); + style.SetFont(font); + style.WrapText = true; + style.VerticalAlignment = VerticalAlignment.Top; + if (border) + { + style.BorderTop = style.BorderBottom = style.BorderLeft = style.BorderRight = BorderStyle.Thin; + } + return style; + } + + // Имя листа Excel: до 31 символа, без []:*?/\, уникальное без учёта регистра. + private static string UniqueSheetName(string raw, HashSet used) + { + var cleaned = new string((raw ?? string.Empty) + .Select(c => InvalidSheetNameChars.Contains(c) ? ' ' : c) + .ToArray()).Trim(); + if (cleaned.Length == 0) cleaned = "Лист"; + + var candidate = Truncate(cleaned, MaxSheetNameLength); + for (var n = 2; !used.Add(candidate); n++) + { + var suffix = $" ({n})"; + candidate = Truncate(cleaned, MaxSheetNameLength - suffix.Length) + suffix; + } + return candidate; + } + + private static string Truncate(string value, int length) => + value.Length <= length ? value : value[..length].TrimEnd(); +} diff --git a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx index efaa5b9..b568590 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx @@ -20,6 +20,7 @@ import { RouteStepsScreen } from '../screens/RouteStepsScreen'; import { ResolveScreen } from '../screens/ResolveScreen'; import { ManageRequestsScreen } from '../screens/ManageRequestsScreen'; import { ManageByUserScreen } from '../screens/ManageByUserScreen'; +import { RoutesExportScreen } from '../screens/RoutesExportScreen'; // Тот же боковой рейл (@tnsu/ui-kit Sidebar), что и в request-portal — единый // стиль навигации во всех веб-частях Tnsu. 'routeSteps' сюда не входит: это не @@ -28,11 +29,12 @@ import { ManageByUserScreen } from '../screens/ManageByUserScreen'; const NAV_ITEMS: ISidebarItem[] = [ { id: 'dashboard', title: texts.nav.dashboard, icon: 'chart' }, { id: 'requestTypes', title: texts.nav.requestTypes, icon: 'document' }, - { id: 'requestTypeAccess', title: texts.nav.requestTypeAccess, icon: 'people' }, { id: 'routes', title: texts.nav.routes, icon: 'send' }, { id: 'resolve', title: texts.nav.resolve, icon: 'search' }, + { id: 'routesExport', title: texts.nav.routesExport, icon: 'document' }, { id: 'manageRequests', title: texts.nav.manageRequests, icon: 'clipboard' }, - { id: 'manageByUser', title: texts.nav.manageByUser, icon: 'user-check' } + { id: 'manageByUser', title: texts.nav.manageByUser, icon: 'user-check' }, + { id: 'requestTypeAccess', title: texts.nav.requestTypeAccess, icon: 'people' } ]; function sidebarActiveId(tab: ScreenKey): string { @@ -184,6 +186,7 @@ export const AdminApp: React.FC = ({ api, dictionary, sharePoint route={route.resolve} /> )} + {route.tab === 'routesExport' && } {route.tab === 'manageRequests' && ( )} diff --git a/frontend/src/webparts/approvalAdmin/components/app/types.ts b/frontend/src/webparts/approvalAdmin/components/app/types.ts index 0f17ad6..2af6f6c 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/types.ts +++ b/frontend/src/webparts/approvalAdmin/components/app/types.ts @@ -5,5 +5,6 @@ export type ScreenKey = | 'routes' | 'routeSteps' | 'resolve' + | 'routesExport' | 'manageRequests' | 'manageByUser'; diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx new file mode 100644 index 0000000..f0c5270 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx @@ -0,0 +1,116 @@ +import * as React from 'react'; +import { ApprovalRouteApi } from '../../services/api'; +import { PortalDictionaryClient } from '../../services/portalDictionary'; +import { texts } from '../../texts'; +import { useRequestTypes } from '../../hooks/useRequestTypes'; +import { usePortalEmployees } from '../../hooks/usePortalEmployees'; +import { usePortalDictionaries } from '../../hooks/usePortalDictionaries'; +import { reportError } from '../../utils/errors'; +import { buildRouteSheet, RouteWithSteps } from '../../utils/routeExport'; +import { Alert, Breadcrumbs, Button, Checkbox, PageHeader, Panel } from '../ui'; + +export interface IRoutesExportScreenProps { + api: ApprovalRouteApi; + /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ + dictionary?: PortalDictionaryClient; +} + +function download(blob: Blob, fileName: string): void { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +export const RoutesExportScreen: React.FC = ({ api, dictionary }) => { + const requestTypes = useRequestTypes(api); + const employees = usePortalEmployees(dictionary); + const portalDictionaries = usePortalDictionaries(dictionary); + + const [selected, setSelected] = React.useState([]); + const [isBusy, setIsBusy] = React.useState(false); + const [error, setError] = React.useState(); + + const allIds = requestTypes.items.map(rt => rt.id); + const allSelected = allIds.length > 0 && allIds.every(id => selected.indexOf(id) !== -1); + + const toggle = (id: number, checked: boolean): void => + setSelected(current => (checked ? [...current, id] : current.filter(x => x !== id))); + + const runExport = (): void => { + setError(undefined); + setIsBusy(true); + // Порядок листов — как в списке типов, а не в порядке кликов. + const types = requestTypes.items.filter(rt => selected.indexOf(rt.id) !== -1); + + Promise.all( + types.map(async rt => { + const routes = await api.templateRoutes.list(rt.id); + const items: RouteWithSteps[] = await Promise.all( + routes.map(async route => ({ route, steps: await api.templateRouteSteps.list(route.id) })) + ); + return buildRouteSheet(rt, items, employees.items, portalDictionaries.companies, portalDictionaries.departments); + }) + ) + .then(sheets => api.reports.routes({ sheets })) + .then(blob => download(blob, texts.routesExport.fileName)) + .catch((e: Error) => setError(texts.routesExport.failed(reportError(e, 'RoutesExportScreen: export')))) + .then(() => setIsBusy(false), () => setIsBusy(false)); + }; + + // Без справочников ФИО исполнителей в файле превратятся в email — лучше дождаться. + const dictionariesLoading = employees.isLoading || portalDictionaries.isLoading; + + return ( +
+ + + {isBusy ? texts.routesExport.exporting : texts.routesExport.export} + + } + /> + + {requestTypes.error && {requestTypes.error}} + {employees.error && {employees.error}} + {error && {error}} + + +

{texts.routesExport.hint}

+ {requestTypes.isLoading &&

{texts.common.loading}

} + {!requestTypes.isLoading && requestTypes.items.length === 0 &&

{texts.routesExport.emptyTypes}

} + {requestTypes.items.length > 0 && ( +
+ setSelected(checked ? allIds : [])} + /> + {requestTypes.items.map(rt => ( + toggle(rt.id, checked)} + /> + ))} +

{texts.routesExport.selected(selected.length)}

+
+ )} +
+
+ ); +}; diff --git a/frontend/src/webparts/approvalAdmin/models/index.ts b/frontend/src/webparts/approvalAdmin/models/index.ts index 4c8e0dc..7cd0345 100644 --- a/frontend/src/webparts/approvalAdmin/models/index.ts +++ b/frontend/src/webparts/approvalAdmin/models/index.ts @@ -9,3 +9,4 @@ export * from './templateRouteSteps'; export * from './resolve'; export * from './requestManage'; export * from './manageByUser'; +export * from './routeExport'; diff --git a/frontend/src/webparts/approvalAdmin/models/routeExport.ts b/frontend/src/webparts/approvalAdmin/models/routeExport.ts new file mode 100644 index 0000000..1a41943 --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/models/routeExport.ts @@ -0,0 +1,24 @@ +// DTO эндпоинта POST api/reports/routes (Application/Reports/RouteExportWorkbook.cs). + +export interface RouteExportRow { + number: number; + role: string; + executor: string; + condition: string; +} + +export interface RouteExportSection { + /** Заголовок маршрута — только когда у типа заявки их несколько. */ + title?: string; + rows: RouteExportRow[]; +} + +/** Лист файла — один тип заявки. */ +export interface RouteExportSheet { + name: string; + sections: RouteExportSection[]; +} + +export interface RouteExportRequest { + sheets: RouteExportSheet[]; +} diff --git a/frontend/src/webparts/approvalAdmin/routing.ts b/frontend/src/webparts/approvalAdmin/routing.ts index fe8ba58..a094c36 100644 --- a/frontend/src/webparts/approvalAdmin/routing.ts +++ b/frontend/src/webparts/approvalAdmin/routing.ts @@ -89,7 +89,7 @@ const ROUTE_CHANGE_EVENT = 'aa-route-change'; // 'routeSteps' сюда намеренно не входит — это не отдельная вкладка URL, а // состояние вкладки 'routes' при наличии aaroute (см. parseRoute). -const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'requestTypeAccess', 'routes', 'resolve', 'manageRequests', 'manageByUser']; +const TABS: ScreenKey[] = ['dashboard', 'requestTypes', 'routes', 'resolve', 'routesExport', 'manageRequests', 'manageByUser', 'requestTypeAccess']; const PARAM_KEYS = [ 'aatab', 'aaroute', 'aarfilter', diff --git a/frontend/src/webparts/approvalAdmin/services/api.ts b/frontend/src/webparts/approvalAdmin/services/api.ts index 1c041f1..8df7528 100644 --- a/frontend/src/webparts/approvalAdmin/services/api.ts +++ b/frontend/src/webparts/approvalAdmin/services/api.ts @@ -13,6 +13,7 @@ import { PatchTemplateRouteInput, PatchTemplateRouteStepInput, RequestListPartition, + RouteExportRequest, RequestType, RequestTypeAccess, RequestTypeListSeries, @@ -172,4 +173,10 @@ export class ApprovalRouteApi { }) .then(rows => rows.map(toUserApprovalRow)) }; + + // Выгрузки файлами (ReportsController) — в ответ приходит xlsx, не JSON. + public readonly reports = { + routes: (body: RouteExportRequest): Promise => + this.http.postForBlob('/api/reports/routes', body) + }; } diff --git a/frontend/src/webparts/approvalAdmin/services/httpClient.ts b/frontend/src/webparts/approvalAdmin/services/httpClient.ts index 399b1e5..db7c264 100644 --- a/frontend/src/webparts/approvalAdmin/services/httpClient.ts +++ b/frontend/src/webparts/approvalAdmin/services/httpClient.ts @@ -26,6 +26,8 @@ export interface IHttpClient { put(path: string, body?: unknown): Promise; patch(path: string, body?: unknown): Promise; delete(path: string): Promise; + /** POST, на который сервер отвечает файлом (xlsx), а не JSON-конвертом. */ + postForBlob(path: string, body: unknown): Promise; } function buildUrl(baseUrl: string, path: string, query?: QueryParams): string { @@ -105,6 +107,21 @@ export class AadApiHttpClient implements IHttpClient { await this.send('DELETE', path); } + public async postForBlob(path: string, body: unknown): Promise { + const url = buildUrl(this.baseUrl, path); + const response = await this.client.fetch(url, AadHttpClient.configurations.v1, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + if (!response.ok) { + // Ошибка приходит обычным JSON-конвертом ({ messages } или { errors }) — разбираем тем же парсером. + await parseResponse(response); + throw new ApiClientError(response.status, [`HTTP ${response.status}`]); + } + return response.blob(); + } + private send(method: string, path: string, body?: unknown): Promise { const url = buildUrl(this.baseUrl, path); return this.client diff --git a/frontend/src/webparts/approvalAdmin/texts.ts b/frontend/src/webparts/approvalAdmin/texts.ts index b6048f2..59c4c0e 100644 --- a/frontend/src/webparts/approvalAdmin/texts.ts +++ b/frontend/src/webparts/approvalAdmin/texts.ts @@ -96,6 +96,7 @@ export const texts = { requestTypeAccess: 'Доступ к заявкам', routes: 'Маршруты', resolve: 'Проверка маршрута', + routesExport: 'Выгрузка маршрутов', manageRequests: 'Редактирование заявок', manageByUser: 'Заявки по участнику' }, @@ -206,6 +207,24 @@ export const texts = { `${email} больше не будет видеть все заявки типа «${requestTypeName}».` }, + routesExport: { + breadcrumb: 'Выгрузка маршрутов', + subtitle: 'Маршруты согласования в Excel', + title: 'Выгрузка маршрутов', + hint: 'Отметьте типы заявок — каждый попадёт на отдельный лист файла (роль, исполнитель, условие).', + selectAll: 'Выбрать все', + export: 'Выгрузить', + exporting: 'Формирование файла...', + emptyTypes: 'Типов заявок пока нет.', + selected: (count: number): string => `Выбрано: ${count}`, + routeTitle: (company: string, department: string, byProject: boolean): string => + `Маршрут: ${company} · ${department}${byProject ? ' · по проекту' : ''}`, + always: 'Всегда', + executorByRequest: 'Определяется по заявке', + fileName: 'Маршруты согласования.xlsx', + failed: (reason: string): string => `Не удалось выгрузить маршруты: ${reason}` + }, + routes: { breadcrumb: 'Маршруты согласования', subtitle: 'Настройка согласований', diff --git a/frontend/src/webparts/approvalAdmin/utils/routeExport.ts b/frontend/src/webparts/approvalAdmin/utils/routeExport.ts new file mode 100644 index 0000000..7974f1b --- /dev/null +++ b/frontend/src/webparts/approvalAdmin/utils/routeExport.ts @@ -0,0 +1,71 @@ +import { Company, Department, Employee, HeadType, ProjectRole, RequestType, RouteExportRow, RouteExportSheet, TemplateRoute, TemplateRouteStep } from '../models'; +import { headTypeLabels, projectRoleLabels, texts } from '../texts'; +import { describeStepCondition } from './stepExecutor'; +import { findEmployeeByEmail } from './employee'; +import { shortGuid } from './guid'; + +export interface RouteWithSteps { + route: TemplateRoute; + steps: TemplateRouteStep[]; +} + +/** + * Роль и исполнитель шага для выгрузки — как в ручной таблице HR: «роль» это + * должность/тип руководителя, «исполнитель» — конкретный человек, если он + * известен уже на этапе настройки маршрута. + */ +function describeRoleAndExecutor(step: TemplateRouteStep, employees: Employee[]): { role: string; executor: string } { + if (step.executorEmail) { + const employee = findEmployeeByEmail(employees, step.executorEmail); + return { role: employee?.position || texts.common.empty, executor: employee?.fio ?? step.executorEmail }; + } + if (step.position) { + const wanted = step.position.trim().toLowerCase(); + const holder = employees.find(e => e.position.trim().toLowerCase() === wanted); + return { role: step.position, executor: holder?.fio ?? texts.common.empty }; + } + if (step.projectRole && step.projectRole !== ProjectRole.None) { + return { role: projectRoleLabels[step.projectRole], executor: texts.routesExport.executorByRequest }; + } + if (step.headType && step.headType !== HeadType.None) { + return { role: headTypeLabels[step.headType], executor: texts.routesExport.executorByRequest }; + } + return { role: texts.common.empty, executor: texts.common.empty }; +} + +/** Строки листа: параллельные шаги (равный orderNo) получают один и тот же номер. */ +export function buildRouteRows(steps: TemplateRouteStep[], employees: Employee[]): RouteExportRow[] { + const orderNos = Array.from(new Set(steps.map(s => s.orderNo))).sort((a, b) => a - b); + return [...steps] + .sort((a, b) => a.orderNo - b.orderNo) + .map(step => ({ + number: orderNos.indexOf(step.orderNo) + 1, + ...describeRoleAndExecutor(step, employees), + condition: describeStepCondition(step) ?? texts.routesExport.always + })); +} + +/** Лист на тип заявки. Заголовки маршрутов нужны, только когда их у типа несколько. */ +export function buildRouteSheet( + requestType: RequestType, + items: RouteWithSteps[], + employees: Employee[], + companies: Company[], + departments: Department[] +): RouteExportSheet { + const title = (route: TemplateRoute): string | undefined => { + if (items.length < 2) return undefined; + const company = route.companyId + ? companies.find(c => c.id === route.companyId)?.name ?? shortGuid(route.companyId) + : texts.routeSteps.anyCompany; + const department = route.departmentId + ? departments.find(d => d.id === route.departmentId)?.name ?? shortGuid(route.departmentId) + : texts.routeSteps.anyDepartment; + return texts.routesExport.routeTitle(company, department, route.byProject); + }; + + return { + name: requestType.name, + sections: items.map(i => ({ title: title(i.route), rows: buildRouteRows(i.steps, employees) })) + }; +} -- 2.34.1 From 0b06e2b9585b0d907272da1ea05baa4587eecfb7 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 13 Oct 2026 09:29:07 +0500 Subject: [PATCH 02/13] =?UTF-8?q?=D0=A1=D0=B2=D0=B5=D1=80=D0=BD=D1=83?= =?UTF-8?q?=D1=82=D1=8C=20appsettings.*.json=20=D0=BF=D0=BE=D0=B4=20appset?= =?UTF-8?q?tings.json=20=D0=B2=20Solution=20Explorer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj b/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj index ac24261..245a76d 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj +++ b/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj @@ -3,6 +3,10 @@ + + + + -- 2.34.1 From 75214f57975ea8f05957d6c3c48954848beb75a8 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 13 Oct 2026 09:38:13 +0500 Subject: [PATCH 03/13] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B0=D0=BB=20=D0=BD=D0=B0=20=D0=BD=D0=B5=D1=87=D0=B5?= =?UTF-8?q?=D1=82=D0=BA=D0=B8=D0=B9=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj b/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj index 245a76d..b663634 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj +++ b/backend/Tnsu.ApprovalRoute.Api/Tnsu.ApprovalRoute.Api.csproj @@ -4,8 +4,7 @@ - - + -- 2.34.1 From 43b3c77ac14eff3cc05b63aee5d9ec08cb76f1f8 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 13 Oct 2026 09:50:04 +0500 Subject: [PATCH 04/13] =?UTF-8?q?=D0=A1=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D0=B2=D1=8B=D0=B1=D1=80=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=B3=D0=B0=D0=BB=D0=BE=D1=87=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B5=20?= =?UTF-8?q?=C2=AB=D0=92=D1=8B=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=80=D1=88=D1=80=D1=83=D1=82=D0=BE=D0=B2=C2=BB=20=D0=B2?= =?UTF-8?q?=20localStorage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../components/screens/RoutesExportScreen.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx index f0c5270..5407a06 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx @@ -3,6 +3,7 @@ import { ApprovalRouteApi } from '../../services/api'; import { PortalDictionaryClient } from '../../services/portalDictionary'; import { texts } from '../../texts'; import { useRequestTypes } from '../../hooks/useRequestTypes'; +import { useLocalStorageState } from '../../hooks/useLocalStorageState'; import { usePortalEmployees } from '../../hooks/usePortalEmployees'; import { usePortalDictionaries } from '../../hooks/usePortalDictionaries'; import { reportError } from '../../utils/errors'; @@ -15,6 +16,10 @@ export interface IRoutesExportScreenProps { dictionary?: PortalDictionaryClient; } +// Выбранные типы заявок переживают перезагрузку страницы — выгрузку обычно +// делают по одному и тому же набору типов. +const SELECTED_STORAGE_KEY = 'approvalAdmin.routesExport.selected'; + function download(blob: Blob, fileName: string): void { const url = URL.createObjectURL(blob); const link = document.createElement('a'); @@ -31,15 +36,17 @@ export const RoutesExportScreen: React.FC = ({ api, di const employees = usePortalEmployees(dictionary); const portalDictionaries = usePortalDictionaries(dictionary); - const [selected, setSelected] = React.useState([]); + const [storedSelected, setSelected] = useLocalStorageState(SELECTED_STORAGE_KEY, []); const [isBusy, setIsBusy] = React.useState(false); const [error, setError] = React.useState(); const allIds = requestTypes.items.map(rt => rt.id); + // Удалённые с тех пор типы из сохранённого выбора не считаем. + const selected = storedSelected.filter(id => allIds.indexOf(id) !== -1); const allSelected = allIds.length > 0 && allIds.every(id => selected.indexOf(id) !== -1); const toggle = (id: number, checked: boolean): void => - setSelected(current => (checked ? [...current, id] : current.filter(x => x !== id))); + setSelected(current => (checked ? [...current.filter(x => x !== id), id] : current.filter(x => x !== id))); const runExport = (): void => { setError(undefined); -- 2.34.1 From 6433299c6280ff80fbbb1cbdd84160c4e3c94ea4 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Tue, 13 Oct 2026 10:24:18 +0500 Subject: [PATCH 05/13] =?UTF-8?q?=D0=92=D1=8B=D0=B3=D1=80=D1=83=D0=B7?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83=D1=82=D0=BE?= =?UTF-8?q?=D0=B2:=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B0=20=D1=82=D0=B0?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D1=86=D1=8B=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5=D1=81=D0=B5=D0=BD=D0=B0=20=D0=BD=D0=B0=20=D0=B1=D1=8D?= =?UTF-8?q?=D0=BA=D0=B5=D0=BD=D0=B4,=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BE=D1=80=D0=B3=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F,=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B9=20=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B0=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B3=D0=BB=D0=B0=D1=81=D1=83=D1=8E=D1=89=D0=B8=D1=85/?= =?UTF-8?q?=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D0=BD=D0=B8=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бэкенд сам читает маршруты, шаги и справочники портала (сотрудники с должностями, компании, отделы), фронт шлёт только id типов заявок и скачивает файл. Колонки: №, Роль, Исполнитель, Организация, Условие, Комментарий. Согласующие и исполнители — отдельными таблицами. Условие по CompaniesJson показывается как «организация». Директор Tansu и Управляющий директор Tansu выводятся с ФИО. Комментарий «назначает согласующего/исполнителя» — при галочке переназначения. Co-Authored-By: Claude Sonnet 5.5 --- .../Controllers/ReportsController.cs | 14 +- .../ApplicationServiceCollectionExtensions.cs | 1 + .../Reports/RouteExportService.cs | 249 ++++++++++++++++++ .../Reports/RouteExportWorkbook.cs | 75 ++++-- .../TnsuPortal/CachingTnsuPortalClient.cs | 6 + .../TnsuPortal/HttpTnsuPortalClient.cs | 26 +- .../TnsuPortal/ITnsuPortalClient.cs | 6 + .../TnsuPortal/TnsuPortalDtos.cs | 8 +- .../approvalAdmin/components/app/AdminApp.tsx | 2 +- .../components/screens/RoutesExportScreen.tsx | 30 +-- .../webparts/approvalAdmin/models/index.ts | 1 - .../approvalAdmin/models/routeExport.ts | 24 -- .../webparts/approvalAdmin/services/api.ts | 5 +- frontend/src/webparts/approvalAdmin/texts.ts | 6 +- .../approvalAdmin/utils/routeExport.ts | 71 ----- 15 files changed, 357 insertions(+), 167 deletions(-) create mode 100644 backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs delete mode 100644 frontend/src/webparts/approvalAdmin/models/routeExport.ts delete mode 100644 frontend/src/webparts/approvalAdmin/utils/routeExport.ts diff --git a/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs b/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs index f2992f6..5fb61b6 100644 --- a/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs +++ b/backend/Tnsu.ApprovalRoute.Api/Controllers/ReportsController.cs @@ -15,7 +15,7 @@ namespace Tnsu.ApprovalRoute.Api.Controllers; [ApiController] [Authorize(Policy = AuthPolicies.Any)] [Route("api/reports")] -public sealed class ReportsController(VacancyReportService vacancies) : ControllerBase +public sealed class ReportsController(VacancyReportService vacancies, RouteExportService routeExport) : ControllerBase { private const string XlsxContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; @@ -54,19 +54,19 @@ public sealed class ReportsController(VacancyReportService vacancies) : Controll /// /// Выгрузка маршрутов согласования выбранных типов заявок в xlsx (лист на тип). - /// POST, потому что строки собирает админка — у неё уже есть справочники - /// сотрудников и подписи шагов (см. RouteExportWorkbook). + /// POST — потому что в теле список id типов (см. RouteExportService). /// [HttpPost("routes")] [Produces(XlsxContentType)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - public IActionResult Routes([FromBody] RouteExportRequest request) + public async Task Routes([FromBody] RouteExportRequest request, CancellationToken ct = default) { - if (request.Sheets is not { Count: > 0 }) - return BadRequest(new { errors = new[] { "Не выбрано ни одного типа заявки." } }); + var result = await routeExport.BuildAsync(request.RequestTypeIds, ct); + if (result.IsFailed) + return BadRequest(new { errors = result.Errors.Select(e => e.Message) }); - var file = RouteExportWorkbook.Build(request); + var file = result.Value; var disposition = new ContentDispositionHeaderValue("attachment"); disposition.SetHttpFileName(RouteExportWorkbook.FileName); diff --git a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs index 4ae58c7..64527fc 100644 --- a/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/Tnsu.ApprovalRoute.Application/ApplicationServiceCollectionExtensions.cs @@ -28,6 +28,7 @@ public static class ApplicationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs new file mode 100644 index 0000000..22d4569 --- /dev/null +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportService.cs @@ -0,0 +1,249 @@ +using FluentResults; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Tnsu.ApprovalRoute.Domain.Entities; +using Tnsu.ApprovalRoute.Domain.Enums; +using Tnsu.ApprovalRoute.Infrastructure.Persistence; +using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; + +namespace Tnsu.ApprovalRoute.Application.Reports; + +/// +/// Выгрузка маршрутов согласования: читает маршруты и шаги выбранных типов +/// заявок, превращает их в понятные человеку строки (роль, исполнитель, +/// организация, условие, комментарий) и отдаёт на вёрстку в +/// . +/// +/// Шаги делятся на две таблицы — «Согласующие» и «Исполнители» (фаза шага, +/// ), у каждой своя нумерация. +/// +public sealed class RouteExportService( + ApprovalRouteDbContext db, + ITnsuPortalClient portalClient, + IOptions portalOptions) +{ + private const string Empty = "—"; + private const string Always = "Всегда"; + private const string AnyOrganization = "Любая"; + private const string AnyCompany = "любая компания"; + private const string AnyDepartment = "любой отдел"; + private const string ByRequest = "Определяется по заявке"; + + /// + /// Технические имена колонок списка, которые в таблице показываем по-человечески + /// (CompaniesJson — организации заявки). + /// + private static readonly Dictionary ConditionFieldAliases = new(StringComparer.Ordinal) + { + ["CompaniesJson"] = "организация" + }; + + private static readonly Dictionary ProjectRoleLabels = new() + { + [ProjectRole.ProjectManager] = "Руководитель проекта", + [ProjectRole.Planner] = "Планировщик", + [ProjectRole.ManagerHSE] = "Менеджер HSE", + [ProjectRole.Security] = "Безопасность", + [ProjectRole.Keeper] = "Заведующий склада" + }; + + private static readonly Dictionary HeadTypeLabels = new() + { + [HeadType.Chief] = "Непосредственный руководитель", + [HeadType.DirectionHead] = "Руководитель направления", + [HeadType.DirectorTansu] = "Директор Tansu", + [HeadType.ManagingDirectorTansu] = "Управляющий директор Tansu", + [HeadType.DepartmentHead] = "Руководитель указанного отдела", + [HeadType.CompanyDirectors] = "Директора компаний инициатора" + }; + + private static readonly Dictionary OperatorSymbols = new() + { + [ComparisonOperator.GreaterThan] = ">", + [ComparisonOperator.LessThan] = "<", + [ComparisonOperator.Equal] = "=", + [ComparisonOperator.NotEqual] = "≠", + [ComparisonOperator.Contains] = "содержит", + [ComparisonOperator.NotContains] = "не содержит" + }; + + public async Task> BuildAsync(IReadOnlyList? requestTypeIds, CancellationToken cancellationToken) + { + if (requestTypeIds is not { Count: > 0 }) + return Result.Fail("Не выбрано ни одного типа заявки."); + + var types = await db.RequestTypes.AsNoTracking() + .Where(x => requestTypeIds.Contains(x.Id)) + .OrderBy(x => x.Name) + .ToListAsync(cancellationToken); + if (types.Count == 0) + return Result.Fail("Выбранные типы заявок не найдены."); + + var routes = await db.TemplateRoutes.AsNoTracking() + .Where(x => requestTypeIds.Contains(x.RequestTypeId)) + .Include(x => x.Steps).ThenInclude(s => s.Conditions) + .OrderBy(x => x.Id) + .ToListAsync(cancellationToken); + + var directory = await LoadDirectoryAsync(cancellationToken); + + var sheets = types + .Select(type => BuildSheet(type, routes.Where(r => r.RequestTypeId == type.Id).ToList(), directory)) + .ToList(); + + return Result.Ok(RouteExportWorkbook.Build(sheets)); + } + + private async Task LoadDirectoryAsync(CancellationToken cancellationToken) + { + // Справочники кешируются клиентом портала (см. CachingTnsuPortalClient). + var employees = await portalClient.GetAllEmployeesAsync(cancellationToken); + var companies = await portalClient.GetCompaniesAsync(cancellationToken); + var departments = await portalClient.GetDepartmentsAsync(cancellationToken); + + return new PortalDirectory( + employees, + companies.GroupBy(c => c.Id).ToDictionary(g => g.Key, g => g.First().Name), + departments.GroupBy(d => d.Id).ToDictionary(g => g.Key, g => g.First().Name)); + } + + private RouteExportSheet BuildSheet(RequestType type, IReadOnlyList routes, PortalDirectory directory) + { + var sections = routes + .Select(route => + { + var organization = route.CompanyId is { } companyId + ? directory.CompanyName(companyId) + : AnyOrganization; + return new RouteExportSection( + routes.Count < 2 ? null : RouteTitle(route, directory), + BuildGroups(route.Steps, organization, directory)); + }) + .ToList(); + + return new RouteExportSheet(type.Name, sections); + } + + private static string RouteTitle(TemplateRoute route, PortalDirectory directory) + { + var company = route.CompanyId is { } c ? directory.CompanyName(c) : AnyCompany; + var department = route.DepartmentId is { } d ? directory.DepartmentName(d) : AnyDepartment; + return $"Маршрут: {company} · {department}{(route.ByProject ? " · по проекту" : string.Empty)}"; + } + + private List BuildGroups(IEnumerable steps, string organization, PortalDirectory directory) + { + var ordered = steps.OrderBy(s => s.OrderNo).ToList(); + var approvers = ordered.Where(s => s.Status != RouteStepStatus.Execution).ToList(); + var executors = ordered.Where(s => s.Status == RouteStepStatus.Execution).ToList(); + + return new[] + { + new RouteExportGroup("Согласующие", BuildRows(approvers, organization, directory)), + new RouteExportGroup("Исполнители", BuildRows(executors, organization, directory)) + } + .Where(g => g.Rows.Count > 0) + .ToList(); + } + + // Параллельные шаги (равный OrderNo) получают один и тот же номер. + private List BuildRows(IReadOnlyList steps, string organization, PortalDirectory directory) + { + var orderNos = steps.Select(s => s.OrderNo).Distinct().OrderBy(n => n).ToList(); + + return steps + .Select(step => + { + var (role, executor) = DescribeRoleAndExecutor(step, directory); + return new RouteExportRow( + orderNos.IndexOf(step.OrderNo) + 1, + role, + executor, + organization, + DescribeCondition(step), + DescribeComment(step)); + }) + .ToList(); + } + + // «Роль» — должность или тип руководителя, «исполнитель» — конкретный человек, + // если он известен уже при настройке маршрута. Директор Tansu и Управляющий + // директор Tansu — тоже конкретные люди (их email лежит в настройках). + private (string Role, string Executor) DescribeRoleAndExecutor(TemplateRouteStep step, PortalDirectory directory) + { + if (!string.IsNullOrWhiteSpace(step.ExecutorEmail)) + { + var employee = directory.FindByEmail(step.ExecutorEmail); + return (EmptyIfBlank(employee?.Position), employee?.Fio ?? step.ExecutorEmail); + } + + if (!string.IsNullOrWhiteSpace(step.Position)) + return (step.Position, directory.FindByPosition(step.Position)?.Fio ?? Empty); + + if (step.ProjectRole is { } projectRole && projectRole != ProjectRole.None) + return (ProjectRoleLabels[projectRole], ByRequest); + + if (step.HeadType is { } headType && headType != HeadType.None) + { + var fixedEmail = headType switch + { + HeadType.DirectorTansu => portalOptions.Value.DirectorTansuEmail, + HeadType.ManagingDirectorTansu => portalOptions.Value.ManagingDirectorTansuEmail, + _ => null + }; + if (fixedEmail is not null) + // Нет в справочнике — лучше показать email, чем пустую ячейку. + return (HeadTypeLabels[headType], directory.FindByEmail(fixedEmail)?.Fio ?? fixedEmail); + + return (HeadTypeLabels[headType], ByRequest); + } + + return (Empty, Empty); + } + + private static string DescribeCondition(TemplateRouteStep step) + { + if (step.Conditions.Count == 0) + return Always; + + var joiner = step.ConditionsLogic == ConditionsLogic.Or ? " ИЛИ " : " И "; + return string.Join(joiner, step.Conditions.OrderBy(c => c.OrderNo).Select(c => + { + var field = ConditionFieldAliases.GetValueOrDefault(c.FieldName, c.FieldName); + var symbol = OperatorSymbols.GetValueOrDefault(c.Operation, string.Empty); + return $"{field} {symbol} {c.Value}"; + })); + } + + // Галочка «можно переназначить» — шаг может передать другому: согласующий + // назначает согласующего, исполнитель — исполнителя. + private static string DescribeComment(TemplateRouteStep step) => + !step.AllowReassignToOtherApprovers + ? string.Empty + : step.Status == RouteStepStatus.Execution ? "назначает исполнителя" : "назначает согласующего"; + + private static string EmptyIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? Empty : value; + + private sealed class PortalDirectory( + IReadOnlyList employees, + IReadOnlyDictionary companies, + IReadOnlyDictionary departments) + { + // Справочник даёт строку на каждую компанию сотрудника — берём первую с + // заполненной должностью, остальные для ФИО/должности ничего не добавляют. + public EmployeeContactInfo? FindByEmail(string email) => + employees.Where(e => string.Equals(e.Email, email, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(e => !string.IsNullOrWhiteSpace(e.Position)) + .FirstOrDefault(); + + public EmployeeContactInfo? FindByPosition(string position) => + employees.FirstOrDefault(e => string.Equals(e.Position?.Trim(), position.Trim(), StringComparison.OrdinalIgnoreCase)); + + // Пока справочник не отдал имя (или запись удалена) — усечённый Guid, как в админке. + public string CompanyName(Guid id) => companies.GetValueOrDefault(id) ?? ShortGuid(id); + + public string DepartmentName(Guid id) => departments.GetValueOrDefault(id) ?? ShortGuid(id); + + private static string ShortGuid(Guid id) => id.ToString()[..8]; + } +} diff --git a/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs index c7cfc4b..eb1fddd 100644 --- a/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs +++ b/backend/Tnsu.ApprovalRoute.Application/Reports/RouteExportWorkbook.cs @@ -4,28 +4,37 @@ using NPOI.XSSF.UserModel; namespace Tnsu.ApprovalRoute.Application.Reports; -/// Строка выгрузки маршрута: шаг в понятном человеку виде (подписи уже собрала админка). -public sealed record RouteExportRow(int Number, string Role, string Executor, string Condition); +/// Строка выгрузки маршрута: шаг в понятном человеку виде. +public sealed record RouteExportRow( + int Number, + string Role, + string Executor, + string Organization, + string Condition, + string Comment); + +/// Таблица внутри маршрута — «Согласующие» или «Исполнители» (у каждой своя нумерация). +public sealed record RouteExportGroup(string Caption, IReadOnlyList Rows); /// /// Один маршрут типа заявки. Заголовок задан, когда у типа несколько маршрутов /// (по компании/отделу) и их нужно различать на одном листе; у единственного /// маршрута он пуст — лист выглядит как в образце «Маршруты согласования». /// -public sealed record RouteExportSection(string? Title, IReadOnlyList Rows); +public sealed record RouteExportSection(string? Title, IReadOnlyList Groups); /// Лист выгрузки — один тип заявки. public sealed record RouteExportSheet(string Name, IReadOnlyList Sections); -public sealed record RouteExportRequest(IReadOnlyList Sheets); +/// Тело запроса POST api/reports/routes — какие типы заявок выгрузить. +public sealed record RouteExportRequest(IReadOnlyList RequestTypeIds); /// /// Выгрузка маршрутов согласования в xlsx — по образцу, который HR ведёт руками: /// лист на тип заявки, колонки «№ / Роль / Исполнитель / Условие». /// -/// Подписи (роль, ФИО исполнителя, текст условия) приходят готовыми из админки: -/// там уже есть справочники сотрудников и логика описания шага, дублировать их -/// на бэкенде незачем. Здесь только вёрстка файла. +/// Здесь только вёрстка файла; подписи (роль, ФИО, текст условия) собирает +/// . /// public static class RouteExportWorkbook { @@ -37,12 +46,14 @@ public static class RouteExportWorkbook (" ", 6.0), ("Роль ", 41.1), ("Исполнитель ", 42.2), + ("Организация ", 28.0), ("Условие ", 66.2), + ("Комментарий ", 36.0), ]; public const string FileName = "Маршруты согласования.xlsx"; - public static byte[] Build(RouteExportRequest request) + public static byte[] Build(IReadOnlyList sheets) { var workbook = new XSSFWorkbook(); @@ -51,7 +62,7 @@ public static class RouteExportWorkbook var titleStyle = CreateStyle(workbook, bold: true, border: false); var usedNames = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var sheetData in request.Sheets) + foreach (var sheetData in sheets) { var sheet = workbook.CreateSheet(UniqueSheetName(sheetData.Name, usedNames)); for (var i = 0; i < Columns.Length; i++) @@ -60,8 +71,6 @@ public static class RouteExportWorkbook var rowIndex = 0; foreach (var section in sheetData.Sections) { - if (rowIndex > 0) rowIndex++; // пустая строка между маршрутами - if (!string.IsNullOrWhiteSpace(section.Title)) { var titleRow = sheet.CreateRow(rowIndex); @@ -72,23 +81,37 @@ public static class RouteExportWorkbook rowIndex++; } - var header = sheet.CreateRow(rowIndex++); - for (var i = 0; i < Columns.Length; i++) + foreach (var group in section.Groups) { - var cell = header.CreateCell(i); - cell.SetCellValue(Columns[i].Title); - cell.CellStyle = headerStyle; - } + var captionRow = sheet.CreateRow(rowIndex); + var captionCell = captionRow.CreateCell(0); + captionCell.SetCellValue(group.Caption); + captionCell.CellStyle = titleStyle; + sheet.AddMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 0, Columns.Length - 1)); + rowIndex++; - foreach (var row in section.Rows) - { - var excelRow = sheet.CreateRow(rowIndex++); - var numberCell = excelRow.CreateCell(0); - numberCell.SetCellValue(row.Number); - numberCell.CellStyle = cellStyle; - WriteText(excelRow, 1, row.Role, cellStyle); - WriteText(excelRow, 2, row.Executor, cellStyle); - WriteText(excelRow, 3, row.Condition, cellStyle); + var header = sheet.CreateRow(rowIndex++); + for (var i = 0; i < Columns.Length; i++) + { + var cell = header.CreateCell(i); + cell.SetCellValue(Columns[i].Title); + cell.CellStyle = headerStyle; + } + + foreach (var row in group.Rows) + { + var excelRow = sheet.CreateRow(rowIndex++); + var numberCell = excelRow.CreateCell(0); + numberCell.SetCellValue(row.Number); + numberCell.CellStyle = cellStyle; + WriteText(excelRow, 1, row.Role, cellStyle); + WriteText(excelRow, 2, row.Executor, cellStyle); + WriteText(excelRow, 3, row.Organization, cellStyle); + WriteText(excelRow, 4, row.Condition, cellStyle); + WriteText(excelRow, 5, row.Comment, cellStyle); + } + + rowIndex++; // пустая строка между таблицами } } } diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs index 4b931e2..4947094 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/CachingTnsuPortalClient.cs @@ -30,6 +30,12 @@ public sealed class CachingTnsuPortalClient(HttpTnsuPortalClient inner, IMemoryC public Task> GetAllEmployeesAsync(CancellationToken cancellationToken) => GetOrAddAsync("emp:all", () => inner.GetAllEmployeesAsync(cancellationToken)); + public Task> GetCompaniesAsync(CancellationToken cancellationToken) => + GetOrAddAsync("dict:companies", () => inner.GetCompaniesAsync(cancellationToken)); + + public Task> GetDepartmentsAsync(CancellationToken cancellationToken) => + GetOrAddAsync("dict:departments", () => inner.GetDepartmentsAsync(cancellationToken)); + public Task GetManagerAsync(string email, string managerType, CancellationToken cancellationToken) => GetOrAddAsync( $"mgr:{email.ToLowerInvariant()}|{managerType.ToLowerInvariant()}", diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs index 4a56e4e..8fb9a9c 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs @@ -40,7 +40,27 @@ public sealed class HttpTnsuPortalClient( return employees .Where(e => !string.IsNullOrWhiteSpace(e.Fio)) - .Select(e => new EmployeeContactInfo(e.Id, e.Fio!, e.Email, e.Company ?? string.Empty)) + .Select(e => new EmployeeContactInfo(e.Id, e.Fio!, e.Email, e.Company ?? string.Empty, e.Position)) + .ToList(); + } + + // /Dictionary/* отдаёт «голый» массив с ключами в PascalCase (Id/Name) — + // PropertyNameCaseInsensitive в JsonOptions это покрывает. + public async Task> GetCompaniesAsync(CancellationToken cancellationToken) + { + var companies = await GetAsync>("/Dictionary/Companies", cancellationToken); + return (companies ?? []) + .Where(c => c.Id is not null && !string.IsNullOrWhiteSpace(c.Name)) + .Select(c => new CompanyInfo(c.Id!.Value, c.Name!)) + .ToList(); + } + + public async Task> GetDepartmentsAsync(CancellationToken cancellationToken) + { + var departments = await GetAsync>("/Dictionary/Departments", cancellationToken); + return (departments ?? []) + .Where(d => d.Id is not null && !string.IsNullOrWhiteSpace(d.Name)) + .Select(d => new DepartmentInfo(d.Id!.Value, d.Name!)) .ToList(); } @@ -121,6 +141,10 @@ public sealed class HttpTnsuPortalClient( [property: JsonPropertyName("fio")] string? Fio, [property: JsonPropertyName("id")] Guid? Id = null); + private sealed record DictionaryItemResponse( + [property: JsonPropertyName("id")] Guid? Id, + [property: JsonPropertyName("name")] string? Name); + private sealed record ManagerResponse( [property: JsonPropertyName("managerEmail")] string? ManagerEmail); diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs index 4da2118..0222c44 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/ITnsuPortalClient.cs @@ -20,6 +20,12 @@ public interface ITnsuPortalClient /// Task> GetAllEmployeesAsync(CancellationToken cancellationToken); + /// Справочник компаний — /Dictionary/Companies. + Task> GetCompaniesAsync(CancellationToken cancellationToken); + + /// Справочник отделов — /Dictionary/Departments. + Task> GetDepartmentsAsync(CancellationToken cancellationToken); + Task GetManagerAsync(string email, string managerType, CancellationToken cancellationToken); /// diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs index 7940891..ee37eb2 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalDtos.cs @@ -30,4 +30,10 @@ public sealed record EmployeeFullBaseInfo(Guid? Id, string Fio, string Company, /// увольнения (в отличие от /Employee/FullBase). Связать одну базу с другой можно /// по — он один и тот же в обоих ответах. /// -public sealed record EmployeeContactInfo(Guid? Id, string Fio, string? Email, string Company); +public sealed record EmployeeContactInfo(Guid? Id, string Fio, string? Email, string Company, string? Position = null); + +/// Компания из справочника /Dictionary/Companies. +public sealed record CompanyInfo(Guid Id, string Name); + +/// Отдел из справочника /Dictionary/Departments. +public sealed record DepartmentInfo(Guid Id, string Name); diff --git a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx index b568590..c2bab51 100644 --- a/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx +++ b/frontend/src/webparts/approvalAdmin/components/app/AdminApp.tsx @@ -186,7 +186,7 @@ export const AdminApp: React.FC = ({ api, dictionary, sharePoint route={route.resolve} /> )} - {route.tab === 'routesExport' && } + {route.tab === 'routesExport' && } {route.tab === 'manageRequests' && ( )} diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx index 5407a06..4ed796a 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RoutesExportScreen.tsx @@ -1,19 +1,13 @@ import * as React from 'react'; import { ApprovalRouteApi } from '../../services/api'; -import { PortalDictionaryClient } from '../../services/portalDictionary'; import { texts } from '../../texts'; import { useRequestTypes } from '../../hooks/useRequestTypes'; import { useLocalStorageState } from '../../hooks/useLocalStorageState'; -import { usePortalEmployees } from '../../hooks/usePortalEmployees'; -import { usePortalDictionaries } from '../../hooks/usePortalDictionaries'; import { reportError } from '../../utils/errors'; -import { buildRouteSheet, RouteWithSteps } from '../../utils/routeExport'; import { Alert, Breadcrumbs, Button, Checkbox, PageHeader, Panel } from '../ui'; export interface IRoutesExportScreenProps { api: ApprovalRouteApi; - /** Undefined, пока SPFx выдаёт AAD-клиент для api.tnsu.kz. */ - dictionary?: PortalDictionaryClient; } // Выбранные типы заявок переживают перезагрузку страницы — выгрузку обычно @@ -31,10 +25,8 @@ function download(blob: Blob, fileName: string): void { URL.revokeObjectURL(url); } -export const RoutesExportScreen: React.FC = ({ api, dictionary }) => { +export const RoutesExportScreen: React.FC = ({ api }) => { const requestTypes = useRequestTypes(api); - const employees = usePortalEmployees(dictionary); - const portalDictionaries = usePortalDictionaries(dictionary); const [storedSelected, setSelected] = useLocalStorageState(SELECTED_STORAGE_KEY, []); const [isBusy, setIsBusy] = React.useState(false); @@ -51,27 +43,12 @@ export const RoutesExportScreen: React.FC = ({ api, di const runExport = (): void => { setError(undefined); setIsBusy(true); - // Порядок листов — как в списке типов, а не в порядке кликов. - const types = requestTypes.items.filter(rt => selected.indexOf(rt.id) !== -1); - - Promise.all( - types.map(async rt => { - const routes = await api.templateRoutes.list(rt.id); - const items: RouteWithSteps[] = await Promise.all( - routes.map(async route => ({ route, steps: await api.templateRouteSteps.list(route.id) })) - ); - return buildRouteSheet(rt, items, employees.items, portalDictionaries.companies, portalDictionaries.departments); - }) - ) - .then(sheets => api.reports.routes({ sheets })) + api.reports.routes(selected) .then(blob => download(blob, texts.routesExport.fileName)) .catch((e: Error) => setError(texts.routesExport.failed(reportError(e, 'RoutesExportScreen: export')))) .then(() => setIsBusy(false), () => setIsBusy(false)); }; - // Без справочников ФИО исполнителей в файле превратятся в email — лучше дождаться. - const dictionariesLoading = employees.isLoading || portalDictionaries.isLoading; - return (
@@ -83,7 +60,7 @@ export const RoutesExportScreen: React.FC = ({ api, di