595 lines
20 KiB
Python
595 lines
20 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 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()
|