feat(formulas): CellFormulaRepository, invalid_formula → 422, схемы (AURORA-1896)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
725b994afa
commit
1d6d95db9f
@ -8,6 +8,7 @@ VALIDATION_PREFIXES = (
|
|||||||
"bad_booking_year:",
|
"bad_booking_year:",
|
||||||
"bad_booking_quarter:",
|
"bad_booking_quarter:",
|
||||||
"unsupported_scope:",
|
"unsupported_scope:",
|
||||||
|
"invalid_formula:",
|
||||||
)
|
)
|
||||||
|
|
||||||
PG_404_ERRORS = (
|
PG_404_ERRORS = (
|
||||||
|
|||||||
@ -394,10 +394,25 @@ class Svod2Facets(BaseModel):
|
|||||||
org_name: list[Svod2FacetValue]
|
org_name: list[Svod2FacetValue]
|
||||||
|
|
||||||
|
|
||||||
|
class UserShort(BaseModel):
|
||||||
|
id: int
|
||||||
|
full_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CellFormulaResponse(BaseModel):
|
||||||
|
line_id: int
|
||||||
|
column: str
|
||||||
|
expression: str
|
||||||
|
value: float
|
||||||
|
updated_by: UserShort
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class CellPatch(BaseModel):
|
class CellPatch(BaseModel):
|
||||||
line_id: int
|
line_id: int
|
||||||
column: str
|
column: str
|
||||||
value: Any = None
|
value: Any = None
|
||||||
|
formula: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class CellsPatch(BaseModel):
|
class CellsPatch(BaseModel):
|
||||||
|
|||||||
65
api/src/repository/cell_formula_repository.py
Normal file
65
api/src/repository/cell_formula_repository.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
def _map_row(row) -> dict:
|
||||||
|
line_id, column_key, expression, value, user_id, user_name, updated_at = tuple(row)
|
||||||
|
return {
|
||||||
|
"line_id": line_id,
|
||||||
|
"column": column_key,
|
||||||
|
"expression": expression,
|
||||||
|
"value": float(value),
|
||||||
|
"updated_by": {"id": user_id, "full_name": user_name},
|
||||||
|
"updated_at": updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CellFormulaRepository:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def set_formulas(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
budget_form_id: int | None,
|
||||||
|
report_id: int | None,
|
||||||
|
sheet: str,
|
||||||
|
changes: list[dict],
|
||||||
|
user_id: int,
|
||||||
|
) -> list[dict]:
|
||||||
|
result = await self.db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT line_id, column_key, expression, value, updated_by, updated_by_name, updated_at
|
||||||
|
FROM v3.set_cell_formulas(:budget_form_id, :report_id, :sheet, CAST(:changes AS JSONB), :user_id)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"budget_form_id": budget_form_id,
|
||||||
|
"report_id": report_id,
|
||||||
|
"sheet": sheet,
|
||||||
|
"changes": json.dumps(changes),
|
||||||
|
"user_id": user_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return [_map_row(r) for r in result.all()]
|
||||||
|
|
||||||
|
async def list_formulas(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
budget_form_id: int | None,
|
||||||
|
report_id: int | None,
|
||||||
|
sheet: str,
|
||||||
|
) -> list[dict]:
|
||||||
|
result = await self.db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT line_id, column_key, expression, value, updated_by, updated_by_name, updated_at
|
||||||
|
FROM v3.v_cell_formulas(:budget_form_id, :report_id, :sheet)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"budget_form_id": budget_form_id, "report_id": report_id, "sheet": sheet},
|
||||||
|
)
|
||||||
|
return [_map_row(r) for r in result.all()]
|
||||||
88
api/tests/unit/test_cell_formula_repository.py
Normal file
88
api/tests/unit/test_cell_formula_repository.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.repository.cell_formula_repository import CellFormulaRepository
|
||||||
|
|
||||||
|
DT = datetime(2026, 11, 11, 10, 30, tzinfo=timezone.utc)
|
||||||
|
ROW = (10, "plan.q1", "100+50", Decimal("150"), 7, "Иванов И.И.", DT)
|
||||||
|
EXPECTED = {
|
||||||
|
"line_id": 10,
|
||||||
|
"column": "plan.q1",
|
||||||
|
"expression": "100+50",
|
||||||
|
"value": 150.0,
|
||||||
|
"updated_by": {"id": 7, "full_name": "Иванов И.И."},
|
||||||
|
"updated_at": DT,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _result(rows):
|
||||||
|
result = MagicMock()
|
||||||
|
result.all.return_value = rows
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_db():
|
||||||
|
return AsyncMock(spec=AsyncSession)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def repository(mock_db):
|
||||||
|
return CellFormulaRepository(mock_db)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_formulas_calls_sql_with_json_changes(repository, mock_db):
|
||||||
|
mock_db.execute.return_value = _result([])
|
||||||
|
changes = [{"line_id": 10, "column": "plan.q1", "value": 150, "formula": "=100+50"}]
|
||||||
|
|
||||||
|
await repository.set_formulas(
|
||||||
|
budget_form_id=1, report_id=None, sheet="AHR", changes=changes, user_id=7
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_db.execute.assert_awaited_once()
|
||||||
|
stmt, params = mock_db.execute.await_args.args
|
||||||
|
assert "v3.set_cell_formulas" in str(stmt)
|
||||||
|
assert params["budget_form_id"] == 1
|
||||||
|
assert params["report_id"] is None
|
||||||
|
assert params["sheet"] == "AHR"
|
||||||
|
assert params["user_id"] == 7
|
||||||
|
assert isinstance(params["changes"], str)
|
||||||
|
assert json.loads(params["changes"])[0]["formula"] == "=100+50"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_set_formulas_maps_rows(repository, mock_db):
|
||||||
|
mock_db.execute.return_value = _result([ROW])
|
||||||
|
|
||||||
|
result = await repository.set_formulas(
|
||||||
|
budget_form_id=1, report_id=None, sheet="AHR", changes=[], user_id=7
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == [EXPECTED]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_formulas_calls_view(repository, mock_db):
|
||||||
|
mock_db.execute.return_value = _result([ROW])
|
||||||
|
|
||||||
|
result = await repository.list_formulas(budget_form_id=None, report_id=5, sheet="AHR")
|
||||||
|
|
||||||
|
stmt, params = mock_db.execute.await_args.args
|
||||||
|
assert "v3.v_cell_formulas" in str(stmt)
|
||||||
|
assert params == {"budget_form_id": None, "report_id": 5, "sheet": "AHR"}
|
||||||
|
assert result == [EXPECTED]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mapping_handles_missing_user_name(repository, mock_db):
|
||||||
|
mock_db.execute.return_value = _result([(1, "q", "1+1", Decimal("2"), 3, None, DT)])
|
||||||
|
|
||||||
|
result = await repository.list_formulas(budget_form_id=1, report_id=None, sheet="CAP")
|
||||||
|
|
||||||
|
assert result[0]["updated_by"] == {"id": 3, "full_name": None}
|
||||||
@ -157,3 +157,15 @@ def test_map_http_exception_not_found_maps_code_from_status():
|
|||||||
assert code == status.HTTP_404_NOT_FOUND
|
assert code == status.HTTP_404_NOT_FOUND
|
||||||
assert message == "not found"
|
assert message == "not found"
|
||||||
assert field == ""
|
assert field == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_sqlalchemy_error_invalid_formula_prefix():
|
||||||
|
exc = SQLAlchemyError()
|
||||||
|
exc.orig = Exception("invalid_formula: expression")
|
||||||
|
|
||||||
|
status_code, code, message, field = map_sqlalchemy_error(exc)
|
||||||
|
|
||||||
|
assert status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||||
|
assert code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||||
|
assert message == "invalid_formula: expression"
|
||||||
|
assert field == "expression"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user