Skip to content

23. Аудит: расчёт и начисление KPI на backend

Date: 2026-03-10

Status

Accepted (документация текущего состояния)

Context

Требуется провести полный аудит того, как работает расчёт и начисление KPI на backend, и зафиксировать архитектуру в виде документированного ADR для дальнейшей поддержки и развития системы.

Модели данных

KPICatalog

Справочник типов KPI.

ПолеОписание
name, descriptionНазвание и описание
metric_typepercentage | absolute | boolean
min_value, max_valueПороги (для percentage — целевые значения)
function_nameИмя функции проверки из kpi_check_functions.py
calculation_timingrealtime — при закрытии смены; deferred — вручную/bulk в конце периода
period_typeshift | weekly | monthly — для deferred

KPIMetric

Метрика, назначенная сотруднику (Salary).

  • Связь: employee → Salary, kpi_type → KPICatalog
  • value — целевое/наградное значение (для percentage — доля от pay_of_hour, для absolute — фикс. сумма)
  • Unique: [employee, kpi_type]

KPIRecord

Фактическая запись начисления KPI на смену (ActualWorkShedule).

  • work_schedule → ActualWorkShedule
  • kpi_type → KPICatalog
  • source_type: auto — рассчитано системой; manual — добавлено вручную или placeholder при статусе
  • status: pending | approved | rejected
  • sum — сумма в рублях
  • Unique: [work_schedule, kpi_type] — одна запись на тип KPI в смене (изменено с 2026-03, миграция 0110)

Salary

  • has_kpi — признак, что сотруднику доступен KPI
  • pay_of_hour — ставка за час (руб), используется в расчёте percentage-метрик

Триггеры расчёта и начисления

1. Сохранение смены (ActualWorkShedule.save)

Условие: shift.is_closed == True

Цепочка:

  1. calc_payment_day() — пересчёт базы оплаты (pay_shift_flat, application_flat) и calculate_kpi()kpi_success_metric
  2. В одной транзакции: super().save(), затем sync_kpi_records() (одна запись на work_schedule + kpi_type), затем recalc_mast_pay_from_kpi_records() и обновление mast_pay_plus_over_time_pay_flat
  3. Выплата = база + сумма всех KPIRecord по смене (без rejected) − обед − штраф

2. POST /manufacture/work-shedule/{id}/sync_kpi/ — «Начислить все выполненные метрики»

Условие: смена закрыта

Цепочка:

  1. calc_payment_day(), флаг _payment_already_calculated, instance.save() (внутри save — sync_kpi_records и recalc без повторного calc)
  2. Ответ: schedule_data + kpi_metrics + kpi_records + kpi_shift_hours + kpi_total_per_hour

3. Изменение статуса присутствия (update_in_shift, perform_update)

Триггер: WorkSheduleViewSet._sync_kpi_records(instance, previous_status)

Активные статусы (enter_work, work, cancel_work):

  • При переходе из неактивного в активный: создаются KPIRecord с source_type='manual', sum=catalog.max_value, status=pending — по одной записи на каждую метрику, назначенную сотруднику (KPIMetric)
  • Только если salary.has_kpi == True

Неактивные статусы (hospital, lost, on_holiday, in_plan, …):

  • При переходе из активного в неактивный: удаляются все KPIRecord с source_type='manual' для данной смены

Логика расчёта KPI

calculate_kpi() (ActualWorkShedule)

Расположение: app/manufacture/models/actual_work_shedule.py

Условия входа:

  • personal.worker_salary.has_kpi == True
  • presence_status == 'work'

Алгоритм:

  1. Выбрать все KPIMetric сотрудника (employee=personal.worker_salary)
  2. Пропустить метрики с calculation_timing == 'deferred'
  3. Для каждой метрики:
    • Вызвать metric.kpi_type.evaluate(self) — проверка выполнения (bool или dict с is_achieved)
    • Если выполнено:
      • Вычислить kpi_hours = min(actual_hours, shift_hours):
        • actual_hours = (end_date - start_date) в часах
        • при wants_lunch вычесть LUNCH_DURATION_MINUTES/60
        • shift_hours = shift.get_duration_hours()
      • kpi_payment = metric.calculate_kpi_payment() * kpi_hours
  4. Сохранить результат в kpi_success_metric: [{'type': kpi_type_id, 'sum': kpi_payment}, …]
  5. Вернуть сумму add_kpi (используется в mast_pay_plus_over_time_pay_flat)

calculate_kpi_payment() (KPIMetric)

Расположение: app/manufacture/models/kpi.py

  • percentage: value / 100 * pay_of_hour (руб/час)
  • absolute: value (фикс. сумма)
  • boolean: не реализовано явно (по умолчанию эквивалент absolute с value)

Проверка выполнения (evaluate)

Расположение: app/manufacture/service/kpi_check_functions.py

Зарегистрированы:

function_nameОписание
full_fillment_of_the_production_planВыполнение плана производства (ManufacturePositions.is_document_completed)
late_to_workОтсутствие опозданий: not work_shedule.is_late_for_shift()

При отсутствии function_name используется default_check: actual >= target.


sync_kpi_records() (ActualWorkShedule)

Расположение: app/manufacture/models/actual_work_shedule.py

Назначение: Синхронизировать KPIRecord с kpi_success_metric.

Правила:

  1. Для каждого элемента kpi_success_metric:
    • Найти KPICatalog по entry['type']
    • update_or_create(work_schedule, kpi_type, source_type='auto') с sum=entry['sum'], value_achieved=1
    • Статус: сохранить approved, если уже был; иначе pending
  2. Удалить auto-записи, для которых нет типа в kpi_success_metric, если статус не approved
  3. Manual-записи не трогаются

Ручное начисление и bulk_accrue

Ручное создание (POST /manufacture/kpi-record/)

Создаётся KPIRecord с source_type='manual', status='pending', указанная sum.

Bulk accrue (POST /manufacture/kpi-record/bulk-accrue/)

Назначение: Начисление deferred KPI за период.

Вход: kpi_type, employee (Salary.id), period_start, period_end, total_sum, source_type

Алгоритм:

  1. Найти смены сотрудника за период (presence_status='work')
  2. Равномерно распределить total_sum по сменам: per_shift_sum = total_sum / count
  3. Для каждой смены: update_or_create с sum=per_shift_sum, is_distributed=True, period_start/end

Approve (POST /manufacture/kpi-record/{id}/approve/)

  • Устанавливает status='approved', approved_by, approved_at
  • Только для deferred (is_distributed=True) создаётся документ «Премия KPI». Realtime (auto/manual) — выплата уже в mast_pay_plus_over_time_pay_flat, после approve вызывается recalc_mast_pay_from_kpi_records() для смены.

Интеграция с оплатой смены

  • Единый источник выплаты: mast_pay_plus_over_time_pay_flat = pay_shift_flat + application_flat + sum(KPIRecord по смене, status ≠ rejected) − обед − штраф. Метод recalc_mast_pay_from_kpi_records() пересчитывает итог из записей; вызывается после sync_kpi_records() при закрытии смены и при сохранении/подтверждении/отклонении KPIRecord.
  • realtime (auto и manual): все записи KPIRecord по смене участвуют в сумме, документ «Премия KPI» при approve не создаётся.
  • deferred (is_distributed): при approve создаётся документ «Премия KPI» за период.

API и эндпоинты

ЭндпоинтНазначение
POST /manufacture/work-shedule/{id}/sync_kpi/Пересчёт и синхронизация KPI (только закрытые смены)
GET /manufacture/kpi-record/?work_schedule__in=...Список записей KPI по сменам
POST /manufacture/kpi-record/Создать запись вручную
PATCH /manufacture/kpi-record/{id}/Обновить статус/сумму/примечание
POST /manufacture/kpi-record/{id}/approve/Подтвердить
POST /manufacture/kpi-record/{id}/reject/Отклонить
POST /manufacture/kpi-record/bulk-accrue/Начислить deferred за период

Сериализация и расчёт sum_per_hour

  • KPIRecordSerializer добавляет sum_per_hour: sum / work_schedule.get_kpi_duration_hours()
  • WorkSheduleSerializerV2 добавляет kpi_shift_hours: get_kpi_duration_hours()
  • В ответе sync_kpi: kpi_shift_hours, kpi_total_per_hour (сумма по неотклонённым / часы)

Константы

  • LUNCH_DURATION_MINUTES = 60 — вычитается из actual_hours при wants_lunch
  • TYPE_PRODUCTION_CALENDAR = 4 — тип зарплаты для расчёта по календарю (используется в calc_payment_day)

Граничные случаи и замечания

  1. Один KPIRecord на тип (с 2026-03): Unique [work_schedule, kpi_type]. Одна запись на тип; при sync approved manual не перезаписывается.
  2. _sync_kpi_records при смене статуса создаёт записи с source_type='manual' только для типов, по которым ещё нет записи (по одной на каждую назначенную метрику).
  3. presence_status='lost': При расчёте штрафа временно выставляется has_kpi=False, чтобы KPI не начислялся.
  4. Deferred-метрики не участвуют в calculate_kpi; начисляются только через bulk_accrue или ручное создание.

Consequences

  • Расчёт KPI централизован в calculate_kpi() и sync_kpi_records()
  • Два сценария создания записей: при закрытии смены (save) и при явном вызове sync_kpi
  • Единый источник выплаты: сумма по KPIRecord, пересчёт через recalc_mast_pay_from_kpi_records()
  • Документ служит справочником для доработок и отладки расчёта KPI

Обновление 2026-03 (реализованные изменения)

  • Unique KPIRecord: [work_schedule, kpi_type] (миграция 0110, дедупликация данных).
  • Выплата из записей: get_total_kpi_from_records(), recalc_mast_pay_from_kpi_records(); при сохранении/approve/reject KPIRecord пересчитывается выплата смены.
  • Переименование: clac_payment_daycalc_payment_day_calc_payment_day_production_calendar).
  • Документ «Премия KPI»: создаётся только для deferred (is_distributed); realtime — всё в mast_pay.
  • Один расчёт при закрытии: флаг _payment_already_calculated в run_sync_kpi_for_shift и sync_kpi убирает двойной вызов calc.
  • Атомарность: save() выполняет super().save(), sync_kpi_records и обновление mast_pay в transaction.atomic().
  • Обработка ошибок: в pay_shift() и application() голые except заменены на логирование через logger.exception.

Рекомендации по улучшению и устранению дублей

Проблема дублей

Текущая схема: Unique [work_schedule, kpi_type, source_type] — для одного типа KPI в смене могут быть и auto, и manual записи.

Пример: «Отсутствие прогулов»

  • auto (195₽) — из calculate_kpi, уже в mast_pay_plus_over_time_pay_flat
  • manual (5₽) — добавлено вручную или placeholder от _sync_kpi_records при смене статуса

Риски:

  1. Двойное начисление: auto входит в mast_pay; manual при approve создаёт документ «Премия KPI» → сотрудник получает 195 + 5 = 200₽ вместо 195₽.
  2. recordsTotal завышен: UI суммирует все неотклонённые записи — при наличии и auto, и manual итог искажается.
  3. Placeholder + auto: _sync_kpi_records создаёт manual-placeholder при активном статусе; sync_kpi добавляет auto, placeholder остаётся → две записи на один тип.

Решение: один KPI-тип на смену

Принцип: Для (work_schedule, kpi_type) должна быть одна платёжная запись.

Вариант A: Ужесточить unique constraint (рекомендуемый)

Изменение: Unique [work_schedule, kpi_type] — одна запись на тип в смене.

Логика:

  • sync_kpi_records(): update_or_create по (work_schedule, kpi_type) без учёта source_type; при наличии manual с user-edited sum — не перезаписывать (или перезаписывать только если manual был placeholder).
  • Убрать создание manual-placeholder в _sync_kpi_records при смене статуса — auto создаются только при sync_kpi/закрытии смены.
  • Ручное добавление: если для типа уже есть auto — либо обновить auto (через PATCH), либо запретить создание manual для этого типа.

Вариант B: Очистка при sync (минимальные изменения)

Изменение в sync_kpi_records(): Перед созданием/обновлением auto для типа X — удалить manual для того же типа (или отклонить: status=rejected), если manual — placeholder (например, sum=max_value).

Плюс: Не меняем схему БД.
Минус: Сложнее надёжно отличать placeholder от осознанно добавленной manual-записи.

Вариант C: Не создавать placeholder при смене статуса

Изменение в _sync_kpi_records: Не создавать manual-записи при переходе в активный статус. Ожидать sync_kpi или закрытия смены.

Плюс: Нет лишних placeholder.
Минус: До sync_kpi в UI не будет «заготовок» по метрикам.

Дополнительные улучшения

ОбластьРекомендация
Правило выплатыЧётко разделить: auto → только mast_pay; manual/deferred → только документ. Не дублировать.
recordsTotalПри наличии и auto, и manual для одного типа учитывать в итоге только одну сумму (например, auto, если есть; иначе manual).
_sync_kpi_recordsЛибо отключить создание placeholder, либо создавать сразу auto (если смена уже закрыта) вместо manual.
Валидация APIПри создании manual проверять: нет ли уже auto для этого типа; при наличии — 409 Conflict или merge.
МиграцияСкрипт очистки: для каждой смены оставить по одной записи на kpi_type (приоритет: approved > auto > manual).

Полный список рекомендаций для реализации

Ниже — приоритизированный список шагов для устранения дублей и улучшения процесса KPI. Порядок учитывает зависимости и риски.

Фаза 1: Модель и контракт «один тип — одна запись»

РекомендацияДетали
1.1Зафиксировать правило в коде и ADRДля (work_schedule, kpi_type) в платёжном смысле учитывается только одна запись. Все последующие пункты этому не противоречат.
1.2Вариант без смены схемы БД (быстрый старт)Оставить unique [work_schedule, kpi_type, source_type]. Достигать «одна запись на тип» только логикой: при sync удалять/не создавать вторую запись.
1.3Вариант со сменой схемы (чистый долгосрочно)Ввести unique [work_schedule, kpi_type], поле source_type оставить как атрибут одной записи. Потребуется миграция с объединением дублей (см. фазу 4).

Фаза 2: sync_kpi_records() — устранение дублей при пересчёте

РекомендацияДетали
2.1Перед созданием/обновлением auto удалять manual того же типаВ ActualWorkShedule.sync_kpi_records(): для каждого типа из kpi_success_metric перед update_or_create(..., source_type='auto') выполнить KPIRecord.objects.filter(work_schedule=..., kpi_type=..., source_type='manual').delete(). В результате после sync по каждому типу остаётся только auto.
2.2Не трогать approved manual (опционально)Если нужна политика «подтверждённую ручную не перезаписывать»: перед удалением manual проверять status != 'approved'; для типов с approved manual не создавать auto. Иначе — удалять все manual для типа (2.1).
2.3ЛогированиеЛогировать удаление manual при sync (work_schedule_id, kpi_type_id, count) для аудита и отладки.

Фаза 3: Создание записей — не плодить вторую запись на тип

РекомендацияДетали
3.1Отключить placeholder при смене статусаВ WorkSheduleViewSet._sync_kpi_records() не создавать KPIRecord при переходе в активный статус. Записи появятся при закрытии смены (save) или при вызове sync_kpi. Устраняет источник manual-placeholder.
3.2Валидация при создании manualВ KPIRecordSerializer.validate() или в KPIRecordViewSet.create(): если source_type='manual' и уже существует запись (auto или manual) для того же (work_schedule, kpi_type) — возвращать 400/409 с сообщением вида «По этому типу KPI в смене уже есть запись. Отредактируйте существующую или пересчитайте KPI».
3.3PATCH вместо дублированияВ UI «Добавить вручную»: если для выбранного типа уже есть запись — открывать редактирование этой записи (PATCH), а не создание новой.

Фаза 4: Итог и выплата — единый источник правды

РекомендацияДетали
4.1Правило выплаты (задокументировать и соблюдать)auto → учитывается только в mast_pay_plus_over_time_pay_flat, документ «Премия KPI» при approve не создавать. manual / deferred (is_distributed) → при approve создавать документ «Премия KPI». После 2.1 дублей по типу не будет, двойного начисления не возникнет.
4.2recordsTotal на бэкенде (опционально)Если API отдаёт агрегат «итого KPI по смене»: считать сумму по одной записи на тип (например, приоритет: auto, иначе manual; или только неотклонённые с группировкой по kpi_type и взятием одной суммы на тип).
4.3recordsTotal на фронтеВ getKpiRecordsTotal: суммировать только уникальные типы — по каждому kpi_type брать одну запись (например, приоритет auto, иначе manual) и добавлять её sum. Либо после 2.1 дублей не будет, и текущее суммирование всех записей останется корректным.

Фаза 5: Очистка существующих данных

РекомендацияДетали
5.1Management-команда для очистки дублейКоманда типа python manage.py manufacture_deduplicate_kpi_records: для каждой пары (work_schedule, kpi_type) с несколькими записями оставить одну по правилу: approved > auto > manual (по приоритету типа), остальные удалить или перевести в rejected. Сделать dry-run (--dry-run) и вывести отчёт.
5.2Миграция данных (опционально)Если переходим на unique [work_schedule, kpi_type] (1.3): в data-миграции применить ту же логику объединения, затем добавить constraint и убрать старый.
5.3Бэкап и запускПеред массовой очисткой — бэкап БД. Запускать команду в период низкой нагрузки; после — выборочная проверка смен и выплат.

Фаза 6: API и фронт

РекомендацияДетали
6.1Ответ 409 при дублированииПри создании manual, если для типа уже есть запись — Response({'detail': '...'}, status=409). На фронте показывать сообщение и предлагать «Пересчитать KPI» или «Редактировать запись».
6.2Список типов для «Добавить вручную»В форме ручного добавления показывать только те типы KPI, по которым в данной смене ещё нет ни одной записи (или фильтровать уже выбранный тип).
6.3Подсказка в UIВозле блока KPI кратко: «По каждому типу метрики в смене учитывается одна запись. Пересчёт заменяет автоматические записи.»

Фаза 7: Тесты и документация

РекомендацияДетали
7.1Тесты sync_kpi_recordsПроверить: после sync для каждого типа из kpi_success_metric одна запись auto; manual для тех же типов удалены; approved manual при выбранной политике (2.2) сохраняются или удаляются по правилу.
7.2Тест создания manual при наличии autoПроверить, что создание manual при существующем auto возвращает 409 (или 400) и запись не создаётся.
7.3Тест recordsTotalПри одной записи на тип сумма совпадает с ожидаемой; при старых дублях (до очистки) либо явное правило расчёта, либо тест на то, что после очистки итог корректен.
7.4Обновить ADRПосле внедрения зафиксировать в ADR-0023 выбранные варианты (удаление manual при sync, отключение placeholder, правило для approved, формулу recordsTotal) и ссылку на команду очистки.

Фаза 8: Долгосрочные улучшения (по желанию)

РекомендацияДетали
8.1Единый unique [work_schedule, kpi_type]После стабилизации логики и очистки данных ввести миграцию на один unique и одну запись на тип; source_type хранить как атрибут.
8.2Флаг «перезаписать ручную»В sync_kpi опциональный параметр force_replace_manual=True: при sync удалять в том числе approved manual и заменять auto (для админ-сценариев).
8.3История измененийДля KPIRecord использовать django-simple-history или аналог, чтобы видеть, какая запись кем и когда была заменена/удалена при sync.

Порядок внедрения (минимум для устранения дублей)

  1. 2.1 — в sync_kpi_records() удалять manual того же типа перед созданием auto.
  2. 3.1 — отключить создание placeholder в _sync_kpi_records.
  3. 3.2 — при создании manual проверять отсутствие записи по (work_schedule, kpi_type) и при наличии возвращать 409.
  4. 6.2 — в форме «Добавить вручную» не предлагать типы, по которым уже есть запись.
  5. 5.1 — один раз выполнить команду очистки существующих дублей (с dry-run и бэкапом).

После этого дубли по начислению в рамках смены устраняются, правило «один тип — одна запись» соблюдается без смены схемы БД.

Обновление от 10.03.2026

Внедренные изменения (Фаза 1 и 2)

Реализованы меры по устранению дублей KPI и добавлены новые метрики для наладчиков.

  1. Устранение дублей (ADR-0023 Implementation)

    • В метод ActualWorkShedule.sync_kpi_records добавлена логика приоритета: при автоматическом расчете KPI (sync_kpi или закрытие смены) удаляются существующие ручные записи (manual) того же типа.
    • Это гарантирует соблюдение принципа "Один тип KPI — одна запись в смене" и предотвращает двойные выплаты (через зарплату + через премию).
  2. KPI Наладчиков

    • В базу данных добавлены 9 новых метрик для наладчиков производственной линии (согласно документу KPI-наладчик-001):
      • Время простоя линии (≤ 15 мин)
      • Точность дозирования (95-100%)
      • ТО выполнено (Да/Нет)
      • Герметичность упаковки — брак (≤ 0.5%)
      • Чистота рабочей зоны (1-5)
      • Журнал неисправностей заполнен (Да/Нет)
      • Выполнение нормы линии (95-100%)
      • Потери сырья при наладке (≤ 2 кг)
      • Параметры соответствуют тех. карте (Да/Нет)
    • Тип расчета: deferred (отложенный/ручной), период: shift (смена).
    • Ввод данных осуществляется бригадиром или начальником производства вручную в конце смены.
  3. Расширение API

    • В KPIRecordPatchSerializer добавлено поле value_achieved. Это позволяет сохранять фактические значения показателей (проценты, килограммы, баллы) при ручном вводе или редактировании, а не только денежную сумму.

Нерешенные вопросы / План развития

  • Для KPI наладчиков пока не реализован автоматический расчет денежной премии на основе введенных показателей. Текущая реализация предполагает ручной ввод и оценку.
  • Жесткий допуск выполнения плана производства (tolerance 0.02) оставлен без изменений по требованию бизнеса.