Corp/backend/tests/finance/test_projects.py

301 lines
10 KiB
Python

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