Corp/backend/tests/finance/test_projects.py
2026-09-07 19:43:48 +03:00

595 lines
20 KiB
Python
Raw 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 datetime import date
from decimal import Decimal
from unittest.mock import Mock
import httpx
import pytest
from django.urls import reverse
from django.utils import timezone
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
from commerce.integrations.moysklad import MoySkladClient, MoySkladError
from commerce.models import (
Cost,
ExternalProject,
ProjectPayment,
Sale,
SaleLine,
SourceSystem,
)
from commerce.services.ingestion import ingest_source_record
from commerce.services.moysklad_projects import (
normalize_project,
normalize_project_payment,
restore_sale_projects,
sync_project_catalog,
sync_project_payments,
)
from finance.analytics.models import ReportingEntry
from finance.analytics.projects import local_project_scope, projects_report
from finance.analytics.sales import sales_report
from finance.enums import ApprovalStatus, PayrollPeriodStatus, PayrollRunStatus
from finance.models import EmployeePayRate, PayrollPeriod, PayrollRun, PayrollStatement
from projects.models import (
DailyAllocation,
Project,
ProjectExternalBinding,
TimeAllocation,
)
pytestmark = pytest.mark.django_db
START, END = date(2026, 1, 1), date(2026, 1, 31)
@pytest.fixture
def source():
return SourceSystem.objects.create(code="projects-test", name="МойСклад")
def payment(source, ref="payment1", **kwargs):
row = {
"id": ref,
"moment": "2026-01-10 12:00:00",
"sum": 10000,
"applicable": True,
"name": "Оплата",
"project": {"id": "project1", "name": "Первый"},
"expenseItem": {"name": "Материалы"},
}
row.update(kwargs)
return normalize_project_payment(source, row, "paymentout", timezone.now())
def test_project_payment_reimport_reassignment_and_cancel(source):
p = payment(source)
assert p.amount == Decimal("100")
assert payment(source).pk == p.pk
assert ProjectPayment.objects.count() == 1
payment(source, project={"id": "project2", "name": "Второй"}, sum=15000)
p.refresh_from_db()
assert p.project.external_id == "project2" and p.amount == 150
payment(source, project=None, applicable=False)
p.refresh_from_db()
assert p.project is None and not p.applicable
assert p.source_record.payload["applicable"] is False
def test_full_scan_marks_missing_but_failed_scan_preserves_history(source):
old = payment(source)
client = Mock()
client.iter_project_data.side_effect = MoySkladError("offline")
with pytest.raises(MoySkladError):
sync_project_payments(source, client)
old.refresh_from_db()
assert not old.missing_from_source
client.iter_project_data.side_effect = lambda *a, **kw: iter([])
assert sync_project_payments(source, client) == 0
old.refresh_from_db()
assert old.missing_from_source
payment(source)
old.refresh_from_db()
assert not old.missing_from_source
def test_catalog_preserves_archive_and_source_identity(source):
client = Mock()
client.iter_project_data.side_effect = lambda kind, archived=False: iter(
[
{
"id": "archived" if archived else "active",
"name": "Одинаковое имя",
"archived": archived,
}
]
)
assert sync_project_catalog(source, client) == 2
assert sync_project_catalog(source, client) == 2
assert ExternalProject.objects.count() == 2
other = SourceSystem.objects.create(code="other", name="Other")
normalize_project(other, {"id": "active", "name": "Other account"})
assert ExternalProject.objects.count() == 3
assert ExternalProject.objects.get(source=source, external_id="archived").archived
def test_project_report_reconciles_currency_dates_unassigned_and_manual(
source, user_factory
):
paid = payment(source)
project = paid.project
payment(source, ref="unassigned", project=None, sum=5000)
payment(source, ref="cancelled", applicable=False)
payment(source, ref="outside", moment="2026-02-01 12:00:00")
payment(source, ref="foreign", rate={"currency": {"isoCode": "USD"}})
sale = Sale.objects.create(
source=source, project=project, external_id="sale", occurred_at=paid.occurred_at
)
line = SaleLine.objects.create(
sale=sale, external_id="1", gross_amount=500, cost_amount=200, quantity=1
)
Cost.objects.create(
source=source,
sale_line=line,
cost_type="delivery",
amount=20,
occurred_at=paid.occurred_at,
)
Cost.objects.create(
source=source,
project=project,
cost_type="other",
amount=30,
occurred_at=paid.occurred_at,
)
user = user_factory()
local = Project.objects.create(
name="Локальный первый", manager=user, created_by=user, status="active"
)
ProjectExternalBinding.objects.create(
project=local,
source=source,
external_id=project.external_id,
external_name=project.name,
)
ReportingEntry.objects.create(
kind="overhead",
project=local,
external_project=project,
date=START,
reference="overhead",
amount=40,
created_by=user,
)
ReportingEntry.objects.create(
kind="cash_out",
project=local,
external_project=project,
date=START,
reference="manual",
amount=10,
created_by=user,
)
day = DailyAllocation.objects.create(
employee=user, date=START, status="confirmed", hours=Decimal("8")
)
TimeAllocation.objects.create(day=day, project=local, percent=Decimal("50"))
sales = sales_report(START, END, "RUB")
report = projects_report(START, END, "RUB", sales)
assert report["summary"]["paid"] == 150
assert report["summary"]["unassigned_paid"] == 50
assert (
sum(r["paid"] for r in report["rows"])
== sum(r["paid"] for r in report["categories"])
== 150
)
assert sum(r["paid"] for r in report["monthly"]) == 150
assert report["summary"]["labor_hours"] == 4
assert report["summary"]["people_count"] == 1
local_row = next(
row for row in report["rows"] if row["id"] == f"project:{local.pk}"
)
assert local_row["name"] == "Локальный первый"
assert local_row["external_project_count"] == 1
scope = local_project_scope(project=local)
scoped_sales = sales_report(START, END, "RUB", project_scope=scope)
assert (
scoped_sales["summary"]["profit"] == 250
) # cash payments are not expensed twice
scoped = projects_report(
START, END, "RUB", scoped_sales, project=local, scope=scope
)
assert scoped["summary"]["paid"] == 100
assert scoped["summary"]["manual_paid"] == 10
assert scoped["summary"]["overhead"] == 40
assert (
sales_report(
START,
END,
"RUB",
project_scope=local_project_scope(project="unassigned"),
)["summary"]["expenses"]
== 0
)
def test_project_report_prices_confirmed_hours_from_approved_finance_rate(
user_factory,
):
creator = user_factory(email="rate.creator@example.test")
approver = user_factory(email="rate.approver@example.test")
employee = user_factory(email="project.worker@example.test")
project = Project.objects.create(
name="Проект со стоимостью труда",
manager=creator,
created_by=creator,
status="active",
)
PayrollPeriod.objects.create(
code="project-labor-2026-01",
name="Январь 2026",
date_from=START,
date_to=END,
currency="RUB",
created_by=creator,
)
EmployeePayRate.objects.create(
employee=employee,
amount=Decimal("1600"),
currency="RUB",
effective_from=START,
status=ApprovalStatus.APPROVED,
created_by=creator,
approved_by=approver,
approved_at=timezone.now(),
)
run = AttendanceAnalysisRun.objects.create(
employee=employee,
period_start=START,
period_end=date(2026, 1, 2),
status=AttendanceAnalysisRun.STATUS_SUCCESS,
)
for day in [START, date(2026, 1, 2)]:
AttendanceRecord.objects.create(
analysis_run=run,
employee=employee,
date=day,
work_hours=8,
expected_hours=8,
effective_is_workday=True,
)
allocation_day = DailyAllocation.objects.create(
employee=employee,
date=START,
status="confirmed",
hours=Decimal("8"),
)
TimeAllocation.objects.create(
day=allocation_day,
project=project,
percent=Decimal("50"),
)
report = projects_report(
START,
END,
"RUB",
sales_report(START, END, "RUB"),
)
row = next(item for item in report["rows"] if item["id"] == f"project:{project.pk}")
assert row["labor_hours"] == Decimal("4")
assert row["labor_cost"] == Decimal("400")
assert row["preliminary_labor_cost"] == Decimal("400")
assert row["exact_labor_cost"] == 0
assert row["labor_cost_status"] == "preliminary"
assert row["labor_hour_cost"] == Decimal("100")
assert row["unpriced_labor_hours"] == 0
assert row["labor_cost_complete"] is True
assert row["project_result"] == Decimal("-400")
assert report["summary"]["labor_cost"] == Decimal("400")
unpriced = projects_report(
START,
END,
"USD",
sales_report(START, END, "USD"),
)
unpriced_row = next(
item for item in unpriced["rows"] if item["id"] == f"project:{project.pk}"
)
assert unpriced_row["labor_cost"] == 0
assert unpriced_row["unpriced_labor_hours"] == Decimal("4")
assert unpriced_row["project_result"] is None
def test_project_report_uses_final_payroll_with_bonuses_only_after_period_close(
user_factory,
):
creator = user_factory(email="closed.creator@example.test")
approver = user_factory(email="closed.approver@example.test")
employee = user_factory(email="closed.worker@example.test")
project = Project.objects.create(
name="Проект с закрытой зарплатой",
manager=creator,
created_by=creator,
status="active",
)
period = PayrollPeriod.objects.create(
code="project-labor-closed-2026-01",
name="Закрытый январь 2026",
date_from=START,
date_to=END,
currency="RUB",
status=PayrollPeriodStatus.CLOSED,
created_by=creator,
)
payroll_run = PayrollRun.objects.create(
period=period,
revision=1,
status=PayrollRunStatus.PUBLISHED,
requested_by=creator,
approved_by=approver,
approved_at=timezone.now(),
published_by=approver,
published_at=timezone.now(),
gross_total=Decimal("2400"),
payable_total=Decimal("2000"),
)
period.current_run = payroll_run
period.save(update_fields=["current_run"])
PayrollStatement.objects.create(
run=payroll_run,
employee=employee,
currency="RUB",
gross_before_adjustments=Decimal("1600"),
adjustment_total=Decimal("800"),
gross_total=Decimal("2400"),
deduction_total=Decimal("400"),
net_pay=Decimal("2000"),
payment_total=0,
payable=Decimal("2000"),
)
# This rate is the preliminary base. The closed period must instead use
# the final 2,400 gross amount, including the 800 bonus/adjustment.
EmployeePayRate.objects.create(
employee=employee,
amount=Decimal("1600"),
currency="RUB",
effective_from=START,
status=ApprovalStatus.APPROVED,
created_by=creator,
approved_by=approver,
approved_at=timezone.now(),
)
attendance_run = AttendanceAnalysisRun.objects.create(
employee=employee,
period_start=START,
period_end=date(2026, 1, 2),
status=AttendanceAnalysisRun.STATUS_SUCCESS,
)
for day in [START, date(2026, 1, 2)]:
AttendanceRecord.objects.create(
analysis_run=attendance_run,
employee=employee,
date=day,
work_hours=8,
expected_hours=8,
effective_is_workday=True,
)
allocation_day = DailyAllocation.objects.create(
employee=employee,
date=START,
status="confirmed",
hours=Decimal("8"),
)
TimeAllocation.objects.create(
day=allocation_day,
project=project,
percent=Decimal("50"),
)
report = projects_report(START, END, "RUB", sales_report(START, END, "RUB"))
row = next(item for item in report["rows"] if item["id"] == f"project:{project.pk}")
assert row["labor_hours"] == Decimal("4")
assert row["labor_cost"] == Decimal("600")
assert row["exact_labor_cost"] == Decimal("600")
assert row["preliminary_labor_cost"] == 0
assert row["labor_cost_status"] == "exact"
assert row["project_result"] == Decimal("-600")
def test_projects_api_permissions_filter_and_journal(api_client, user_factory, source):
p = payment(source)
url = reverse("api:v1:finance-payroll:analytics-report")
payments_url = reverse("api:v1:finance-payroll:analytics-project-payments")
assert api_client.get(payments_url).status_code == 401
api_client.force_authenticate(user=user_factory())
assert api_client.get(payments_url).status_code == 403
staff = user_factory(staff=True)
local = Project.objects.create(
name="Портальный проект", manager=staff, created_by=staff, status="active"
)
ProjectExternalBinding.objects.create(
project=local,
source=source,
external_id=p.project.external_id,
external_name=p.project.name,
)
api_client.force_authenticate(user=staff)
params = {
"date_from": str(START),
"date_to": str(END),
"project": local.pk,
"section": "projects",
}
response = api_client.get(url, params)
assert response.status_code == 200
assert response.data["projects"]["summary"]["paid"] == "100.0000"
assert "no-store" in response["Cache-Control"]
options = api_client.get(reverse("api:v1:finance-payroll:analytics-options"))
assert options.data["projects"] == [
{
"id": local.pk,
"name": "Портальный проект",
"source_ids": [source.pk],
"archived": False,
}
]
assert api_client.get(url, {**params, "project": "bad"}).status_code == 400
other = SourceSystem.objects.create(code="other", name="Other")
assert api_client.get(url, {**params, "source": other.pk}).status_code == 400
response = api_client.get(payments_url, params)
assert response.status_code == 200 and response.data["count"] == 1
assert response.data["results"][0]["project_name"] == "Портальный проект"
assert response.data["results"][0]["external_project_name"] == "Первый"
assert api_client.post(payments_url, {}).status_code == 405
entry_url = reverse("api:v1:finance-payroll:analytics-entries")
assert (
api_client.post(
entry_url,
{
"kind": "overhead",
"project": local.pk,
"date": "2026-01-01",
"reference": "API",
"amount": "5.00",
},
format="json",
).status_code
== 201
)
assert api_client.get(entry_url, params).data["count"] == 1
assert (
api_client.get(entry_url, {**params, "project": "unassigned"}).data["count"]
== 0
)
def test_local_project_combines_bound_sources_and_keeps_unbound_separate(
source, user_factory
):
manager = user_factory()
local = Project.objects.create(
name="Общий проект", manager=manager, created_by=manager, status="active"
)
first = payment(source, ref="first-bound").project
second_source = SourceSystem.objects.create(code="second-source", name="Второй")
second = payment(
second_source,
ref="second-bound",
project={"id": "second-project", "name": "Внешний второй"},
sum=20000,
).project
payment(
second_source,
ref="unbound-payment",
project={"id": "unbound", "name": "Не связан"},
sum=3000,
)
for external in [first, second]:
ProjectExternalBinding.objects.create(
project=local,
source=external.source,
external_id=external.external_id,
external_name=external.name,
)
sales = sales_report(START, END, "RUB")
report = projects_report(START, END, "RUB", sales)
local_row = next(row for row in report["rows"] if row["name"] == "Общий проект")
assert local_row["paid"] == 300
assert local_row["external_project_count"] == 2
assert report["summary"]["unassigned_paid"] == 30
scope = local_project_scope(second_source, local)
scoped_sales = sales_report(START, END, "RUB", second_source, project_scope=scope)
scoped = projects_report(
START,
END,
"RUB",
scoped_sales,
second_source,
local,
scope,
)
assert scoped["summary"]["paid"] == 200
def test_saved_sale_project_backfill_is_idempotent(source):
record = ingest_source_record(
source=source,
entity_type="demand",
external_id="old",
payload={
"project": {
"meta": {
"href": "https://api.moysklad.ru/api/remap/1.2/entity/project/project1"
}
}
},
).record
sale = Sale.objects.create(
source=source,
source_record=record,
external_id="old",
occurred_at=timezone.now(),
)
assert restore_sale_projects(source) == 1
assert restore_sale_projects(source) == 0
sale.refresh_from_db()
assert sale.project.external_id == "project1"
def test_project_client_pagination_and_archive_filters():
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
client._get = Mock(side_effect=[{"rows": [{"id": "1"}]}, {"rows": []}])
assert list(client.iter_project_data("project", archived=True, limit=1)) == [
{"id": "1"}
]
assert client._get.call_args_list[0].kwargs["params"]["filter"] == "archived=true"
assert client._get.call_args_list[1].kwargs["params"]["offset"] == 1
client._get = Mock(return_value={"rows": None})
with pytest.raises(MoySkladError):
list(client.iter_project_data("paymentout"))
client.close()
def test_transient_http_failure_retries_without_replaying_writes(monkeypatch):
monkeypatch.setattr("commerce.integrations.moysklad.time.sleep", lambda _: None)
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
client.client.close()
responses = iter([502, 429, 200])
client.client = httpx.Client(
base_url=client.base_url,
transport=httpx.MockTransport(
lambda request: httpx.Response(
next(responses), json={"rows": []}, request=request
)
),
)
assert list(client.iter_project_data("project")) == []
client.close()
def test_payment_references_are_cached_and_paths_stay_on_api():
client = MoySkladClient(auth_type="token", credentials={"token": "test"})
external_id = "11111111-1111-1111-1111-111111111111"
reference = {
"meta": {
"type": "expenseitem",
"href": f"https://api.moysklad.ru/api/remap/1.2/entity/expenseitem/{external_id}",
}
}
client._get = Mock(return_value={"id": external_id, "name": "Доставка"})
for _ in range(3):
assert (
client._payment_references({"expenseItem": reference})["expenseItem"][
"name"
]
== "Доставка"
)
client._get.assert_called_once_with(f"/entity/expenseitem/{external_id}")
with pytest.raises(MoySkladError):
client._payment_reference(
{"meta": {"type": "../private", "href": "https://invalid/secret"}}
)
client.close()