386 lines
13 KiB
Python

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
@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,
)
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())
],
}