From bed190835e6fc2a13544bedf38c3e9048ed7c986 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 1 Oct 2026 16:46:43 +0500 Subject: [PATCH 01/24] =?UTF-8?q?feat:=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B0=20=D0=BE=D1=82=D0=BF?= =?UTF-8?q?=D1=83=D1=81=D0=BA=20=D1=83=D1=85=D0=BE=D0=B4=D0=B8=D1=82=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D1=8C=20?= =?UTF-8?q?=D0=B2=20=D0=AD=D0=94=D0=9E=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B3=D0=BB=D0=B0=D1=81=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Когда последний согласующий согласовывает заявку на отпуск, заявление собирается в браузере как DOCX и отправляется в ЭДО (POST /api/v1/integration/signing-requests). Подписывает сотрудник из заявки, его же почта передаётся как initiator_email. ИИН берётся из Employee/ByEmail, БИН компании — из Dictionary/Companies. Неудача отправки не отменяет согласование и показывается сообщением с кнопкой повтора. Свойство веб-части «С каким ЭДО работать»: стейдж (https://10.0.1.225:8443) или локальный бэкенд (https://localhost:7016). jszip объявлен зависимостью явно (раньше был транзитивным). Co-Authored-By: Claude Sonnet 5.5 --- package-lock.json | 1 + package.json | 1 + .../RequestPortalWebPart.manifest.json | 2 +- .../requestPortal/RequestPortalWebPart.ts | 25 +++ .../requestPortal/forms/Vacation/README.md | 18 +++ .../forms/Vacation/VacationView.tsx | 45 +++++- .../requestPortal/forms/Vacation/dates.ts | 12 ++ .../forms/Vacation/sendToSign.ts | 82 ++++++++++ .../requestPortal/forms/Vacation/submit.ts | 8 +- .../forms/Vacation/vacationDocument.test.ts | 96 +++++++++++ .../forms/Vacation/vacationDocument.ts | 149 ++++++++++++++++++ src/webparts/requestPortal/shared/config.ts | 27 ++++ .../employeeOrganizations.service.ts | 1 + .../shared/dictionary/models/ICompany.ts | 4 +- .../models/IEmployeeOrganization.ts | 2 + .../requestPortal/shared/edo/edo.service.ts | 80 ++++++++++ .../requestPortal/shared/edo/edo.test.ts | 125 +++++++++++++++ .../requestPortal/shared/edo/edoErrors.ts | 37 +++++ src/webparts/requestPortal/shared/edo/fio.ts | 19 +++ .../requestPortal/shared/edo/signingTarget.ts | 53 +++++++ 20 files changed, 777 insertions(+), 10 deletions(-) create mode 100644 src/webparts/requestPortal/forms/Vacation/dates.ts create mode 100644 src/webparts/requestPortal/forms/Vacation/sendToSign.ts create mode 100644 src/webparts/requestPortal/forms/Vacation/vacationDocument.test.ts create mode 100644 src/webparts/requestPortal/forms/Vacation/vacationDocument.ts create mode 100644 src/webparts/requestPortal/shared/edo/edo.service.ts create mode 100644 src/webparts/requestPortal/shared/edo/edo.test.ts create mode 100644 src/webparts/requestPortal/shared/edo/edoErrors.ts create mode 100644 src/webparts/requestPortal/shared/edo/fio.ts create mode 100644 src/webparts/requestPortal/shared/edo/signingTarget.ts diff --git a/package-lock.json b/package-lock.json index f67ed01..7e37810 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@pnp/sp": "^4.20.0", "@pnp/spfx-controls-react": "^3.25.0", "@tnsu/ui-kit": "^0.12.0", + "jszip": "~3.8.0", "react": "17.0.1", "react-dom": "17.0.1", "tslib": "2.3.1" diff --git a/package.json b/package.json index f6a56db..27d97b3 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@pnp/sp": "^4.20.0", "@pnp/spfx-controls-react": "^3.25.0", "@tnsu/ui-kit": "^0.12.0", + "jszip": "~3.8.0", "react": "17.0.1", "react-dom": "17.0.1", "tslib": "2.3.1" diff --git a/src/webparts/requestPortal/RequestPortalWebPart.manifest.json b/src/webparts/requestPortal/RequestPortalWebPart.manifest.json index 4c47d65..9c5e9f3 100644 --- a/src/webparts/requestPortal/RequestPortalWebPart.manifest.json +++ b/src/webparts/requestPortal/RequestPortalWebPart.manifest.json @@ -16,6 +16,6 @@ "title": { "default": "request-portal" }, "description": { "default": "request-portal description" }, "officeFabricIconFontName": "Page", - "properties": { "notificationsEnabled": true } + "properties": { "notificationsEnabled": true, "edoApiBaseUrl": "https://10.0.1.225:8443" } }] } diff --git a/src/webparts/requestPortal/RequestPortalWebPart.ts b/src/webparts/requestPortal/RequestPortalWebPart.ts index 7de97bf..32e26c2 100644 --- a/src/webparts/requestPortal/RequestPortalWebPart.ts +++ b/src/webparts/requestPortal/RequestPortalWebPart.ts @@ -19,7 +19,10 @@ import { setImpersonatedIdentity } from './shared/impersonation'; import { APPROVAL_API_BASE_URL_LOCAL, APPROVAL_API_BASE_URL_PROD, + EDO_API_BASE_URL_LOCAL, + EDO_API_BASE_URL_STAGE, setApprovalApiBaseUrl, + setEdoApiBaseUrl, setNotificationSettings } from './shared/config'; import { initListPartitions } from './shared/approval'; @@ -38,6 +41,11 @@ export interface IRequestPortalWebPartProps { debugUserDisplayName: string; /** Куда шлём запросы approval-route (маршруты согласования) — прод или локальный backend. */ approvalApiBaseUrl: string; + /** + * С каким ЭДО работаем при отправке документов на подписание — локальным или + * стейджинговым. У веб-частей, добавленных до появления свойства, оно пустое — это стейдж. + */ + edoApiBaseUrl: string; /** * Уведомления согласующим (поток Power Automate). По умолчанию включены: * у веб-частей, добавленных до появления свойства, оно undefined — это тоже «вкл». @@ -58,6 +66,8 @@ export default class RequestPortalWebPart extends BaseClientSideWebPart = ({ context, itemId, formKey, const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); + // Ф.И.О. из заявки (Employee/Title) — запасной вариант для документа на подпись. + const [employeeName, setEmployeeName] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Какая из организаций сотрудника (см. OrganizationField) идёт в печать — // выбор в тулбаре через PrintSplitButton, по умолчанию первая. const [printOrgIndex, setPrintOrgIndex] = useState(0); + // Итог отправки согласованного заявления на подпись в ЭДО (см. sendToSigning). + const [signingInfo, setSigningInfo] = useState(''); + const [signingError, setSigningError] = useState(''); + const [signingBusy, setSigningBusy] = useState(false); // listId уже проверен в ApproveFeature (не смонтировались бы сюда иначе) — // vacationApprovalService можно звать не опасаясь исключения. @@ -84,6 +92,7 @@ const VacationView: React.FC = ({ context, itemId, formKey, setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); + setEmployeeName(loaded.employeeName); notifyCode(loaded.code); setError(null); setLoading(false); @@ -125,6 +134,27 @@ const VacationView: React.FC = ({ context, itemId, formKey, }).catch(() => undefined); }; + // Все согласовали → отправляем заявление на подпись сотруднику, на которого оформлен отпуск. + // Решение к этому моменту уже применено, поэтому неудача отправки показывается отдельным + // сообщением и согласование не отменяет. Повторить можно кнопкой, пока страница не закрыта: + // признака «уже отправлено» в заявке нет, и после перезагрузки повтор был бы дублем. + const sendToSigning = (): Promise => { + setSigningError(''); + setSigningInfo('Отправляем заявление на подпись в ЭДО…'); + setSigningBusy(true); + return sendVacationForSigning(context, { data, employeeName, itemId, code }) + .then(() => { + setSigningInfo(`Заявление отправлено на подпись: письмо со ссылкой ушло на ${data.Employee}.`); + }) + .catch((e) => { + setSigningInfo(''); + setSigningError( + `Заявка согласована, но отправить заявление на подпись не удалось: ${friendlyErrorMessage(e)}` + ); + }) + .then(() => setSigningBusy(false)); + }; + return (
@@ -160,6 +190,15 @@ const VacationView: React.FC = ({ context, itemId, formKey,
+ {signingInfo && {signingInfo}} + {signingError && ( + <> + {signingError} + + + )} = ({ context, itemId, formKey, setStatus(newStatus); setDecisionSeq((n) => n + 1); notify(); + // «Согласовано» = маршрут пройден целиком (фазы исполнения у отпуска нет). + // Промис возвращаем: пока он не разрешится, панель решения не пишет «Решение + // сохранено» — иначе успех появлялся бы раньше, чем заявление ушло на подпись. + return newStatus === REQUEST_STATUS.approved ? sendToSigning() : undefined; }} onDelegated={() => { setDecisionSeq((n) => n + 1); diff --git a/src/webparts/requestPortal/forms/Vacation/dates.ts b/src/webparts/requestPortal/forms/Vacation/dates.ts new file mode 100644 index 0000000..b604a17 --- /dev/null +++ b/src/webparts/requestPortal/forms/Vacation/dates.ts @@ -0,0 +1,12 @@ +/** «Дата окончания» в форме не вводится: начало + (дни − 1). Пусто → ''. */ +export function computeEndDate(startIso: string, days: number): string { + if (!startIso || !Number.isFinite(days) || days <= 0) return ''; + const endMs = new Date(startIso).getTime() + (days - 1) * 86400000; + return new Date(endMs).toISOString().slice(0, 10); +} + +/** ISO `YYYY-MM-DD` → `дд.мм.гггг`. Не похоже на дату → строка как есть (чтобы документ не падал из-за мусора). */ +export function formatRuDate(iso: string): string { + const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso); + return match ? `${match[3]}.${match[2]}.${match[1]}` : iso; +} diff --git a/src/webparts/requestPortal/forms/Vacation/sendToSign.ts b/src/webparts/requestPortal/forms/Vacation/sendToSign.ts new file mode 100644 index 0000000..f3b9ea2 --- /dev/null +++ b/src/webparts/requestPortal/forms/Vacation/sendToSign.ts @@ -0,0 +1,82 @@ +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { getCompanies, getEmployeeOrganizations } from '../../shared/dictionary'; +import { IEdoSigningResult, submitSigningRequest } from '../../shared/edo/edo.service'; +import { resolveSigningTarget } from '../../shared/edo/signingTarget'; +import { computeEndDate, formatRuDate } from './dates'; +import { IFormData } from './model'; +import { createVacationDocx, vacationFileName } from './vacationDocument'; + +/** Что нужно знать о согласованной заявке, чтобы отправить её на подпись. */ +export interface IVacationToSign { + /** Данные заявки (прочитаны из списка). */ + data: IFormData; + /** Ф.И.О. сотрудника из заявки (`Employee/Title`) — запасной вариант, если справочник пуст. */ + employeeName: string; + /** Уникальный код заявки (`RequestUniqueCode`); пусто — берётся id. */ + code: string; + itemId: number; +} + +/** Сегодняшняя дата `дд.мм.гггг` по местному времени — дата составления заявления. */ +function todayRu(): string { + const now = new Date(); + const pad = (n: number): string => (n < 10 ? `0${n}` : `${n}`); + return `${pad(now.getDate())}.${pad(now.getMonth() + 1)}.${now.getFullYear()}`; +} + +/** Ф.И.О. замещающего по его e-mail; не нашли — сам адрес, чтобы заявление всё равно собралось. */ +async function substituteName(context: WebPartContext, email: string): Promise { + if (!email) return ''; + try { + const records = await getEmployeeOrganizations(context, email); + return records[0]?.fio || email; + } catch { + // Имя замещающего — украшение документа: из-за него отправку не срываем. + return email; + } +} + +/** + * Отправляет согласованное заявление на отпуск на подпись в ЭДО. + * + * Подписывает сотрудник, указанный в заявке (он же «инициатор»): ему уходит приглашение, а на + * его же почту ЭДО сообщит, что документ подписан. ИИН и БИН компании портал берёт из + * справочников api.tnsu.kz, в самой заявке их нет. + */ +export async function sendVacationForSigning( + context: WebPartContext, + vacation: IVacationToSign +): Promise { + const { data } = vacation; + if (!data.Employee) { + throw new Error('в заявке не указан сотрудник'); + } + + const [records, companies] = await Promise.all([getEmployeeOrganizations(context, data.Employee), getCompanies(context)]); + const target = resolveSigningTarget(records, companies); + + const days = Number(data.DaysCount); + const documentDate = todayRu(); + const employeeName = + [target.name.lastName, target.name.firstName, target.name.middleName].filter((p) => p).join(' ') || + vacation.employeeName; + + const file = await createVacationDocx({ + companyName: target.companyName, + position: data.Position, + employeeName, + startDate: formatRuDate(data.StartDate), + endDate: formatRuDate(computeEndDate(data.StartDate, days)), + daysCount: days, + substituteName: await substituteName(context, data.Substitute), + requestCode: vacation.code || String(vacation.itemId), + documentDate + }); + + return submitSigningRequest(context, { + recipient: { iin: target.iin, email: data.Employee, name: target.name }, + companyBin: target.companyBin, + initiatorEmail: data.Employee, + file: { name: vacationFileName(employeeName, documentDate), content: file } + }); +} diff --git a/src/webparts/requestPortal/forms/Vacation/submit.ts b/src/webparts/requestPortal/forms/Vacation/submit.ts index 947260e..e5bbd27 100644 --- a/src/webparts/requestPortal/forms/Vacation/submit.ts +++ b/src/webparts/requestPortal/forms/Vacation/submit.ts @@ -1,6 +1,7 @@ import { ApprovalListService, IApproverToWrite, approvalServiceFor, writeRoute } from '../../shared/approval'; import { ISaveItemResult, saveRequestItem } from '../../shared/dataList'; import { ensureEmployee } from '../../shared/employee'; +import { computeEndDate } from './dates'; import { FORM_ID, IFormData } from './model'; /** @@ -16,13 +17,6 @@ function pad2(n: number): string { return n < 10 ? `0${n}` : `${n}`; } -/** «Дата окончания» в форме не вводится: начало + (дни − 1). Пусто → ''. */ -function computeEndDate(startIso: string, days: number): string { - if (!startIso || !Number.isFinite(days) || days <= 0) return ''; - const endMs = new Date(startIso).getTime() + (days - 1) * 86400000; - return new Date(endMs).toISOString().slice(0, 10); -} - /** Осмысленный заголовок элемента (системная колонка Title). */ function buildTitle(employeeName: string): string { const now = new Date(); diff --git a/src/webparts/requestPortal/forms/Vacation/vacationDocument.test.ts b/src/webparts/requestPortal/forms/Vacation/vacationDocument.test.ts new file mode 100644 index 0000000..7bd35c0 --- /dev/null +++ b/src/webparts/requestPortal/forms/Vacation/vacationDocument.test.ts @@ -0,0 +1,96 @@ +import JSZip from 'jszip'; +import { computeEndDate, formatRuDate } from './dates'; +import { + IVacationDocumentInput, + buildDocumentXml, + createVacationDocx, + escapeXml, + vacationFileName +} from './vacationDocument'; + +const INPUT: IVacationDocumentInput = { + companyName: 'ТОО TANSU Construction', + position: 'Инженер', + employeeName: 'Иванов Иван Иванович', + startDate: '01.12.2026', + endDate: '14.12.2026', + daysCount: 14, + substituteName: 'Петров Пётр Петрович', + requestCode: 'HR-0042', + documentDate: '30.09.2026' +}; + +/** Blob → байты через FileReader: в окружении Jest (jsdom) нет ни Response, ни blob.arrayBuffer(). */ +function readBlob(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as ArrayBuffer); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(blob); + }); +} + +describe('даты отпуска', () => { + it('конец = начало + (дни − 1)', () => { + expect(computeEndDate('2026-12-01', 14)).toBe('2026-12-14'); + expect(computeEndDate('2026-12-01', 1)).toBe('2026-12-01'); + }); + + it('без начала или дней — пусто', () => { + expect(computeEndDate('', 5)).toBe(''); + expect(computeEndDate('2026-12-01', 0)).toBe(''); + }); + + it('формат дд.мм.гггг, мусор остаётся как есть', () => { + expect(formatRuDate('2026-12-01')).toBe('01.12.2026'); + expect(formatRuDate('завтра')).toBe('завтра'); + }); +}); + +describe('заявление на отпуск', () => { + it('в тексте есть все данные заявки', () => { + const xml = buildDocumentXml(INPUT); + + for (const part of [ + 'ТОО TANSU Construction', + 'Инженер', + 'Иванов Иван Иванович', + 'ЗАЯВЛЕНИЕ', + '14 календарных дней с 01.12.2026 по 14.12.2026', + 'Петров Пётр Петрович', + 'HR-0042', + '30.09.2026' + ]) { + expect(xml).toContain(part); + } + }); + + it('без замещающего абзаца о замещении нет', () => { + expect(buildDocumentXml({ ...INPUT, substituteName: '' })).not.toContain('исполнение обязанностей'); + }); + + it('специальные символы в данных экранируются, разметка не ломается', () => { + const xml = buildDocumentXml({ ...INPUT, companyName: 'ТОО "Рога & Копыта" <филиал>' }); + + expect(xml).toContain('ТОО "Рога & Копыта" <филиал>'); + expect(xml).not.toContain('Рога & Копыта'); + }); + + it('экранирование', () => { + expect(escapeXml(`&'`)).toBe('<a href="x">&'</a>'); + }); + + it('имя файла без недопустимых символов', () => { + expect(vacationFileName('Иванов И.И.', '30.09.2026')).toBe('Заявление на отпуск — Иванов И.И. — 30.09.2026.docx'); + expect(vacationFileName('A/B:C', '01.01.2026')).not.toMatch(/[\\/:*?"<>|]/); + }); + + it('DOCX — это zip с обязательными частями пакета', async () => { + const blob = await createVacationDocx(INPUT); + const zip = await JSZip.loadAsync(await readBlob(blob)); + + expect(Object.keys(zip.files).sort()).toEqual(['[Content_Types].xml', '_rels/.rels', 'word/document.xml']); + const document = await zip.file('word/document.xml')!.async('string'); + expect(document).toContain('ЗАЯВЛЕНИЕ'); + }); +}); diff --git a/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts b/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts new file mode 100644 index 0000000..ac64c5c --- /dev/null +++ b/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts @@ -0,0 +1,149 @@ +import JSZip from 'jszip'; + +/** Данные заявления на отпуск. Всё, что идёт в текст, уже подготовлено вызывающим. */ +export interface IVacationDocumentInput { + /** Организация, на которую пишется заявление («ТОО TANSU Construction»). */ + companyName: string; + /** Должность заявителя; пусто — строка «должность» не выводится. */ + position: string; + /** Ф.И.О. заявителя. */ + employeeName: string; + /** Начало отпуска, ISO `YYYY-MM-DD`. */ + startDate: string; + /** Конец отпуска, ISO `YYYY-MM-DD`. */ + endDate: string; + daysCount: number; + /** Ф.И.О. замещающего; пусто — абзац о замещении не выводится. */ + substituteName: string; + /** Номер заявки в портале. */ + requestCode: string; + /** Дата составления заявления, `дд.мм.гггг`. */ + documentDate: string; +} + +export const DOCX_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + +/** Экранирование для XML: имена и должности приходят из справочников и могут содержать `&`, `<`, кавычки. */ +export function escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +type Align = 'left' | 'right' | 'center' | 'both'; + +interface IParagraph { + text: string; + align?: Align; + bold?: boolean; + /** Отступ после абзаца, twips (20 twips = 1 пт). */ + after?: number; + /** Отступ первой строки, twips. */ + firstLine?: number; +} + +// Times New Roman 14 пт — привычный вид официального заявления. Размер в «половинках» пункта. +const FONT = 'Times New Roman'; +const FONT_SIZE_HALF_POINTS = 28; + +function paragraph({ text, align = 'left', bold = false, after = 0, firstLine = 0 }: IParagraph): string { + const indent = firstLine > 0 ? `` : ''; + const run = + `` + + `${bold ? '' : ''}`; + return ( + `${indent}` + + `${run}${escapeXml(text)}` + ); +} + +/** Абзацы заявления. Формулировка — рабочий вариант, финальный текст утверждает кадровая служба. */ +export function buildVacationParagraphs(input: IVacationDocumentInput): IParagraph[] { + const paragraphs: IParagraph[] = [ + // Шапка «кому/от кого»: должность в именительном падеже, поэтому без предлога «от». + { text: input.companyName, align: 'right' }, + ...(input.position ? [{ text: input.position, align: 'right' as const }] : []), + { text: input.employeeName, align: 'right', after: 480 }, + { text: 'ЗАЯВЛЕНИЕ', align: 'center', bold: true, after: 360 }, + { + text: + `Прошу предоставить мне ежегодный оплачиваемый трудовой отпуск продолжительностью ${input.daysCount} ` + + `календарных дней с ${input.startDate} по ${input.endDate} включительно.`, + align: 'both', + firstLine: 709, + after: 240 + } + ]; + + if (input.substituteName) { + paragraphs.push({ + text: `На период моего отсутствия исполнение обязанностей прошу возложить на ${input.substituteName}.`, + align: 'both', + firstLine: 709, + after: 240 + }); + } + + paragraphs.push( + { text: `Дата: ${input.documentDate}`, after: 120 }, + { text: `Заявка в портале №${input.requestCode}`, after: 360 }, + { text: `${input.employeeName} ____________` } + ); + + return paragraphs; +} + +/** Тело `word/document.xml`. Отдельно от упаковки в zip — чтобы проверять разметку без архива. */ +export function buildDocumentXml(input: IVacationDocumentInput): string { + const body = buildVacationParagraphs(input).map(paragraph).join(''); + // A4, поля 2 см сверху/снизу/слева и 1,5 см справа — как у канцелярских бланков. + const section = + '' + + ''; + return ( + '' + + '' + + `${body}${section}` + ); +} + +const CONTENT_TYPES_XML = + '' + + '' + + '' + + '' + + '' + + ''; + +const ROOT_RELS_XML = + '' + + '' + + ''; + +/** + * Собирает заявление как DOCX. Файл строится прямо в браузере — отдельного сервиса + * документов нет, а ЭДО сам переводит DOCX в PDF перед подписанием. + */ +export async function createVacationDocx(input: IVacationDocumentInput): Promise { + const zip = new JSZip(); + // Без записей каталогов (_rels/, word/): в файлах, собранных Word, их нет, и строгие + // читатели пакета (в том числе конвертер ЭДО) не должны на них спотыкаться. + const options = { createFolders: false }; + zip.file('[Content_Types].xml', CONTENT_TYPES_XML, options); + zip.file('_rels/.rels', ROOT_RELS_XML, options); + zip.file('word/document.xml', buildDocumentXml(input), options); + return zip.generateAsync({ type: 'blob', mimeType: DOCX_CONTENT_TYPE, compression: 'DEFLATE' }); +} + +/** Имя файла для ЭДО: по нему человек узнает документ в письме и кабинете. */ +export function vacationFileName(employeeName: string, documentDate: string): string { + // Недопустимые в имени файла символы заменяем, а не удаляем: ФИО остаётся читаемым. + const safeName = employeeName.replace(/[\\/:*?"<>|]/g, ' ').replace(/\s+/g, ' ').trim(); + return `Заявление на отпуск — ${safeName} — ${documentDate}.docx`; +} diff --git a/src/webparts/requestPortal/shared/config.ts b/src/webparts/requestPortal/shared/config.ts index 09d48e5..5bab3d7 100644 --- a/src/webparts/requestPortal/shared/config.ts +++ b/src/webparts/requestPortal/shared/config.ts @@ -45,6 +45,33 @@ export function getApprovalApiBaseUrl(): string { return _approvalApiBaseUrl; } +/** + * ЭДО (TANSU ЭДО) — подписание документов. Токен берётся для того же AAD-приложения, что и + * у approval-route и справочников: бэкенд ЭДО принимает токены сотрудников этого приложения + * (Auth:Entra:Audience). Если ЭДО переедет на своё приложение — поменять только эту константу + * и объявить доступ в package-solution.json (webApiPermissionRequests). + */ +export const EDO_API_CLIENT_ID = APPROVAL_API_CLIENT_ID; + +/** Локальный бэкенд ЭДО — порт из launchSettings.json (https). */ +export const EDO_API_BASE_URL_LOCAL = 'https://localhost:7016'; +/** Стейджинг ЭДО. Сертификат самоподписанный: браузер должен ему доверять, иначе запрос не уйдёт. */ +export const EDO_API_BASE_URL_STAGE = 'https://10.0.1.225:8443'; + +// Настраивается свойством веб-части edoApiBaseUrl (селект в панели свойств) — тот же +// модульный синглтон, что и approvalApiBaseUrl выше. +let _edoApiBaseUrl = EDO_API_BASE_URL_STAGE; + +export function setEdoApiBaseUrl(url: string): void { + // Хвостовые слэши отбрасываем: адрес склеивается с путём «/api/...», а в панели + // свойств его могли вписать с «/» на конце. + _edoApiBaseUrl = (url || EDO_API_BASE_URL_STAGE).trim().replace(/\/+$/, ''); +} + +export function getEdoApiBaseUrl(): string { + return _edoApiBaseUrl; +} + // Уведомления согласующим (поток Power Automate, см. approval/notify.service.ts). // Настраивается свойствами веб-части notificationsEnabled / notifyOnlyTo — тот // же модульный синглтон, что и у approvalApiBaseUrl выше. diff --git a/src/webparts/requestPortal/shared/dictionary/employeeOrganizations.service.ts b/src/webparts/requestPortal/shared/dictionary/employeeOrganizations.service.ts index a15fe4e..df481a5 100644 --- a/src/webparts/requestPortal/shared/dictionary/employeeOrganizations.service.ts +++ b/src/webparts/requestPortal/shared/dictionary/employeeOrganizations.service.ts @@ -23,6 +23,7 @@ async function fetchEmployeeOrganizations(context: WebPartContext, email: string id: (r.Id as string) || '', fio: (r.Fio as string) || '', email: (r.Email as string) || '', + iin: (r.Iin as string) || '', company: (r.Company as string) || '', companyId: (r.CompanyId as string) || '', section: (r.Section as string) || '', diff --git a/src/webparts/requestPortal/shared/dictionary/models/ICompany.ts b/src/webparts/requestPortal/shared/dictionary/models/ICompany.ts index 9ed45c5..28ebfbd 100644 --- a/src/webparts/requestPortal/shared/dictionary/models/ICompany.ts +++ b/src/webparts/requestPortal/shared/dictionary/models/ICompany.ts @@ -1,8 +1,10 @@ -// GET https://api.tnsu.kz/Dictionary/Companies → плоский массив { Id, Name } +// GET https://api.tnsu.kz/Dictionary/Companies → плоский массив { Id, Name, Bin } // (PascalCase, без обёртки — как /Dictionary/Departments). export interface ICompany { /** GUID компании — стабильный идентификатор. */ Id: string; /** Наименование (напр. «ТОО AFL Engineering») — то, что видит пользователь. */ Name: string; + /** БИН компании, 12 цифр. Нужен для ЭДО (от имени какой компании подписывается документ). */ + Bin?: string; } diff --git a/src/webparts/requestPortal/shared/dictionary/models/IEmployeeOrganization.ts b/src/webparts/requestPortal/shared/dictionary/models/IEmployeeOrganization.ts index d31d5ff..91f9f3b 100644 --- a/src/webparts/requestPortal/shared/dictionary/models/IEmployeeOrganization.ts +++ b/src/webparts/requestPortal/shared/dictionary/models/IEmployeeOrganization.ts @@ -8,6 +8,8 @@ export interface IEmployeeOrganization { id: string; fio: string; email: string; + /** ИИН сотрудника, 12 цифр. Нужен для ЭДО (по нему там заводится подписант). */ + iin: string; company: string; companyId: string; section: string; diff --git a/src/webparts/requestPortal/shared/edo/edo.service.ts b/src/webparts/requestPortal/shared/edo/edo.service.ts new file mode 100644 index 0000000..3020dde --- /dev/null +++ b/src/webparts/requestPortal/shared/edo/edo.service.ts @@ -0,0 +1,80 @@ +import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http'; +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { EDO_API_CLIENT_ID, getEdoApiBaseUrl } from '../config'; +import { describeEdoError } from './edoErrors'; +import { IFioParts } from './fio'; + +/** Путь приёма документов на подпись от интеграций (TANSU ЭДО, multipart/form-data). */ +const SIGNING_REQUESTS_PATH = '/api/v1/integration/signing-requests'; + +/** Что отправляем в ЭДО: одного получателя, он же подписант, и один файл. */ +export interface IEdoSigningRequest { + /** Подписывающий: ЭДО заводит его по ИИН, если нет, и зовёт подписать письмом. */ + recipient: { iin: string; email: string; name: IFioParts }; + /** БИН компании, от имени которой документ идёт на подпись (нужен сертификат Sigex этой компании в ЭДО). */ + companyBin: string; + /** Кому сообщать о подписи. */ + initiatorEmail: string; + file: { name: string; content: Blob }; +} + +/** Ответ ЭДО: то, что нужно показать пользователю. */ +export interface IEdoSigningResult { + /** Кабинет получателя без ключа: войти по ЭЦП или через Microsoft. */ + cabinetUrl: string; + documents: Array<{ id: string; fileName: string }>; +} + +/** Ответ ЭДО в snake_case — только поля, которые читаем. */ +interface IEdoResponseBody { + cabinet_url?: string; + documents?: Array<{ id?: string; file_name?: string }>; +} + +/** + * Отправляет документ на подпись в ЭДО. + * + * Токен берётся для приложения ЭДО текущего пользователя (AadHttpClient), поэтому в ЭДО + * запрос приходит от того, кто нажал «Согласовать», — он заводится там автоматически при + * первом обращении. Адрес ЭДО — свойство веб-части edoApiBaseUrl. + */ +export async function submitSigningRequest( + context: WebPartContext, + request: IEdoSigningRequest +): Promise { + const baseUrl = getEdoApiBaseUrl(); + const client = await context.aadHttpClientFactory.getClient(EDO_API_CLIENT_ID); + + // Content-Type не задаём: браузер сам допишет boundary для multipart. + const form = new FormData(); + form.append('iin', request.recipient.iin); + form.append('email', request.recipient.email); + form.append('last_name', request.recipient.name.lastName); + form.append('first_name', request.recipient.name.firstName); + if (request.recipient.name.middleName) form.append('middle_name', request.recipient.name.middleName); + form.append('company_bin', request.companyBin); + form.append('initiator_email', request.initiatorEmail); + form.append('files', request.file.content, request.file.name); + + let response: HttpClientResponse; + try { + response = await client.post(`${baseUrl}${SIGNING_REQUESTS_PATH}`, AadHttpClient.configurations.v1, { body: form }); + } catch (e) { + // Сеть/сертификат: у браузера для таких случаев одно сообщение «Failed to fetch», + // а причина (недоверенный сертификат, ЭДО выключен, адрес не тот) видна только в консоли. + console.error('ЭДО недоступен', e); + throw new Error( + `ЭДО (${baseUrl}) недоступен. Проверьте, что сервер запущен и браузер доверяет его сертификату` + ); + } + + if (!response.ok) { + throw new Error(describeEdoError(response.status, await response.text())); + } + + const body = (await response.json()) as IEdoResponseBody; + return { + cabinetUrl: body.cabinet_url || '', + documents: (body.documents || []).map((d) => ({ id: d.id || '', fileName: d.file_name || '' })) + }; +} diff --git a/src/webparts/requestPortal/shared/edo/edo.test.ts b/src/webparts/requestPortal/shared/edo/edo.test.ts new file mode 100644 index 0000000..09c6d3e --- /dev/null +++ b/src/webparts/requestPortal/shared/edo/edo.test.ts @@ -0,0 +1,125 @@ +import { ICompany } from '../dictionary/models/ICompany'; +import { IEmployeeOrganization } from '../dictionary/models/IEmployeeOrganization'; +import { describeEdoError } from './edoErrors'; +import { splitFio } from './fio'; +import { resolveSigningTarget } from './signingTarget'; + +const TANSU_ID = '9de064a4-44c8-88ef-11ec-8284930fad96'; + +function record(overrides: Partial = {}): IEmployeeOrganization { + return { + id: 'e1', + fio: 'Иванов Иван Иванович', + email: 'ivanov@tnsu.kz', + iin: '950515123452', + company: 'ТОО TANSU Construction', + companyId: TANSU_ID, + section: '', + departmentId: '', + position: 'Инженер', + mobile: '', + ...overrides + }; +} + +const COMPANIES: ICompany[] = [ + { Id: TANSU_ID, Name: 'ТОО TANSU Construction', Bin: '021040005165' }, + { Id: '0a1b2c3d-0000-4000-8000-000000000002', Name: 'ТОО Без БИН', Bin: '' } +]; + +describe('Ф.И.О. по частям для ЭДО', () => { + it('три слова', () => { + expect(splitFio('Иванов Иван Иванович')).toEqual({ lastName: 'Иванов', firstName: 'Иван', middleName: 'Иванович' }); + }); + + it('без отчества', () => { + expect(splitFio('Иванов Иван')).toEqual({ lastName: 'Иванов', firstName: 'Иван', middleName: '' }); + }); + + it('лишние пробелы и «оглы» не теряются', () => { + expect(splitFio(' Алиев Тимур Расул оглы ')).toEqual({ + lastName: 'Алиев', + firstName: 'Тимур', + middleName: 'Расул оглы' + }); + }); + + it('одно слово или пусто — не разобрать', () => { + expect(splitFio('Иванов')).toBeUndefined(); + expect(splitFio('')).toBeUndefined(); + }); +}); + +describe('кому и от какой компании отправлять на подпись', () => { + it('берёт ИИН из справочника сотрудников и БИН из справочника компаний', () => { + const target = resolveSigningTarget([record()], COMPANIES); + + expect(target.iin).toBe('950515123452'); + expect(target.companyBin).toBe('021040005165'); + expect(target.companyName).toBe('ТОО TANSU Construction'); + expect(target.name.lastName).toBe('Иванов'); + }); + + it('идентификаторы компаний сравниваются без учёта регистра', () => { + const target = resolveSigningTarget([record({ companyId: TANSU_ID.toUpperCase() })], COMPANIES); + + expect(target.companyBin).toBe('021040005165'); + }); + + it('при нескольких организациях берёт первую, где у компании есть БИН', () => { + const records = [ + record({ company: 'ТОО Без БИН', companyId: '0a1b2c3d-0000-4000-8000-000000000002' }), + record() + ]; + + expect(resolveSigningTarget(records, COMPANIES).companyBin).toBe('021040005165'); + }); + + it('нет записи о сотруднике — понятная причина', () => { + expect(() => resolveSigningTarget([], COMPANIES)).toThrow('нет в кадровом справочнике'); + }); + + it('у компании нет БИН — называет компанию', () => { + const records = [record({ company: 'ТОО Без БИН', companyId: '0a1b2c3d-0000-4000-8000-000000000002' })]; + + expect(() => resolveSigningTarget(records, COMPANIES)).toThrow('нет БИН для «ТОО Без БИН»'); + }); + + it('компании нет в справочнике', () => { + expect(() => resolveSigningTarget([record({ companyId: 'ffffffff-0000-4000-8000-000000000000' })], COMPANIES)).toThrow( + 'нет БИН' + ); + }); + + it('нет ИИН в карточке', () => { + expect(() => resolveSigningTarget([record({ iin: ' ' })], COMPANIES)).toThrow('не указан ИИН'); + }); + + it('ФИО из одного слова не годится', () => { + expect(() => resolveSigningTarget([record({ fio: 'Иванов' })], COMPANIES)).toThrow('Ф.И.О.'); + }); +}); + +describe('текст ошибки ЭДО', () => { + it('берёт русский detail из ProblemDetails', () => { + const body = JSON.stringify({ title: 'Bad Request', detail: 'Для компании «X» не загружен сертификат Sigex.' }); + + expect(describeEdoError(400, body)).toBe('Для компании «X» не загружен сертификат Sigex.'); + }); + + it('title, если detail нет', () => { + expect(describeEdoError(400, JSON.stringify({ title: 'Подписант не найден' }))).toBe('Подписант не найден'); + }); + + it('ошибки проверки собираются в одну строку', () => { + const body = JSON.stringify({ errors: { Iin: ['ИИН неверный'], Email: ['Email неверный', 'Слишком длинный'] } }); + + expect(describeEdoError(400, body)).toBe('ИИН неверный; Email неверный; Слишком длинный'); + }); + + it('не JSON — по статусу', () => { + expect(describeEdoError(502, 'Bad gateway')).toBe('ЭДО ответил 502'); + expect(describeEdoError(401, '')).toContain('401'); + expect(describeEdoError(403, '')).toContain('403'); + }); +}); diff --git a/src/webparts/requestPortal/shared/edo/edoErrors.ts b/src/webparts/requestPortal/shared/edo/edoErrors.ts new file mode 100644 index 0000000..31a8cc4 --- /dev/null +++ b/src/webparts/requestPortal/shared/edo/edoErrors.ts @@ -0,0 +1,37 @@ +/** + * Текст ошибки из ответа ЭДО. Бэкенд отвечает ProblemDetails (`title`, `detail`, `errors`, `code`), + * и именно `title`/`detail` написаны по-русски для человека. Тело не JSON (прокси, 502) — отдаём + * статус: лучше короткое «ЭДО ответил 502», чем кусок HTML в интерфейсе. + */ +export function describeEdoError(status: number, bodyText: string): string { + let parsed: { title?: unknown; detail?: unknown; errors?: unknown } = {}; + try { + parsed = JSON.parse(bodyText) as typeof parsed; + } catch { + // не JSON — разберём по статусу ниже + } + + const validation = flattenValidationErrors(parsed.errors); + if (validation) return validation; + + const text = [parsed.detail, parsed.title].find((v): v is string => typeof v === 'string' && v.trim().length > 0); + if (text) return text.trim(); + + if (status === 401) return 'ЭДО не принял токен: вход под этой учётной записью в ЭДО невозможен (401)'; + if (status === 403) return 'ЭДО отказал в доступе (403)'; + return `ЭДО ответил ${status}`; +} + +/** Ошибки проверки формата `{ поле: [сообщения] }` → одна строка. */ +function flattenValidationErrors(errors: unknown): string { + if (!errors || typeof errors !== 'object') return ''; + const messages: string[] = []; + for (const value of Object.values(errors as Record)) { + if (Array.isArray(value)) { + for (const message of value) { + if (typeof message === 'string') messages.push(message); + } + } + } + return messages.join('; '); +} diff --git a/src/webparts/requestPortal/shared/edo/fio.ts b/src/webparts/requestPortal/shared/edo/fio.ts new file mode 100644 index 0000000..22bc81c --- /dev/null +++ b/src/webparts/requestPortal/shared/edo/fio.ts @@ -0,0 +1,19 @@ +/** Ф.И.О. по частям — так их принимает ЭДО. */ +export interface IFioParts { + lastName: string; + firstName: string; + middleName: string; +} + +/** + * «Иванов Иван Иванович» → фамилия, имя, отчество. Справочник отдаёт ФИО одной строкой. + * Два слова — отчества нет; больше трёх (например, «оглы», двойные фамилии) — всё после + * имени уходит в отчество, чтобы ничего не потерять. Одно слово → ничего не определить: + * ЭДО требует и фамилию, и имя. + */ +export function splitFio(fio: string): IFioParts | undefined { + const words = (fio || '').split(/\s+/).filter((w) => w.length > 0); + if (words.length < 2) return undefined; + const [lastName, firstName, ...rest] = words; + return { lastName, firstName, middleName: rest.join(' ') }; +} diff --git a/src/webparts/requestPortal/shared/edo/signingTarget.ts b/src/webparts/requestPortal/shared/edo/signingTarget.ts new file mode 100644 index 0000000..a846b52 --- /dev/null +++ b/src/webparts/requestPortal/shared/edo/signingTarget.ts @@ -0,0 +1,53 @@ +import { ICompany } from '../dictionary/models/ICompany'; +import { IEmployeeOrganization } from '../dictionary/models/IEmployeeOrganization'; +import { IFioParts, splitFio } from './fio'; + +/** Кому и от имени какой компании отправляется документ на подпись. */ +export interface ISigningTarget { + iin: string; + name: IFioParts; + /** БИН компании, от имени которой документ идёт в Sigex. */ + companyBin: string; + companyName: string; +} + +/** + * Определяет подписанта и компанию по данным справочников api.tnsu.kz: + * ИИН и ФИО — из Employee/ByEmail, БИН — из Dictionary/Companies по CompanyId сотрудника. + * + * Сотрудник может состоять в нескольких организациях (на один e-mail — несколько записей). + * Берём первую запись, у которой компания известна справочнику и в нём есть БИН: заявление + * от имени компании без БИН в ЭДО не принять. Каждая невозможность — отдельное сообщение, + * чтобы по тексту было ясно, что поправить: карточку сотрудника или справочник компаний. + */ +export function resolveSigningTarget(records: IEmployeeOrganization[], companies: ICompany[]): ISigningTarget { + if (records.length === 0) { + throw new Error('сотрудника нет в кадровом справочнике — ИИН для подписания взять неоткуда'); + } + + const withCompany = records.filter((r) => r.companyId); + if (withCompany.length === 0) { + throw new Error('в карточке сотрудника не указана компания'); + } + + const byId = new Map(companies.map((c) => [c.Id.toLowerCase(), c])); + const match = withCompany + .map((record) => ({ record, company: byId.get(record.companyId.toLowerCase()) })) + .find((m) => m.company && m.company.Bin && m.company.Bin.trim().length > 0); + + if (!match || !match.company) { + throw new Error(`в справочнике компаний нет БИН для «${withCompany[0].company}»`); + } + + const iin = (match.record.iin || '').trim(); + if (!iin) { + throw new Error('в кадровом справочнике у сотрудника не указан ИИН'); + } + + const name = splitFio(match.record.fio); + if (!name) { + throw new Error(`не удалось разобрать Ф.И.О. «${match.record.fio}» на фамилию и имя`); + } + + return { iin, name, companyBin: (match.company.Bin as string).trim(), companyName: match.company.Name }; +} -- 2.34.1 From 80caa59301313a17a97e16ee09ae67e855e28d3e Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Thu, 1 Oct 2026 17:08:06 +0500 Subject: [PATCH 02/24] =?UTF-8?q?feat:=20=D1=83=D0=B4=D0=B0=D0=BB=D1=8F?= =?UTF-8?q?=D1=82=D1=8C=20=D1=81=D0=BE=D0=B3=D0=BB=D0=B0=D1=81=D1=83=D1=8E?= =?UTF-8?q?=D1=89=D0=B8=D1=85=20=D0=BF=D1=80=D0=B8=20=D1=81=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=B7=D0=B0?= =?UTF-8?q?=D1=8F=D0=B2=D0=BA=D0=B8=20=D0=B2=D0=BE=20=D0=B2=D1=81=D0=B5?= =?UTF-8?q?=D1=85=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=85=20=E2=80=94=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=20=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83=D1=82?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Редактор маршрута (canEditRoute) теперь может удалить любого согласующего при подаче или правке заявки во всех формах с маршрутом согласования, а не только в трёх рекрутинговых. В список редакторов добавлен Павел Горбенко. Реестр особых прав обновлён. Co-Authored-By: Claude Opus 5.5 --- docs/permissions.md | 3 ++- .../requestPortal/forms/Clearance/ClearanceForm.tsx | 8 +++++++- .../forms/ConcreteRequest/ConcreteRequestForm.tsx | 3 +++ .../requestPortal/forms/Dismissal/DismissalForm.tsx | 8 +++++++- .../forms/EmployeeTransfer/EmployeeTransferForm.tsx | 8 +++++++- .../forms/ServiceRequest/ServiceRequestForm.tsx | 2 ++ .../forms/SkudExplanation/SkudExplanationForm.tsx | 8 +++++++- .../requestPortal/forms/Ticket1C/Ticket1CForm.tsx | 2 ++ .../forms/TmcTransfer/TmcTransferForm.tsx | 2 ++ .../forms/UnpaidLeave/UnpaidLeaveForm.tsx | 8 +++++++- .../requestPortal/forms/Vacation/VacationForm.tsx | 8 +++++++- .../forms/WorkplaceSetup/WorkplaceSetupForm.tsx | 7 ++++++- src/webparts/requestPortal/shared/approval/index.ts | 2 +- .../shared/approval/routeEditRights.ts | 13 ++++++++++++- 14 files changed, 72 insertions(+), 10 deletions(-) diff --git a/docs/permissions.md b/docs/permissions.md index eebf95b..17d045c 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -16,7 +16,8 @@ | Право | Кто | Как определяется | Где действует | Файл с проверкой | |---|---|---|---|---| -| Удалять согласующих, которые ещё ждут очереди | Алтынай Бултурукова | email в коде | Подбор, Приём, Реферальная рекомендация: и при составлении, и в поданной заявке | `shared/approval/routeEditRights.ts` → `canEditRoute` | +| Удалять согласующих, которые ещё ждут очереди | Алтынай Бултурукова, Павел Горбенко | email в коде | Подбор, Приём, Реферальная рекомендация: и при составлении, и в поданной заявке | `shared/approval/routeEditRights.ts` → `canEditRoute` | +| Удалять любых согласующих **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все остальные формы с маршрутом согласования (отпуск, увольнение, 1С и т. д.); в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `composeRemoveRowRule` | | Заменять и удалять исполнителей, **в том числе активного** («На исполнении») | Алтынай Бултурукова | тот же email (`canEditRoute`) | **Только** Заявка на подбор, матрица «Исполнение» | `canEditRoute` + `forms/RecruitingSelection/RecruitingSelectionView.tsx` | | Править **все поля** заявки на своём активном шаге согласования (без вложений) | Алтынай Бултурукова | email в коде | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` | | Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` | diff --git a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx index 0b22ae0..d8e8af6 100644 --- a/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx +++ b/src/webparts/requestPortal/forms/Clearance/ClearanceForm.tsx @@ -13,6 +13,7 @@ import { useRequestSubmit } from '../../shared/ui'; import { + composeRemoveRowRule, ApprovalMatrix, ApprovalHistory, StatusBadge, @@ -27,6 +28,7 @@ import { APPROVAL_PHASE, unassignedStepMessage } from '../../shared/approval'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; import { friendlyErrorMessage } from '../../shared/diagnostics'; @@ -326,7 +328,11 @@ const ClearanceForm: React.FC = ({ - approval.searchUsers(q)} /> + approval.searchUsers(q)} + canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} + /> {/* Блок исполнителя показываем, только когда по маршруту реально есть исполнители (условие «IsManager» и т.п. могло не назначить ни одного). */} diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx index c3e7f14..a7aea88 100644 --- a/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx +++ b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx @@ -13,6 +13,7 @@ import { useRequestSubmit } from '../../shared/ui'; import { + composeRemoveRowRule, ApprovalMatrix, ApprovalHistory, StatusBadge, @@ -26,6 +27,7 @@ import { IApproverToWrite, unassignedStepMessage } from '../../shared/approval'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; import { friendlyErrorMessage } from '../../shared/diagnostics'; @@ -323,6 +325,7 @@ const ConcreteRequestForm: React.FC = ({ approval.searchUsers(q)} waiting={!matrixReady} waitingText="Укажите начальника участка и проект, чтобы построить маршрут согласования." diff --git a/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx b/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx index 9d41b88..7de7791 100644 --- a/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx +++ b/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx @@ -13,6 +13,7 @@ import { useRequestSubmit } from '../../shared/ui'; import { + composeRemoveRowRule, ApprovalMatrix, ApprovalHistory, StatusBadge, @@ -26,6 +27,7 @@ import { unassignedStepMessage, resolveWriteListId } from '../../shared/approval'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; import { getEmployeeOrganizations, getCompanyDirectorEmail, IEmployeeOrganization } from '../../shared/dictionary'; import { friendlyErrorMessage } from '../../shared/diagnostics'; @@ -340,7 +342,11 @@ const DismissalForm: React.FC = ({ - approval.searchUsers(q)} /> + approval.searchUsers(q)} + canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} + /> {/* Автору на доработке важнее всего видеть, почему вернули или отклонили, — поэтому история и в форме, а не только в просмотре. diff --git a/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx b/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx index 9be0af3..77cf7b8 100644 --- a/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx +++ b/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx @@ -13,6 +13,7 @@ import { useRequestSubmit } from '../../shared/ui'; import { + composeRemoveRowRule, ApprovalMatrix, ApprovalHistory, StatusBadge, @@ -26,6 +27,7 @@ import { IApproverToWrite, unassignedStepMessage } from '../../shared/approval'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; import { friendlyErrorMessage } from '../../shared/diagnostics'; @@ -302,7 +304,11 @@ const EmployeeTransferForm: React.FC = ({ - approval.searchUsers(q)} /> + approval.searchUsers(q)} + canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} + /> {/* Автору на доработке важнее всего видеть, почему вернули или отклонили. Только у уже существующей заявки: у новой истории нет. */} diff --git a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx index 0c2354f..768fc32 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx @@ -13,6 +13,7 @@ import { useRequestSubmit } from '../../shared/ui'; import { + composeRemoveRowRule, ApprovalMatrix, ApprovalHistory, StatusBadge, @@ -383,6 +384,7 @@ const ServiceRequestForm: React.FC = ({ approval.searchUsers(q)} waiting={!matrixReady} waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования." diff --git a/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx b/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx index 92d9731..359b075 100644 --- a/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx +++ b/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx @@ -13,6 +13,7 @@ import { useRequestSubmit } from '../../shared/ui'; import { + composeRemoveRowRule, ApprovalMatrix, ApprovalHistory, StatusBadge, @@ -25,6 +26,7 @@ import { unassignedStepMessage, resolveWriteListId } from '../../shared/approval'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; import { friendlyErrorMessage } from '../../shared/diagnostics'; @@ -268,7 +270,11 @@ const SkudExplanationForm: React.FC = ({ - approval.searchUsers(q)} /> + approval.searchUsers(q)} + canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} + /> {!executors.loading && executors.rows.length > 0 && ( = ({ approval.searchUsers(q)} // Пустой маршрут — штатная ситуация: для части видов работ согласование // не нужно, заявка сразу уходит в Planner. Сбой загрузки маршрута diff --git a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx index 6331bb0..6676050 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx +++ b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx @@ -17,6 +17,7 @@ import { useRequestSubmit } from '../../shared/ui'; import { + composeRemoveRowRule, ApprovalMatrix, ApprovalHistory, StatusBadge, @@ -498,6 +499,7 @@ const TmcTransferForm: React.FC = ({ approval.searchUsers(q)} waiting={!matrixReady} waitingText="Укажите заявителя и проект-источник («Откуда»), чтобы построить маршрут согласования." diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx index 181407b..228b758 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx +++ b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx @@ -13,6 +13,7 @@ import { useRequestSubmit } from '../../shared/ui'; import { + composeRemoveRowRule, ApprovalMatrix, ApprovalHistory, StatusBadge, @@ -26,6 +27,7 @@ import { unassignedStepMessage, resolveWriteListId } from '../../shared/approval'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; import { friendlyErrorMessage } from '../../shared/diagnostics'; @@ -314,7 +316,11 @@ const UnpaidLeaveForm: React.FC = ({ - approval.searchUsers(q)} /> + approval.searchUsers(q)} + canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} + /> {/* Автору на доработке важнее всего видеть, почему вернули или отклонили. Только у уже существующей заявки: у новой истории нет. */} diff --git a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx index 21f6480..6950f14 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx @@ -13,6 +13,7 @@ import { useRequestSubmit } from '../../shared/ui'; import { + composeRemoveRowRule, ApprovalMatrix, ApprovalHistory, StatusBadge, @@ -26,6 +27,7 @@ import { IApproverToWrite, unassignedStepMessage } from '../../shared/approval'; +import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; import { friendlyErrorMessage } from '../../shared/diagnostics'; @@ -313,7 +315,11 @@ const VacationForm: React.FC = ({ - approval.searchUsers(q)} /> + approval.searchUsers(q)} + canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} + /> {/* Автору на доработке важнее всего видеть, почему вернули или отклонили. Только у уже существующей заявки: у новой истории нет. */} diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx index 672cc90..77c2199 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx @@ -17,6 +17,7 @@ import { useRequestSubmit } from '../../shared/ui'; import { + composeRemoveRowRule, ApprovalMatrix, ApprovalHistory, StatusBadge, @@ -391,7 +392,11 @@ const WorkplaceSetupForm: React.FC = ({ )} - approval.searchUsers(q)} /> + approval.searchUsers(q)} + canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} + /> {!executors.loading && executors.rows.length > 0 && ( = new Set([ - 'a.bulturukova@tnsu.kz' // Алтынай Бултурукова + 'a.bulturukova@tnsu.kz', // Алтынай Бултурукова + 'gorbenko.pavel@tnsu.kz' // Павел Горбенко — составляет свой маршрут при подаче (обкатка форм) ]); /** Может ли этот email удалять согласующих из маршрута (см. описание выше). */ export function canEditRoute(email: string | undefined): boolean { return !!email && ROUTE_EDITOR_EMAILS.has(email.trim().toLowerCase()); } + +/** + * Правило для ApprovalMatrix.canRemoveRow при СОСТАВЛЕНИИ заявки: редактор маршрута удаляет + * любого согласующего, остальным кнопки удаления нет. Удаление тут чисто локальное — при + * отправке маршрут целиком пишется из строк матрицы, поэтому записи на сервер не нужно. + * В поданной заявке так нельзя: там своя реализация (см. RecruitingHireView). + */ +export function composeRemoveRowRule(email: string | undefined): (() => boolean) | undefined { + return canEditRoute(email) ? () => true : undefined; +} -- 2.34.1 From 3337aeda6cbc6b799b5af7b8a5bdd56a3e172c1b Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 12:58:34 +0500 Subject: [PATCH 03/24] =?UTF-8?q?feat:=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D1=86=D0=B0=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D0=BD=D0=B8=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D0=B9=20=D0=B2=D0=BE=20=D0=B2=D1=81=D0=B5?= =?UTF-8?q?=D1=85=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=85=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D0=B7=D0=B0=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B8=20=D0=B8=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC?= =?UTF-8?q?=D0=BE=D1=82=D1=80=D0=B5,=20=D0=B5=D1=81=D0=BB=D0=B8=20=D0=BE?= =?UTF-8?q?=D0=BD=D0=B8=20=D0=B5=D1=81=D1=82=D1=8C=20=D0=B2=20=D0=BC=D0=B0?= =?UTF-8?q?=D1=80=D1=88=D1=80=D1=83=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Общие useFormExecutors/useViewExecutors и ExecutorsMatrix вместо копирования в каждой форме. Подключены в формах без фазы исполнения: ConcreteRequest, Dismissal, EmployeeTransfer, ServiceRequest, Ticket1C, UnpaidLeave, Vacation. Исполнители теперь пишутся в маршрут при подаче; таблица согласующих грузит только фазу согласования, чтобы не смешиваться с исполнением. Co-Authored-By: Claude Sonnet 5.5 --- .../ConcreteRequest/ConcreteRequestForm.tsx | 37 ++++++- .../ConcreteRequest/ConcreteRequestView.tsx | 9 +- .../forms/ConcreteRequest/submit.ts | 5 +- .../forms/Dismissal/DismissalForm.tsx | 43 +++++++- .../forms/Dismissal/DismissalView.tsx | 11 +- .../requestPortal/forms/Dismissal/submit.ts | 7 +- .../EmployeeTransfer/EmployeeTransferForm.tsx | 40 ++++++- .../EmployeeTransfer/EmployeeTransferView.tsx | 7 +- .../forms/EmployeeTransfer/submit.ts | 6 +- .../ServiceRequest/ServiceRequestForm.tsx | 42 +++++++- .../ServiceRequest/ServiceRequestView.tsx | 7 +- .../forms/ServiceRequest/submit.ts | 7 +- .../forms/Ticket1C/Ticket1CForm.tsx | 41 ++++++- .../forms/Ticket1C/Ticket1CView.tsx | 10 +- .../requestPortal/forms/Ticket1C/submit.ts | 9 +- .../forms/UnpaidLeave/UnpaidLeaveForm.tsx | 34 +++++- .../forms/UnpaidLeave/UnpaidLeaveView.tsx | 8 +- .../requestPortal/forms/UnpaidLeave/submit.ts | 5 +- .../forms/Vacation/VacationForm.tsx | 40 ++++++- .../forms/Vacation/VacationView.tsx | 7 +- .../requestPortal/forms/Vacation/submit.ts | 5 +- .../requestPortal/shared/approval/index.ts | 8 ++ .../shared/approval/ui/ExecutorsMatrix.tsx | 28 +++++ .../shared/approval/useRouteExecutors.ts | 102 ++++++++++++++++++ 24 files changed, 464 insertions(+), 54 deletions(-) create mode 100644 src/webparts/requestPortal/shared/approval/ui/ExecutorsMatrix.tsx create mode 100644 src/webparts/requestPortal/shared/approval/useRouteExecutors.ts diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx index a7aea88..f31a471 100644 --- a/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx +++ b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx @@ -15,6 +15,7 @@ import { import { composeRemoveRowRule, ApprovalMatrix, + ExecutorsMatrix, ApprovalHistory, StatusBadge, useMatrixEditor, @@ -25,7 +26,11 @@ import { REQUEST_STATUS, isEditableStatus, IApproverToWrite, - unassignedStepMessage + unassignedStepMessage, + useFormExecutors, + approvalPhaseRows, + executorsToWrite, + initialSubmitStatus } from '../../shared/approval'; import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; @@ -171,7 +176,7 @@ const ConcreteRequestForm: React.FC = ({ const loadMatrix = useCallback( () => editId - ? approval.getMatrixForRequest(editId) + ? approval.getMatrixForRequest(editId).then(approvalPhaseRows) : getPlannedMatrix(context, requestTypeId as number, data.SiteChiefEmail, approval, 'Approval', null, undefined, projectId), [context, approval, editId, data.SiteChiefEmail, requestTypeId, projectId] ); @@ -183,6 +188,17 @@ const ConcreteRequestForm: React.FC = ({ (login) => approval.getJobTitle(login) ); + // Исполнители — из маршрута админки (заявитель их не правит); те же параметры + // резолва, что и у согласующих. + const executors = useFormExecutors({ + context, + approval, + requestTypeId, + userEmail: data.SiteChiefEmail, + editId, + projectId + }); + const hasItems = data.Items.some((i) => i.mixClass.trim().length > 0 && i.volume.trim().length > 0); const isValid = !!(data.RequestDate && data.SiteChiefEmail && data.Project.trim() && hasItems); const validate = (): string | undefined => { @@ -224,6 +240,7 @@ const ConcreteRequestForm: React.FC = ({ rtId, data, toApprovers(matrix.rows), + executorsToWrite(executors.rows), status, writeListId ); @@ -238,7 +255,7 @@ const ConcreteRequestForm: React.FC = ({ // Отправка на согласование — уведомляем первый активный шаг (поток оповещения). // Имена списков — той же партиции, куда фактически записана заявка (writeListId), // не текущей: поток ищет строку маршрута именно в НЕЙ. - if (status === REQUEST_STATUS.onApproval) { + if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) { await notifyActiveStep(context, approval, id, { requestTypeId: rtId, dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID), @@ -260,11 +277,21 @@ const ConcreteRequestForm: React.FC = ({ (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.` ); if (unassignedError) return unassignedError; + const unassignedExecutorError = unassignedStepMessage( + executors.rows, + (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.` + ); + if (unassignedExecutorError) return unassignedExecutorError; return undefined; }; const handleSaveDraft = (): Promise => submit.run('draft', save(REQUEST_STATUS.draft), validate); - const handleSubmit = (): Promise => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit); + // Без исполнителей — прежний статус «На согласовании»; с ними — как у рефералки + // (initialSubmitStatus учитывает маршрут без согласующих). + const submitStatus = executors.rows.length > 0 + ? initialSubmitStatus(matrix.rows.length, executors.rows.length) + : REQUEST_STATUS.onApproval; + const handleSubmit = (): Promise => submit.run('submit', save(submitStatus), validateSubmit); const sectionProps = { data, @@ -331,6 +358,8 @@ const ConcreteRequestForm: React.FC = ({ waitingText="Укажите начальника участка и проект, чтобы построить маршрут согласования." /> + approval.searchUsers(q)} /> + {/* Автору на доработке важнее всего видеть, почему вернули или отклонили. Только у уже существующей заявки: у новой истории нет. */} {editId && ( diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestView.tsx b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestView.tsx index a0e5fcd..f6d8c91 100644 --- a/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestView.tsx +++ b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestView.tsx @@ -8,10 +8,13 @@ import { DecisionPanel, StatusBadge, ApprovalMatrix, + ExecutorsMatrix, ApprovalHistory, useMatrixEditor, notifyActiveStep, - useRequestTypeId + useRequestTypeId, + useViewExecutors, + approvalPhaseRows } from '../../shared/approval'; import { friendlyErrorMessage } from '../../shared/diagnostics'; import { @@ -71,10 +74,11 @@ const ConcreteRequestView: React.FC = ({ context, ite // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в // reloadKey: после решения строки на сервере меняются — перечитываем матрицу. const [decisionSeq, setDecisionSeq] = useState(0); - const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]); + const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]); const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l) ); + const executors = useViewExecutors(context, approval, itemId, decisionSeq); useEffect(() => { let alive = true; @@ -153,6 +157,7 @@ const ConcreteRequestView: React.FC = ({ context, ite approval.searchUsers(q)} /> + approval.searchUsers(q)} />
{/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/submit.ts b/src/webparts/requestPortal/forms/ConcreteRequest/submit.ts index 80ad495..9b381c9 100644 --- a/src/webparts/requestPortal/forms/ConcreteRequest/submit.ts +++ b/src/webparts/requestPortal/forms/ConcreteRequest/submit.ts @@ -47,7 +47,7 @@ export type ISaveResult = ISaveItemResult; /** * Сохранить заявку на бетон: содержимое + маршрут согласования. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. - * Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей. + * Исполнители (фаза «Исполнение») приходят из маршрута админки и пишутся тем же writeRoute. * Возвращает Id заявки (+ код при создании). * * listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadConcreteRequest). Имеет смысл @@ -59,12 +59,13 @@ export async function saveConcreteRequest( requestTypeId: number, data: IFormData, approvers: IApproverToWrite[], + executors: IApproverToWrite[], status: string, listId?: string ): Promise { const item = buildItem(data, status); const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item); - await writeRoute(concreteRequestApprovalService(listId), result.id, approvers, [], status); + await writeRoute(concreteRequestApprovalService(listId), result.id, approvers, executors, status); return result; } diff --git a/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx b/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx index 7de7791..151d50e 100644 --- a/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx +++ b/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx @@ -15,6 +15,7 @@ import { import { composeRemoveRowRule, ApprovalMatrix, + ExecutorsMatrix, ApprovalHistory, StatusBadge, useMatrixEditor, @@ -25,7 +26,11 @@ import { isEditableStatus, IApproverToWrite, unassignedStepMessage, - resolveWriteListId + resolveWriteListId, + approvalPhaseRows, + executorsToWrite, + initialSubmitStatus, + useFormExecutors } from '../../shared/approval'; import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; @@ -159,7 +164,7 @@ const DismissalForm: React.FC = ({ const loadMatrix = useCallback( () => editId - ? approval.getMatrixForRequest(editId) + ? approval.getMatrixForRequest(editId).then(approvalPhaseRows) : getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval', null, [ { fieldName: 'Position', value: primaryPosition(data.Organization) } ]), @@ -173,6 +178,17 @@ const DismissalForm: React.FC = ({ (login) => approval.getJobTitle(login) ); + // Исполнители самого заявления (фаза «Исполнение» маршрута из админки) — по + // тому же сотруднику и тем же fieldValues, что и согласующие выше. + const executors = useFormExecutors({ + context, + approval, + requestTypeId, + userEmail: data.Employee, + editId, + fieldValues: [{ fieldName: 'Position', value: primaryPosition(data.Organization) }] + }); + // Убрать компанию из заявления (совместитель, реально не увольняется из // ВСЕХ) — см. OrganizationsTable. Снимает компанию из data.Organization И, по // возможности, соответствующего согласующего (директора/должностного @@ -245,6 +261,7 @@ const DismissalForm: React.FC = ({ rtId, data, toApprovers(matrix.rows), + executorsToWrite(executors.rows), status, writeListId ); @@ -259,7 +276,7 @@ const DismissalForm: React.FC = ({ // Отправка на согласование — уведомляем первый активный шаг (поток оповещения). // Имена списков — той же партиции, куда фактически записана заявка (writeListId), // не текущей: поток ищет строку маршрута именно в НЕЙ. - if (status === REQUEST_STATUS.onApproval) { + if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) { await notifyActiveStep(context, approval, id, { requestTypeId: rtId, dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID), @@ -281,11 +298,27 @@ const DismissalForm: React.FC = ({ (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.` ); if (unassignedError) return unassignedError; + const unassignedExecutorError = unassignedStepMessage( + executors.rows, + (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.` + ); + if (unassignedExecutorError) return unassignedExecutorError; return undefined; }; const handleSaveDraft = (): Promise => submit.run('draft', save(REQUEST_STATUS.draft), validate); - const handleSubmit = (): Promise => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit); + // Исполнители в маршруте есть → статус стартует с учётом фаз (initialSubmitStatus: + // без согласующих сразу «Выполнение»); иначе прежний «На согласовании». + const handleSubmit = (): Promise => + submit.run( + 'submit', + save( + executors.rows.length > 0 + ? initialSubmitStatus(matrix.rows.length, executors.rows.length) + : REQUEST_STATUS.onApproval + ), + validateSubmit + ); const sectionProps = { data, @@ -348,6 +381,8 @@ const DismissalForm: React.FC = ({ canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} /> + approval.searchUsers(q)} /> + {/* Автору на доработке важнее всего видеть, почему вернули или отклонили, — поэтому история и в форме, а не только в просмотре. Только у уже существующей заявки: у новой истории нет. */} diff --git a/src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx b/src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx index b8ac226..4bfe0b6 100644 --- a/src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx +++ b/src/webparts/requestPortal/forms/Dismissal/DismissalView.tsx @@ -8,11 +8,14 @@ import { DecisionPanel, StatusBadge, ApprovalMatrix, + ExecutorsMatrix, ApprovalHistory, useMatrixEditor, notifyActiveStep, useRequestTypeId, - REQUEST_STATUS + REQUEST_STATUS, + approvalPhaseRows, + useViewExecutors } from '../../shared/approval'; import { friendlyErrorMessage } from '../../shared/diagnostics'; import { getManagerEmail } from '../../shared/dictionary'; @@ -87,10 +90,12 @@ const DismissalView: React.FC = ({ context, itemId, formKey // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в // reloadKey: после решения строки на сервере меняются — перечитываем матрицу. const [decisionSeq, setDecisionSeq] = useState(0); - const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]); + const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]); const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l) ); + // Исполнители — отдельная фаза тех же строк маршрута; перечитываются вместе с согласующими. + const executors = useViewExecutors(context, approval, itemId, decisionSeq); useEffect(() => { let alive = true; @@ -282,6 +287,8 @@ const DismissalView: React.FC = ({ context, itemId, formKey approval.searchUsers(q)} /> + + approval.searchUsers(q)} />
{/* История согласования: маршрут пересохраняется патчем (см. diff --git a/src/webparts/requestPortal/forms/Dismissal/submit.ts b/src/webparts/requestPortal/forms/Dismissal/submit.ts index 1ee007d..f6450b8 100644 --- a/src/webparts/requestPortal/forms/Dismissal/submit.ts +++ b/src/webparts/requestPortal/forms/Dismissal/submit.ts @@ -96,7 +96,9 @@ export type ISaveResult = ISaveItemResult; /** * Сохранить заявление на увольнение: содержимое + маршрут согласования. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. - * Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей. + * executors — исполнители самого заявления (фаза «Исполнение» маршрута из + * админки; пусто, если в маршруте их нет). Это не маршрут связанной заявки + * Clearance (см. createClearance.ts). * Возвращает Id заявки (+ код при создании). * * listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadDismissalRequest). Имеет смысл @@ -109,6 +111,7 @@ export async function saveDismissalRequest( requestTypeId: number, data: IFormData, approvers: IApproverToWrite[], + executors: IApproverToWrite[], status: string, listId?: string ): Promise { @@ -116,7 +119,7 @@ export async function saveDismissalRequest( const item = buildItem(data, status, employee.Id, employee.Title || data.Employee); const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item); - await writeRoute(dismissalApprovalService(listId), result.id, approvers, [], status); + await writeRoute(dismissalApprovalService(listId), result.id, approvers, executors, status); return result; } diff --git a/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx b/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx index 77cf7b8..6a0ab99 100644 --- a/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx +++ b/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx @@ -18,6 +18,11 @@ import { ApprovalHistory, StatusBadge, useMatrixEditor, + useFormExecutors, + ExecutorsMatrix, + approvalPhaseRows, + executorsToWrite, + initialSubmitStatus, getPlannedMatrix, resolveWriteListId, notifyActiveStep, @@ -155,7 +160,7 @@ const EmployeeTransferForm: React.FC = ({ const loadMatrix = useCallback( () => editId - ? approval.getMatrixForRequest(editId) + ? approval.getMatrixForRequest(editId).then(approvalPhaseRows) : getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval'), [context, approval, editId, data.Employee, requestTypeId] ); @@ -167,6 +172,16 @@ const EmployeeTransferForm: React.FC = ({ (login) => approval.getJobTitle(login) ); + // Исполнители — по тому же сотруднику, что и маршрут согласования. Только чтение: + // их назначает маршрут из админки, не заявитель. + const executors = useFormExecutors({ + context, + approval, + requestTypeId, + userEmail: data.Employee, + editId + }); + const isValid = !!( data.Employee && data.Position.trim() && @@ -213,6 +228,7 @@ const EmployeeTransferForm: React.FC = ({ rtId, data, toApprovers(matrix.rows), + executorsToWrite(executors.rows), status, writeListId ); @@ -227,7 +243,7 @@ const EmployeeTransferForm: React.FC = ({ // Отправка на согласование — уведомляем первый активный шаг (поток оповещения). // Имена списков — той же партиции, куда фактически записана заявка (writeListId), // не текущей: поток ищет строку маршрута именно в НЕЙ. - if (status === REQUEST_STATUS.onApproval) { + if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) { await notifyActiveStep(context, approval, id, { requestTypeId: rtId, dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID), @@ -249,11 +265,27 @@ const EmployeeTransferForm: React.FC = ({ (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.` ); if (unassignedError) return unassignedError; + const unassignedExecutorError = unassignedStepMessage( + executors.rows, + (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.` + ); + if (unassignedExecutorError) return unassignedExecutorError; return undefined; }; const handleSaveDraft = (): Promise => submit.run('draft', save(REQUEST_STATUS.draft), validate); - const handleSubmit = (): Promise => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit); + // Есть исполнители — initialSubmitStatus учитывает маршрут без согласующих + // (сразу «Выполнение»); иначе прежний статус «На согласовании». + const handleSubmit = (): Promise => + submit.run( + 'submit', + save( + executors.rows.length > 0 + ? initialSubmitStatus(matrix.rows.length, executors.rows.length) + : REQUEST_STATUS.onApproval + ), + validateSubmit + ); const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange }; @@ -310,6 +342,8 @@ const EmployeeTransferForm: React.FC = ({ canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} /> + approval.searchUsers(q)} /> + {/* Автору на доработке важнее всего видеть, почему вернули или отклонили. Только у уже существующей заявки: у новой истории нет. */} {editId && ( diff --git a/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferView.tsx b/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferView.tsx index eb29c3a..e47881f 100644 --- a/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferView.tsx +++ b/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferView.tsx @@ -10,6 +10,9 @@ import { ApprovalMatrix, ApprovalHistory, useMatrixEditor, + useViewExecutors, + ExecutorsMatrix, + approvalPhaseRows, notifyActiveStep, useRequestTypeId } from '../../shared/approval'; @@ -70,10 +73,11 @@ const EmployeeTransferView: React.FC = ({ context, i // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в // reloadKey: после решения строки на сервере меняются — перечитываем матрицу. const [decisionSeq, setDecisionSeq] = useState(0); - const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]); + const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]); const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l) ); + const executors = useViewExecutors(context, approval, itemId, decisionSeq); useEffect(() => { let alive = true; @@ -150,6 +154,7 @@ const EmployeeTransferView: React.FC = ({ context, i approval.searchUsers(q)} /> + approval.searchUsers(q)} /> {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} diff --git a/src/webparts/requestPortal/forms/EmployeeTransfer/submit.ts b/src/webparts/requestPortal/forms/EmployeeTransfer/submit.ts index 139ac20..ff6f152 100644 --- a/src/webparts/requestPortal/forms/EmployeeTransfer/submit.ts +++ b/src/webparts/requestPortal/forms/EmployeeTransfer/submit.ts @@ -43,7 +43,8 @@ export type ISaveResult = ISaveItemResult; /** * Сохранить заявку на перемещение: содержимое + маршрут согласования. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. - * Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей. + * executors — исполнители из маршрута (фаза «Исполнение»); пустой список, если в + * маршруте их нет. * Возвращает Id заявки (+ код при создании). * * listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadEmployeeTransferRequest). Имеет @@ -55,6 +56,7 @@ export async function saveEmployeeTransferRequest( requestTypeId: number, data: IFormData, approvers: IApproverToWrite[], + executors: IApproverToWrite[], status: string, listId?: string ): Promise { @@ -62,6 +64,6 @@ export async function saveEmployeeTransferRequest( const item = buildItem(data, status, employee.Id, employee.Title || data.Employee); const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item); - await writeRoute(employeeTransferApprovalService(listId), result.id, approvers, [], status); + await writeRoute(employeeTransferApprovalService(listId), result.id, approvers, executors, status); return result; } diff --git a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx index 768fc32..405faf6 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx @@ -25,7 +25,12 @@ import { isEditableStatus, IApproverToWrite, unassignedStepMessage, - resolveWriteListId + resolveWriteListId, + useFormExecutors, + ExecutorsMatrix, + approvalPhaseRows, + executorsToWrite, + initialSubmitStatus } from '../../shared/approval'; import { uploadAttachments, @@ -181,13 +186,23 @@ const ServiceRequestForm: React.FC = ({ const loadMatrix = useCallback( () => editId - ? approval.getMatrixForRequest(editId) + ? approval.getMatrixForRequest(editId).then(approvalPhaseRows) : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, undefined, projectId), [context, userEmail, approval, editId, projectId, requestTypeId] ); const matrix = useMatrixEditor(loadMatrix, `${userEmail}-${editId ?? 'new'}-${projectId}`, !readOnly, matrixReady, (login) => approval.getJobTitle(login) ); + // Исполнители резолвятся тем же маршрутом и с тем же проектом, что и согласующие: + // пока проект не выбран, e-mail пустой — useFormExecutors не грузит маршрут (404). + const executors = useFormExecutors({ + context, + approval, + requestTypeId, + userEmail: matrixReady ? userEmail : '', + editId, + projectId + }); const hasCosts = hasAnyCostItem(data.Costs); const isValid = @@ -273,7 +288,7 @@ const ServiceRequestForm: React.FC = ({ throw new Error('Тип заявки не определён — обновите страницу и повторите.'); } const writeListId = await resolveWriteListId(context, FORM_ID, rtId, requestId, editListId); - const { id, code } = await saveServiceRequest(requestId, rtId, data, toApprovers(matrix.rows), status, writeListId); + const { id, code } = await saveServiceRequest(requestId, rtId, data, toApprovers(matrix.rows), executorsToWrite(executors.rows), status, writeListId); setRequestId(id); setRequestStatus(status); if (code) { @@ -294,7 +309,7 @@ const ServiceRequestForm: React.FC = ({ if (onSaved) { onSaved(); } - if (status === REQUEST_STATUS.onApproval) { + if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) { await notifyActiveStep(context, approval, id, { requestTypeId: rtId, dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID), @@ -314,11 +329,26 @@ const ServiceRequestForm: React.FC = ({ (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.` ); if (unassignedError) return unassignedError; + const unassignedExecutorError = unassignedStepMessage( + executors.rows, + (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.` + ); + if (unassignedExecutorError) return unassignedExecutorError; return undefined; }; const handleSaveDraft = (): Promise => submit.run('draft', save(REQUEST_STATUS.draft), validate); - const handleSubmit = (): Promise => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit); + const handleSubmit = (): Promise => submit.run( + 'submit', + // Есть исполнители — initialSubmitStatus сам выбирает стартовый статус (согласующих + // может не быть вовсе), иначе прежнее «На согласовании». + save( + executors.rows.length > 0 + ? initialSubmitStatus(matrix.rows.length, executors.rows.length) + : REQUEST_STATUS.onApproval + ), + validateSubmit + ); const sectionProps = { data, @@ -390,6 +420,8 @@ const ServiceRequestForm: React.FC = ({ waitingText="Укажите заявителя и проект, чтобы построить маршрут согласования." /> + approval.searchUsers(q)} /> + {/* Автору на доработке важнее всего видеть, почему вернули или отклонили. Только у уже существующей заявки: у новой истории нет. */} {editId && ( diff --git a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx index 15b34cf..66cc213 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx @@ -20,6 +20,9 @@ import { ApprovalMatrix, ApprovalHistory, useMatrixEditor, + useViewExecutors, + ExecutorsMatrix, + approvalPhaseRows, notifyActiveStep, useRequestTypeId, isEditableStatus @@ -77,10 +80,11 @@ const ServiceRequestView: React.FC = ({ context, itemI const approverService = useMemo(() => new ApproverService(approval, dataListRef), [approval, dataListRef]); const [decisionSeq, setDecisionSeq] = useState(0); - const loadApprovers = React.useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]); + const loadApprovers = React.useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]); const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l) ); + const executors = useViewExecutors(context, approval, itemId, decisionSeq); useEffect(() => { let alive = true; @@ -180,6 +184,7 @@ const ServiceRequestView: React.FC = ({ context, itemI approval.searchUsers(q)} /> + approval.searchUsers(q)} /> {/* История согласования: маршрут пересохраняется патчем (см. diff --git a/src/webparts/requestPortal/forms/ServiceRequest/submit.ts b/src/webparts/requestPortal/forms/ServiceRequest/submit.ts index 2d3263b..e8ea569 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/submit.ts +++ b/src/webparts/requestPortal/forms/ServiceRequest/submit.ts @@ -43,7 +43,7 @@ function buildItem(data: IFormData, status: string): Record { export type ISaveResult = ISaveItemResult; /** - * Сохранить «Заявку на услуги»: контент + маршрут согласования. + * Сохранить «Заявку на услуги»: контент + маршрут согласования и исполнения. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём * новую. Возвращает Id заявки (+ уникальный код при создании). * @@ -56,14 +56,13 @@ export async function saveServiceRequest( requestTypeId: number, data: IFormData, approvers: IApproverToWrite[], + executors: IApproverToWrite[], status: string, listId?: string ): Promise { const item = buildItem(data, status); const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item); - // Фазы исполнения у заявки на услуги нет — договор дальше ведут вне портала, - // поэтому исполнителей не пишем (пустой массив). - await writeRoute(serviceRequestApprovalService(listId), result.id, approvers, [], status); + await writeRoute(serviceRequestApprovalService(listId), result.id, approvers, executors, status); return result; } diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx index 425f81b..a2af1ef 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx @@ -29,7 +29,12 @@ import { REQUEST_STATUS, isEditableStatus, IApproverToWrite, - unassignedStepMessage + unassignedStepMessage, + approvalPhaseRows, + executorsToWrite, + useFormExecutors, + ExecutorsMatrix, + initialSubmitStatus } from '../../shared/approval'; import { uploadAttachments, @@ -172,7 +177,7 @@ const Ticket1CForm: React.FC = ({ const loadMatrix = useCallback( () => editId - ? approval.getMatrixForRequest(editId) + ? approval.getMatrixForRequest(editId).then(approvalPhaseRows) : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Approval', null, [ { fieldName: 'WorkType', value: data.WorkType }, { fieldName: 'Department', value: data.Department } @@ -186,6 +191,20 @@ const Ticket1CForm: React.FC = ({ !!editId || (!!data.WorkType && !!requestTypeId), (login) => approval.getJobTitle(login) ); + // Исполнители (фаза «Исполнение») резолвятся с теми же fieldValues, что и + // согласующие: условные шаги маршрута зависят от вида работ и отдела. + const executors = useFormExecutors({ + context, + approval, + requestTypeId, + userEmail, + editId, + departmentId: null, + fieldValues: [ + { fieldName: 'WorkType', value: data.WorkType }, + { fieldName: 'Department', value: data.Department } + ] + }); // Должность нового «Руководителя» — approval-route её не знает (резолвил шаг // на ДРУГОГО человека и отдал его должность), поэтому подтягиваем сами по // e-mail из поля (SPS-JobTitle, тот же источник, что и у обычных согласующих, @@ -278,7 +297,13 @@ const Ticket1CForm: React.FC = ({ // работ (раньше в этом случае заявка сразу становилась «Согласовано» в // обход согласования; теперь это просто «На согласовании» с пустой // матрицей — сигнал администратору маршрутов донастроить условие). - const finalStatus = intendedStatus === REQUEST_STATUS.draft ? REQUEST_STATUS.draft : REQUEST_STATUS.onApproval; + // Есть исполнители — статус отправки по initialSubmitStatus (без согласующих + // сразу «Выполнение»); иначе, как раньше, «На согласовании» (в т.ч. при пустом маршруте). + const submitStatus = + executors.rows.length > 0 + ? initialSubmitStatus(matrixRows.length, executors.rows.length) + : REQUEST_STATUS.onApproval; + const finalStatus = intendedStatus === REQUEST_STATUS.draft ? REQUEST_STATUS.draft : submitStatus; // requestId уже задан → редактируем существующую заявку, пишем в ту же // партицию (editListId). Иначе — создаём новую, и партицию для записи @@ -293,6 +318,7 @@ const Ticket1CForm: React.FC = ({ requestId, data, toApprovers(matrixRows), + executorsToWrite(executors.rows), finalStatus, writeListId ); @@ -315,7 +341,7 @@ const Ticket1CForm: React.FC = ({ // Передача в Planner теперь происходит только после реального решения // согласующего (см. Ticket1CView.handoffToPlanner) — здесь заявка всегда // отправляется на согласование, минуя Planner напрямую. - if (finalStatus === REQUEST_STATUS.onApproval) { + if (finalStatus === REQUEST_STATUS.onApproval || finalStatus === REQUEST_STATUS.execution) { // Имена списков — той же партиции, куда фактически записали (writeListId), // не обязательно текущей на момент рендера формы: поток ищет строку // маршрута именно в НЕЙ. @@ -345,6 +371,11 @@ const Ticket1CForm: React.FC = ({ (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») не назначен на конкретного человека. Обратитесь к администратору маршрутов согласования.` ); if (unassignedError) return unassignedError; + const unassignedExecutorError = unassignedStepMessage( + executors.rows, + (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.` + ); + if (unassignedExecutorError) return unassignedExecutorError; return undefined; }; @@ -424,6 +455,8 @@ const Ticket1CForm: React.FC = ({ emptyText="Для этого вида работ согласование не требуется — заявка сразу поступит в отдел 1С." /> + approval.searchUsers(q)} /> + {/* Автору на доработке важнее всего видеть, почему вернули или отклонили. Только у уже существующей заявки: у новой истории нет. */} {editId && ( diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx index ae504f4..710b68b 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CView.tsx @@ -25,7 +25,10 @@ import { notifyActiveStep, useRequestTypeId, isEditableStatus, - REQUEST_STATUS + REQUEST_STATUS, + approvalPhaseRows, + useViewExecutors, + ExecutorsMatrix } from '../../shared/approval'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { friendlyErrorMessage } from '../../shared/diagnostics'; @@ -112,10 +115,11 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey, // decisionSeq входит в reloadKey: после решения строки маршрута меняются на // сервере, поэтому счётчик инкрементируется и useMatrixEditor перечитывает матрицу. const [decisionSeq, setDecisionSeq] = useState(0); - const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]); + const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]); const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l) ); + const executors = useViewExecutors(context, approval, itemId, decisionSeq); useEffect(() => { let alive = true; @@ -246,6 +250,8 @@ const Ticket1CView: React.FC = ({ context, itemId, formKey, search={(q) => approval.searchUsers(q)} emptyText="Согласование не требовалось для этого вида работ." /> + + approval.searchUsers(q)} /> {/* История согласования: маршрут пересохраняется патчем (см. diff --git a/src/webparts/requestPortal/forms/Ticket1C/submit.ts b/src/webparts/requestPortal/forms/Ticket1C/submit.ts index b4e459a..7b145cf 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/submit.ts +++ b/src/webparts/requestPortal/forms/Ticket1C/submit.ts @@ -32,10 +32,10 @@ function buildItem(data: IFormData, status: string): Record { export type ISaveResult = ISaveItemResult; /** - * Сохранить заявку в отдел 1С: содержимое + маршрут согласования. + * Сохранить заявку в отдел 1С: содержимое + маршрут (согласующие и исполнители). * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. - * Фазы исполнения нет: после согласования заявка уходит в Planner, а не исполнителю - * из матрицы. Возвращает Id заявки (+ код при создании). + * executors — исполнители из маршрута админки (фаза «Исполнение»), могут быть пустыми: + * тогда после согласования заявка уходит в Planner. Возвращает Id заявки (+ код при создании). * * listId — партиция, в которую фактически пишем: при редактировании — партиция * ОБНОВЛЯЕМОЙ заявки (см. loadTicketRequest), при создании — партиция, @@ -49,13 +49,14 @@ export async function saveTicketRequest( existingId: number | undefined, data: IFormData, approvers: IApproverToWrite[], + executors: IApproverToWrite[], status: string, listId?: string ): Promise { const item = buildItem(data, status); const result = await saveRequestItem(FORM_ID, existingId, listId, TICKET1C_CODE_PREFIX, item); - await writeRoute(ticket1cApprovalService(listId), result.id, approvers, [], status); + await writeRoute(ticket1cApprovalService(listId), result.id, approvers, executors, status); return result; } diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx index 228b758..cd5ac84 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx +++ b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx @@ -15,9 +15,14 @@ import { import { composeRemoveRowRule, ApprovalMatrix, + ExecutorsMatrix, ApprovalHistory, StatusBadge, useMatrixEditor, + useFormExecutors, + approvalPhaseRows, + executorsToWrite, + initialSubmitStatus, getPlannedMatrix, notifyActiveStep, useRequestTypeId, @@ -156,7 +161,7 @@ const UnpaidLeaveForm: React.FC = ({ const loadMatrix = useCallback( () => editId - ? approval.getMatrixForRequest(editId) + ? approval.getMatrixForRequest(editId).then(approvalPhaseRows) : getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval'), [context, approval, editId, data.Employee, requestTypeId] ); @@ -168,6 +173,15 @@ const UnpaidLeaveForm: React.FC = ({ (login) => approval.getJobTitle(login) ); + // Исполнители фазы «Исполнение» — по маршруту из админки, только чтение. + const executors = useFormExecutors({ + context, + approval, + requestTypeId, + userEmail: data.Employee, + editId + }); + // Даты корректны, если конец не раньше начала (обе обязательны). const datesValid = !!(data.StartDate && data.EndDate && data.EndDate >= data.StartDate); // Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала @@ -225,6 +239,7 @@ const UnpaidLeaveForm: React.FC = ({ rtId, data, toApprovers(matrix.rows), + executorsToWrite(executors.rows), status, writeListId ); @@ -239,7 +254,7 @@ const UnpaidLeaveForm: React.FC = ({ // Отправка на согласование — уведомляем первый активный шаг (поток оповещения). // Имена списков — той же партиции, куда фактически записана заявка (writeListId), // не текущей: поток ищет строку маршрута именно в НЕЙ. - if (status === REQUEST_STATUS.onApproval) { + if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) { await notifyActiveStep(context, approval, id, { requestTypeId: rtId, dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID), @@ -261,11 +276,22 @@ const UnpaidLeaveForm: React.FC = ({ (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.` ); if (unassignedError) return unassignedError; + const unassignedExecutorError = unassignedStepMessage( + executors.rows, + (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.` + ); + if (unassignedExecutorError) return unassignedExecutorError; return undefined; }; const handleSaveDraft = (): Promise => submit.run('draft', save(REQUEST_STATUS.draft), validate); - const handleSubmit = (): Promise => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit); + // Есть исполнители — initialSubmitStatus учитывает маршрут без согласующих. + const handleSubmit = (): Promise => + submit.run( + 'submit', + save(executors.rows.length > 0 ? initialSubmitStatus(matrix.rows.length, executors.rows.length) : REQUEST_STATUS.onApproval), + validateSubmit + ); const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange }; @@ -322,6 +348,8 @@ const UnpaidLeaveForm: React.FC = ({ canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} /> + approval.searchUsers(q)} /> + {/* Автору на доработке важнее всего видеть, почему вернули или отклонили. Только у уже существующей заявки: у новой истории нет. */} {editId && ( diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveView.tsx b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveView.tsx index a9af4a3..e2f0d53 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveView.tsx +++ b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveView.tsx @@ -8,8 +8,11 @@ import { DecisionPanel, StatusBadge, ApprovalMatrix, + ExecutorsMatrix, ApprovalHistory, useMatrixEditor, + useViewExecutors, + approvalPhaseRows, notifyActiveStep, useRequestTypeId } from '../../shared/approval'; @@ -70,7 +73,8 @@ const UnpaidLeaveView: React.FC = ({ context, itemId, for // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в // reloadKey: после решения строки на сервере меняются — перечитываем матрицу. const [decisionSeq, setDecisionSeq] = useState(0); - const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]); + const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]); + const executors = useViewExecutors(context, approval, itemId, decisionSeq); const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l) ); @@ -150,6 +154,8 @@ const UnpaidLeaveView: React.FC = ({ context, itemId, for approval.searchUsers(q)} /> + + approval.searchUsers(q)} /> {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/submit.ts b/src/webparts/requestPortal/forms/UnpaidLeave/submit.ts index 62353f3..4b43bc0 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/submit.ts +++ b/src/webparts/requestPortal/forms/UnpaidLeave/submit.ts @@ -62,7 +62,7 @@ export type ISaveResult = ISaveItemResult; /** * Сохранить заявку без содержания: содержимое + маршрут согласования. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. - * Фазы исполнения нет — writeRoute вызывается с пустым списком исполнителей. + * executors — исполнители фазы «Исполнение» из маршрута (пусто, если в маршруте их нет). * Возвращает Id заявки (+ код при создании). * * listId — партиция ОБНОВЛЯЕМОЙ заявки (см. loadUnpaidLeaveRequest). Имеет смысл @@ -75,6 +75,7 @@ export async function saveUnpaidLeaveRequest( requestTypeId: number, data: IFormData, approvers: IApproverToWrite[], + executors: IApproverToWrite[], status: string, listId?: string ): Promise { @@ -84,6 +85,6 @@ export async function saveUnpaidLeaveRequest( const item = buildItem(data, status, employee.Id, employee.Title || data.Employee, substituteId); const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item); - await writeRoute(unpaidLeaveApprovalService(listId), result.id, approvers, [], status); + await writeRoute(unpaidLeaveApprovalService(listId), result.id, approvers, executors, status); return result; } diff --git a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx index 6950f14..2220059 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx @@ -18,6 +18,11 @@ import { ApprovalHistory, StatusBadge, useMatrixEditor, + useFormExecutors, + ExecutorsMatrix, + approvalPhaseRows, + executorsToWrite, + initialSubmitStatus, getPlannedMatrix, resolveWriteListId, notifyActiveStep, @@ -157,7 +162,7 @@ const VacationForm: React.FC = ({ const loadMatrix = useCallback( () => editId - ? approval.getMatrixForRequest(editId) + ? approval.getMatrixForRequest(editId).then(approvalPhaseRows) : getPlannedMatrix(context, requestTypeId as number, data.Employee, approval, 'Approval'), [context, approval, editId, data.Employee, requestTypeId] ); @@ -169,6 +174,16 @@ const VacationForm: React.FC = ({ (login) => approval.getJobTitle(login) ); + // Исполнители из маршрута админки (фаза «Исполнение»): только чтение, таблица + // и запись в заявку — когда они в маршруте есть. + const executors = useFormExecutors({ + context, + approval, + requestTypeId, + userEmail: data.Employee, + editId + }); + const daysNum = Number(data.DaysCount); // Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала // отпуска — календарь (min у input type=date, см. RequestSection) не даёт @@ -223,6 +238,7 @@ const VacationForm: React.FC = ({ rtId, data, toApprovers(matrix.rows), + executorsToWrite(executors.rows), status, writeListId ); @@ -238,7 +254,7 @@ const VacationForm: React.FC = ({ // Имена списков — той же партиции, куда фактически записали (writeListId), // не обязательно текущей на момент рендера формы: поток ищет строку // маршрута именно в НЕЙ. - if (status === REQUEST_STATUS.onApproval) { + if (status === REQUEST_STATUS.onApproval || status === REQUEST_STATUS.execution) { await notifyActiveStep(context, approval, id, { requestTypeId: rtId, dataListName: writeListId ? dataListTitleForListId(FORM_ID, writeListId) : dataListTitle(FORM_ID), @@ -260,11 +276,27 @@ const VacationForm: React.FC = ({ (r) => `Шаг ${r.order} маршрута согласования («${r.position || 'без должности'}») без назначенного человека — выберите согласующего или удалите шаг.` ); if (unassignedError) return unassignedError; + const unassignedExecutorError = unassignedStepMessage( + executors.rows, + (r) => `Шаг ${r.order} исполнения («${r.position || 'без должности'}») без назначенного человека — обратитесь к администратору маршрута.` + ); + if (unassignedExecutorError) return unassignedExecutorError; return undefined; }; const handleSaveDraft = (): Promise => submit.run('draft', save(REQUEST_STATUS.draft), validate); - const handleSubmit = (): Promise => submit.run('submit', save(REQUEST_STATUS.onApproval), validateSubmit); + // Есть исполнители — initialSubmitStatus учтёт и маршрут без согласующих (сразу «Выполнение»); + // иначе прежнее поведение: «На согласовании». + const handleSubmit = (): Promise => + submit.run( + 'submit', + save( + executors.rows.length > 0 + ? initialSubmitStatus(matrix.rows.length, executors.rows.length) + : REQUEST_STATUS.onApproval + ), + validateSubmit + ); const sectionProps = { data, set, context, onEmployeeChange: handleEmployeeChange }; @@ -321,6 +353,8 @@ const VacationForm: React.FC = ({ canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} /> + approval.searchUsers(q)} /> + {/* Автору на доработке важнее всего видеть, почему вернули или отклонили. Только у уже существующей заявки: у новой истории нет. */} {editId && ( diff --git a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx index e5ba3ba..31b875e 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx @@ -10,6 +10,9 @@ import { ApprovalMatrix, ApprovalHistory, useMatrixEditor, + useViewExecutors, + ExecutorsMatrix, + approvalPhaseRows, notifyActiveStep, useRequestTypeId } from '../../shared/approval'; @@ -78,10 +81,11 @@ const VacationView: React.FC = ({ context, itemId, formKey, // Матрица маршрута из фактических строк заявки (read-only). decisionSeq в // reloadKey: после решения строки на сервере меняются — перечитываем матрицу. const [decisionSeq, setDecisionSeq] = useState(0); - const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId), [approval, itemId]); + const loadApprovers = useCallback(() => approval.getMatrixForRequest(itemId).then(approvalPhaseRows), [approval, itemId]); const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l) ); + const executors = useViewExecutors(context, approval, itemId, decisionSeq); useEffect(() => { let alive = true; @@ -180,6 +184,7 @@ const VacationView: React.FC = ({ context, itemId, formKey, approval.searchUsers(q)} /> + approval.searchUsers(q)} /> {/* Панель решения — ВНЕ печати и FormModeProvider (комментарий редактируемый). */} diff --git a/src/webparts/requestPortal/forms/Vacation/submit.ts b/src/webparts/requestPortal/forms/Vacation/submit.ts index e5bbd27..42d95dd 100644 --- a/src/webparts/requestPortal/forms/Vacation/submit.ts +++ b/src/webparts/requestPortal/forms/Vacation/submit.ts @@ -54,7 +54,7 @@ export type ISaveResult = ISaveItemResult; /** * Сохранить заявку на отпуск: содержимое + маршрут согласования. * existingId задан → обновляем ту же запись (черновик/доработка), иначе создаём новую. - * Фазы исполнения у отпуска нет — writeRoute вызывается с пустым списком исполнителей. + * executors — исполнители фазы «Исполнение» из маршрута админки (пусто, если в маршруте их нет). * Возвращает Id заявки (+ код при создании). * * listId — партиция, в которую фактически пишем: при редактировании — партиция @@ -70,6 +70,7 @@ export async function saveVacationRequest( requestTypeId: number, data: IFormData, approvers: IApproverToWrite[], + executors: IApproverToWrite[], status: string, listId?: string ): Promise { @@ -79,6 +80,6 @@ export async function saveVacationRequest( const item = buildItem(data, status, employee.Id, employee.Title || data.Employee, substituteId); const result = await saveRequestItem(FORM_ID, existingId, listId, requestTypeId, item); - await writeRoute(vacationApprovalService(listId), result.id, approvers, [], status); + await writeRoute(vacationApprovalService(listId), result.id, approvers, executors, status); return result; } diff --git a/src/webparts/requestPortal/shared/approval/index.ts b/src/webparts/requestPortal/shared/approval/index.ts index 2e2ee9a..56b8b04 100644 --- a/src/webparts/requestPortal/shared/approval/index.ts +++ b/src/webparts/requestPortal/shared/approval/index.ts @@ -63,11 +63,19 @@ export { notifyActiveStep } from './notify.service'; export type { INotifyTarget } from './notify.service'; export { useMatrixEditor, findUnassignedStep, unassignedStepMessage } from './useMatrixEditor'; export type { IUseMatrixEditor } from './useMatrixEditor'; +export { + approvalPhaseRows, + executionPhaseRows, + executorsToWrite, + useFormExecutors, + useViewExecutors +} from './useRouteExecutors'; export { resolveApproverOrgInfo } from './approverOrgInfo'; export type { IApproverOrgInfo } from './approverOrgInfo'; export { canEditRoute, composeRemoveRowRule } from './routeEditRights'; export { canEditAllFields } from './fullEditRights'; export { ApprovalMatrix } from './ui/ApprovalMatrix'; +export { ExecutorsMatrix } from './ui/ExecutorsMatrix'; export { ApprovalHistory } from './history/ApprovalHistory'; export { ApprovalHistoryFeed } from './history/ApprovalHistoryFeed'; export { HISTORY_TEXT } from './history/historyTexts'; diff --git a/src/webparts/requestPortal/shared/approval/ui/ExecutorsMatrix.tsx b/src/webparts/requestPortal/shared/approval/ui/ExecutorsMatrix.tsx new file mode 100644 index 0000000..9a4e1bc --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/ui/ExecutorsMatrix.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { ISiteUser } from '../models/ISiteUser'; +import { IUseMatrixEditor } from '../useMatrixEditor'; +import { ApprovalMatrix } from './ApprovalMatrix'; + +interface IExecutorsMatrixProps { + editor: IUseMatrixEditor; + search: (query: string) => Promise; + /** Заголовок; по умолчанию «Исполнение». */ + title?: string; +} + +/** + * Таблица исполнителей — рисуется только когда в маршруте из админки они есть: + * у форм без фазы исполнения блока нет вовсе. + */ +export const ExecutorsMatrix: React.FC = ({ editor, search, title = 'Исполнение' }) => { + if (editor.loading || editor.rows.length === 0) return null; + return ( + + ); +}; diff --git a/src/webparts/requestPortal/shared/approval/useRouteExecutors.ts b/src/webparts/requestPortal/shared/approval/useRouteExecutors.ts new file mode 100644 index 0000000..0cd6f38 --- /dev/null +++ b/src/webparts/requestPortal/shared/approval/useRouteExecutors.ts @@ -0,0 +1,102 @@ +import { useCallback } from 'react'; +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { ApprovalListService, IApproverToWrite } from './approvalList.service'; +import { getPlannedMatrix } from './approvalSubmission.service'; +import { resolveApproverOrgInfo } from './approverOrgInfo'; +import { IResolveFieldValue } from './models/IApprovalRouteApi'; +import { IMatrixRow } from './models/IMatrixRow'; +import { APPROVAL_PHASE } from './models/statuses'; +import { IUseMatrixEditor, useMatrixEditor } from './useMatrixEditor'; + +/** Строки фазы согласования (всё, что не «Исполнение») — для таблицы согласующих. */ +export const approvalPhaseRows = (rows: IMatrixRow[]): IMatrixRow[] => + rows.filter((r) => r.phase !== APPROVAL_PHASE.execution); + +/** Строки фазы исполнения — для таблицы исполнителей. */ +export const executionPhaseRows = (rows: IMatrixRow[]): IMatrixRow[] => + rows.filter((r) => r.phase === APPROVAL_PHASE.execution); + +/** + * Строки матрицы исполнителей → то, что пишет writeRoute. Номер шага + * нормализуется к 1..N внутри фазы: у уже записанных строк он сквозной (со + * смещением на согласующих), а writeRoute смещает фазу сам. + */ +export const executorsToWrite = (rows: IMatrixRow[]): IApproverToWrite[] => { + const base = rows.reduce((min, r) => Math.min(min, r.order), Number.MAX_SAFE_INTEGER); + return rows.map((r) => ({ + email: r.email, + position: r.position, + allowReassign: r.allowReassign, + order: r.order - base + 1, + slaHours: r.slaHours, + askOverdueReason: r.askOverdueReason, + autoApprove: r.autoApprove + })); +}; + +interface IFormExecutorsSource { + context: WebPartContext; + approval: ApprovalListService; + requestTypeId: number | undefined; + /** E-mail, по которому резолвится маршрут (инициатор/выбранный сотрудник). */ + userEmail: string; + /** Редактируемая заявка — исполнители читаются из её строк, а не планируются заново. */ + editId: number | undefined; + /** Те же параметры, что форма отдаёт getPlannedMatrix для согласующих. */ + departmentId?: string | null; // eslint-disable-line @rushstack/no-new-null -- транзитом в resolveRoute + fieldValues?: IResolveFieldValue[]; + projectId?: string | null; // eslint-disable-line @rushstack/no-new-null -- транзитом в resolveRoute + /** Дополнительный ключ перезагрузки (значение поля, влияющее на маршрут). */ + reloadSuffix?: string; +} + +/** + * Исполнители формы заполнения (фаза «Исполнение»): планируемые по маршруту из + * админки для новой заявки, из записанных строк — для черновика/доработки. + * Только чтение: исполнителя назначает маршрут, не заявитель. + */ +export function useFormExecutors(source: IFormExecutorsSource): IUseMatrixEditor { + const { context, approval, requestTypeId, userEmail, editId, departmentId, fieldValues, projectId, reloadSuffix = '' } = source; + const fieldValuesKey = JSON.stringify(fieldValues ?? []); + const load = useCallback( + () => + editId + ? approval.getMatrixForRequest(editId).then(executionPhaseRows) + : getPlannedMatrix(context, requestTypeId as number, userEmail, approval, 'Execution', departmentId, fieldValues, projectId), + // fieldValues сравниваем по содержимому: форма пересоздаёт массив на каждый рендер. + // eslint-disable-next-line react-hooks/exhaustive-deps + [context, approval, editId, requestTypeId, userEmail, departmentId, fieldValuesKey, projectId] + ); + return useMatrixEditor( + load, + `x-${userEmail}-${editId ?? 'new'}-${departmentId ?? ''}-${projectId ?? ''}-${fieldValuesKey}-${reloadSuffix}`, + false, + !!editId || (!!userEmail && !!requestTypeId), + (login) => approval.getJobTitle(login), + resolveApproverOrgInfo(context) + ); +} + +/** + * Исполнители вьюхи просмотра — из фактических строк заявки. decisionSeq в + * ключе: после решения/замены строки на сервере меняются — перечитываем. + */ +export function useViewExecutors( + context: WebPartContext, + approval: ApprovalListService, + itemId: number, + decisionSeq: number +): IUseMatrixEditor { + const load = useCallback( + () => approval.getMatrixForRequest(itemId).then(executionPhaseRows), + [approval, itemId] + ); + return useMatrixEditor( + load, + `e-${itemId}-${decisionSeq}`, + false, + true, + (l) => approval.getJobTitle(l), + resolveApproverOrgInfo(context) + ); +} -- 2.34.1 From d2d2f9f647f98e0103d46753abe0e1aec8204bbc Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 13:41:45 +0500 Subject: [PATCH 04/24] =?UTF-8?q?feat:=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D1=8B=20=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83?= =?UTF-8?q?=D1=82=D0=B0=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D1=8F=D1=8E?= =?UTF-8?q?=D1=82=20=D0=B8=20=D1=83=D0=B4=D0=B0=D0=BB=D1=8F=D1=8E=D1=82=20?= =?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=20=D0=BF=D1=80=D0=B8=20=D1=81=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=B7=D0=B0=D1=8F?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit canEditExecutors: те же люди, что в canEditRoute (в т.ч. gorbenko.pavel), могут править матрицу «Исполнение» во всех формах с фазой исполнения; блок виден им и пустым. Остальным исполнители по-прежнему только для чтения. Реестр особых прав обновлён. Co-Authored-By: Claude Sonnet 5.5 --- docs/permissions.md | 1 + .../forms/ConcreteRequest/ConcreteRequestForm.tsx | 1 + .../forms/Dismissal/DismissalForm.tsx | 1 + .../EmployeeTransfer/EmployeeTransferForm.tsx | 1 + .../forms/RecruitingHire/RecruitingHireForm.tsx | 6 ++++-- .../RecruitingSelectionForm.tsx | 6 ++++-- .../requestPortal/forms/Referral/ReferralForm.tsx | 6 ++++-- .../forms/ServiceRequest/ServiceRequestForm.tsx | 1 + .../forms/SkudExplanation/SkudExplanationForm.tsx | 7 +++++-- .../forms/SkudExplanation/useSkudRoute.ts | 8 +++++--- .../requestPortal/forms/Ticket1C/Ticket1CForm.tsx | 1 + .../forms/TmcTransfer/TmcTransferForm.tsx | 6 ++++-- .../forms/UnpaidLeave/UnpaidLeaveForm.tsx | 1 + .../requestPortal/forms/Vacation/VacationForm.tsx | 1 + .../forms/WorkplaceSetup/WorkplaceSetupForm.tsx | 6 ++++-- .../requestPortal/shared/approval/index.ts | 2 +- .../shared/approval/routeEditRights.ts | 15 +++++++++++++++ .../shared/approval/ui/ExecutorsMatrix.tsx | 6 ++++-- .../shared/approval/useRouteExecutors.ts | 10 +++++++--- 19 files changed, 65 insertions(+), 21 deletions(-) diff --git a/docs/permissions.md b/docs/permissions.md index 17d045c..43e9487 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -18,6 +18,7 @@ |---|---|---|---|---| | Удалять согласующих, которые ещё ждут очереди | Алтынай Бултурукова, Павел Горбенко | email в коде | Подбор, Приём, Реферальная рекомендация: и при составлении, и в поданной заявке | `shared/approval/routeEditRights.ts` → `canEditRoute` | | Удалять любых согласующих **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все остальные формы с маршрутом согласования (отпуск, увольнение, 1С и т. д.); в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `composeRemoveRowRule` | +| Добавлять и удалять исполнителей **при составлении** заявки | те же (`canEditRoute`) | email в коде | Все формы, где в маршруте из админки есть фаза исполнения; в поданной заявке — нет | `shared/approval/routeEditRights.ts` → `canEditExecutors` | | Заменять и удалять исполнителей, **в том числе активного** («На исполнении») | Алтынай Бултурукова | тот же email (`canEditRoute`) | **Только** Заявка на подбор, матрица «Исполнение» | `canEditRoute` + `forms/RecruitingSelection/RecruitingSelectionView.tsx` | | Править **все поля** заявки на своём активном шаге согласования (без вложений) | Алтынай Бултурукова | email в коде | Подбор, Приём (Реферальная рекомендация — нет) | `shared/approval/fullEditRights.ts` → `canEditAllFields` | | Править оклад на ИС, оклад после ИС, «Отдел/Участок» на своём шаге согласования | Начальник расчётной группы (сейчас Ким Анна) | должность шага: «начальник/руководитель» + «расчет» | Заявка на приём | `forms/RecruitingHire/approverRights.ts` → `canEditPayrollFields` | diff --git a/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx index f31a471..d619235 100644 --- a/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx +++ b/src/webparts/requestPortal/forms/ConcreteRequest/ConcreteRequestForm.tsx @@ -191,6 +191,7 @@ const ConcreteRequestForm: React.FC = ({ // Исполнители — из маршрута админки (заявитель их не правит); те же параметры // резолва, что и у согласующих. const executors = useFormExecutors({ + readOnly, context, approval, requestTypeId, diff --git a/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx b/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx index 151d50e..0600f05 100644 --- a/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx +++ b/src/webparts/requestPortal/forms/Dismissal/DismissalForm.tsx @@ -181,6 +181,7 @@ const DismissalForm: React.FC = ({ // Исполнители самого заявления (фаза «Исполнение» маршрута из админки) — по // тому же сотруднику и тем же fieldValues, что и согласующие выше. const executors = useFormExecutors({ + readOnly, context, approval, requestTypeId, diff --git a/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx b/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx index 6a0ab99..07f4471 100644 --- a/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx +++ b/src/webparts/requestPortal/forms/EmployeeTransfer/EmployeeTransferForm.tsx @@ -175,6 +175,7 @@ const EmployeeTransferForm: React.FC = ({ // Исполнители — по тому же сотруднику, что и маршрут согласования. Только чтение: // их назначает маршрут из админки, не заявитель. const executors = useFormExecutors({ + readOnly, context, approval, requestTypeId, diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index 22e01f7..c86d129 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -24,6 +24,7 @@ import { ApprovalHistory, StatusBadge, useMatrixEditor, + canEditExecutors, getPlannedMatrix, resolveWriteListId, notifyActiveStep, @@ -204,7 +205,7 @@ const RecruitingHireForm: React.FC = ({ const executors = useMatrixEditor( loadExecutors, `${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`, - false, + canEditExecutors(context, readOnly), matrixReady, (login) => approval.getJobTitle(login), resolveApproverOrgInfo(context) @@ -460,12 +461,13 @@ const RecruitingHireForm: React.FC = ({ canRemoveRow={canRemoveApprover ? () => true : undefined} /> - {!executors.loading && executors.rows.length > 0 && ( + {!executors.loading && (executors.editable || executors.rows.length > 0) && ( approval.searchUsers(q)} title="Исполнение (после согласования)" roleLabel="Исполнитель" + pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не заданы по маршруту." /> )} diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx index f15cb44..b6342ad 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx @@ -24,6 +24,7 @@ import { ApprovalHistory, StatusBadge, useMatrixEditor, + canEditExecutors, getPlannedMatrix, resolveWriteListId, notifyActiveStep, @@ -233,7 +234,7 @@ const RecruitingSelectionForm: React.FC = ({ const executors = useMatrixEditor( loadExecutors, `${userEmail}-${editId ?? 'new'}-${projectId}-${companiesFieldValue}`, - false, + canEditExecutors(context, readOnly), matrixReady, (login) => approval.getJobTitle(login), resolveApproverOrgInfo(context) @@ -450,12 +451,13 @@ const RecruitingSelectionForm: React.FC = ({ canRemoveRow={canRemoveApprover ? () => true : undefined} /> - {!executors.loading && executors.rows.length > 0 && ( + {!executors.loading && (executors.editable || executors.rows.length > 0) && ( approval.searchUsers(q)} title="Исполнение (после согласования)" roleLabel="Исполнитель" + pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не заданы по маршруту." /> )} diff --git a/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx b/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx index 9c55fe6..5d41623 100644 --- a/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx +++ b/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx @@ -21,6 +21,7 @@ import { ApprovalHistory, StatusBadge, useMatrixEditor, + canEditExecutors, getPlannedMatrix, notifyActiveStep, useRequestTypeId, @@ -240,7 +241,7 @@ const ReferralForm: React.FC = ({ const executors = useMatrixEditor( loadExecutors, `${data.Referrer}-${editId ?? 'new'}`, - false, + canEditExecutors(context, readOnly), !!editId || (!!data.Referrer && !!requestTypeId), (login) => approval.getJobTitle(login), resolveApproverOrgInfo(context) @@ -469,12 +470,13 @@ const ReferralForm: React.FC = ({ canRemoveRow={canRemoveApprover ? () => true : undefined} /> - {!executors.loading && executors.rows.length > 0 && ( + {!executors.loading && (executors.editable || executors.rows.length > 0) && ( approval.searchUsers(q)} title={noApprovalPhase ? 'Исполнение' : 'Исполнение (после согласования)'} roleLabel="Исполнитель" + pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не заданы по маршруту." /> )} diff --git a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx index 405faf6..b2aa3c8 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestForm.tsx @@ -196,6 +196,7 @@ const ServiceRequestForm: React.FC = ({ // Исполнители резолвятся тем же маршрутом и с тем же проектом, что и согласующие: // пока проект не выбран, e-mail пустой — useFormExecutors не грузит маршрут (404). const executors = useFormExecutors({ + readOnly, context, approval, requestTypeId, diff --git a/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx b/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx index 359b075..a06538d 100644 --- a/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx +++ b/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx @@ -15,6 +15,7 @@ import { import { composeRemoveRowRule, ApprovalMatrix, + canEditExecutors, ApprovalHistory, StatusBadge, notifyActiveStep, @@ -136,7 +137,8 @@ const SkudExplanationForm: React.FC = ({ requestId: editId, employeeEmail: data.Employee, requestTypeId, - editable: !readOnly + editable: !readOnly, + executorsEditable: canEditExecutors(context, readOnly) }); const validate = (): string | undefined => { @@ -276,12 +278,13 @@ const SkudExplanationForm: React.FC = ({ canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} /> - {!executors.loading && executors.rows.length > 0 && ( + {!executors.loading && (executors.editable || executors.rows.length > 0) && ( approval.searchUsers(q)} title="Исполнение (после согласования)" roleLabel="Исполнитель" + pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не заданы по маршруту." /> )} diff --git a/src/webparts/requestPortal/forms/SkudExplanation/useSkudRoute.ts b/src/webparts/requestPortal/forms/SkudExplanation/useSkudRoute.ts index e798198..fd3d7d3 100644 --- a/src/webparts/requestPortal/forms/SkudExplanation/useSkudRoute.ts +++ b/src/webparts/requestPortal/forms/SkudExplanation/useSkudRoute.ts @@ -14,7 +14,7 @@ type MatrixEditor = ReturnType; export interface ISkudRoute { /** Согласующие — редактируемы заявителем в новой заявке / черновике / доработке. */ approvers: MatrixEditor; - /** Исполнители по маршруту (фаза «Исполнение») — только чтение. */ + /** Исполнители по маршруту (фаза «Исполнение») — только чтение, кроме редактора маршрута. */ executors: MatrixEditor; } @@ -27,6 +27,8 @@ interface IRouteSource { requestTypeId: number | undefined; /** Можно ли править согласующих. */ editable: boolean; + /** Можно ли править исполнителей (только редактору маршрута, см. canEditExecutors). */ + executorsEditable?: boolean; /** Меняется после решения согласующего — перечитать строки. */ reloadSeq?: number; } @@ -40,7 +42,7 @@ const byPhase = (execution: boolean) => (rows: IMatrixRow[]): IMatrixRow[] => * маршрута «пропадал» бы. Паттерн тот же, что в Referral. */ export function useSkudRoute(source: IRouteSource): ISkudRoute { - const { context, approval, requestId, employeeEmail, requestTypeId, editable, reloadSeq = 0 } = source; + const { context, approval, requestId, employeeEmail, requestTypeId, editable, executorsEditable = false, reloadSeq = 0 } = source; const canLoad = !!requestId || (!!employeeEmail && !!requestTypeId); const reloadKey = `${employeeEmail}-${requestId ?? 'new'}-${reloadSeq}`; @@ -62,6 +64,6 @@ export function useSkudRoute(source: IRouteSource): ISkudRoute { const jobTitle = (login: string): Promise => approval.getJobTitle(login); const approvers = useMatrixEditor(loadApprovers, reloadKey, editable, canLoad, jobTitle, resolveApproverOrgInfo(context)); - const executors = useMatrixEditor(loadExecutors, reloadKey, false, canLoad, jobTitle, resolveApproverOrgInfo(context)); + const executors = useMatrixEditor(loadExecutors, reloadKey, executorsEditable, canLoad, jobTitle, resolveApproverOrgInfo(context)); return { approvers, executors }; } diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx index a2af1ef..e394b6a 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx @@ -194,6 +194,7 @@ const Ticket1CForm: React.FC = ({ // Исполнители (фаза «Исполнение») резолвятся с теми же fieldValues, что и // согласующие: условные шаги маршрута зависят от вида работ и отдела. const executors = useFormExecutors({ + readOnly, context, approval, requestTypeId, diff --git a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx index 6676050..bfb23d4 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx +++ b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx @@ -22,6 +22,7 @@ import { ApprovalHistory, StatusBadge, useMatrixEditor, + canEditExecutors, getPlannedMatrix, notifyActiveStep, useRequestTypeId, @@ -314,7 +315,7 @@ const TmcTransferForm: React.FC = ({ const executors = useMatrixEditor( loadExecutors, `${userEmail}-${editId ?? 'new'}-${projectId}-${targetProjectId}`, - false, + canEditExecutors(context, readOnly), executorsReady, (login) => approval.getJobTitle(login) ); @@ -507,12 +508,13 @@ const TmcTransferForm: React.FC = ({ {/* Блок исполнителя показываем, только когда по маршруту реально есть исполнители (грузится по проектам, см. executorsReady). */} - {executorsReady && !executors.loading && executors.rows.length > 0 && ( + {executorsReady && !executors.loading && (executors.editable || executors.rows.length > 0) && ( approval.searchUsers(q)} title="Исполнение (после согласования)" roleLabel="Исполнитель" + pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не заданы по маршруту." /> )} diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx index cd5ac84..c737fab 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx +++ b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx @@ -175,6 +175,7 @@ const UnpaidLeaveForm: React.FC = ({ // Исполнители фазы «Исполнение» — по маршруту из админки, только чтение. const executors = useFormExecutors({ + readOnly, context, approval, requestTypeId, diff --git a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx index 2220059..0911ea8 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx @@ -177,6 +177,7 @@ const VacationForm: React.FC = ({ // Исполнители из маршрута админки (фаза «Исполнение»): только чтение, таблица // и запись в заявку — когда они в маршруте есть. const executors = useFormExecutors({ + readOnly, context, approval, requestTypeId, diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx index 77c2199..0947f02 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx @@ -22,6 +22,7 @@ import { ApprovalHistory, StatusBadge, useMatrixEditor, + canEditExecutors, getPlannedMatrix, resolveWriteListId, notifyActiveStep, @@ -213,7 +214,7 @@ const WorkplaceSetupForm: React.FC = ({ const executors = useMatrixEditor( loadExecutors, `${userEmail}-${editId ?? 'new'}-${data.DepartmentId}`, - false, + canEditExecutors(context, readOnly), !!editId || !!requestTypeId, (login) => approval.getJobTitle(login) ); @@ -398,12 +399,13 @@ const WorkplaceSetupForm: React.FC = ({ canRemoveRow={composeRemoveRowRule(effectiveUserEmail(context))} /> - {!executors.loading && executors.rows.length > 0 && ( + {!executors.loading && (executors.editable || executors.rows.length > 0) && ( approval.searchUsers(q)} title="Исполнение (после согласования)" roleLabel="Исполнитель" + pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не заданы по маршруту." /> )} diff --git a/src/webparts/requestPortal/shared/approval/index.ts b/src/webparts/requestPortal/shared/approval/index.ts index 56b8b04..d089037 100644 --- a/src/webparts/requestPortal/shared/approval/index.ts +++ b/src/webparts/requestPortal/shared/approval/index.ts @@ -72,7 +72,7 @@ export { } from './useRouteExecutors'; export { resolveApproverOrgInfo } from './approverOrgInfo'; export type { IApproverOrgInfo } from './approverOrgInfo'; -export { canEditRoute, composeRemoveRowRule } from './routeEditRights'; +export { canEditRoute, canEditExecutors, composeRemoveRowRule } from './routeEditRights'; export { canEditAllFields } from './fullEditRights'; export { ApprovalMatrix } from './ui/ApprovalMatrix'; export { ExecutorsMatrix } from './ui/ExecutorsMatrix'; diff --git a/src/webparts/requestPortal/shared/approval/routeEditRights.ts b/src/webparts/requestPortal/shared/approval/routeEditRights.ts index e780671..ce16e29 100644 --- a/src/webparts/requestPortal/shared/approval/routeEditRights.ts +++ b/src/webparts/requestPortal/shared/approval/routeEditRights.ts @@ -1,3 +1,6 @@ +import { WebPartContext } from '@microsoft/sp-webpart-base'; +import { effectiveUserEmail } from '../impersonation'; + /** * Кто может удалять согласующих из маршрута (и при составлении заявки, и на * своём шаге согласования) — сверх обычных прав инициатора/согласующего. @@ -30,3 +33,15 @@ export function canEditRoute(email: string | undefined): boolean { export function composeRemoveRowRule(email: string | undefined): (() => boolean) | undefined { return canEditRoute(email) ? () => true : undefined; } + +/** + * Может ли текущий пользователь править состав ИСПОЛНИТЕЛЕЙ (добавлять и удалять) + * при составлении заявки: те же редакторы маршрута, что и для согласующих. + * Исполнителей по умолчанию назначает маршрут из админки, заявителю они только + * показываются; редактору маршрута матрица «Исполнение» становится редактируемой. + * Учитывает подмену личности (effectiveUserEmail). readOnly — заявка уже не + * редактируется (отправлена), тогда править нельзя никому. + */ +export function canEditExecutors(context: WebPartContext, readOnly: boolean): boolean { + return !readOnly && canEditRoute(effectiveUserEmail(context)); +} diff --git a/src/webparts/requestPortal/shared/approval/ui/ExecutorsMatrix.tsx b/src/webparts/requestPortal/shared/approval/ui/ExecutorsMatrix.tsx index 9a4e1bc..369f457 100644 --- a/src/webparts/requestPortal/shared/approval/ui/ExecutorsMatrix.tsx +++ b/src/webparts/requestPortal/shared/approval/ui/ExecutorsMatrix.tsx @@ -12,16 +12,18 @@ interface IExecutorsMatrixProps { /** * Таблица исполнителей — рисуется только когда в маршруте из админки они есть: - * у форм без фазы исполнения блока нет вовсе. + * у форм без фазы исполнения блока нет вовсе (кроме редактора маршрута, см. canEditExecutors). */ export const ExecutorsMatrix: React.FC = ({ editor, search, title = 'Исполнение' }) => { - if (editor.loading || editor.rows.length === 0) return null; + // Редактор маршрута видит блок и пустым — иначе исполнителя некуда добавить. + if (editor.loading || (editor.rows.length === 0 && !editor.editable)) return null; return ( ); diff --git a/src/webparts/requestPortal/shared/approval/useRouteExecutors.ts b/src/webparts/requestPortal/shared/approval/useRouteExecutors.ts index 0cd6f38..6e87107 100644 --- a/src/webparts/requestPortal/shared/approval/useRouteExecutors.ts +++ b/src/webparts/requestPortal/shared/approval/useRouteExecutors.ts @@ -6,6 +6,7 @@ import { resolveApproverOrgInfo } from './approverOrgInfo'; import { IResolveFieldValue } from './models/IApprovalRouteApi'; import { IMatrixRow } from './models/IMatrixRow'; import { APPROVAL_PHASE } from './models/statuses'; +import { canEditExecutors } from './routeEditRights'; import { IUseMatrixEditor, useMatrixEditor } from './useMatrixEditor'; /** Строки фазы согласования (всё, что не «Исполнение») — для таблицы согласующих. */ @@ -46,6 +47,8 @@ interface IFormExecutorsSource { departmentId?: string | null; // eslint-disable-line @rushstack/no-new-null -- транзитом в resolveRoute fieldValues?: IResolveFieldValue[]; projectId?: string | null; // eslint-disable-line @rushstack/no-new-null -- транзитом в resolveRoute + /** Заявка не редактируется — правка состава исполнителей закрыта для всех. */ + readOnly?: boolean; /** Дополнительный ключ перезагрузки (значение поля, влияющее на маршрут). */ reloadSuffix?: string; } @@ -53,10 +56,11 @@ interface IFormExecutorsSource { /** * Исполнители формы заполнения (фаза «Исполнение»): планируемые по маршруту из * админки для новой заявки, из записанных строк — для черновика/доработки. - * Только чтение: исполнителя назначает маршрут, не заявитель. + * Заявителю — только чтение: исполнителя назначает маршрут. Редактор маршрута + * (см. canEditExecutors) может добавлять и удалять исполнителей. */ export function useFormExecutors(source: IFormExecutorsSource): IUseMatrixEditor { - const { context, approval, requestTypeId, userEmail, editId, departmentId, fieldValues, projectId, reloadSuffix = '' } = source; + const { context, approval, requestTypeId, userEmail, editId, departmentId, fieldValues, projectId, reloadSuffix = '', readOnly = false } = source; const fieldValuesKey = JSON.stringify(fieldValues ?? []); const load = useCallback( () => @@ -70,7 +74,7 @@ export function useFormExecutors(source: IFormExecutorsSource): IUseMatrixEditor return useMatrixEditor( load, `x-${userEmail}-${editId ?? 'new'}-${departmentId ?? ''}-${projectId ?? ''}-${fieldValuesKey}-${reloadSuffix}`, - false, + canEditExecutors(context, readOnly), !!editId || (!!userEmail && !!requestTypeId), (login) => approval.getJobTitle(login), resolveApproverOrgInfo(context) -- 2.34.1 From 2009535f1a67e4eb77ab45bef41bf7ebdf004f5e Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 13:50:57 +0500 Subject: [PATCH 05/24] =?UTF-8?q?fix:=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20?= =?UTF-8?q?=D1=81=D0=BE=D1=82=D1=80=D1=83=D0=B4=D0=BD=D0=B8=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BD=D0=B0=D1=85=D0=BE=D0=B4=D0=B8=D0=BB,=20?= =?UTF-8?q?=D0=B5=D1=81=D0=BB=D0=B8=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20?= =?UTF-8?q?=D0=B8=D0=BC=D0=B5=D0=BD=D0=B8=20=D0=B8=D0=BB=D0=B8=20=D1=84?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8=D0=BB=D0=B8=D0=B8=20=D1=81=D1=82=D0=BE=D0=B8?= =?UTF-8?q?=D1=82=20=D0=BF=D1=80=D0=BE=D0=B1=D0=B5=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PeoplePicker шлёт введённый текст в SharePoint как есть. Теперь в запросе поиска пробелы по краям обрезаются, а двойные внутри схлопываются. Co-Authored-By: Claude Sonnet 5.5 --- .../ui/PeopleField/pickerContext.test.ts | 27 +++++++++++++ .../shared/ui/PeopleField/pickerContext.ts | 39 ++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 src/webparts/requestPortal/shared/ui/PeopleField/pickerContext.test.ts diff --git a/src/webparts/requestPortal/shared/ui/PeopleField/pickerContext.test.ts b/src/webparts/requestPortal/shared/ui/PeopleField/pickerContext.test.ts new file mode 100644 index 0000000..9d7be02 --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/PeopleField/pickerContext.test.ts @@ -0,0 +1,27 @@ +import { trimPickerQuery } from './pickerContext'; + +const body = (q: unknown): string => JSON.stringify({ queryParams: { QueryString: q, PrincipalType: 1 } }); +const query = (b: unknown): unknown => (JSON.parse(b as string) as { queryParams: { QueryString: unknown } }).queryParams.QueryString; + +describe('trimPickerQuery', () => { + it('убирает пробел в конце и в начале', () => { + expect(query(trimPickerQuery(body('Иван ')))).toBe('Иван'); + expect(query(trimPickerQuery(body(' Иван Петров ')))).toBe('Иван Петров'); + }); + + it('схлопывает двойные пробелы внутри', () => { + expect(query(trimPickerQuery(body('Иван Петров')))).toBe('Иван Петров'); + }); + + it('не трогает остальные поля запроса', () => { + const parsed = JSON.parse(trimPickerQuery(body('a ')) as string) as { queryParams: { PrincipalType: number } }; + expect(parsed.queryParams.PrincipalType).toBe(1); + }); + + it('чужие тела возвращает как есть', () => { + expect(trimPickerQuery('не json QueryString')).toBe('не json QueryString'); + expect(trimPickerQuery(undefined)).toBeUndefined(); + const other = JSON.stringify({ a: 1 }); + expect(trimPickerQuery(other)).toBe(other); + }); +}); diff --git a/src/webparts/requestPortal/shared/ui/PeopleField/pickerContext.ts b/src/webparts/requestPortal/shared/ui/PeopleField/pickerContext.ts index 021a13c..31aceed 100644 --- a/src/webparts/requestPortal/shared/ui/PeopleField/pickerContext.ts +++ b/src/webparts/requestPortal/shared/ui/PeopleField/pickerContext.ts @@ -18,6 +18,43 @@ export function pickedLogin(user: IPickedUser): string { return user.secondaryText || user.loginName || user.id || ''; } +/** + * Убирает пробелы по краям (и схлопывает внутренние) у QueryString в теле запроса + * поиска сотрудника. PeoplePicker из @pnp/spfx-controls-react шлёт введённый + * текст как есть, а SharePoint с «Иван » (пробел в конце, обычное дело при вводе + * «имя фамилия») ничего не находит. Тело не JSON или без QueryString — возвращается как было. + */ +export function trimPickerQuery(body: unknown): unknown { + if (typeof body !== 'string' || body.indexOf('QueryString') < 0) return body; + try { + const parsed = JSON.parse(body) as { queryParams?: { QueryString?: unknown } }; + const query = parsed.queryParams?.QueryString; + if (typeof query !== 'string') return body; + parsed.queryParams!.QueryString = query.trim().replace(/\s+/g, ' '); + return JSON.stringify(parsed); + } catch { + return body; + } +} + +/** + * spHttpClient с подчисткой запроса поиска (см. trimPickerQuery): прокси, чтобы + * остальные методы клиента работали как прежде, подменяется только post. + */ +function withTrimmedSearch(client: WebPartContext['spHttpClient']): WebPartContext['spHttpClient'] { + return new Proxy(client, { + get(target, prop, receiver) { + if (prop !== 'post') return Reflect.get(target, prop, receiver); + return (url: string, config: unknown, options?: { body?: unknown }) => + (target.post as (u: string, c: unknown, o?: unknown) => unknown)( + url, + config, + options ? { ...options, body: trimPickerQuery(options.body) } : options + ); + } + }); +} + /** * Контекст для PeoplePicker из WebPartContext. * as unknown: @pnp/spfx-controls-react тянет свою копию sp-http-msgraph, @@ -27,6 +64,6 @@ export function buildPickerContext(context: WebPartContext): IPeoplePickerContex return { absoluteUrl: context.pageContext.web.absoluteUrl, msGraphClientFactory: context.msGraphClientFactory, - spHttpClient: context.spHttpClient + spHttpClient: withTrimmedSearch(context.spHttpClient) } as unknown as IPeoplePickerContext; } -- 2.34.1 From 58e4d3c868ebd8bc8e04e943e0981fbafe013d8b Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 13:56:59 +0500 Subject: [PATCH 06/24] =?UTF-8?q?feat:=20=D0=BE=D1=82=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D0=BA=20=E2=80=94=20=D0=B4=D0=BE=D0=BB=D0=B6=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B8=20=D0=BF=D0=BE=D0=B4=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0,=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B0=20=D0=BE?= =?UTF-8?q?=D1=80=D0=B3=D0=B0=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B9=20?= =?UTF-8?q?=D1=81=20=D0=BF=D0=BE=D0=B4=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Убраны редактируемые поля «Должность» и «Подразделение». Должность и подразделение подтягиваются из Employee/ByEmail и показываются таблицей «Организация · Должность · Подразделение» — своё подразделение на каждую организацию. В скалярные колонки Position/Department/DepartmentId пишется основная (первая) запись — их читают документ и ЭДО. Должность больше не обязательное поле формы. Co-Authored-By: Claude Sonnet 5.5 --- .../forms/Vacation/VacationForm.tsx | 24 ++++++---- .../Vacation/sections/RequestSection.tsx | 22 ++------- .../dictionary/employeeOrganizations.test.ts | 28 ++++++++++++ .../requestPortal/shared/dictionary/index.ts | 2 + .../shared/dictionary/organizationLines.ts | 29 ++++++++++++ .../OrganizationField.module.scss | 31 +++++++++++++ .../OrganizationField/OrganizationTable.tsx | 45 +++++++++++++++++++ src/webparts/requestPortal/shared/ui/index.ts | 1 + 8 files changed, 156 insertions(+), 26 deletions(-) create mode 100644 src/webparts/requestPortal/shared/dictionary/employeeOrganizations.test.ts create mode 100644 src/webparts/requestPortal/shared/dictionary/organizationLines.ts create mode 100644 src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationTable.tsx diff --git a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx index 0911ea8..d987e62 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationForm.tsx @@ -34,7 +34,7 @@ import { } from '../../shared/approval'; import { effectiveUserEmail } from '../../shared/impersonation'; import { IRequestFormProps } from '../../models/types'; -import { getEmployeeOrganizations, formatEmployeeOrganizations } from '../../shared/dictionary'; +import { getEmployeeOrganizations, formatEmployeeOrganizationsWithDepartment, IEmployeeOrganization } from '../../shared/dictionary'; import { friendlyErrorMessage } from '../../shared/diagnostics'; import { isoDatePlusDays, formatIsoDate } from '../../shared/dateUtils'; import { @@ -109,6 +109,15 @@ const VacationForm: React.FC = ({ }; }, [editId, editListId, notifyCode, setSubmitError]); + // Должность и подразделение больше не вводятся руками: берутся из ПЕРВОЙ (основной) + // записи Employee/ByEmail и пишутся в скалярные колонки Position/Department (их + // читают документ и ЭДО); по каждой организации то же видно в таблице (Organization). + const primaryOrgFields = (records: IEmployeeOrganization[]): Pick => ({ + Position: records[0]?.position || '', + Department: records[0]?.section || '', + DepartmentId: records[0]?.departmentId || '' + }); + // Новая заявка — по умолчанию на себя: подставляем текущего пользователя, // его должность и организации (Employee/ByEmail — см. shared/dictionary/ // employeeOrganizations.service.ts; сотрудник может состоять сразу в @@ -125,8 +134,8 @@ const VacationForm: React.FC = ({ if (!alive) return; setData((prev) => ({ ...prev, - Position: prev.Position || records[0]?.position || prev.Position, - Organization: formatEmployeeOrganizations(records) + ...primaryOrgFields(records), + Organization: formatEmployeeOrganizationsWithDepartment(records) })); }) .catch(() => undefined); @@ -142,15 +151,15 @@ const VacationForm: React.FC = ({ const handleEmployeeChange = (login: string): void => { setData((prev) => ({ ...prev, Employee: login })); if (!login) { - setData((prev) => ({ ...prev, Organization: '' })); + setData((prev) => ({ ...prev, ...primaryOrgFields([]), Organization: '' })); return; } getEmployeeOrganizations(context, login) .then((records) => setData((prev) => ({ ...prev, - Position: records[0]?.position || prev.Position, - Organization: formatEmployeeOrganizations(records) + ...primaryOrgFields(records), + Organization: formatEmployeeOrganizationsWithDepartment(records) })) ) .catch(() => undefined); @@ -193,7 +202,6 @@ const VacationForm: React.FC = ({ const isStartDateValid = !data.StartDate || data.StartDate >= minStartDate; const isValid = !!( data.Employee && - data.Position.trim() && data.StartDate && isStartDateValid && Number.isFinite(daysNum) && @@ -203,7 +211,7 @@ const VacationForm: React.FC = ({ if (data.StartDate && !isStartDateValid) { return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`; } - if (!isValid) return 'Заполните обязательные поля: сотрудник, должность, дата начала, количество дней (> 0).'; + if (!isValid) return 'Заполните обязательные поля: сотрудник, дата начала, количество дней (> 0).'; if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; return undefined; diff --git a/src/webparts/requestPortal/forms/Vacation/sections/RequestSection.tsx b/src/webparts/requestPortal/forms/Vacation/sections/RequestSection.tsx index 8d7efbf..8c2652e 100644 --- a/src/webparts/requestPortal/forms/Vacation/sections/RequestSection.tsx +++ b/src/webparts/requestPortal/forms/Vacation/sections/RequestSection.tsx @@ -1,10 +1,10 @@ import * as React from 'react'; -import { FormGrid, FormField, TextInput, PeopleField, OrganizationField } from '../../../shared/ui'; -import { DepartmentPicker, ProjectPicker } from '../../../shared/dictionary'; +import { FormGrid, FormField, TextInput, PeopleField, OrganizationTable } from '../../../shared/ui'; +import { ProjectPicker } from '../../../shared/dictionary'; import { ISectionProps, EMPLOYEE_PLACEHOLDER, SUBSTITUTE_PLACEHOLDER, MIN_NOTICE_DAYS } from '../model'; import { isoDatePlusDays } from '../../../shared/dateUtils'; -/** Единственная секция формы «Заявка на отпуск»: сотрудник, должность, даты. */ +/** Единственная секция формы «Заявка на отпуск»: сотрудник (должность и подразделение подтягиваются из справочника), даты. */ export const RequestSection: React.FC = ({ data, set, context, onEmployeeChange, printOrgIndex }) => ( @@ -15,21 +15,7 @@ export const RequestSection: React.FC = ({ data, set, context, on placeholder={EMPLOYEE_PLACEHOLDER} /> - - set('Position', v)} /> - - - - { - set('Department', name); - set('DepartmentId', id); - }} - /> - + ): IEmployeeOrganization => ({ + id: '', fio: '', email: '', iin: '', company: 'ТОО А', companyId: '', section: '', departmentId: '', position: '', mobile: '', ...p +}); + +describe('организации с подразделением', () => { + it('на каждую организацию своё подразделение', () => { + const text = formatEmployeeOrganizationsWithDepartment([ + rec({ company: 'ТОО А', position: 'Директор', section: 'Дирекция' }), + rec({ company: 'ТОО Б', position: 'Казначей', section: 'Финансы' }) + ]); + expect(text.split('\n').map(parseOrganizationLine)).toEqual([ + { company: 'ТОО А', position: 'Директор', department: 'Дирекция' }, + { company: 'ТОО Б', position: 'Казначей', department: 'Финансы' } + ]); + }); + + it('без подразделения формат прежний «Компания — должность»', () => { + expect(formatEmployeeOrganizationsWithDepartment([rec({ position: 'Директор' })])).toBe('ТОО А — Директор'); + }); + + it('старые строки разбираются с пустым подразделением', () => { + expect(parseOrganizationLine('ТОО А — Директор')).toEqual({ company: 'ТОО А', position: 'Директор', department: '' }); + expect(parseOrganizationLine('ТОО А')).toEqual({ company: 'ТОО А', position: '', department: '' }); + }); +}); diff --git a/src/webparts/requestPortal/shared/dictionary/index.ts b/src/webparts/requestPortal/shared/dictionary/index.ts index bd742f5..727740e 100644 --- a/src/webparts/requestPortal/shared/dictionary/index.ts +++ b/src/webparts/requestPortal/shared/dictionary/index.ts @@ -28,3 +28,5 @@ export { getCompanyDirectorEmail, getManagerEmail } from './employeeLookup.service'; +export { formatEmployeeOrganizationsWithDepartment, parseOrganizationLine } from './organizationLines'; +export type { IOrganizationLine } from './organizationLines'; diff --git a/src/webparts/requestPortal/shared/dictionary/organizationLines.ts b/src/webparts/requestPortal/shared/dictionary/organizationLines.ts new file mode 100644 index 0000000..c3e9432 --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/organizationLines.ts @@ -0,0 +1,29 @@ +import { IEmployeeOrganization } from './models/IEmployeeOrganization'; + +/** + * Организации сотрудника с подразделением: «Компания — должность — подразделение» + * на строку. Нужен формам, что показывают организации таблицей (см. + * OrganizationTable). Первый сегмент по-прежнему компания — остальные разборщики + * (OrgLetterhead и др.) берут только его и не ломаются. Нет подразделения — + * формат прежний, как у formatEmployeeOrganizations. + */ +export function formatEmployeeOrganizationsWithDepartment(records: IEmployeeOrganization[]): string { + return records + .map((r) => { + if (r.section) return [r.company, r.position, r.section].join(' — '); + return r.position ? `${r.company} — ${r.position}` : r.company; + }) + .join('\n'); +} + +export interface IOrganizationLine { + company: string; + position: string; + department: string; +} + +/** Разбор строки «Компания — должность[ — подразделение]»; старые строки без подразделения дают пустое. */ +export function parseOrganizationLine(line: string): IOrganizationLine { + const [company = '', position = '', department = ''] = line.split(' — ').map((p) => p.trim()); + return { company, position, department }; +} diff --git a/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationField.module.scss b/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationField.module.scss index 1d7d945..95938a0 100644 --- a/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationField.module.scss +++ b/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationField.module.scss @@ -19,3 +19,34 @@ font-size: 13.5px; color: $gray-700; } + +// Таблица организаций: те же цвета, что у .line — «подставлено автоматически». +.table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + border: 1px solid $gray-200; + border-radius: $r-sm; + background: $gray-50; + font-family: $font-sans; + font-size: 13.5px; + color: $gray-700; + + th, + td { + padding: 9px 13px; + text-align: left; + vertical-align: top; + } + + th { + font-weight: 600; + font-size: 12px; + color: $gray-500; + border-bottom: 1px solid $gray-200; + } + + tr + tr td { + border-top: 1px solid $gray-200; + } +} diff --git a/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationTable.tsx b/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationTable.tsx new file mode 100644 index 0000000..8b6473e --- /dev/null +++ b/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationTable.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import styles from './OrganizationField.module.scss'; +import { FormField } from '@tnsu/ui-kit'; +import { parseOrganizationLine } from '../../dictionary/organizationLines'; + +interface IOrganizationTableProps { + /** `\n`-joined «Компания — должность — подразделение» на организацию (см. formatEmployeeOrganizationsWithDepartment). */ + value: string; + label?: string; + /** Индекс строки, остающейся в печати (см. OrganizationField.printIndex); остальные — `rp-no-print`. */ + printIndex?: number; +} + +/** + * Организации сотрудника таблицей «Организация · Должность · Подразделение» — + * read-only, подтягивается из Employee/ByEmail. На каждую организацию своё + * подразделение (сотрудник может состоять в нескольких). Нет данных — ничего не рендерим. + */ +export const OrganizationTable: React.FC = ({ value, label = 'Организация', printIndex }) => { + const rows = value ? value.split('\n').map(parseOrganizationLine) : []; + if (rows.length === 0) return null; + + return ( + + + + + + + + + + + {rows.map((r, i) => ( + + + + + + ))} + +
ОрганизацияДолжностьПодразделение
{r.company}{r.position || '—'}{r.department || '—'}
+
+ ); +}; diff --git a/src/webparts/requestPortal/shared/ui/index.ts b/src/webparts/requestPortal/shared/ui/index.ts index 35fa453..8069eb2 100644 --- a/src/webparts/requestPortal/shared/ui/index.ts +++ b/src/webparts/requestPortal/shared/ui/index.ts @@ -47,6 +47,7 @@ export { PeopleField } from './PeopleField/PeopleField'; export { PeopleFieldMulti } from './PeopleField/PeopleFieldMulti'; export { PeopleTextField } from './PeopleField/PeopleTextField'; export { OrganizationField } from './OrganizationField/OrganizationField'; +export { OrganizationTable } from './OrganizationField/OrganizationTable'; export { PrintSplitButton } from './PrintSplitButton/PrintSplitButton'; export { OrgLetterhead } from './OrgLetterhead/OrgLetterhead'; export { LinkField } from './LinkField/LinkField'; -- 2.34.1 From 9e6c56c2e79b53d13e5b63f3d92b35247d86dc3b Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 13:59:32 +0500 Subject: [PATCH 07/24] =?UTF-8?q?fix:=20=D1=83=20=D1=80=D0=B5=D0=B4=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=B8=D1=80=D1=83=D0=B5=D0=BC=D1=8B=D1=85=20=D0=B8?= =?UTF-8?q?=D1=81=D0=BF=D0=BE=D0=BB=D0=BD=D0=B8=D1=82=D0=B5=D0=BB=D0=B5?= =?UTF-8?q?=D0=B9=20=D0=BD=D0=B5=20=D0=B1=D1=8B=D0=BB=D0=BE=20=D0=BA=D0=BD?= =?UTF-8?q?=D0=BE=D0=BF=D0=BA=D0=B8=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Матрица рисует корзину только при заданном canRemoveRow — для исполнителей его не передали. Co-Authored-By: Claude Sonnet 5.5 --- .../requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx | 1 + .../forms/RecruitingSelection/RecruitingSelectionForm.tsx | 1 + src/webparts/requestPortal/forms/Referral/ReferralForm.tsx | 1 + .../requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx | 1 + src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx | 1 + .../requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx | 1 + .../requestPortal/shared/approval/ui/ExecutorsMatrix.tsx | 1 + 7 files changed, 7 insertions(+) diff --git a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx index c86d129..1d726ba 100644 --- a/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingHire/RecruitingHireForm.tsx @@ -467,6 +467,7 @@ const RecruitingHireForm: React.FC = ({ search={(q) => approval.searchUsers(q)} title="Исполнение (после согласования)" roleLabel="Исполнитель" + canRemoveRow={executors.editable ? () => true : undefined} pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не заданы по маршруту." /> diff --git a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx index b6342ad..971b07e 100644 --- a/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx +++ b/src/webparts/requestPortal/forms/RecruitingSelection/RecruitingSelectionForm.tsx @@ -457,6 +457,7 @@ const RecruitingSelectionForm: React.FC = ({ search={(q) => approval.searchUsers(q)} title="Исполнение (после согласования)" roleLabel="Исполнитель" + canRemoveRow={executors.editable ? () => true : undefined} pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не заданы по маршруту." /> diff --git a/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx b/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx index 5d41623..286a7cc 100644 --- a/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx +++ b/src/webparts/requestPortal/forms/Referral/ReferralForm.tsx @@ -476,6 +476,7 @@ const ReferralForm: React.FC = ({ search={(q) => approval.searchUsers(q)} title={noApprovalPhase ? 'Исполнение' : 'Исполнение (после согласования)'} roleLabel="Исполнитель" + canRemoveRow={executors.editable ? () => true : undefined} pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не заданы по маршруту." /> diff --git a/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx b/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx index a06538d..5522db8 100644 --- a/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx +++ b/src/webparts/requestPortal/forms/SkudExplanation/SkudExplanationForm.tsx @@ -284,6 +284,7 @@ const SkudExplanationForm: React.FC = ({ search={(q) => approval.searchUsers(q)} title="Исполнение (после согласования)" roleLabel="Исполнитель" + canRemoveRow={executors.editable ? () => true : undefined} pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не заданы по маршруту." /> diff --git a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx index bfb23d4..7ff5aec 100644 --- a/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx +++ b/src/webparts/requestPortal/forms/TmcTransfer/TmcTransferForm.tsx @@ -514,6 +514,7 @@ const TmcTransferForm: React.FC = ({ search={(q) => approval.searchUsers(q)} title="Исполнение (после согласования)" roleLabel="Исполнитель" + canRemoveRow={executors.editable ? () => true : undefined} pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не заданы по маршруту." /> diff --git a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx index 0947f02..6d008ad 100644 --- a/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx +++ b/src/webparts/requestPortal/forms/WorkplaceSetup/WorkplaceSetupForm.tsx @@ -405,6 +405,7 @@ const WorkplaceSetupForm: React.FC = ({ search={(q) => approval.searchUsers(q)} title="Исполнение (после согласования)" roleLabel="Исполнитель" + canRemoveRow={executors.editable ? () => true : undefined} pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не заданы по маршруту." /> diff --git a/src/webparts/requestPortal/shared/approval/ui/ExecutorsMatrix.tsx b/src/webparts/requestPortal/shared/approval/ui/ExecutorsMatrix.tsx index 369f457..c2e9430 100644 --- a/src/webparts/requestPortal/shared/approval/ui/ExecutorsMatrix.tsx +++ b/src/webparts/requestPortal/shared/approval/ui/ExecutorsMatrix.tsx @@ -23,6 +23,7 @@ export const ExecutorsMatrix: React.FC = ({ editor, searc search={search} title={title} roleLabel="Исполнитель" + canRemoveRow={editor.editable ? () => true : undefined} pickerPlaceholder="Добавить исполнителя: начните вводить ФИО…" emptyText="Исполнители не назначены." /> -- 2.34.1 From 8cdee8349671ec099eb1cf57bbc100c5d3bd52a2 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 14:00:23 +0500 Subject: [PATCH 08/24] =?UTF-8?q?fix:=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D1=86=D0=B0=20=D0=BE=D1=80=D0=B3=D0=B0=D0=BD=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B9=20=D0=B1=D0=B5=D0=B7=20=D0=B4=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=80=D1=83=D1=8E=D1=89=D0=B5=D0=B9=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D0=BF=D0=B8=D1=81=D0=B8=20=C2=AB=D0=9E=D1=80=D0=B3=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../OrganizationField/OrganizationField.module.scss | 5 +++++ .../shared/ui/OrganizationField/OrganizationTable.tsx | 11 +++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationField.module.scss b/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationField.module.scss index 95938a0..d072838 100644 --- a/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationField.module.scss +++ b/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationField.module.scss @@ -20,6 +20,11 @@ color: $gray-700; } +// Блок таблицы на всю ширину сетки формы (без FormField — его подпись дублировала бы заголовок колонки). +.tableWrap { + grid-column: 1 / -1; +} + // Таблица организаций: те же цвета, что у .line — «подставлено автоматически». .table { width: 100%; diff --git a/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationTable.tsx b/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationTable.tsx index 8b6473e..56cba86 100644 --- a/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationTable.tsx +++ b/src/webparts/requestPortal/shared/ui/OrganizationField/OrganizationTable.tsx @@ -1,12 +1,10 @@ import * as React from 'react'; import styles from './OrganizationField.module.scss'; -import { FormField } from '@tnsu/ui-kit'; import { parseOrganizationLine } from '../../dictionary/organizationLines'; interface IOrganizationTableProps { /** `\n`-joined «Компания — должность — подразделение» на организацию (см. formatEmployeeOrganizationsWithDepartment). */ value: string; - label?: string; /** Индекс строки, остающейся в печати (см. OrganizationField.printIndex); остальные — `rp-no-print`. */ printIndex?: number; } @@ -14,14 +12,15 @@ interface IOrganizationTableProps { /** * Организации сотрудника таблицей «Организация · Должность · Подразделение» — * read-only, подтягивается из Employee/ByEmail. На каждую организацию своё - * подразделение (сотрудник может состоять в нескольких). Нет данных — ничего не рендерим. + * подразделение (сотрудник может состоять в нескольких). Без внешней подписи: её роль + * играет заголовок колонки «Организация». Нет данных — ничего не рендерим. */ -export const OrganizationTable: React.FC = ({ value, label = 'Организация', printIndex }) => { +export const OrganizationTable: React.FC = ({ value, printIndex }) => { const rows = value ? value.split('\n').map(parseOrganizationLine) : []; if (rows.length === 0) return null; return ( - +
@@ -40,6 +39,6 @@ export const OrganizationTable: React.FC = ({ value, la ))}
- +
); }; -- 2.34.1 From 7f5f59fc81e9fa99ac336dfbec6c790dea855e1a Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 14:48:33 +0500 Subject: [PATCH 09/24] =?UTF-8?q?feat:=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B0=20=D0=BE=D1=82=D0=BF?= =?UTF-8?q?=D1=83=D1=81=D0=BA=20=E2=80=94=20=D0=BF=D0=BE=20=D1=84=D0=B0?= =?UTF-8?q?=D0=B9=D0=BB=D1=83=20=D0=BD=D0=B0=20=D0=BA=D0=B0=D0=B6=D0=B4?= =?UTF-8?q?=D1=83=D1=8E=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8=D1=8E?= =?UTF-8?q?=20=D1=81=D0=BE=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=B8=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=BE=D0=B4=D0=BD=D0=B8=D0=BC=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D0=BE=D0=BC=20=D0=B2=20=D0=AD=D0=94?= =?UTF-8?q?=D0=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сотрудник заполняет заявку один раз, а в ЭДО уходит по документу на каждую компанию, где он работает: своя шапка (компания, должность) и свой БИН в массиве file_company_bins. Подписант один — сам сотрудник, от имени физлица. Запрос один на все файлы: принимается целиком или отклоняется целиком, повтор безопасен. Компания без БИН в справочнике пропускается и показывается в сообщении. Удалять согласующих при составлении может редактор маршрута (во всех формах, не только в трёх). Co-Authored-By: Claude Sonnet 5.5 --- .../requestPortal/forms/Vacation/README.md | 13 ++- .../forms/Vacation/VacationView.tsx | 40 ++++--- .../forms/Vacation/sendToSign.ts | 76 ++++++++----- .../forms/Vacation/vacationDocument.test.ts | 6 +- .../forms/Vacation/vacationDocument.ts | 10 +- .../requestPortal/shared/edo/edo.service.ts | 23 ++-- .../requestPortal/shared/edo/edo.test.ts | 101 +++++++++++------- .../requestPortal/shared/edo/signingTarget.ts | 94 ++++++++++------ 8 files changed, 241 insertions(+), 122 deletions(-) diff --git a/src/webparts/requestPortal/forms/Vacation/README.md b/src/webparts/requestPortal/forms/Vacation/README.md index 7d4bd6c..e0f4c63 100644 --- a/src/webparts/requestPortal/forms/Vacation/README.md +++ b/src/webparts/requestPortal/forms/Vacation/README.md @@ -35,11 +35,18 @@ получатель, и как `initiator_email`: ему уходит приглашение, и он же получает письмо, когда документ подписан. - **Документ** — заявление, собранное в браузере как DOCX (`vacationDocument.ts`, JSZip); ЭДО сам переводит его в PDF. Текст — рабочий вариант, финальную формулировку утверждает кадровая служба. -- **ИИН** берётся из `GET /Employee/ByEmail` (поле `Iin`), **БИН компании** — из `GET /Dictionary/Companies` - по `CompanyId` сотрудника (`shared/edo/signingTarget.ts`). Если сотрудник состоит в нескольких - организациях, берётся первая, у которой в справочнике есть БИН. +- **Совместители.** Заявку сотрудник заполняет одну, а подписывает заявление по каждой компании, где + работает: в ЭДО уходит по файлу на компанию (своя шапка — компания и должность в ней), БИН файлов — в + массиве `file_company_bins`, общий `company_bin` не передаётся. Подписант один — сам сотрудник, от + имени физического лица; БИН нужны ЭДО только чтобы выбрать сертификат Sigex (mTLS) нужной компании. + Запрос один на все файлы: ЭДО принимает его целиком или отклоняет целиком (например, если у какой-то + компании нет сертификата), поэтому повтор безопасен. +- **ИИН** берётся из `GET /Employee/ByEmail` (поле `Iin`), **БИН компаний** — из `GET /Dictionary/Companies` + по `CompanyId` каждой записи сотрудника (`shared/edo/signingTarget.ts`). Компания без БИН в справочнике + пропускается и показывается в сообщении; остальные уходят. - **С каким ЭДО работать** — свойство веб-части «С каким ЭДО работать» (`edoApiBaseUrl`): стейдж или локальный бэкенд. По умолчанию стейдж. Токен берётся для того же AAD-приложения, что у approval-route. +- **Отпуск без содержания в ЭДО не подписывается** (`UnpaidLeave`): подписание подключено только к этой форме. - **Неудача отправки** показывается сообщением над панелью решения и согласование не отменяет; рядом кнопка «Повторить отправку на подпись». Признака «уже отправлено» в заявке нет, поэтому после перезагрузки страницы повтор недоступен. diff --git a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx index 31b875e..129c33c 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx @@ -27,7 +27,7 @@ import { import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { loadVacationRequest } from './load'; import { vacationApprovalService } from './submit'; -import { sendVacationForSigning } from './sendToSign'; +import { IVacationSigningResult, sendVacationForSigning } from './sendToSign'; import { RequestSection } from './sections/RequestSection'; import { useStableCallback } from '../../shared/useStableCallback'; @@ -64,8 +64,9 @@ const VacationView: React.FC = ({ context, itemId, formKey, // Какая из организаций сотрудника (см. OrganizationField) идёт в печать — // выбор в тулбаре через PrintSplitButton, по умолчанию первая. const [printOrgIndex, setPrintOrgIndex] = useState(0); - // Итог отправки согласованного заявления на подпись в ЭДО (см. sendToSigning). - const [signingInfo, setSigningInfo] = useState(''); + // Итог отправки согласованного заявления на подпись в ЭДО (см. sendToSigning): по каким + // компаниям ушло, по каким нельзя, либо ошибка всего запроса. + const [signingResult, setSigningResult] = useState(undefined); const [signingError, setSigningError] = useState(''); const [signingBusy, setSigningBusy] = useState(false); @@ -85,6 +86,7 @@ const VacationView: React.FC = ({ context, itemId, formKey, const approvers = useMatrixEditor(loadApprovers, `a-${itemId}-${decisionSeq}`, false, true, (l) => approval.getJobTitle(l) ); + // Исполнители (фаза «Исполнение») — отдельная таблица, если они есть в маршруте. const executors = useViewExecutors(context, approval, itemId, decisionSeq); useEffect(() => { @@ -138,20 +140,20 @@ const VacationView: React.FC = ({ context, itemId, formKey, }).catch(() => undefined); }; - // Все согласовали → отправляем заявление на подпись сотруднику, на которого оформлен отпуск. - // Решение к этому моменту уже применено, поэтому неудача отправки показывается отдельным - // сообщением и согласование не отменяет. Повторить можно кнопкой, пока страница не закрыта: - // признака «уже отправлено» в заявке нет, и после перезагрузки повтор был бы дублем. + // Все согласовали → отправляем заявление на подпись сотруднику, на которого оформлен отпуск, — + // по документу на каждую его компанию, одним запросом. Решение к этому моменту уже применено, + // поэтому неудача показывается отдельным сообщением и согласование не отменяет. Повторить можно + // кнопкой: запрос принимается целиком или отклоняется целиком, а повтор уже принятого ЭДО + // отклонит как дубль — признака «уже отправлено» в заявке нет. const sendToSigning = (): Promise => { setSigningError(''); - setSigningInfo('Отправляем заявление на подпись в ЭДО…'); + setSigningResult(undefined); setSigningBusy(true); return sendVacationForSigning(context, { data, employeeName, itemId, code }) - .then(() => { - setSigningInfo(`Заявление отправлено на подпись: письмо со ссылкой ушло на ${data.Employee}.`); + .then((result) => { + setSigningResult(result); }) .catch((e) => { - setSigningInfo(''); setSigningError( `Заявка согласована, но отправить заявление на подпись не удалось: ${friendlyErrorMessage(e)}` ); @@ -195,11 +197,21 @@ const VacationView: React.FC = ({ context, itemId, formKey,
- {signingInfo && {signingInfo}} - {signingError && ( + {signingBusy && Отправляем заявление на подпись в ЭДО…} + {!signingBusy && signingResult && ( + + {`Заявление отправлено на подпись (${signingResult.sent.join(', ')}): письма со ссылкой ушли на ${data.Employee}.`} + + )} + {!signingBusy && signingResult && signingResult.skipped.length > 0 && ( + + {`Не отправлено по компаниям: ${signingResult.skipped.map((c) => `${c.name} — ${c.reason}`).join('; ')}.`} + + )} + {!signingBusy && signingError && ( <> {signingError} - diff --git a/src/webparts/requestPortal/forms/Vacation/sendToSign.ts b/src/webparts/requestPortal/forms/Vacation/sendToSign.ts index f3b9ea2..42b8d48 100644 --- a/src/webparts/requestPortal/forms/Vacation/sendToSign.ts +++ b/src/webparts/requestPortal/forms/Vacation/sendToSign.ts @@ -1,7 +1,7 @@ import { WebPartContext } from '@microsoft/sp-webpart-base'; import { getCompanies, getEmployeeOrganizations } from '../../shared/dictionary'; -import { IEdoSigningResult, submitSigningRequest } from '../../shared/edo/edo.service'; -import { resolveSigningTarget } from '../../shared/edo/signingTarget'; +import { submitSigningRequest } from '../../shared/edo/edo.service'; +import { ISigningCompany, ISkippedCompany, resolveSigningTargets } from '../../shared/edo/signingTarget'; import { computeEndDate, formatRuDate } from './dates'; import { IFormData } from './model'; import { createVacationDocx, vacationFileName } from './vacationDocument'; @@ -17,6 +17,14 @@ export interface IVacationToSign { itemId: number; } +/** Итог отправки: по каким компаниям документ ушёл и по каким нельзя. */ +export interface IVacationSigningResult { + /** Названия компаний, по которым заявление отправлено на подпись. */ + sent: string[]; + /** Компании сотрудника, по которым документ не собрать (нет БИН в справочнике), и почему. */ + skipped: ISkippedCompany[]; +} + /** Сегодняшняя дата `дд.мм.гггг` по местному времени — дата составления заявления. */ function todayRu(): string { const now = new Date(); @@ -37,46 +45,66 @@ async function substituteName(context: WebPartContext, email: string): Promise { +): Promise { const { data } = vacation; if (!data.Employee) { throw new Error('в заявке не указан сотрудник'); } const [records, companies] = await Promise.all([getEmployeeOrganizations(context, data.Employee), getCompanies(context)]); - const target = resolveSigningTarget(records, companies); + const targets = resolveSigningTargets(records, companies); + + if (targets.companies.length === 0) { + const reasons = targets.skipped.map((c) => `${c.name} — ${c.reason}`).join('; '); + throw new Error(`ни по одной компании сотрудника заявление не отправить: ${reasons}`); + } const days = Number(data.DaysCount); const documentDate = todayRu(); const employeeName = - [target.name.lastName, target.name.firstName, target.name.middleName].filter((p) => p).join(' ') || + [targets.name.lastName, targets.name.firstName, targets.name.middleName].filter((p) => p).join(' ') || vacation.employeeName; + const substitute = await substituteName(context, data.Substitute); - const file = await createVacationDocx({ - companyName: target.companyName, - position: data.Position, - employeeName, - startDate: formatRuDate(data.StartDate), - endDate: formatRuDate(computeEndDate(data.StartDate, days)), - daysCount: days, - substituteName: await substituteName(context, data.Substitute), - requestCode: vacation.code || String(vacation.itemId), - documentDate - }); + const files = await Promise.all( + targets.companies.map(async (company: ISigningCompany) => ({ + name: vacationFileName(employeeName, company.name, documentDate), + companyBin: company.bin, + content: await createVacationDocx({ + companyName: company.name, + // В каждой компании своя должность; нет её в справочнике — та, что в заявке. + position: company.position || data.Position, + employeeName, + startDate: formatRuDate(data.StartDate), + endDate: formatRuDate(computeEndDate(data.StartDate, days)), + daysCount: days, + substituteName: substitute, + requestCode: vacation.code || String(vacation.itemId), + documentDate + }) + })) + ); - return submitSigningRequest(context, { - recipient: { iin: target.iin, email: data.Employee, name: target.name }, - companyBin: target.companyBin, + await submitSigningRequest(context, { + recipient: { iin: targets.iin, email: data.Employee, name: targets.name }, initiatorEmail: data.Employee, - file: { name: vacationFileName(employeeName, documentDate), content: file } + files }); + + return { sent: targets.companies.map((c) => c.name), skipped: targets.skipped }; } diff --git a/src/webparts/requestPortal/forms/Vacation/vacationDocument.test.ts b/src/webparts/requestPortal/forms/Vacation/vacationDocument.test.ts index 7bd35c0..6b86ed3 100644 --- a/src/webparts/requestPortal/forms/Vacation/vacationDocument.test.ts +++ b/src/webparts/requestPortal/forms/Vacation/vacationDocument.test.ts @@ -81,8 +81,10 @@ describe('заявление на отпуск', () => { }); it('имя файла без недопустимых символов', () => { - expect(vacationFileName('Иванов И.И.', '30.09.2026')).toBe('Заявление на отпуск — Иванов И.И. — 30.09.2026.docx'); - expect(vacationFileName('A/B:C', '01.01.2026')).not.toMatch(/[\\/:*?"<>|]/); + expect(vacationFileName('Иванов И.И.', 'ТОО TANSU Construction', '30.09.2026')).toBe( + 'Заявление на отпуск — Иванов И.И. — ТОО TANSU Construction — 30.09.2026.docx' + ); + expect(vacationFileName('A/B:C', 'ТОО "X"', '01.01.2026')).not.toMatch(/[\\/:*?"<>|]/); }); it('DOCX — это zip с обязательными частями пакета', async () => { diff --git a/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts b/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts index ac64c5c..dca7833 100644 --- a/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts +++ b/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts @@ -141,9 +141,9 @@ export async function createVacationDocx(input: IVacationDocumentInput): Promise return zip.generateAsync({ type: 'blob', mimeType: DOCX_CONTENT_TYPE, compression: 'DEFLATE' }); } -/** Имя файла для ЭДО: по нему человек узнает документ в письме и кабинете. */ -export function vacationFileName(employeeName: string, documentDate: string): string { - // Недопустимые в имени файла символы заменяем, а не удаляем: ФИО остаётся читаемым. - const safeName = employeeName.replace(/[\\/:*?"<>|]/g, ' ').replace(/\s+/g, ' ').trim(); - return `Заявление на отпуск — ${safeName} — ${documentDate}.docx`; +/** Имя файла для ЭДО: по нему человек узнает документ в письме и кабинете — и отличит компании. */ +export function vacationFileName(employeeName: string, companyName: string, documentDate: string): string { + // Недопустимые в имени файла символы заменяем, а не удаляем: ФИО и компания остаются читаемыми. + const safe = (text: string): string => text.replace(/[\\/:*?"<>|]/g, ' ').replace(/\s+/g, ' ').trim(); + return `Заявление на отпуск — ${safe(employeeName)} — ${safe(companyName)} — ${documentDate}.docx`; } diff --git a/src/webparts/requestPortal/shared/edo/edo.service.ts b/src/webparts/requestPortal/shared/edo/edo.service.ts index 3020dde..2fc4d0f 100644 --- a/src/webparts/requestPortal/shared/edo/edo.service.ts +++ b/src/webparts/requestPortal/shared/edo/edo.service.ts @@ -7,15 +7,21 @@ import { IFioParts } from './fio'; /** Путь приёма документов на подпись от интеграций (TANSU ЭДО, multipart/form-data). */ const SIGNING_REQUESTS_PATH = '/api/v1/integration/signing-requests'; -/** Что отправляем в ЭДО: одного получателя, он же подписант, и один файл. */ +/** Файл на подпись и компания, от имени которой он идёт (по её сертификату Sigex в ЭДО). */ +export interface IEdoSigningFile { + name: string; + content: Blob; + /** БИН компании файла. У сотрудника-совместителя файлы одного запроса идут от разных компаний. */ + companyBin: string; +} + +/** Что отправляем в ЭДО: одного получателя, он же подписант (физлицо), и файлы. */ export interface IEdoSigningRequest { /** Подписывающий: ЭДО заводит его по ИИН, если нет, и зовёт подписать письмом. */ recipient: { iin: string; email: string; name: IFioParts }; - /** БИН компании, от имени которой документ идёт на подпись (нужен сертификат Sigex этой компании в ЭДО). */ - companyBin: string; /** Кому сообщать о подписи. */ initiatorEmail: string; - file: { name: string; content: Blob }; + files: IEdoSigningFile[]; } /** Ответ ЭДО: то, что нужно показать пользователю. */ @@ -32,7 +38,7 @@ interface IEdoResponseBody { } /** - * Отправляет документ на подпись в ЭДО. + * Отправляет документы на подпись в ЭДО одним запросом: либо принимаются все, либо ни один. * * Токен берётся для приложения ЭДО текущего пользователя (AadHttpClient), поэтому в ЭДО * запрос приходит от того, кто нажал «Согласовать», — он заводится там автоматически при @@ -52,9 +58,12 @@ export async function submitSigningRequest( form.append('last_name', request.recipient.name.lastName); form.append('first_name', request.recipient.name.firstName); if (request.recipient.name.middleName) form.append('middle_name', request.recipient.name.middleName); - form.append('company_bin', request.companyBin); form.append('initiator_email', request.initiatorEmail); - form.append('files', request.file.content, request.file.name); + // БИН файлов — массивом, по порядку файлов: общий company_bin не нужен, подписант один (физлицо). + for (const file of request.files) { + form.append('files', file.content, file.name); + form.append('file_company_bins', file.companyBin); + } let response: HttpClientResponse; try { diff --git a/src/webparts/requestPortal/shared/edo/edo.test.ts b/src/webparts/requestPortal/shared/edo/edo.test.ts index 09c6d3e..4174a45 100644 --- a/src/webparts/requestPortal/shared/edo/edo.test.ts +++ b/src/webparts/requestPortal/shared/edo/edo.test.ts @@ -2,7 +2,7 @@ import { ICompany } from '../dictionary/models/ICompany'; import { IEmployeeOrganization } from '../dictionary/models/IEmployeeOrganization'; import { describeEdoError } from './edoErrors'; import { splitFio } from './fio'; -import { resolveSigningTarget } from './signingTarget'; +import { resolveSigningTargets } from './signingTarget'; const TANSU_ID = '9de064a4-44c8-88ef-11ec-8284930fad96'; @@ -50,53 +50,80 @@ describe('Ф.И.О. по частям для ЭДО', () => { }); }); -describe('кому и от какой компании отправлять на подпись', () => { - it('берёт ИИН из справочника сотрудников и БИН из справочника компаний', () => { - const target = resolveSigningTarget([record()], COMPANIES); +const STROY_ID = '0a1b2c3d-0000-4000-8000-000000000003'; +const NO_BIN_ID = '0a1b2c3d-0000-4000-8000-000000000002'; - expect(target.iin).toBe('950515123452'); - expect(target.companyBin).toBe('021040005165'); - expect(target.companyName).toBe('ТОО TANSU Construction'); - expect(target.name.lastName).toBe('Иванов'); +describe('кому и по каким компаниям отправлять на подпись', () => { + const companies: ICompany[] = [...COMPANIES, { Id: STROY_ID, Name: 'ТОО TANSU Строй', Bin: '021040005166' }]; + + it('ИИН из справочника сотрудников, БИН из справочника компаний', () => { + const targets = resolveSigningTargets([record()], companies); + + expect(targets.iin).toBe('950515123452'); + expect(targets.name.lastName).toBe('Иванов'); + expect(targets.companies).toEqual([{ bin: '021040005165', name: 'ТОО TANSU Construction', position: 'Инженер' }]); + expect(targets.skipped).toEqual([]); + }); + + // Заявку заполняют одну, а подписывают по каждой компании, где человек работает. + it('по документу на каждую компанию, со своей должностью', () => { + const targets = resolveSigningTargets( + [record(), record({ company: 'ТОО TANSU Строй', companyId: STROY_ID, position: 'Главный инженер' })], + companies + ); + + expect(targets.companies).toEqual([ + { bin: '021040005165', name: 'ТОО TANSU Construction', position: 'Инженер' }, + { bin: '021040005166', name: 'ТОО TANSU Строй', position: 'Главный инженер' } + ]); + }); + + it('одна и та же компания дважды не попадает', () => { + const targets = resolveSigningTargets([record(), record({ position: 'Совместитель' })], companies); + + expect(targets.companies).toHaveLength(1); }); it('идентификаторы компаний сравниваются без учёта регистра', () => { - const target = resolveSigningTarget([record({ companyId: TANSU_ID.toUpperCase() })], COMPANIES); - - expect(target.companyBin).toBe('021040005165'); - }); - - it('при нескольких организациях берёт первую, где у компании есть БИН', () => { - const records = [ - record({ company: 'ТОО Без БИН', companyId: '0a1b2c3d-0000-4000-8000-000000000002' }), - record() - ]; - - expect(resolveSigningTarget(records, COMPANIES).companyBin).toBe('021040005165'); - }); - - it('нет записи о сотруднике — понятная причина', () => { - expect(() => resolveSigningTarget([], COMPANIES)).toThrow('нет в кадровом справочнике'); - }); - - it('у компании нет БИН — называет компанию', () => { - const records = [record({ company: 'ТОО Без БИН', companyId: '0a1b2c3d-0000-4000-8000-000000000002' })]; - - expect(() => resolveSigningTarget(records, COMPANIES)).toThrow('нет БИН для «ТОО Без БИН»'); - }); - - it('компании нет в справочнике', () => { - expect(() => resolveSigningTarget([record({ companyId: 'ffffffff-0000-4000-8000-000000000000' })], COMPANIES)).toThrow( - 'нет БИН' + expect(resolveSigningTargets([record({ companyId: TANSU_ID.toUpperCase() })], companies).companies[0].bin).toBe( + '021040005165' ); }); + it('компания без БИН не мешает остальным — уходит в пропущенные с причиной', () => { + const targets = resolveSigningTargets( + [record({ company: 'ТОО Без БИН', companyId: NO_BIN_ID }), record()], + companies + ); + + expect(targets.companies.map((c) => c.bin)).toEqual(['021040005165']); + expect(targets.skipped).toEqual([{ name: 'ТОО Без БИН', reason: 'в справочнике компаний нет БИН' }]); + }); + + it('компании нет в справочнике — тоже пропущена', () => { + const targets = resolveSigningTargets( + [record({ company: 'ТОО Неизвестная', companyId: 'ffffffff-0000-4000-8000-000000000000' })], + companies + ); + + expect(targets.companies).toEqual([]); + expect(targets.skipped[0].name).toBe('ТОО Неизвестная'); + }); + + it('нет записи о сотруднике — понятная причина', () => { + expect(() => resolveSigningTargets([], companies)).toThrow('нет в кадровом справочнике'); + }); + it('нет ИИН в карточке', () => { - expect(() => resolveSigningTarget([record({ iin: ' ' })], COMPANIES)).toThrow('не указан ИИН'); + expect(() => resolveSigningTargets([record({ iin: ' ' })], companies)).toThrow('не указан ИИН'); }); it('ФИО из одного слова не годится', () => { - expect(() => resolveSigningTarget([record({ fio: 'Иванов' })], COMPANIES)).toThrow('Ф.И.О.'); + expect(() => resolveSigningTargets([record({ fio: 'Иванов' })], companies)).toThrow('Ф.И.О.'); + }); + + it('ни в одной записи нет компании', () => { + expect(() => resolveSigningTargets([record({ companyId: '' })], companies)).toThrow('не указана компания'); }); }); diff --git a/src/webparts/requestPortal/shared/edo/signingTarget.ts b/src/webparts/requestPortal/shared/edo/signingTarget.ts index a846b52..9853295 100644 --- a/src/webparts/requestPortal/shared/edo/signingTarget.ts +++ b/src/webparts/requestPortal/shared/edo/signingTarget.ts @@ -2,52 +2,86 @@ import { ICompany } from '../dictionary/models/ICompany'; import { IEmployeeOrganization } from '../dictionary/models/IEmployeeOrganization'; import { IFioParts, splitFio } from './fio'; -/** Кому и от имени какой компании отправляется документ на подпись. */ -export interface ISigningTarget { +/** Одна компания, от имени которой подписывается документ. */ +export interface ISigningCompany { + /** БИН компании: от её имени документ идёт в Sigex. */ + bin: string; + name: string; + /** Должность сотрудника именно в этой компании. */ + position: string; +} + +/** Компания сотрудника, по которой документ отправить нельзя, и почему. */ +export interface ISkippedCompany { + name: string; + reason: string; +} + +/** Кто подписывает и по каким компаниям. */ +export interface ISigningTargets { iin: string; name: IFioParts; - /** БИН компании, от имени которой документ идёт в Sigex. */ - companyBin: string; - companyName: string; + /** По документу на каждую компанию, где у сотрудника есть БИН в справочнике. */ + companies: ISigningCompany[]; + /** Компании, по которым документ не отправить (нет БИН в справочнике и т. п.). */ + skipped: ISkippedCompany[]; } /** - * Определяет подписанта и компанию по данным справочников api.tnsu.kz: - * ИИН и ФИО — из Employee/ByEmail, БИН — из Dictionary/Companies по CompanyId сотрудника. + * Определяет подписанта и компании по справочникам api.tnsu.kz: ИИН и ФИО — из Employee/ByEmail, + * БИН — из Dictionary/Companies по CompanyId. * - * Сотрудник может состоять в нескольких организациях (на один e-mail — несколько записей). - * Берём первую запись, у которой компания известна справочнику и в нём есть БИН: заявление - * от имени компании без БИН в ЭДО не принять. Каждая невозможность — отдельное сообщение, - * чтобы по тексту было ясно, что поправить: карточку сотрудника или справочник компаний. + * Сотрудник может работать в нескольких компаниях (на один e-mail — несколько записей), и заявление + * подписывается по каждой из них отдельно: у каждой компании свой документ со своей должностью. + * Одна и та же компания дважды не попадает. Компания без БИН не роняет остальные — она уходит в + * skipped с причиной. Ошибка бросается, только если подписать нельзя вовсе: нет человека, ИИН, ФИО + * или ни одной компании. */ -export function resolveSigningTarget(records: IEmployeeOrganization[], companies: ICompany[]): ISigningTarget { +export function resolveSigningTargets(records: IEmployeeOrganization[], companies: ICompany[]): ISigningTargets { if (records.length === 0) { throw new Error('сотрудника нет в кадровом справочнике — ИИН для подписания взять неоткуда'); } - const withCompany = records.filter((r) => r.companyId); - if (withCompany.length === 0) { - throw new Error('в карточке сотрудника не указана компания'); - } - - const byId = new Map(companies.map((c) => [c.Id.toLowerCase(), c])); - const match = withCompany - .map((record) => ({ record, company: byId.get(record.companyId.toLowerCase()) })) - .find((m) => m.company && m.company.Bin && m.company.Bin.trim().length > 0); - - if (!match || !match.company) { - throw new Error(`в справочнике компаний нет БИН для «${withCompany[0].company}»`); - } - - const iin = (match.record.iin || '').trim(); + // Человек один на все записи: ИИН и ФИО берём из первой, где они есть. + const iin = (records.find((r) => (r.iin || '').trim())?.iin || '').trim(); if (!iin) { throw new Error('в кадровом справочнике у сотрудника не указан ИИН'); } - const name = splitFio(match.record.fio); + const fio = records.find((r) => (r.fio || '').trim())?.fio || ''; + const name = splitFio(fio); if (!name) { - throw new Error(`не удалось разобрать Ф.И.О. «${match.record.fio}» на фамилию и имя`); + throw new Error(`не удалось разобрать Ф.И.О. «${fio}» на фамилию и имя`); } - return { iin, name, companyBin: (match.company.Bin as string).trim(), companyName: match.company.Name }; + const byId = new Map(companies.map((c) => [c.Id.toLowerCase(), c])); + const result: ISigningCompany[] = []; + const skipped: ISkippedCompany[] = []; + const seen = new Set(); + + for (const record of records) { + if (!record.companyId) continue; + + const company = byId.get(record.companyId.toLowerCase()); + const bin = (company?.Bin || '').trim(); + const companyName = company?.Name || record.company; + + if (!bin) { + if (!seen.has(`name:${companyName}`)) { + seen.add(`name:${companyName}`); + skipped.push({ name: companyName, reason: 'в справочнике компаний нет БИН' }); + } + continue; + } + + if (seen.has(bin)) continue; + seen.add(bin); + result.push({ bin, name: companyName, position: record.position }); + } + + if (result.length === 0 && skipped.length === 0) { + throw new Error('в карточке сотрудника не указана компания'); + } + + return { iin, name, companies: result, skipped }; } -- 2.34.1 From cce4dfa4b3ea1ba112adaa5019409d584b5b6b08 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 14:59:12 +0500 Subject: [PATCH 10/24] =?UTF-8?q?feat:=20=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20=D0=BA=D1=8D=D1=88=D0=B8=D1=80=D1=83=D0=B5?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20=D0=B2=20localStorage=20=D0=BD=D0=B0=204?= =?UTF-8?q?=20=D1=87=D0=B0=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше он жил только пока открыта страница и загружался заново при каждой перезагрузке. Теперь между перезагрузками API не дёргается 4 часа; просроченный, битый или записанный «из будущего» кэш игнорируется и удаляется, недоступное хранилище кэш не ломает. Co-Authored-By: Claude Sonnet 5.5 --- .../shared/dictionary/companies.service.ts | 30 ++++++- .../shared/dictionary/companiesCache.test.ts | 90 +++++++++++++++++++ .../shared/dictionary/companiesCache.ts | 73 +++++++++++++++ 3 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 src/webparts/requestPortal/shared/dictionary/companiesCache.test.ts create mode 100644 src/webparts/requestPortal/shared/dictionary/companiesCache.ts diff --git a/src/webparts/requestPortal/shared/dictionary/companies.service.ts b/src/webparts/requestPortal/shared/dictionary/companies.service.ts index 82efc0b..4b5bfaf 100644 --- a/src/webparts/requestPortal/shared/dictionary/companies.service.ts +++ b/src/webparts/requestPortal/shared/dictionary/companies.service.ts @@ -1,10 +1,11 @@ import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http'; import { WebPartContext } from '@microsoft/sp-webpart-base'; import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config'; +import { IStorageLike, readCachedCompanies, writeCachedCompanies } from './companiesCache'; import { ICompany } from './models/ICompany'; // GET /Dictionary/Companies — справочник компаний, ответ — плоский массив -// { Id, Name } (без обёртки, как /Dictionary/Departments). +// { Id, Name, Bin } (без обёртки, как /Dictionary/Departments). async function fetchCompanies(context: WebPartContext): Promise { const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID); const response: HttpClientResponse = await client.get( @@ -17,12 +18,33 @@ async function fetchCompanies(context: WebPartContext): Promise { return response.json(); } -// Справочник в рамках сессии не меняется — грузим один раз и отдаём тот же -// промис повторным вызовам (как в departments.service). +/** localStorage, если он есть: в некоторых режимах браузера обращение к нему бросает исключение. */ +function browserStorage(): IStorageLike | undefined { + try { + return window.localStorage; + } catch { + return undefined; + } +} + +async function loadCompanies(context: WebPartContext): Promise { + const storage = browserStorage(); + const cached = storage ? readCachedCompanies(storage, Date.now()) : undefined; + if (cached) return cached; + + const fresh = await fetchCompanies(context); + if (storage) writeCachedCompanies(storage, fresh, Date.now()); + return fresh; +} + +// Справочник живёт в localStorage 4 часа (см. companiesCache.ts) — между перезагрузками страницы +// к API не ходим. Поверх этого один промис на сессию: параллельные вызовы делят один запрос и не +// читают хранилище заново (как в departments.service). Ошибку не кешируем — следующий вызов +// попробует ещё раз. let cache: Promise | undefined; export function getCompanies(context: WebPartContext): Promise { if (!cache) { - cache = fetchCompanies(context).catch((e) => { + cache = loadCompanies(context).catch((e) => { cache = undefined; throw e; }); diff --git a/src/webparts/requestPortal/shared/dictionary/companiesCache.test.ts b/src/webparts/requestPortal/shared/dictionary/companiesCache.test.ts new file mode 100644 index 0000000..ff349d5 --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/companiesCache.test.ts @@ -0,0 +1,90 @@ +import { + COMPANIES_CACHE_KEY, + COMPANIES_CACHE_TTL_MS, + IStorageLike, + readCachedCompanies, + writeCachedCompanies +} from './companiesCache'; +import { ICompany } from './models/ICompany'; + +class MemoryStorage implements IStorageLike { + public readonly data = new Map(); + // eslint-disable-next-line @rushstack/no-new-null + public getItem(key: string): string | null { + return this.data.has(key) ? (this.data.get(key) as string) : null; + } + public setItem(key: string, value: string): void { + this.data.set(key, value); + } + public removeItem(key: string): void { + this.data.delete(key); + } +} + +const COMPANIES: ICompany[] = [ + { Id: '9de064a4-44c8-88ef-11ec-8284930fad96', Name: 'ТОО TANSU Construction', Bin: '021040005165' }, + { Id: '0a1b2c3d-0000-4000-8000-000000000002', Name: 'ТОО TANSU Строй' } +]; + +const NOW = 1_800_000_000_000; + +describe('кэш справочника компаний', () => { + it('в пустом хранилище кэша нет', () => { + expect(readCachedCompanies(new MemoryStorage(), NOW)).toBeUndefined(); + }); + + it('записанное читается обратно, пока не прошло 4 часа', () => { + const storage = new MemoryStorage(); + writeCachedCompanies(storage, COMPANIES, NOW); + + expect(readCachedCompanies(storage, NOW + 1000)).toEqual(COMPANIES); + expect(readCachedCompanies(storage, NOW + COMPANIES_CACHE_TTL_MS - 1)).toEqual(COMPANIES); + }); + + it('через 4 часа кэш устаревает и удаляется', () => { + const storage = new MemoryStorage(); + writeCachedCompanies(storage, COMPANIES, NOW); + + expect(readCachedCompanies(storage, NOW + COMPANIES_CACHE_TTL_MS)).toBeUndefined(); + expect(storage.data.has(COMPANIES_CACHE_KEY)).toBe(false); + }); + + it('срок — ровно 4 часа', () => { + expect(COMPANIES_CACHE_TTL_MS).toBe(14_400_000); + }); + + it('запись из будущего (часы переведены назад) не доверяется', () => { + const storage = new MemoryStorage(); + writeCachedCompanies(storage, COMPANIES, NOW + 60_000); + + expect(readCachedCompanies(storage, NOW)).toBeUndefined(); + }); + + it('битый JSON и неожиданная форма — как отсутствующий кэш', () => { + const broken = new MemoryStorage(); + broken.setItem(COMPANIES_CACHE_KEY, '{не json'); + expect(readCachedCompanies(broken, NOW)).toBeUndefined(); + expect(broken.data.has(COMPANIES_CACHE_KEY)).toBe(false); + + const wrongShape = new MemoryStorage(); + wrongShape.setItem(COMPANIES_CACHE_KEY, JSON.stringify({ savedAt: NOW, items: [{ Id: 1 }] })); + expect(readCachedCompanies(wrongShape, NOW)).toBeUndefined(); + }); + + it('недоступное хранилище не ломает ни чтение, ни запись', () => { + const throwing: IStorageLike = { + getItem: () => { + throw new Error('запрещено'); + }, + setItem: () => { + throw new Error('переполнено'); + }, + removeItem: () => { + throw new Error('запрещено'); + } + }; + + expect(readCachedCompanies(throwing, NOW)).toBeUndefined(); + expect(() => writeCachedCompanies(throwing, COMPANIES, NOW)).not.toThrow(); + }); +}); diff --git a/src/webparts/requestPortal/shared/dictionary/companiesCache.ts b/src/webparts/requestPortal/shared/dictionary/companiesCache.ts new file mode 100644 index 0000000..d78ae3a --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/companiesCache.ts @@ -0,0 +1,73 @@ +import { ICompany } from './models/ICompany'; + +/** + * Ключ в localStorage. Версия в имени: поменяется форма записи — старые кэши просто перестанут + * читаться, а не сломают разбор. + */ +export const COMPANIES_CACHE_KEY = 'rp.dictionary.companies.v1'; + +/** Справочник компаний меняется редко, поэтому живёт в кэше 4 часа. */ +export const COMPANIES_CACHE_TTL_MS = 4 * 60 * 60 * 1000; + +/** Тот минимум localStorage, который нужен кэшу — для подмены в тестах. */ +export interface IStorageLike { + // Сигнатура повторяет DOM-интерфейс Storage, где «нет значения» — это null. + // eslint-disable-next-line @rushstack/no-new-null + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +interface ICachedCompanies { + savedAt: number; + items: ICompany[]; +} + +function isCompany(value: unknown): value is ICompany { + const company = value as Partial | undefined; + return !!company && typeof company.Id === 'string' && typeof company.Name === 'string'; +} + +/** + * Свежий кэш или undefined. Просроченный, битый или в неожиданной форме кэш считается отсутствующим + * и удаляется: лучше лишний раз сходить в справочник, чем показать мусор. + */ +export function readCachedCompanies(storage: IStorageLike, now: number): ICompany[] | undefined { + // eslint-disable-next-line @rushstack/no-new-null + let raw: string | null; + try { + raw = storage.getItem(COMPANIES_CACHE_KEY); + } catch { + return undefined; + } + if (!raw) return undefined; + + try { + const parsed = JSON.parse(raw) as Partial; + const fresh = + typeof parsed.savedAt === 'number' && + now - parsed.savedAt >= 0 && + now - parsed.savedAt < COMPANIES_CACHE_TTL_MS && + Array.isArray(parsed.items) && + parsed.items.every(isCompany); + if (fresh) return parsed.items as ICompany[]; + } catch { + // битый JSON — как отсутствующий кэш + } + + try { + storage.removeItem(COMPANIES_CACHE_KEY); + } catch { + // хранилище недоступно — удалять нечего + } + return undefined; +} + +/** Кладёт справочник в кэш. Переполнение или запрет хранилища (режим инкогнито) кэш не ломают — просто его не будет. */ +export function writeCachedCompanies(storage: IStorageLike, items: ICompany[], now: number): void { + try { + storage.setItem(COMPANIES_CACHE_KEY, JSON.stringify({ savedAt: now, items } as ICachedCompanies)); + } catch { + // кэш — оптимизация, а не источник данных + } +} -- 2.34.1 From 5fd8bfe453132d677ac28a34fedd2a68749df81e Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 16:39:06 +0500 Subject: [PATCH 11/24] =?UTF-8?q?fix:=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B0=20=D0=BE=D1=82=D0=BF?= =?UTF-8?q?=D1=83=D1=81=D0=BA=20=D0=BD=D0=B5=20=D1=83=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D0=BB=D0=BE=20=D0=B2=20=D0=AD=D0=94=D0=9E,=20=D0=B5?= =?UTF-8?q?=D1=81=D0=BB=D0=B8=20=D0=B2=20=D0=BC=D0=B0=D1=80=D1=88=D1=80?= =?UTF-8?q?=D1=83=D1=82=D0=B5=20=D0=B5=D1=81=D1=82=D1=8C=20=D0=B8=D1=81?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BD=D0=B8=D1=82=D0=B5=D0=BB=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Отправка срабатывала только на статус «Согласовано», а при исполнителе в маршруте последний согласующий переводит заявку сразу в «Выполнение» — запрос в ЭДО не шёл вообще. Теперь отправка срабатывает на решение, завершившее фазу согласования (isApprovalPhaseCompleted: «На согласовании» → «Согласовано»/«Выполнение»/«Закрыта»); решение исполнителя повторно её не запускает. В зелёное сообщение «Решение сохранено» дописывается «Документы отправлены на подписание в ЭДО…» (onDecided панели решения может вернуть строку). Заодно пять чужих предупреждений линтера: eqeqeq в SubmitActions, явные типы возврата в approverOrgInfo и useSkudRoute, зависимость эффекта в ServiceRequestView, null → undefined в ячейках календаря СКУД. Co-Authored-By: Claude Sonnet 5.5 --- .../ServiceRequest/ServiceRequestView.tsx | 8 +++-- .../SkudExplanation/skud/skudDays.test.ts | 4 +-- .../forms/SkudExplanation/skud/skudDays.ts | 8 ++--- .../forms/SkudExplanation/useSkudRoute.ts | 2 +- .../requestPortal/forms/Vacation/README.md | 13 ++++--- .../forms/Vacation/VacationView.tsx | 32 ++++++++++------- .../shared/approval/approverOrgInfo.ts | 2 +- .../shared/approval/models/statuses.test.ts | 36 +++++++++++++++++++ .../shared/approval/models/statuses.ts | 15 ++++++++ .../shared/approval/ui/DecisionPanel.tsx | 15 ++++++-- .../shared/ui/SubmitActions/SubmitActions.tsx | 2 +- 11 files changed, 106 insertions(+), 31 deletions(-) diff --git a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx index 66cc213..120d91e 100644 --- a/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx +++ b/src/webparts/requestPortal/forms/ServiceRequest/ServiceRequestView.tsx @@ -29,6 +29,7 @@ import { } from '../../shared/approval'; import { getAttachments, IAttachmentInfo } from '../../shared/attachments/attachments.service'; import { friendlyErrorMessage } from '../../shared/diagnostics'; +import { useStableCallback } from '../../shared/useStableCallback'; import { approvalListTitle, approvalListTitleForListId, @@ -57,6 +58,9 @@ interface IServiceRequestViewProps { // Заявка «Заявка на услуги» только для просмотра (режим согласующего). const ServiceRequestView: React.FC = ({ context, itemId, formKey, listId, onCodeLoaded }) => { + // onCodeLoaded — колбэк родителя, его ссылка меняется на каждом рендере; в зависимостях эффекта + // загрузки идёт стабильная обёртка, иначе заявка перезапрашивалась бы при каждом рендере родителя. + const notifyCode = useStableCallback(onCodeLoaded); const [data, setData] = useState(EMPTY_DATA); const [status, setStatus] = useState(''); const [code, setCode] = useState(''); @@ -95,7 +99,7 @@ const ServiceRequestView: React.FC = ({ context, itemI setData(loaded.data); setStatus(loaded.status); setCode(loaded.code); - onCodeLoaded?.(loaded.code); + notifyCode(loaded.code); setAttachments(files); setError(null); setLoading(false); @@ -110,7 +114,7 @@ const ServiceRequestView: React.FC = ({ context, itemI return () => { alive = false; }; - }, [itemId, listId]); + }, [itemId, listId, notifyCode]); // Ссылки на скачивание для файлов строк «Приложения» (см. AnnexesSection). // Считаем ДО ранних return'ов ниже: хук, вызванный только на части рендеров, diff --git a/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.test.ts b/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.test.ts index 6a95c96..ec0dd56 100644 --- a/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.test.ts +++ b/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.test.ts @@ -87,8 +87,8 @@ describe('cellKind / toMissedKind', () => { }); it('месяц начинается с понедельника: 1 июля 2026 — среда, два пустых места', () => { - expect(cells[0]).toBeNull(); - expect(cells[1]).toBeNull(); + expect(cells[0]).toBeUndefined(); + expect(cells[1]).toBeUndefined(); expect(cells[2]?.day).toBe(1); }); }); diff --git a/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.ts b/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.ts index 8057192..051a6bd 100644 --- a/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.ts +++ b/src/webparts/requestPortal/forms/SkudExplanation/skud/skudDays.ts @@ -130,12 +130,12 @@ export function monthWorkedHours(byDay: Map): number { return total; } -/** Ячейки месяца: ведущие null — пустые места до первого числа (неделя с понедельника). */ -export function buildMonthCells(year: number, month: number, byDay: Map): (IDayInfo | null)[] { +/** Ячейки месяца: ведущие undefined — пустые места до первого числа (неделя с понедельника). */ +export function buildMonthCells(year: number, month: number, byDay: Map): (IDayInfo | undefined)[] { const daysInMonth = new Date(year, month + 1, 0).getDate(); const firstColumn = (new Date(year, month, 1).getDay() + 6) % DAYS_IN_WEEK; - const cells: (IDayInfo | null)[] = []; - for (let i = 0; i < firstColumn; i += 1) cells.push(null); + const cells: (IDayInfo | undefined)[] = []; + for (let i = 0; i < firstColumn; i += 1) cells.push(undefined); for (let d = 1; d <= daysInMonth; d += 1) { const key = toDateKey(year, month, d); cells.push(byDay.get(key) ?? { day: d, dateKey: key, events: [], first: '', last: '' }); diff --git a/src/webparts/requestPortal/forms/SkudExplanation/useSkudRoute.ts b/src/webparts/requestPortal/forms/SkudExplanation/useSkudRoute.ts index fd3d7d3..640c858 100644 --- a/src/webparts/requestPortal/forms/SkudExplanation/useSkudRoute.ts +++ b/src/webparts/requestPortal/forms/SkudExplanation/useSkudRoute.ts @@ -33,7 +33,7 @@ interface IRouteSource { reloadSeq?: number; } -const byPhase = (execution: boolean) => (rows: IMatrixRow[]): IMatrixRow[] => +const byPhase = (execution: boolean): ((rows: IMatrixRow[]) => IMatrixRow[]) => (rows) => rows.filter((r) => (r.phase === APPROVAL_PHASE.execution) === execution); /** diff --git a/src/webparts/requestPortal/forms/Vacation/README.md b/src/webparts/requestPortal/forms/Vacation/README.md index e0f4c63..33e4cd6 100644 --- a/src/webparts/requestPortal/forms/Vacation/README.md +++ b/src/webparts/requestPortal/forms/Vacation/README.md @@ -8,8 +8,9 @@ ## Маршрут - Тип заявки в approval-route API (`/api/request-types`): id **11**, `pageUrl` «hr-vacation», `listId` «/sites/requests/Lists/List5», section «Кадры». -- Фазы: только согласование. Фазы исполнения нет — после согласования заявка - просто получает статус «Согласовано» (никакого Planner, в отличие от Ticket1C). +- Фазы: согласование, а при наличии исполнителей в маршруте (бывает: тип заявки может их + содержать) — ещё и исполнение. Без исполнителей после согласования заявка получает статус + «Согласовано», с исполнителем — сразу «Выполнение». Planner не используется (в отличие от Ticket1C). - **Матрица редактируема заявителем** при новой заявке / черновике / возврате (как RecruitingHire / WorkplaceSetup): `useMatrixEditor(editable = !readOnly)`. Заявитель может добавлять/удалять/переставлять согласующих поверх планируемого @@ -29,8 +30,12 @@ - **Заголовок элемента** (`Title`): `Отпуск — {ФИО} — дд.мм.гггг`. ## Подписание в ЭДО -После того как все согласующие согласовали (статус заявки становится «Согласовано»), из браузера -**последнего согласующего** (`VacationView` → `onDecided`) заявление уходит на подпись в TANSU ЭДО: +После того как все согласующие согласовали, из браузера **последнего согласующего** +(`VacationView` → `onDecided`) заявление уходит на подпись в TANSU ЭДО. Срабатывает на решение, которое +завершило фазу согласования (`isApprovalPhaseCompleted`: «На согласовании» → «Согласовано» **или** +«Выполнение»), а не только на «Согласовано»: если в маршруте есть исполнитель, заявка сразу становится +«Выполнение». Решение исполнителя повторно отправку не запускает. В зелёном сообщении «Решение сохранено» +появляется строка «Документы отправлены на подписание в ЭДО…»: - **Кто подписывает** — сотрудник, указанный в заявке (поле «Сотрудник»). Его почта передаётся и как получатель, и как `initiator_email`: ему уходит приглашение, и он же получает письмо, когда документ подписан. - **Документ** — заявление, собранное в браузере как DOCX (`vacationDocument.ts`, JSZip); ЭДО сам diff --git a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx index 129c33c..77fe59a 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx @@ -16,7 +16,7 @@ import { notifyActiveStep, useRequestTypeId } from '../../shared/approval'; -import { REQUEST_STATUS } from '../../shared/approval/models/statuses'; +import { isApprovalPhaseCompleted } from '../../shared/approval/models/statuses'; import { friendlyErrorMessage } from '../../shared/diagnostics'; import { approvalListTitle, @@ -145,20 +145,27 @@ const VacationView: React.FC = ({ context, itemId, formKey, // поэтому неудача показывается отдельным сообщением и согласование не отменяет. Повторить можно // кнопкой: запрос принимается целиком или отклоняется целиком, а повтор уже принятого ЭДО // отклонит как дубль — признака «уже отправлено» в заявке нет. - const sendToSigning = (): Promise => { + // Возвращает фразу для зелёного сообщения панели решения, если документы ушли; при неудаче — + // ничего (ошибку и кнопку повтора показывает блок ниже). + const sendToSigning = (): Promise => { setSigningError(''); setSigningResult(undefined); setSigningBusy(true); return sendVacationForSigning(context, { data, employeeName, itemId, code }) - .then((result) => { + .then((result): string | undefined => { setSigningResult(result); + return `Документы отправлены на подписание в ЭДО (${result.sent.join(', ')}): ссылка для подписания ушла на ${data.Employee}.`; }) - .catch((e) => { + .catch((e): string | undefined => { setSigningError( `Заявка согласована, но отправить заявление на подпись не удалось: ${friendlyErrorMessage(e)}` ); + return undefined; }) - .then(() => setSigningBusy(false)); + .then((note) => { + setSigningBusy(false); + return note; + }); }; return ( @@ -198,11 +205,6 @@ const VacationView: React.FC = ({ context, itemId, formKey,
{signingBusy && Отправляем заявление на подпись в ЭДО…} - {!signingBusy && signingResult && ( - - {`Заявление отправлено на подпись (${signingResult.sent.join(', ')}): письма со ссылкой ушли на ${data.Employee}.`} - - )} {!signingBusy && signingResult && signingResult.skipped.length > 0 && ( {`Не отправлено по компаниям: ${signingResult.skipped.map((c) => `${c.name} — ${c.reason}`).join('; ')}.`} @@ -224,10 +226,14 @@ const VacationView: React.FC = ({ context, itemId, formKey, setStatus(newStatus); setDecisionSeq((n) => n + 1); notify(); - // «Согласовано» = маршрут пройден целиком (фазы исполнения у отпуска нет). + // Это решение завершило согласование: подписывать пора, а не когда заявка «Согласовано». + // Если в маршруте есть исполнитель, последний согласующий переводит заявку сразу в + // «Выполнение», и проверка только на «Согласовано» пропускала такие заявки. Предыдущий + // статус (`status` — до этого решения) отсекает решения исполнителя. // Промис возвращаем: пока он не разрешится, панель решения не пишет «Решение - // сохранено» — иначе успех появлялся бы раньше, чем заявление ушло на подпись. - return newStatus === REQUEST_STATUS.approved ? sendToSigning() : undefined; + // сохранено» — иначе успех появлялся бы раньше, чем заявление ушло на подпись; а его + // результат панель допишет к зелёному сообщению. + return isApprovalPhaseCompleted(status, newStatus) ? sendToSigning() : undefined; }} onDelegated={() => { setDecisionSeq((n) => n + 1); diff --git a/src/webparts/requestPortal/shared/approval/approverOrgInfo.ts b/src/webparts/requestPortal/shared/approval/approverOrgInfo.ts index b97d6d7..b6c2485 100644 --- a/src/webparts/requestPortal/shared/approval/approverOrgInfo.ts +++ b/src/webparts/requestPortal/shared/approval/approverOrgInfo.ts @@ -17,7 +17,7 @@ export interface IApproverOrgInfo { * email не найден в кадровом справочнике (404) или пуст — getEmployeeOrganizations * отдаёт [], здесь это просто пустые отдел/организация, не ошибка матрицы. */ -export function resolveApproverOrgInfo(context: WebPartContext) { +export function resolveApproverOrgInfo(context: WebPartContext): (email: string) => Promise { return async (email: string): Promise => { const records = await getEmployeeOrganizations(context, email); const first = records[0]; diff --git a/src/webparts/requestPortal/shared/approval/models/statuses.test.ts b/src/webparts/requestPortal/shared/approval/models/statuses.test.ts index d9f049f..638231d 100644 --- a/src/webparts/requestPortal/shared/approval/models/statuses.test.ts +++ b/src/webparts/requestPortal/shared/approval/models/statuses.test.ts @@ -6,6 +6,7 @@ import { REQUEST_STATUS, REQUEST_STATUS_CHOICES, initialSubmitStatus, + isApprovalPhaseCompleted, isEditableStatus, isSubmittedStatus, } from './statuses'; @@ -54,6 +55,41 @@ describe('initialSubmitStatus', () => { // возвращает initialSubmitStatus, ОБЯЗАН считаться отправкой: иначе строки // маршрута получают недопустимый статус, а активации не происходит вовсе — // именно так заявка без согласующих перестала попадать в очередь исполнения. +describe('isApprovalPhaseCompleted', () => { + it('последний согласующий без исполнителей: «На согласовании» → «Согласовано»', () => { + expect(isApprovalPhaseCompleted(REQUEST_STATUS.onApproval, REQUEST_STATUS.approved)).toBe(true); + }); + + // Тот самый случай: в маршруте есть исполнитель, и заявка сразу становится «Выполнение». + it('последний согласующий при наличии исполнителя: «На согласовании» → «Выполнение»', () => { + expect(isApprovalPhaseCompleted(REQUEST_STATUS.onApproval, REQUEST_STATUS.execution)).toBe(true); + }); + + it('согласование завершилось, исполнители уже отработали: → «Закрыта»', () => { + expect(isApprovalPhaseCompleted(REQUEST_STATUS.onApproval, REQUEST_STATUS.closed)).toBe(true); + }); + + it('согласуют не последние: заявка остаётся «На согласовании»', () => { + expect(isApprovalPhaseCompleted(REQUEST_STATUS.onApproval, REQUEST_STATUS.onApproval)).toBe(false); + }); + + it('возврат и отклонение не завершают согласование', () => { + expect(isApprovalPhaseCompleted(REQUEST_STATUS.onApproval, REQUEST_STATUS.returned)).toBe(false); + expect(isApprovalPhaseCompleted(REQUEST_STATUS.onApproval, REQUEST_STATUS.rejected)).toBe(false); + }); + + // Решение исполнителя не должно второй раз запускать то, что делается по итогам согласования. + it('решение исполнителя — не завершение согласования', () => { + expect(isApprovalPhaseCompleted(REQUEST_STATUS.execution, REQUEST_STATUS.execution)).toBe(false); + expect(isApprovalPhaseCompleted(REQUEST_STATUS.execution, REQUEST_STATUS.closed)).toBe(false); + }); + + it('предыдущий статус неизвестен — не гадаем', () => { + expect(isApprovalPhaseCompleted(undefined, REQUEST_STATUS.approved)).toBe(false); + expect(isApprovalPhaseCompleted('', REQUEST_STATUS.execution)).toBe(false); + }); +}); + describe('isSubmittedStatus', () => { it('любой результат initialSubmitStatus считается отправкой', () => { [initialSubmitStatus(1, 0), initialSubmitStatus(0, 1), initialSubmitStatus(0, 0)].forEach((s) => diff --git a/src/webparts/requestPortal/shared/approval/models/statuses.ts b/src/webparts/requestPortal/shared/approval/models/statuses.ts index dc00973..a3e38b0 100644 --- a/src/webparts/requestPortal/shared/approval/models/statuses.ts +++ b/src/webparts/requestPortal/shared/approval/models/statuses.ts @@ -61,6 +61,21 @@ export function initialSubmitStatus(approversCount: number, executorsCount: numb return REQUEST_STATUS.approved; } +/** + * Это решение завершило фазу СОГЛАСОВАНИЯ: заявка была «На согласовании», а стала «Согласовано», + * «Выполнение» или «Закрыта». + * + * Статус «Согласовано» бывает только у маршрута без исполнителей. Если исполнитель в маршруте + * есть, последний согласующий переводит заявку сразу в «Выполнение», и проверять только + * «Согласовано» — значит пропускать такие заявки. Сравнение с ПРЕДЫДУЩИМ статусом нужно затем, + * чтобы решение исполнителя («Выполнение» → «Выполнение»/«Закрыта») не принималось за + * завершение согласования и побочное действие не срабатывало второй раз. + */ +export function isApprovalPhaseCompleted(previous: string | undefined, next: string): boolean { + if (previous !== REQUEST_STATUS.onApproval) return false; + return next === REQUEST_STATUS.approved || next === REQUEST_STATUS.execution || next === REQUEST_STATUS.closed; +} + /** * Статус заявки означает «отправлена» (в отличие от черновика)? * diff --git a/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx b/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx index a85387e..95d04a4 100644 --- a/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx +++ b/src/webparts/requestPortal/shared/approval/ui/DecisionPanel.tsx @@ -23,8 +23,12 @@ interface IDecisionPanelProps { * Отказ (reject) панель НЕ считает провалом решения — решение к этому моменту * уже применено на сервере; сообщить о проблеме побочного действия должен сам * обработчик (у него для этого есть своя область экрана). + * + * Может вернуть строку — её панель допишет к зелёному сообщению «Решение сохранено…» + * (например, «Документы отправлены на подписание в ЭДО»). Нет побочного действия или оно + * не удалось — не возвращайте ничего, сообщение останется прежним. */ - onDecided?: (status: string) => void | Promise; + onDecided?: (status: string) => void | string | undefined | Promise; /** Выполнить ПЕРЕД применением решения (напр. сохранить поля, заполненные * согласующим). Если бросит — решение не применяется, показывается ошибка. * decision — какое решение применяется (проверки, нужные только при @@ -66,6 +70,8 @@ export const DecisionPanel: React.FC = ({ const [comment, setComment] = useState(''); const [submitting, setSubmitting] = useState(false); const [resultStatus, setResultStatus] = useState(undefined); + // Что обработчик onDecided велел дописать к сообщению об успехе. + const [resultNote, setResultNote] = useState(undefined); const [delegated, setDelegated] = useState(0); const [showDelegate, setShowDelegate] = useState(false); // Выбранные делеганты копятся здесь и пишутся одной пачкой по кнопке — иначе @@ -119,13 +125,16 @@ export const DecisionPanel: React.FC = ({ // отработал, кнопки остаются заблокированными (submitting), и пользователь не // видит успеха раньше, чем всё сделано. Провал onDecided на статус решения не // влияет — оно уже применено, поэтому сообщение показываем всё равно. + let note: string | undefined; if (onDecided) { try { - await onDecided(status); + const returned = await onDecided(status); + note = typeof returned === 'string' && returned ? returned : undefined; } catch (e) { console.error('Побочное действие после решения не выполнилось', e); } } + setResultNote(note); setResultStatus(status); setSubmitting(false); } catch (e) { @@ -205,7 +214,7 @@ export const DecisionPanel: React.FC = ({
{isExecution ? 'Отмечено как выполнено' : 'Решение сохранено'}. Статус заявки: « - {resultStatus}». + {resultStatus}».{resultNote ? ` ${resultNote}` : ''}
); diff --git a/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitActions.tsx b/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitActions.tsx index 653ca17..484537a 100644 --- a/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitActions.tsx +++ b/src/webparts/requestPortal/shared/ui/SubmitActions/SubmitActions.tsx @@ -78,7 +78,7 @@ export const SubmitActions: React.FC = ({ onDelete, }) => { const { saving, kind } = submit; - const unfrozenForm = formId != null && UNFROZEN_FORMS.has(formId); + const unfrozenForm = formId !== undefined && UNFROZEN_FORMS.has(formId); // false, пока не пришёл ответ о членстве в группе, — безопасный дефолт: не // мигать разблокированными кнопками до подтверждения, что это тестировщик. const [isTester, setIsTester] = React.useState(false); -- 2.34.1 From 911ccfbcf44f8d00a37b900d79b3a2d0ca3af5d3 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 17:00:43 +0500 Subject: [PATCH 12/24] =?UTF-8?q?=D0=9E=D1=82=D0=BF=D1=83=D1=81=D0=BA:=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D1=8B=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BF=D0=B0=D0=BD=D0=B8=D0=B9=20=D0=B1=D0=B5=D0=B7?= =?UTF-8?q?=20=D1=81=D0=B5=D1=80=D1=82=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=20Sigex=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D1=8F?= =?UTF-8?q?=D1=8E=D1=82=D1=81=D1=8F=20=D0=B2=20=D0=AD=D0=94=D0=9E=20=D0=B8?= =?UTF-8?q?=20=D0=B6=D0=B4=D1=83=D1=82=20=D0=B5=D0=B3=D0=BE=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8,=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D0=B1=20=D1=8D?= =?UTF-8?q?=D1=82=D0=BE=D0=BC=20=D0=B2=20=C2=AB=D0=A0=D0=B5=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=BE=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../requestPortal/forms/Vacation/README.md | 4 +++- .../forms/Vacation/VacationView.tsx | 3 ++- .../forms/Vacation/sendToSign.ts | 15 ++++++++++-- .../forms/Vacation/signingMessage.ts | 23 ++++++++++++++++++ .../requestPortal/shared/edo/edo.service.ts | 18 +++++++++++--- .../requestPortal/shared/edo/edo.test.ts | 24 +++++++++++++++++++ 6 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 src/webparts/requestPortal/forms/Vacation/signingMessage.ts diff --git a/src/webparts/requestPortal/forms/Vacation/README.md b/src/webparts/requestPortal/forms/Vacation/README.md index 33e4cd6..2f9db15 100644 --- a/src/webparts/requestPortal/forms/Vacation/README.md +++ b/src/webparts/requestPortal/forms/Vacation/README.md @@ -45,7 +45,9 @@ массиве `file_company_bins`, общий `company_bin` не передаётся. Подписант один — сам сотрудник, от имени физического лица; БИН нужны ЭДО только чтобы выбрать сертификат Sigex (mTLS) нужной компании. Запрос один на все файлы: ЭДО принимает его целиком или отклоняет целиком (например, если у какой-то - компании нет сертификата), поэтому повтор безопасен. + компании не распознан), поэтому повтор безопасен. Если у компании нет сертификата Sigex, ЭДО всё равно + сохраняет её документ (`awaiting_certificate: true`), но подписать его нельзя, пока администратор не + загрузит сертификат — тогда подписание откроется само. В зелёном сообщении такие компании названы отдельно. - **ИИН** берётся из `GET /Employee/ByEmail` (поле `Iin`), **БИН компаний** — из `GET /Dictionary/Companies` по `CompanyId` каждой записи сотрудника (`shared/edo/signingTarget.ts`). Компания без БИН в справочнике пропускается и показывается в сообщении; остальные уходят. diff --git a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx index 77fe59a..073b0b2 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx @@ -28,6 +28,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { loadVacationRequest } from './load'; import { vacationApprovalService } from './submit'; import { IVacationSigningResult, sendVacationForSigning } from './sendToSign'; +import { describeSigning } from './signingMessage'; import { RequestSection } from './sections/RequestSection'; import { useStableCallback } from '../../shared/useStableCallback'; @@ -154,7 +155,7 @@ const VacationView: React.FC = ({ context, itemId, formKey, return sendVacationForSigning(context, { data, employeeName, itemId, code }) .then((result): string | undefined => { setSigningResult(result); - return `Документы отправлены на подписание в ЭДО (${result.sent.join(', ')}): ссылка для подписания ушла на ${data.Employee}.`; + return describeSigning(result, data.Employee); }) .catch((e): string | undefined => { setSigningError( diff --git a/src/webparts/requestPortal/forms/Vacation/sendToSign.ts b/src/webparts/requestPortal/forms/Vacation/sendToSign.ts index 42b8d48..414ccfd 100644 --- a/src/webparts/requestPortal/forms/Vacation/sendToSign.ts +++ b/src/webparts/requestPortal/forms/Vacation/sendToSign.ts @@ -21,6 +21,11 @@ export interface IVacationToSign { export interface IVacationSigningResult { /** Названия компаний, по которым заявление отправлено на подпись. */ sent: string[]; + /** + * Компании, у которых в ЭДО нет сертификата Sigex: заявление там сохранено, но подписать его нельзя, + * пока администратор не загрузит сертификат. Подписание откроется само — это подмножество `sent`. + */ + awaitingCertificate: string[]; /** Компании сотрудника, по которым документ не собрать (нет БИН в справочнике), и почему. */ skipped: ISkippedCompany[]; } @@ -100,11 +105,17 @@ export async function sendVacationForSigning( })) ); - await submitSigningRequest(context, { + const accepted = await submitSigningRequest(context, { recipient: { iin: targets.iin, email: data.Employee, name: targets.name }, initiatorEmail: data.Employee, files }); - return { sent: targets.companies.map((c) => c.name), skipped: targets.skipped }; + const waitingBins = accepted.documents.filter((d) => d.awaitingCertificate).map((d) => d.companyBin); + + return { + sent: targets.companies.map((c) => c.name), + awaitingCertificate: targets.companies.filter((c) => waitingBins.indexOf(c.bin) >= 0).map((c) => c.name), + skipped: targets.skipped + }; } diff --git a/src/webparts/requestPortal/forms/Vacation/signingMessage.ts b/src/webparts/requestPortal/forms/Vacation/signingMessage.ts new file mode 100644 index 0000000..6c556b7 --- /dev/null +++ b/src/webparts/requestPortal/forms/Vacation/signingMessage.ts @@ -0,0 +1,23 @@ +import type { IVacationSigningResult } from './sendToSign'; + +/** + * Фраза для зелёного сообщения «Решение сохранено…»: что ушло на подпись, а что сохранено и ждёт + * сертификата Sigex своей компании (подписание откроется после его загрузки). + */ +export function describeSigning(result: IVacationSigningResult, employeeEmail: string): string { + const ready = result.sent.filter((name) => result.awaitingCertificate.indexOf(name) < 0); + const parts: string[] = []; + + if (ready.length > 0) { + parts.push(`Документы отправлены на подписание в ЭДО (${ready.join(', ')}): ссылка для подписания ушла на ${employeeEmail}.`); + } + + if (result.awaitingCertificate.length > 0) { + parts.push( + `Документы сохранены в ЭДО, но ждут загрузки сертификата Sigex по компаниям: ${result.awaitingCertificate.join(', ')}. ` + + `Подписание откроется само, когда администратор загрузит сертификат; ссылка придёт на ${employeeEmail}.` + ); + } + + return parts.join(' '); +} diff --git a/src/webparts/requestPortal/shared/edo/edo.service.ts b/src/webparts/requestPortal/shared/edo/edo.service.ts index 2fc4d0f..7034ce9 100644 --- a/src/webparts/requestPortal/shared/edo/edo.service.ts +++ b/src/webparts/requestPortal/shared/edo/edo.service.ts @@ -28,13 +28,20 @@ export interface IEdoSigningRequest { export interface IEdoSigningResult { /** Кабинет получателя без ключа: войти по ЭЦП или через Microsoft. */ cabinetUrl: string; - documents: Array<{ id: string; fileName: string }>; + documents: Array<{ + id: string; + fileName: string; + /** БИН компании файла. */ + companyBin: string; + /** У компании нет сертификата Sigex: файл сохранён, но подписать его нельзя до загрузки сертификата. */ + awaitingCertificate: boolean; + }>; } /** Ответ ЭДО в snake_case — только поля, которые читаем. */ interface IEdoResponseBody { cabinet_url?: string; - documents?: Array<{ id?: string; file_name?: string }>; + documents?: Array<{ id?: string; file_name?: string; company_bin?: string; awaiting_certificate?: boolean }>; } /** @@ -84,6 +91,11 @@ export async function submitSigningRequest( const body = (await response.json()) as IEdoResponseBody; return { cabinetUrl: body.cabinet_url || '', - documents: (body.documents || []).map((d) => ({ id: d.id || '', fileName: d.file_name || '' })) + documents: (body.documents || []).map((d) => ({ + id: d.id || '', + fileName: d.file_name || '', + companyBin: d.company_bin || '', + awaitingCertificate: d.awaiting_certificate === true + })) }; } diff --git a/src/webparts/requestPortal/shared/edo/edo.test.ts b/src/webparts/requestPortal/shared/edo/edo.test.ts index 4174a45..6437667 100644 --- a/src/webparts/requestPortal/shared/edo/edo.test.ts +++ b/src/webparts/requestPortal/shared/edo/edo.test.ts @@ -3,6 +3,7 @@ import { IEmployeeOrganization } from '../dictionary/models/IEmployeeOrganizatio import { describeEdoError } from './edoErrors'; import { splitFio } from './fio'; import { resolveSigningTargets } from './signingTarget'; +import { describeSigning } from '../../forms/Vacation/signingMessage'; const TANSU_ID = '9de064a4-44c8-88ef-11ec-8284930fad96'; @@ -150,3 +151,26 @@ describe('текст ошибки ЭДО', () => { expect(describeEdoError(403, '')).toContain('403'); }); }); + +describe('сообщение об отправке в ЭДО', () => { + it('всё ушло на подпись', () => { + const text = describeSigning({ sent: ['ТОО А', 'ТОО Б'], awaitingCertificate: [], skipped: [] }, 'a@b.kz'); + + expect(text).toContain('отправлены на подписание в ЭДО (ТОО А, ТОО Б)'); + expect(text).not.toContain('сертификат'); + }); + + it('у части компаний нет сертификата — документы сохранены и ждут', () => { + const text = describeSigning({ sent: ['ТОО А', 'ТОО Б'], awaitingCertificate: ['ТОО Б'], skipped: [] }, 'a@b.kz'); + + expect(text).toContain('отправлены на подписание в ЭДО (ТОО А)'); + expect(text).toContain('ждут загрузки сертификата Sigex по компаниям: ТОО Б'); + }); + + it('сертификата нет ни у кого', () => { + const text = describeSigning({ sent: ['ТОО А'], awaitingCertificate: ['ТОО А'], skipped: [] }, 'a@b.kz'); + + expect(text).not.toContain('отправлены на подписание'); + expect(text).toContain('ждут загрузки сертификата Sigex'); + }); +}); -- 2.34.1 From 1fd759732e9ab9cb161ca94f8ce69144b48e071e Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 17:16:23 +0500 Subject: [PATCH 13/24] =?UTF-8?q?=D0=9E=D1=82=D0=BF=D1=83=D1=81=D0=BA:=20?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D1=86=D0=B8=D0=B0=D1=82=D0=BE=D1=80=20=D0=B2?= =?UTF-8?q?=20=D0=AD=D0=94=D0=9E=20=E2=80=94=20=D0=B8=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=BD=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=20=D0=B7=D0=B0=D1=8F?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20(=D0=BF=D0=B5=D1=80=D0=B2=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=B2=20=D1=84=D0=B0=D0=B7=D0=B5=20=D0=B8=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F),=20=D0=B0=20=D0=BD=D0=B5?= =?UTF-8?q?=20=D1=81=D0=BE=D1=82=D1=80=D1=83=D0=B4=D0=BD=D0=B8=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../requestPortal/forms/Vacation/VacationView.tsx | 9 ++++++++- .../requestPortal/forms/Vacation/sendToSign.ts | 11 ++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx index 073b0b2..f4d6d4c 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx @@ -17,6 +17,7 @@ import { useRequestTypeId } from '../../shared/approval'; import { isApprovalPhaseCompleted } from '../../shared/approval/models/statuses'; +import { executionPhaseRows } from '../../shared/approval/useRouteExecutors'; import { friendlyErrorMessage } from '../../shared/diagnostics'; import { approvalListTitle, @@ -152,7 +153,13 @@ const VacationView: React.FC = ({ context, itemId, formKey, setSigningError(''); setSigningResult(undefined); setSigningBusy(true); - return sendVacationForSigning(context, { data, employeeName, itemId, code }) + // Исполнитель — первый в фазе исполнения маршрута; берём свежие строки, а не те, что на экране: + // решение только что применено, и экран мог ещё не перечитать маршрут. + return approval + .getMatrixForRequest(itemId) + .then((rows) => executionPhaseRows(rows).filter((r) => r.email)[0]?.email) + .catch((): string | undefined => undefined) + .then((initiatorEmail) => sendVacationForSigning(context, { data, employeeName, itemId, code, initiatorEmail })) .then((result): string | undefined => { setSigningResult(result); return describeSigning(result, data.Employee); diff --git a/src/webparts/requestPortal/forms/Vacation/sendToSign.ts b/src/webparts/requestPortal/forms/Vacation/sendToSign.ts index 414ccfd..db6a78d 100644 --- a/src/webparts/requestPortal/forms/Vacation/sendToSign.ts +++ b/src/webparts/requestPortal/forms/Vacation/sendToSign.ts @@ -15,6 +15,11 @@ export interface IVacationToSign { /** Уникальный код заявки (`RequestUniqueCode`); пусто — берётся id. */ code: string; itemId: number; + /** + * Почта исполнителя заявки (тот, кто идёт в маршруте после согласующих): он «инициатор» в ЭДО, и + * на его почту придёт сообщение о подписи. Нет исполнителя в маршруте — инициатором будет сам сотрудник. + */ + initiatorEmail?: string; } /** Итог отправки: по каким компаниям документ ушёл и по каким нельзя. */ @@ -59,8 +64,8 @@ async function substituteName(context: WebPartContext, email: string): Promise Date: Fri, 2 Oct 2026 17:17:52 +0500 Subject: [PATCH 14/24] =?UTF-8?q?=D0=9E=D1=82=D0=BF=D1=83=D1=81=D0=BA:=20?= =?UTF-8?q?=D0=B7=D0=B5=D0=BB=D1=91=D0=BD=D0=BE=D0=B5=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D0=B1=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B5=20=D0=B2=20=D0=AD?= =?UTF-8?q?=D0=94=D0=9E=20=D0=B1=D0=B5=D0=B7=20=D0=BF=D0=BE=D0=B4=D1=80?= =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D1=81=D1=82=D0=B5=D0=B9=20=E2=80=94=20?= =?UTF-8?q?=D0=BE=D1=82=D0=B2=D0=B5=D1=82=20200=20=D0=B7=D0=BD=D0=B0=D1=87?= =?UTF-8?q?=D0=B8=D1=82=20=D0=BF=D1=80=D0=B8=D0=BD=D1=8F=D1=82=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../requestPortal/forms/Vacation/README.md | 4 ++-- .../forms/Vacation/VacationView.tsx | 3 +-- .../forms/Vacation/sendToSign.ts | 10 +------- .../forms/Vacation/signingMessage.ts | 23 ------------------ .../requestPortal/shared/edo/edo.test.ts | 24 ------------------- 5 files changed, 4 insertions(+), 60 deletions(-) delete mode 100644 src/webparts/requestPortal/forms/Vacation/signingMessage.ts diff --git a/src/webparts/requestPortal/forms/Vacation/README.md b/src/webparts/requestPortal/forms/Vacation/README.md index 2f9db15..6bd206d 100644 --- a/src/webparts/requestPortal/forms/Vacation/README.md +++ b/src/webparts/requestPortal/forms/Vacation/README.md @@ -46,8 +46,8 @@ имени физического лица; БИН нужны ЭДО только чтобы выбрать сертификат Sigex (mTLS) нужной компании. Запрос один на все файлы: ЭДО принимает его целиком или отклоняет целиком (например, если у какой-то компании не распознан), поэтому повтор безопасен. Если у компании нет сертификата Sigex, ЭДО всё равно - сохраняет её документ (`awaiting_certificate: true`), но подписать его нельзя, пока администратор не - загрузит сертификат — тогда подписание откроется само. В зелёном сообщении такие компании названы отдельно. + сохраняет её документ, но подписать его нельзя, пока администратор не загрузит сертификат. Ответ 200 + значит «принято»: в зелёном сообщении просто «Документы отправлены на подписание в ЭДО». - **ИИН** берётся из `GET /Employee/ByEmail` (поле `Iin`), **БИН компаний** — из `GET /Dictionary/Companies` по `CompanyId` каждой записи сотрудника (`shared/edo/signingTarget.ts`). Компания без БИН в справочнике пропускается и показывается в сообщении; остальные уходят. diff --git a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx index f4d6d4c..30b2b6b 100644 --- a/src/webparts/requestPortal/forms/Vacation/VacationView.tsx +++ b/src/webparts/requestPortal/forms/Vacation/VacationView.tsx @@ -29,7 +29,6 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model'; import { loadVacationRequest } from './load'; import { vacationApprovalService } from './submit'; import { IVacationSigningResult, sendVacationForSigning } from './sendToSign'; -import { describeSigning } from './signingMessage'; import { RequestSection } from './sections/RequestSection'; import { useStableCallback } from '../../shared/useStableCallback'; @@ -162,7 +161,7 @@ const VacationView: React.FC = ({ context, itemId, formKey, .then((initiatorEmail) => sendVacationForSigning(context, { data, employeeName, itemId, code, initiatorEmail })) .then((result): string | undefined => { setSigningResult(result); - return describeSigning(result, data.Employee); + return 'Документы отправлены на подписание в ЭДО.'; }) .catch((e): string | undefined => { setSigningError( diff --git a/src/webparts/requestPortal/forms/Vacation/sendToSign.ts b/src/webparts/requestPortal/forms/Vacation/sendToSign.ts index db6a78d..e4f0109 100644 --- a/src/webparts/requestPortal/forms/Vacation/sendToSign.ts +++ b/src/webparts/requestPortal/forms/Vacation/sendToSign.ts @@ -26,11 +26,6 @@ export interface IVacationToSign { export interface IVacationSigningResult { /** Названия компаний, по которым заявление отправлено на подпись. */ sent: string[]; - /** - * Компании, у которых в ЭДО нет сертификата Sigex: заявление там сохранено, но подписать его нельзя, - * пока администратор не загрузит сертификат. Подписание откроется само — это подмножество `sent`. - */ - awaitingCertificate: string[]; /** Компании сотрудника, по которым документ не собрать (нет БИН в справочнике), и почему. */ skipped: ISkippedCompany[]; } @@ -110,17 +105,14 @@ export async function sendVacationForSigning( })) ); - const accepted = await submitSigningRequest(context, { + await submitSigningRequest(context, { recipient: { iin: targets.iin, email: data.Employee, name: targets.name }, initiatorEmail: vacation.initiatorEmail || data.Employee, files }); - const waitingBins = accepted.documents.filter((d) => d.awaitingCertificate).map((d) => d.companyBin); - return { sent: targets.companies.map((c) => c.name), - awaitingCertificate: targets.companies.filter((c) => waitingBins.indexOf(c.bin) >= 0).map((c) => c.name), skipped: targets.skipped }; } diff --git a/src/webparts/requestPortal/forms/Vacation/signingMessage.ts b/src/webparts/requestPortal/forms/Vacation/signingMessage.ts deleted file mode 100644 index 6c556b7..0000000 --- a/src/webparts/requestPortal/forms/Vacation/signingMessage.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { IVacationSigningResult } from './sendToSign'; - -/** - * Фраза для зелёного сообщения «Решение сохранено…»: что ушло на подпись, а что сохранено и ждёт - * сертификата Sigex своей компании (подписание откроется после его загрузки). - */ -export function describeSigning(result: IVacationSigningResult, employeeEmail: string): string { - const ready = result.sent.filter((name) => result.awaitingCertificate.indexOf(name) < 0); - const parts: string[] = []; - - if (ready.length > 0) { - parts.push(`Документы отправлены на подписание в ЭДО (${ready.join(', ')}): ссылка для подписания ушла на ${employeeEmail}.`); - } - - if (result.awaitingCertificate.length > 0) { - parts.push( - `Документы сохранены в ЭДО, но ждут загрузки сертификата Sigex по компаниям: ${result.awaitingCertificate.join(', ')}. ` + - `Подписание откроется само, когда администратор загрузит сертификат; ссылка придёт на ${employeeEmail}.` - ); - } - - return parts.join(' '); -} diff --git a/src/webparts/requestPortal/shared/edo/edo.test.ts b/src/webparts/requestPortal/shared/edo/edo.test.ts index 6437667..4174a45 100644 --- a/src/webparts/requestPortal/shared/edo/edo.test.ts +++ b/src/webparts/requestPortal/shared/edo/edo.test.ts @@ -3,7 +3,6 @@ import { IEmployeeOrganization } from '../dictionary/models/IEmployeeOrganizatio import { describeEdoError } from './edoErrors'; import { splitFio } from './fio'; import { resolveSigningTargets } from './signingTarget'; -import { describeSigning } from '../../forms/Vacation/signingMessage'; const TANSU_ID = '9de064a4-44c8-88ef-11ec-8284930fad96'; @@ -151,26 +150,3 @@ describe('текст ошибки ЭДО', () => { expect(describeEdoError(403, '')).toContain('403'); }); }); - -describe('сообщение об отправке в ЭДО', () => { - it('всё ушло на подпись', () => { - const text = describeSigning({ sent: ['ТОО А', 'ТОО Б'], awaitingCertificate: [], skipped: [] }, 'a@b.kz'); - - expect(text).toContain('отправлены на подписание в ЭДО (ТОО А, ТОО Б)'); - expect(text).not.toContain('сертификат'); - }); - - it('у части компаний нет сертификата — документы сохранены и ждут', () => { - const text = describeSigning({ sent: ['ТОО А', 'ТОО Б'], awaitingCertificate: ['ТОО Б'], skipped: [] }, 'a@b.kz'); - - expect(text).toContain('отправлены на подписание в ЭДО (ТОО А)'); - expect(text).toContain('ждут загрузки сертификата Sigex по компаниям: ТОО Б'); - }); - - it('сертификата нет ни у кого', () => { - const text = describeSigning({ sent: ['ТОО А'], awaitingCertificate: ['ТОО А'], skipped: [] }, 'a@b.kz'); - - expect(text).not.toContain('отправлены на подписание'); - expect(text).toContain('ждут загрузки сертификата Sigex'); - }); -}); -- 2.34.1 From 7aacc1730f223f280bae2d2daeb0792ac83c46d5 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 17:23:29 +0500 Subject: [PATCH 15/24] =?UTF-8?q?=D0=9E=D1=82=D0=BF=D1=83=D1=81=D0=BA:=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D0=BD=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8F=20=D0=B2=20=D0=BC=D0=B0=D1=80=D1=88?= =?UTF-8?q?=D1=80=D1=83=D1=82=D0=B5=20=D0=B8=D0=BD=D0=B8=D1=86=D0=B8=D0=B0?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B0=20=D0=BD=D0=B5=D1=82,=20initiator=5Fem?= =?UTF-8?q?ail=20=D0=BD=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=D0=B0=D1=91?= =?UTF-8?q?=D1=82=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/webparts/requestPortal/forms/Vacation/sendToSign.ts | 4 ++-- src/webparts/requestPortal/shared/edo/edo.service.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/webparts/requestPortal/forms/Vacation/sendToSign.ts b/src/webparts/requestPortal/forms/Vacation/sendToSign.ts index e4f0109..7fca9c3 100644 --- a/src/webparts/requestPortal/forms/Vacation/sendToSign.ts +++ b/src/webparts/requestPortal/forms/Vacation/sendToSign.ts @@ -17,7 +17,7 @@ export interface IVacationToSign { itemId: number; /** * Почта исполнителя заявки (тот, кто идёт в маршруте после согласующих): он «инициатор» в ЭДО, и - * на его почту придёт сообщение о подписи. Нет исполнителя в маршруте — инициатором будет сам сотрудник. + * на его почту придёт сообщение о подписи. Нет исполнителя в маршруте — инициатора нет, и никто не уведомляется о подписях. */ initiatorEmail?: string; } @@ -107,7 +107,7 @@ export async function sendVacationForSigning( await submitSigningRequest(context, { recipient: { iin: targets.iin, email: data.Employee, name: targets.name }, - initiatorEmail: vacation.initiatorEmail || data.Employee, + initiatorEmail: vacation.initiatorEmail, files }); diff --git a/src/webparts/requestPortal/shared/edo/edo.service.ts b/src/webparts/requestPortal/shared/edo/edo.service.ts index 7034ce9..712c604 100644 --- a/src/webparts/requestPortal/shared/edo/edo.service.ts +++ b/src/webparts/requestPortal/shared/edo/edo.service.ts @@ -19,8 +19,8 @@ export interface IEdoSigningFile { export interface IEdoSigningRequest { /** Подписывающий: ЭДО заводит его по ИИН, если нет, и зовёт подписать письмом. */ recipient: { iin: string; email: string; name: IFioParts }; - /** Кому сообщать о подписи. */ - initiatorEmail: string; + /** Кому сообщать о подписи; пусто — никому. */ + initiatorEmail?: string; files: IEdoSigningFile[]; } @@ -65,7 +65,7 @@ export async function submitSigningRequest( form.append('last_name', request.recipient.name.lastName); form.append('first_name', request.recipient.name.firstName); if (request.recipient.name.middleName) form.append('middle_name', request.recipient.name.middleName); - form.append('initiator_email', request.initiatorEmail); + if (request.initiatorEmail) form.append('initiator_email', request.initiatorEmail); // БИН файлов — массивом, по порядку файлов: общий company_bin не нужен, подписант один (физлицо). for (const file of request.files) { form.append('files', file.content, file.name); -- 2.34.1 From 16a478053c3e6c724ea8f28dcfc68eeb78c21ed1 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 17:36:03 +0500 Subject: [PATCH 16/24] =?UTF-8?q?=D0=AD=D0=94=D0=9E:=20=D1=81=D0=B5=D1=80?= =?UTF-8?q?=D1=82=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=82=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=BF=D0=B0=D0=BD=D0=B8=D0=B8=20=D0=BD=D1=83=D0=B6=D0=B5=D0=BD?= =?UTF-8?q?=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?mTLS,=20=D0=BF=D0=BE=D1=80=D1=82=D0=B0=D0=BB=20=D0=B1=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D1=88=D0=B5=20=D0=BD=D0=B5=20=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=B1=D0=B8=D1=80=D0=B0=D0=B5=D1=82=20awaiting=5Fcertificate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/webparts/requestPortal/forms/Vacation/README.md | 6 +++--- src/webparts/requestPortal/shared/edo/edo.service.ts | 10 ++-------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/webparts/requestPortal/forms/Vacation/README.md b/src/webparts/requestPortal/forms/Vacation/README.md index 6bd206d..5ba7ace 100644 --- a/src/webparts/requestPortal/forms/Vacation/README.md +++ b/src/webparts/requestPortal/forms/Vacation/README.md @@ -45,9 +45,9 @@ массиве `file_company_bins`, общий `company_bin` не передаётся. Подписант один — сам сотрудник, от имени физического лица; БИН нужны ЭДО только чтобы выбрать сертификат Sigex (mTLS) нужной компании. Запрос один на все файлы: ЭДО принимает его целиком или отклоняет целиком (например, если у какой-то - компании не распознан), поэтому повтор безопасен. Если у компании нет сертификата Sigex, ЭДО всё равно - сохраняет её документ, но подписать его нельзя, пока администратор не загрузит сертификат. Ответ 200 - значит «принято»: в зелёном сообщении просто «Документы отправлены на подписание в ЭДО». + компании не распознан), поэтому повтор безопасен. Сертификат Sigex компании нужен ЭДО только для mTLS + (карточка документа): без него документ принимается и подписывается как обычно. Ответ 200 значит + «принято»: в зелёном сообщении просто «Документы отправлены на подписание в ЭДО». - **ИИН** берётся из `GET /Employee/ByEmail` (поле `Iin`), **БИН компаний** — из `GET /Dictionary/Companies` по `CompanyId` каждой записи сотрудника (`shared/edo/signingTarget.ts`). Компания без БИН в справочнике пропускается и показывается в сообщении; остальные уходят. diff --git a/src/webparts/requestPortal/shared/edo/edo.service.ts b/src/webparts/requestPortal/shared/edo/edo.service.ts index 712c604..d0fc56f 100644 --- a/src/webparts/requestPortal/shared/edo/edo.service.ts +++ b/src/webparts/requestPortal/shared/edo/edo.service.ts @@ -31,17 +31,13 @@ export interface IEdoSigningResult { documents: Array<{ id: string; fileName: string; - /** БИН компании файла. */ - companyBin: string; - /** У компании нет сертификата Sigex: файл сохранён, но подписать его нельзя до загрузки сертификата. */ - awaitingCertificate: boolean; }>; } /** Ответ ЭДО в snake_case — только поля, которые читаем. */ interface IEdoResponseBody { cabinet_url?: string; - documents?: Array<{ id?: string; file_name?: string; company_bin?: string; awaiting_certificate?: boolean }>; + documents?: Array<{ id?: string; file_name?: string }>; } /** @@ -93,9 +89,7 @@ export async function submitSigningRequest( cabinetUrl: body.cabinet_url || '', documents: (body.documents || []).map((d) => ({ id: d.id || '', - fileName: d.file_name || '', - companyBin: d.company_bin || '', - awaitingCertificate: d.awaiting_certificate === true + fileName: d.file_name || '' })) }; } -- 2.34.1 From e51d2fd98c0f2c9efe7897e2335c9ed415edc00d Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 18:15:13 +0500 Subject: [PATCH 17/24] =?UTF-8?q?=D0=AD=D0=94=D0=9E:=20=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=D0=B0=D0=BB=20=D0=BD=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5?= =?UTF-8?q?=D1=82=20=D1=81=D0=B5=D0=B1=D1=8F=20=C2=AB=D0=9F=D0=BE=D1=80?= =?UTF-8?q?=D1=82=D0=B0=D0=BB=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BE=D0=BA=C2=BB?= =?UTF-8?q?=20(service=5Finitiator=5Fname)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/webparts/requestPortal/shared/edo/edo.service.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/webparts/requestPortal/shared/edo/edo.service.ts b/src/webparts/requestPortal/shared/edo/edo.service.ts index d0fc56f..535ac3a 100644 --- a/src/webparts/requestPortal/shared/edo/edo.service.ts +++ b/src/webparts/requestPortal/shared/edo/edo.service.ts @@ -8,6 +8,9 @@ import { IFioParts } from './fio'; const SIGNING_REQUESTS_PATH = '/api/v1/integration/signing-requests'; /** Файл на подпись и компания, от имени которой он идёт (по её сертификату Sigex в ЭДО). */ +/** Как портал называет себя в ЭДО. */ +export const EDO_SOURCE_NAME = 'Портал заявок'; + export interface IEdoSigningFile { name: string; content: Blob; @@ -62,6 +65,8 @@ export async function submitSigningRequest( form.append('first_name', request.recipient.name.firstName); if (request.recipient.name.middleName) form.append('middle_name', request.recipient.name.middleName); if (request.initiatorEmail) form.append('initiator_email', request.initiatorEmail); + // Так ЭДО подпишет документ в колонке «Кто отправил», а не техническим client_id. + form.append('service_initiator_name', EDO_SOURCE_NAME); // БИН файлов — массивом, по порядку файлов: общий company_bin не нужен, подписант один (физлицо). for (const file of request.files) { form.append('files', file.content, file.name); -- 2.34.1 From 68424cd6f6e5ccc7acee48edeb104f2688536aef Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 5 Oct 2026 09:23:07 +0500 Subject: [PATCH 18/24] =?UTF-8?q?=D0=97=D0=B0=D1=8F=D0=B2=D0=BA=D0=B0=201?= =?UTF-8?q?=D0=A1:=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D0=BD=D0=B8=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8=20=D0=BF=D0=B8=D1=88=D1=83=D1=82=D1=81=D1=8F?= =?UTF-8?q?=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20executorsToWrite=20(=D0=BD?= =?UTF-8?q?=D1=83=D0=BC=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D1=88=D0=B0?= =?UTF-8?q?=D0=B3=D0=BE=D0=B2=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D1=81=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx index da6e9c3..f166573 100644 --- a/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx +++ b/src/webparts/requestPortal/forms/Ticket1C/Ticket1CForm.tsx @@ -29,7 +29,8 @@ import { isEditableStatus, IApproverToWrite, unassignedStepMessage, - APPROVAL_PHASE + APPROVAL_PHASE, + executorsToWrite } from '../../shared/approval'; import { uploadAttachments, @@ -306,7 +307,7 @@ const Ticket1CForm: React.FC = ({ requestId, data, toApprovers(matrixRows), - toApprovers(executors.rows), + executorsToWrite(executors.rows), finalStatus, writeListId ); -- 2.34.1 From af5a91c09240cf5f3f7c782a19d6fe594b31151e Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 5 Oct 2026 11:39:59 +0500 Subject: [PATCH 19/24] =?UTF-8?q?=D0=97=D0=B0=D1=8F=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B0=20=D0=BE=D1=82=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D0=BA:=20=D0=B1=D0=B5=D0=B7=20=D0=B4=D0=B0=D1=82=D1=8B?= =?UTF-8?q?=20=D0=BE=D0=BA=D0=BE=D0=BD=D1=87=D0=B0=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D1=80=D0=BE=D0=B4=D0=BE=D0=BB=D0=B6=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=B8?= =?UTF-8?q?=20=D0=BD=D0=B0=D1=87=D0=B0=D0=BB=D0=BE,=20=D0=B1=D0=B5=D0=B7?= =?UTF-8?q?=20=D0=B2=D0=BE=D0=B7=D0=BD=D0=B8=20=D1=81=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=B4=D0=BD=D0=B8=D0=BA=D0=B0=D0=BC=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D0=B2=D1=8B=D1=85=D0=BE=D0=B4=D0=BD=D1=8B=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/webparts/requestPortal/forms/Vacation/sendToSign.ts | 3 +-- .../requestPortal/forms/Vacation/vacationDocument.test.ts | 3 +-- .../requestPortal/forms/Vacation/vacationDocument.ts | 5 ++--- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/webparts/requestPortal/forms/Vacation/sendToSign.ts b/src/webparts/requestPortal/forms/Vacation/sendToSign.ts index 7fca9c3..325bf8b 100644 --- a/src/webparts/requestPortal/forms/Vacation/sendToSign.ts +++ b/src/webparts/requestPortal/forms/Vacation/sendToSign.ts @@ -2,7 +2,7 @@ import { WebPartContext } from '@microsoft/sp-webpart-base'; import { getCompanies, getEmployeeOrganizations } from '../../shared/dictionary'; import { submitSigningRequest } from '../../shared/edo/edo.service'; import { ISigningCompany, ISkippedCompany, resolveSigningTargets } from '../../shared/edo/signingTarget'; -import { computeEndDate, formatRuDate } from './dates'; +import { formatRuDate } from './dates'; import { IFormData } from './model'; import { createVacationDocx, vacationFileName } from './vacationDocument'; @@ -96,7 +96,6 @@ export async function sendVacationForSigning( position: company.position || data.Position, employeeName, startDate: formatRuDate(data.StartDate), - endDate: formatRuDate(computeEndDate(data.StartDate, days)), daysCount: days, substituteName: substitute, requestCode: vacation.code || String(vacation.itemId), diff --git a/src/webparts/requestPortal/forms/Vacation/vacationDocument.test.ts b/src/webparts/requestPortal/forms/Vacation/vacationDocument.test.ts index 6b86ed3..5551581 100644 --- a/src/webparts/requestPortal/forms/Vacation/vacationDocument.test.ts +++ b/src/webparts/requestPortal/forms/Vacation/vacationDocument.test.ts @@ -13,7 +13,6 @@ const INPUT: IVacationDocumentInput = { position: 'Инженер', employeeName: 'Иванов Иван Иванович', startDate: '01.12.2026', - endDate: '14.12.2026', daysCount: 14, substituteName: 'Петров Пётр Петрович', requestCode: 'HR-0042', @@ -56,7 +55,7 @@ describe('заявление на отпуск', () => { 'Инженер', 'Иванов Иван Иванович', 'ЗАЯВЛЕНИЕ', - '14 календарных дней с 01.12.2026 по 14.12.2026', + '14 календарных дней, начиная с 01.12.2026', 'Петров Пётр Петрович', 'HR-0042', '30.09.2026' diff --git a/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts b/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts index dca7833..c2c452d 100644 --- a/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts +++ b/src/webparts/requestPortal/forms/Vacation/vacationDocument.ts @@ -10,8 +10,7 @@ export interface IVacationDocumentInput { employeeName: string; /** Начало отпуска, ISO `YYYY-MM-DD`. */ startDate: string; - /** Конец отпуска, ISO `YYYY-MM-DD`. */ - endDate: string; + /** Сколько календарных дней длится отпуск. Конца нет: праздники и выходные считает кадровая служба. */ daysCount: number; /** Ф.И.О. замещающего; пусто — абзац о замещении не выводится. */ substituteName: string; @@ -71,7 +70,7 @@ export function buildVacationParagraphs(input: IVacationDocumentInput): IParagra { text: `Прошу предоставить мне ежегодный оплачиваемый трудовой отпуск продолжительностью ${input.daysCount} ` + - `календарных дней с ${input.startDate} по ${input.endDate} включительно.`, + `календарных дней, начиная с ${input.startDate}.`, align: 'both', firstLine: 709, after: 240 -- 2.34.1 From 74de2b441e696b0f10dc3c5d3bfa8d02b29d38c8 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Mon, 5 Oct 2026 11:49:37 +0500 Subject: [PATCH 20/24] =?UTF-8?q?=D0=9E=D1=82=D0=BF=D1=83=D1=81=D0=BA=20?= =?UTF-8?q?=D0=B8=20=D0=BE=D1=82=D0=BF=D1=83=D1=81=D0=BA=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D1=81=D0=BE=D0=B4=D0=B5=D1=80=D0=B6=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F:=20=D0=B1=D0=B5=D0=B7=20=D0=B4=D0=B0=D1=82=D1=8B=20?= =?UTF-8?q?=D0=BE=D0=BA=D0=BE=D0=BD=D1=87=D0=B0=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=E2=80=94=20=D0=BD=D0=B0=D1=87=D0=B0=D0=BB=D0=BE=20=D0=B8=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB=D0=B8=D1=87=D0=B5=D1=81=D1=82=D0=B2=D0=BE=20?= =?UTF-8?q?=D0=B4=D0=BD=D0=B5=D0=B9;=20EndDate=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B8=D0=B7=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B8?= =?UTF-8?q?=D0=B6=D0=B8=D0=BD=D0=B8=D0=BD=D0=B3=D0=B0,=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0=20=D0=B8=20SharePoint=20(=D0=B8=D0=B7=20=D1=82?= =?UTF-8?q?=D0=B8=D0=BF=D0=B0=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=20=D0=B8=20=D0=BF=D1=80=D0=B5=D0=B4=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../requestPortal/forms/Clearance/README.md | 2 +- .../requestPortal/forms/UnpaidLeave/README.md | 4 ++-- .../forms/UnpaidLeave/UnpaidLeaveForm.tsx | 14 ++++++++------ .../requestPortal/forms/UnpaidLeave/load.ts | 6 +++--- .../requestPortal/forms/UnpaidLeave/model.ts | 10 +++++----- .../forms/UnpaidLeave/provisioning.json | 1 - .../forms/UnpaidLeave/sections/RequestSection.tsx | 4 ++-- .../requestPortal/forms/UnpaidLeave/submit.ts | 15 +++------------ .../requestPortal/forms/Vacation/README.md | 4 ++-- .../requestPortal/forms/Vacation/dates.ts | 7 ------- .../requestPortal/forms/Vacation/model.ts | 2 +- .../forms/Vacation/provisioning.json | 1 - .../requestPortal/forms/Vacation/submit.ts | 4 +--- .../forms/Vacation/vacationDocument.test.ts | 12 +----------- 14 files changed, 29 insertions(+), 57 deletions(-) diff --git a/src/webparts/requestPortal/forms/Clearance/README.md b/src/webparts/requestPortal/forms/Clearance/README.md index 048fa68..646084b 100644 --- a/src/webparts/requestPortal/forms/Clearance/README.md +++ b/src/webparts/requestPortal/forms/Clearance/README.md @@ -36,7 +36,7 @@ RequestTypeId» в `docs/forms.md`). Тип уже зарегистрирова - **Сумма обучения** — `MoneyInput` (разделитель тысяч в UI, в списке — «сырое» число), колонка `TrainingAmount` (Number, 2 знака), поле необязательное. - **Дата расторжения договора** — обычный `input type=date`, без вычислений - (в отличие от `EndDate` в «Заявке на отпуск», которая считается). + (в отличие от даты окончания отпуска, которой нет совсем). - **Заголовок элемента** (`Title`): `Обходной лист — {ФИО} — дд.мм.гггг`. ## Связь с заявлением на увольнение diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/README.md b/src/webparts/requestPortal/forms/UnpaidLeave/README.md index 277c2cb..f3ad67f 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/README.md +++ b/src/webparts/requestPortal/forms/UnpaidLeave/README.md @@ -25,11 +25,11 @@ - **Заголовок элемента** (`Title`): `Отпуск без содержания — {ФИО} — дд.мм.гггг`. ## Отличия от «Заявка на отпуск» (hr-vacation) -- Даты с/по вместо «дата начала + количество дней». +- Те же «дата начала + количество дней»: даты окончания нет, праздники и выходные не считаются. - Добавлены поля «В связи с» (`Reason`) и «Лицо замещающее» (`Substitute`). ## Секции формы -RequestSection (сотрудник · должность · дата начала · дата окончания · основание · замещающий) +RequestSection (сотрудник · должность · дата начала · количество дней · основание · замещающий) ## Точки входа - rptype: `hr-unpaid-leave` diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx index c737fab..ca38166 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx +++ b/src/webparts/requestPortal/forms/UnpaidLeave/UnpaidLeaveForm.tsx @@ -183,8 +183,9 @@ const UnpaidLeaveForm: React.FC = ({ editId }); - // Даты корректны, если конец не раньше начала (обе обязательны). - const datesValid = !!(data.StartDate && data.EndDate && data.EndDate >= data.StartDate); + // Продолжительность: целое положительное число календарных дней. + const daysNum = Number(data.DaysCount); + const daysValid = Number.isFinite(daysNum) && daysNum > 0; // Заявку нужно подавать не позднее чем за MIN_NOTICE_DAYS дней до даты начала // отпуска — календарь (min у input type=date, см. RequestSection) не даёт // выбрать более раннюю дату, но проверяем и здесь на случай обхода UI. @@ -193,7 +194,8 @@ const UnpaidLeaveForm: React.FC = ({ const isValid = !!( data.Employee && data.Position.trim() && - datesValid && + data.StartDate && + daysValid && isStartDateValid && data.Reason.trim() ); @@ -201,10 +203,10 @@ const UnpaidLeaveForm: React.FC = ({ if (data.StartDate && !isStartDateValid) { return `Заявку на отпуск нужно подать не позднее чем за ${MIN_NOTICE_DAYS} дней до даты начала — минимально доступная дата: ${formatIsoDate(minStartDate)}.`; } - if (!data.Employee || !data.Position.trim() || !data.StartDate || !data.EndDate || !data.Reason.trim()) { - return 'Заполните обязательные поля: сотрудник, должность, дата начала, дата окончания, основание.'; + if (!data.Employee || !data.Position.trim() || !data.StartDate || !data.Reason.trim()) { + return 'Заполните обязательные поля: сотрудник, должность, дата начала, основание.'; } - if (!datesValid) return 'Дата окончания не может быть раньше даты начала.'; + if (!daysValid) return 'Количество дней должно быть больше нуля.'; if (requestTypeIdState.error) return `Не удалось определить тип заявки: ${requestTypeIdState.error}`; if (!requestTypeId) return 'Тип заявки ещё определяется — подождите немного и повторите.'; return undefined; diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/load.ts b/src/webparts/requestPortal/forms/UnpaidLeave/load.ts index 25ace41..22f33dc 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/load.ts +++ b/src/webparts/requestPortal/forms/UnpaidLeave/load.ts @@ -27,7 +27,7 @@ export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): P 'Id', 'Position', 'StartDate', - 'EndDate', + 'DaysCount', 'Reason', 'Organization', 'Department', @@ -45,14 +45,14 @@ export async function loadUnpaidLeaveRequest(itemId: number, listId?: string): P const employee = item.Employee as { Title?: string; EMail?: string } | undefined; const substitute = item.Substitute as { EMail?: string } | undefined; const start = (item.StartDate as string) || ''; - const end = (item.EndDate as string) || ''; + const days = item.DaysCount; const data: IFormData = { ...EMPTY_DATA, Employee: employee?.EMail || '', Position: (item.Position as string) || '', StartDate: start.slice(0, 10), // ISO → YYYY-MM-DD для input type=date - EndDate: end.slice(0, 10), + DaysCount: days === null || days === undefined ? '' : String(days), Reason: (item.Reason as string) || '', Substitute: substitute?.EMail || '', Organization: (item.Organization as string) || '', diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/model.ts b/src/webparts/requestPortal/forms/UnpaidLeave/model.ts index 4259f10..b70bd49 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/model.ts +++ b/src/webparts/requestPortal/forms/UnpaidLeave/model.ts @@ -12,8 +12,8 @@ export const FORM_ID: RequestTypeId = 'hr-unpaid-leave'; * по возможности = internalName колонок списка данных (см. shared/listTitles.ts). * Employee/Substitute хранят e-mail выбранного сотрудника (то, что отдаёт * PeopleField); по Employee резолвится маршрут согласования, поэтому заявку можно - * подать и за другого сотрудника. В отличие от «Заявки на отпуск» здесь обе даты - * (с/по) вводятся явно, а DaysCount считается при сохранении. + * подать и за другого сотрудника. Как и в «Заявке на отпуск», даты окончания нет: + * отпуск задаётся началом и количеством дней, чтобы не считать праздники и выходные. */ export interface IFormData { /** E-mail сотрудника, за которого заявка (People). Пусто = не выбран. */ @@ -21,8 +21,8 @@ export interface IFormData { Position: string; /** ISO YYYY-MM-DD (input type=date). */ StartDate: string; - /** ISO YYYY-MM-DD (input type=date). */ - EndDate: string; + /** Количество календарных дней (строка из input type=number). */ + DaysCount: string; /** Основание «В связи с …». */ Reason: string; /** E-mail замещающего на время отсутствия (People). Пусто = не выбран. */ @@ -68,7 +68,7 @@ export const EMPTY_DATA: IFormData = { Employee: '', Position: '', StartDate: '', - EndDate: '', + DaysCount: '', Reason: '', Substitute: '', Organization: '', diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/provisioning.json b/src/webparts/requestPortal/forms/UnpaidLeave/provisioning.json index 8c994ba..c53f544 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/provisioning.json +++ b/src/webparts/requestPortal/forms/UnpaidLeave/provisioning.json @@ -7,7 +7,6 @@ { "internalName": "Employee", "title": "Сотрудник", "type": "User", "userSelectionMode": "PeopleOnly", "required": true }, { "internalName": "Position", "title": "Должность", "type": "Text" }, { "internalName": "StartDate", "title": "Дата начала", "type": "DateTime", "dateOnly": true }, - { "internalName": "EndDate", "title": "Дата окончания", "type": "DateTime", "dateOnly": true }, { "internalName": "DaysCount", "title": "Количество дней", "type": "Number" }, { "internalName": "Reason", "title": "Основание (в связи с)", "type": "Note" }, { "internalName": "Substitute", "title": "Лицо, замещающее на время отсутствия", "type": "User", "userSelectionMode": "PeopleOnly" }, diff --git a/src/webparts/requestPortal/forms/UnpaidLeave/sections/RequestSection.tsx b/src/webparts/requestPortal/forms/UnpaidLeave/sections/RequestSection.tsx index bf35601..ece13c1 100644 --- a/src/webparts/requestPortal/forms/UnpaidLeave/sections/RequestSection.tsx +++ b/src/webparts/requestPortal/forms/UnpaidLeave/sections/RequestSection.tsx @@ -51,8 +51,8 @@ export const RequestSection: React.FC = ({ data, set, context, on min={isoDatePlusDays(MIN_NOTICE_DAYS)} /> - - set('EndDate', v)} /> + + set('DaysCount', v)} />