31a0a8e9e3
Напоминания: - ProbationEndingReminderDto: ManagerEmail убран — это был унаследованный ReceiverEmail под вторым именем. - Ссылка для ProbationEnding больше не ведёт на саму заявку на приём (она уже закрыта, решать по ней нечего) — берётся из новой настройки RemindersOptions.ProbationReminderUrl и ведёт в раздел, где заводят новую заявку. Подстановок в ней нет: адрес конкретной формы в портал не передать, rptype без rpid/rpsource игнорируется (см. parseRoute в request-portal). Заодно ушли BuildProbationUrl и ненужные больше параметры type/listId. Стайлгайд (CLAUDE.md) — у enum первое значение None: - ReminderFeed: None=0 трактуется как ApprovalOverdue, чтобы вызов без явного выбора работал как раньше. - EmploymentStatus: заодно инвертирован switch в ReminderService — раньше «продолжить» было веткой по умолчанию, и новый None молча означал бы «сотрудник работает». Теперь по умолчанию пропуск с предупреждением. - ConditionsLogic: None — НЕвалидное значение, отвергается валидаторами шага (в том числе новым PatchTemplateRouteStepRequestValidator, его не было вовсе). Поэтому у ConditionsLogic в Create/Update-запросах убрано значение по умолчанию: System.Text.Json в .NET 10 уважает дефолты параметров конструктора, и пропущенное поле молча означало «И». Теперь пропущенное поле даёт None и упирается в валидатор. ЛОМАЮЩЕЕ ИЗМЕНЕНИЕ КОНТРАКТА: клиент, не присылающий conditionsLogic в POST/PUT шага, получит 400 вместо прежнего молчаливого «И». Админка не затронута — она это поле всегда отправляет.
96 lines
5.7 KiB
C#
96 lines
5.7 KiB
C#
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||
|
||
namespace Tnsu.ApprovalRoute.Application.Reminders;
|
||
|
||
internal enum EmploymentStatus
|
||
{
|
||
/// <summary>
|
||
/// Статус не определяли. EmploymentOf такого не возвращает — значение существует
|
||
/// только как значение по умолчанию (default(EmploymentStatus)); трактуется
|
||
/// наравне с <see cref="NotFound"/>, то есть «трудоустройство не подтверждено».
|
||
/// </summary>
|
||
None = 0,
|
||
|
||
/// <summary>Сотрудника с таким ФИО в справочнике нет вовсе — трудоустройство не подтверждено.</summary>
|
||
NotFound = 1,
|
||
|
||
/// <summary>Работает хотя бы в одной компании.</summary>
|
||
Employed = 2,
|
||
|
||
/// <summary>Есть в справочнике, но уволен из всех компаний, где числился.</summary>
|
||
Dismissed = 3,
|
||
}
|
||
|
||
/// <summary>
|
||
/// Оба справочника api.tnsu.kz разом, разобранные по ФИО: дата увольнения есть
|
||
/// только в /Employee/FullBase, а email — только в /Employee/All, поэтому нужны
|
||
/// оба. Ищем ПО ФИО, потому что точечного поиска по нему у api.tnsu.kz нет
|
||
/// (эндпоинта /Employee/ByFio не существует), а в заявке на приём кандидат
|
||
/// записан именно ФИО (колонка CandidateName) — временное решение, см.
|
||
/// <see cref="ITnsuPortalClient.GetAllEmployeesAsync"/>.
|
||
///
|
||
/// ФИО не уникально: один человек может числиться в нескольких компаниях (тогда
|
||
/// это несколько записей одного и того же человека), но с тем же успехом могут
|
||
/// совпасть ФИО двух разных людей — различить эти два случая по одному ФИО нельзя,
|
||
/// поэтому все совпадения трактуются как записи одного человека.
|
||
/// </summary>
|
||
internal sealed class EmployeeDirectorySnapshot
|
||
{
|
||
private readonly ILookup<string, EmployeeFullBaseInfo> _fullBaseByFio;
|
||
private readonly ILookup<string, EmployeeContactInfo> _contactsByFio;
|
||
private readonly IReadOnlyDictionary<string, string> _fioByEmail;
|
||
|
||
public EmployeeDirectorySnapshot(
|
||
IReadOnlyList<EmployeeFullBaseInfo> fullBase, IReadOnlyList<EmployeeContactInfo> contacts)
|
||
{
|
||
_fullBaseByFio = fullBase.ToLookup(e => Normalize(e.Fio));
|
||
_contactsByFio = contacts.ToLookup(e => Normalize(e.Fio));
|
||
|
||
// Обратный индекс email → ФИО — нужен, чтобы подписать напоминание именем
|
||
// получателя, когда известен только его email (руководитель кандидата,
|
||
// см. GetManagerAsync). При дублях email в справочнике побеждает первая
|
||
// запись — этого достаточно для отображения имени, не для идентификации.
|
||
_fioByEmail = contacts
|
||
.Where(e => !string.IsNullOrWhiteSpace(e.Email))
|
||
.GroupBy(e => e.Email!.Trim().ToLowerInvariant())
|
||
.ToDictionary(g => g.Key, g => g.First().Fio);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Уволенным считаем только того, у кого дата увольнения стоит во ВСЕХ его
|
||
/// записях: сотрудник, уволенный из одной компании холдинга, но оставшийся в
|
||
/// другой, продолжает работать — и напоминание ему по-прежнему нужно.
|
||
/// </summary>
|
||
public EmploymentStatus EmploymentOf(string fio)
|
||
{
|
||
var entries = _fullBaseByFio[Normalize(fio)].ToList();
|
||
if (entries.Count == 0) return EmploymentStatus.NotFound;
|
||
|
||
return entries.All(e => e.DismissalDate is not null)
|
||
? EmploymentStatus.Dismissed
|
||
: EmploymentStatus.Employed;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Первый непустой email среди записей этого ФИО. У одного человека записей
|
||
/// несколько (по компании на каждую), а email у него один и тот же — но
|
||
/// заполнен он может быть не в каждой записи.
|
||
/// </summary>
|
||
public string? EmailOf(string fio) =>
|
||
_contactsByFio[Normalize(fio)]
|
||
.Select(e => e.Email)
|
||
.FirstOrDefault(email => !string.IsNullOrWhiteSpace(email));
|
||
|
||
/// <summary>ФИО по email — для ReceiverName, когда известен только email получателя (см. EmailOf).</summary>
|
||
public string? NameByEmail(string email) =>
|
||
_fioByEmail.GetValueOrDefault(email.Trim().ToLowerInvariant());
|
||
|
||
/// <summary>
|
||
/// В справочнике и в заявке ФИО набирают руками, поэтому сравниваем без учёта
|
||
/// регистра и лишних пробелов между словами (в живых данных встречаются и
|
||
/// двойные пробелы, и неразрывный пробел — см. поле department в /Employee/All).
|
||
/// </summary>
|
||
private static string Normalize(string fio) =>
|
||
string.Join(' ', fio.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).ToLowerInvariant();
|
||
}
|