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()