386 lines
13 KiB
Python
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.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,
|
|
)
|
|
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())
|
|
],
|
|
}
|