Corp/backend/tests/finance/test_analytics.py

328 lines
11 KiB
Python

from datetime import date, datetime
from decimal import Decimal
import pytest
from django.urls import reverse
from django.utils import timezone
from commerce.models import Cost, Product, Sale, SaleLine, SourceSystem
from finance.analytics.models import ReportingEntry
from finance.analytics.people import people_report
from finance.analytics.sales import production_report, sales_report
from finance.models import FinanceSettings, PayrollPeriod, PayrollRun, PayrollStatement
pytestmark = pytest.mark.django_db
def moment(day):
return timezone.make_aware(datetime.fromisoformat(day + "T12:00:00"))
@pytest.fixture
def facts():
source = SourceSystem.objects.create(code="test-analytics", name="Test")
product = Product.objects.create(sku="ANALYTICS", name="Test product")
def line(ref, revenue="100", cost="40", day="2026-01-10", **kwargs):
sale = Sale.objects.create(
source=source,
external_id=ref,
occurred_at=moment(day),
channel="direct",
customer_external_id="client-1",
customer_name="Client",
**kwargs,
)
return SaleLine.objects.create(
sale=sale,
product=product,
external_id="1",
quantity=1,
gross_amount=Decimal(revenue),
cost_amount=Decimal(cost),
)
return source, product, line
def test_sales_reconcile_refunds_expenses_currency_and_date_boundaries(facts):
source, product, line = facts
sale_line = line("sale")
line("return", revenue="25", cost="10", event_type="return")
line("cancelled", counts_as_sale=False)
line("usd", currency="USD")
line("outside", day="2026-02-01")
Cost.objects.create(
source=source,
product=product,
sale_line=sale_line,
external_id="fee",
amount=5,
cost_type="commission",
occurred_at=moment("2026-01-31"),
)
Cost.objects.create(
source=source,
product=product,
amount=2,
cost_type="advertising",
occurred_at=moment("2026-01-15"),
)
report = sales_report(date(2026, 1, 1), date(2026, 1, 31), "RUB")
assert report["summary"]["revenue"] == 75
assert report["summary"]["cost_of_goods"] == 30
assert report["summary"]["profit"] == 38
assert report["summary"]["expenses"] == 7
for dimension in [
"products",
"channels",
"customers",
"product_channels",
"monthly",
]:
assert sum(row["profit"] for row in report[dimension]) == 38
assert next(r for r in report["customers"] if r["id"] == "unknown")["expenses"] == 2
assert report["quality"]["missing_customer_lines"] == 0
def test_unknown_cost_is_not_reported_as_profit_and_source_filter(facts):
source, _, line = facts
line("missing", cost="0")
report = sales_report(date(2026, 1, 1), date(2026, 1, 31), "RUB", source)
assert report["summary"]["profit"] is None
assert report["summary"]["missing_cost_lines"] == 1
other = SourceSystem.objects.create(code="empty", name="Empty")
assert (
sales_report(date(2026, 1, 1), date(2026, 1, 31), "RUB", other)["summary"][
"revenue"
]
== 0
)
def test_report_api_auth_allowlist_validation_and_no_store(api_client, user_factory):
url = reverse("api:v1:finance-payroll:analytics-report")
assert api_client.get(url).status_code == 401
api_client.force_authenticate(user=user_factory())
assert api_client.get(url).status_code == 403
staff = user_factory(staff=True)
api_client.force_authenticate(user=staff)
params = {"date_from": "2026-01-01", "date_to": "2026-01-31", "currency": "RUB"}
response = api_client.get(url, params)
assert response.status_code == 200
assert response.data["sales"]["summary"]["revenue"] == "0.0000"
assert "no-store" in response["Cache-Control"]
for changes in [
{"date_from": "bad"},
{"date_to": "2025-01-01"},
{"date_to": "2028-01-01"},
{"currency": "rub"},
{"section": "bad"},
{"source": "nope"},
]:
assert api_client.get(url, {**params, **changes}).status_code == 400
FinanceSettings.get_solo().management_users.add(user_factory(staff=True))
assert api_client.get(url, params).status_code == 403
def test_journal_validates_duplicates_and_void_preserves_history(
api_client, user_factory, facts
):
_, product, _ = facts
staff = user_factory(staff=True)
api_client.force_authenticate(user=staff)
url = reverse("api:v1:finance-payroll:analytics-entries")
payload = {
"kind": "production",
"reference": "output-1",
"date": "2026-01-10",
"quantity": "3",
"product": str(product.pk),
}
for change in [
{"product": None},
{"quantity": "-1"},
{"amount": "10"},
{"date": "2099-01-01"},
]:
assert (
api_client.post(url, {**payload, **change}, format="json").status_code
== 400
)
response = api_client.post(url, payload, format="json")
assert response.status_code == 201, response.data
assert api_client.post(url, payload, format="json").status_code == 400
start, end = date(2026, 1, 1), date(2026, 1, 31)
sales = sales_report(start, end, "RUB")
assert production_report(start, end, "RUB", sales)["totals"]["production"] == 3
void_url = reverse(
"api:v1:finance-payroll:analytics-entry-void", args=[response.data["id"]]
)
assert api_client.post(void_url).status_code == 200
assert api_client.post(void_url).status_code == 200
assert ReportingEntry.objects.count() == 1
assert production_report(start, end, "RUB", sales)["totals"]["production"] == 0
def test_payroll_uses_published_revision_month_sums_and_actual_tax(user_factory):
author, approver, employee = user_factory(), user_factory(), user_factory()
for index, (start, end, gross) in enumerate(
[
("2026-01-01", "2026-01-15", 40),
("2026-01-16", "2026-01-31", 60),
("2026-02-01", "2026-02-28", 120),
]
):
period = PayrollPeriod.objects.create(
code=f"a-{index}", date_from=start, date_to=end, currency="RUB"
)
run = PayrollRun.objects.create(
period=period,
revision=1,
status="published",
requested_by=author,
approved_by=approver,
approved_at=timezone.now(),
published_by=approver,
published_at=timezone.now(),
gross_total=gross,
deduction_total=0,
payable_total=gross,
)
PayrollStatement.objects.create(
run=run,
employee=employee,
currency="RUB",
gross_before_adjustments=gross,
adjustment_total=0,
gross_total=gross,
deduction_total=0,
net_pay=gross,
payment_total=0,
payable=gross,
)
# A new draft must not hide or double-count the published run.
PayrollRun.objects.create(
period=period,
revision=2,
requested_by=author,
gross_total=999,
deduction_total=0,
payable_total=999,
)
ReportingEntry.objects.create(
kind="tax",
reference="tax-1",
date=date(2026, 2, 10),
employee=employee,
amount=13,
created_by=author,
)
report = people_report(date(2026, 1, 1), date(2026, 2, 28), "RUB")
row = next(r for r in report["employees"] if r["id"] == employee.pk)
assert row["salary_total"] == 220
assert row["average_salary"] == 110
assert row["salary_growth_percent"] == 20
assert row["tax_paid"] == 13
assert row["attendance_days"] == 0
partial = people_report(date(2026, 1, 10), date(2026, 1, 31), "RUB")
assert (
next(r for r in partial["employees"] if r["id"] == employee.pk)["salary_total"]
== 60
)
def test_personnel_history_leave_overlap_missing_tax_and_future_hire(user_factory):
from employees.models import EmployeeAction
employee = user_factory()
future = user_factory()
EmployeeAction.objects.bulk_create(
[
EmployeeAction(
employee=employee, action="hired", date=moment("2025-01-01")
),
EmployeeAction(
employee=employee,
action="on_leave",
date=moment("2026-01-10"),
date_to=date(2026, 1, 20),
),
EmployeeAction(
employee=employee,
action="on_sick_leave",
date=moment("2026-01-15"),
date_to=date(2026, 1, 17),
),
EmployeeAction(employee=future, action="hired", date=moment("2026-03-01")),
]
)
report = people_report(date(2026, 1, 1), date(2026, 1, 31), "RUB")
row = next(r for r in report["employees"] if r["id"] == employee.pk)
assert row["vacation_days"] == 8
assert row["sick_days"] == 3
assert row["tenure_days"] == 395
assert all(r["id"] != future.pk for r in report["employees"])
assert report["summary"]["tax_paid"] is None
assert report["summary"]["average_salary"] is None
def test_customer_backfill_extracts_only_counterparty_and_is_idempotent(facts):
from importlib import import_module
from types import SimpleNamespace
from django.apps import apps
from django.db import connection
from commerce.models import SourceRecord
source, _, line = facts
record = SourceRecord.objects.create(
source=source,
entity_type="demand",
external_id="backfill",
payload={
"agent": {
"meta": {"href": "https://example.test/counterparty/client-id"},
"name": "Customer",
}
},
)
sale = line("backfill").sale
sale.source_record = record
sale.customer_external_id = ""
sale.customer_name = ""
sale.save()
restore = import_module(
"commerce.migrations.0013_restore_sale_customers"
).restore_customers
restore(apps, SimpleNamespace(connection=connection))
restore(apps, SimpleNamespace(connection=connection))
sale.refresh_from_db()
assert sale.customer_external_id == "client-id"
assert sale.customer_name == "Customer"
def test_production_api_excludes_resold_products(api_client, user_factory, facts):
_, product, line = facts
staff = user_factory(staff=True)
api_client.force_authenticate(user=staff)
line("manufactured")
resold = Product.objects.create(sku="RESOLD", name="Resold")
other = line("resold", revenue="999", cost="1")
other.product = resold
other.save()
ReportingEntry.objects.create(
kind="production",
reference="previous-year",
date=date(2025, 12, 1),
product=product,
quantity=10,
created_by=staff,
)
response = api_client.get(
reverse("api:v1:finance-payroll:analytics-report"),
{"section": "production", "date_from": "2026-01-01", "date_to": "2026-01-31"},
)
assert response.status_code == 200, response.data
assert response.data["sales"]["summary"]["revenue"] == "100.0000"
assert response.data["production"]["record_counts"]["production"] == 0