Corp/backend/projects/services/time_tracking.py
2026-09-07 19:43:48 +03:00

198 lines
6.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from datetime import timedelta
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from rest_framework.exceptions import ValidationError
from attendance.models import AttendanceRecord
from projects.models import (
AllocationReminder,
AllocationRevision,
DailyAllocation,
Project,
TimeAllocation,
)
def available_projects(user, day):
return (
Project.objects.filter(status__in=["planned", "active", "paused"])
.filter(
Q(manager=user)
| (
Q(memberships__employee=user)
& (
Q(memberships__starts_on__isnull=True)
| Q(memberships__starts_on__lte=day)
)
& (
Q(memberships__ends_on__isnull=True)
| Q(memberships__ends_on__gte=day)
)
)
)
.distinct()
)
def attendance_for(user, day):
return AttendanceRecord.objects.filter(
employee=user, date=day, analysis_run__status="success", work_hours__gt=0
).first()
def hours_for(record):
return (
Decimal(str(record.work_hours)).quantize(Decimal("0.001")) if record else None
)
def day_data(user, day):
entry = (
DailyAllocation.objects.filter(employee=user, date=day)
.prefetch_related("allocations__project")
.first()
)
record = attendance_for(user, day)
return {
"date": day,
"status": entry.status if entry else "draft",
"version": entry.version if entry else 0,
"hours": entry.hours if entry else hours_for(record),
"hours_source": entry.hours_source
if entry
else ("attendance" if record else "unknown"),
"attendance_changed": bool(entry and entry.hours != hours_for(record)),
"note": entry.note if entry else "",
"allocations": [
{
"project": a.project_id,
"project_name": a.project.name if a.project else "",
"activity": a.activity,
"percent": str(a.percent),
}
for a in entry.allocations.all()
]
if entry
else [],
"projects": list(available_projects(user, day).values("id", "name")),
}
@transaction.atomic
def save_day(user, data):
# Lock the owner as well as existing days: first saves must serialize too.
get_user_model().objects.select_for_update().get(pk=user.pk)
day = data["date"]
if day > timezone.localdate() or day < timezone.localdate() - timedelta(days=366):
raise ValidationError("Выберите прошедший день или сегодня, в пределах года.")
entry = DailyAllocation.objects.filter(employee=user, date=day).first()
if data["version"] != (entry.version if entry else 0):
raise ValidationError(
"Запись изменена в другом окне. Обновите данные перед сохранением."
)
allocations = data["allocations"]
total = sum((a["percent"] for a in allocations), Decimal(0))
if total > 100 or (data["status"] == "confirmed" and total != 100):
raise ValidationError(
"Для подтверждения распределите ровно 100%. В черновике допустимо меньше."
)
allowed = set(available_projects(user, day).values_list("pk", flat=True))
# Existing history remains editable after project completion, without adding new projects.
if entry:
allowed.update(
entry.allocations.exclude(project=None).values_list("project_id", flat=True)
)
seen = set()
for row in allocations:
pid, activity = row.get("project"), row.get("activity", "")
if bool(pid) == bool(activity):
raise ValidationError("Выберите либо проект, либо вид общей деятельности.")
if pid and pid not in allowed:
raise ValidationError("Нет участия в выбранном проекте на эту дату.")
key = (pid, activity)
if key in seen:
raise ValidationError(
"Объедините повторяющиеся строки проекта или деятельности."
)
seen.add(key)
record = attendance_for(user, day)
if not entry:
entry = DailyAllocation(employee=user, date=day)
entry.status, entry.note = data["status"], data.get("note", "")
entry.hours, entry.hours_source = (
hours_for(record),
"attendance" if record else "unknown",
)
entry.attendance_record = record
entry.version += 1
entry.save()
entry.allocations.all().delete()
TimeAllocation.objects.bulk_create(
[
TimeAllocation(
day=entry,
project_id=a.get("project"),
activity=a.get("activity", ""),
percent=a["percent"],
)
for a in allocations
]
)
snapshot = {
"version": entry.version,
"status": entry.status,
"hours": str(entry.hours) if entry.hours is not None else None,
"hours_source": entry.hours_source,
"note": entry.note,
"allocations": [
{
"project": a.get("project"),
"activity": a.get("activity", ""),
"percent": str(a["percent"]),
}
for a in allocations
],
}
AllocationRevision.objects.create(day=entry, actor=user, snapshot=snapshot)
return day_data(user, day)
@transaction.atomic
def claim_reminder(user):
get_user_model().objects.select_for_update().get(pk=user.pk)
now, today = timezone.now(), timezone.localdate()
reminder, _ = AllocationReminder.objects.get_or_create(employee=user)
if (
not reminder.enabled
or reminder.last_claim_date == today
or (reminder.snoozed_until and reminder.snoozed_until > now)
):
return None
confirmed = DailyAllocation.objects.filter(
employee=user, status="confirmed"
).values("date")
# Completed past shifts only. Attendance, not a guessed Monday-Friday calendar,
# determines working days including holidays, leave and overnight schedules.
days = (
AttendanceRecord.objects.filter(
employee=user,
analysis_run__status="success",
work_hours__gt=0,
date__lt=today,
date__gte=today - timedelta(days=14),
)
.exclude(date__in=confirmed)
.order_by("-date")
)
for record in days:
if available_projects(user, record.date).exists():
reminder.last_claim_date = today
reminder.snoozed_until = None
reminder.save()
return record.date
return None