298 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

from collections import defaultdict
from datetime import datetime, time, timedelta
from decimal import Decimal
from django.db.models import Q
from django.utils import timezone
from commerce.models import Cost, SaleLine
from finance.analytics.models import ReportingEntry
ZERO = Decimal("0")
def bounds(start, end):
tz = timezone.get_current_timezone()
return (
timezone.make_aware(datetime.combine(start, time.min), tz),
timezone.make_aware(datetime.combine(end + timedelta(days=1), time.min), tz),
)
def bucket():
return dict(
quantity=ZERO,
revenue=ZERO,
cost_of_goods=ZERO,
expenses=ZERO,
missing_cost_lines=0,
sale_lines=0,
)
def finish(row):
row = dict(row)
row["gross_profit"] = row["revenue"] - row["cost_of_goods"]
row["contribution_profit"] = row["gross_profit"] - row["expenses"]
row["profit"] = (
row["contribution_profit"] if not row["missing_cost_lines"] else None
)
row["margin_percent"] = (
row["profit"] / row["revenue"] * 100
if row["profit"] is not None and row["revenue"] > 0
else None
)
return row
def months(start, end):
current = start.replace(day=1)
while current <= end:
yield current.strftime("%Y-%m")
current = (current.replace(day=28) + timedelta(days=4)).replace(day=1)
def dimensions(labels, product, sale, channel=""):
product_key = str(product.pk) if product else "unmatched"
labels[product_key] = product.name if product else "Товар не сопоставлен"
channel_key = channel or (sale.channel if sale else "") or "Не указан"
customer_id = sale.customer_external_id if sale else ""
customer_name = sale.customer_name if sale else ""
customer_key = (
f"{sale.source_id}:{customer_id or customer_name}"
if sale and (customer_id or customer_name)
else "unknown"
)
labels[customer_key] = customer_name or customer_id or "Клиент не указан"
return product_key, channel_key, customer_key
def project_costs(costs, project_id):
if project_id is None:
return costs.filter(project__isnull=True, sale_line__sale__project__isnull=True)
return costs.filter(
Q(project_id=project_id)
| Q(project__isnull=True, sale_line__sale__project_id=project_id)
)
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,
sale__currency=currency,
sale__occurred_at__gte=lower,
sale__occurred_at__lt=upper,
).select_related("sale", "sale__project", "product")
costs = (
Cost.objects.filter(
currency=currency, occurred_at__gte=lower, occurred_at__lt=upper
)
.select_related(
"product",
"sale_line__product",
"sale_line__sale",
"sale_line__sale__project",
"project",
)
.exclude(sale_line__sale__counts_as_sale=False)
)
if source:
lines = lines.filter(sale__source_id=source)
costs = costs.filter(source_id=source)
lines, costs = filter_project_records(lines, costs, project, project_scope)
if product_ids is not None:
lines = lines.filter(product_id__in=product_ids)
costs = costs.filter(
Q(sale_line__product_id__in=product_ids)
| Q(sale_line__isnull=True, product_id__in=product_ids)
)
total = bucket()
groups = {
name: defaultdict(bucket)
for name in [
"products",
"channels",
"customers",
"product_channels",
"monthly",
"projects",
]
}
for month in months(start, end):
groups["monthly"][month]
labels = {}
unmatched = 0
missing_customer = 0
cost_types = defaultdict(lambda: ZERO)
def targets(product, sale, occurred_at, channel="", project=None):
p, ch, customer = dimensions(labels, product, sale, channel)
project = project or (sale.project if sale else None)
project_key = f"project:{project.pk}" if project else "unassigned"
labels[project_key] = project.name if project else "Без проекта"
month = timezone.localtime(occurred_at).strftime("%Y-%m")
return [
total,
groups["products"][p],
groups["channels"][ch],
groups["customers"][customer],
groups["product_channels"][(p, ch)],
groups["monthly"][month],
groups["projects"][project_key],
]
for line in lines.iterator(chunk_size=1000):
sign = -1 if line.sale.event_type == "return" else 1
# A zero without explicit provenance is unknown, not free inventory.
missing = line.metadata.get("cost_source") == "unavailable" or (
line.cost_amount == 0
and line.metadata.get("cost_source") not in ["manual", "known_zero"]
)
for row in targets(line.product, line.sale, line.sale.occurred_at):
row["quantity"] += sign * line.quantity
row["revenue"] += sign * (line.gross_amount - line.discount_amount)
row["cost_of_goods"] += sign * line.cost_amount
row["missing_cost_lines"] += int(missing)
row["sale_lines"] += 1
unmatched += int(line.product_id is None)
missing_customer += int(
dimensions(labels, line.product, line.sale)[2] == "unknown"
)
for cost in costs.iterator(chunk_size=1000):
sale = cost.sale_line.sale if cost.sale_line_id else None
product = cost.sale_line.product if cost.sale_line_id else cost.product
# Attribute linked expenses to the same channel as their sale.
for row in targets(
product, sale, cost.occurred_at, "" if sale else cost.channel, cost.project
):
row["expenses"] += cost.amount
cost_types[cost.cost_type] += cost.amount
result = {
"summary": finish(total),
"cost_breakdown": dict(cost_types),
"quality": {
"unmatched_product_lines": unmatched,
"missing_customer_lines": missing_customer,
"missing_cost_lines": total["missing_cost_lines"],
},
}
result.update(grouped_rows(groups, labels))
return result
def grouped_rows(groups, labels):
result = {}
for dimension, rows in groups.items():
output = []
for key, row in rows.items():
item = finish(row)
if dimension == "product_channels":
item.update(
id=f"{key[0]}:{key[1]}",
product_id=key[0],
name=labels[key[0]],
channel=key[1],
)
else:
item.update(id=key, name=labels.get(key, key))
output.append(item)
result[dimension] = (
sorted(output, key=lambda r: r["id"])
if dimension == "monthly"
else sorted(output, key=lambda r: (-r["revenue"], r["name"], r["id"]))
)
return result
def production_report(start, end, currency, sales, project=None):
entries = ReportingEntry.objects.filter(
date__range=(start, end), voided_at__isnull=True
).filter(Q(kind="production") | Q(currency=currency))
if project is not None:
entries = entries.filter(
project_id=None if project == "unassigned" else project.pk
)
product_rows = {
r["id"]: {**r, "produced": ZERO, "purchases": ZERO} for r in sales["products"]
}
monthly = {
r["id"]: {**r, "produced": ZERO, "purchases": ZERO} for r in sales["monthly"]
}
totals = defaultdict(lambda: ZERO)
counts = defaultdict(int)
for entry in entries.select_related("product"):
totals[entry.kind] += (
entry.quantity if entry.kind == "production" else entry.amount
)
counts[entry.kind] += 1
if entry.kind not in ["production", "purchase"]:
continue
field = "produced" if entry.kind == "production" else "purchases"
value = entry.quantity if entry.kind == "production" else entry.amount
month = entry.date.strftime("%Y-%m")
monthly[month][field] += value
key = str(entry.product_id) if entry.product_id else "unallocated"
if key not in product_rows:
product_rows[key] = {
**finish(bucket()),
"id": key,
"name": entry.product.name
if entry.product
else "Закупки без привязки к товару",
"produced": ZERO,
"purchases": ZERO,
}
product_rows[key][field] += value
# Cash facts are included once, by payment date; not expensed as salary again.
if project is None or project == "unassigned":
from finance.models import PayrollOffCyclePayment
off_cycle = PayrollOffCyclePayment.objects.filter(
paid_on__range=(start, end),
currency=currency,
voided_at__isnull=True,
)
for payment in off_cycle:
totals["cash_out"] += payment.amount
counts["cash_out"] += 1
return {
"products": list(product_rows.values()),
"monthly": list(monthly.values()),
"totals": {kind: totals[kind] for kind in ReportingEntry.Kind.values},
"record_counts": {kind: counts[kind] for kind in ReportingEntry.Kind.values},
"product_channels": sales["product_channels"],
}