301 lines
10 KiB
Python
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()
|