Update application functionality and supporting components

This commit is contained in:
lizerk 2026-09-07 16:59:38 +03:00
parent cd64ee4ef7
commit efd4a84c89
56 changed files with 7353 additions and 176 deletions

View File

@ -12,6 +12,7 @@ application consists of a Django/DRF ASGI backend and a Next.js frontend.
- requests, approval workflows, tasks and notification-driven status changes;
- documents, attachments, comments and acknowledgements;
- procurement requests, approval routes, budgets and equipment inventory;
- local projects, project teams, external project bindings and daily time allocation;
- calendars, shared events and attendance analysis through LogStorm;
- chats, reactions, polls and user notifications over one authenticated WebSocket connection;
- notification delivery through WebSocket, email and Web Push.
@ -35,6 +36,7 @@ and [`frontend/package.json`](frontend/package.json).
- [WebSocket and realtime](docs/guides/WEBSOCKET.md)
- [Notifications](docs/guides/NOTIFICATIONS.md)
- [Commerce and MoySklad](docs/guides/COMMERCE.md)
- [Local projects and daily participation](backend/projects/README.md)
With the backend running, generated contracts are available at:

View File

@ -2,7 +2,7 @@ from datetime import date
from decimal import Decimal
from django.db import transaction
from django.db.models import Q
from django.db.models import OuterRef, Q, Subquery
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import generics, permissions, serializers
@ -10,16 +10,21 @@ from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from rest_framework.views import APIView
from commerce.models import ExternalProject, Product, ProjectPayment, SourceSystem
from commerce.models import Product, ProjectPayment, SourceSystem
from employees.models import Employee
from finance.analytics.models import ReportingEntry
from finance.analytics.people import people_report
from finance.analytics.projects import payment_queryset, projects_report
from finance.analytics.projects import (
local_project_scope,
payment_queryset,
projects_report,
)
from finance.analytics.sales import production_report, sales_report
from finance.payroll.access import (
can_access_payroll_management,
has_simple_admin_access,
)
from projects.models import Project, ProjectExternalBinding
from .views import NoStorePayrollResponseMixin
@ -44,12 +49,17 @@ class ReportQuery(serializers.Serializer):
def validate(self, attrs):
if attrs.get("project") and attrs["project"] != "unassigned":
try:
attrs["project"] = ExternalProject.objects.get(pk=int(attrs["project"]))
except (ValueError, OverflowError, ExternalProject.DoesNotExist):
attrs["project"] = Project.objects.get(pk=int(attrs["project"]))
except (ValueError, OverflowError, Project.DoesNotExist):
raise serializers.ValidationError({"project": "Проект не найден."})
if attrs.get("source") and attrs["project"].source_id != attrs["source"].pk:
if (
attrs.get("source")
and not attrs["project"]
.bindings.filter(source=attrs["source"], object_type="project")
.exists()
):
raise serializers.ValidationError(
{"project": "Проект относится к другому источнику."}
{"project": "Проект не связан с выбранным источником."}
)
today = timezone.localdate()
attrs.setdefault("date_from", today.replace(month=1, day=1))
@ -89,15 +99,20 @@ class AnalyticsReportView(NoStorePayrollResponseMixin, APIView):
permission_classes = [permissions.IsAuthenticated, AnalyticsPermission]
def get(self, request):
section = request.query_params.get("section", "sales")
if section not in ["sales", "people", "production", "projects"]:
raise serializers.ValidationError({"section": "Неизвестный раздел отчёта."})
params = report_params(request)
start, end, currency = (
params["date_from"],
params["date_to"],
params["currency"],
)
section = request.query_params.get("section", "sales")
if section not in ["sales", "people", "production", "projects"]:
raise serializers.ValidationError({"section": "Неизвестный раздел отчёта."})
project_scope = (
local_project_scope(params.get("source"), params.get("project"))
if section != "people"
else None
)
result = {
"period": {"from": start, "to": end},
"currency": currency,
@ -129,7 +144,7 @@ class AnalyticsReportView(NoStorePayrollResponseMixin, APIView):
currency,
params.get("source"),
product_ids,
params.get("project"),
project_scope=project_scope,
)
result["sales"] = sales
result["production"] = production_report(
@ -143,6 +158,7 @@ class AnalyticsReportView(NoStorePayrollResponseMixin, APIView):
sales,
params.get("source"),
params.get("project"),
project_scope,
)
return Response(json_values(result))
@ -151,6 +167,9 @@ class ReportingEntrySerializer(serializers.ModelSerializer):
project_name = serializers.CharField(
source="project.name", read_only=True, default=""
)
external_project_name = serializers.CharField(
source="external_project.name", read_only=True, default=""
)
product_name = serializers.CharField(
source="product.name", read_only=True, default=""
)
@ -169,6 +188,7 @@ class ReportingEntrySerializer(serializers.ModelSerializer):
"description",
"project",
"project_name",
"external_project_name",
"product",
"product_name",
"employee",
@ -222,7 +242,7 @@ class ReportingEntryListView(NoStorePayrollResponseMixin, generics.ListCreateAPI
qs = ReportingEntry.objects.filter(
date__range=(params["date_from"], params["date_to"]),
currency=params["currency"],
).select_related("product", "employee", "project")
).select_related("product", "employee", "project", "external_project")
if params.get("project") is not None:
qs = qs.filter(
project_id=None
@ -281,11 +301,21 @@ class AnalyticsOptionsView(NoStorePayrollResponseMixin, APIView):
"sources": list(
SourceSystem.objects.order_by("name").values("id", "name")
),
"projects": list(
ExternalProject.objects.values(
"id", "name", "source_id", "archived"
)
),
"projects": [
{
"id": project.pk,
"name": project.name,
"source_ids": sorted(
{
binding.source_id
for binding in project.bindings.all()
if binding.object_type == "project"
}
),
"archived": project.status == Project.Status.ARCHIVED,
}
for project in Project.objects.prefetch_related("bindings")
],
"can_write": can_write(request.user),
"can_void": has_simple_admin_access(request.user)
or request.user.has_perm("finance.change_reportingentry"),
@ -294,17 +324,22 @@ class AnalyticsOptionsView(NoStorePayrollResponseMixin, APIView):
class ProjectPaymentSerializer(serializers.ModelSerializer):
project_name = serializers.CharField(
source="project.name", default="Без проекта", read_only=True
project_name = serializers.SerializerMethodField()
external_project_name = serializers.CharField(
source="project.name", default="", read_only=True
)
source_name = serializers.CharField(source="source.name", read_only=True)
def get_project_name(self, obj):
return obj.local_project_name or "Без привязки"
class Meta:
model = ProjectPayment
fields = [
"id",
"project",
"project_name",
"external_project_name",
"source_name",
"document_type",
"number",
@ -323,10 +358,16 @@ class ProjectPaymentListView(NoStorePayrollResponseMixin, generics.ListAPIView):
def get_queryset(self):
p = report_params(self.request)
scope = local_project_scope(p.get("source"), p.get("project"))
binding = ProjectExternalBinding.objects.filter(
source_id=OuterRef("source_id"),
object_type="project",
external_id=OuterRef("project__external_id"),
)
return payment_queryset(
p["date_from"],
p["date_to"],
p["currency"],
p.get("source"),
p.get("project"),
)
scope=scope,
).annotate(local_project_name=Subquery(binding.values("project__name")[:1]))

View File

View File

@ -0,0 +1,185 @@
from decimal import Decimal
from django.contrib.auth import get_user_model
from rest_framework import serializers
from commerce.models import SourceSystem
from projects.models import (
Project,
ProjectMembership,
ProjectStage,
ProjectExternalBinding,
ProjectResource,
)
class ProjectSerializer(serializers.ModelSerializer):
manager_name = serializers.SerializerMethodField()
can_manage = serializers.SerializerMethodField()
can_view_finances = serializers.SerializerMethodField()
class Meta:
model = Project
fields = [
"id",
"name",
"description",
"customer",
"manager",
"manager_name",
"status",
"starts_on",
"ends_on",
"created_at",
"updated_at",
"can_manage",
"can_view_finances",
]
read_only_fields = ["created_at", "updated_at"]
extra_kwargs = {
"manager": {"queryset": get_user_model().objects.filter(is_active=True)}
}
def get_manager_name(self, obj):
return str(obj.manager)
def get_can_manage(self, obj):
from projects.permissions import can_manage
return can_manage(self.context["request"].user, obj)
def get_can_view_finances(self, obj):
cached = getattr(self, "_can_view_finances", None)
if cached is None:
from finance.payroll.access import can_access_payroll_management
cached = can_access_payroll_management(self.context["request"].user)
self._can_view_finances = cached
return cached
def validate(self, attrs):
start = attrs.get(
"starts_on", self.instance.starts_on if self.instance else None
)
end = attrs.get("ends_on", self.instance.ends_on if self.instance else None)
if start and end and end < start:
raise serializers.ValidationError("Окончание не может быть раньше начала.")
return attrs
class MembershipSerializer(serializers.ModelSerializer):
employee_name = serializers.SerializerMethodField()
class Meta:
model = ProjectMembership
fields = [
"id",
"employee",
"employee_name",
"role",
"planned_percent",
"starts_on",
"ends_on",
]
extra_kwargs = {
"planned_percent": {"min_value": 0, "max_value": 100},
"employee": {"queryset": get_user_model().objects.filter(is_active=True)},
}
def get_employee_name(self, obj):
return str(obj.employee)
def validate(self, attrs):
if (
attrs.get("starts_on")
and attrs.get("ends_on")
and attrs["ends_on"] < attrs["starts_on"]
):
raise serializers.ValidationError("Окончание участия раньше начала.")
return attrs
class StageSerializer(serializers.ModelSerializer):
class Meta:
model = ProjectStage
fields = ["id", "name", "due_on", "done", "position"]
class BindingSerializer(serializers.ModelSerializer):
source_name = serializers.CharField(source="source.name", read_only=True)
class Meta:
model = ProjectExternalBinding
fields = [
"id",
"source",
"source_name",
"object_type",
"external_id",
"external_name",
]
validators = []
class ResourceSerializer(serializers.ModelSerializer):
class Meta:
model = ProjectResource
fields = ["id", "title", "kind", "url"]
def validate(self, attrs):
from urllib.parse import urlsplit
url = urlsplit(attrs["url"])
prefix = {
"task": "/tasks",
"procurement": "/procurement",
"document": "/documents",
}[attrs["kind"]]
if (
url.scheme
or url.netloc
or "\\" in attrs["url"]
or not (url.path == prefix or url.path.startswith(prefix + "/"))
):
raise serializers.ValidationError(
"Укажите внутреннюю ссылку соответствующего раздела портала."
)
return attrs
class AllocationRowSerializer(serializers.Serializer):
project = serializers.IntegerField(min_value=1, required=False, allow_null=True)
activity = serializers.ChoiceField(
choices=["", "department", "training", "support"], default=""
)
percent = serializers.DecimalField(
max_digits=5, decimal_places=2, min_value=Decimal("0.01"), max_value=100
)
class DaySerializer(serializers.Serializer):
date = serializers.DateField()
version = serializers.IntegerField(min_value=0)
status = serializers.ChoiceField(choices=["draft", "confirmed"])
note = serializers.CharField(max_length=2000, allow_blank=True, default="")
allocations = AllocationRowSerializer(many=True, max_length=50)
class ExternalProjectImportSerializer(serializers.Serializer):
source = serializers.PrimaryKeyRelatedField(queryset=SourceSystem.objects.all())
external_id = serializers.CharField(max_length=255)
name = serializers.CharField(max_length=255)
description = serializers.CharField(max_length=5000, allow_blank=True, default="")
customer = serializers.CharField(max_length=255, allow_blank=True, default="")
manager = serializers.PrimaryKeyRelatedField(
queryset=get_user_model().objects.filter(is_active=True)
)
status = serializers.ChoiceField(choices=Project.Status.choices, default="planned")
starts_on = serializers.DateField(required=False, allow_null=True)
ends_on = serializers.DateField(required=False, allow_null=True)
def validate(self, attrs):
if attrs.get("starts_on") and attrs.get("ends_on"):
if attrs["ends_on"] < attrs["starts_on"]:
raise serializers.ValidationError(
"Окончание не может быть раньше начала."
)
return attrs

View File

@ -0,0 +1,12 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from .views import ProjectViewSet, OwnDayView, OwnCalendarView, ReminderView
router = DefaultRouter()
router.register("", ProjectViewSet, basename="local-project")
urlpatterns = [
path("my-day/", OwnDayView.as_view()),
path("my-calendar/", OwnCalendarView.as_view()),
path("reminder/", ReminderView.as_view()),
*router.urls,
]

View File

@ -0,0 +1,730 @@
from datetime import timedelta
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.db import transaction, IntegrityError
from django.db.models import Count, Exists, Max, Min, OuterRef, Sum, Q
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import permissions, serializers, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.response import Response
from rest_framework.views import APIView
from commerce.models import (
ExternalProject,
ProjectPayment,
Sale,
SourceRecord,
SourceSystem,
)
from projects.models import (
Project,
ProjectMembership,
ProjectExternalBinding,
ProjectEvent,
TimeAllocation,
DailyAllocation,
AllocationReminder,
ProjectBoardLink,
)
from projects.permissions import visible_projects, can_create, can_manage
from projects.services.time_tracking import day_data, save_day, claim_reminder
from tasks.access import task_board_access_q
from tasks.application import link_task_object, unlink_task_object
from tasks.models import Task, TaskBoard, TaskLinkedObject, TaskLinkedObjectKind
from tasks.presentation import linked_task_badges_for_object
from .serializers import (
ProjectSerializer,
MembershipSerializer,
StageSerializer,
BindingSerializer,
ResourceSerializer,
DaySerializer,
ExternalProjectImportSerializer,
)
class PrivateResponseMixin:
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(request, response, *args, **kwargs)
response["Cache-Control"] = "private, no-store"
return response
class ProjectViewSet(PrivateResponseMixin, viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticated]
serializer_class = ProjectSerializer
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
def get_queryset(self):
qs = visible_projects(self.request.user).select_related("manager")
if self.request.query_params.get("mine") == "true":
qs = qs.filter(
Q(manager=self.request.user)
| Q(memberships__employee=self.request.user)
).distinct()
search = self.request.query_params.get("search", "").strip()
return (
qs.filter(Q(name__icontains=search) | Q(customer__icontains=search))
if search
else qs
)
def perform_create(self, serializer):
if not can_create(self.request.user):
raise PermissionDenied(
"Создание проектов доступно администраторам и пользователям с правом создания проектов."
)
with transaction.atomic():
p = serializer.save(created_by=self.request.user)
ProjectMembership.objects.create(
project=p,
employee=p.manager,
role="Руководитель",
starts_on=timezone.localdate(),
)
ProjectEvent.objects.create(
project=p, actor=self.request.user, action="Проект создан"
)
def require_manager(self):
p = self.get_object()
if not can_manage(self.request.user, p):
raise PermissionDenied("Управление доступно руководителю проекта.")
return p
def perform_update(self, serializer):
p = self.require_manager()
with transaction.atomic():
serializer.save()
ProjectEvent.objects.create(
project=p,
actor=self.request.user,
action="Карточка изменена",
details={k: str(v) for k, v in serializer.validated_data.items()},
)
def destroy(self, request, *args, **kwargs):
raise ValidationError("Проекты сохраняют историю. Используйте статус «Архив».")
@action(detail=False, methods=["get"])
def options(self, request):
# Employee directory visibility follows the portal's authenticated directory.
employees = (
get_user_model()
.objects.filter(is_active=True)
.order_by("last_name", "first_name")
)
manageable = visible_projects(request.user)
if not (request.user.is_staff or request.user.is_superuser):
manageable = manageable.filter(manager=request.user)
return Response(
{
"can_create": can_create(request.user),
"employees": [{"id": u.pk, "name": str(u)} for u in employees],
"manageable_projects": list(
manageable.order_by("name").values("id", "name")[:500]
),
}
)
@action(detail=False, methods=["get"], url_path="external-candidates")
def external_candidates(self, request):
page = serializers.IntegerField(min_value=1).run_validation(
request.query_params.get("page", 1)
)
page_size = 24
search = request.query_params.get("q", "").strip()
binding = ProjectExternalBinding.objects.filter(
source_id=OuterRef("source_id"),
object_type="project",
external_id=OuterRef("external_id"),
)
rows = (
ExternalProject.objects.filter(archived=False)
.annotate(is_bound=Exists(binding))
.filter(is_bound=False)
.select_related("source")
.order_by("name", "pk")
)
if search:
rows = rows.filter(name__icontains=search)
count = rows.count()
start = (page - 1) * page_size
return Response(
{
"count": count,
"next": page + 1 if start + page_size < count else None,
"previous": page - 1 if page > 1 else None,
"results": [
{
"source": row.source_id,
"source_name": row.source.name,
"external_id": row.external_id,
"external_name": row.name,
"bound": False,
}
for row in rows[start : start + page_size]
],
}
)
@action(detail=False, methods=["get"], url_path="external-project")
def external_project(self, request):
source_id = serializers.IntegerField(min_value=1).run_validation(
request.query_params.get("source")
)
external_id = serializers.CharField(max_length=255).run_validation(
request.query_params.get("external_id")
)
external = get_object_or_404(
ExternalProject.objects.select_related("source"),
source_id=source_id,
external_id=external_id,
archived=False,
)
bound = ProjectExternalBinding.objects.filter(
source_id=external.source_id,
object_type="project",
external_id=external.external_id,
).exists()
source_record = (
SourceRecord.objects.filter(
source_id=external.source_id,
entity_type="project",
external_id=external.external_id,
)
.only("source_updated_at")
.first()
)
payment_rows = list(
ProjectPayment.objects.filter(
project=external,
applicable=True,
missing_from_source=False,
)
.values("currency")
.annotate(count=Count("pk"), amount=Sum("amount"))
.order_by("currency")
)
payment_period = ProjectPayment.objects.filter(
project=external,
applicable=True,
missing_from_source=False,
).aggregate(first=Min("occurred_at"), last=Max("occurred_at"))
sales = Sale.objects.filter(project=external, counts_as_sale=True)
sales_period = sales.aggregate(first=Min("occurred_at"), last=Max("occurred_at"))
return Response(
{
"source": external.source_id,
"source_name": external.source.name,
"external_id": external.external_id,
"external_name": external.name,
"code": external.code,
"description": external.description,
"synced_at": external.synced_at,
"source_updated_at": (
source_record.source_updated_at if source_record else None
),
"bound": bound,
"payment_count": sum(row["count"] for row in payment_rows),
"payments": payment_rows,
"payment_first_at": payment_period["first"],
"payment_last_at": payment_period["last"],
"sale_count": sales.count(),
"sale_first_at": sales_period["first"],
"sale_last_at": sales_period["last"],
}
)
@action(detail=False, methods=["post"], url_path="from-external")
def from_external(self, request):
if not can_create(request.user):
raise PermissionDenied(
"Создание проектов доступно администраторам и пользователям с правом создания проектов."
)
payload = ExternalProjectImportSerializer(data=request.data)
payload.is_valid(raise_exception=True)
values = payload.validated_data
with transaction.atomic():
external = get_object_or_404(
ExternalProject.objects.select_for_update(),
source=values["source"],
external_id=values["external_id"],
archived=False,
)
if ProjectExternalBinding.objects.filter(
source=external.source,
object_type="project",
external_id=external.external_id,
).exists():
raise ValidationError(
"Этот внешний проект уже привязан к локальному проекту."
)
project = Project.objects.create(
name=values["name"],
description=values["description"],
customer=values["customer"],
manager=values["manager"],
status=values["status"],
starts_on=values.get("starts_on"),
ends_on=values.get("ends_on"),
created_by=request.user,
)
ProjectMembership.objects.create(
project=project,
employee=project.manager,
role="Руководитель",
starts_on=timezone.localdate(),
)
try:
with transaction.atomic():
ProjectExternalBinding.objects.create(
project=project,
source=external.source,
object_type="project",
external_id=external.external_id,
external_name=external.name,
)
except IntegrityError as exc:
raise ValidationError(
"Этот внешний проект уже привязан к локальному проекту."
) from exc
ProjectEvent.objects.create(
project=project,
actor=request.user,
action="Проект создан из внешнего проекта",
details={
"source": external.source.name,
"external_id": external.external_id,
"external_name": external.name,
},
)
return Response(
ProjectSerializer(project, context={"request": request}).data,
status=201,
)
@action(detail=True, methods=["get", "post", "delete"])
def members(self, request, pk=None):
p = self.get_object()
if request.method == "GET":
return Response(
MembershipSerializer(
p.memberships.select_related("employee"), many=True
).data
)
self.require_manager()
with transaction.atomic():
if request.method == "DELETE":
member = get_object_or_404(
p.memberships, pk=parse_id(request.data.get("id"))
)
name = str(member.employee)
member.delete()
ProjectEvent.objects.create(
project=p,
actor=request.user,
action="Участник удалён",
details={"employee": name},
)
else:
existing = p.memberships.filter(
employee_id=parse_id(request.data.get("employee"))
).first()
s = MembershipSerializer(existing, data=request.data)
s.is_valid(raise_exception=True)
s.save(project=p)
ProjectEvent.objects.create(
project=p,
actor=request.user,
action="Участие обновлено",
details={k: str(v) for k, v in s.validated_data.items()},
)
return Response(
MembershipSerializer(
p.memberships.select_related("employee"), many=True
).data
)
@action(detail=True, methods=["get", "post", "delete"])
def stages(self, request, pk=None):
return self.collection(request, "stages", StageSerializer)
@action(detail=True, methods=["get", "post", "delete"])
def resources(self, request, pk=None):
return self.collection(request, "resources", ResourceSerializer)
@action(detail=True, methods=["get", "post", "delete"], url_path="task-links")
def task_links(self, request, pk=None):
p = self.get_object()
if request.method != "GET":
p = self.require_manager()
content_type = ContentType.objects.get_for_model(Project)
if request.method == "POST":
task_id = parse_id(request.data.get("task_id"))
task = get_object_or_404(
Task.objects.select_related("board", "column").filter(
board__in=TaskBoard.objects.filter(task_board_access_q(request.user))
),
pk=task_id,
)
_, created = link_task_object(
task=task,
actor=request.user,
kind=TaskLinkedObjectKind.PROJECT,
content_type=content_type,
object_id=p.pk,
object_label=p.name,
object_type="Проект",
realtime_type="project",
)
if created:
ProjectEvent.objects.create(
project=p,
actor=request.user,
action="Задача связана",
details={"task_id": str(task.pk), "task": task.title},
)
elif request.method == "DELETE":
link = get_object_or_404(
TaskLinkedObject,
pk=parse_id(request.data.get("id")),
kind=TaskLinkedObjectKind.PROJECT,
content_type=content_type,
object_id=p.pk,
)
task_title = link.task.title
unlink_task_object(
task=link.task,
actor=request.user,
kind=TaskLinkedObjectKind.PROJECT,
link_id=link.pk,
object_type="Проект",
realtime_type="project",
)
ProjectEvent.objects.create(
project=p,
actor=request.user,
action="Связь с задачей удалена",
details={"task": task_title},
)
return Response(
linked_task_badges_for_object(
obj=p,
kind=TaskLinkedObjectKind.PROJECT,
user=request.user,
include_task_card_fields=True,
)
)
@action(detail=True, methods=["get", "post", "delete"], url_path="board-links")
def board_links(self, request, pk=None):
p = self.get_object()
if request.method != "GET":
p = self.require_manager()
accessible = TaskBoard.objects.filter(
is_archived=False
).filter(task_board_access_q(request.user)).distinct()
if request.method == "POST":
board = get_object_or_404(accessible, pk=parse_id(request.data.get("board_id")))
_, created = ProjectBoardLink.objects.get_or_create(
project=p,
board=board,
defaults={"created_by": request.user},
)
if created:
ProjectEvent.objects.create(
project=p,
actor=request.user,
action="Доска связана",
details={"board_id": str(board.pk), "board": board.name},
)
elif request.method == "DELETE":
link = get_object_or_404(
p.board_links.select_related("board"),
pk=parse_id(request.data.get("id")),
)
board_name = link.board.name
link.delete()
ProjectEvent.objects.create(
project=p,
actor=request.user,
action="Связь с доской удалена",
details={"board": board_name},
)
links = p.board_links.filter(board__in=accessible).select_related("board")
return Response(
[
{
"id": link.pk,
"board_id": link.board_id,
"name": link.board.name,
"url": f"/tasks?board={link.board_id}",
}
for link in links
]
)
def collection(self, request, relation, serializer_class):
p = self.get_object()
rows = getattr(p, relation)
if request.method != "GET":
self.require_manager()
with transaction.atomic():
obj = (
get_object_or_404(rows, pk=parse_id(request.data["id"]))
if request.data.get("id")
else None
)
if request.method == "DELETE":
if obj is None:
raise ValidationError("Не указана запись.")
obj.delete()
else:
s = serializer_class(obj, data=request.data)
s.is_valid(raise_exception=True)
s.save(project=p)
ProjectEvent.objects.create(
project=p,
actor=request.user,
action="Этапы обновлены"
if relation == "stages"
else "Связанные материалы обновлены",
)
return Response(serializer_class(rows.all(), many=True).data)
@action(detail=True, methods=["get", "post", "delete"])
def bindings(self, request, pk=None):
p = self.require_manager()
if request.method != "GET":
with transaction.atomic():
if request.method == "DELETE":
b = get_object_or_404(
p.bindings, pk=parse_id(request.data.get("id"))
)
details = {"source": b.source_id, "external_id": b.external_id}
b.delete()
else:
s = BindingSerializer(data=request.data)
s.is_valid(raise_exception=True)
details = {k: str(v) for k, v in s.validated_data.items()}
try:
with transaction.atomic():
s.save(project=p)
except IntegrityError:
raise ValidationError(
"Этот внешний объект уже привязан к локальному проекту."
)
ProjectEvent.objects.create(
project=p,
actor=request.user,
action="Привязки обновлены",
details=details,
)
return Response(
BindingSerializer(p.bindings.select_related("source"), many=True).data
)
@action(detail=True, methods=["get"], url_path="external-options")
def external_options(self, request, pk=None):
self.require_manager()
search = request.query_params.get("q", "")
bound = ProjectExternalBinding.objects.filter(
object_type="project"
).values_list("source_id", "external_id")
identities = set(bound)
rows = (
ExternalProject.objects.filter(name__icontains=search)
.select_related("source")
.order_by("name")[:200]
)
return Response(
{
"sources": list(SourceSystem.objects.values("id", "name")),
"external_projects": [
{
"source": r.source_id,
"source_name": r.source.name,
"external_id": r.external_id,
"external_name": r.name,
"bound": (r.source_id, r.external_id) in identities,
}
for r in rows
],
}
)
@action(detail=True, methods=["get"])
def history(self, request, pk=None):
p = self.require_manager()
return Response(
[
{
"id": e.pk,
"actor": str(e.actor),
"action": e.action,
"details": e.details,
"created_at": e.created_at,
}
for e in p.events.select_related("actor")[:100]
]
)
@action(detail=True, methods=["get"], url_path="time-report")
def time_report(self, request, pk=None):
p = self.get_object()
today = timezone.localdate()
start = parse_date(
request.query_params.get("from", today.replace(day=1).isoformat())
)
end = parse_date(request.query_params.get("to", today.isoformat()))
if end.year >= 9999 or end < start or (end - start).days > 366:
raise ValidationError("Выберите период до 367 дней.")
rows = TimeAllocation.objects.filter(
project=p, day__date__range=[start, end]
).select_related("day__employee")
manager = can_manage(request.user, p)
if not manager:
rows = rows.filter(day__employee=request.user)
results = []
total = Decimal(0)
unknown = drafts = 0
for a in rows.order_by("-day__date", "day__employee_id"):
hours = a.day.hours * a.percent / 100 if a.day.hours is not None else None
if a.day.status == "confirmed":
if hours is None:
unknown += 1
else:
total += hours
else:
drafts += 1
results.append(
{
"date": a.day.date,
"employee": str(a.day.employee),
"percent": a.percent,
"hours": hours,
"status": a.day.status,
"hours_source": a.day.hours_source,
}
)
return Response(
{
"from": start,
"to": end,
"scope": "team" if manager else "self",
"confirmed_hours": total,
"unknown_days": unknown,
"draft_days": drafts,
"rows": results,
}
)
@action(detail=True, methods=["get"])
def finances(self, request, pk=None):
from finance.payroll.access import can_access_payroll_management
from finance.analytics.labor import labor_summary
from finance.analytics.projects import payment_queryset
p = self.get_object()
if not can_access_payroll_management(request.user):
raise PermissionDenied("Нет доступа к финансовой аналитике.")
today = timezone.localdate()
start = parse_date(
request.query_params.get("from", today.replace(month=1, day=1).isoformat())
)
end = parse_date(request.query_params.get("to", today.isoformat()))
if end.year >= 9999 or end < start or (end - start).days > 366:
raise ValidationError("Выберите период до 367 дней.")
currencies = request.query_params.get("currency", "RUB")
currencies = serializers.RegexField(r"^[A-Z]{3}$").run_validation(
currencies.upper()
)
condition = Q(pk__in=[])
for b in p.bindings.filter(object_type="project"):
condition |= Q(source_id=b.source_id, external_id=b.external_id)
external = ExternalProject.objects.filter(condition)
payments = payment_queryset(start, end, currencies.upper()).filter(
project__in=external
)
allocations = TimeAllocation.objects.filter(
project=p,
day__date__range=(start, end),
day__status="confirmed",
).select_related("day__employee")
labor = labor_summary(allocations, currencies.upper())
return Response(
{
"from": start,
"to": end,
"currency": currencies.upper(),
"external_project_count": external.count(),
"payment_count": payments.count(),
"paid": payments.aggregate(total=Sum("amount"))["total"],
"categories": list(
payments.values("expense_category")
.annotate(paid=Sum("amount"))
.order_by("-paid")
),
**labor,
}
)
def parse_id(value):
return serializers.IntegerField(min_value=1).run_validation(value)
def parse_date(value):
s = serializers.DateField()
return s.run_validation(value)
class OwnDayView(PrivateResponseMixin, APIView):
permission_classes = [permissions.IsAuthenticated]
def get(self, request):
day = parse_date(
request.query_params.get("date", timezone.localdate().isoformat())
)
return Response(day_data(request.user, day))
def post(self, request):
s = DaySerializer(data=request.data)
s.is_valid(raise_exception=True)
return Response(save_day(request.user, s.validated_data))
class OwnCalendarView(PrivateResponseMixin, APIView):
permission_classes = [permissions.IsAuthenticated]
def get(self, request):
rows = DailyAllocation.objects.filter(
employee=request.user, date__gte=timezone.localdate() - timedelta(days=90)
)
return Response(
list(rows.values("date", "status", "hours", "hours_source", "version"))
)
class ReminderView(PrivateResponseMixin, APIView):
permission_classes = [permissions.IsAuthenticated]
def post(self, request):
if serializers.BooleanField().run_validation(request.data.get("snooze", False)):
return self.snooze(request)
return Response({"date": claim_reminder(request.user)})
@transaction.atomic
def snooze(self, request):
get_user_model().objects.select_for_update().get(pk=request.user.pk)
AllocationReminder.objects.update_or_create(
employee=request.user,
defaults={
"last_claim_date": None,
"snoozed_until": timezone.now() + timedelta(hours=2),
},
)
return Response({"date": None})

View File

@ -183,6 +183,7 @@ urlpatterns = [
path("commerce/", include("api.v1.commerce.urls")),
# Finance / payroll API
path("finance/", include("api.v1.finance.urls")),
path("projects/", include("api.v1.projects.urls")),
# Tasks API
path("tasks/", include("api.v1.tasks.urls")),
# Guests API

View File

@ -76,3 +76,14 @@ All employees may read. Writes require staff status or the corresponding
Django model permission. The frontend management screen can bootstrap and
correct normalized records manually; integrations should still write them
through application services so raw provenance and idempotency are preserved.
## Local project management
Local project cards, teams and daily participation live in the sibling
[`projects`](../projects/README.md) app. `ProjectExternalBinding` explicitly maps
source object identities to local projects. Commerce synchronization preserves
external projects and payments and never overwrites local project management data.
Active external projects without a binding appear in the `/projects` intake queue,
where a manager can create or select their local project explicitly.
Company analytics groups imported sales, costs and project payments by these exact
bindings. Unbound source records remain visible as a separate reconciliation group.

View File

@ -79,6 +79,7 @@ INSTALLED_APPS = [
"finance.apps.FinanceConfig",
"procurement.apps.ProcurementConfig",
"commerce.apps.CommerceConfig",
"projects.apps.ProjectsConfig",
"tasks.apps.TasksConfig",
"guests.apps.GuestsConfig",
"push_notifications", # django-push-notifications для Web Push

View File

@ -0,0 +1,311 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from decimal import Decimal
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
from finance.enums import ApprovalStatus, PayrollPeriodStatus, PayrollRunStatus
from finance.models import EmployeePayRate, PayrollPeriod, PayrollStatement
from finance.payroll.config import base_rate_code
ZERO = Decimal("0")
EXACT = "exact"
PRELIMINARY = "preliminary"
UNPRICED = "unpriced"
@dataclass(frozen=True)
class PricedLabor:
hours: Decimal
cost: Decimal | None
cost_status: str
def _period_for(day, periods):
candidates = [period for period in periods if period.date_from <= day <= period.date_to]
if not candidates:
return None
return min(
candidates,
key=lambda period: (
(period.date_to - period.date_from).days,
-period.date_from.toordinal(),
-period.pk,
),
)
def labor_cost_status(exact_hours, preliminary_hours):
if exact_hours and preliminary_hours:
return "mixed"
if preliminary_hours:
return PRELIMINARY
if exact_hours:
return EXACT
return "unavailable"
def _attendance_hours(periods, employee_ids):
norm_hours = defaultdict(lambda: ZERO)
actual_hours = defaultdict(lambda: ZERO)
if not periods:
return norm_hours, actual_hours
attendance = AttendanceRecord.objects.filter(
employee_id__in=employee_ids,
date__range=(
min(period.date_from for period in periods),
max(period.date_to for period in periods),
),
analysis_run__status=AttendanceAnalysisRun.STATUS_SUCCESS,
).only(
"employee_id",
"date",
"work_hours",
"expected_hours",
"effective_is_workday",
)
for record in attendance.iterator(chunk_size=1000):
for period in periods:
if not period.date_from <= record.date <= period.date_to:
continue
key = (period.pk, record.employee_id)
if record.effective_is_workday and record.expected_hours:
norm_hours[key] += Decimal(str(record.expected_hours))
if record.work_hours and record.work_hours > 0:
actual_hours[key] += Decimal(str(record.work_hours))
return norm_hours, actual_hours
def _payroll_statements(periods, employee_ids, currency):
run_ids = {period.current_run_id for period in periods if period.current_run_id}
return {
(statement.run_id, statement.employee_id): statement
for statement in PayrollStatement.objects.filter(
run_id__in=run_ids,
employee_id__in=employee_ids,
currency=currency,
).only("run_id", "employee_id", "gross_total")
}
def _approved_rates(employee_ids, currency, end):
rates = defaultdict(list)
queryset = (
EmployeePayRate.objects.filter(
employee_id__in=employee_ids,
rate_code=base_rate_code(),
status=ApprovalStatus.APPROVED,
currency=currency,
effective_from__lte=end,
)
.only("pk", "employee_id", "amount", "effective_from", "revision")
.order_by("employee_id", "effective_from", "revision", "pk")
)
for rate in queryset:
rates[rate.employee_id].append(rate)
return rates
def _preliminary_amount(rates, statement, employee_id, day):
if statement is not None:
return statement.gross_total
applicable = [rate for rate in rates[employee_id] if rate.effective_from <= day]
if not applicable:
return None
return max(
applicable,
key=lambda item: (item.effective_from, item.revision, item.pk),
).amount
def price_labor_allocations(allocations, currency: str) -> dict[int, PricedLabor]:
"""Price confirmed project hours against the employee payroll period.
An open period is always preliminary. Its current payroll calculation is
used when available, otherwise the approved base rate is used. A period
becomes exact only after closing; the immutable final gross statement then
includes bonuses, additional earnings and payroll adjustments. Deductions
and advance-payment mechanics do not reduce project labour cost.
"""
allocations = list(allocations)
known = [allocation for allocation in allocations if allocation.day.hours is not None]
if not known:
return {}
start = min(allocation.day.date for allocation in known)
end = max(allocation.day.date for allocation in known)
employee_ids = {allocation.day.employee_id for allocation in known}
periods = list(
PayrollPeriod.objects.filter(
currency=currency,
date_from__lte=end,
date_to__gte=start,
)
.select_related("current_run")
.order_by("date_from", "date_to", "pk")
)
period_by_allocation = {
allocation.pk: _period_for(allocation.day.date, periods)
for allocation in known
}
used_periods = {
period.pk: period
for period in period_by_allocation.values()
if period is not None
}
period_list = list(used_periods.values())
norm_hours, actual_hours = _attendance_hours(period_list, employee_ids)
statements = _payroll_statements(period_list, employee_ids, currency)
rates = _approved_rates(employee_ids, currency, end)
result = {}
for allocation in known:
employee_id = allocation.day.employee_id
hours = allocation.day.hours * allocation.percent / Decimal("100")
period = period_by_allocation[allocation.pk]
if period is None:
result[allocation.pk] = PricedLabor(hours, None, UNPRICED)
continue
statement = (
statements.get((period.current_run_id, employee_id))
if period.current_run_id
and period.current_run.status
not in [PayrollRunStatus.RETURNED, PayrollRunStatus.SUPERSEDED]
else None
)
is_exact = (
period.status == PayrollPeriodStatus.CLOSED
and period.current_run is not None
and period.current_run.status == PayrollRunStatus.PUBLISHED
and statement is not None
)
if is_exact:
denominator = actual_hours[(period.pk, employee_id)]
if denominator <= 0:
result[allocation.pk] = PricedLabor(hours, None, UNPRICED)
else:
result[allocation.pk] = PricedLabor(
hours,
hours * statement.gross_total / denominator,
EXACT,
)
continue
# A closed period without its final published statement must remain
# visibly unpriced. Falling back to a rate would present an estimate as
# a final amount.
if period.status == PayrollPeriodStatus.CLOSED:
result[allocation.pk] = PricedLabor(hours, None, UNPRICED)
continue
denominator = norm_hours[(period.pk, employee_id)]
amount = _preliminary_amount(
rates, statement, employee_id, allocation.day.date
)
if amount is None or denominator <= 0:
result[allocation.pk] = PricedLabor(hours, None, UNPRICED)
else:
result[allocation.pk] = PricedLabor(
hours,
hours * amount / denominator,
PRELIMINARY,
)
return result
def labor_summary(allocations, currency: str) -> dict:
allocations = list(allocations)
priced = price_labor_allocations(allocations, currency)
employees = defaultdict(
lambda: {
"hours": ZERO,
"cost": ZERO,
"exact_cost": ZERO,
"preliminary_cost": ZERO,
"exact_hours": ZERO,
"preliminary_hours": ZERO,
"unpriced_hours": ZERO,
"unknown_days": 0,
}
)
total_hours = ZERO
total_cost = ZERO
exact_cost = ZERO
preliminary_cost = ZERO
exact_hours = ZERO
preliminary_hours = ZERO
unpriced_hours = ZERO
unknown_days = set()
for allocation in allocations:
employee = allocation.day.employee
label = employee.get_full_name().strip() or str(employee)
row = employees[(employee.pk, label)]
if allocation.day.hours is None:
row["unknown_days"] += 1
unknown_days.add(allocation.day_id)
continue
item = priced.get(allocation.pk)
hours = allocation.day.hours * allocation.percent / Decimal("100")
total_hours += hours
row["hours"] += hours
if item is None or item.cost is None:
unpriced_hours += hours
row["unpriced_hours"] += hours
continue
total_cost += item.cost
row["cost"] += item.cost
if item.cost_status == EXACT:
exact_cost += item.cost
exact_hours += hours
row["exact_cost"] += item.cost
row["exact_hours"] += hours
else:
preliminary_cost += item.cost
preliminary_hours += hours
row["preliminary_cost"] += item.cost
row["preliminary_hours"] += hours
complete = not unknown_days and unpriced_hours == 0
return {
"labor_hours": total_hours,
"labor_cost": total_cost,
"exact_labor_cost": exact_cost,
"preliminary_labor_cost": preliminary_cost,
"exact_labor_hours": exact_hours,
"preliminary_labor_hours": preliminary_hours,
"labor_cost_status": labor_cost_status(exact_hours, preliminary_hours),
"labor_hour_cost": (
total_cost / total_hours if complete and total_hours > 0 else None
),
"unpriced_labor_hours": unpriced_hours,
"unknown_hours_days": len(unknown_days),
"labor_cost_complete": complete,
"labor": [
{
"employee_id": employee_id,
"employee": label,
**values,
"cost_status": labor_cost_status(
values["exact_hours"], values["preliminary_hours"]
),
"cost_complete": not values["unknown_days"]
and values["unpriced_hours"] == 0,
"hour_cost": (
values["cost"] / values["hours"]
if not values["unknown_days"]
and values["unpriced_hours"] == 0
and values["hours"] > 0
else None
),
}
for (employee_id, label), values in sorted(
employees.items(), key=lambda item: item[0][1]
)
],
}

View File

@ -24,9 +24,12 @@ class ReportingEntry(models.Model):
date = models.DateField("Дата факта", db_index=True)
reference = models.CharField("Документ / уникальный номер", max_length=200)
description = models.CharField("Описание", max_length=500, blank=True)
project = models.ForeignKey(
external_project = models.ForeignKey(
"commerce.ExternalProject", null=True, blank=True, on_delete=models.PROTECT
)
project = models.ForeignKey(
"projects.Project", null=True, blank=True, on_delete=models.PROTECT
)
product = models.ForeignKey(
"commerce.Product", null=True, blank=True, on_delete=models.PROTECT
)

View File

@ -1,12 +1,88 @@
from collections import defaultdict
from dataclasses import dataclass
from decimal import Decimal
from django.db.models import Q
from django.utils import timezone
from commerce.models import ExternalProject, ProjectPayment
from finance.analytics.models import ReportingEntry
from finance.analytics.labor import labor_cost_status, price_labor_allocations
from finance.analytics.sales import ZERO, bounds, bucket, finish, months
from projects.models import Project, ProjectExternalBinding, TimeAllocation
def payment_queryset(start, end, currency, source=None, project=None):
@dataclass
class LocalProjectScope:
projects: list[Project]
external_to_local: dict[int, int]
external_ids: set[int]
selected_external_ids: set[int] | None
include_projectless: bool
selected_project: Project | str | None
source_id: int | None
@property
def is_filtered(self):
return self.selected_project is not None
def local_project_scope(source=None, project=None):
external = ExternalProject.objects.all()
bindings = ProjectExternalBinding.objects.filter(object_type="project")
local = Project.objects.all()
if source:
external = external.filter(source=source)
bindings = bindings.filter(source=source)
local = local.filter(
bindings__source=source, bindings__object_type="project"
).distinct()
external_by_identity = {
(row.source_id, row.external_id): row.pk for row in external.iterator()
}
external_to_local = {}
for binding in bindings.iterator():
external_id = external_by_identity.get((binding.source_id, binding.external_id))
if external_id:
external_to_local[external_id] = binding.project_id
external_ids = set(external_by_identity.values())
selected_external_ids = None
include_projectless = True
if project == "unassigned":
local = local.none()
selected_external_ids = external_ids - set(external_to_local)
elif project is not None:
local = local.filter(pk=project.pk)
selected_external_ids = {
external_id
for external_id, local_id in external_to_local.items()
if local_id == project.pk
}
include_projectless = False
return LocalProjectScope(
projects=list(local.select_related("manager").order_by("name", "pk")),
external_to_local=external_to_local,
external_ids=external_ids,
selected_external_ids=selected_external_ids,
include_projectless=include_projectless,
selected_project=project,
source_id=source.pk if source else None,
)
def filter_external_projects(qs, scope, field="project_id"):
if not scope.is_filtered:
return qs
condition = Q(**{f"{field}__in": scope.selected_external_ids})
if scope.include_projectless:
condition |= Q(**{f"{field}__isnull": True})
return qs.filter(condition)
def payment_queryset(start, end, currency, source=None, project=None, scope=None):
lower, upper = bounds(start, end)
qs = ProjectPayment.objects.filter(
currency=currency,
@ -17,98 +93,293 @@ def payment_queryset(start, end, currency, source=None, project=None):
).select_related("project", "source")
if source:
qs = qs.filter(source=source)
if project is not None:
if scope is not None:
qs = filter_external_projects(qs, scope)
elif project is not None:
qs = qs.filter(project_id=None if project == "unassigned" else project.pk)
return qs
def journal_queryset(start, end, currency, source, project):
entries = ReportingEntry.objects.filter(
def journal_queryset(start, end, currency):
return ReportingEntry.objects.filter(
date__range=(start, end),
currency=currency,
voided_at__isnull=True,
kind__in=["cash_out", "overhead", "purchase"],
).select_related("project")
if source:
entries = entries.filter(project__source=source)
if project is not None:
entries = entries.filter(
project_id=None if project == "unassigned" else project.pk
).select_related("project", "external_project")
def project_row(rows, key, name):
if key not in rows:
rows[key] = {
**bucket(),
"id": key,
"name": name,
"paid": ZERO,
"payment_count": 0,
"manual_paid": ZERO,
"overhead": ZERO,
"purchases": ZERO,
"labor_hours": ZERO,
"labor_cost": ZERO,
"exact_labor_cost": ZERO,
"preliminary_labor_cost": ZERO,
"exact_labor_hours": ZERO,
"preliminary_labor_hours": ZERO,
"unpriced_labor_hours": ZERO,
"unknown_hours_days": 0,
"people_count": 0,
"allocation_days": 0,
"external_project_count": 0,
"archived": False,
}
return rows[key]
def local_target(scope, local_by_id, external_id=None, local_id=None):
local_id = local_id or (
scope.external_to_local.get(external_id) if external_id else None
)
local = local_by_id.get(local_id)
return (
(f"project:{local.pk}", local.name) if local else ("unassigned", "Без привязки")
)
def add_catalog(rows, scope):
for local in scope.projects:
row = project_row(rows, f"project:{local.pk}", local.name)
row["archived"] = local.status == Project.Status.ARCHIVED
row["external_project_count"] = sum(
local_id == local.pk for local_id in scope.external_to_local.values()
)
return entries
def projects_report(start, end, currency, sales, source=None, project=None):
rows = {}
def row_for(key, name):
if key not in rows:
rows[key] = {
**finish(bucket()),
"id": key,
"name": name,
"paid": ZERO,
"payment_count": 0,
"manual_paid": ZERO,
"overhead": ZERO,
"purchases": ZERO,
"archived": False,
}
return rows[key]
catalog = ExternalProject.objects.all()
if source:
catalog = catalog.filter(source=source)
if project is not None:
catalog = (
catalog.none() if project == "unassigned" else catalog.filter(pk=project.pk)
)
for p in catalog:
row_for(f"project:{p.pk}", p.name)["archived"] = p.archived
def add_sales(rows, sales, scope, local_by_id):
fields = [
"quantity",
"revenue",
"cost_of_goods",
"expenses",
"missing_cost_lines",
"sale_lines",
]
for sale_row in sales["projects"]:
row_for(sale_row["id"], sale_row["name"]).update(sale_row)
monthly = {
m: {"id": m, "name": m, "paid": ZERO, "manual_paid": ZERO}
for m in months(start, end)
}
categories = defaultdict(lambda: {"paid": ZERO, "payment_count": 0})
for p in payment_queryset(start, end, currency, source, project).iterator(
chunk_size=500
):
row = row_for(
f"project:{p.project_id}" if p.project_id else "unassigned",
p.project.name if p.project else "Без проекта",
row_id = str(sale_row["id"])
external_id = (
int(row_id.split(":", 1)[1]) if row_id.startswith("project:") else None
)
row["paid"] += p.amount
key, name = local_target(scope, local_by_id, external_id=external_id)
row = project_row(rows, key, name)
for field in fields:
row[field] += sale_row[field]
def add_payments(rows, monthly, categories, payments, scope, local_by_id):
for payment in payments.iterator(chunk_size=500):
key, name = local_target(scope, local_by_id, external_id=payment.project_id)
row = project_row(rows, key, name)
row["paid"] += payment.amount
row["payment_count"] += 1
monthly[timezone.localtime(p.occurred_at).strftime("%Y-%m")]["paid"] += p.amount
category = categories[p.expense_category or "Статья не указана"]
category["paid"] += p.amount
month = timezone.localtime(payment.occurred_at).strftime("%Y-%m")
monthly[month]["paid"] += payment.amount
category = categories[payment.expense_category or "Статья не указана"]
category["paid"] += payment.amount
category["payment_count"] += 1
for entry in journal_queryset(start, end, currency, source, project):
row = row_for(
f"project:{entry.project_id}" if entry.project_id else "unassigned",
entry.project.name if entry.project else "Без проекта",
)
field = {
"cash_out": "manual_paid",
"overhead": "overhead",
"purchase": "purchases",
}[entry.kind]
row[field] += entry.amount
def journal_local_id(entry, scope):
return entry.project_id or (
scope.external_to_local.get(entry.external_project_id)
if entry.external_project_id
else None
)
def journal_entry_in_scope(local_id, project, scope, local_by_id):
if project == "unassigned":
return not local_id
if project is not None:
return local_id == project.pk
return not scope.source_id or local_id in local_by_id
def add_journal(rows, monthly, entries, project, scope, local_by_id):
fields = {
"cash_out": "manual_paid",
"overhead": "overhead",
"purchase": "purchases",
}
for entry in entries:
local_id = journal_local_id(entry, scope)
if not journal_entry_in_scope(local_id, project, scope, local_by_id):
continue
key, name = local_target(scope, local_by_id, local_id=local_id)
row = project_row(rows, key, name)
row[fields[entry.kind]] += entry.amount
if entry.kind == "cash_out":
monthly[entry.date.strftime("%Y-%m")]["manual_paid"] += entry.amount
summary = {
field: sum((r[field] for r in rows.values()), ZERO)
for field in ["paid", "manual_paid", "overhead", "purchases", "payment_count"]
def add_allocations(rows, monthly, allocations, currency):
allocations = list(allocations)
priced = price_labor_allocations(allocations, currency)
row_days = defaultdict(set)
row_people = defaultdict(set)
all_days = set()
all_people = set()
for allocation in allocations:
key = f"project:{allocation.project_id}"
row = project_row(rows, key, allocation.project.name)
row_days[key].add(allocation.day_id)
row_people[key].add(allocation.day.employee_id)
all_days.add(allocation.day_id)
all_people.add(allocation.day.employee_id)
if allocation.day.hours is None:
row["unknown_hours_days"] += 1
continue
hours = allocation.day.hours * allocation.percent / Decimal("100")
row["labor_hours"] += hours
monthly[allocation.day.date.strftime("%Y-%m")]["labor_hours"] += hours
labor = priced.get(allocation.pk)
if labor is None or labor.cost is None:
row["unpriced_labor_hours"] += hours
monthly[allocation.day.date.strftime("%Y-%m")][
"unpriced_labor_hours"
] += hours
else:
row["labor_cost"] += labor.cost
monthly[allocation.day.date.strftime("%Y-%m")]["labor_cost"] += labor.cost
if labor.cost_status == "exact":
row["exact_labor_cost"] += labor.cost
row["exact_labor_hours"] += hours
monthly[allocation.day.date.strftime("%Y-%m")][
"exact_labor_cost"
] += labor.cost
monthly[allocation.day.date.strftime("%Y-%m")][
"exact_labor_hours"
] += hours
else:
row["preliminary_labor_cost"] += labor.cost
row["preliminary_labor_hours"] += hours
monthly[allocation.day.date.strftime("%Y-%m")][
"preliminary_labor_cost"
] += labor.cost
monthly[allocation.day.date.strftime("%Y-%m")][
"preliminary_labor_hours"
] += hours
for key, row in rows.items():
row["people_count"] = len(row_people[key])
row["allocation_days"] = len(row_days[key])
row.update(finish(row))
row["labor_cost_complete"] = not (
row["unknown_hours_days"] or row["unpriced_labor_hours"]
)
row["labor_cost_status"] = labor_cost_status(
row["exact_labor_hours"], row["preliminary_labor_hours"]
)
row["labor_hour_cost"] = (
row["labor_cost"] / row["labor_hours"]
if row["labor_cost_complete"] and row["labor_hours"] > 0
else None
)
row["project_result"] = (
row["profit"] - row["overhead"] - row["labor_cost"]
if row["profit"] is not None and row["labor_cost_complete"]
else None
)
for month in monthly.values():
month["labor_cost_status"] = labor_cost_status(
month["exact_labor_hours"], month["preliminary_labor_hours"]
)
return all_people, all_days
def projects_report(start, end, currency, sales, source=None, project=None, scope=None):
scope = scope or local_project_scope(source, project)
local_by_id = {item.pk: item for item in scope.projects}
rows = {}
monthly = {
month: {
"id": month,
"name": month,
"paid": ZERO,
"manual_paid": ZERO,
"labor_hours": ZERO,
"labor_cost": ZERO,
"exact_labor_cost": ZERO,
"preliminary_labor_cost": ZERO,
"exact_labor_hours": ZERO,
"preliminary_labor_hours": ZERO,
"unpriced_labor_hours": ZERO,
}
for month in months(start, end)
}
summary["unassigned_paid"] = rows.get("unassigned", {}).get("paid", ZERO)
summary["project_count"] = catalog.count()
categories = defaultdict(lambda: {"paid": ZERO, "payment_count": 0})
add_catalog(rows, scope)
add_sales(rows, sales, scope, local_by_id)
payments = payment_queryset(start, end, currency, source, scope=scope)
add_payments(rows, monthly, categories, payments, scope, local_by_id)
add_journal(
rows,
monthly,
journal_queryset(start, end, currency),
project,
scope,
local_by_id,
)
allocations = TimeAllocation.objects.filter(
day__date__range=(start, end),
day__status="confirmed",
project__in=scope.projects,
).select_related("day", "project")
all_people, all_days = add_allocations(rows, monthly, allocations, currency)
summary_fields = [
"paid",
"manual_paid",
"overhead",
"purchases",
"payment_count",
"labor_hours",
"labor_cost",
"exact_labor_cost",
"preliminary_labor_cost",
"exact_labor_hours",
"preliminary_labor_hours",
"unpriced_labor_hours",
"unknown_hours_days",
]
summary = {
field: sum((row[field] for row in rows.values()), ZERO)
for field in summary_fields
}
labor_cost_complete = not any(
row["unknown_hours_days"] or row["unpriced_labor_hours"]
for row in rows.values()
)
summary.update(
unassigned_paid=rows.get("unassigned", {}).get("paid", ZERO),
project_count=len(scope.projects),
people_count=len(all_people),
allocation_days=len(all_days),
labor_cost_complete=labor_cost_complete,
labor_cost_status=labor_cost_status(
summary["exact_labor_hours"], summary["preliminary_labor_hours"]
),
labor_hour_cost=(
summary["labor_cost"] / summary["labor_hours"]
if labor_cost_complete and summary["labor_hours"] > 0
else None
),
)
return {
"summary": summary,
"rows": sorted(rows.values(), key=lambda r: (-r["paid"], r["name"])),
"rows": sorted(rows.values(), key=lambda row: (-row["paid"], row["name"])),
"monthly": list(monthly.values()),
"categories": [
{"id": k, "name": k, **v} for k, v in sorted(categories.items())
{"id": key, "name": key, **value}
for key, value in sorted(categories.items())
],
}

View File

@ -76,7 +76,34 @@ def project_costs(costs, project_id):
)
def sales_report(start, end, currency, source=None, product_ids=None, project=None):
def filter_project_records(lines, costs, project, project_scope):
if project_scope is not None and project_scope.is_filtered:
line_filter = Q(sale__project_id__in=project_scope.selected_external_ids)
cost_filter = Q(project_id__in=project_scope.selected_external_ids) | Q(
project__isnull=True,
sale_line__sale__project_id__in=project_scope.selected_external_ids,
)
if project_scope.include_projectless:
line_filter |= Q(sale__project__isnull=True)
cost_filter |= Q(
project__isnull=True, sale_line__sale__project__isnull=True
)
return lines.filter(line_filter), costs.filter(cost_filter)
if project is None:
return lines, costs
project_id = None if project == "unassigned" else project.pk
return lines.filter(sale__project_id=project_id), project_costs(costs, project_id)
def sales_report(
start,
end,
currency,
source=None,
product_ids=None,
project=None,
project_scope=None,
):
lower, upper = bounds(start, end)
lines = SaleLine.objects.filter(
sale__counts_as_sale=True,
@ -101,10 +128,7 @@ def sales_report(start, end, currency, source=None, product_ids=None, project=No
lines = lines.filter(sale__source_id=source)
costs = costs.filter(source_id=source)
if project is not None:
project_id = None if project == "unassigned" else project.pk
lines = lines.filter(sale__project_id=project_id)
costs = project_costs(costs, project_id)
lines, costs = filter_project_records(lines, costs, project, project_scope)
if product_ids is not None:
lines = lines.filter(product_id__in=product_ids)

View File

@ -0,0 +1,45 @@
import django.db.models.deletion
from django.db import migrations, models
def bind_existing_entries(apps, schema_editor):
ReportingEntry = apps.get_model("finance", "ReportingEntry")
Binding = apps.get_model("projects", "ProjectExternalBinding")
bindings = {
(binding.source_id, binding.external_id): binding.project_id
for binding in Binding.objects.filter(object_type="project").iterator()
}
for entry in ReportingEntry.objects.exclude(
external_project_id=None
).select_related("external_project"):
entry.project_id = bindings.get(
(entry.external_project.source_id, entry.external_project.external_id)
)
if entry.project_id:
entry.save(update_fields=["project"])
class Migration(migrations.Migration):
dependencies = [
("finance", "0033_reportingentry_project"),
("projects", "0001_initial"),
]
operations = [
migrations.RenameField(
model_name="reportingentry",
old_name="project",
new_name="external_project",
),
migrations.AddField(
model_name="reportingentry",
name="project",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="projects.project",
),
),
migrations.RunPython(bind_existing_entries, migrations.RunPython.noop),
]

119
backend/projects/README.md Normal file
View File

@ -0,0 +1,119 @@
# Локальные проекты и участие сотрудников
`projects` — самостоятельный Django-модуль корпоративного портала. Проект создаётся
локально и не требует подключения МойСклад. Торговые модели и действующая
синхронизация остаются в `commerce`.
## Структура
- `models/project.py`: карточка проекта, история настроек и связанные материалы.
- `models/membership.py`: участники, роли, даты участия и плановая загрузка.
- `models/stage.py`: этапы и сроки.
- `models/external_binding.py`: связь локального проекта с внешним объектом.
- `models/time_allocation.py`: день сотрудника, распределение, версии и напоминания.
- `services/time_tracking.py`: валидация распределения, снимок часов, напоминания.
- `permissions.py`: доступ к проектам и управлению ими.
- `api/v1/projects/`: API; `frontend/src/app/projects/`: список и карточка.
## Доступ и история
Создание доступно администраторам и пользователям с `projects.add_project`.
Руководитель управляет своей карточкой, командой, этапами, связями и видит отчёт
команды. Участник видит проект и собственные строки времени. Администратор видит
все проекты. `?mine=true` ограничивает список собственными назначениями и для
администратора. Финансовый endpoint отдельно проверяет действующие права
`can_access_payroll_management`; руководство проектом не открывает зарплаты.
Все ответы API имеют `Cache-Control: private, no-store`.
Проекты архивируются, API не удаляет их. Участие завершается датой окончания,
чтобы сохранить контекст. API сохраняет события изменений карточки и связей;
версии распределения времени хранят полный снимок процентов, часов, комментария
и статуса. В админке дни и версии доступны только для чтения.
## Внешние связи
Идентичность объекта — `(source, object_type, external_id)`. Один внешний объект
может принадлежать одному локальному проекту; локальный проект может объединять
несколько внешних объектов. Названия не служат ключом и автоматически не
объединяются. Привязка не изменяет внешнюю запись, перенос привязки требует явного
удаления прежней связи. Синхронизация `commerce` не записывает локальные проекты.
Для проектов МойСклад есть поиск по импортированному справочнику, включая
индикацию занятых привязок. Главная страница `/projects` отдельно показывает
активные внешние проекты без связи. Из этой очереди руководитель может одной
операцией создать локальную карточку вместе с привязкой либо связать внешний
проект с уже существующей карточкой. Создание выполняется транзакционно и не
изменяет исходную запись МойСклад. Для будущих источников можно сохранять
привязки по ID и типу объекта. Это хранение соответствий, а не реализация новых
адаптеров.
Финансовая вкладка карточки и корпоративная аналитика выбирают проведённые, не
удалённые из источника исходящие платежи по точным связанным идентичностям и
одной валюте. Корпоративный отчёт использует локальную карточку как основную
строку, объединяет в ней продажи, расходы и платежи всех привязанных объектов и
показывает неподключённые данные в группе «Без привязки». Подтверждённое
распределение сотрудников добавляется в часы, дни учёта и число участников.
Движение денег, расходы продаж и трудозатраты остаются отдельными показателями.
## Ежедневное распределение
`/projects`, профиль и ежедневная «Выработка» открывают один модал. В нём есть
дата, проекты сотрудника, общая деятельность (работа отдела, обучение, поддержка),
проценты и комментарий. Подтверждение требует ровно 100%; черновик допускает
меньшую сумму. Повторяющиеся строки и сумма больше 100% отклоняются. Повторение
прошлого подтверждённого дня за 90 дней фильтрует недоступные назначения и требует
ручного подтверждения нового дня. Можно редактировать дни за прошедшие 366 дней.
Доступные назначения учитывают даты участия. Новые записи не относятся к
завершённым и архивным проектам. Ранее сохранённые строки можно исправлять с
сохранением версии. Продолжительность берётся из успешного анализа посещаемости
с положительным `work_hours`. Без него сохраняются проценты, часы остаются `null`.
Это часы по данным посещаемости, не автоматически подтверждённый трудозатратный
табель. Изменение посещаемости после сохранения отмечается при открытии дня;
повторное сохранение создаёт новую версию с обновлёнными часами.
Часы участия = часы конкретного дня × процент / 100. В итог входят только
подтверждённые дни с известными часами; черновики и дни без часов отмечены отдельно.
Средние дневные проценты не используются вместо суммы часов. Часы и баллы
выработки не смешиваются, зарплата от ввода процентов не изменяется.
Сохранение блокирует строку сотрудника, проверяет версию и атомарно заменяет
строки дня вместе со снимком истории. Для конкурентного доступа предусмотрен
PostgreSQL; SQLite в локальной разработке не реализует `SELECT FOR UPDATE`.
## Напоминания
Общая оболочка портала проверяет напоминание после входа и раз в минуту, когда
вкладка видима, другое окно не открыто и пользователь не вводит текст. Сервер
выбирает последний незаполненный день за 14 дней, для которого есть успешная
посещаемость с положительными часами и доступный проект. Используются только
прошедшие даты, поэтому сегодняшняя/ночная смена не предлагается до завершения
календарного дня. Выходные и отсутствия не выводятся из предположения о пятидневке.
Право показа резервируется на сервере один раз за день, общим для устройств.
«Напомнить через 2 часа» откладывает следующую попытку; закрытие оставляет день
незаполненным, доступным из «Мой день». Нужна открытая вкладка портала: отдельных
email/push-рассылок эта версия не отправляет. Отключение напоминаний конкретному
сотруднику доступно в админке `AllocationReminder.enabled`.
## Связи с другими разделами и границы версии
Задачи, закупки и документы присоединяются внутренними ссылками из карточки
проекта. Доступ к оригиналу проверяется его модулем. Пока это явные ссылки, а не
обязательное поле проекта в формах создания каждого из этих объектов.
Плановые денежные бюджеты, распределение зарплаты и общих затрат, согласование
трудозатрат руководителем и закрытие финансовых периодов этой версией не вводятся.
Они требуют отдельных правил расчёта и будут строиться поверх сохранённого
участия. Исходная книга Excel не импортируется этим модулем.
## Проверка
```sh
cd backend
.venv/bin/python manage.py migrate
.venv/bin/python manage.py check
.venv/bin/python -m pytest tests/projects tests/finance/test_projects.py -q
```
Frontend: `npm run build`, `npx tsc --noEmit`, целевой ESLint для новых компонентов.

View File

52
backend/projects/admin.py Normal file
View File

@ -0,0 +1,52 @@
from django.contrib import admin
from .models import (
Project,
ProjectMembership,
ProjectStage,
ProjectExternalBinding,
ProjectEvent,
DailyAllocation,
AllocationRevision,
AllocationReminder,
)
class MembershipInline(admin.TabularInline):
model = ProjectMembership
extra = 0
class StageInline(admin.TabularInline):
model = ProjectStage
extra = 0
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
list_display = ["name", "manager", "status", "starts_on", "ends_on"]
list_filter = ["status"]
search_fields = ["name", "customer"]
inlines = [MembershipInline, StageInline]
@admin.register(ProjectExternalBinding)
class BindingAdmin(admin.ModelAdmin):
list_display = ["project", "source", "external_name", "external_id"]
search_fields = ["project__name", "external_name", "external_id"]
class ReadOnlyAdmin(admin.ModelAdmin):
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
admin.site.register(ProjectEvent, ReadOnlyAdmin)
admin.site.register(DailyAllocation, ReadOnlyAdmin)
admin.site.register(AllocationRevision, ReadOnlyAdmin)
admin.site.register(AllocationReminder)

7
backend/projects/apps.py Normal file
View File

@ -0,0 +1,7 @@
from django.apps import AppConfig
class ProjectsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "projects"
verbose_name = "Проекты"

View File

@ -0,0 +1,478 @@
# Generated by Django 5.2.4 on 2026-09-07 09:28
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("attendance", "0006_attendanceautosyncsettings"),
("commerce", "0014_externalproject_projectpayment_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="AllocationReminder",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("last_claim_date", models.DateField(null=True)),
("snoozed_until", models.DateTimeField(null=True)),
("enabled", models.BooleanField(default=True)),
(
"employee",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.CreateModel(
name="DailyAllocation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("date", models.DateField()),
(
"status",
models.CharField(
choices=[("draft", "Черновик"), ("confirmed", "Подтверждено")],
default="draft",
max_length=12,
),
),
(
"hours",
models.DecimalField(
blank=True, decimal_places=3, max_digits=6, null=True
),
),
("hours_source", models.CharField(default="unknown", max_length=30)),
("note", models.TextField(blank=True)),
("version", models.PositiveIntegerField(default=0)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"attendance_record",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="attendance.attendancerecord",
),
),
(
"employee",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="project_days",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-date"],
},
),
migrations.CreateModel(
name="AllocationRevision",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("snapshot", models.JSONField()),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"actor",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
(
"day",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="revisions",
to="projects.dailyallocation",
),
),
],
),
migrations.CreateModel(
name="Project",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255, verbose_name="Название")),
(
"description",
models.TextField(blank=True, verbose_name="Цель и описание"),
),
(
"customer",
models.CharField(
blank=True, max_length=255, verbose_name="Заказчик"
),
),
(
"status",
models.CharField(
choices=[
("planned", "Планируется"),
("active", "В работе"),
("paused", "Приостановлен"),
("completed", "Завершён"),
("archived", "Архив"),
],
default="planned",
max_length=16,
),
),
("starts_on", models.DateField(blank=True, null=True)),
("ends_on", models.DateField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"created_by",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="created_projects",
to=settings.AUTH_USER_MODEL,
),
),
(
"manager",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="managed_projects",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Локальный проект",
"verbose_name_plural": "Локальные проекты",
"ordering": ["name", "pk"],
},
),
migrations.CreateModel(
name="ProjectEvent",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("action", models.CharField(max_length=100)),
("details", models.JSONField(default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"actor",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="events",
to="projects.project",
),
),
],
options={
"ordering": ["-created_at", "-id"],
},
),
migrations.CreateModel(
name="ProjectExternalBinding",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("external_id", models.CharField(max_length=255)),
("object_type", models.CharField(default="project", max_length=50)),
("external_name", models.CharField(blank=True, max_length=255)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="bindings",
to="projects.project",
),
),
(
"source",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to="commerce.sourcesystem",
),
),
],
),
migrations.CreateModel(
name="ProjectMembership",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("role", models.CharField(default="Участник", max_length=100)),
(
"planned_percent",
models.DecimalField(decimal_places=2, default=0, max_digits=5),
),
("starts_on", models.DateField(blank=True, null=True)),
("ends_on", models.DateField(blank=True, null=True)),
(
"employee",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="project_memberships",
to=settings.AUTH_USER_MODEL,
),
),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="memberships",
to="projects.project",
),
),
],
),
migrations.CreateModel(
name="ProjectResource",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=255)),
(
"kind",
models.CharField(
choices=[
("task", "Задача"),
("procurement", "Закупка"),
("document", "Документ"),
],
max_length=16,
),
),
("url", models.CharField(max_length=1000)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="resources",
to="projects.project",
),
),
],
),
migrations.CreateModel(
name="ProjectStage",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
("due_on", models.DateField(blank=True, null=True)),
("done", models.BooleanField(default=False)),
("position", models.PositiveIntegerField(default=0)),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="stages",
to="projects.project",
),
),
],
options={
"ordering": ["position", "id"],
},
),
migrations.CreateModel(
name="TimeAllocation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"activity",
models.CharField(
blank=True,
choices=[
("", "Проект"),
("department", "Работа отдела"),
("training", "Обучение"),
("support", "Поддержка"),
],
max_length=20,
),
),
("percent", models.DecimalField(decimal_places=2, max_digits=5)),
(
"day",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="allocations",
to="projects.dailyallocation",
),
),
(
"project",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="time_allocations",
to="projects.project",
),
),
],
),
migrations.AddConstraint(
model_name="dailyallocation",
constraint=models.UniqueConstraint(
fields=("employee", "date"), name="project_day_unique"
),
),
migrations.AddConstraint(
model_name="project",
constraint=models.CheckConstraint(
condition=models.Q(
("ends_on__isnull", True),
("starts_on__isnull", True),
("ends_on__gte", models.F("starts_on")),
_connector="OR",
),
name="project_dates_order",
),
),
migrations.AddConstraint(
model_name="projectexternalbinding",
constraint=models.UniqueConstraint(
fields=("source", "object_type", "external_id"),
name="project_external_identity",
),
),
migrations.AddConstraint(
model_name="projectmembership",
constraint=models.UniqueConstraint(
fields=("project", "employee"), name="project_member_unique"
),
),
migrations.AddConstraint(
model_name="projectmembership",
constraint=models.CheckConstraint(
condition=models.Q(
("planned_percent__gte", 0), ("planned_percent__lte", 100)
),
name="project_member_percent",
),
),
migrations.AddConstraint(
model_name="projectmembership",
constraint=models.CheckConstraint(
condition=models.Q(
("ends_on__isnull", True),
("starts_on__isnull", True),
("ends_on__gte", models.F("starts_on")),
_connector="OR",
),
name="project_member_dates",
),
),
migrations.AddConstraint(
model_name="timeallocation",
constraint=models.CheckConstraint(
condition=models.Q(("percent__gt", 0), ("percent__lte", 100)),
name="project_allocation_percent",
),
),
migrations.AddConstraint(
model_name="timeallocation",
constraint=models.CheckConstraint(
condition=models.Q(
models.Q(("activity", ""), ("project__isnull", False)),
models.Q(
("activity__in", ["department", "training", "support"]),
("project__isnull", True),
),
_connector="OR",
),
name="project_allocation_target",
),
),
]

View File

@ -0,0 +1,31 @@
# Generated by Django 5.2.4 on 2026-09-07 12:53
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('projects', '0001_initial'),
('tasks', '0042_alter_taskactivity_object_kind_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ProjectBoardLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('board', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_links', to='tasks.taskboard')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_project_board_links', to=settings.AUTH_USER_MODEL)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='board_links', to='projects.project')),
],
options={
'ordering': ['board__name', 'board_id'],
'constraints': [models.UniqueConstraint(fields=('project', 'board'), name='project_board_link_unique')],
},
),
]

View File

View File

@ -0,0 +1,24 @@
from .project import Project, ProjectBoardLink, ProjectEvent, ProjectResource
from .membership import ProjectMembership
from .stage import ProjectStage
from .external_binding import ProjectExternalBinding
from .time_allocation import (
DailyAllocation,
TimeAllocation,
AllocationRevision,
AllocationReminder,
)
__all__ = [
"Project",
"ProjectEvent",
"ProjectResource",
"ProjectBoardLink",
"ProjectMembership",
"ProjectStage",
"ProjectExternalBinding",
"DailyAllocation",
"TimeAllocation",
"AllocationRevision",
"AllocationReminder",
]

View File

@ -0,0 +1,20 @@
from django.db import models
class ProjectExternalBinding(models.Model):
project = models.ForeignKey(
"projects.Project", on_delete=models.PROTECT, related_name="bindings"
)
source = models.ForeignKey("commerce.SourceSystem", on_delete=models.PROTECT)
external_id = models.CharField(max_length=255)
object_type = models.CharField(max_length=50, default="project")
external_name = models.CharField(max_length=255, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["source", "object_type", "external_id"],
name="project_external_identity",
)
]

View File

@ -0,0 +1,34 @@
from django.conf import settings
from django.db import models
class ProjectMembership(models.Model):
project = models.ForeignKey(
"projects.Project", on_delete=models.CASCADE, related_name="memberships"
)
employee = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="project_memberships",
)
role = models.CharField(max_length=100, default="Участник")
planned_percent = models.DecimalField(max_digits=5, decimal_places=2, default=0)
starts_on = models.DateField(null=True, blank=True)
ends_on = models.DateField(null=True, blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["project", "employee"], name="project_member_unique"
),
models.CheckConstraint(
condition=models.Q(planned_percent__gte=0, planned_percent__lte=100),
name="project_member_percent",
),
models.CheckConstraint(
condition=models.Q(ends_on__isnull=True)
| models.Q(starts_on__isnull=True)
| models.Q(ends_on__gte=models.F("starts_on")),
name="project_member_dates",
),
]

View File

@ -0,0 +1,101 @@
from django.conf import settings
from django.db import models
class Project(models.Model):
class Status(models.TextChoices):
PLANNED = "planned", "Планируется"
ACTIVE = "active", "В работе"
PAUSED = "paused", "Приостановлен"
COMPLETED = "completed", "Завершён"
ARCHIVED = "archived", "Архив"
name = models.CharField("Название", max_length=255)
description = models.TextField("Цель и описание", blank=True)
customer = models.CharField("Заказчик", max_length=255, blank=True)
manager = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="managed_projects",
)
status = models.CharField(
max_length=16, choices=Status.choices, default=Status.PLANNED
)
starts_on = models.DateField(null=True, blank=True)
ends_on = models.DateField(null=True, blank=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="created_projects",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name", "pk"]
verbose_name = "Локальный проект"
verbose_name_plural = "Локальные проекты"
constraints = [
models.CheckConstraint(
condition=models.Q(ends_on__isnull=True)
| models.Q(starts_on__isnull=True)
| models.Q(ends_on__gte=models.F("starts_on")),
name="project_dates_order",
)
]
def __str__(self):
return self.name
class ProjectEvent(models.Model):
project = models.ForeignKey(
Project, on_delete=models.PROTECT, related_name="events"
)
actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
action = models.CharField(max_length=100)
details = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at", "-id"]
class ProjectResource(models.Model):
project = models.ForeignKey(
Project, on_delete=models.CASCADE, related_name="resources"
)
title = models.CharField(max_length=255)
kind = models.CharField(
max_length=16,
choices=[
("task", "Задача"),
("procurement", "Закупка"),
("document", "Документ"),
],
)
url = models.CharField(max_length=1000)
created_at = models.DateTimeField(auto_now_add=True)
class ProjectBoardLink(models.Model):
project = models.ForeignKey(
Project, on_delete=models.CASCADE, related_name="board_links"
)
board = models.ForeignKey(
"tasks.TaskBoard", on_delete=models.CASCADE, related_name="project_links"
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="created_project_board_links",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["board__name", "board_id"]
constraints = [
models.UniqueConstraint(
fields=["project", "board"], name="project_board_link_unique"
)
]

View File

@ -0,0 +1,14 @@
from django.db import models
class ProjectStage(models.Model):
project = models.ForeignKey(
"projects.Project", on_delete=models.CASCADE, related_name="stages"
)
name = models.CharField(max_length=255)
due_on = models.DateField(null=True, blank=True)
done = models.BooleanField(default=False)
position = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["position", "id"]

View File

@ -0,0 +1,88 @@
from django.conf import settings
from django.db import models
class DailyAllocation(models.Model):
employee = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="project_days"
)
date = models.DateField()
status = models.CharField(
max_length=12,
choices=[("draft", "Черновик"), ("confirmed", "Подтверждено")],
default="draft",
)
hours = models.DecimalField(max_digits=6, decimal_places=3, null=True, blank=True)
hours_source = models.CharField(max_length=30, default="unknown")
attendance_record = models.ForeignKey(
"attendance.AttendanceRecord", on_delete=models.SET_NULL, null=True
)
note = models.TextField(blank=True)
version = models.PositiveIntegerField(default=0)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-date"]
constraints = [
models.UniqueConstraint(
fields=["employee", "date"], name="project_day_unique"
)
]
class TimeAllocation(models.Model):
day = models.ForeignKey(
DailyAllocation, on_delete=models.CASCADE, related_name="allocations"
)
project = models.ForeignKey(
"projects.Project",
null=True,
blank=True,
on_delete=models.PROTECT,
related_name="time_allocations",
)
activity = models.CharField(
max_length=20,
blank=True,
choices=[
("", "Проект"),
("department", "Работа отдела"),
("training", "Обучение"),
("support", "Поддержка"),
],
)
percent = models.DecimalField(max_digits=5, decimal_places=2)
class Meta:
constraints = [
models.CheckConstraint(
condition=models.Q(percent__gt=0, percent__lte=100),
name="project_allocation_percent",
),
models.CheckConstraint(
condition=(
models.Q(project__isnull=False, activity="")
| models.Q(
project__isnull=True,
activity__in=["department", "training", "support"],
)
),
name="project_allocation_target",
),
]
class AllocationRevision(models.Model):
day = models.ForeignKey(
DailyAllocation, on_delete=models.PROTECT, related_name="revisions"
)
actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
snapshot = models.JSONField()
created_at = models.DateTimeField(auto_now_add=True)
class AllocationReminder(models.Model):
employee = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
last_claim_date = models.DateField(null=True)
snoozed_until = models.DateTimeField(null=True)
enabled = models.BooleanField(default=True)

View File

@ -0,0 +1,17 @@
from django.db.models import Q
from .models import Project
def can_create(user):
return user.is_staff or user.has_perm("projects.add_project")
def can_manage(user, project):
return user.is_staff or user.is_superuser or project.manager_id == user.pk
def visible_projects(user):
qs = Project.objects.all()
if user.is_staff or user.is_superuser:
return qs
return qs.filter(Q(manager=user) | Q(memberships__employee=user)).distinct()

View File

View File

@ -0,0 +1,195 @@
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 (
DailyAllocation,
TimeAllocation,
AllocationRevision,
Project,
AllocationReminder,
)
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

View File

@ -186,6 +186,14 @@ def user_can_access_attendance_record(
return bool(user.is_staff or user.is_superuser or record.employee_id == user.id)
def user_can_access_project(user, project) -> bool:
if not user or not user.is_authenticated or project is None:
return False
from projects.permissions import visible_projects
return visible_projects(user).filter(pk=project.pk).exists()
def user_can_access_feed_post(user, post: Post | None) -> bool:
return bool(user and user.is_authenticated and post is not None)
@ -209,6 +217,7 @@ def user_can_access_task_link_source(user, link) -> bool:
TaskLinkedObjectKind.GUEST: user_can_access_guest,
TaskLinkedObjectKind.GUEST_VISIT: user_can_access_guest_visit,
TaskLinkedObjectKind.ATTENDANCE_RECORD: user_can_access_attendance_record,
TaskLinkedObjectKind.PROJECT: user_can_access_project,
}
if link.kind == TaskLinkedObjectKind.MESSAGE:
from communications.utils import user_can_access_chat

View File

@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2026-09-07 12:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tasks', '0041_taskexecutioncycle'),
]
operations = [
migrations.AlterField(
model_name='taskactivity',
name='object_kind',
field=models.CharField(blank=True, choices=[('project', 'Проект'), ('post', 'Новость'), ('message', 'Сообщение'), ('calendar_event', 'Календарное событие'), ('document', 'Документ'), ('request', 'Заявление'), ('procurement_request', 'Заявка на закупку'), ('employee', 'Сотрудник'), ('guest', 'Гость'), ('guest_visit', 'Заявка на гостевой визит'), ('attendance_record', 'Запись посещаемости'), ('external_link', 'Внешняя ссылка'), ('checklist_item', 'Пункт чек-листа'), ('comment', 'Комментарий')], max_length=32, verbose_name='Тип объекта'),
),
migrations.AlterField(
model_name='tasklinkedobject',
name='kind',
field=models.CharField(choices=[('project', 'Проект'), ('post', 'Новость'), ('message', 'Сообщение'), ('calendar_event', 'Календарное событие'), ('document', 'Документ'), ('request', 'Заявление'), ('procurement_request', 'Заявка на закупку'), ('employee', 'Сотрудник'), ('guest', 'Гость'), ('guest_visit', 'Заявка на гостевой визит'), ('attendance_record', 'Запись посещаемости')], max_length=32, verbose_name='Тип связи'),
),
]

View File

@ -730,6 +730,7 @@ class TaskChecklistItem(models.Model):
class TaskLinkedObjectKind(models.TextChoices):
PROJECT = "project", "Проект"
POST = "post", "Новость"
MESSAGE = "message", "Сообщение"
CALENDAR_EVENT = "calendar_event", "Календарное событие"
@ -743,6 +744,7 @@ class TaskLinkedObjectKind(models.TextChoices):
class TaskActivityObjectKind(models.TextChoices):
PROJECT = "project", "Проект"
POST = "post", "Новость"
MESSAGE = "message", "Сообщение"
CALENDAR_EVENT = "calendar_event", "Календарное событие"

View File

@ -7,6 +7,7 @@ import pytest
from django.urls import reverse
from django.utils import timezone
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
from commerce.integrations.moysklad import MoySkladClient, MoySkladError
from commerce.models import (
ExternalProject,
@ -24,9 +25,17 @@ from commerce.services.moysklad_projects import (
restore_sale_projects,
)
from commerce.services.ingestion import ingest_source_record
from finance.analytics.projects import projects_report
from finance.analytics.projects import local_project_scope, projects_report
from finance.analytics.sales import sales_report
from finance.analytics.models import ReportingEntry
from finance.enums import ApprovalStatus, PayrollPeriodStatus, PayrollRunStatus
from finance.models import EmployeePayRate, PayrollPeriod, PayrollRun, PayrollStatement
from projects.models import (
DailyAllocation,
Project,
ProjectExternalBinding,
TimeAllocation,
)
pytestmark = pytest.mark.django_db
START, END = date(2026, 1, 1), date(2026, 1, 31)
@ -132,9 +141,19 @@ def test_project_report_reconciles_currency_dates_unassigned_and_manual(
occurred_at=paid.occurred_at,
)
user = user_factory()
local = Project.objects.create(
name="Локальный первый", manager=user, created_by=user, status="active"
)
ProjectExternalBinding.objects.create(
project=local,
source=source,
external_id=project.external_id,
external_name=project.name,
)
ReportingEntry.objects.create(
kind="overhead",
project=project,
project=local,
external_project=project,
date=START,
reference="overhead",
amount=40,
@ -142,12 +161,17 @@ def test_project_report_reconciles_currency_dates_unassigned_and_manual(
)
ReportingEntry.objects.create(
kind="cash_out",
project=project,
project=local,
external_project=project,
date=START,
reference="manual",
amount=10,
created_by=user,
)
day = DailyAllocation.objects.create(
employee=user, date=START, status="confirmed", hours=Decimal("8")
)
TimeAllocation.objects.create(day=day, project=local, percent=Decimal("50"))
sales = sales_report(START, END, "RUB")
report = projects_report(START, END, "RUB", sales)
assert report["summary"]["paid"] == 150
@ -158,20 +182,220 @@ def test_project_report_reconciles_currency_dates_unassigned_and_manual(
== 150
)
assert sum(r["paid"] for r in report["monthly"]) == 150
scoped_sales = sales_report(START, END, "RUB", project=project)
assert report["summary"]["labor_hours"] == 4
assert report["summary"]["people_count"] == 1
local_row = next(
row for row in report["rows"] if row["id"] == f"project:{local.pk}"
)
assert local_row["name"] == "Локальный первый"
assert local_row["external_project_count"] == 1
scope = local_project_scope(project=local)
scoped_sales = sales_report(START, END, "RUB", project_scope=scope)
assert (
scoped_sales["summary"]["profit"] == 250
) # cash payments are not expensed twice
scoped = projects_report(START, END, "RUB", scoped_sales, project=project)
scoped = projects_report(
START, END, "RUB", scoped_sales, project=local, scope=scope
)
assert scoped["summary"]["paid"] == 100
assert scoped["summary"]["manual_paid"] == 10
assert scoped["summary"]["overhead"] == 40
assert (
sales_report(START, END, "RUB", project="unassigned")["summary"]["expenses"]
sales_report(
START,
END,
"RUB",
project_scope=local_project_scope(project="unassigned"),
)["summary"]["expenses"]
== 0
)
def test_project_report_prices_confirmed_hours_from_approved_finance_rate(
user_factory,
):
creator = user_factory(email="rate.creator@example.test")
approver = user_factory(email="rate.approver@example.test")
employee = user_factory(email="project.worker@example.test")
project = Project.objects.create(
name="Проект со стоимостью труда",
manager=creator,
created_by=creator,
status="active",
)
PayrollPeriod.objects.create(
code="project-labor-2026-01",
name="Январь 2026",
date_from=START,
date_to=END,
currency="RUB",
created_by=creator,
)
EmployeePayRate.objects.create(
employee=employee,
amount=Decimal("1600"),
currency="RUB",
effective_from=START,
status=ApprovalStatus.APPROVED,
created_by=creator,
approved_by=approver,
approved_at=timezone.now(),
)
run = AttendanceAnalysisRun.objects.create(
employee=employee,
period_start=START,
period_end=date(2026, 1, 2),
status=AttendanceAnalysisRun.STATUS_SUCCESS,
)
for day in [START, date(2026, 1, 2)]:
AttendanceRecord.objects.create(
analysis_run=run,
employee=employee,
date=day,
work_hours=8,
expected_hours=8,
effective_is_workday=True,
)
allocation_day = DailyAllocation.objects.create(
employee=employee,
date=START,
status="confirmed",
hours=Decimal("8"),
)
TimeAllocation.objects.create(
day=allocation_day,
project=project,
percent=Decimal("50"),
)
report = projects_report(
START,
END,
"RUB",
sales_report(START, END, "RUB"),
)
row = next(item for item in report["rows"] if item["id"] == f"project:{project.pk}")
assert row["labor_hours"] == Decimal("4")
assert row["labor_cost"] == Decimal("400")
assert row["preliminary_labor_cost"] == Decimal("400")
assert row["exact_labor_cost"] == 0
assert row["labor_cost_status"] == "preliminary"
assert row["labor_hour_cost"] == Decimal("100")
assert row["unpriced_labor_hours"] == 0
assert row["labor_cost_complete"] is True
assert row["project_result"] == Decimal("-400")
assert report["summary"]["labor_cost"] == Decimal("400")
unpriced = projects_report(
START,
END,
"USD",
sales_report(START, END, "USD"),
)
unpriced_row = next(
item for item in unpriced["rows"] if item["id"] == f"project:{project.pk}"
)
assert unpriced_row["labor_cost"] == 0
assert unpriced_row["unpriced_labor_hours"] == Decimal("4")
assert unpriced_row["project_result"] is None
def test_project_report_uses_final_payroll_with_bonuses_only_after_period_close(
user_factory,
):
creator = user_factory(email="closed.creator@example.test")
approver = user_factory(email="closed.approver@example.test")
employee = user_factory(email="closed.worker@example.test")
project = Project.objects.create(
name="Проект с закрытой зарплатой",
manager=creator,
created_by=creator,
status="active",
)
period = PayrollPeriod.objects.create(
code="project-labor-closed-2026-01",
name="Закрытый январь 2026",
date_from=START,
date_to=END,
currency="RUB",
status=PayrollPeriodStatus.CLOSED,
created_by=creator,
)
payroll_run = PayrollRun.objects.create(
period=period,
revision=1,
status=PayrollRunStatus.PUBLISHED,
requested_by=creator,
approved_by=approver,
approved_at=timezone.now(),
published_by=approver,
published_at=timezone.now(),
gross_total=Decimal("2400"),
payable_total=Decimal("2000"),
)
period.current_run = payroll_run
period.save(update_fields=["current_run"])
PayrollStatement.objects.create(
run=payroll_run,
employee=employee,
currency="RUB",
gross_before_adjustments=Decimal("1600"),
adjustment_total=Decimal("800"),
gross_total=Decimal("2400"),
deduction_total=Decimal("400"),
net_pay=Decimal("2000"),
payment_total=0,
payable=Decimal("2000"),
)
# This rate is the preliminary base. The closed period must instead use
# the final 2,400 gross amount, including the 800 bonus/adjustment.
EmployeePayRate.objects.create(
employee=employee,
amount=Decimal("1600"),
currency="RUB",
effective_from=START,
status=ApprovalStatus.APPROVED,
created_by=creator,
approved_by=approver,
approved_at=timezone.now(),
)
attendance_run = AttendanceAnalysisRun.objects.create(
employee=employee,
period_start=START,
period_end=date(2026, 1, 2),
status=AttendanceAnalysisRun.STATUS_SUCCESS,
)
for day in [START, date(2026, 1, 2)]:
AttendanceRecord.objects.create(
analysis_run=attendance_run,
employee=employee,
date=day,
work_hours=8,
expected_hours=8,
effective_is_workday=True,
)
allocation_day = DailyAllocation.objects.create(
employee=employee,
date=START,
status="confirmed",
hours=Decimal("8"),
)
TimeAllocation.objects.create(
day=allocation_day,
project=project,
percent=Decimal("50"),
)
report = projects_report(START, END, "RUB", sales_report(START, END, "RUB"))
row = next(item for item in report["rows"] if item["id"] == f"project:{project.pk}")
assert row["labor_hours"] == Decimal("4")
assert row["labor_cost"] == Decimal("600")
assert row["exact_labor_cost"] == Decimal("600")
assert row["preliminary_labor_cost"] == 0
assert row["labor_cost_status"] == "exact"
assert row["project_result"] == Decimal("-600")
def test_projects_api_permissions_filter_and_journal(api_client, user_factory, source):
p = payment(source)
url = reverse("api:v1:finance-payroll:analytics-report")
@ -179,23 +403,43 @@ def test_projects_api_permissions_filter_and_journal(api_client, user_factory, s
assert api_client.get(payments_url).status_code == 401
api_client.force_authenticate(user=user_factory())
assert api_client.get(payments_url).status_code == 403
api_client.force_authenticate(user=user_factory(staff=True))
staff = user_factory(staff=True)
local = Project.objects.create(
name="Портальный проект", manager=staff, created_by=staff, status="active"
)
ProjectExternalBinding.objects.create(
project=local,
source=source,
external_id=p.project.external_id,
external_name=p.project.name,
)
api_client.force_authenticate(user=staff)
params = {
"date_from": str(START),
"date_to": str(END),
"project": p.project_id,
"project": local.pk,
"section": "projects",
}
response = api_client.get(url, params)
assert response.status_code == 200
assert response.data["projects"]["summary"]["paid"] == "100.0000"
assert "no-store" in response["Cache-Control"]
options = api_client.get(reverse("api:v1:finance-payroll:analytics-options"))
assert options.data["projects"] == [
{
"id": local.pk,
"name": "Портальный проект",
"source_ids": [source.pk],
"archived": False,
}
]
assert api_client.get(url, {**params, "project": "bad"}).status_code == 400
other = SourceSystem.objects.create(code="other", name="Other")
assert api_client.get(url, {**params, "source": other.pk}).status_code == 400
response = api_client.get(payments_url, params)
assert response.status_code == 200 and response.data["count"] == 1
assert response.data["results"][0]["project_name"] == "Первый"
assert response.data["results"][0]["project_name"] == "Портальный проект"
assert response.data["results"][0]["external_project_name"] == "Первый"
assert api_client.post(payments_url, {}).status_code == 405
entry_url = reverse("api:v1:finance-payroll:analytics-entries")
assert (
@ -203,7 +447,7 @@ def test_projects_api_permissions_filter_and_journal(api_client, user_factory, s
entry_url,
{
"kind": "overhead",
"project": p.project_id,
"project": local.pk,
"date": "2026-01-01",
"reference": "API",
"amount": "5.00",
@ -219,6 +463,56 @@ def test_projects_api_permissions_filter_and_journal(api_client, user_factory, s
)
def test_local_project_combines_bound_sources_and_keeps_unbound_separate(
source, user_factory
):
manager = user_factory()
local = Project.objects.create(
name="Общий проект", manager=manager, created_by=manager, status="active"
)
first = payment(source, ref="first-bound").project
second_source = SourceSystem.objects.create(code="second-source", name="Второй")
second = payment(
second_source,
ref="second-bound",
project={"id": "second-project", "name": "Внешний второй"},
sum=20000,
).project
payment(
second_source,
ref="unbound-payment",
project={"id": "unbound", "name": "Не связан"},
sum=3000,
)
for external in [first, second]:
ProjectExternalBinding.objects.create(
project=local,
source=external.source,
external_id=external.external_id,
external_name=external.name,
)
sales = sales_report(START, END, "RUB")
report = projects_report(START, END, "RUB", sales)
local_row = next(row for row in report["rows"] if row["name"] == "Общий проект")
assert local_row["paid"] == 300
assert local_row["external_project_count"] == 2
assert report["summary"]["unassigned_paid"] == 30
scope = local_project_scope(second_source, local)
scoped_sales = sales_report(START, END, "RUB", second_source, project_scope=scope)
scoped = projects_report(
START,
END,
"RUB",
scoped_sales,
second_source,
local,
scope,
)
assert scoped["summary"]["paid"] == 200
def test_saved_sale_project_backfill_is_idempotent(source):
record = ingest_source_record(
source=source,

View File

@ -0,0 +1,558 @@
from datetime import timedelta
from decimal import Decimal
import pytest
from django.utils import timezone
from rest_framework.test import APIClient
from attendance.models import AttendanceRecord, AttendanceAnalysisRun
from commerce.models import SourceSystem, ExternalProject
from finance.enums import ApprovalStatus
from finance.models import EmployeePayRate, PayrollPeriod
from projects.models import (
Project,
ProjectMembership,
ProjectExternalBinding,
DailyAllocation,
AllocationRevision,
AllocationReminder,
TimeAllocation,
ProjectBoardLink,
)
from tasks.models import Task, TaskBoard, TaskColumn, TaskLinkedObject
pytestmark = pytest.mark.django_db
ROOT = "/api/v1/projects/"
@pytest.fixture
def setup_project(user_factory):
manager, worker, outsider = user_factory(staff=True), user_factory(), user_factory()
p = Project.objects.create(
name="Локальная разработка",
manager=manager,
created_by=manager,
status="active",
)
ProjectMembership.objects.create(project=p, employee=worker)
return manager, worker, outsider, p, APIClient()
def attendance(user, day, hours=8, status="success"):
run = AttendanceAnalysisRun.objects.create(
employee=user, period_start=day, period_end=day, status=status
)
return AttendanceRecord.objects.create(
employee=user, date=day, analysis_run=run, work_hours=hours
)
def payload(p, day=None, **kwargs):
return {
"date": str(day or timezone.localdate()),
"version": 0,
"status": "confirmed",
"allocations": [
{"project": p.pk, "percent": "60"},
{"activity": "department", "percent": "40"},
],
**kwargs,
}
def test_visibility_and_management(setup_project):
manager, worker, outsider, p, c = setup_project
c.force_authenticate(outsider)
assert c.get(f"{ROOT}{p.pk}/").status_code == 404
assert c.get(ROOT).data["count"] == 0
assert c.post(ROOT, {"name": "Нельзя", "manager": outsider.pk}).status_code == 403
c.force_authenticate(worker)
worker_detail = c.get(f"{ROOT}{p.pk}/")
assert worker_detail.status_code == 200
assert worker_detail.data["can_view_finances"] is False
assert c.patch(f"{ROOT}{p.pk}/", {"name": "Нельзя"}).status_code == 403
assert (
c.post(f"{ROOT}{p.pk}/members/", {"employee": outsider.pk}).status_code == 403
)
assert c.get(f"{ROOT}{p.pk}/bindings/").status_code == 403
assert c.get(f"{ROOT}{p.pk}/finances/").status_code == 403
c.force_authenticate(manager)
assert c.get(f"{ROOT}{p.pk}/").data["can_view_finances"] is True
assert c.delete(f"{ROOT}{p.pk}/").status_code == 400
assert c.patch(f"{ROOT}{p.pk}/", {"status": "archived"}).status_code == 200
assert p.events.filter(action="Карточка изменена").exists()
def test_project_links_real_tasks_and_boards_with_access_checks(setup_project):
manager, worker, outsider, project, client = setup_project
board = TaskBoard.objects.create(name="Рабочая доска", created_by=manager)
column = TaskColumn.objects.create(board=board, name="В работе")
task = Task.objects.create(
board=board,
column=column,
title="Связанная задача",
created_by=manager,
)
client.force_authenticate(manager)
task_url = f"{ROOT}{project.pk}/task-links/"
task_response = client.post(task_url, {"task_id": task.pk}, format="json")
assert task_response.status_code == 200
assert task_response.data[0]["id"] == task.pk
assert task_response.data[0]["board_id"] == board.pk
assert TaskLinkedObject.objects.filter(task=task, object_id=project.pk).exists()
board_url = f"{ROOT}{project.pk}/board-links/"
board_response = client.post(board_url, {"board_id": board.pk}, format="json")
assert board_response.status_code == 200
assert board_response.data == [
{
"id": ProjectBoardLink.objects.get().pk,
"board_id": board.pk,
"name": "Рабочая доска",
"url": f"/tasks?board={board.pk}",
}
]
client.force_authenticate(worker)
assert client.get(task_url).status_code == 200
assert client.get(board_url).status_code == 200
assert client.post(task_url, {"task_id": task.pk}).status_code == 403
client.force_authenticate(outsider)
assert client.get(task_url).status_code == 404
assert client.get(board_url).status_code == 404
client.force_authenticate(manager)
task_link_id = TaskLinkedObject.objects.get(task=task, object_id=project.pk).pk
assert client.delete(task_url, {"id": task_link_id}, format="json").data == []
board_link_id = ProjectBoardLink.objects.get().pk
assert client.delete(board_url, {"id": board_link_id}, format="json").data == []
def test_creation_and_dates(setup_project):
manager, _, _, _, c = setup_project
c.force_authenticate(manager)
assert (
c.post(
ROOT,
{
"name": "Новый",
"manager": manager.pk,
"starts_on": "2026-02-01",
"ends_on": "2026-01-01",
},
format="json",
).status_code
== 400
)
r = c.post(ROOT, {"name": "Новый", "manager": manager.pk}, format="json")
assert r.status_code == 201, r.data
p = Project.objects.get(pk=r.data["id"])
assert p.memberships.get().employee == manager
assert not p.bindings.exists()
def test_snapshots_hours_and_conflicts(setup_project):
_, worker, _, p, c = setup_project
c.force_authenticate(worker)
record = attendance(worker, timezone.localdate())
r = c.post(ROOT + "my-day/", payload(p), format="json")
assert r.status_code == 200, r.data
assert r.data["hours"] == Decimal("8.000") and r.data["version"] == 1
assert c.post(ROOT + "my-day/", payload(p), format="json").status_code == 400
record.work_hours = 6
record.save()
assert c.get(ROOT + "my-day/").data["attendance_changed"] is True
r = c.post(ROOT + "my-day/", payload(p, version=1), format="json")
assert r.status_code == 200 and r.data["hours"] == Decimal("6.000")
assert AllocationRevision.objects.count() == 2
assert (
AllocationRevision.objects.order_by("id").first().snapshot["hours"] == "8.000"
)
assert DailyAllocation.objects.count() == 1
@pytest.mark.parametrize(
"allocations",
[
[{"percent": "100"}],
[{"activity": "training", "percent": "101"}],
[{"activity": "training", "percent": "99"}],
[
{"activity": "training", "percent": "50"},
{"activity": "training", "percent": "50"},
],
],
)
def test_invalid_distribution_is_atomic(setup_project, allocations):
_, worker, _, p, c = setup_project
c.force_authenticate(worker)
assert (
c.post(
ROOT + "my-day/", payload(p, allocations=allocations), format="json"
).status_code
== 400
)
assert not DailyAllocation.objects.exists()
def test_membership_and_future_dates(setup_project):
_, worker, outsider, p, c = setup_project
c.force_authenticate(outsider)
assert c.post(ROOT + "my-day/", payload(p), format="json").status_code == 400
c.force_authenticate(worker)
assert (
c.post(
ROOT + "my-day/",
payload(p, day=timezone.localdate() + timedelta(days=1)),
format="json",
).status_code
== 400
)
p.memberships.update(starts_on=timezone.localdate() + timedelta(days=1))
assert c.post(ROOT + "my-day/", payload(p), format="json").status_code == 400
def test_unknown_hours_drafts_and_private_reports(setup_project):
manager, worker, _, p, c = setup_project
c.force_authenticate(worker)
r = c.post(ROOT + "my-day/", payload(p), format="json")
assert r.status_code == 200 and r.data["hours"] is None
report = c.get(f"{ROOT}{p.pk}/time-report/").data
assert (
report["unknown_days"] == 1
and report["confirmed_hours"] == 0
and report["scope"] == "self"
)
c.force_authenticate(manager)
attendance(manager, timezone.localdate(), 10)
r = c.post(
ROOT + "my-day/",
payload(p, status="draft", allocations=[{"project": p.pk, "percent": "30"}]),
format="json",
)
assert r.status_code == 200
report = c.get(f"{ROOT}{p.pk}/time-report/").data
assert report["draft_days"] == 1 and report["confirmed_hours"] == 0
c.force_authenticate(worker)
assert len(c.get(f"{ROOT}{p.pk}/time-report/").data["rows"]) == 1
def test_reminders_and_snooze(setup_project):
_, worker, _, p, c = setup_project
c.force_authenticate(worker)
yesterday = timezone.localdate() - timedelta(days=1)
attendance(worker, yesterday)
assert c.post(ROOT + "reminder/", {}, format="json").data["date"] == yesterday
assert c.post(ROOT + "reminder/", {}, format="json").data["date"] is None
assert (
c.post(ROOT + "reminder/", {"snooze": True}, format="json").status_code == 200
)
assert c.post(ROOT + "reminder/", {}, format="json").data["date"] is None
AllocationReminder.objects.update(
snoozed_until=timezone.now() - timedelta(seconds=1)
)
assert (
c.post(ROOT + "my-day/", payload(p, day=yesterday), format="json").status_code
== 200
)
assert c.post(ROOT + "reminder/", {}, format="json").data["date"] is None
def test_failed_attendance_excluded(setup_project):
_, worker, _, _, c = setup_project
c.force_authenticate(worker)
yesterday = timezone.localdate() - timedelta(days=1)
attendance(worker, yesterday, status="failed")
assert c.get(ROOT + "my-day/", {"date": str(yesterday)}).data["hours"] is None
assert c.post(ROOT + "reminder/", {}, format="json").data["date"] is None
def test_binding_identity(setup_project):
manager, _, _, p, c = setup_project
c.force_authenticate(manager)
a = SourceSystem.objects.create(code="test-project-a", name="A")
b = SourceSystem.objects.create(code="test-project-b", name="B")
data = {"source": a.pk, "external_id": "same", "external_name": "Внешний"}
assert c.post(f"{ROOT}{p.pk}/bindings/", data, format="json").status_code == 200
assert c.post(f"{ROOT}{p.pk}/bindings/", data, format="json").status_code == 400
data["source"] = b.pk
assert c.post(f"{ROOT}{p.pk}/bindings/", data, format="json").status_code == 200
assert (
ProjectExternalBinding.objects.count() == 2
and ExternalProject.objects.count() == 0
)
def test_resource_urls(setup_project):
manager, _, _, p, c = setup_project
c.force_authenticate(manager)
for url in [
"https://evil.test/tasks",
"//evil.test/tasks",
"/profile",
"/tasks\\evil",
]:
assert (
c.post(
f"{ROOT}{p.pk}/resources/",
{"title": "X", "kind": "task", "url": url},
format="json",
).status_code
== 400
)
assert (
c.post(
f"{ROOT}{p.pk}/resources/",
{"title": "Задача", "kind": "task", "url": "/tasks?task=12"},
format="json",
).status_code
== 200
)
def test_finances_use_source_scoped_bindings_and_valid_payments(setup_project):
from commerce.services.moysklad_projects import normalize_project_payment
manager, approver, _, p, c = setup_project
c.force_authenticate(manager)
source = SourceSystem.objects.create(code="finance-project-a", name="A")
other = SourceSystem.objects.create(code="finance-project-b", name="B")
def pay(src, ref, project_id, amount, applicable=True):
return normalize_project_payment(
src,
{
"id": ref,
"moment": f"{timezone.localdate()} 12:00:00",
"sum": amount,
"name": ref,
"applicable": applicable,
"project": {"id": project_id, "name": project_id},
},
"paymentout",
timezone.now(),
)
pay(source, "a", "first", 10000)
pay(source, "b", "second", 20000)
pay(other, "c", "first", 90000)
pay(source, "cancelled", "first", 50000, False)
stale = pay(source, "stale", "first", 60000)
stale.missing_from_source = True
stale.save()
for ref in ["first", "second"]:
ProjectExternalBinding.objects.create(project=p, source=source, external_id=ref)
today = timezone.localdate()
PayrollPeriod.objects.create(
code="project-finance-current",
date_from=today,
date_to=today,
currency="RUB",
created_by=manager,
)
EmployeePayRate.objects.create(
employee=manager,
amount=Decimal("800"),
effective_from=today,
status=ApprovalStatus.APPROVED,
created_by=manager,
approved_by=approver,
approved_at=timezone.now(),
)
attendance_record = attendance(manager, today)
attendance_record.expected_hours = 8
attendance_record.save(update_fields=["expected_hours"])
allocation_day = DailyAllocation.objects.create(
employee=manager,
date=today,
status="confirmed",
hours=Decimal("8"),
attendance_record=attendance_record,
)
TimeAllocation.objects.create(
day=allocation_day,
project=p,
percent=Decimal("50"),
)
r = c.get(f"{ROOT}{p.pk}/finances/")
assert r.status_code == 200, r.data
assert r.data["paid"] == Decimal("300")
assert r.data["payment_count"] == 2
assert r.data["external_project_count"] == 2
assert r.data["labor_hours"] == Decimal("4")
assert r.data["labor_cost"] == Decimal("400")
assert r.data["preliminary_labor_cost"] == Decimal("400")
assert r.data["exact_labor_cost"] == 0
assert r.data["labor_cost_status"] == "preliminary"
assert r.data["labor_hour_cost"] == Decimal("100")
assert r.data["labor_cost_complete"] is True
assert r.data["labor"][0]["employee_id"] == manager.pk
def test_confirmed_time_is_weighted_by_daily_hours(setup_project):
_, worker, _, p, c = setup_project
c.force_authenticate(worker)
for offset, hours in [(0, 8), (1, 4)]:
day = timezone.localdate() - timedelta(days=offset)
attendance(worker, day, hours)
assert (
c.post(ROOT + "my-day/", payload(p, day=day), format="json").status_code
== 200
)
r = c.get(
f"{ROOT}{p.pk}/time-report/",
{
"from": str(timezone.localdate() - timedelta(days=1)),
"to": str(timezone.localdate()),
},
)
assert r.data["confirmed_hours"] == Decimal("7.2")
def test_archived_project_preserves_existing_day_history(setup_project):
_, worker, _, p, c = setup_project
c.force_authenticate(worker)
assert c.post(ROOT + "my-day/", payload(p), format="json").status_code == 200
p.status = "archived"
p.save()
assert c.get(ROOT + "my-day/").data["projects"] == []
assert (
c.post(ROOT + "my-day/", payload(p, version=1), format="json").status_code
== 200
)
assert (
c.post(
ROOT + "my-day/",
payload(p, day=timezone.localdate() - timedelta(days=1)),
format="json",
).status_code
== 400
)
def test_mine_filter_and_malformed_requests(setup_project):
manager, _, outsider, p, c = setup_project
Project.objects.create(name="Другой", manager=outsider, created_by=outsider)
c.force_authenticate(manager)
assert c.get(ROOT).data["count"] == 2
assert c.get(ROOT, {"mine": "true"}).data["count"] == 1
assert (
c.post(
f"{ROOT}{p.pk}/members/", {"employee": "invalid"}, format="json"
).status_code
== 400
)
assert (
c.delete(f"{ROOT}{p.pk}/stages/", {"id": "invalid"}, format="json").status_code
== 400
)
assert (
c.get(
f"{ROOT}{p.pk}/finances/", {"from": "9999-12-30", "to": "9999-12-31"}
).status_code
== 400
)
assert c.get(f"{ROOT}{p.pk}/finances/", {"currency": "РУБ"}).status_code == 400
def test_external_candidates_only_return_active_unbound_records(setup_project):
manager, _, _, project, client = setup_project
client.force_authenticate(manager)
source = SourceSystem.objects.create(code="candidate-source", name="МойСклад")
available = ExternalProject.objects.create(
source=source,
external_id="available",
name="Рабочий проект",
code="PROJECT-7",
description="Проект из внешней системы",
)
bound = ExternalProject.objects.create(
source=source, external_id="bound", name="Уже связан"
)
ExternalProject.objects.create(
source=source, external_id="archived", name="Архив", archived=True
)
ProjectExternalBinding.objects.create(
project=project,
source=source,
external_id=bound.external_id,
external_name=bound.name,
)
result = client.get(f"{ROOT}external-candidates/")
assert result.status_code == 200
assert result.data["count"] == 1
assert result.data["results"][0]["external_id"] == available.external_id
assert result["Cache-Control"] == "private, no-store"
detail = client.get(
f"{ROOT}external-project/",
{"source": source.pk, "external_id": available.external_id},
)
assert detail.status_code == 200
assert detail.data["external_name"] == available.name
assert detail.data["code"] == "PROJECT-7"
assert detail.data["description"] == "Проект из внешней системы"
assert detail.data["payment_count"] == 0
assert detail.data["payments"] == []
assert detail.data["sale_count"] == 0
assert detail.data["bound"] is False
assert (
client.get(
f"{ROOT}external-project/",
{"source": source.pk, "external_id": "missing"},
).status_code
== 404
)
assert (
client.get(f"{ROOT}external-candidates/", {"q": "не найдено"}).data["count"]
== 0
)
def test_create_from_external_is_atomic_and_preserves_source(setup_project):
manager, _, outsider, _, client = setup_project
source = SourceSystem.objects.create(code="import-source", name="МойСклад")
external = ExternalProject.objects.create(
source=source, external_id="external-project", name="Разработка автомата"
)
data = {
"source": source.pk,
"external_id": external.external_id,
"name": "Локальная разработка автомата",
"description": "Внутренняя карточка",
"customer": "Заказчик",
"manager": manager.pk,
"status": "active",
}
client.force_authenticate(outsider)
assert client.post(f"{ROOT}from-external/", data, format="json").status_code == 403
client.force_authenticate(manager)
result = client.post(f"{ROOT}from-external/", data, format="json")
assert result.status_code == 201, result.data
local = Project.objects.get(pk=result.data["id"])
assert local.name == data["name"]
assert local.bindings.get().external_id == external.external_id
assert local.memberships.get().employee == manager
assert local.events.get().action == "Проект создан из внешнего проекта"
external.refresh_from_db()
assert external.name == "Разработка автомата"
project_count = Project.objects.count()
duplicate = client.post(f"{ROOT}from-external/", data, format="json")
assert duplicate.status_code == 400
assert Project.objects.count() == project_count
def test_project_options_only_offer_manageable_projects(setup_project):
manager, worker, outsider, project, client = setup_project
other = Project.objects.create(name="Чужой", manager=outsider, created_by=outsider)
client.force_authenticate(worker)
options = client.get(f"{ROOT}options/").data
assert options["manageable_projects"] == []
client.force_authenticate(manager)
options = client.get(f"{ROOT}options/").data
assert {row["id"] for row in options["manageable_projects"]} == {
project.pk,
other.pk,
}

View File

@ -37,3 +37,5 @@ Troubleshooting должен находиться в соответствующ
session report.
При добавлении документа сначала проверьте, нельзя ли обновить существующий guide или README модуля. Отдельные `*_PLAN.md`, `*_PROGRESS.md`, `*_COMPLETE.md` и session reports в репозитории не создаются.
Локальные проекты, внешние привязки и ежедневное участие: [Projects](../backend/projects/README.md).

View File

@ -50,6 +50,9 @@
в производственном отчёте и рассчитывается тем же сервисом.
- Журнал подтверждённых фактов: выпуск, производственные закупки, уплаченные налоги
сотрудника, общие расходы, поступления и выплаты денег.
- Локальные проекты объединяют продажи, расходы и платежи связанных внешних
объектов; подтверждённые часы получают предварительную оценку до закрытия
зарплатного периода и точную оценку по его итоговому начислению после закрытия.
- Выбор дат включительно, валюты и источника продаж. Поиск, страницы таблиц и CSV
всех строк текущей группировки с учётом поиска. Диапазон ограничен 367 днями.
@ -110,7 +113,9 @@ API дополнительно возвращает арифметический
относиться к конкретному конечному товару либо оставаться общей. Производственные
закупки не вычитаются повторно из прибыли поверх себестоимости продаж.
Фильтр источника влияет на продажи и связанные расходы; журнал остаётся общим.
Фильтр проекта использует локальную карточку и применяется к продажам через её
внешние привязки, а к ручному журналу и трудозатратам — напрямую. Фильтр источника
влияет на продажи и связанные расходы; журнал остаётся общим.
Денежный поток считается только по поступлениям и выплатам из журнала. Нулевое
число записей не подтверждает отсутствие фактических выплат, налогов или выпуска.
@ -132,20 +137,22 @@ no-store`; фронтовое скрытие ссылки не подменяе
## API
- `GET /api/v1/finance/analytics/report/?section=sales|people|production`
- `GET /api/v1/finance/analytics/report/?section=sales|people|production|projects`
- `GET /api/v1/finance/analytics/options/?search=...`
- `GET|POST /api/v1/finance/analytics/entries/`
- `POST /api/v1/finance/analytics/entries/{id}/void/`
Параметры отчёта и списка фактов: `date_from`, `date_to`, `currency`; параметр
`source` применяется к продажам. Список фактов использует стандартную пагинацию API.
`options` возвращает первые 100 товаров и сотрудников по строке поиска.
Параметры отчёта и списка фактов: `date_from`, `date_to`, `currency`, локальный
`project` либо `unassigned`; параметр `source` применяется к продажам и внешним
привязкам. Список фактов использует стандартную пагинацию API. `options`
возвращает локальные проекты и первые 100 товаров и сотрудников по строке поиска.
CSV экранирует кавычки, переносы строк и потенциальные формулы в текстовых полях.
## Подключение и проверка
Перед запуском обновлённого backend выполните `python manage.py migrate`:
миграции Commerce 0012–0013 и Finance 0032 добавляют клиента продажи и журнал фактов.
миграции Commerce 0012–0014, Projects 0001 и Finance 0032–0034 добавляют клиента
продажи, внешний справочник, локальные проекты и журнал фактов.
Для старых документов без имени клиента может потребоваться повторная синхронизация.
```bash
@ -169,13 +176,27 @@ npx tsc --noEmit
Индекс здоровья, планы и прогнозы из примерных изображений не включены в объём
реализации текстового ТЗ.
## Проекты МойСклад
## Локальные проекты и внешние источники
Раздел «Проекты» показывает оплаченные расходы по проектам и статьям, документы
платежей, ручные выплаты, закупки и общие расходы из журнала, а также продажи проекта.
Фильтр проекта работает в продажах, производстве, проектах и журнале. Кадровые
показатели не распределяются по проектам. Вариант «Без проекта» позволяет найти
документы, для которых привязка не заполнена.
Раздел «Проекты» использует локальную карточку портала как основную строку. В неё
по точным внешним привязкам собираются оплаченные расходы, документы платежей,
продажи и прямые расходы всех подключённых сервисов. Ручные выплаты, закупки и
общие расходы журнала ссылаются на локальную карточку напрямую. Подтверждённые
распределения сотрудников добавляют трудозатраты в часах, число дней учёта и
участников. Пока расчётный период открыт, стоимость часа предварительная: система
использует текущий расчёт зарплаты, а до его появления — действующую утверждённую
базовую ставку, разделённую на норму часов периода. После закрытия периода
стоимость становится точной: итоговое начисление из опубликованного расчётного
листка, включая премии, доплаты и корректировки, распределяется по фактически
отработанным часам. Полученная стоимость часа умножается только на подтверждённые
часы проекта. Черновые ставки не используются. Закрытый период без итогового
листка, а также часы без периода, нормы или фактических часов сохраняются как
неоценённые и не превращаются в ноль.
Фильтр локального проекта работает в продажах, производстве, проектах и журнале.
Вариант «Без привязки» объединяет внешние документы, для которых ещё не выбрана
локальная карточка, а также документы без проекта источника. В списке платежей
сохраняются и локальное имя, и исходное имя проекта для проверки происхождения.
Синхронизация получает `/entity/project` (включая архив), `/entity/paymentout`
и `/entity/cashout` с проектом, статьёй расходов, получателем и валютой. Продажи
@ -183,7 +204,7 @@ npx tsc --noEmit
восстанавливается из сохранённого исходного документа, если поле присутствует.
Проекты идентифицируются сочетанием источника и внешнего ID, а не названием.
«Оплачено в МойСклад» — сумма проведённых исходящих платежей и расходных ордеров
«Оплачено по источникам» — сумма проведённых исходящих платежей и расходных ордеров
за период в выбранной валюте. Проект определяется по явному полю документа;
из связанных заказов и приёмок он не угадывается. Непроведённые документы и
документы, исчезнувшие после полностью успешного обхода источника, исключаются.
@ -204,7 +225,9 @@ npx tsc --noEmit
доступно в ручной записи, продаже и прямом расходе. Справочник проектов возвращается
в `analytics/options/`. Права и `no-store` совпадают с остальной аналитикой.
Миграции: Commerce 0014 и Finance 0033. Обычная синхронизация теперь включает
Миграции: Commerce 0014, Projects 0001 и Finance 0033–0034. Миграция 0034
сохраняет прежнюю внешнюю связь ручной записи для аудита и заполняет локальный
проект там, где уже существовала точная привязка. Обычная синхронизация включает
проекты и исходящие платежи. Для отдельной загрузки без товаров и изображений:
```bash

View File

@ -6,6 +6,7 @@ import { KeyRound, Mail, MessageCircle, Phone, RefreshCw } from "lucide-react";
import { AppShell } from "@/components/AppShell";
import EmployeeAttendanceCard from "@/components/attendance/EmployeeAttendanceCard";
import EmployeeActionsTimeline from "@/components/users/EmployeeActionsTimeline";
import { ProjectParticipationProfileSection } from "@/components/projects/ProjectParticipationProfileSection";
import EmployeePayrollWorkProfileSection from "@/components/users/EmployeePayrollWorkProfileSection";
import EmployeeRequestsProfileSections from "@/components/users/EmployeeRequestsProfileSections";
import {
@ -198,6 +199,7 @@ export default function ProfilePage() {
<EmployeeRequestsProfileSections employeeId={user.id} />
<ProjectParticipationProfileSection />
<EmployeePayrollWorkProfileSection />
<ProfileWorkScheduleCard

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,904 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import {
FolderKanban,
Plus,
Clock3,
Search,
HelpCircle,
Maximize2,
Minimize2,
PlugZap,
Link2,
ChevronDown,
ChevronRight,
CalendarDays,
UserRound,
Building2,
Eye,
ReceiptText,
ShoppingCart,
} from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { Modal } from "@/components/ui";
import { AutoRevealStickyHeader } from "@/components/ui/AutoRevealStickyHeader";
import { ModulePrimaryAction } from "@/components/ui/ModulePrimaryAction";
import { apiClient } from "@/lib/api";
import { useRouter } from "next/navigation";
import type {
ExternalProjectDetail,
ExternalProjectOption,
LocalProject,
ProjectOption,
} from "@/types/projects";
import { ProjectEditor } from "@/components/projects/ProjectEditor";
import { DailyTimeAllocationModal } from "@/components/projects/DailyTimeAllocationModal";
import {
Panel,
buttonClass,
primaryClass,
fieldClass,
projectStatuses,
errorText,
today,
} from "@/components/projects/shared";
export default function ProjectsPage() {
const router = useRouter();
const [projects, setProjects] = useState<LocalProject[]>([]),
[canCreate, setCanCreate] = useState(false),
[create, setCreate] = useState(false),
[date, setDate] = useState<string | null>(null),
[help, setHelp] = useState(false),
[search, setSearch] = useState(""),
[page, setPage] = useState(1),
[count, setCount] = useState(0),
[next, setNext] = useState(false),
[error, setError] = useState(""),
[loading, setLoading] = useState(true),
[wide, setWide] = useState(false),
[calendarOpen, setCalendarOpen] = useState(false);
const [externalProjects, setExternalProjects] = useState<
ExternalProjectOption[]
>([]),
[externalSearch, setExternalSearch] = useState(""),
[externalPage, setExternalPage] = useState(1),
[externalCount, setExternalCount] = useState(0),
[externalNext, setExternalNext] = useState(false),
[externalLoading, setExternalLoading] = useState(true),
[externalOpen, setExternalOpen] = useState(false),
[importExternal, setImportExternal] =
useState<ExternalProjectOption | null>(null),
[linkExternal, setLinkExternal] = useState<ExternalProjectOption | null>(
null,
),
[linkProjectId, setLinkProjectId] = useState(0),
[externalBusy, setExternalBusy] = useState(false),
[manageableProjects, setManageableProjects] = useState<ProjectOption[]>([]);
const [previewExternal, setPreviewExternal] =
useState<ExternalProjectOption | null>(null),
[externalDetail, setExternalDetail] =
useState<ExternalProjectDetail | null>(null),
[externalDetailLoading, setExternalDetailLoading] = useState(false),
[externalDetailError, setExternalDetailError] = useState("");
const [calendar, setCalendar] = useState<
{ date: string; status: string; hours: string | null }[]
>([]);
const refreshCalendar = () =>
apiClient
.getProjectCalendar()
.then(setCalendar)
.catch((e) => setError(errorText(e)));
useEffect(() => {
let live = true;
Promise.all([apiClient.getProjectOptions(), apiClient.getProjectCalendar()])
.then(([o, c]) => {
if (live) {
setCanCreate(o.can_create);
setCalendar(c);
setManageableProjects(o.manageable_projects);
}
})
.catch((e) => {
if (live) setError(errorText(e));
});
return () => {
live = false;
};
}, []);
useEffect(() => {
let live = true;
const timer = setTimeout(() => {
setLoading(true);
apiClient
.getLocalProjects(search, page)
.then((r) => {
if (live) {
setProjects(r.results);
setCount(r.count);
setNext(Boolean(r.next));
setError("");
}
})
.catch((e) => {
if (live) setError(errorText(e));
})
.finally(() => {
if (live) setLoading(false);
});
}, 200);
return () => {
live = false;
clearTimeout(timer);
};
}, [search, page]);
useEffect(() => {
let live = true;
const timer = setTimeout(() => {
setExternalLoading(true);
apiClient
.getExternalProjectCandidates(externalSearch, externalPage)
.then((result) => {
if (!live) return;
setExternalProjects(result.results);
setExternalCount(result.count);
setExternalNext(Boolean(result.next));
})
.catch((e) => {
if (live) setError(errorText(e));
})
.finally(() => {
if (live) setExternalLoading(false);
});
}, 200);
return () => {
live = false;
clearTimeout(timer);
};
}, [externalSearch, externalPage]);
const removeExternalCandidate = (external: ExternalProjectOption) => {
setExternalProjects((current) =>
current.filter(
(item) =>
item.source !== external.source ||
item.external_id !== external.external_id,
),
);
setExternalCount((current) => Math.max(0, current - 1));
};
const openExternalPreview = async (external: ExternalProjectOption) => {
setPreviewExternal(external);
setExternalDetail(null);
setExternalDetailError("");
setExternalDetailLoading(true);
try {
setExternalDetail(
await apiClient.getExternalProjectDetail(
external.source,
external.external_id,
),
);
} catch (detailError) {
setExternalDetailError(errorText(detailError));
} finally {
setExternalDetailLoading(false);
}
};
const formatDate = (value: string | null) =>
value
? new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(`${value}T00:00:00`))
: null;
const formatDateTime = (value: string | null) =>
value
? new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value))
: "Нет данных";
const formatAmount = (value: string | number, currency: string) =>
`${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(Number(value))} ${currency}`;
const projectStatusClass: Record<string, string> = {
planned: "app-badge",
active: "app-feedback-success",
paused: "app-feedback-warning",
completed: "app-badge-accent",
archived: "app-badge",
};
return (
<AppShell desktopWideMode={wide} onDesktopWideModeChange={setWide}>
<div
className={`mx-auto w-full space-y-5 ${
wide ? "lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pr-1" : ""
}`}
>
{error && (
<p
role="alert"
className="app-feedback-danger rounded-xl px-4 py-3 text-sm"
>
{error}
</p>
)}
<section
className={`app-surface min-w-0 rounded-2xl p-4 ${
wide
? "lg:flex lg:min-h-0 lg:flex-1 lg:flex-col lg:overflow-hidden"
: ""
}`}
>
<AutoRevealStickyHeader
className="rounded-b-xl"
disableOnDesktop={wide}
>
<div className="mb-4 flex min-w-0 items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<p className="app-text-muted truncate text-sm font-semibold uppercase tracking-wide">
Проекты
</p>
<span className="app-badge app-badge-accent shrink-0 px-2 py-0.5 text-xs">
{loading ? "…" : count}
</span>
</div>
<div className="ml-auto flex min-w-0 shrink items-center gap-1.5">
<button
type="button"
aria-label="Заполнить мой день"
title="Мой день"
className="app-action-secondary inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
onClick={() => setDate(today())}
>
<Clock3 size={16} />
</button>
{canCreate && (
<ModulePrimaryAction
className="max-sm:w-10 max-sm:px-0 [&>span:last-child]:max-sm:hidden"
onClick={() => setCreate(true)}
icon={<Plus size={16} />}
label="Создать проект"
/>
)}
<button
type="button"
aria-label="Справка о проектах"
title="Справка"
className="app-action-secondary inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
onClick={() => setHelp(true)}
>
<HelpCircle size={18} />
</button>
<button
type="button"
aria-label={wide ? "Обычный вид" : "Широкий вид"}
title={wide ? "Обычный вид" : "Широкий вид"}
className="app-action-secondary hidden h-10 w-10 shrink-0 items-center justify-center rounded-lg md:inline-flex"
onClick={() => setWide(!wide)}
>
{wide ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
</button>
</div>
</div>
<div className="relative mb-4">
<Search
className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2"
size={16}
/>
<input
aria-label="Поиск проектов"
className="app-input w-full rounded-lg py-2.5 pl-9 pr-3 text-sm"
value={search}
placeholder="Поиск по названию или заказчику"
onChange={(event) => {
setSearch(event.target.value);
setPage(1);
}}
/>
</div>
</AutoRevealStickyHeader>
<div
className={`space-y-2 ${wide ? "lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pr-1" : ""}`}
>
{loading ? (
Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="app-surface-muted flex animate-pulse items-center gap-3 rounded-xl p-3"
>
<div className="h-10 w-10 shrink-0 rounded-lg bg-[var(--surface-tertiary)]" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-4 w-2/5 rounded bg-[var(--surface-tertiary)]" />
<div className="h-3 w-3/5 rounded bg-[var(--surface-tertiary)]" />
</div>
</div>
))
) : projects.length ? (
projects.map((project) => {
const startsOn = formatDate(project.starts_on);
const endsOn = formatDate(project.ends_on);
const dates =
startsOn || endsOn
? `${startsOn || "Начало не задано"} — ${endsOn || "Срок не задан"}`
: "Сроки не заданы";
return (
<Link
key={project.id}
href={`/projects/${project.id}`}
className="app-surface-muted group flex min-w-0 items-center gap-3 rounded-xl border border-transparent px-3 py-3 transition hover:border-[var(--accent-primary)] hover:bg-[var(--surface-elevated)] sm:px-4"
>
<span className="app-selected app-accent-text flex h-10 w-10 shrink-0 items-center justify-center rounded-lg">
<FolderKanban size={19} />
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="truncate text-sm font-semibold text-[var(--foreground)]">
{project.name}
</span>
<span
className={`app-status-pill min-h-0 px-2 py-0.5 text-[11px] ${projectStatusClass[project.status] || "app-badge"}`}
>
{projectStatuses[project.status] || project.status}
</span>
</span>
<span className="app-text-muted mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span className="inline-flex min-w-0 items-center gap-1.5">
<UserRound size={13} className="shrink-0" />
<span className="truncate">
{project.manager_name}
</span>
</span>
{project.customer ? (
<span className="inline-flex min-w-0 items-center gap-1.5">
<Building2 size={13} className="shrink-0" />
<span className="truncate">{project.customer}</span>
</span>
) : null}
<span className="inline-flex min-w-0 items-center gap-1.5">
<CalendarDays size={13} className="shrink-0" />
<span className="truncate">{dates}</span>
</span>
</span>
</span>
<ChevronRight
size={18}
className="app-text-muted shrink-0 transition group-hover:translate-x-0.5 group-hover:text-[var(--accent-primary)]"
/>
</Link>
);
})
) : (
<div className="app-surface-muted rounded-xl px-4 py-8 text-center">
<FolderKanban
size={22}
className="app-text-muted mx-auto mb-2"
/>
<p className="app-text-muted text-sm">
{search
? "Проекты не найдены"
: "Пока нет доступных проектов. Создайте локальный проект или дождитесь приглашения в команду."}
</p>
</div>
)}
</div>
{!loading && (page > 1 || next) ? (
<div className="mt-4 flex items-center justify-between border-t border-[var(--border-subtle)] pt-3">
<span className="app-text-muted text-xs">Страница {page}</span>
<div className="flex gap-2">
<button
className={buttonClass}
disabled={page === 1}
onClick={() => setPage(page - 1)}
>
Назад
</button>
<button
className={buttonClass}
disabled={!next}
onClick={() => setPage(page + 1)}
>
Далее
</button>
</div>
</div>
) : null}
</section>
<Panel>
<h2>
<button
type="button"
className="flex w-full min-w-0 items-center gap-3 text-left"
aria-expanded={externalOpen}
aria-controls="external-project-candidates"
aria-label={`${externalOpen ? "Свернуть" : "Развернуть"} проекты из МойСклад без привязки`}
onClick={() => setExternalOpen((value) => !value)}
>
<PlugZap
size={19}
className="shrink-0 text-[var(--accent-primary)]"
/>
<span className="min-w-0 flex-1 text-sm font-semibold sm:text-base">
Проекты из МойСклад без привязки
</span>
<span className="app-badge app-badge-accent shrink-0 px-2 py-0.5 text-xs">
{externalCount}
</span>
<ChevronDown
size={18}
className={`app-text-muted shrink-0 transition ${externalOpen ? "rotate-180" : ""}`}
/>
</button>
</h2>
<div
id="external-project-candidates"
className={
externalOpen
? "space-y-3 border-t border-[var(--border-subtle)] pt-4"
: "hidden"
}
>
<p className="app-text-muted text-sm">
Выберите, какие записи являются рабочими проектами компании.
</p>
<div className="relative w-full max-w-sm">
<Search
className="app-text-muted absolute left-3 top-3"
size={17}
/>
<input
aria-label="Поиск проектов МойСклад"
className={`${fieldClass} pl-10`}
value={externalSearch}
placeholder="Найти внешний проект"
onChange={(event) => {
setExternalSearch(event.target.value);
setExternalPage(1);
}}
/>
</div>
<p className="app-text-muted text-xs">
Учётные категории вроде перемещений и кредитов не создаются как
локальные проекты автоматически.
</p>
{externalLoading ? (
<p className="app-text-muted text-sm">Загрузка из МойСклад…</p>
) : (
<>
<div className="space-y-2">
{externalProjects.map((external) => (
<article
key={`${external.source}:${external.external_id}`}
className="app-surface-muted flex min-w-0 flex-wrap items-center gap-2 rounded-xl px-3 py-2.5 sm:flex-nowrap"
>
<button
type="button"
className="group flex min-w-40 flex-1 items-center gap-2 rounded-lg py-1 text-left"
aria-label={`Просмотреть проект ${external.external_name}`}
title="Просмотреть проект"
onClick={() => void openExternalPreview(external)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium group-hover:text-[var(--accent-primary)]">
{external.external_name}
</span>
<span className="app-text-muted mt-0.5 block truncate text-xs">
{external.source_name} · Просмотреть
</span>
</span>
<Eye
size={16}
className="app-text-muted shrink-0 group-hover:text-[var(--accent-primary)]"
/>
</button>
{canCreate && (
<div className="ml-auto flex shrink-0 gap-2">
<button
className={`${primaryClass} px-3 py-2 text-xs`}
onClick={() => setImportExternal(external)}
>
<Plus size={14} />
Создать
</button>
<button
className={`${buttonClass} px-3 py-2 text-xs`}
disabled={!manageableProjects.length}
title={
manageableProjects.length
? "Привязать к существующему проекту"
: "Сначала создайте локальный проект"
}
onClick={() => {
setLinkExternal(external);
setLinkProjectId(manageableProjects[0]?.id || 0);
}}
>
<Link2 size={14} />
Привязать
</button>
</div>
)}
</article>
))}
</div>
{!externalProjects.length && (
<p className="app-text-muted text-sm">
{externalSearch
? "Непривязанные проекты не найдены."
: "Все активные внешние проекты сопоставлены."}
</p>
)}
<div className="flex items-center justify-between gap-3">
<span className="app-text-muted text-sm">
Страница {externalPage}
</span>
<div className="flex gap-2">
<button
className={buttonClass}
disabled={externalPage === 1}
onClick={() => setExternalPage(externalPage - 1)}
>
Назад
</button>
<button
className={buttonClass}
disabled={!externalNext}
onClick={() => setExternalPage(externalPage + 1)}
>
Далее
</button>
</div>
</div>
</>
)}
</div>
</Panel>
<Panel>
<h2>
<button
type="button"
className="flex w-full min-w-0 items-center gap-3 text-left"
aria-expanded={calendarOpen}
aria-controls="recent-project-days"
aria-label={`${calendarOpen ? "Свернуть" : "Развернуть"} мои последние дни`}
onClick={() => setCalendarOpen((value) => !value)}
>
<Clock3
size={19}
className="shrink-0 text-[var(--accent-primary)]"
/>
<span className="min-w-0 flex-1 text-sm font-semibold sm:text-base">
Мои последние дни
</span>
<span className="app-badge app-badge-accent shrink-0 px-2 py-0.5 text-xs">
{calendar.length}
</span>
<ChevronDown
size={18}
className={`app-text-muted shrink-0 transition ${calendarOpen ? "rotate-180" : ""}`}
/>
</button>
</h2>
<div
id="recent-project-days"
className={
calendarOpen
? `grid grid-cols-1 gap-2 border-t border-[var(--border-subtle)] pt-4 ${wide ? "lg:grid-cols-2" : ""}`
: "hidden"
}
>
{calendar.map((d) => (
<button
key={d.date}
className="app-surface-muted group flex w-full min-w-0 items-center gap-3 rounded-xl border border-transparent px-3 py-3 text-left transition hover:border-[var(--accent-primary)] hover:bg-[var(--surface-elevated)]"
onClick={() => setDate(d.date)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{formatDate(d.date)}
</span>
<span className="app-text-muted mt-1 block truncate text-xs">
{d.hours === null ? "Часы неизвестны" : `${d.hours} ч`}
</span>
</span>
<span
className={`app-status-pill min-h-0 shrink-0 px-2 py-1 text-[11px] ${
d.status === "confirmed"
? "app-feedback-success"
: "app-badge"
}`}
>
{d.status === "confirmed" ? "Подтверждено" : "Черновик"}
</span>
<ChevronRight
size={17}
className="app-text-muted shrink-0 transition group-hover:translate-x-0.5 group-hover:text-[var(--accent-primary)]"
/>
</button>
))}
{!calendar.length && (
<p className="app-text-muted text-sm">
Распределите первый день через кнопку «Мой день».
</p>
)}
</div>
</Panel>
{create && (
<ProjectEditor
onClose={() => setCreate(false)}
onSaved={(p) => router.push(`/projects/${p.id}`)}
/>
)}
{importExternal && (
<ProjectEditor
initial={{
name: importExternal.external_name,
description: `Создано из проекта «${importExternal.external_name}» в ${importExternal.source_name}.`,
}}
saveProject={(data) =>
apiClient.createProjectFromExternal(importExternal, data)
}
onClose={() => setImportExternal(null)}
onSaved={(project) => router.push(`/projects/${project.id}`)}
/>
)}
<Modal
isOpen={Boolean(previewExternal)}
onClose={() => setPreviewExternal(null)}
title="Проект из МойСклад"
size="md"
>
{externalDetailLoading ? (
<div className="flex min-h-44 flex-col items-center justify-center gap-3">
<span className="h-7 w-7 animate-spin rounded-full border-2 border-[var(--border-strong)] border-t-[var(--accent-primary)]" />
<p className="app-text-muted text-sm">Загрузка проекта…</p>
</div>
) : externalDetailError ? (
<div className="app-feedback-danger rounded-xl p-4 text-sm">
{externalDetailError}
</div>
) : externalDetail ? (
<div className="space-y-4">
<section className="app-surface-muted flex min-w-0 items-start gap-3 rounded-xl p-4">
<span className="app-selected app-accent-text flex h-10 w-10 shrink-0 items-center justify-center rounded-lg">
<FolderKanban size={19} />
</span>
<div className="min-w-0 flex-1">
<h4 className="break-words font-semibold">
{externalDetail.external_name}
</h4>
<p className="app-text-muted mt-1 text-xs">
{externalDetail.source_name}
{externalDetail.code
? ` · Код ${externalDetail.code}`
: " · Код не задан"}
</p>
</div>
<span className="app-status-pill app-badge-accent min-h-0 shrink-0 px-2 py-1 text-[11px]">
Не привязан
</span>
</section>
<div className="grid grid-cols-2 gap-2">
<section className="app-surface-muted rounded-xl p-3">
<div className="app-text-muted flex items-center gap-2 text-xs">
<ReceiptText size={14} />
Расходные документы
</div>
<p className="mt-2 text-lg font-semibold">
{externalDetail.payment_count}
</p>
</section>
<section className="app-surface-muted rounded-xl p-3">
<div className="app-text-muted flex items-center gap-2 text-xs">
<ShoppingCart size={14} />
Продажи и возвраты
</div>
<p className="mt-2 text-lg font-semibold">
{externalDetail.sale_count}
</p>
</section>
</div>
{externalDetail.payments.length ? (
<section className="space-y-2">
<h4 className="app-text-muted text-xs font-semibold uppercase tracking-wide">
Оплаченные расходы
</h4>
<div className="space-y-2">
{externalDetail.payments.map((payment) => (
<div
key={payment.currency}
className="app-surface-muted flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-sm"
>
<span className="app-text-muted">
{payment.count} док.
</span>
<strong>
{formatAmount(payment.amount, payment.currency)}
</strong>
</div>
))}
</div>
</section>
) : null}
<section className="space-y-1">
<h4 className="app-text-muted text-xs font-semibold uppercase tracking-wide">
Описание
</h4>
<p className="whitespace-pre-wrap text-sm leading-relaxed">
{externalDetail.description ||
"Описание в МойСклад не заполнено."}
</p>
</section>
<dl className="app-surface-muted grid gap-3 rounded-xl p-3 text-xs sm:grid-cols-2">
<div>
<dt className="app-text-muted">Изменено в источнике</dt>
<dd className="mt-1">
{formatDateTime(externalDetail.source_updated_at)}
</dd>
</div>
<div>
<dt className="app-text-muted">Синхронизировано</dt>
<dd className="mt-1">
{formatDateTime(externalDetail.synced_at)}
</dd>
</div>
<div className="min-w-0 sm:col-span-2">
<dt className="app-text-muted">Внешний ID</dt>
<dd className="mt-1 break-all font-mono text-[11px]">
{externalDetail.external_id}
</dd>
</div>
</dl>
{canCreate ? (
<div className="flex flex-wrap justify-end gap-2 border-t border-[var(--border-subtle)] pt-4">
<button
type="button"
className={buttonClass}
disabled={!manageableProjects.length}
onClick={() => {
setPreviewExternal(null);
setLinkExternal(externalDetail);
setLinkProjectId(manageableProjects[0]?.id || 0);
}}
>
<Link2 size={15} />
Привязать
</button>
<button
type="button"
className={primaryClass}
onClick={() => {
setPreviewExternal(null);
setImportExternal(externalDetail);
}}
>
<Plus size={15} />
Создать локальный
</button>
</div>
) : null}
</div>
) : null}
</Modal>
<Modal
isOpen={Boolean(linkExternal)}
onClose={() => {
if (!externalBusy) setLinkExternal(null);
}}
title="Привязать к локальному проекту"
>
{linkExternal && (
<form
className="space-y-4"
onSubmit={async (event) => {
event.preventDefault();
setExternalBusy(true);
setError("");
try {
await apiClient.saveProjectBinding(linkProjectId, {
source: linkExternal.source,
external_id: linkExternal.external_id,
external_name: linkExternal.external_name,
object_type: "project",
});
removeExternalCandidate(linkExternal);
setLinkExternal(null);
} catch (e) {
setError(errorText(e));
} finally {
setExternalBusy(false);
}
}}
>
<p className="text-sm">
<strong>{linkExternal.external_name}</strong>
<span className="app-text-muted">
{" "}
· {linkExternal.source_name}
</span>
</p>
<label className="block space-y-1 text-sm">
<span className="app-text-muted">Локальный проект</span>
<select
required
className={fieldClass}
value={linkProjectId}
onChange={(event) =>
setLinkProjectId(Number(event.target.value))
}
>
{manageableProjects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
</label>
<p className="app-text-muted text-xs">
Платежи и другие данные внешнего проекта будут учитываться в
выбранном локальном проекте. Названия не объединяются.
</p>
<button
className={primaryClass}
disabled={externalBusy || !linkProjectId}
>
{externalBusy ? "Привязка…" : "Привязать"}
</button>
</form>
)}
</Modal>
{date && (
<DailyTimeAllocationModal
date={date}
onClose={() => setDate(null)}
onSaved={() => void refreshCalendar()}
/>
)}
<Modal
isOpen={help}
onClose={() => setHelp(false)}
title="Проекты и участие"
size="lg"
>
<div className="space-y-4 text-sm">
<p>
Создавайте проекты внутри портала, добавляйте команду и этапы.
Непривязанные проекты МойСклад показаны в очереди на главной
странице: из записи можно создать локальный проект или привязать
её к существующему. Руководитель также может связать объекты
других подключённых сервисов. Совпадение названий не объединяет
записи автоматически.
</p>
<p>
В «Мой день» распределите время между проектами и общей
деятельностью. Для подтверждения нужно 100%. Часы берутся из
успешного анализа посещаемости; без него часы неизвестны. Проценты
не изменяют зарплату или баллы выработки.
</p>
<p>
Напоминание появляется один раз в день при входе после
отработанной смены, если за последние 14 дней есть незаполненный
день и участие в проекте. Оно не перекрывает другое модальное
окно. Можно отложить на два часа.
</p>
<p>
Участник видит свои часы, руководитель — часы команды. Финансовые
суммы доступны только с правами финансовой аналитики. Завершённые
проекты сохраняют историю.
</p>
</div>
</Modal>
</div>
</AppShell>
);
}

View File

@ -1,6 +1,6 @@
"use client";
import { AlertTriangle, Building2, CalendarCheck, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Kanban, Loader2, Menu, MessageSquare, ScrollText, Search, Send, ShoppingCart, UserRoundPlus, Users, Wallet } from "lucide-react";
import { FolderKanban, AlertTriangle, Building2, CalendarCheck, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Kanban, Loader2, Menu, MessageSquare, ScrollText, Search, Send, ShoppingCart, UserRoundPlus, Users, Wallet } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { ReactNode, Suspense, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
@ -21,6 +21,8 @@ import { NAV_NOTIFICATION_CATEGORIES } from "@/lib/navigation-notifications";
import wsManager from "@/lib/websocketManager";
import type { GuestVisit, GuestVisitComment } from "@/types/api";
import { DailyTimeAllocationPrompt } from "@/components/projects/DailyTimeAllocationPrompt";
type AppShellProps = {
children: ReactNode;
desktopWideMode?: boolean;
@ -69,6 +71,7 @@ const navItems = [
{ href: "/procurement", label: "Закупки", icon: ShoppingCart, category: NAV_NOTIFICATION_CATEGORIES.procurement, autoReadOnNavigate: false },
// Раздел сохранён для дальнейшей разработки; интеграции доступны в Django admin.
// { href: "/products", label: "Товары", icon: PackageSearch },
{ href: "/projects", label: "Проекты", icon: FolderKanban },
{ href: "/tasks", label: "Доска", icon: Kanban, category: NAV_NOTIFICATION_CATEGORIES.tasks, autoReadOnNavigate: false },
{ href: "/documents?section=folders", label: "Документы", icon: FileText, category: NAV_NOTIFICATION_CATEGORIES.documents, documentSection: "folders" as const },
{ href: "/documents?section=regulations", label: "Регламенты", icon: ScrollText, category: NAV_NOTIFICATION_CATEGORIES.regulations, autoReadOnNavigate: false, documentSection: "regulations" as const },
@ -828,6 +831,7 @@ export function AppShell({
{!standaloneAppMode ? <CalendarModals {...cal} /> : null}
<GuestInfoRequestPrompt userId={user.id} />
{!standaloneAppMode && <DailyTimeAllocationPrompt userId={user.id} />}
</div>
);
}

View File

@ -76,12 +76,15 @@ export function AnalyticsHelpContent({ tab }: { tab: AnalyticsTab }) {
</>}
{tab === "projects" && <>
<p>Оплачено — проведённые исходящие платежи и расходные ордера МойСклад. Выплаты и закупки показаны отдельно и не вычитаются повторно из прибыли продаж.</p>
<p> Проект платежа берётся из поля «Проект» в МойСклад. Платежи без этой
связи остаются в группе «Без проекта». Изменяйте импортированные
<p>Стоимость труда = подтверждённые часы участия × стоимость часа. Пока зарплатный период открыт, сумма предварительная: используется текущий расчёт, а до его появления — утверждённая ставка и норма часов. После закрытия периода сумма становится точной и рассчитывается из итогового начисления сотруднику с премиями, доплатами и корректировками. Если расчётного периода, нормы или итоговых данных нет, соответствующая часть помечается как неоценённая и не считается нулём.</p>
<p>Проектом отчёта служит локальная карточка портала. Платежи, продажи и
расходы подключённых сервисов объединяются по точным внешним привязкам.
Записи без локальной связи остаются в группе «Без привязки». Изменяйте импортированные
документы в МойСклад. Синхронизацией управляет администратор в админке,
в разделе «Подключения интеграций». В журнал добавляйте только
отсутствующие в МойСклад факты.
Архивные проекты сохраняются в отчётах. Отсутствие загруженных
Подтверждённое распределение рабочего времени показывается в часах и
денежной стоимости. Архивные проекты сохраняются в отчётах. Отсутствие загруженных
документов не подтверждает отсутствие реальных расходов.
</p>
</>}

View File

@ -125,7 +125,7 @@ export function AnalyticsWorkspace({ actionsTargetId, desktopWideMode, onDesktop
`${params.date_from.split("-").reverse().join(".")} — ${params.date_to.split("-").reverse().join(".")}`,
params.currency,
query.source ? options?.sources.find((source) => String(source.id) === query.source)?.name : null,
query.project === "unassigned" ? "Без проекта" : query.project
query.project === "unassigned" ? "Без привязки" : query.project
? options?.projects.find((project) => String(project.id) === query.project)?.name : null,
].filter(Boolean).join(" · ");
const actionsTarget = typeof document !== "undefined" ? document.getElementById(actionsTargetId) : null;
@ -247,11 +247,12 @@ export function AnalyticsWorkspace({ actionsTargetId, desktopWideMode, onDesktop
}
>
<option value="">Все проекты</option>
<option value="unassigned">Без проекта</option>
<option value="unassigned">Без привязки</option>
{options?.projects
.filter(
(p) =>
!draft.source || String(p.source_id) === draft.source,
!draft.source ||
p.source_ids.includes(Number(draft.source)),
)
.map((p) => (
<option key={p.id} value={p.id}>
@ -299,7 +300,7 @@ export function AnalyticsWorkspace({ actionsTargetId, desktopWideMode, onDesktop
{tab === "journal" && options ? (
<ReportingJournal
key={JSON.stringify(params)}
params={params}
params={{ ...params, source: undefined }}
options={options}
onChanged={() => setReport(null)}
/>

View File

@ -13,6 +13,13 @@ import { AnalyticsRecordCard } from "./AnalyticsRecordCard";
import { DatasetChart } from "./DatasetChart";
import { AnalyticsListSection } from "./AnalyticsListSection";
const laborStatusLabel = (status: unknown) => ({
exact: "Точная — период закрыт",
preliminary: "Предварительная — период открыт",
mixed: "Смешанная — есть открытые периоды",
unavailable: "Нет оценки",
}[String(status)] || "Нет оценки");
export function ProjectsReport({
report,
params,
@ -24,32 +31,57 @@ export function ProjectsReport({
const unit = report.currency;
return (
<>
<div className="grid grid-cols-1 gap-3 @min-[420px]:grid-cols-2 @min-[900px]:grid-cols-4">
<div className="grid grid-cols-1 gap-3 @min-[420px]:grid-cols-2 @min-[900px]:grid-cols-3">
{[
["Оплачено по МойСклад", data.summary.paid],
["Из них без проекта", data.summary.unassigned_paid],
["Ручные выплаты", data.summary.manual_paid],
["Общие расходы по журналу", data.summary.overhead],
].map(([label, value]) => (
["Оплачено по источникам", data.summary.paid, unit],
["Без привязки", data.summary.unassigned_paid, unit],
["Ручные выплаты", data.summary.manual_paid, unit],
["Общие расходы", data.summary.overhead, unit],
["Подтверждённое время", data.summary.labor_hours, "ч"],
["Стоимость труда", data.summary.labor_cost, unit],
["Участников", data.summary.people_count, "чел."],
].map(([label, value, valueUnit]) => (
<section key={String(label)} className="app-surface min-w-0 rounded-2xl p-4 sm:p-5">
<p className="app-text-muted text-sm">{label}</p>
<p className="mt-3 break-words text-2xl font-semibold tabular-nums">
{formatMetric(value, 2)}{" "}
<span className="app-text-muted text-sm">{unit}</span>
<span className="app-text-muted text-sm">{valueUnit}</span>
</p>
</section>
))}
</div>
<p className="app-surface rounded-xl px-4 py-3 text-sm">
<strong>{laborStatusLabel(data.summary.labor_cost_status)}</strong>
{Number(data.summary.preliminary_labor_hours || 0) > 0 && (
<span className="app-text-muted">
{" "}Предварительно: {formatMetric(data.summary.preliminary_labor_cost, 2)} {unit}
{Number(data.summary.exact_labor_hours || 0) > 0
? ` · точно по закрытым периодам: ${formatMetric(data.summary.exact_labor_cost, 2)} ${unit}`
: ""}.
</span>
)}
</p>
{!data.summary.labor_cost_complete && (
<p className="app-feedback-warning rounded-xl p-3 text-sm">
Стоимость труда рассчитана не полностью: {formatMetric(data.summary.unpriced_labor_hours, 2)} ч
без предварительной базы или точного расчёта закрытого периода; дней без достоверных часов —{" "}
{formatMetric(data.summary.unknown_hours_days, 0)}.
</p>
)}
<div className="analytics-report-grid">
<ReportTable
featured
title="Расходы и продажи по проектам"
rows={data.rows}
rows={data.rows.map((row) => ({
...row,
labor_cost_status: laborStatusLabel(row.labor_cost_status),
}))}
filename={`projects-${report.period.from}-${report.period.to}-${unit}`}
note="Оплачено — проведённые исходящие платежи и расходные ордера МойСклад. Выплаты и закупки показаны отдельно и не вычитаются повторно из прибыли продаж."
note="Локальная карточка объединяет продажи, расходы и платежи всех связанных внешних проектов. До закрытия зарплатного периода стоимость труда предварительная. После закрытия она рассчитывается по итоговому начислению с премиями и доплатами."
columns={[
{ key: "name", label: "Проект", text: true },
{ key: "paid", additive: true, label: `Оплачено в МойСклад, ${unit}`, digits: 2 },
{ key: "external_project_count", additive: true, label: "Связей", digits: 0 },
{ key: "paid", additive: true, label: `Оплачено по источникам, ${unit}`, digits: 2 },
{ key: "payment_count", additive: true, label: "Платежей", digits: 0 },
{ key: "manual_paid", additive: true, label: `Ручные выплаты, ${unit}`, digits: 2 },
{
@ -69,20 +101,50 @@ export function ProjectsReport({
label: `Прибыль продаж по учтённым расходам, ${unit}`,
digits: 2,
},
{ key: "labor_cost", additive: true, label: `Стоимость труда, ${unit}`, digits: 2 },
{ key: "labor_cost_status", label: "Статус суммы", text: true },
{ key: "project_result", additive: true, label: `Результат после труда и общих расходов, ${unit}`, digits: 2 },
]}
/>
<ReportTable
title="Выплаты по месяцам"
rows={data.monthly}
title="Участие сотрудников по проектам"
rows={data.rows.filter((row) => row.id !== "unassigned").map((row) => ({
...row,
labor_cost_status: laborStatusLabel(row.labor_cost_status),
}))}
filename={`project-labor-${report.period.from}-${report.period.to}`}
note="Часы рассчитываются из подтверждённого распределения дня. Сумма открытого периода предварительная; сумма закрытого периода использует итоговое начисление сотруднику, включая премии и доплаты. Пропуски учитываются отдельно."
columns={[
{ key: "name", label: "Проект", text: true },
{ key: "labor_hours", additive: true, label: "Трудозатраты, ч", digits: 2 },
{ key: "labor_cost", additive: true, label: `Стоимость труда, ${unit}`, digits: 2 },
{ key: "labor_cost_status", label: "Статус суммы", text: true },
{ key: "labor_hour_cost", label: `Средняя стоимость часа, ${unit}`, digits: 2 },
{ key: "unpriced_labor_hours", additive: true, label: "Часы без оценки", digits: 2 },
{ key: "people_count", label: "Участников", digits: 0 },
{ key: "allocation_days", additive: true, label: "Дней учёта", digits: 0 },
{ key: "unknown_hours_days", additive: true, label: "Дней без часов", digits: 0 },
]}
/>
<ReportTable
title="Движения и трудозатраты по месяцам"
rows={data.monthly.map((row) => ({
...row,
labor_cost_status: laborStatusLabel(row.labor_cost_status),
}))}
filename={`project-months-${report.period.from}-${report.period.to}-${unit}`}
columns={[
{ key: "name", label: "Месяц", text: true },
{ key: "paid", additive: true, label: `МойСклад, ${unit}`, digits: 2 },
{ key: "paid", additive: true, label: `Источники, ${unit}`, digits: 2 },
{ key: "manual_paid", additive: true, label: `Ручные выплаты, ${unit}`, digits: 2 },
{ key: "labor_hours", additive: true, label: "Трудозатраты, ч", digits: 2 },
{ key: "labor_cost", additive: true, label: `Стоимость труда, ${unit}`, digits: 2 },
{ key: "labor_cost_status", label: "Статус суммы", text: true },
{ key: "unpriced_labor_hours", additive: true, label: "Часы без оценки", digits: 2 },
]}
/>
<ReportTable
title="Статьи оплаченных расходов МойСклад"
title="Статьи оплаченных расходов"
rows={data.categories}
filename={`project-expenses-${report.period.from}-${report.period.to}-${unit}`}
columns={[
@ -151,12 +213,13 @@ function ProjectPayments({ params }: { params: ReportParams }) {
<div className="analytics-records-compact space-y-2">
{data.results.map((payment) => <AnalyticsRecordCard key={payment.id}
title={`${payment.document_type === "cashout" ? "Расходный ордер" : "Платёж"} ${payment.number}`}
subtitle={`${new Date(payment.occurred_at).toLocaleDateString("ru-RU")} · ${payment.project_name || "Без проекта"}`}
subtitle={`${new Date(payment.occurred_at).toLocaleDateString("ru-RU")} · ${payment.project_name || "Без привязки"}`}
summary={`${formatMetric(payment.amount, 2)} ${payment.currency}`}
fields={[
{ label: "Дата", value: new Date(payment.occurred_at).toLocaleDateString("ru-RU") },
{ label: "Документ", value: payment.number || "—" },
{ label: "Проект", value: payment.project_name || "Без проекта" },
{ label: "Локальный проект", value: payment.project_name || "Без привязки" },
{ label: "Проект источника", value: payment.external_project_name || "Не указан" },
{ label: "Статья", value: payment.expense_category || "Статья не указана" },
{ label: "Получатель", value: payment.payee || "—" },
{ label: "Назначение", value: payment.description || "—" },
@ -197,7 +260,12 @@ function ProjectPayments({ params }: { params: ReportParams }) {
</p>
</td>
<td className="min-w-36 px-3 py-3">
{p.project_name || "Без проекта"}
{p.project_name || "Без привязки"}
{p.external_project_name && (
<p className="app-text-muted mt-1 text-xs">
{p.external_project_name} · {p.source_name}
</p>
)}
</td>
<td className="min-w-44 px-3 py-3">
{p.expense_category || "Статья не указана"}

View File

@ -107,7 +107,7 @@ function EntryForm({
name="project"
defaultValue={project === "unassigned" ? "" : project || ""}
>
<option value="">Без проекта</option>
<option value="">Без локального проекта</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
@ -355,7 +355,7 @@ export function ReportingJournal({
{ label: "Дата", value: entry.date },
{ label: "Документ", value: entry.reference || "—" },
{ label: "Вид", value: reportingKinds[entry.kind] },
{ label: "Проект", value: entry.project_name || "Без проекта" },
{ label: "Проект", value: entry.project_name || "Без локального проекта" },
{ label: "Товар", value: entry.product_name || "—" },
{ label: "Сотрудник", value: entry.employee_name || "—" },
{ label: "Назначение", value: entry.description || "—" },
@ -398,7 +398,7 @@ export function ReportingJournal({
<td className="min-w-56 px-3 py-3">
<p>{reportingKinds[entry.kind]}</p>
<p className="app-text-muted mt-1">
{entry.project_name || "Без проекта"}
{entry.project_name || "Без локального проекта"}
</p>
<p className="app-text-muted mt-1">
{entry.product_name ||

View File

@ -51,6 +51,8 @@ function currentEntryForDate(
return entries.find((entry) => entry.work_date === workDate) || null;
}
import { DailyTimeAllocationModal } from "@/components/projects/DailyTimeAllocationModal";
export function PayrollDailyWorkSection({
compactPeriodSelector = false,
detailsTarget,
@ -65,6 +67,7 @@ export function PayrollDailyWorkSection({
const [workspace, setWorkspace] = useState<PayrollOwnDailyWorkWorkspace | null>(null);
const [selectedPeriodId, setSelectedPeriodId] = useState<number | null>(null);
const [workDate, setWorkDate] = useState("");
const [projectDayOpen, setProjectDayOpen] = useState(false);
const [actualPoints, setActualPoints] = useState("");
const [note, setNote] = useState("");
const [loading, setLoading] = useState(true);
@ -447,6 +450,8 @@ export function PayrollDailyWorkSection({
{periodSelector}
</div>
)}
<div className="flex justify-end"><button type="button" className="app-action-secondary rounded-xl px-3 py-2 text-sm" disabled={!workDate} onClick={() => setProjectDayOpen(true)}>Распределить день по проектам</button></div>
{projectDayOpen && <DailyTimeAllocationModal date={workDate} onClose={() => setProjectDayOpen(false)} />}
<section className={cardClassName}>
<form onSubmit={submit}>
<div className="flex items-start gap-2">

View File

@ -0,0 +1,353 @@
"use client";
import { useEffect, useState } from "react";
import { Plus, Trash2, Copy } from "lucide-react";
import { Modal } from "@/components/ui";
import { apiClient } from "@/lib/api";
import type { ProjectDay } from "@/types/projects";
import {
Field,
fieldClass,
buttonClass,
primaryClass,
activityLabels,
today,
number,
errorText,
} from "./shared";
export function DailyTimeAllocationModal({
date,
onClose,
onSaved,
onSnooze,
}: {
date: string;
onClose: () => void;
onSaved?: () => void;
onSnooze?: () => Promise<void>;
}) {
const [selected, setSelected] = useState(date),
[day, setDay] = useState<ProjectDay | null>(null),
[error, setError] = useState(""),
[busy, setBusy] = useState(false),
[dirty, setDirty] = useState(false);
useEffect(() => {
let live = true;
const timer = setTimeout(() => {
setDay(null);
setError("");
apiClient
.getProjectDay(selected)
.then((result) => {
if (live) {
setDay(result);
setDirty(false);
}
})
.catch((e) => {
if (live) setError(errorText(e));
});
}, 0);
return () => {
live = false;
clearTimeout(timer);
};
}, [selected]);
const total =
day?.allocations.reduce(
(sum, a) => sum + Math.round(Number(a.percent || 0) * 100),
0,
) || 0;
const change = (update: Partial<ProjectDay>) => {
if (day) {
setDay({ ...day, ...update });
setDirty(true);
}
};
const close = () => {
if (
!busy &&
(!dirty || window.confirm("Закрыть без сохранения распределения?"))
)
onClose();
};
const save = async (status: "draft" | "confirmed") => {
if (!day) return;
setBusy(true);
setError("");
try {
const saved = await apiClient.saveProjectDay({ ...day, status });
setDay(saved);
setDirty(false);
onSaved?.();
onClose();
} catch (e) {
setError(errorText(e));
} finally {
setBusy(false);
}
};
return (
<Modal isOpen onClose={close} title="На что ушёл рабочий день?" size="lg">
<div className="space-y-4">
<Field label="Рабочий день">
<input
type="date"
max={today()}
className={fieldClass}
value={selected}
disabled={busy}
onChange={(e) => {
if (
e.target.value &&
(!dirty || window.confirm("Сменить дату без сохранения?"))
) {
setDay(null);
setSelected(e.target.value);
}
}}
/>
</Field>
{error && (
<p role="alert" className="text-red-400">
{error}
</p>
)}
{!day && !error && <p>Загрузка…</p>}
{day && (
<>
<p className="app-text-muted text-sm">
{day.hours === null
? "Продолжительность дня пока неизвестна. Проценты можно сохранить; часы останутся неподтверждёнными."
: `По посещаемости: ${number(day.hours)} ч. Распределение не меняет зарплату и баллы выработки.`}
</p>
{day.attendance_changed && (
<p className="text-sm text-amber-400">
Посещаемость изменилась. При сохранении часы будут обновлены;
предыдущая версия сохранится в истории.
</p>
)}
<button
type="button"
className={buttonClass}
disabled={busy}
onClick={async () => {
setBusy(true);
try {
const calendar = await apiClient.getProjectCalendar();
const previous = calendar.find(
(d) => d.date < selected && d.status === "confirmed",
);
if (!previous)
throw new Error(
"Нет предыдущего подтверждённого дня за последние 90 дней",
);
const old = await apiClient.getProjectDay(previous.date);
const allowed = new Set(day.projects.map((p) => p.id));
const rows = old.allocations.filter(
(a) => !a.project || allowed.has(a.project),
);
change({ allocations: rows });
if (rows.length !== old.allocations.length)
setError(
"Недоступные на эту дату проекты пропущены. Проверьте остаток.",
);
} catch (e) {
setError(errorText(e));
} finally {
setBusy(false);
}
}}
>
<Copy size={16} />
Повторить прошлый рабочий день
</button>
<div className="space-y-3">
{day.allocations.map((a, i) => (
<div
key={i}
className="app-surface-muted grid grid-cols-[minmax(0,1fr)_82px_32px] gap-2 rounded-xl p-3"
>
<select
aria-label={`Проект или деятельность ${i + 1}`}
className={fieldClass}
value={a.project ? `p:${a.project}` : a.activity}
disabled={busy}
onChange={(e) => {
const value = e.target.value;
change({
allocations: day.allocations.map((row, j) =>
i === j
? {
...row,
project: value.startsWith("p:")
? Number(value.slice(2))
: null,
activity: value.startsWith("p:") ? "" : value,
}
: row,
),
});
}}
>
<option value="">Выберите</option>
<optgroup label="Мои проекты">
{day.projects.map((p) => (
<option key={p.id} value={`p:${p.id}`}>
{p.name}
</option>
))}
{a.project &&
!day.projects.some((p) => p.id === a.project) && (
<option value={`p:${a.project}`}>
{a.project_name || `Проект #${a.project}`} (история)
</option>
)}
</optgroup>
<optgroup label="Общая деятельность">
{Object.entries(activityLabels).map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))}
</optgroup>
</select>
<input
aria-label={`Процент времени ${i + 1}`}
type="number"
inputMode="decimal"
min="0.01"
max="100"
step="0.01"
disabled={busy}
className={fieldClass}
value={a.percent}
onChange={(e) =>
change({
allocations: day.allocations.map((row, j) =>
i === j ? { ...row, percent: e.target.value } : row,
),
})
}
/>
<button
type="button"
aria-label={`Удалить строку ${i + 1}`}
disabled={busy}
onClick={() =>
change({
allocations: day.allocations.filter((_, j) => i !== j),
})
}
>
<Trash2 size={16} />
</button>
<div className="col-span-3 flex flex-wrap items-center gap-2 text-xs app-text-muted">
{[25, 50, 75, 100].map((v) => (
<button
disabled={busy}
key={v}
type="button"
className="rounded px-2 py-1 hover:bg-white/10"
onClick={() =>
change({
allocations: day.allocations.map((row, j) =>
i === j ? { ...row, percent: String(v) } : row,
),
})
}
>
{v}%
</button>
))}
<span className="ml-auto">
{day.hours === null
? "Часы неизвестны"
: `${number((Number(day.hours) * Number(a.percent || 0)) / 100)} ч`}
</span>
</div>
</div>
))}
</div>
<button
className={buttonClass}
disabled={busy || day.allocations.length >= 50}
onClick={() =>
change({
allocations: [
...day.allocations,
{
project: null,
activity: "",
percent: String(Math.max(0, 100 - total / 100)),
},
],
})
}
>
<Plus size={16} />
Добавить строку
</button>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>Распределено</span>
<strong className={total > 10000 ? "text-red-400" : ""}>
{number(total / 100)}% / 100%
</strong>
</div>
<div className="app-surface-muted h-2 overflow-hidden rounded-full">
<div
className="h-full bg-[var(--accent-primary)]"
style={{ width: `${Math.min(100, total / 100)}%` }}
/>
</div>
</div>
<Field label="Комментарий (необязательно)">
<textarea
className={fieldClass}
maxLength={2000}
value={day.note}
onChange={(e) => change({ note: e.target.value })}
/>
</Field>
<div className="flex flex-wrap gap-2">
<button
disabled={busy || total > 10000}
className={buttonClass}
onClick={() => void save("draft")}
>
Сохранить черновик
</button>
<button
disabled={busy || total !== 10000}
className={primaryClass}
onClick={() => void save("confirmed")}
>
Подтвердить день
</button>
{onSnooze && (
<button
disabled={busy}
className={buttonClass}
onClick={async () => {
if (dirty && !window.confirm("Отложить без сохранения?"))
return;
setBusy(true);
try {
await onSnooze();
} catch (e) {
setError(errorText(e));
} finally {
setBusy(false);
}
}}
>
Напомнить через 2 часа
</button>
)}
</div>
</>
)}
</div>
</Modal>
);
}

View File

@ -0,0 +1,67 @@
"use client";
import { useEffect, useState } from "react";
import { apiClient } from "@/lib/api";
import { DailyTimeAllocationModal } from "./DailyTimeAllocationModal";
export function DailyTimeAllocationPrompt({ userId }: { userId: number }) {
const [date, setDate] = useState<string | null>(null);
useEffect(() => {
let live = true,
inFlight = false;
let pending: string | null = null;
const check = async () => {
if (
!live ||
inFlight ||
document.visibilityState !== "visible" ||
document.querySelector('[role="dialog"]') ||
["INPUT", "TEXTAREA", "SELECT"].includes(
document.activeElement?.tagName || "",
)
)
return;
if (pending) {
setDate(pending);
pending = null;
return;
}
inFlight = true;
try {
const r = await apiClient.claimProjectReminder();
if (live && r.date) {
if (
document.querySelector('[role="dialog"]') ||
document.visibilityState !== "visible" ||
["INPUT", "TEXTAREA", "SELECT"].includes(
document.activeElement?.tagName || "",
)
)
pending = r.date;
else setDate(r.date);
}
} catch {
/* Retry on the next scheduled check; foreground UI remains usable. */
} finally {
inFlight = false;
}
};
const timer = window.setInterval(() => void check(), 60000);
const first = window.setTimeout(() => void check(), 15000);
document.addEventListener("visibilitychange", check);
return () => {
live = false;
clearInterval(timer);
clearTimeout(first);
document.removeEventListener("visibilitychange", check);
};
}, [userId]);
return date ? (
<DailyTimeAllocationModal
date={date}
onClose={() => setDate(null)}
onSnooze={async () => {
await apiClient.claimProjectReminder(true);
setDate(null);
}}
/>
) : null;
}

View File

@ -0,0 +1,172 @@
"use client";
import { useEffect, useState } from "react";
import { Modal } from "@/components/ui";
import { apiClient } from "@/lib/api";
import { useUser } from "@/contexts/UserContext";
import type { LocalProject, ProjectInput } from "@/types/projects";
import {
Field,
fieldClass,
primaryClass,
projectStatuses,
errorText,
} from "./shared";
export function ProjectEditor({
project,
initial,
saveProject,
onClose,
onSaved,
}: {
project?: LocalProject;
initial?: Partial<ProjectInput>;
saveProject?: (data: ProjectInput) => Promise<LocalProject>;
onClose: () => void;
onSaved: (p: LocalProject) => void;
}) {
const { user } = useUser();
const [form, setForm] = useState<ProjectInput>({
name: project?.name || initial?.name || "",
description: project?.description || initial?.description || "",
customer: project?.customer || initial?.customer || "",
manager: project?.manager || initial?.manager || user?.id || 0,
status: project?.status || initial?.status || "planned",
starts_on: project?.starts_on || initial?.starts_on || null,
ends_on: project?.ends_on || initial?.ends_on || null,
});
const [people, setPeople] = useState<{ id: number; name: string }[]>([]);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => {
let live = true;
apiClient
.getProjectOptions()
.then((v) => {
if (live) setPeople(v.employees);
})
.catch((e) => {
if (live) setError(errorText(e));
});
return () => {
live = false;
};
}, []);
return (
<Modal
isOpen
onClose={() => {
if (!busy) onClose();
}}
title={project ? "Редактировать проект" : "Новый проект"}
size="lg"
>
<form
className="space-y-4"
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
setError("");
try {
onSaved(
saveProject
? await saveProject(form)
: await apiClient.saveLocalProject(form, project?.id),
);
} catch (e) {
setError(errorText(e));
} finally {
setBusy(false);
}
}}
>
<Field label="Название">
<input
autoFocus
required
maxLength={255}
className={fieldClass}
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</Field>
<Field label="Цель и описание">
<textarea
className={fieldClass}
rows={3}
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</Field>
<Field label="Заказчик">
<input
maxLength={255}
className={fieldClass}
value={form.customer}
onChange={(e) => setForm({ ...form, customer: e.target.value })}
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Руководитель">
<select
required
className={fieldClass}
value={form.manager}
onChange={(e) =>
setForm({ ...form, manager: Number(e.target.value) })
}
>
<option value="">Выберите сотрудника</option>
{people.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</Field>
<Field label="Статус">
<select
className={fieldClass}
value={form.status}
onChange={(e) => setForm({ ...form, status: e.target.value })}
>
{Object.entries(projectStatuses).map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))}
</select>
</Field>
<Field label="Начало">
<input
type="date"
className={fieldClass}
value={form.starts_on || ""}
onChange={(e) =>
setForm({ ...form, starts_on: e.target.value || null })
}
/>
</Field>
<Field label="Окончание">
<input
type="date"
className={fieldClass}
min={form.starts_on || undefined}
value={form.ends_on || ""}
onChange={(e) =>
setForm({ ...form, ends_on: e.target.value || null })
}
/>
</Field>
</div>
{error && (
<p role="alert" className="text-red-400">
{error}
</p>
)}
<button disabled={busy} className={primaryClass}>
{busy ? "Сохранение…" : "Сохранить проект"}
</button>
</form>
</Modal>
);
}

View File

@ -0,0 +1,82 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { FolderKanban } from "lucide-react";
import { apiClient } from "@/lib/api";
import type { LocalProject } from "@/types/projects";
import { DailyTimeAllocationModal } from "./DailyTimeAllocationModal";
import { buttonClass, today, projectStatuses, errorText } from "./shared";
export function ProjectParticipationProfileSection() {
const [projects, setProjects] = useState<LocalProject[]>([]),
[count, setCount] = useState(0),
[open, setOpen] = useState(false),
[error, setError] = useState("");
useEffect(() => {
let live = true;
apiClient
.getLocalProjects("", 1, true)
.then((r) => {
if (live) {
setProjects(r.results.slice(0, 5));
setCount(r.count);
}
})
.catch((e) => {
if (live) setError(errorText(e));
});
return () => {
live = false;
};
}, []);
return (
<section className="app-surface rounded-2xl p-4 sm:p-5">
<details>
<summary className="flex cursor-pointer items-center gap-3 text-sm font-semibold">
<FolderKanban size={18} />
<span className="flex-1">Мои проекты</span>
<span className="app-badge app-badge-accent px-2 py-0.5">
{count}
</span>
</summary>
<div className="mt-4 space-y-3">
{error && (
<p role="alert" className="text-red-400 text-sm">
{error}
</p>
)}
{projects.map((p) => (
<Link
className="app-surface-muted flex items-center justify-between gap-3 rounded-xl p-3 text-sm"
href={`/projects/${p.id}`}
key={p.id}
>
<span className="min-w-0 break-words">{p.name}</span>
<span className="app-text-muted text-xs">
{projectStatuses[p.status]}
</span>
</Link>
))}
{!projects.length && !error && (
<p className="app-text-muted text-sm">
Вы пока не участвуете в проектах.
</p>
)}
<div className="flex flex-wrap gap-2">
<button className={buttonClass} onClick={() => setOpen(true)}>
Распределить мой день
</button>
<Link href="/projects" className={buttonClass}>
Открыть проекты
</Link>
</div>
</div>
</details>
{open && (
<DailyTimeAllocationModal
date={today()}
onClose={() => setOpen(false)}
/>
)}
</section>
);
}

View File

@ -0,0 +1,50 @@
import type { ReactNode } from "react";
export const projectStatuses: Record<string, string> = {
planned: "Планируется",
active: "В работе",
paused: "Приостановлен",
completed: "Завершён",
archived: "Архив",
};
export const activityLabels: Record<string, string> = {
department: "Работа отдела",
training: "Обучение",
support: "Поддержка",
};
export const fieldClass =
"app-input w-full min-w-0 rounded-xl px-3 py-2.5 text-sm";
export const buttonClass =
"app-action-secondary inline-flex items-center justify-center gap-2 rounded-xl px-3 py-2 text-sm";
export const primaryClass =
"app-action-primary inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm";
export const today = () =>
new Date().toLocaleDateString("en-CA", { timeZone: "Europe/Moscow" });
export const number = (n: string | number | null) =>
n === null
? "—"
: new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(
Number(n),
);
export const errorText = (e: unknown) =>
e instanceof Error ? e.message : "Не удалось выполнить действие";
export function Field({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<label className="block min-w-0 space-y-1 text-sm">
<span className="app-text-muted">{label}</span>
{children}
</label>
);
}
export function Panel({ children }: { children: ReactNode }) {
return (
<section className="app-surface min-w-0 space-y-4 rounded-2xl p-4 sm:p-5">
{children}
</section>
);
}

View File

@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronRight, Link2, Loader2, Plus } from "lucide-react";
import { ChevronRight, Link2, Loader2, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import TaskLinkPill from "@/components/tasks/TaskLinkPill";
@ -24,6 +24,8 @@ type RelatedTaskLinksProps = {
open?: boolean;
onClose?: () => void;
onLinked?: () => void;
canLink?: boolean;
unlinkTask?: (linkId: number) => Promise<unknown>;
};
const taskPriorityOptions: { value: TaskPriority; label: string }[] = [
@ -64,6 +66,8 @@ export function RelatedTaskLinks({
open = false,
onClose,
onLinked,
canLink = true,
unlinkTask,
}: RelatedTaskLinksProps) {
const [linkedTasks, setLinkedTasks] = useState<TaskCard[]>([]);
const [loading, setLoading] = useState(true);
@ -84,6 +88,7 @@ export function RelatedTaskLinks({
const [labelIds, setLabelIds] = useState<number[]>([]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [unlinkingId, setUnlinkingId] = useState<number | null>(null);
const selectedBoard = boards.find((board) => board.id === boardId) || null;
const selectedTask = (selectedBoard?.tasks || []).find((task) => task.id === taskId) || null;
@ -540,14 +545,16 @@ export function RelatedTaskLinks({
Связанные задачи ({linkedTasks.length})
</h3>
</div>
<button
type="button"
onClick={() => void openLinkModal()}
className="app-action-primary inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium"
>
<Plus size={14} />
Связать
</button>
{canLink ? (
<button
type="button"
onClick={() => void openLinkModal()}
className="app-action-primary inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium"
>
<Plus size={14} />
Связать
</button>
) : null}
</div>
{loading ? (
@ -557,21 +564,43 @@ export function RelatedTaskLinks({
) : linkedTasks.length > 0 ? (
<div className="flex flex-wrap gap-2">
{linkedTasks.map((task) => (
<TaskLinkPill
key={task.id}
task={{
id: task.id,
link_id: task.link_id,
title: task.title,
board_id: task.board,
board_name: task.board_name || "",
column_color: task.column_color,
priority: task.priority,
priority_display: task.priority_display,
can_open: task.can_open,
}}
maxTitleClassName="max-w-56"
/>
<span key={task.id} className="inline-flex max-w-full items-center gap-1">
<TaskLinkPill
task={{
id: task.id,
link_id: task.link_id,
title: task.title,
board_id: task.board,
board_name: task.board_name || "",
column_color: task.column_color,
priority: task.priority,
priority_display: task.priority_display,
can_open: task.can_open,
}}
maxTitleClassName="max-w-56"
/>
{canLink && unlinkTask && task.link_id ? (
<button
type="button"
className="app-icon-button"
aria-label={`Убрать связь с задачей ${task.title}`}
disabled={unlinkingId === task.link_id}
onClick={() => void (async () => {
setUnlinkingId(task.link_id!);
try {
await unlinkTask(task.link_id!);
await refreshLinkedTasks();
} catch (unlinkError) {
toast.error(unlinkError instanceof Error ? unlinkError.message : "Не удалось убрать связь");
} finally {
setUnlinkingId(null);
}
})()}
>
{unlinkingId === task.link_id ? <Loader2 className="animate-spin" size={14} /> : <Trash2 size={14} />}
</button>
) : null}
</span>
))}
</div>
) : (

View File

@ -18,6 +18,7 @@ import { createGuestsApi } from './guests';
import { createTasksApi } from './tasks';
import { createFinanceApi } from './finance';
import { createCommerceApi } from './commerce';
import { createProjectsApi } from './projects';
import { createAnalyticsApi } from './analytics';
const base = new ApiClientBase();
@ -41,6 +42,7 @@ export const apiClient = Object.assign(base, {
...createFinanceApi(req),
...createCommerceApi(req),
...createAnalyticsApi(req),
...createProjectsApi(req),
});
export default apiClient;

View File

@ -0,0 +1,174 @@
import type { RequestFn } from "./utils";
import { buildQuery } from "./utils";
import type {
LocalProject,
ProjectInput,
ProjectMember,
ProjectStage,
ProjectResource,
ProjectBinding,
ExternalProjectOption,
ProjectDay,
ProjectTimeReport,
ProjectFinance,
ProjectEvent,
ProjectOption,
ProjectBoardLink,
ExternalProjectDetail,
} from "@/types/projects";
import type { TaskCard } from "@/types/api";
export function createProjectsApi(request: RequestFn) {
const root = "/api/v1/projects/";
const write = (url: string, body: unknown, method = "POST") =>
request(url, { method, body: JSON.stringify(body) });
return {
getLocalProjects: (
search = "",
page = 1,
mine = false,
): Promise<{
count: number;
next: string | null;
results: LocalProject[];
}> => request(root + buildQuery({ search, page, mine })),
getLocalProject: (id: number): Promise<LocalProject> =>
request(`${root}${id}/`),
getProjectOptions: (): Promise<{
can_create: boolean;
employees: { id: number; name: string }[];
manageable_projects: ProjectOption[];
}> => request(root + "options/"),
getExternalProjectCandidates: (
q = "",
page = 1,
): Promise<{
count: number;
next: number | null;
previous: number | null;
results: ExternalProjectOption[];
}> => request(root + "external-candidates/" + buildQuery({ q, page })),
getExternalProjectDetail: (
source: number,
externalId: string,
): Promise<ExternalProjectDetail> =>
request(
root +
"external-project/" +
buildQuery({ source, external_id: externalId }),
),
createProjectFromExternal: (
external: Pick<
ExternalProjectOption,
"source" | "external_id" | "external_name"
>,
data: ProjectInput,
): Promise<LocalProject> =>
write(root + "from-external/", {
source: external.source,
external_id: external.external_id,
...data,
}),
saveLocalProject: (
data: ProjectInput,
id?: number,
): Promise<LocalProject> =>
write(id ? `${root}${id}/` : root, data, id ? "PATCH" : "POST"),
getProjectMembers: (id: number): Promise<ProjectMember[]> =>
request(`${root}${id}/members/`),
saveProjectMember: (
id: number,
data: Omit<ProjectMember, "id" | "employee_name">,
): Promise<ProjectMember[]> => write(`${root}${id}/members/`, data),
removeProjectMember: (
id: number,
member: number,
): Promise<ProjectMember[]> =>
write(`${root}${id}/members/`, { id: member }, "DELETE"),
getProjectStages: (id: number): Promise<ProjectStage[]> =>
request(`${root}${id}/stages/`),
saveProjectStage: (
id: number,
data: Partial<ProjectStage>,
): Promise<ProjectStage[]> => write(`${root}${id}/stages/`, data),
removeProjectStage: (id: number, stage: number): Promise<ProjectStage[]> =>
write(`${root}${id}/stages/`, { id: stage }, "DELETE"),
getProjectResources: (id: number): Promise<ProjectResource[]> =>
request(`${root}${id}/resources/`),
saveProjectResource: (
id: number,
data: Omit<ProjectResource, "id">,
): Promise<ProjectResource[]> => write(`${root}${id}/resources/`, data),
removeProjectResource: (
id: number,
resource: number,
): Promise<ProjectResource[]> =>
write(`${root}${id}/resources/`, { id: resource }, "DELETE"),
getProjectTaskLinks: (id: number): Promise<TaskCard[]> =>
request(`${root}${id}/task-links/`),
linkProjectTask: (id: number, taskId: number): Promise<TaskCard[]> =>
write(`${root}${id}/task-links/`, { task_id: taskId }),
unlinkProjectTask: (id: number, linkId: number): Promise<TaskCard[]> =>
write(`${root}${id}/task-links/`, { id: linkId }, "DELETE"),
getProjectBoardLinks: (id: number): Promise<ProjectBoardLink[]> =>
request(`${root}${id}/board-links/`),
linkProjectBoard: (
id: number,
boardId: number,
): Promise<ProjectBoardLink[]> =>
write(`${root}${id}/board-links/`, { board_id: boardId }),
unlinkProjectBoard: (
id: number,
linkId: number,
): Promise<ProjectBoardLink[]> =>
write(`${root}${id}/board-links/`, { id: linkId }, "DELETE"),
getProjectBindings: (id: number): Promise<ProjectBinding[]> =>
request(`${root}${id}/bindings/`),
saveProjectBinding: (
id: number,
data: Pick<
ProjectBinding,
"source" | "external_id" | "external_name" | "object_type"
>,
): Promise<ProjectBinding[]> => write(`${root}${id}/bindings/`, data),
removeProjectBinding: (
id: number,
binding: number,
): Promise<ProjectBinding[]> =>
write(`${root}${id}/bindings/`, { id: binding }, "DELETE"),
getExternalProjectOptions: (
id: number,
q = "",
): Promise<{
sources: { id: number; name: string }[];
external_projects: ExternalProjectOption[];
}> => request(`${root}${id}/external-options/${buildQuery({ q })}`),
getProjectHistory: (id: number): Promise<ProjectEvent[]> =>
request(`${root}${id}/history/`),
getProjectTimeReport: (
id: number,
from: string,
to: string,
): Promise<ProjectTimeReport> =>
request(`${root}${id}/time-report/${buildQuery({ from, to })}`),
getProjectFinances: (
id: number,
from: string,
to: string,
currency = "RUB",
): Promise<ProjectFinance> =>
request(`${root}${id}/finances/${buildQuery({ from, to, currency })}`),
getProjectDay: (date: string): Promise<ProjectDay> =>
request(root + "my-day/" + buildQuery({ date })),
saveProjectDay: (
day: Pick<
ProjectDay,
"date" | "version" | "status" | "note" | "allocations"
>,
): Promise<ProjectDay> => write(root + "my-day/", day),
getProjectCalendar: (): Promise<
{ date: string; status: string; hours: string | null }[]
> => request(root + "my-calendar/"),
claimProjectReminder: (snooze = false): Promise<{ date: string | null }> =>
write(root + "reminder/", { snooze }),
};
}

View File

@ -67,6 +67,7 @@ export type ReportingEntryInput = {
};
export type ReportingEntry = ReportingEntryInput & {
project_name: string;
external_project_name: string;
id: number;
product_name: string;
employee_name: string;
@ -77,7 +78,7 @@ export type AnalyticsOptions = {
projects: {
id: number;
name: string;
source_id: number;
source_ids: number[];
archived: boolean;
}[];
products: { id: string; name: string }[];
@ -90,6 +91,7 @@ export type AnalyticsOptions = {
export type ProjectPayment = {
id: number;
project_name: string;
external_project_name: string;
source_name: string;
document_type: string;
number: string;

View File

@ -0,0 +1,155 @@
export type LocalProject = {
id: number;
name: string;
description: string;
customer: string;
manager: number;
manager_name: string;
status: string;
starts_on: string | null;
ends_on: string | null;
can_manage: boolean;
can_view_finances: boolean;
};
export type ProjectInput = Pick<
LocalProject,
| "name"
| "description"
| "customer"
| "manager"
| "status"
| "starts_on"
| "ends_on"
>;
export type ProjectMember = {
id: number;
employee: number;
employee_name: string;
role: string;
planned_percent: string;
starts_on: string | null;
ends_on: string | null;
};
export type ProjectStage = {
id: number;
name: string;
due_on: string | null;
done: boolean;
position: number;
};
export type ProjectResource = {
id: number;
title: string;
kind: string;
url: string;
};
export type ProjectBoardLink = {
id: number;
board_id: number;
name: string;
url: string;
};
export type ProjectBinding = {
id: number;
source: number;
source_name: string;
object_type: string;
external_id: string;
external_name: string;
};
export type ExternalProjectOption = {
source: number;
source_name: string;
external_id: string;
external_name: string;
bound: boolean;
};
export type ExternalProjectDetail = ExternalProjectOption & {
code: string;
description: string;
synced_at: string | null;
source_updated_at: string | null;
payment_count: number;
payments: { currency: string; count: number; amount: string | number }[];
payment_first_at: string | null;
payment_last_at: string | null;
sale_count: number;
sale_first_at: string | null;
sale_last_at: string | null;
};
export type ProjectOption = { id: number; name: string };
export type AllocationRow = {
project: number | null;
project_name?: string;
activity: string;
percent: string;
};
export type ProjectDay = {
date: string;
version: number;
status: "draft" | "confirmed";
hours: string | number | null;
hours_source: string;
attendance_changed: boolean;
note: string;
allocations: AllocationRow[];
projects: { id: number; name: string }[];
};
export type ProjectTimeReport = {
from: string;
to: string;
scope: "team" | "self";
confirmed_hours: string | number;
unknown_days: number;
draft_days: number;
rows: {
date: string;
employee: string;
percent: string | number;
hours: string | number | null;
status: string;
hours_source: string;
}[];
};
export type ProjectFinance = {
from: string;
to: string;
currency: string;
external_project_count: number;
payment_count: number;
paid: string | number | null;
categories: { expense_category: string; paid: string | number }[];
labor_hours: string | number;
labor_cost: string | number;
exact_labor_cost: string | number;
preliminary_labor_cost: string | number;
exact_labor_hours: string | number;
preliminary_labor_hours: string | number;
labor_cost_status: "exact" | "preliminary" | "mixed" | "unavailable";
labor_hour_cost: string | number | null;
unpriced_labor_hours: string | number;
unknown_hours_days: number;
labor_cost_complete: boolean;
labor: {
employee_id: number;
employee: string;
hours: string | number;
cost: string | number;
exact_cost: string | number;
preliminary_cost: string | number;
exact_hours: string | number;
preliminary_hours: string | number;
cost_status: "exact" | "preliminary" | "mixed" | "unavailable";
hour_cost: string | number | null;
unpriced_hours: string | number;
unknown_days: number;
cost_complete: boolean;
}[];
};
export type ProjectEvent = {
id: number;
actor: string;
action: string;
details: Record<string, string>;
created_at: string;
};