23. Аудит: расчёт и начисление KPI на backend
Date: 2026-03-10
Status
Accepted (документация текущего состояния)
Context
Требуется провести полный аудит того, как работает расчёт и начисление KPI на backend, и зафиксировать архитектуру в виде документированного ADR для дальнейшей поддержки и развития системы.
Модели данных
KPICatalog
Справочник типов KPI.
| Поле | Описание |
|---|---|
name, description | Название и описание |
metric_type | percentage | absolute | boolean |
min_value, max_value | Пороги (для percentage — целевые значения) |
function_name | Имя функции проверки из kpi_check_functions.py |
calculation_timing | realtime — при закрытии смены; deferred — вручную/bulk в конце периода |
period_type | shift | 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→ ActualWorkShedulekpi_type→ KPICatalogsource_type:auto— рассчитано системой;manual— добавлено вручную или placeholder при статусеstatus:pending|approved|rejectedsum— сумма в рублях- Unique:
[work_schedule, kpi_type]— одна запись на тип KPI в смене (изменено с 2026-03, миграция 0110)
Salary
has_kpi— признак, что сотруднику доступен KPIpay_of_hour— ставка за час (руб), используется в расчёте percentage-метрик
Триггеры расчёта и начисления
1. Сохранение смены (ActualWorkShedule.save)
Условие: shift.is_closed == True
Цепочка:
calc_payment_day()— пересчёт базы оплаты (pay_shift_flat, application_flat) иcalculate_kpi()→kpi_success_metric- В одной транзакции:
super().save(), затемsync_kpi_records()(одна запись наwork_schedule+kpi_type), затемrecalc_mast_pay_from_kpi_records()и обновлениеmast_pay_plus_over_time_pay_flat - Выплата = база + сумма всех KPIRecord по смене (без rejected) − обед − штраф
2. POST /manufacture/work-shedule/{id}/sync_kpi/ — «Начислить все выполненные метрики»
Условие: смена закрыта
Цепочка:
calc_payment_day(), флаг_payment_already_calculated,instance.save()(внутри save — sync_kpi_records и recalc без повторного calc)- Ответ:
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 == Truepresence_status == 'work'
Алгоритм:
- Выбрать все KPIMetric сотрудника (
employee=personal.worker_salary) - Пропустить метрики с
calculation_timing == 'deferred' - Для каждой метрики:
- Вызвать
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
- Вычислить
- Вызвать
- Сохранить результат в
kpi_success_metric:[{'type': kpi_type_id, 'sum': kpi_payment}, …] - Вернуть сумму
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.
Правила:
- Для каждого элемента
kpi_success_metric:- Найти KPICatalog по
entry['type'] update_or_create(work_schedule, kpi_type, source_type='auto')сsum=entry['sum'],value_achieved=1- Статус: сохранить
approved, если уже был; иначеpending
- Найти KPICatalog по
- Удалить auto-записи, для которых нет типа в
kpi_success_metric, если статус неapproved - 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
Алгоритм:
- Найти смены сотрудника за период (
presence_status='work') - Равномерно распределить
total_sumпо сменам:per_shift_sum = total_sum / count - Для каждой смены:
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_lunchTYPE_PRODUCTION_CALENDAR = 4— тип зарплаты для расчёта по календарю (используется вcalc_payment_day)
Граничные случаи и замечания
- Один KPIRecord на тип (с 2026-03): Unique
[work_schedule, kpi_type]. Одна запись на тип; при sync approved manual не перезаписывается. - _sync_kpi_records при смене статуса создаёт записи с
source_type='manual'только для типов, по которым ещё нет записи (по одной на каждую назначенную метрику). - presence_status='lost': При расчёте штрафа временно выставляется
has_kpi=False, чтобы KPI не начислялся. - 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_day→calc_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 при смене статуса
Риски:
- Двойное начисление: auto входит в mast_pay; manual при approve создаёт документ «Премия KPI» → сотрудник получает 195 + 5 = 200₽ вместо 195₽.
- recordsTotal завышен: UI суммирует все неотклонённые записи — при наличии и auto, и manual итог искажается.
- 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.3 | PATCH вместо дублирования | В UI «Добавить вручную»: если для выбранного типа уже есть запись — открывать редактирование этой записи (PATCH), а не создание новой. |
Фаза 4: Итог и выплата — единый источник правды
| № | Рекомендация | Детали |
|---|---|---|
| 4.1 | Правило выплаты (задокументировать и соблюдать) | auto → учитывается только в mast_pay_plus_over_time_pay_flat, документ «Премия KPI» при approve не создавать. manual / deferred (is_distributed) → при approve создавать документ «Премия KPI». После 2.1 дублей по типу не будет, двойного начисления не возникнет. |
| 4.2 | recordsTotal на бэкенде (опционально) | Если API отдаёт агрегат «итого KPI по смене»: считать сумму по одной записи на тип (например, приоритет: auto, иначе manual; или только неотклонённые с группировкой по kpi_type и взятием одной суммы на тип). |
| 4.3 | recordsTotal на фронте | В getKpiRecordsTotal: суммировать только уникальные типы — по каждому kpi_type брать одну запись (например, приоритет auto, иначе manual) и добавлять её sum. Либо после 2.1 дублей не будет, и текущее суммирование всех записей останется корректным. |
Фаза 5: Очистка существующих данных
| № | Рекомендация | Детали |
|---|---|---|
| 5.1 | Management-команда для очистки дублей | Команда типа 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. |
Порядок внедрения (минимум для устранения дублей)
- 2.1 — в
sync_kpi_records()удалять manual того же типа перед созданием auto. - 3.1 — отключить создание placeholder в
_sync_kpi_records. - 3.2 — при создании manual проверять отсутствие записи по
(work_schedule, kpi_type)и при наличии возвращать 409. - 6.2 — в форме «Добавить вручную» не предлагать типы, по которым уже есть запись.
- 5.1 — один раз выполнить команду очистки существующих дублей (с dry-run и бэкапом).
После этого дубли по начислению в рамках смены устраняются, правило «один тип — одна запись» соблюдается без смены схемы БД.
Обновление от 10.03.2026
Внедренные изменения (Фаза 1 и 2)
Реализованы меры по устранению дублей KPI и добавлены новые метрики для наладчиков.
Устранение дублей (ADR-0023 Implementation)
- В метод
ActualWorkShedule.sync_kpi_recordsдобавлена логика приоритета: при автоматическом расчете KPI (sync_kpiили закрытие смены) удаляются существующие ручные записи (manual) того же типа. - Это гарантирует соблюдение принципа "Один тип KPI — одна запись в смене" и предотвращает двойные выплаты (через зарплату + через премию).
- В метод
KPI Наладчиков
- В базу данных добавлены 9 новых метрик для наладчиков производственной линии (согласно документу
KPI-наладчик-001):- Время простоя линии (≤ 15 мин)
- Точность дозирования (95-100%)
- ТО выполнено (Да/Нет)
- Герметичность упаковки — брак (≤ 0.5%)
- Чистота рабочей зоны (1-5)
- Журнал неисправностей заполнен (Да/Нет)
- Выполнение нормы линии (95-100%)
- Потери сырья при наладке (≤ 2 кг)
- Параметры соответствуют тех. карте (Да/Нет)
- Тип расчета:
deferred(отложенный/ручной), период:shift(смена). - Ввод данных осуществляется бригадиром или начальником производства вручную в конце смены.
- В базу данных добавлены 9 новых метрик для наладчиков производственной линии (согласно документу
Расширение API
- В
KPIRecordPatchSerializerдобавлено полеvalue_achieved. Это позволяет сохранять фактические значения показателей (проценты, килограммы, баллы) при ручном вводе или редактировании, а не только денежную сумму.
- В
Нерешенные вопросы / План развития
- Для KPI наладчиков пока не реализован автоматический расчет денежной премии на основе введенных показателей. Текущая реализация предполагает ручной ввод и оценку.
- Жесткий допуск выполнения плана производства (tolerance 0.02) оставлен без изменений по требованию бизнеса.