328 lines
11 KiB
Python
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
|