Перейти к содержимому

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_REPLACEDAuto-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. Audit PAYMENT_RATE_CLOSED (reason = CASCADE_DISMISSAL).
  • Уменьшается (D₁ → D₂ < D₁)recalculateCascadeDismissalEndDate обновляет endDate = D₂ тем ставкам, у которых closedReason = CASCADE_DISMISSAL и endDate = D₁. Ставки с RATE_REPLACED, TRANSFER, MANUAL не затрагиваются. Audit PAYMENT_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, каждый час):

  1. Переводит статус в OVERDUE (просрочена)
  2. Устанавливает confirmedDepartureTime = confirmedArrivalTime + threshold (но не позднее текущего момента и не позднее конца дня увольнения, если сотрудник уволен)
  3. Рассчитывает заработок по этой длительности
  4. Пишет в 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: booleantrue означает, что явка открыта по карте/PIN другого сотрудника.

Офисный handler (LOCALIOFFICE-770) обязан проверить:

  1. Proxy-card. При openedViaProxyCard = true — у сотрудника на дату прихода должна быть активная EmployeePosition, у позиции которой Position.canUseProxyCard = true. При нарушении явка создаётся со статусом PENDING_PROXY_CARD_APPROVAL, audit PROXY_CARD_VIOLATION, source = KASSA.

  2. Overtime. Если durationInMinutes > Position.allowedOvertimeMinutesallowedOvertimeMinutes > 0) — статус → PENDING_OVERTIME_APPROVAL, audit KASSA_CLOSE_WITH_OVERTIME, source = KASSA. Офис подтверждает или отклоняет через PATCH /attendances/:id.

  3. Версионная рассинхронизация. Касса работает на кешированных 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:

  1. Сервис ищет ScheduleEntry сотрудника, перекрывающий confirmedArrivalTime.
  2. Найден → явка открывается штатно (OPEN).
  3. Не найден:
    • Position.noConfirmationForOffScheduleWork = trueOPEN + audit OFF_SCHEDULE_AUTO_APPROVED + warning OFF_SCHEDULE_AUTO_APPROVED.
    • Position.noConfirmationForOffScheduleWork = falsePENDING_OFF_SCHEDULE_APPROVAL + audit OFF_SCHEDULE_PENDING.

Подтверждение / отклонение off-schedule явки — через PATCH /attendances/:id { offScheduleApproval: "APPROVE" | "REJECT" }. Поле взаимоисключающее с остальными полями PATCH (правка времени/типа делается отдельным запросом после APPROVE).

  • APPROVE → статус становится OPEN или CLOSED (если confirmedDepartureTime уже выставлен), audit OFF_SCHEDULE_APPROVED.
  • REJECTisDeleted = true, earnedAmount = 0, audit OFF_SCHEDULE_REJECTED.

Авторизация (MVP): CRUD расписаний и подтверждение off-schedule явок доступны любому пользователю корпорации (через CorporationGuard); ролевое разделение прав отложено до появления модели ролей.

Ограничения агрегата ScheduleEntryAggregate:

  • endAt > startAt;
  • длительность смены ≤ 24 часов;
  • запрет пересечений с другими (не удалёнными) сменами того же сотрудника.

Синхронизация с Кассой: ScheduleEntry пишется в outbox с entityType = schedule_entry; касса применяет ту же логику локально.