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 commerce.integrations.moysklad import MoySkladClient, MoySkladError from commerce.models import ( ExternalProject, ProjectPayment, Sale, SaleLine, Cost, SourceSystem, ) from commerce.services.moysklad_projects import ( normalize_project, normalize_project_payment, sync_project_payments, sync_project_catalog, restore_sale_projects, ) from commerce.services.ingestion import ingest_source_record from finance.analytics.projects import projects_report from finance.analytics.sales import sales_report from finance.analytics.models import ReportingEntry 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() ReportingEntry.objects.create( kind="overhead", project=project, date=START, reference="overhead", amount=40, created_by=user, ) ReportingEntry.objects.create( kind="cash_out", project=project, date=START, reference="manual", amount=10, created_by=user, ) 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 scoped_sales = sales_report(START, END, "RUB", project=project) assert ( scoped_sales["summary"]["profit"] == 250 ) # cash payments are not expensed twice scoped = projects_report(START, END, "RUB", scoped_sales, project=project) assert scoped["summary"]["paid"] == 100 assert scoped["summary"]["manual_paid"] == 10 assert scoped["summary"]["overhead"] == 40 assert ( sales_report(START, END, "RUB", project="unassigned")["summary"]["expenses"] == 0 ) 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 api_client.force_authenticate(user=user_factory(staff=True)) params = { "date_from": str(START), "date_to": str(END), "project": p.project_id, "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"] 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 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": p.project_id, "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_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()