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.labor import labor_cost_status, price_labor_allocations from finance.analytics.models import ReportingEntry from finance.analytics.sales import ZERO, bounds, bucket, finish, months from projects.models import Project, ProjectExternalBinding, TimeAllocation @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, occurred_at__gte=lower, occurred_at__lt=upper, applicable=True, missing_from_source=False, ).select_related("project", "source") if source: qs = qs.filter(source=source) 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): return ReportingEntry.objects.filter( date__range=(start, end), currency=currency, voided_at__isnull=True, kind__in=["cash_out", "overhead", "purchase"], ).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() ) 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_id = str(sale_row["id"]) external_id = ( int(row_id.split(":", 1)[1]) if row_id.startswith("project:") else None ) 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 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 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 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) } 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, ) if journal_entry_in_scope(None, project, scope, local_by_id): from finance.models import PayrollOffCyclePayment for payment in PayrollOffCyclePayment.objects.filter( paid_on__range=(start, end), currency=currency, voided_at__isnull=True, ): row = project_row(rows, "unassigned", "Без привязки") row["manual_paid"] += payment.amount monthly[payment.paid_on.strftime("%Y-%m")]["manual_paid"] += payment.amount 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 row: (-row["paid"], row["name"])), "monthly": list(monthly.values()), "categories": [ {"id": key, "name": key, **value} for key, value in sorted(categories.items()) ], }