5a. Кадры и расчёт зарплат
Что это
Модуль кадров управляет сотрудниками, должностями, явками (табель) и ставками оплаты. Ключевой расчёт — заработок по явке на основе ставки.
Ставки оплаты (
PaymentRate) — историзированная сущность (см. ниже «Два типа ставок»). Отдельного публичного CRUD-API надPaymentRateнет: ставки задаются и закрываются исключительно в составе операций над карточкой сотрудника (POST/PATCH /staffing/employees, увольнение). См. PRD «Ставки оплаты» и LOCALIOFFICE-867.
Сотрудник и юрлицо (гибридная модель)
Один сотрудник в корпорации может работать сразу от нескольких юрлиц (совместительство). Связь Employee ↔ LegalEntity моделируется по гибридной схеме:
Employee.primaryLegalEntityId— primary юрлицо сотрудника, используется в отчётности (Т-13/Т-51, кассовые и платёжные документы по умолчанию). Поле обязательное.EmployeePosition.legalEntityId— юрлицо, от которого сотрудник работает на этой должности. У одного сотрудника разные должности могут иметь разныеlegalEntityId(совместительство).EmployeePosition.isPrimary— флаг primary-позиции. Инвариант: ровно одна активная позиция (unassignedAt IS NULL) сisPrimary = true; еёlegalEntityIdсовпадает сEmployee.primaryLegalEntityId.
Смена primary-позиции — только через явный эндпоинт POST /employees/:id/positions/:positionId/set-primary, который атомарно переносит isPrimary и обновляет Employee.primaryLegalEntityId.
Историчность — через assignedAt / unassignedAt на EmployeePosition и EmployeeSubdivision. Удаление связи — soft-закрытие (unassignedAt = now()), не физический DELETE.
Структура
КОРПОРАЦИЯ «Бергамот»│ ЮЛ-1 «ООО Бергамот» (primary)│ ЮЛ-2 «ИП Иванов»│├── Должность: Повар│ └── Сотрудник: Иванов А.С.│ ├── EmployeePosition (legalEntityId = ЮЛ-1, isPrimary)│ ├── Ставка: 300 ₽/час (почасовая)│ └── Явки: табель рабочих дней│├── Должность: Бармен (совместительство)│ └── Сотрудник: Иванов А.С.│ └── EmployeePosition (legalEntityId = ЮЛ-2, isPrimary=false)│└── Должность: Управляющий └── Сотрудник: Сидорова И.М. ├── EmployeePosition (legalEntityId = ЮЛ-1, isPrimary) ├── Ставка: 80 000 ₽/мес (оклад) └── Явки: табельEmployee.primaryLegalEntityId указывает на ЮЛ-1 у Иванова — это и юрлицо primary-позиции (Повар).
Два типа ставок
1. Почасовая (HOURLY)
Сотрудник получает фиксированную сумму за каждый час работы.
Повар Иванов: Ставка: 300 ₽/час Явка: 08:00 — 20:00 (12 часов) Заработок = 12 × 300 = 3 600 ₽2. Оклад (SALARY)
Сотрудник получает фиксированную сумму в месяц. Заработок за день рассчитывается пропорционально рабочим часам месяца.
Бармен Петров: Оклад: 45 000 ₽/мес Рабочих часов в апреле: 22 дня × 8 ч = 176 часов Ставка в час: 45 000 / 176 = 255.68 ₽/час
Явка: 10:00 — 22:00 (12 часов) Заработок = 255.68 × 12 = 3 068.18 ₽Закрытие ставок: причина (closedReason)
При закрытии (endDate IS NOT NULL) ставка получает маркер причины — PaymentRate.closedReason. Открытая ставка имеет closedReason = NULL (CHECK на уровне БД). Возможные значения:
| Значение | Когда проставляется | Где |
|---|---|---|
CASCADE_DISMISSAL | Массовое закрытие при увольнении сотрудника либо каскад при выставлении/увеличении dismissalDate через BFF PUT /staffing/employees/:id. | EmployeeService.dismiss, EmployeeService.applyDesiredState |
RATE_REPLACED | Auto-close предыдущей открытой ставки той же должности при появлении новой (endDate = startDate − 1). | PaymentRateService.applyOneRate |
TRANSFER | Закрытие активной ставки старой должности при переводе сотрудника. | EmployeeService.transfer (closeOldRate = true) |
MANUAL | Не проставляется кодом: только бэкфилл миграции LOCALIOFFICE-913 для исторически закрытых ставок и резерв на будущее (ручные операторские правки). | бэкфилл миграции |
Каскад dismissalDate через BFF PUT /staffing/employees/:id
Сравнивается dto.dismissalDate с текущим Employee.dismissalDate:
- Выставляется (
null → D) / увеличивается (D₁ → D₂ > D₁) —closeAllOpenByEmployeeIdзакрывает открытые и будущие ставки сendDate = DиclosedReason = CASCADE_DISMISSAL. AuditPAYMENT_RATE_CLOSED(reason = CASCADE_DISMISSAL). - Уменьшается (
D₁ → D₂ < D₁) —recalculateCascadeDismissalEndDateобновляетendDate = D₂тем ставкам, у которыхclosedReason = CASCADE_DISMISSALиendDate = D₁. Ставки сRATE_REPLACED,TRANSFER,MANUALне затрагиваются. AuditPAYMENT_RATE_END_DATE_RECALCULATED({ rateIds, oldEndDate, newEndDate }). - Сбрасывается в
null(D → null) — открытые ставки автоматически НЕ восстанавливаются. Возврат сотрудника в строй — только через явныйPOST /staffing/employees/:id/rehire.
Расчёт рабочих часов месяца
Система считает количество рабочих дней в месяце (понедельник — пятница) и умножает на 8 часов.
Апрель 2026:
Пн Вт Ср Чт Пт Сб Вс 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
Рабочих дней: 22 (все Пн-Пт) Рабочих часов: 22 × 8 = 176 часовЯвки (табель)
Жизненный цикл явки
┌──────────┐ закрытие ┌──────────┐│ ОТКРЫТА │──────────────►│ ЗАКРЫТА ││ (OPEN) │ │ (CLOSED) │└────┬─────┘ └──────────┘ │ │ через 24 часа автоматически ▼┌──────────────┐│ ПРОСРОЧЕНА ││ (OVERDUE) │└──────────────┘Пример явки
┌─────────────────────────────────────────────────────────────────┐│ ЯВКА: Иванов А.С. (повар) │├─────────────────────────────────────────────────────────────────┤│ Дата: 16.04.2026 ││ Подразделение: Кухня «Горячий цех» ││ Приход: 08:00 ││ Уход: 20:00 ││ Длительность: 720 минут = 12 часов │├─────────────────────────────────────────────────────────────────┤│ Ставка: 300 ₽/час (почасовая) ││ Заработок: 12 × 300 = 3 600.00 ₽ │├─────────────────────────────────────────────────────────────────┤│ Статус: ЗАКРЫТА │└─────────────────────────────────────────────────────────────────┘Формулы расчёта заработка
Почасовая ставка
длительность_часов = (время_ухода − время_прихода) / 60заработок = длительность_часов × ставка_в_час
Точность: 2 знака после запятой, округление ROUND_HALF_UPОклад
рабочих_часов_в_месяце = рабочие_дни_месяца × 8ставка_в_час = оклад / рабочих_часов_в_месяцедлительность_часов = (время_ухода − время_прихода) / 60заработок = ставка_в_час × длительность_часов
Точность: 2 знака после запятой, округление ROUND_HALF_UPПример: окладник в короткий месяц
Февраль 2026: 20 рабочих дней × 8 = 160 часовОклад: 45 000 ₽
Ставка в час: 45 000 / 160 = 281.25 ₽/часЯвка: 10:00 — 20:00 (10 часов)Заработок: 281.25 × 10 = 2 812.50 ₽
Март 2026: 22 рабочих дня × 8 = 176 часовТа же явка 10 часов:Ставка в час: 45 000 / 176 = 255.68 ₽/часЗаработок: 255.68 × 10 = 2 556.82 ₽
→ В коротком месяце тот же час стоит дорожеПросроченные явки
Если длительность явки превысила Position.allowedOvertimeMinutes (а при значении 0 — fallback в 24 часа), система автоматически (cron, каждый час):
- Переводит статус в
OVERDUE(просрочена) - Устанавливает
confirmedDepartureTime = confirmedArrivalTime + threshold(но не позднее текущего момента и не позднее конца дня увольнения, если сотрудник уволен) - Рассчитывает заработок по этой длительности
- Пишет в audit-лог
AUTO_CLOSED_ON_OVERDUEс диагностикой{ reason, allowedOvertimeMinutes, thresholdMinutes, confirmedDepartureTime }
При ручном закрытии из офиса (POST /staffing/attendances/:id/close) превышение allowedOvertimeMinutes НЕ блокирует операцию — переработка фиксируется сознательно. В response добавляется warnings: [{ code: "OVERTIME_LIMIT_EXCEEDED", exceededByMinutes }], в audit пишется MANUAL_CLOSE_WITH_OVERTIME.
Обратный канал из кассы (контракт для LOCALIOFFICE-770)
Касса присылает в офис события core.company.kassa.attendance.opened и core.company.kassa.attendance.closed. Расширенный payload attendance.opened содержит флаг openedViaProxyCard: boolean — true означает, что явка открыта по карте/PIN другого сотрудника.
Офисный handler (LOCALIOFFICE-770) обязан проверить:
-
Proxy-card. При
openedViaProxyCard = true— у сотрудника на дату прихода должна быть активнаяEmployeePosition, у позиции которойPosition.canUseProxyCard = true. При нарушении явка создаётся со статусомPENDING_PROXY_CARD_APPROVAL, auditPROXY_CARD_VIOLATION,source = KASSA. -
Overtime. Если
durationInMinutes > Position.allowedOvertimeMinutes(иallowedOvertimeMinutes > 0) — статус →PENDING_OVERTIME_APPROVAL, auditKASSA_CLOSE_WITH_OVERTIME,source = KASSA. Офис подтверждает или отклоняет черезPATCH /attendances/:id. -
Версионная рассинхронизация. Касса работает на кешированных
Position/PaymentRateи могла принять решение по устаревшей версии. Поэтому офис никогда не отклоняет событие, а сохраняет факт работы и помечаетPENDING_*для ручной проверки.
Контракт зафиксирован в src/staffing/domain/events/contracts/kassa-attendance-events.contract.ts.
Аудит явок
Все операции с явками логируются:
┌───────────┬──────────┬─────────────────────────────────────┐│ Дата/время│ Действие │ Детали │├───────────┼──────────┼─────────────────────────────────────┤│ 08:00 │ Открытие │ Иванов А.С., подразделение «Кухня» ││ 20:00 │ Закрытие │ Уход 20:00, заработок 3 600.00 ₽ ││ 20:05 │ Изменение│ Управляющий скорректировал уход на ││ │ │ 19:45, заработок 3 562.50 ₽ │└───────────┴──────────┴─────────────────────────────────────┘Бизнес-правила
| Правило | Описание |
|---|---|
| Ставка > 0 | Нулевая и отрицательная ставка запрещены |
| Дата окончания > даты начала | Для ставок с ограниченным сроком |
| Соответствие ставки графику | Почасовая — для сменного графика, оклад — для постоянного |
| Одна открытая явка на сотрудника | Нельзя открыть вторую, пока первая не закрыта |
| Время ухода > времени прихода | Явка не может быть отрицательной |
Автозакрытие по allowedOvertimeMinutes | Защита от забытых явок (fallback 24 ч при значении 0) |
Запрет смены workScheduleType | Если на должности есть PaymentRate или Attendance — смена графика заблокирована |
advancePayment только для SALARY | Для HOURLY/SCHEDULED — обязан быть null; для SALARY — <= salary |
| Архивирование должности | Запрещено при наличии активной EmployeePosition без unassignedAt |
canUseProxyCard | Передаётся на кассу через outbox; на стороне офиса используется для валидации обратных событий |
noConfirmationForOffScheduleWork | При открытии явки на должности SCHEDULED без ScheduleEntry, перекрывающего время прихода: true — статус OPEN + audit OFF_SCHEDULE_AUTO_APPROVED; false — статус PENDING_OFF_SCHEDULE_APPROVAL (LOCALIOFFICE-865) |
Расписание смен (Schedule)
ScheduleEntry — плановая смена сотрудника на конкретной должности в подразделении (LOCALIOFFICE-865). Управляющий заранее формирует «понедельную сетку» через CRUD /staffing/schedule-entries (создание поштучно или массовое POST /staffing/schedule-entries/bulk).
Поведение при открытии явки для должности workScheduleType = SCHEDULED:
- Сервис ищет
ScheduleEntryсотрудника, перекрывающийconfirmedArrivalTime. - Найден → явка открывается штатно (
OPEN). - Не найден:
Position.noConfirmationForOffScheduleWork = true→OPEN+ auditOFF_SCHEDULE_AUTO_APPROVED+ warningOFF_SCHEDULE_AUTO_APPROVED.Position.noConfirmationForOffScheduleWork = false→PENDING_OFF_SCHEDULE_APPROVAL+ auditOFF_SCHEDULE_PENDING.
Подтверждение / отклонение off-schedule явки — через PATCH /attendances/:id { offScheduleApproval: "APPROVE" | "REJECT" }. Поле взаимоисключающее с остальными полями PATCH (правка времени/типа делается отдельным запросом после APPROVE).
APPROVE→ статус становитсяOPENилиCLOSED(еслиconfirmedDepartureTimeуже выставлен), auditOFF_SCHEDULE_APPROVED.REJECT→isDeleted = true,earnedAmount = 0, auditOFF_SCHEDULE_REJECTED.
Авторизация (MVP): CRUD расписаний и подтверждение off-schedule явок доступны любому пользователю корпорации (через CorporationGuard); ролевое разделение прав отложено до появления модели ролей.
Ограничения агрегата ScheduleEntryAggregate:
endAt > startAt;- длительность смены ≤ 24 часов;
- запрет пересечений с другими (не удалёнными) сменами того же сотрудника.
Синхронизация с Кассой: ScheduleEntry пишется в outbox с entityType = schedule_entry; касса применяет ту же логику локально.