diff --git a/api/src/api/v1/forms.py b/api/src/api/v1/forms.py index 23b0962..9e120d4 100644 --- a/api/src/api/v1/forms.py +++ b/api/src/api/v1/forms.py @@ -10,6 +10,7 @@ from src.services.org_unit_service import OrgUnitService from src.db.models.form_type import FormTypeEnum from src.db.models.role import UserRoleEnum from src.services.sheet_service import SheetService +from src.api.v1.json_response import list_json_response from src.domain.schemas import AddLineSchema, BaseListResponse, BaseSingleResponse, ResponseBase, BudgetFormResponse, CellPatch, CellsPatch, DirectionSchemaEnum, FormCreateSchema, SheetFormTypeResponse, SheetResponse from src.db.models.app_user import AppUser from src.services.budget_form_service import BudgetFormService @@ -147,16 +148,15 @@ async def get_form_sheets( ) -@router.get("/{form_id}/sheet/{sheet}") +@router.get("/{form_id}/sheet/{sheet}", response_model=BaseListResponse[SheetResponse]) async def get_sheet( form_id: int, sheet: str, - response: Response, direction: Optional[DirectionSchemaEnum] = None, sections: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: AppUser = Depends(get_current_active_user_with_set_db), -) -> BaseListResponse[SheetResponse]: +) -> Response: sections = _parse_sections_csv(sections) t0 = time.perf_counter() bf_service = BudgetFormService(db) @@ -174,14 +174,13 @@ async def get_sheet( if sections and (rem_sects := set(sections) - set(form.form_type.section_list)): raise HTTPException(400, f"Недопустимые sections: {', '.join(sorted(rem_sects))}") - result = await sheet_service.get( + rows = await sheet_service.get_json( form_id=form_id, sheet=sheet, direction=direction.value if direction else None, sections=sections, ) db_ms = (time.perf_counter() - t0) * 1000 - response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}" extra={ "type": "db_time", "time_ms": f"{db_ms:.2f}", @@ -196,10 +195,7 @@ async def get_sheet( f"db_time: {extra}", extra=extra, ) - return BaseListResponse( - count=len(result), - result=_rows_to_sheet_response(result), - ) + return list_json_response(rows, headers={"X-DB-Time-Ms": f"{db_ms:.2f}"}) @router.patch("/{form_id}/sheet/{sheet}/cell") diff --git a/api/src/api/v1/json_response.py b/api/src/api/v1/json_response.py new file mode 100644 index 0000000..9155765 --- /dev/null +++ b/api/src/api/v1/json_response.py @@ -0,0 +1,14 @@ +"""Ответ тяжёлых GET из готовых JSON-строк Postgres (AURORA-1848, design D3).""" +from fastapi import Response + + +def list_json_response(rows: list[str], headers: dict[str, str] | None = None) -> Response: + """Обёртка как у BaseListResponse при успехе; rows — уже сериализованные строки ответа.""" + body = b"".join(( + b'{"success":true,"message":null,"result":[', + ",".join(rows).encode(), + b'],"count":', + str(len(rows)).encode(), + b"}", + )) + return Response(content=body, media_type="application/json", headers=headers) diff --git a/api/src/api/v1/projects.py b/api/src/api/v1/projects.py index e929082..288a56f 100644 --- a/api/src/api/v1/projects.py +++ b/api/src/api/v1/projects.py @@ -9,6 +9,7 @@ from src.api.v1.deps import ( get_current_active_user_with_set_db, require_executor, ) +from src.api.v1.json_response import list_json_response from src.db.models.app_user import AppUser from src.db.session import get_db from src.domain.schemas import ( @@ -114,18 +115,17 @@ async def get_projects_with_reports( return BaseListResponse(result=projects, count=count) -async def _get_projects_summary_response( +def _projects_summary_response( *, year: int, - response: Response, branch_id: Optional[str], project_type: Optional[str], handler_name: str, - rows, + rows: list[str], + t0: float, current_user: AppUser, -) -> BaseListResponse[SheetResponse]: - db_ms = (time.perf_counter() - rows["t0"]) * 1000 - response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}" +) -> Response: + db_ms = (time.perf_counter() - t0) * 1000 extra = { "type": "db_time", "time_ms": f"{db_ms:.2f}", @@ -136,92 +136,89 @@ async def _get_projects_summary_response( "user": current_user.id, } logger.info(f"db_time: {extra}", extra=extra) - return BaseListResponse(count=len(rows["data"]), result=_rows_to_payload(rows["data"])) + return list_json_response(rows, headers={"X-DB-Time-Ms": f"{db_ms:.2f}"}) -@router.get("/projects/plan-summary") +@router.get("/projects/plan-summary", response_model=BaseListResponse[SheetResponse]) async def get_projects_plan_summary( year: int, - response: Response, branch_id: Optional[str] = None, project_type: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: AppUser = Depends(get_current_active_user_with_set_db), -) -> BaseListResponse[SheetResponse]: +) -> Response: branch_ids = _parse_org_units_csv(branch_id) project_service = ProjectService(db) t0 = time.perf_counter() - rows = await project_service.get_plan_summary_rows( + rows = await project_service.get_plan_summary_json( year=year, branch_ids=branch_ids, user=current_user, project_type=project_type, ) - return await _get_projects_summary_response( + return _projects_summary_response( year=year, - response=response, branch_id=branch_id, project_type=project_type, handler_name="get_projects_plan_summary", - rows={"t0": t0, "data": rows}, + rows=rows, + t0=t0, current_user=current_user, ) -@router.get("/projects/fact-summary") +@router.get("/projects/fact-summary", response_model=BaseListResponse[SheetResponse]) async def get_projects_fact_summary( year: int, - response: Response, branch_id: Optional[str] = None, project_type: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: AppUser = Depends(get_current_active_user_with_set_db), -) -> BaseListResponse[SheetResponse]: +) -> Response: branch_ids = _parse_org_units_csv(branch_id) project_service = ProjectService(db) t0 = time.perf_counter() - rows = await project_service.get_fact_summary_rows( + rows = await project_service.get_fact_summary_json( year=year, branch_ids=branch_ids, user=current_user, project_type=project_type, ) - return await _get_projects_summary_response( + return _projects_summary_response( year=year, - response=response, branch_id=branch_id, project_type=project_type, handler_name="get_projects_fact_summary", - rows={"t0": t0, "data": rows}, + rows=rows, + t0=t0, current_user=current_user, ) -@router.get("/projects/detail-summary") +@router.get("/projects/detail-summary", response_model=BaseListResponse[SheetResponse]) async def get_projects_detail_summary( year: int, - response: Response, branch_id: Optional[str] = None, project_type: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: AppUser = Depends(get_current_active_user_with_set_db), -) -> BaseListResponse[SheetResponse]: +) -> Response: branch_ids = _parse_org_units_csv(branch_id) project_service = ProjectService(db) t0 = time.perf_counter() - rows = await project_service.get_detail_summary_rows( + rows = await project_service.get_detail_summary_json( year=year, branch_ids=branch_ids, user=current_user, project_type=project_type, ) - return await _get_projects_summary_response( + return _projects_summary_response( year=year, - response=response, branch_id=branch_id, project_type=project_type, handler_name="get_projects_detail_summary", - rows={"t0": t0, "data": rows}, + rows=rows, + t0=t0, current_user=current_user, ) @@ -239,19 +236,18 @@ async def get_project( return BaseSingleResponse(result=project) -@router.get("/projects/{project_id}/report/{year}/{report_type}") +@router.get("/projects/{project_id}/report/{year}/{report_type}", response_model=BaseListResponse[SheetResponse]) async def get_project_report( project_id: int, year: int, report_type: str, - response: Response, sections: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: AppUser = Depends(get_current_active_user_with_set_db), -) -> BaseListResponse[SheetResponse]: +) -> Response: project_service = ProjectService(db) t0 = time.perf_counter() - rows = await project_service.get_report_rows( + rows = await project_service.get_report_json( project_id=project_id, year=year, report_type=report_type, @@ -259,7 +255,6 @@ async def get_project_report( user=current_user, ) db_ms = (time.perf_counter() - t0) * 1000 - response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}" extra={ "type": "db_time", "time_ms": f"{db_ms:.2f}", @@ -274,28 +269,26 @@ async def get_project_report( f"db_time: {extra}", extra=extra, ) - return BaseListResponse(count=len(rows), result=_rows_to_payload(rows)) + return list_json_response(rows, headers={"X-DB-Time-Ms": f"{db_ms:.2f}"}) -@router.get("/rf-rollup/{branch_id}/{year}") +@router.get("/rf-rollup/{branch_id}/{year}", response_model=BaseListResponse[SheetResponse]) async def get_rf_rollup( branch_id: int, year: int, - response: Response, sections: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: AppUser = Depends(get_current_active_user_with_set_db), -) -> BaseListResponse[SheetResponse]: +) -> Response: project_service = ProjectService(db) t0 = time.perf_counter() - rows = await project_service.get_rf_rollup_rows( + rows = await project_service.get_rf_rollup_json( branch_id=branch_id, year=year, sections=_parse_sections(sections), user=current_user, ) db_ms = (time.perf_counter() - t0) * 1000 - response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}" extra={ "type": "db_time", "time_ms": f"{db_ms:.2f}", @@ -309,24 +302,22 @@ async def get_rf_rollup( f"db_time: {extra}", extra=extra, ) - return BaseListResponse(count=len(rows), result=_rows_to_payload(rows)) + return list_json_response(rows, headers={"X-DB-Time-Ms": f"{db_ms:.2f}"}) -@router.get("/projects/{project_id}/summary") +@router.get("/projects/{project_id}/summary", response_model=BaseListResponse[SheetResponse]) async def get_project_summary( project_id: int, - response: Response, db: AsyncSession = Depends(get_db), current_user: AppUser = Depends(get_current_active_user_with_set_db), -) -> BaseListResponse[SheetResponse]: +) -> Response: project_service = ProjectService(db) t0 = time.perf_counter() - rows = await project_service.get_project_summary_rows( + rows = await project_service.get_project_summary_json( project_id=project_id, user=current_user, ) db_ms = (time.perf_counter() - t0) * 1000 - response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}" extra = { "type": "db_time", "time_ms": f"{db_ms:.2f}", @@ -335,7 +326,7 @@ async def get_project_summary( "user": current_user.id, } logger.info(f"db_time: {extra}", extra=extra) - return BaseListResponse(count=len(rows), result=_rows_to_payload(rows)) + return list_json_response(rows, headers={"X-DB-Time-Ms": f"{db_ms:.2f}"}) @router.patch("/projects/{project_id}/report/{year}/{report_type}/cell") diff --git a/api/src/api/v1/summary.py b/api/src/api/v1/summary.py index 84b2f8d..e3a2e9f 100644 --- a/api/src/api/v1/summary.py +++ b/api/src/api/v1/summary.py @@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.api.v1.deps import get_current_user from src.db.models.app_user import AppUser +from src.api.v1.json_response import list_json_response from src.db.session import get_db from src.domain.schemas import BaseListResponse, SheetResponse from src.services.summary_service import SummaryService @@ -26,17 +27,6 @@ VALID_SUMMARY_SHEETS = { VALID_SUMMARY2_SHEETS = {"GO", "RF"} -def _rows_to_sheet_response(result: list[tuple]) -> list[SheetResponse]: - return [ - SheetResponse( - row_type=el[0], - depth=el[1], - sort_order=el[2], - data=el[3], - ) for el in result - ] - - def _parse_org_ids(org_ids: Optional[str]) -> Optional[list[int]]: if not org_ids: return None @@ -47,15 +37,14 @@ def _parse_org_ids(org_ids: Optional[str]) -> Optional[list[int]]: return parsed or None -@router.get("/1/{sheet}/{year}") +@router.get("/1/{sheet}/{year}", response_model=BaseListResponse[SheetResponse]) async def get_summary( sheet: str, year: int, - response: Response, org_ids: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: AppUser = Depends(get_current_user), -) -> BaseListResponse[SheetResponse]: +) -> Response: if sheet not in VALID_SUMMARY_SHEETS: raise HTTPException( 404, @@ -64,14 +53,13 @@ async def get_summary( t0 = time.perf_counter() summary_service = SummaryService(db) - rows = await summary_service.get_svod1_rows( + rows = await summary_service.get_svod1_json( year=year, sheet=sheet, user=current_user, org_ids=_parse_org_ids(org_ids), ) db_ms = (time.perf_counter() - t0) * 1000 - response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}" extra = { "type": "db_time", @@ -84,17 +72,13 @@ async def get_summary( } logger.info(f"db_time: {extra}", extra=extra) - return BaseListResponse( - count=len(rows), - result=_rows_to_sheet_response(rows), - ) + return list_json_response(rows, headers={"X-DB-Time-Ms": f"{db_ms:.2f}"}) -@router.get("/2/{sheet}/{year}") +@router.get("/2/{sheet}/{year}", response_model=BaseListResponse[SheetResponse]) async def get_summary2( sheet: str, year: int, - response: Response, org_ids: Optional[str] = None, offset: Optional[int] = Query(default=None, ge=0), limit: Optional[int] = Query(default=None, ge=1), @@ -102,7 +86,7 @@ async def get_summary2( sort_direction: Literal["asc", "desc"] = "asc", db: AsyncSession = Depends(get_db), current_user: AppUser = Depends(get_current_user), -) -> BaseListResponse[SheetResponse]: +) -> Response: sheet = sheet.upper() if sheet not in VALID_SUMMARY2_SHEETS: raise HTTPException( @@ -112,7 +96,7 @@ async def get_summary2( t0 = time.perf_counter() summary_service = SummaryService(db) - rows = await summary_service.get_svod2_rows( + rows = await summary_service.get_svod2_json( year=year, sheet=sheet, user=current_user, @@ -123,7 +107,6 @@ async def get_summary2( sort_direction=sort_direction, ) db_ms = (time.perf_counter() - t0) * 1000 - response.headers["X-DB-Time-Ms"] = f"{db_ms:.2f}" extra = { "type": "db_time", @@ -140,7 +123,4 @@ async def get_summary2( } logger.info(f"db_time: {extra}", extra=extra) - return BaseListResponse( - count=len(rows), - result=_rows_to_sheet_response(rows), - ) + return list_json_response(rows, headers={"X-DB-Time-Ms": f"{db_ms:.2f}"}) diff --git a/api/src/repository/project_repository.py b/api/src/repository/project_repository.py index 3a95d64..0f6baab 100644 --- a/api/src/repository/project_repository.py +++ b/api/src/repository/project_repository.py @@ -10,6 +10,7 @@ from src.db.models.rf_project_report_line import RfProjectReportLine from src.db.models.rf_project_year import RfProjectYear from src.db.models.smeta import Smeta from src.domain.schemas import placement_type_from_db +from src.repository.row_json import ROW_COLUMNS, fetch_row_texts, row_json_select class ProjectRepository: @@ -355,22 +356,22 @@ class ProjectRepository: query = query.where(Project.org_unit_id.in_(org_unit_ids)) return (await self.db.execute(query)).scalar_one_or_none() + @staticmethod + def _report_source() -> str: + return ( + "FROM v3.v_form3_report_jsonb(" + "CAST(:report_id AS INT), " + "CAST(:sections AS TEXT[]), " + "CAST(:user_id AS INT))" + ) + async def get_report_rows( self, report_id: int, sections: list[str] | None = None, user_id: int | None = None, ) -> list[tuple]: - query = text( - """ - SELECT row_type, depth, sort_order, data - FROM v3.v_form3_report_jsonb( - CAST(:report_id AS INT), - CAST(:sections AS TEXT[]), - CAST(:user_id AS INT) - ) - """ - ) + query = text(f"SELECT {ROW_COLUMNS} {self._report_source()}") return ( await self.db.execute( query, @@ -378,18 +379,32 @@ class ProjectRepository: ) ).all() + async def get_report_json( + self, + report_id: int, + sections: list[str] | None = None, + user_id: int | None = None, + ) -> list[str]: + return await fetch_row_texts( + self.db, + f"{row_json_select()} {self._report_source()}", + {"report_id": report_id, "sections": sections, "user_id": user_id}, + ) + + @staticmethod + def _rf_rollup_source() -> str: + return ( + "FROM v3.v_form3_rf_rollup_jsonb(" + "CAST(:branch_id AS INT), CAST(:year AS INT), CAST(:sections AS TEXT[]))" + ) + async def get_rf_rollup_rows( self, branch_id: int, year: int, sections: list[str] | None = None, ) -> list[tuple]: - query = text( - """ - SELECT row_type, depth, sort_order, data - FROM v3.v_form3_rf_rollup_jsonb(CAST(:branch_id AS INT), CAST(:year AS INT), CAST(:sections AS TEXT[])) - """ - ) + query = text(f"SELECT {ROW_COLUMNS} {self._rf_rollup_source()}") return ( await self.db.execute( query, @@ -397,17 +412,44 @@ class ProjectRepository: ) ).all() - async def get_project_summary_rows(self, project_id: int) -> list[tuple]: - query = text( - """ - SELECT row_type, depth, sort_order, data - FROM v3.v_project_summary_jsonb(CAST(:project_id AS INT)) - """ + async def get_rf_rollup_json( + self, + branch_id: int, + year: int, + sections: list[str] | None = None, + ) -> list[str]: + return await fetch_row_texts( + self.db, + f"{row_json_select()} {self._rf_rollup_source()}", + {"branch_id": branch_id, "year": year, "sections": sections}, ) + + @staticmethod + def _project_summary_one_source() -> str: + return "FROM v3.v_project_summary_jsonb(CAST(:project_id AS INT))" + + async def get_project_summary_rows(self, project_id: int) -> list[tuple]: + query = text(f"SELECT {ROW_COLUMNS} {self._project_summary_one_source()}") return ( await self.db.execute(query, {"project_id": project_id}) ).all() + async def get_project_summary_json(self, project_id: int) -> list[str]: + return await fetch_row_texts( + self.db, + f"{row_json_select()} {self._project_summary_one_source()}", + {"project_id": project_id}, + ) + + @staticmethod + def _project_summary_source(function_name: str) -> str: + return ( + f"FROM {function_name}(" + "CAST(:year AS INT), " + "CAST(:branch_ids AS INT[]), " + "CAST(:project_type AS VARCHAR))" + ) + async def _get_project_summary_rows( self, function_name: str, @@ -415,16 +457,7 @@ class ProjectRepository: branch_ids: list[int] | None, project_type: str | None = None, ) -> list[tuple]: - query = text( - f""" - SELECT row_type, depth, sort_order, data - FROM {function_name}( - CAST(:year AS INT), - CAST(:branch_ids AS INT[]), - CAST(:project_type AS VARCHAR) - ) - """ - ) + query = text(f"SELECT {ROW_COLUMNS} {self._project_summary_source(function_name)}") return ( await self.db.execute( query, @@ -432,6 +465,19 @@ class ProjectRepository: ) ).all() + async def _get_project_summary_json( + self, + function_name: str, + year: int, + branch_ids: list[int] | None, + project_type: str | None = None, + ) -> list[str]: + return await fetch_row_texts( + self.db, + f"{row_json_select()} {self._project_summary_source(function_name)}", + {"year": year, "branch_ids": branch_ids, "project_type": project_type}, + ) + async def get_plan_summary_rows( self, year: int, @@ -471,6 +517,45 @@ class ProjectRepository: project_type=project_type, ) + async def get_plan_summary_json( + self, + year: int, + branch_ids: list[int] | None, + project_type: str | None = None, + ) -> list[str]: + return await self._get_project_summary_json( + function_name="v3.v_project_plan_summary_jsonb", + year=year, + branch_ids=branch_ids, + project_type=project_type, + ) + + async def get_fact_summary_json( + self, + year: int, + branch_ids: list[int] | None, + project_type: str | None = None, + ) -> list[str]: + return await self._get_project_summary_json( + function_name="v3.v_project_fact_summary_jsonb", + year=year, + branch_ids=branch_ids, + project_type=project_type, + ) + + async def get_detail_summary_json( + self, + year: int, + branch_ids: list[int] | None, + project_type: str | None = None, + ) -> list[str]: + return await self._get_project_summary_json( + function_name="v3.v_project_detail_summary_jsonb", + year=year, + branch_ids=branch_ids, + project_type=project_type, + ) + async def upd_form3_cell( self, report_id: int, diff --git a/api/src/repository/row_json.py b/api/src/repository/row_json.py new file mode 100644 index 0000000..27dd415 --- /dev/null +++ b/api/src/repository/row_json.py @@ -0,0 +1,17 @@ +"""Строка ответа тяжёлого GET как текст JSON из Postgres (AURORA-1848, design D1).""" +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +ROW_COLUMNS = "row_type, depth, sort_order, data" + + +def row_json_select(row_type: str = "row_type", depth: str = "depth") -> str: + """json (не jsonb) сохраняет порядок ключей; ::text обходит кодек json.loads у asyncpg.""" + return ( + f"SELECT json_build_object('row_type', {row_type}, 'depth', {depth}, " + f"'data', data, 'sort_order', sort_order)::text" + ) + + +async def fetch_row_texts(db: AsyncSession, sql: str, params: dict) -> list[str]: + return list((await db.execute(text(sql), params)).scalars().all()) diff --git a/api/src/repository/sheet_repository.py b/api/src/repository/sheet_repository.py index 834cf84..3fc0ed1 100644 --- a/api/src/repository/sheet_repository.py +++ b/api/src/repository/sheet_repository.py @@ -6,6 +6,7 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from src.db.models.form_type import FormType +from src.repository.row_json import ROW_COLUMNS, fetch_row_texts, row_json_select class DirectionEnum(str, enum.Enum): @@ -28,13 +29,14 @@ class SheetRepository: def __init__(self, db: AsyncSession): self.db = db - async def get( - self, - form_id: int, - sheet: str, + def _view_source( + self, + form_id: int, + sheet: str, direction: str | None, - sections: list[str] | None = None, - ) -> list[tuple]: + sections: list[str] | None, + ) -> tuple[str, dict]: + """Валидация и FROM-выражение v3.v_form_view: общие для get (выгрузка) и get_json (GET листа).""" if any( [ not isinstance(form_id, int), @@ -45,27 +47,41 @@ class SheetRepository: ): raise SheetValidationError("Некорректные данные") - if direction: func_query = "v3.v_form_view(:form_id, :sheet, :sections, :direction)" else: func_query = "v3.v_form_view(:form_id, :sheet, :sections)" + return func_query, { + "form_id": form_id, + "sheet": sheet, + "direction": direction, + "sections": sections, + } + + async def get( + self, + form_id: int, + sheet: str, + direction: str | None, + sections: list[str] | None = None, + ) -> list[tuple]: + func_query, params = self._view_source(form_id, sheet, direction, sections) return ( await self.db.execute( - text( - f""" - SELECT row_type, depth, sort_order, data - FROM {func_query} - """ - ), - { - "form_id": form_id, - "sheet": sheet, - "direction": direction, - "sections": sections, - } + text(f"SELECT {ROW_COLUMNS} FROM {func_query}"), + params, ) ).all() + + async def get_json( + self, + form_id: int, + sheet: str, + direction: str | None, + sections: list[str] | None = None, + ) -> list[str]: + func_query, params = self._view_source(form_id, sheet, direction, sections) + return await fetch_row_texts(self.db, f"{row_json_select()} FROM {func_query}", params) async def update_cell( self, diff --git a/api/src/repository/summary_repository.py b/api/src/repository/summary_repository.py index 88285bb..8dc0070 100644 --- a/api/src/repository/summary_repository.py +++ b/api/src/repository/summary_repository.py @@ -3,27 +3,63 @@ from typing import Any from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession +from src.repository.row_json import ROW_COLUMNS, fetch_row_texts, row_json_select + class SummaryRepository: def __init__(self, db: AsyncSession): self.db = db + @staticmethod + def _svod1_source() -> str: + return ( + "FROM v3.v_summary_view(" + "CAST(:year AS INT), " + "CAST(:sheet AS TEXT), " + "CAST(:org_ids AS INT[]))" + ) + + @staticmethod + def _svod2_source(sheet: str) -> str: + function_name = { + "GO": "v3.v_svod2_go", + "RF": "v3.v_svod2_rf", + }[sheet] + return ( + f"FROM {function_name}(" + "CAST(:year AS INT), " + "CAST(:org_ids AS INT[]), " + "CAST(:offset AS INT), " + "CAST(:limit AS INT), " + "CAST(:sort_by AS VARCHAR), " + "CAST(:sort_direction AS VARCHAR))" + ) + + @staticmethod + def _svod2_params( + year: int, + org_ids: list[int] | None, + offset: int | None, + limit: int | None, + sort_by: str, + sort_direction: str, + ) -> dict: + return { + "year": year, + "org_ids": org_ids, + "offset": offset, + "limit": limit, + "sort_by": sort_by, + "sort_direction": sort_direction, + } + async def get_svod1_rows( self, year: int, sheet: str, org_ids: list[int] | None = None, ) -> list[tuple]: - query = text( - """ - SELECT row_type, depth, sort_order, data - FROM v3.v_summary_view( - CAST(:year AS INT), - CAST(:sheet AS TEXT), - CAST(:org_ids AS INT[]) - ) - """ - ) + query = text(f"SELECT {ROW_COLUMNS} {self._svod1_source()}") return ( await self.db.execute( query, @@ -31,6 +67,19 @@ class SummaryRepository: ) ).all() + async def get_svod1_json( + self, + year: int, + sheet: str, + org_ids: list[int] | None = None, + ) -> list[str]: + sql = f"{row_json_select()} {self._svod1_source()}" + return await fetch_row_texts( + self.db, + sql, + {"year": year, "sheet": sheet, "org_ids": org_ids}, + ) + async def get_svod2_rows( self, year: int, @@ -41,41 +90,34 @@ class SummaryRepository: sort_by: str = "org_name", sort_direction: str = "asc", ) -> list[tuple]: - function_name = { - "GO": "v3.v_svod2_go", - "RF": "v3.v_svod2_rf", - }[sheet] query = text( - f""" - SELECT - 'INPUT'::VARCHAR AS row_type, - 3 AS depth, - sort_order, - data - FROM {function_name}( - CAST(:year AS INT), - CAST(:org_ids AS INT[]), - CAST(:offset AS INT), - CAST(:limit AS INT), - CAST(:sort_by AS VARCHAR), - CAST(:sort_direction AS VARCHAR) - ) - """ + f"SELECT 'INPUT'::VARCHAR AS row_type, 3 AS depth, sort_order, data " + f"{self._svod2_source(sheet)}" ) return ( await self.db.execute( query, - { - "year": year, - "org_ids": org_ids, - "offset": offset, - "limit": limit, - "sort_by": sort_by, - "sort_direction": sort_direction, - }, + self._svod2_params(year, org_ids, offset, limit, sort_by, sort_direction), ) ).all() + async def get_svod2_json( + self, + year: int, + sheet: str, + org_ids: list[int] | None = None, + offset: int | None = None, + limit: int | None = None, + sort_by: str = "org_name", + sort_direction: str = "asc", + ) -> list[str]: + select = row_json_select(row_type="'INPUT'::VARCHAR", depth="3") + return await fetch_row_texts( + self.db, + f"{select} {self._svod2_source(sheet)}", + self._svod2_params(year, org_ids, offset, limit, sort_by, sort_direction), + ) + async def get_svod2_go_export_rows( self, year: int, diff --git a/api/src/services/project_service.py b/api/src/services/project_service.py index f042487..8099e8f 100644 --- a/api/src/services/project_service.py +++ b/api/src/services/project_service.py @@ -195,6 +195,93 @@ class ProjectService: project_type=project_type, ) + async def get_report_json( + self, + project_id: int, + year: int, + report_type: str, + sections: list[str] | None, + user: AppUser, + ) -> list[str]: + report_id = await self.resolve_report_id(project_id, year, report_type, user) + if not report_id: + raise ValidationException("Отчёт не найден") + return await self.project_repo.get_report_json(report_id=report_id, sections=sections, user_id=user.id) + + async def get_rf_rollup_json( + self, + branch_id: int, + year: int, + sections: list[str] | None, + user: AppUser, + ) -> list[str]: + org_unit_ids = await self._allowed_org_unit_ids(user) + if org_unit_ids is not None and branch_id not in org_unit_ids: + raise AccessDeniedException() + return await self.project_repo.get_rf_rollup_json( + branch_id=branch_id, + year=year, + sections=sections, + ) + + async def get_project_summary_json( + self, + project_id: int, + user: AppUser, + ) -> list[str]: + project = await self.get_instance(project_id, user) + if not project: + raise ValidationException("Проект не найден") + return await self.project_repo.get_project_summary_json(project_id=project_id) + + async def get_plan_summary_json( + self, + year: int, + branch_ids: list[int] | None, + user: AppUser, + project_type: str | None = None, + ) -> list[str]: + branch_ids = await self._allowed_summary_branch_ids(branch_ids=branch_ids, user=user) + if branch_ids == []: + return [] + return await self.project_repo.get_plan_summary_json( + year=year, + branch_ids=branch_ids, + project_type=project_type, + ) + + async def get_fact_summary_json( + self, + year: int, + branch_ids: list[int] | None, + user: AppUser, + project_type: str | None = None, + ) -> list[str]: + branch_ids = await self._allowed_summary_branch_ids(branch_ids=branch_ids, user=user) + if branch_ids == []: + return [] + return await self.project_repo.get_fact_summary_json( + year=year, + branch_ids=branch_ids, + project_type=project_type, + ) + + async def get_detail_summary_json( + self, + year: int, + branch_ids: list[int] | None, + user: AppUser, + project_type: str | None = None, + ) -> list[str]: + branch_ids = await self._allowed_summary_branch_ids(branch_ids=branch_ids, user=user) + if branch_ids == []: + return [] + return await self.project_repo.get_detail_summary_json( + year=year, + branch_ids=branch_ids, + project_type=project_type, + ) + async def upd_form3_cell( self, project_id: int, diff --git a/api/src/services/sheet_service.py b/api/src/services/sheet_service.py index 51bdab9..06a5eb9 100644 --- a/api/src/services/sheet_service.py +++ b/api/src/services/sheet_service.py @@ -35,6 +35,21 @@ class SheetService: sections=sections, ) + async def get_json( + self, + sheet: str, + direction: str | None, + sections: list[str] | None = None, + form_id: int | None = None, + form: BudgetForm | None = None + ) -> list[str]: + return await self.sheet_repo.get_json( + form_id=form_id, + sheet=sheet, + direction=direction, + sections=sections, + ) + async def update_cells( self, form_id: int, diff --git a/api/src/services/summary_service.py b/api/src/services/summary_service.py index 1408e04..71c9a08 100644 --- a/api/src/services/summary_service.py +++ b/api/src/services/summary_service.py @@ -52,6 +52,46 @@ class SummaryService: sort_direction=sort_direction, ) + async def get_svod1_json( + self, + year: int, + sheet: str, + user: AppUser, + org_ids: list[int] | None = None, + ) -> list[str]: + org_ids = await self._allowed_org_ids(user, org_ids) + if org_ids == []: + return [] + return await self.summary_repo.get_svod1_json( + year=year, + sheet=sheet, + org_ids=org_ids, + ) + + async def get_svod2_json( + self, + year: int, + sheet: str, + user: AppUser, + org_ids: list[int] | None = None, + offset: int | None = None, + limit: int | None = None, + sort_by: str = "org_name", + sort_direction: str = "asc", + ) -> list[str]: + org_ids = await self._allowed_org_ids(user, org_ids) + if org_ids == []: + return [] + return await self.summary_repo.get_svod2_json( + year=year, + sheet=sheet, + org_ids=org_ids, + offset=offset, + limit=limit, + sort_by=sort_by, + sort_direction=sort_direction, + ) + async def get_svod2_go_export_rows( self, year: int, diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index 81cb26d..6c067f5 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -13,8 +13,15 @@ from src.main import app @pytest.fixture -def client(): +def db_session(): + """Сессия БД теста: её же использует client, всё откатывается в конце теста.""" db_ref = SessionLocal() + yield db_ref + + +@pytest.fixture +def client(db_session): + db_ref = db_session loop_ref = None async def _get_db(): diff --git a/api/tests/integration/test_heavy_get_json.py b/api/tests/integration/test_heavy_get_json.py new file mode 100644 index 0000000..f4a432a --- /dev/null +++ b/api/tests/integration/test_heavy_get_json.py @@ -0,0 +1,374 @@ +"""AURORA-1848: тяжёлые GET отдают готовый JSON из Postgres. + +Эталон — старый путь (методы репозиториев и сервисов, которыми пользуется выгрузка Excel) +в той же сессии теста. Спека: openspec/changes/aurora-1848-sheet-json/specs/sheet-read-api/spec.md +""" +import json +import uuid +from types import SimpleNamespace + +import pytest +from sqlalchemy import text + +from src.main import app +from src.repository.project_repository import ProjectRepository +from src.repository.sheet_repository import SheetRepository +from src.repository.summary_repository import SummaryRepository +from src.services.project_service import ProjectService +from src.services.summary_service import SummaryService + +ADMIN = SimpleNamespace(id=1, role_id=1) # fixture.sql: admin +ISP = SimpleNamespace(id=2, role_id=2) # fixture.sql: isp1, EXECUTOR_DFIP +YEAR = 2026 + +REPORT_NOT_FOUND = (400, {"code": 400, "field": "", "message": "Отчёт не найден"}) +RF_ROLLUP_SQL_ERROR = (400, {"code": 400, "field": "", "message": ": column r.year does not exist"}) +RF_FOREIGN = (403, {"code": 403, "field": "", "message": "Доступ запрещен"}) + +HEAVY_GET_PATHS = [ + "/api/v1/form/{form_id}/sheet/{sheet}", + "/api/v1/svod/1/{sheet}/{year}", + "/api/v1/svod/2/{sheet}/{year}", + "/api/v1/projects/{project_id}/report/{year}/{report_type}", + "/api/v1/rf-rollup/{branch_id}/{year}", + "/api/v1/projects/{project_id}/summary", + "/api/v1/projects/plan-summary", + "/api/v1/projects/fact-summary", + "/api/v1/projects/detail-summary", +] +OPENAPI_REF = "#/components/schemas/BaseListResponse_SheetResponse_" + + +def _payload(rows) -> list[dict]: + """Строки старого пути в том виде, в каком их отдавал BaseListResponse[SheetResponse].""" + return [{"row_type": r[0], "depth": r[1], "sort_order": r[2], "data": r[3]} for r in rows] + + +def old_rows(client, coro_factory) -> list[dict]: + """Вызвать старый метод (coro_factory() -> awaitable list[tuple]) в цикле приложения.""" + async def _go(): + return _payload(await coro_factory()) + + return client.portal.call(_go) + + +def expected_sheet(rows: list[dict]) -> list[dict]: + """Ожидаемый ответ листа и сводов по эталону старого пути. Фаза B (Task 13) меняет тело на strip_nulls.""" + return rows + + +def get_ok(client, url, headers, params=None) -> list[dict]: + r = client.get(url, headers=headers, params=params) + assert r.status_code == 200, r.text + assert r.headers["content-type"] == "application/json" + assert float(r.headers["x-db-time-ms"]) >= 0 + body = r.json() + assert list(body) == ["success", "message", "result", "count"] + assert body["success"] is True and body["message"] is None + assert body["count"] == len(body["result"]) + for row in body["result"]: + assert row.keys() == {"row_type", "depth", "sort_order", "data"} + return body["result"] + + +def test_openapi_heavy_get_schema_unchanged(): + spec = app.openapi() + for path in HEAVY_GET_PATHS: + schema = spec["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"] + assert schema == {"$ref": OPENAPI_REF}, path + + +SHEET_CASES = [ + (1, "AHR", "Support", None), # FORM_1, лист с direction + (4, "AHR", None, None), # FORM_2, без direction + (4, "AHR", None, ["plan", "q1"]), # FORM_2, с sections +] + + +def _sheet_params(direction, sections): + params = {} + if direction: + params["direction"] = direction + if sections: + params["sections"] = ",".join(sections) + return params + + +@pytest.mark.parametrize("form_id,sheet,direction,sections", SHEET_CASES) +def test_sheet_matches_old_path(client, db_session, admin_tokens, auth_headers, form_id, sheet, direction, sections): + result = get_ok(client, f"/api/v1/form/{form_id}/sheet/{sheet}", auth_headers(admin_tokens), _sheet_params(direction, sections)) + old = old_rows(client, lambda: SheetRepository(db_session).get(form_id=form_id, sheet=sheet, direction=direction, sections=sections)) + assert result + assert result[0]["row_type"] == "META" and result[0]["depth"] == -1 + assert result == expected_sheet(old) + + +TRICKY = 'Кавычка " и \\ слэш\nперенос\tтаб' + + +def test_sheet_text_escaping_matches_old_path(client, db_session, admin_tokens, auth_headers): + # В фикстуре у FORM_2 нет budget_line: вставляем строку с TRICKY в name (header.name листа). + # justification в ответе листа не выводится; вставка откатывается вместе с сессией теста. + async def _set(): + await db_session.execute( + text( + "INSERT INTO v3.budget_line (budget_form_id, expense_item_id, name, direction) " + "SELECT 4, t.id, :t, 'Support' FROM v3.mv_expense_item_tree t " + "JOIN v3.expense_item_form_type eft ON eft.expense_item_id = t.id " + "WHERE eft.form_type_code = 'FORM_2' AND t.sheet = 'AHR' ORDER BY t.id LIMIT 1" + ), + {"t": TRICKY}, + ) + client.portal.call(_set) + result = get_ok(client, "/api/v1/form/4/sheet/AHR", auth_headers(admin_tokens)) + old = old_rows(client, lambda: SheetRepository(db_session).get(form_id=4, sheet="AHR", direction=None)) + assert result == expected_sheet(old) + assert any((r["data"].get("header") or {}).get("name") == TRICKY for r in result) + + +def test_heavy_get_gzip_compressed(client, admin_tokens, auth_headers): + """Спека, сценарий «Сжатие»: ответ >= 1000 байт (GZipMiddleware minimum_size=1000) уходит с Content-Encoding: gzip.""" + headers = {**auth_headers(admin_tokens), "Accept-Encoding": "gzip"} + r = client.get("/api/v1/form/4/sheet/AHR", headers=headers) + assert r.status_code == 200, r.text + assert r.headers.get("content-encoding") == "gzip" + assert r.json()["success"] is True # клиент распаковал тело + + +def test_sheet_get_does_not_use_old_path(client, admin_tokens, auth_headers, monkeypatch): + async def boom(*args, **kwargs): + raise AssertionError("GET листа не должен вызывать SheetRepository.get") + monkeypatch.setattr(SheetRepository, "get", boom) + get_ok(client, "/api/v1/form/4/sheet/AHR", auth_headers(admin_tokens)) + + +# Свод 2 берёт строки по версиям v3.v_svod2_go / v3.v_svod2_rf (миграция 0049, SQL-файл 0049_summary2_related_records.sql): +# GO - budget_form FORM_1/FORM_4 за год, RF (с 0041) - только FORM_2; статья с sheet IN (AHR, CAP, OPER); +# строка попадает в свод, если у неё есть хотя бы одна "присутствующая" секция (например, plan с ненулевым планом). +# Хардкод (предположение о fixture.sql, T4.2): орг. единицы 1 (Test_SSP) и 2 (Test РФ) - единственные в фикстуре; +# в фикстуре за YEAR есть только FORM_2 (budget_form id=4, org 2) без budget_line; статьи 88 (AHR, R061161003), +# 523 (CAP, 3.00.0.), 729 (OPER) существуют в справочнике и имеют разные section_code - это нужно, чтобы +# sort_by=section_code и offset/limit давали различимый порядок. Если fixture.sql или справочник изменятся, +# test_svod2_fixture_not_empty упадёт первым. +SVOD2_ORG_IDS = (1, 2) +SVOD2_EXPENSE_ITEM_IDS = (88, 523, 729) # AHR, CAP, OPER +SVOD2_FORMS = [("FORM_1", 1), ("FORM_1", 2), ("FORM_2", 1), ("FORM_2", 2)] # GO: FORM_1, RF: FORM_2 +SVOD2_MIN_ROWS = 6 # 2 орг. единицы x 3 статьи на каждый из листов GO и RF + + +@pytest.fixture +def svod2_plan_line(client, db_session): + """Фикстура не даёт свода 2 строк за YEAR: достраиваем формы FORM_1 и FORM_2 по двум орг. единицам + и строки трёх статей (AHR, CAP, OPER) с ненулевым планом. Откатывается вместе с сессией теста.""" + async def _set(): + for form_type, org_id in SVOD2_FORMS: + await db_session.execute( + text( + "INSERT INTO v3.budget_form (form_type_code, year, org_unit_id) " + "VALUES (:ft, :year, :org) ON CONFLICT (form_type_code, org_unit_id, year) DO NOTHING" + ), + {"ft": form_type, "year": YEAR, "org": org_id}, + ) + await db_session.execute( + text( + "INSERT INTO v3.budget_line (budget_form_id, expense_item_id) " + "SELECT bf.id, e FROM v3.budget_form bf CROSS JOIN unnest(CAST(:items AS int[])) AS e " + "WHERE bf.year = :year AND bf.form_type_code IN ('FORM_1', 'FORM_2') " + "AND NOT EXISTS (SELECT 1 FROM v3.budget_line x " + "WHERE x.budget_form_id = bf.id AND x.expense_item_id = e)" + ), + {"year": YEAR, "items": list(SVOD2_EXPENSE_ITEM_IDS)}, + ) + await db_session.execute( + text( + "INSERT INTO v3.plan (line_id, plan_q1) " + "SELECT bl.id, 1000 FROM v3.budget_line bl JOIN v3.budget_form bf ON bf.id = bl.budget_form_id " + "WHERE bf.year = :year AND bf.form_type_code IN ('FORM_1', 'FORM_2') " + "AND bl.expense_item_id = ANY(CAST(:items AS int[])) " + "ON CONFLICT (line_id) DO UPDATE SET plan_q1 = 1000" + ), + {"year": YEAR, "items": list(SVOD2_EXPENSE_ITEM_IDS)}, + ) + client.portal.call(_set) + + +@pytest.mark.parametrize("sheet", ["FORM_1", "FORM_2", "FORM_3", "FORM_4", "MAIN"]) +def test_svod1_matches_old_path(client, db_session, admin_tokens, auth_headers, sheet): + result = get_ok(client, f"/api/v1/svod/1/{sheet}/{YEAR}", auth_headers(admin_tokens)) + old = old_rows(client, lambda: SummaryRepository(db_session).get_svod1_rows(year=YEAR, sheet=sheet, org_ids=None)) + assert result == expected_sheet(old) + + +def test_svod1_org_ids_matches_old_path(client, db_session, admin_tokens, auth_headers): + result = get_ok(client, f"/api/v1/svod/1/FORM_1/{YEAR}", auth_headers(admin_tokens), {"org_ids": "1,2,3"}) + old = old_rows(client, lambda: SummaryRepository(db_session).get_svod1_rows(year=YEAR, sheet="FORM_1", org_ids=[1, 2, 3])) + assert result + assert result == expected_sheet(old) + + +SVOD2_CASES = [ + ("GO", {}, {}), + ("RF", {}, {}), + ("GO", {"org_ids": "1,2"}, {"org_ids": [1, 2]}), + ("GO", {"offset": 1, "limit": 2}, {"offset": 1, "limit": 2}), + ("RF", {"sort_by": "section_code", "sort_direction": "desc"}, {"sort_by": "section_code", "sort_direction": "desc"}), +] + + +def _svod2_min_rows(query: dict) -> int: + if "limit" in query: + return 1 + if "sort_by" in query: + return 2 + return 1 + + +@pytest.mark.usefixtures("svod2_plan_line") +@pytest.mark.parametrize("sheet,query,repo_kwargs", SVOD2_CASES) +def test_svod2_matches_old_path(client, db_session, admin_tokens, auth_headers, sheet, query, repo_kwargs): + result = get_ok(client, f"/api/v1/svod/2/{sheet}/{YEAR}", auth_headers(admin_tokens), query) + old = old_rows(client, lambda: SummaryRepository(db_session).get_svod2_rows(year=YEAR, sheet=sheet, **repo_kwargs)) + assert len(result) >= _svod2_min_rows(query), "сравнение на пустом/слишком коротком списке ничего не доказывает" + assert result == expected_sheet(old) + + +@pytest.mark.usefixtures("svod2_plan_line") +def test_svod2_fixture_not_empty(client, admin_tokens, auth_headers): + """Иначе сравнения свода 2 выше проходят на пустых списках.""" + go = get_ok(client, f"/api/v1/svod/2/GO/{YEAR}", auth_headers(admin_tokens)) + rf = get_ok(client, f"/api/v1/svod/2/RF/{YEAR}", auth_headers(admin_tokens)) + assert len(go) >= SVOD2_MIN_ROWS, len(go) + assert len(rf) >= SVOD2_MIN_ROWS, len(rf) + for rows in (go, rf): + assert {r["data"]["header"]["source_org_unit_id"] for r in rows} == set(SVOD2_ORG_IDS) + assert len({r["data"]["header"]["section_code"] for r in rows}) >= 2 + + +@pytest.mark.usefixtures("svod2_plan_line") +def test_svod2_isp_matches_service_old_path(client, db_session, isp_tokens, auth_headers): + result = get_ok(client, f"/api/v1/svod/2/GO/{YEAR}", auth_headers(isp_tokens)) + old = old_rows(client, lambda: SummaryService(db_session).get_svod2_rows(year=YEAR, sheet="GO", user=ISP)) + assert result == expected_sheet(old) + + +def test_svod2_foreign_org_ids_empty_body(client, isp_tokens, auth_headers): + r = client.get(f"/api/v1/svod/2/GO/{YEAR}", headers=auth_headers(isp_tokens), params={"org_ids": "999999"}) + assert r.status_code == 200 + assert r.json() == {"success": True, "message": None, "result": [], "count": 0} + assert float(r.headers["x-db-time-ms"]) >= 0 + + +@pytest.mark.parametrize("url,method", [ + (f"/api/v1/svod/1/MAIN/{YEAR}", "get_svod1_rows"), + (f"/api/v1/svod/2/GO/{YEAR}", "get_svod2_rows"), +]) +def test_svod_get_does_not_use_old_path(client, admin_tokens, auth_headers, monkeypatch, url, method): + async def boom(*args, **kwargs): + raise AssertionError(f"GET свода не должен вызывать SummaryRepository.{method}") + monkeypatch.setattr(SummaryRepository, method, boom) + get_ok(client, url, auth_headers(admin_tokens)) + + +def _create_project(client, headers, year=YEAR, branch_id=1) -> int: + r = client.post( + "/api/v1/projects", + json={ + "name": f"PT_{uuid.uuid4().hex[:10]}", + "year": year, + "branch_id": branch_id, + "project_type": "Открытие ВСП", + "vsp_format": "Типовой", + "placement_type": "Собственность", + "object_address": "Тестовая улица 1", + }, + headers=headers, + ) + assert r.status_code == 200, r.text + return r.json()["project_id"] + + +@pytest.fixture +def project_id(client, admin_tokens, auth_headers) -> int: + """Новый проект сразу получает отчёт LIMIT со всеми строками статей (POST строки на нём даёт 400/409 + и ломает транзакцию теста), так что отдельная строка не нужна.""" + return _create_project(client, auth_headers(admin_tokens)) + + +@pytest.mark.parametrize("sections", [None, "q1,year"]) +def test_project_report_matches_old_path(client, db_session, admin_tokens, auth_headers, project_id, sections): + pid = project_id + result = get_ok(client, f"/api/v1/projects/{pid}/report/{YEAR}/LIMIT", auth_headers(admin_tokens), + {"sections": sections} if sections else None) + old = old_rows(client, lambda: ProjectService(db_session).get_report_rows( + project_id=pid, year=YEAR, report_type="LIMIT", + sections=sections.split(",") if sections else None, user=ADMIN)) + assert result == old + assert any(row["row_type"] == "INPUT" for row in result) + + +def test_rf_rollup_pinned_preexisting_sql_error(client, admin_tokens, auth_headers): + """Фиксирует СУЩЕСТВУЮЩИЙ дефект вне AURORA-1848: v3.v_form3_rf_rollup_jsonb в миграции 0002_functions.sql + ссылается на несуществующий r.year, поэтому на любой БД ответ - 400 с текстом SQL-ошибки (так же отвечал + и старый путь). Когда дефект будет исправлен, этот тест упадёт и его нужно превратить в тест + эквивалентности с эталоном ProjectService.get_rf_rollup_rows (как остальные get_ok-сравнения).""" + r = client.get(f"/api/v1/rf-rollup/1/{YEAR}", headers=auth_headers(admin_tokens)) + assert (r.status_code, r.json()) == RF_ROLLUP_SQL_ERROR + + +def test_project_summary_matches_old_path(client, db_session, admin_tokens, auth_headers, project_id): + pid = project_id + result = get_ok(client, f"/api/v1/projects/{pid}/summary", auth_headers(admin_tokens)) + old = old_rows(client, lambda: ProjectService(db_session).get_project_summary_rows(project_id=pid, user=ADMIN)) + assert result == old + + +@pytest.mark.parametrize("kind", ["plan", "fact", "detail"]) +def test_projects_summaries_match_old_path(client, db_session, admin_tokens, auth_headers, project_id, kind): + result = get_ok(client, f"/api/v1/projects/{kind}-summary", auth_headers(admin_tokens), {"year": YEAR}) + method = getattr(ProjectService(db_session), f"get_{kind}_summary_rows") + old = old_rows(client, lambda: method(year=YEAR, branch_ids=None, user=ADMIN)) + assert result == old + + +def test_projects_summaries_not_all_empty(client, admin_tokens, auth_headers, project_id): + pid = project_id + got = [get_ok(client, f"/api/v1/projects/{k}-summary", auth_headers(admin_tokens), {"year": YEAR}) + for k in ("plan", "fact", "detail")] + got.append(get_ok(client, f"/api/v1/projects/{pid}/summary", auth_headers(admin_tokens))) + assert all(got), [bool(g) for g in got] + + +GET_METHODS = [ + ("/api/v1/projects/{pid}/report/%d/LIMIT" % YEAR, "get_report_rows"), + ("/api/v1/rf-rollup/1/%d" % YEAR, "get_rf_rollup_rows"), + ("/api/v1/projects/{pid}/summary", "get_project_summary_rows"), + ("/api/v1/projects/plan-summary?year=%d" % YEAR, "get_plan_summary_rows"), + ("/api/v1/projects/fact-summary?year=%d" % YEAR, "get_fact_summary_rows"), + ("/api/v1/projects/detail-summary?year=%d" % YEAR, "get_detail_summary_rows"), +] + + +@pytest.mark.parametrize("url,method", GET_METHODS) +def test_projects_get_does_not_use_old_path(client, admin_tokens, auth_headers, project_id, monkeypatch, url, method): + called = [] + + async def boom(*args, **kwargs): + called.append(method) + raise AssertionError(f"GET не должен вызывать ProjectRepository.{method}") + monkeypatch.setattr(ProjectRepository, method, boom) + r = client.get(url.format(pid=project_id), headers=auth_headers(admin_tokens)) + assert not called + if method != "get_rf_rollup_rows": # rf-rollup: см. test_rf_rollup_pinned_preexisting_sql_error + assert r.status_code == 200, r.text + + +# Характеризующие тесты ошибок: литералы сняты со старого кода (до правки роутеров). +def test_project_report_not_found_unchanged(client, admin_tokens, auth_headers, project_id): + pid = project_id + r = client.get(f"/api/v1/projects/{pid}/report/2099/LIMIT", headers=auth_headers(admin_tokens)) + assert (r.status_code, r.json()) == REPORT_NOT_FOUND + + +def test_rf_rollup_foreign_branch_unchanged(client, isp_tokens, auth_headers): + r = client.get(f"/api/v1/rf-rollup/1/{YEAR}", headers=auth_headers(isp_tokens)) + assert (r.status_code, r.json()) == RF_FOREIGN diff --git a/api/tests/unit/test_json_list_response.py b/api/tests/unit/test_json_list_response.py new file mode 100644 index 0000000..bd2ff43 --- /dev/null +++ b/api/tests/unit/test_json_list_response.py @@ -0,0 +1,41 @@ +import json + +from src.api.v1.json_response import list_json_response +from src.repository.row_json import ROW_COLUMNS, row_json_select + + +def test_list_json_response_empty(): + r = list_json_response([]) + assert json.loads(r.body) == {"success": True, "message": None, "result": [], "count": 0} + + +def test_list_json_response_rows_order_count_escaping(): + rows = [ + '{"row_type":"META","depth":-1,"data":{"a":null},"sort_order":null}', + r'{"row_type": "INPUT", "depth": 3, "data": {"t": "Кавычка \" и \\ слэш\nперенос"}, "sort_order": 1}', + ] + body = json.loads(list_json_response(rows).body) + assert list(body) == ["success", "message", "result", "count"] + assert body["count"] == 2 + assert [row["row_type"] for row in body["result"]] == ["META", "INPUT"] + assert body["result"][0]["data"] == {"a": None} + assert body["result"][1]["data"]["t"] == 'Кавычка " и \\ слэш\nперенос' + + +def test_list_json_response_media_type_and_headers(): + r = list_json_response(["{}"], headers={"X-DB-Time-Ms": "1.50"}) + assert r.media_type == "application/json" + assert r.headers["content-type"] == "application/json" + assert r.headers["x-db-time-ms"] == "1.50" + assert r.headers["content-length"] == str(len(r.body)) + + +def test_row_json_select_key_order(): + assert ROW_COLUMNS == "row_type, depth, sort_order, data" + assert row_json_select() == ( + "SELECT json_build_object('row_type', row_type, 'depth', depth, " + "'data', data, 'sort_order', sort_order)::text" + ) + assert row_json_select(row_type="'INPUT'::VARCHAR", depth="3").startswith( + "SELECT json_build_object('row_type', 'INPUT'::VARCHAR, 'depth', 3, " + ) diff --git a/docs/superpowers/plans/2026-10-28-aurora-1848-sheet-json.md b/docs/superpowers/plans/2026-10-28-aurora-1848-sheet-json.md new file mode 100644 index 0000000..2c3aae1 --- /dev/null +++ b/docs/superpowers/plans/2026-10-28-aurora-1848-sheet-json.md @@ -0,0 +1,912 @@ +# AURORA-1848: тяжёлые GET — готовый JSON без pydantic-модели на строку и без null-полей. План реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Issue:** Gitea #233, Jira AURORA-1848 (эпик AURORA-1834). + +**Ветки и фазы:** +- **Фаза A** (Task 1–10) — ветка `feat/233-aurora-1848-sheet-json-no-pydantic` (от `test`), worktree `.claude/worktrees/aurora-1848`, PR в `test`. +- **Фаза B** (Task 11–19) — ветка `feat/233-aurora-1848-sheet-json-no-pydantic-b` стеком на ветке A, worktree `.claude/worktrees/aurora-1848-b`, PR в ветку A. Между фазами остановки для человека нет: после открытия PR A контроллер сразу начинает B. **PR B не мержится раньше PR A и PR #223 (AURORA-1838)** — это пишется в описании PR B. + +**Goal:** GET листа, сводов 1 и 2 и отчётов проектов отдают тело, собранное из текста строк Postgres (`json_build_object(...)::text`), без pydantic-модели на строку (A); у листа и сводов в `data` нет null-полей, фронт не теряет правки соавторов (B). + +**Architecture:** Рядом со старыми методами репозиториев и сервисов (их использует выгрузка Excel) появляются методы `*_json`, возвращающие `list[str]` — по готовой JSON-строке на ряд. Роутеры склеивают строки в обёртку `{"success","message","result","count"}` хелпером `list_json_response` и возвращают `Response` с `X-DB-Time-Ms`; `response_model` остаётся в декораторе ради OpenAPI. В B выражение `data` заменяется на `jsonb_strip_nulls(data)` для листа и сводов, фронт закрепляет вставку отсутствующих ключей (`updateCells` из #223) и отправку `value: null` вместо пропуска. + +**Tech Stack:** Python 3.12, FastAPI 0.111, SQLAlchemy 2 async + asyncpg, PostgreSQL 16 (тесты) / 15.12 (контур), pytest; React 18 (JSX), `node:test`, Biome, Rsbuild; httpx для `perf/`. + +**Spec:** `openspec/changes/aurora-1848-sheet-json/` (`proposal.md`, `design.md`, `specs/sheet-read-api/spec.md`, `specs/realtime-sheet-updates/spec.md`). Спека — единственный источник истины при рулингах; ссылки вида «D3» — разделы `design.md`. + +## Global Constraints + +**Замок ночного прогона (обязательно).** С ~01:00 на машине идут замеры производительности; любая нагрузка на CPU их портит. +- Перед КАЖДЫМ шагом, помеченным **[замок]** (pytest, alembic, `node --test`, `npx biome check`, `npm run build`, `codegraph init`, docker-команды, запуск API/фронта), ждать, пока замок существует: + `L=/private/tmp/claude-501/-Users-andrey-epifancev-projects-fomichev-claude/6fa9c75e-eb11-4e5d-98c1-05eb590ab14f/scratchpad/BENCH_LOCK; while [ -e "$L" ]; do sleep 60; done` + Команду ожидания запускать с большим timeout или в фоне с уведомлением; если инструмент не даёт ждать долго — повторять проверку. +- Контроллер передаёт это правило в бриф каждого исполнителя и ревьюера (сабагенты не видят этот раздел, если его не процитировать). +- Чтение и написание кода, git-коммиты — можно при замке. +- Стенд `perf/` (БД `perf` в `perf-pg`, порты 8000/8080/8081/8082) — **не запускать вообще**: замеры делает главный контроллер (Task 19). + +**Окружение тестов.** +- `PY=/Users/andrey.epifancev/projects/fomichev_claude/.venvs/dfip-api/bin` — pytest, python. Свой venv не создавать. +- `DBURL=postgresql+asyncpg://perf:perf@localhost:55432/dfip_1848` — своя БД в контейнере `perf-pg` (PG16). БД `perf` не трогать никогда. Пароли `OPENBAO__TEST_*` по умолчанию `admin123`. +- Полный набор бэка: `cd api && DATABASE_URL=$DBURL $PY/pytest -q -p no:cacheprovider -W ignore` (с `--init_db` — только на только что созданной БД, см. Task 1). Зелёным считается весь набор. +- Исходное состояние `test`: ровно 3 падения не наши — `test_backend_calls_form3_add_project_and_add_line_functions` и два теста в `test_export_service_query_params.py`. Новых падений быть не должно. +- Тесты `perf/`: `cd perf && $PY/python -m pytest -q -p no:cacheprovider -m "not stand" tests`. +- Фронт: `web/node_modules` — симлинк на `/Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/web/node_modules`, `npm install` не запускать. Команды: `cd web && node --test src/components/RealtimeTable/utils/*.test.js`, `npx biome check <изменённые файлы>`, `npm run build`. +- PG 15.12 (`dfip-pg15:55434`) проверяет главный контроллер (Task 8, Task 16). +- Карта кода: `mcp__codegraph__codegraph_explore` с `projectPath` = абсолютный путь текущего worktree. Пустой ответ — один раз отметить `O-n` в ledger и дальше читать файлы. + +**Инварианты кода (из спеки).** +- Старые методы `SheetRepository.get`, `SummaryRepository.get_svod1_rows/get_svod2_rows`, `ProjectRepository.get_*_rows`, `SheetService.get`, `SummaryService.get_svod*_rows`, `ProjectService.get_*_rows` не меняют сигнатуру и результат; единственная допустимая правка — вынос FROM-части в приватный метод с эквивалентным текстом SQL (D2). +- Дифф `api/src/services/export_service/`, `api/src/api/v1/websocket.py`, `api/alembic/` против `test` — пустой. Миграций нет. +- Строка ответа: `json_build_object('row_type', , 'depth', , 'data', , 'sort_order', sort_order)::text`, порядок строк — порядок функции, без `ORDER BY` (D1). +- Обёртка: `{"success":true,"message":null,"result":[...],"count":N}`, `media_type="application/json"` без `charset` (D3). +- `X-DB-Time-Ms` ставится на возвращаемый `Response` (`headers=`), формат `f"{db_ms:.2f}"`, окно замера прежнее, лог `db_time` с прежними полями. Параметр `response: Response` из сигнатур убрать (D3). +- `response_model=BaseListResponse[SheetResponse]` — в декораторе `@router.get(...)`, аннотация возврата `-> Response` (D3). +- B: `jsonb_strip_nulls` только у листа и сводов 1/2; отчёты и сводные проектов отдают null как раньше; ответы путей записи и WebSocket не меняются (D4, решение человека 3). + +**Коммиты и PR.** +- Стиль репозитория (`perf(...)`, `fix(...)`, `test(...)`, `docs(...)`), по-русски, `git add` конкретных файлов (не `-A`). Последняя строка сообщения: `Co-Authored-By: Claude Opus 5.5 `. +- `git stash` без тега не использовать. Не мержить. Не пушить в `test`/`main`. `.bounded` не создавать (задача architectural, правки разрешает этот план). +- PR: `tea pulls create --base <база> --head <ветка> --title "AURORA-1848: …" --description "…"`. В описании: `Closes #233` (только PR B; в PR A — `Part of #233`), ключ AURORA-1848, что сделано, тесты, чек-лист ручной проверки, строка «Замер до/после — добавит контроллер после прогона на стенде», раздел «Rulings I made» (маркеры `R-n`), таблица находок, последняя строка `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. + +**Маркировка находок.** Каждая находка и решение несёт маркер, присвоенный один раз: `T.` — minor ревьюера задачи n; `C-n`/`I-n`/`M-n` — финальное ревью; `R-n` — рулинг контроллера (рядом номер строки в `design.md`, напр. `R-3 (design #24)`); `O-n` — наблюдение контроллера; `H-n` — замечание человека из PR. Статус каждой: `fixed ` / `deferred` / `rejected <причина>`. Таблица `## Findings` (`| Маркер | Суть | Статус |`) — в конце ledger, обновляется при каждой правке; та же таблица — в описании PR. Каждый рулинг без обсуждения с человеком дописывается в `openspec/changes/aurora-1848-sheet-json/design.md` → `## Decisions made without discussion` в той же ветке, где он принят. + +## Review Focus + +1. **Текст с кавычками, обратным слэшем, переводом строки и кириллицей в `data`** (обоснование, наименование). Postgres экранирует иначе, чем `json.dumps`; ожидается, что после `json.loads` значение то же. Тест `test_sheet_text_escaping_matches_old_path` (Task 3) и unit-тест хелпера (Task 2). +2. **Пустой результат** (пользователь без доступа к запрошенным `org_ids`, свод без строк): тело `{"success":true,"message":null,"result":[],"count":0}` — валидный JSON, а не `"result":[]`-обрывок. Тесты `test_list_json_response_empty` (Task 2), `test_svod2_foreign_org_ids_empty_body` (Task 4). +3. **Ответ в обход `response_model` перестаёт проверяться**: незаметно уходит старый путь, теряется `X-DB-Time-Ms` или обёртка. Ожидается: GET не вызывает старые методы, заголовок есть на всех 9 эндпоинтах. Тесты `test_*_does_not_use_old_path` (Task 3, 4, 5), `get_ok` проверяет заголовок и ключи. +4. **Очистка `null` затрагивает не только `data`** (обёртку, `sort_order`, ответы путей записи, отчёты проектов) или удаляет `{}` и `null` в массивах. Ожидается: чистится только `data` листа и сводов, `{}` и `null`-элементы массивов остаются, PATCH отдаёт `null` явно. Тесты `test_strip_response_*` (Task 7), `test_patch_cell_null_is_explicit` и `test_project_report_keeps_nulls` (Task 13). +5. **Пустая ячейка без ключа на фронте**: выход из редактирования без ввода отправляет сообщение без `value`, и сервер падает на `event_data["value"]`. Ожидается `"value": null`. Тест `buildCellUpdatedMessage: undefined → null` (Task 12). + +--- + +# Фаза A — готовый JSON без pydantic-модели на строку + +Ветка `feat/233-aurora-1848-sheet-json-no-pydantic`, worktree `/Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/.claude/worktrees/aurora-1848`. + +### Task 1: Окружение тестов, фикстура `db_session`, снимок схемы OpenAPI + +**Исполнитель:** haiku (код готов в плане). + +**Files:** +- Modify: `api/tests/integration/conftest.py:15-18` (хунк AURORA-1846, если его ещё нет) +- Create: `api/tests/integration/test_heavy_get_json.py` + +**Interfaces:** +- Produces: фикстура `db_session` (та же сессия, что у `client`); в `test_heavy_get_json.py` — `ADMIN`, `ISP`, `YEAR`, `HEAVY_GET_PATHS`, `OPENAPI_REF`, `old_rows(client, coro_factory) -> list[dict]`, `expected_sheet(rows: list[dict]) -> list[dict]`, `get_ok(client, url, headers, params=None) -> list[dict]`. Все последующие интеграционные тесты фазы A и B дописываются в этот файл и пользуются этими именами. + +- [ ] **Step 1: Фикстура `db_session`.** Если `grep -q "def db_session" api/tests/integration/conftest.py` ничего не находит — внести ровно этот хунк (тот же, что в PR #232; одинаковая правка сольётся без конфликта): + +```python +@pytest.fixture +def db_session(): + """Сессия БД теста: её же использует client, всё откатывается в конце теста.""" + db_ref = SessionLocal() + yield db_ref + + +@pytest.fixture +def client(db_session): + db_ref = db_session + loop_ref = None +``` + +(заменяет строки `def client():` / `db_ref = SessionLocal()` / `loop_ref = None`; остальное тело `client` без изменений). + +- [ ] **Step 2: Создать модуль тестов с помощниками и снимком OpenAPI** + +```python +"""AURORA-1848: тяжёлые GET отдают готовый JSON из Postgres. + +Эталон — старый путь (методы репозиториев и сервисов, которыми пользуется выгрузка Excel) +в той же сессии теста. Спека: openspec/changes/aurora-1848-sheet-json/specs/sheet-read-api/spec.md +""" +from types import SimpleNamespace + +import pytest + +from src.main import app + +ADMIN = SimpleNamespace(id=1, role_id=1) # fixture.sql: admin +ISP = SimpleNamespace(id=2, role_id=2) # fixture.sql: isp1, EXECUTOR_DFIP +YEAR = 2026 + +HEAVY_GET_PATHS = [ + "/api/v1/form/{form_id}/sheet/{sheet}", + "/api/v1/svod/1/{sheet}/{year}", + "/api/v1/svod/2/{sheet}/{year}", + "/api/v1/projects/{project_id}/report/{year}/{report_type}", + "/api/v1/rf-rollup/{branch_id}/{year}", + "/api/v1/projects/{project_id}/summary", + "/api/v1/projects/plan-summary", + "/api/v1/projects/fact-summary", + "/api/v1/projects/detail-summary", +] +OPENAPI_REF = "#/components/schemas/BaseListResponse_SheetResponse_" + + +def _payload(rows) -> list[dict]: + """Строки старого пути в том виде, в каком их отдавал BaseListResponse[SheetResponse].""" + return [{"row_type": r[0], "depth": r[1], "sort_order": r[2], "data": r[3]} for r in rows] + + +def old_rows(client, coro_factory) -> list[dict]: + """Вызвать старый метод (coro_factory() -> awaitable list[tuple]) в цикле приложения.""" + async def _go(): + return _payload(await coro_factory()) + + return client.portal.call(_go) + + +def expected_sheet(rows: list[dict]) -> list[dict]: + """Ожидаемый ответ листа и сводов по эталону старого пути. Фаза B (Task 13) меняет тело на strip_nulls.""" + return rows + + +def get_ok(client, url, headers, params=None) -> list[dict]: + r = client.get(url, headers=headers, params=params) + assert r.status_code == 200, r.text + assert r.headers["content-type"] == "application/json" + assert float(r.headers["x-db-time-ms"]) >= 0 + body = r.json() + assert list(body) == ["success", "message", "result", "count"] + assert body["success"] is True and body["message"] is None + assert body["count"] == len(body["result"]) + for row in body["result"]: + assert row.keys() == {"row_type", "depth", "sort_order", "data"} + return body["result"] + + +def test_openapi_heavy_get_schema_unchanged(): + spec = app.openapi() + for path in HEAVY_GET_PATHS: + schema = spec["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"] + assert schema == {"$ref": OPENAPI_REF}, path +``` + +- [ ] **Step 3: [замок] Создать БД.** `docker exec perf-pg dropdb -U perf --if-exists dfip_1848 && docker exec perf-pg createdb -U perf dfip_1848` + +- [ ] **Step 4: [замок] Базовый полный прогон на ещё не изменённом коде** (это и снимок OpenAPI). +Run: `cd api && DATABASE_URL=$DBURL $PY/pytest --init_db -q -p no:cacheprovider -W ignore` +Expected: падают ровно 3 известных теста (см. Global Constraints); `test_openapi_heavy_get_schema_unchanged` PASS. Если он падает только из-за имени компоненты — заменить литерал `OPENAPI_REF` на фактическое значение из схемы (это и есть снимок до изменения), отметить `O-n` в ledger. Другие падения — стоп, `superpowers:systematic-debugging`. + +- [ ] **Step 5: Commit** + +```bash +git add api/tests/integration/conftest.py api/tests/integration/test_heavy_get_json.py +git commit -m "test(api): фикстура db_session и снимок схемы OpenAPI тяжёлых GET" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Хелпер ответа и SQL-выражение строки + +**Исполнитель:** haiku (код готов в плане). + +**Files:** +- Create: `api/src/api/v1/json_response.py` +- Create: `api/src/repository/row_json.py` +- Test: `api/tests/unit/test_json_list_response.py` + +**Interfaces:** +- Produces: + - `list_json_response(rows: list[str], headers: dict[str, str] | None = None) -> fastapi.Response` (`src.api.v1.json_response`) + - `ROW_COLUMNS: str = "row_type, depth, sort_order, data"` (`src.repository.row_json`) + - `row_json_select(row_type: str = "row_type", depth: str = "depth") -> str` — SELECT-список без `FROM` + - `async fetch_row_texts(db: AsyncSession, sql: str, params: dict) -> list[str]` + +- [ ] **Step 1: Write the failing tests** + +```python +import json + +from src.api.v1.json_response import list_json_response +from src.repository.row_json import ROW_COLUMNS, row_json_select + + +def test_list_json_response_empty(): + r = list_json_response([]) + assert json.loads(r.body) == {"success": True, "message": None, "result": [], "count": 0} + + +def test_list_json_response_rows_order_count_escaping(): + rows = [ + '{"row_type":"META","depth":-1,"data":{"a":null},"sort_order":null}', + r'{"row_type": "INPUT", "depth": 3, "data": {"t": "Кавычка \" и \\ слэш\nперенос"}, "sort_order": 1}', + ] + body = json.loads(list_json_response(rows).body) + assert list(body) == ["success", "message", "result", "count"] + assert body["count"] == 2 + assert [row["row_type"] for row in body["result"]] == ["META", "INPUT"] + assert body["result"][0]["data"] == {"a": None} + assert body["result"][1]["data"]["t"] == 'Кавычка " и \\ слэш\nперенос' + + +def test_list_json_response_media_type_and_headers(): + r = list_json_response(["{}"], headers={"X-DB-Time-Ms": "1.50"}) + assert r.media_type == "application/json" + assert r.headers["content-type"] == "application/json" + assert r.headers["x-db-time-ms"] == "1.50" + assert r.headers["content-length"] == str(len(r.body)) + + +def test_row_json_select_key_order(): + assert ROW_COLUMNS == "row_type, depth, sort_order, data" + assert row_json_select() == ( + "SELECT json_build_object('row_type', row_type, 'depth', depth, " + "'data', data, 'sort_order', sort_order)::text" + ) + assert row_json_select(row_type="'INPUT'::VARCHAR", depth="3").startswith( + "SELECT json_build_object('row_type', 'INPUT'::VARCHAR, 'depth', 3, " + ) +``` + +- [ ] **Step 2: [замок] Run — FAIL** `cd api && DATABASE_URL=$DBURL $PY/pytest tests/unit/test_json_list_response.py -q -p no:cacheprovider` → `ModuleNotFoundError: src.api.v1.json_response`. + +- [ ] **Step 3: Implement** + +`api/src/api/v1/json_response.py`: +```python +"""Ответ тяжёлых GET из готовых JSON-строк Postgres (AURORA-1848, design D3).""" +from fastapi import Response + + +def list_json_response(rows: list[str], headers: dict[str, str] | None = None) -> Response: + """Обёртка как у BaseListResponse при успехе; rows — уже сериализованные строки ответа.""" + body = b"".join(( + b'{"success":true,"message":null,"result":[', + ",".join(rows).encode(), + b'],"count":', + str(len(rows)).encode(), + b"}", + )) + return Response(content=body, media_type="application/json", headers=headers) +``` + +`api/src/repository/row_json.py`: +```python +"""Строка ответа тяжёлого GET как текст JSON из Postgres (AURORA-1848, design D1).""" +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +ROW_COLUMNS = "row_type, depth, sort_order, data" + + +def row_json_select(row_type: str = "row_type", depth: str = "depth") -> str: + """json (не jsonb) сохраняет порядок ключей; ::text обходит кодек json.loads у asyncpg.""" + return ( + f"SELECT json_build_object('row_type', {row_type}, 'depth', {depth}, " + f"'data', data, 'sort_order', sort_order)::text" + ) + + +async def fetch_row_texts(db: AsyncSession, sql: str, params: dict) -> list[str]: + return list((await db.execute(text(sql), params)).scalars().all()) +``` + +- [ ] **Step 4: [замок] Run — PASS** (та же команда). + +- [ ] **Step 5: Commit** `git add api/src/api/v1/json_response.py api/src/repository/row_json.py api/tests/unit/test_json_list_response.py` → `perf(api): хелпер ответа из готовых JSON-строк и SQL-выражение строки` + Co-Authored-By. + +--- + +### Task 3: GET листа формы — готовый JSON + +**Исполнитель:** haiku (код готов в плане). + +**Files:** +- Modify: `api/src/repository/sheet_repository.py:33-68` (`get` → вынос `_view_source`, новый `get_json`) +- Modify: `api/src/services/sheet_service.py:23-36` (новый `get_json` рядом с `get`) +- Modify: `api/src/api/v1/forms.py:150-202` (`get_sheet`) +- Test: `api/tests/integration/test_heavy_get_json.py` + +**Interfaces:** +- Consumes: `row_json_select`, `fetch_row_texts`, `ROW_COLUMNS` (Task 2), `list_json_response` (Task 2), помощники `old_rows/expected_sheet/get_ok/ADMIN` (Task 1). +- Produces: + - `SheetRepository._view_source(self, form_id, sheet, direction, sections) -> tuple[str, dict]` — валидация (`SheetValidationError`) + FROM-выражение и параметры + - `SheetRepository.get_json(self, form_id: int, sheet: str, direction: str | None, sections: list[str] | None = None) -> list[str]` + - `SheetService.get_json(self, sheet: str, direction: str | None, sections: list[str] | None = None, form_id: int | None = None) -> list[str]` + - Task 13 добавит к обоим `get_json` параметр `strip_nulls: bool = False`. + +- [ ] **Step 1: Write the failing tests** (дописать в `test_heavy_get_json.py`; импорты — в шапку модуля) + +```python +import json + +from sqlalchemy import text + +from src.repository.sheet_repository import SheetRepository + +SHEET_CASES = [ + (1, "AHR", "Support", None), # FORM_1, лист с direction + (4, "AHR", None, None), # FORM_2, без direction + (4, "AHR", None, ["plan", "q1"]), # FORM_2, с sections +] + + +def _sheet_params(direction, sections): + params = {} + if direction: + params["direction"] = direction + if sections: + params["sections"] = ",".join(sections) + return params + + +@pytest.mark.parametrize("form_id,sheet,direction,sections", SHEET_CASES) +def test_sheet_matches_old_path(client, db_session, admin_tokens, auth_headers, form_id, sheet, direction, sections): + result = get_ok(client, f"/api/v1/form/{form_id}/sheet/{sheet}", auth_headers(admin_tokens), _sheet_params(direction, sections)) + old = old_rows(client, lambda: SheetRepository(db_session).get(form_id=form_id, sheet=sheet, direction=direction, sections=sections)) + assert result + assert result[0]["row_type"] == "META" and result[0]["depth"] == -1 + assert result == expected_sheet(old) + + +TRICKY = 'Кавычка " и \\ слэш\nперенос\tтаб' + + +def test_sheet_text_escaping_matches_old_path(client, db_session, admin_tokens, auth_headers): + async def _set(): + await db_session.execute( + text("UPDATE v3.budget_line SET justification = :t WHERE id = (SELECT min(id) FROM v3.budget_line WHERE budget_form_id = 4)"), + {"t": TRICKY}, + ) + client.portal.call(_set) + result = get_ok(client, "/api/v1/form/4/sheet/AHR", auth_headers(admin_tokens)) + old = old_rows(client, lambda: SheetRepository(db_session).get(form_id=4, sheet="AHR", direction=None)) + assert result == expected_sheet(old) + assert any(TRICKY in json.dumps(r["data"], ensure_ascii=False) for r in result) + + +def test_sheet_get_does_not_use_old_path(client, admin_tokens, auth_headers, monkeypatch): + async def boom(*args, **kwargs): + raise AssertionError("GET листа не должен вызывать SheetRepository.get") + monkeypatch.setattr(SheetRepository, "get", boom) + get_ok(client, "/api/v1/form/4/sheet/AHR", auth_headers(admin_tokens)) +``` + +Если `justification` не попадает в `data` листа FORM_2/AHR (последний assert падает и на старом пути) — взять колонку `budget_line.name`; проверка присутствия `TRICKY` в ответе обязательна. Замену отметить в отчёте задачи. + +- [ ] **Step 2: [замок] Run — FAIL** `cd api && DATABASE_URL=$DBURL $PY/pytest tests/integration/test_heavy_get_json.py -q -p no:cacheprovider -W ignore`. Expected: `test_sheet_get_does_not_use_old_path` падает с `AssertionError: GET листа не должен…`; `test_sheet_matches_old_path`/`escaping` проходят уже на старом коде (они — страховка эквивалентности). + +- [ ] **Step 3: Repository.** Вынести из `get` валидацию и выбор `func_query` в `_view_source`; `get` и `get_json`: + +```python + def _view_source(self, form_id, sheet, direction, sections) -> tuple[str, dict]: + """Валидация и FROM-выражение v3.v_form_view: общие для get (выгрузка) и get_json (GET листа).""" + # проверка any([...]) -> SheetValidationError и выбор func_query — перенести из get без изменений + ... + return func_query, {"form_id": form_id, "sheet": sheet, "direction": direction, "sections": sections} + + async def get(self, form_id: int, sheet: str, direction: str | None, sections: list[str] | None = None) -> list[tuple]: + func_query, params = self._view_source(form_id, sheet, direction, sections) + return (await self.db.execute(text(f"SELECT {ROW_COLUMNS} FROM {func_query}"), params)).all() + + async def get_json(self, form_id: int, sheet: str, direction: str | None, sections: list[str] | None = None) -> list[str]: + func_query, params = self._view_source(form_id, sheet, direction, sections) + return await fetch_row_texts(self.db, f"{row_json_select()} FROM {func_query}", params) +``` + +- [ ] **Step 4: Service.** `SheetService.get_json(...)` — копия `get`, вызывает `self.sheet_repo.get_json(form_id=..., sheet=..., direction=..., sections=...)`. + +- [ ] **Step 5: Router `get_sheet`.** + - декоратор: `@router.get("/{form_id}/sheet/{sheet}", response_model=BaseListResponse[SheetResponse])`; аннотация `-> Response`; параметр `response: Response` удалить; + - `result = await sheet_service.get(...)` → `rows = await sheet_service.get_json(...)` (аргументы те же); + - строку `response.headers["X-DB-Time-Ms"] = ...` удалить; `extra`/`logger.info` без изменений; + - возврат: `return list_json_response(rows, headers={"X-DB-Time-Ms": f"{db_ms:.2f}"})`; + - импорт `from src.api.v1.json_response import list_json_response`. `_rows_to_sheet_response` остаётся (её используют PATCH/POST/DELETE). + +- [ ] **Step 6: [замок] Run — PASS** модуль `test_heavy_get_json.py` + `tests/integration/test_forms_api_smoke.py` + `tests/integration/test_db_functions_integration.py`. + +- [ ] **Step 7: Commit** `git add api/src/repository/sheet_repository.py api/src/services/sheet_service.py api/src/api/v1/forms.py api/tests/integration/test_heavy_get_json.py` → `perf(api): GET листа отдаёт готовый JSON из Postgres без SheetResponse на строку` + Co-Authored-By. + +--- + +### Task 4: GET сводов 1 и 2 — готовый JSON + +**Исполнитель:** haiku (код готов в плане). + +**Files:** +- Modify: `api/src/repository/summary_repository.py:11-76` (вынос FROM-частей, `get_svod1_json`, `get_svod2_json`) +- Modify: `api/src/services/summary_service.py:15-53` (`get_svod1_json`, `get_svod2_json`) +- Modify: `api/src/api/v1/summary.py` (оба GET; удалить `_rows_to_sheet_response`) +- Test: `api/tests/integration/test_heavy_get_json.py` + +**Interfaces:** +- Consumes: Task 1, Task 2. +- Produces: + - `SummaryRepository._svod1_source() -> str`, `SummaryRepository._svod2_source(sheet: str) -> str` (staticmethod, строка `FROM ...`) + - `SummaryRepository.get_svod1_json(self, year: int, sheet: str, org_ids: list[int] | None = None) -> list[str]` + - `SummaryRepository.get_svod2_json(self, year: int, sheet: str, org_ids=None, offset=None, limit=None, sort_by="org_name", sort_direction="asc") -> list[str]` + - `SummaryService.get_svod1_json(self, year, sheet, user, org_ids=None) -> list[str]`, `SummaryService.get_svod2_json(self, year, sheet, user, org_ids=None, offset=None, limit=None, sort_by="org_name", sort_direction="asc") -> list[str]` — те же проверки, что у `*_rows` (`_allowed_org_ids`, ранний `[]`). + - Task 13 добавит `strip_nulls: bool = False` ко всем четырём. + +- [ ] **Step 1: Write the failing tests** + +```python +from src.repository.summary_repository import SummaryRepository +from src.services.summary_service import SummaryService + + +@pytest.mark.parametrize("sheet", ["FORM_1", "FORM_2", "FORM_3", "FORM_4", "MAIN"]) +def test_svod1_matches_old_path(client, db_session, admin_tokens, auth_headers, sheet): + result = get_ok(client, f"/api/v1/svod/1/{sheet}/{YEAR}", auth_headers(admin_tokens)) + old = old_rows(client, lambda: SummaryRepository(db_session).get_svod1_rows(year=YEAR, sheet=sheet, org_ids=None)) + assert result == expected_sheet(old) + + +def test_svod1_org_ids_matches_old_path(client, db_session, admin_tokens, auth_headers): + result = get_ok(client, f"/api/v1/svod/1/FORM_1/{YEAR}", auth_headers(admin_tokens), {"org_ids": "1,2,3"}) + old = old_rows(client, lambda: SummaryRepository(db_session).get_svod1_rows(year=YEAR, sheet="FORM_1", org_ids=[1, 2, 3])) + assert result + assert result == expected_sheet(old) + + +SVOD2_CASES = [ + ("GO", {}, {}), + ("RF", {}, {}), + ("GO", {"org_ids": "1,2"}, {"org_ids": [1, 2]}), + ("GO", {"offset": 1, "limit": 2}, {"offset": 1, "limit": 2}), + ("RF", {"sort_by": "section_code", "sort_direction": "desc"}, {"sort_by": "section_code", "sort_direction": "desc"}), +] + + +@pytest.mark.parametrize("sheet,query,repo_kwargs", SVOD2_CASES) +def test_svod2_matches_old_path(client, db_session, admin_tokens, auth_headers, sheet, query, repo_kwargs): + result = get_ok(client, f"/api/v1/svod/2/{sheet}/{YEAR}", auth_headers(admin_tokens), query) + old = old_rows(client, lambda: SummaryRepository(db_session).get_svod2_rows(year=YEAR, sheet=sheet, **repo_kwargs)) + assert result == expected_sheet(old) + + +def test_svod2_fixture_not_empty(client, admin_tokens, auth_headers): + """Иначе сравнения свода 2 выше проходят на пустых списках.""" + go = get_ok(client, f"/api/v1/svod/2/GO/{YEAR}", auth_headers(admin_tokens)) + rf = get_ok(client, f"/api/v1/svod/2/RF/{YEAR}", auth_headers(admin_tokens)) + assert go or rf + + +def test_svod2_isp_matches_service_old_path(client, db_session, isp_tokens, auth_headers): + result = get_ok(client, f"/api/v1/svod/2/GO/{YEAR}", auth_headers(isp_tokens)) + old = old_rows(client, lambda: SummaryService(db_session).get_svod2_rows(year=YEAR, sheet="GO", user=ISP)) + assert result == expected_sheet(old) + + +def test_svod2_foreign_org_ids_empty_body(client, isp_tokens, auth_headers): + r = client.get(f"/api/v1/svod/2/GO/{YEAR}", headers=auth_headers(isp_tokens), params={"org_ids": "999999"}) + assert r.status_code == 200 + assert r.json() == {"success": True, "message": None, "result": [], "count": 0} + assert float(r.headers["x-db-time-ms"]) >= 0 + + +@pytest.mark.parametrize("url,method", [ + (f"/api/v1/svod/1/MAIN/{YEAR}", "get_svod1_rows"), + (f"/api/v1/svod/2/GO/{YEAR}", "get_svod2_rows"), +]) +def test_svod_get_does_not_use_old_path(client, admin_tokens, auth_headers, monkeypatch, url, method): + async def boom(*args, **kwargs): + raise AssertionError(f"GET свода не должен вызывать SummaryRepository.{method}") + monkeypatch.setattr(SummaryRepository, method, boom) + get_ok(client, url, auth_headers(admin_tokens)) +``` + +Если `test_svod2_fixture_not_empty` падает и на старом коде — до сравнения добавить строку через `POST /api/v1/form/4/sheet/AHR/line` (как `test_forms_add_line` в `test_forms_api_smoke.py`) в фикстуре модуля и отметить в отчёте задачи. + +- [ ] **Step 2: [замок] Run — FAIL**: падают оба `test_svod_get_does_not_use_old_path`; сравнения проходят на старом коде. + +- [ ] **Step 3: Repository.** `_svod1_source()` возвращает `"FROM v3.v_summary_view(CAST(:year AS INT), CAST(:sheet AS TEXT), CAST(:org_ids AS INT[]))"`; `_svod2_source(sheet)` — словарь `{"GO": "v3.v_svod2_go", "RF": "v3.v_svod2_rf"}[sheet]` и прежний список из шести `CAST(...)`. Старые методы: + - `get_svod1_rows`: `text(f"SELECT {ROW_COLUMNS} {self._svod1_source()}")`, параметры прежние; + - `get_svod2_rows`: `text(f"SELECT 'INPUT'::VARCHAR AS row_type, 3 AS depth, sort_order, data {self._svod2_source(sheet)}")`, параметры прежние. + Новые методы — те же параметры, SQL `f"{row_json_select()} {self._svod1_source()}"` и для свода 2 `select = row_json_select(row_type="'INPUT'::VARCHAR", depth="3")` → `f"{select} {self._svod2_source(sheet)}"` (константы те же, что в старом SELECT), выполнение через `fetch_row_texts`. Словарь параметров свода 2 строится в одном приватном `_svod2_params(...) -> dict` для обоих методов. + +- [ ] **Step 4: Service.** `get_svod1_json`/`get_svod2_json` — копии `get_svod1_rows`/`get_svod2_rows`, вызывают `*_json` репозитория. + +- [ ] **Step 5: Routers `get_summary`, `get_summary2`** — по образцу Task 3 Step 5: `response_model=BaseListResponse[SheetResponse]` в декоратор, `-> Response`, убрать параметр `response`, вызвать `*_json`, вернуть `list_json_response(rows, headers={"X-DB-Time-Ms": f"{db_ms:.2f}"})`. Удалить `_rows_to_sheet_response` из `summary.py` (импорты `BaseListResponse`, `SheetResponse` остаются для декоратора; `Response` импортирован уже). + +- [ ] **Step 6: [замок] Run — PASS** `test_heavy_get_json.py` + `tests/integration/test_summary_api_smoke.py`. + +- [ ] **Step 7: Commit** (`summary_repository.py`, `summary_service.py`, `summary.py`, тест) → `perf(api): GET сводов 1 и 2 отдают готовый JSON из Postgres` + Co-Authored-By. + +--- + +### Task 5: GET отчётов и сводных проектов — готовый JSON (отдельный коммит) + +**Исполнитель:** sonnet (интеграция: шесть эндпоинтов, разные проверки доступа, характеризующие тесты ошибок). + +**Files:** +- Modify: `api/src/repository/project_repository.py:358-472` +- Modify: `api/src/services/project_service.py:97-196` +- Modify: `api/src/api/v1/projects.py:117-338` (шесть GET и `_get_projects_summary_response`) +- Test: `api/tests/integration/test_heavy_get_json.py` + +**Interfaces:** +- Consumes: Task 1, Task 2. +- Produces (репозиторий, все `-> list[str]`, параметры как у пары `*_rows`): `get_report_json(report_id, sections=None, user_id=None)`, `get_rf_rollup_json(branch_id, year, sections=None)`, `get_project_summary_json(project_id)`, `get_plan_summary_json(year, branch_ids, project_type=None)`, `get_fact_summary_json(...)`, `get_detail_summary_json(...)`; FROM-части выносятся в приватные функции, как в Task 3/4 (у `_get_project_summary_rows` — общий `_project_summary_source(function_name) -> str`). +- Сервис: `get_report_json`, `get_rf_rollup_json`, `get_project_summary_json`, `get_plan_summary_json`, `get_fact_summary_json`, `get_detail_summary_json` — те же проверки, что у `*_rows` (`resolve_report_id` + `ValidationException("Отчёт не найден")`, `_allowed_org_unit_ids` + `AccessDeniedException`, `get_instance` + `ValidationException("Проект не найден")`, `_allowed_summary_branch_ids` + ранний `[]`). +- Роутер: `_get_projects_summary_response` заменяется синхронной `_projects_summary_response(*, year, branch_id, project_type, handler_name, rows: list[str], t0: float, current_user) -> Response`. Без `strip_nulls` и в фазе B (D4). + +- [ ] **Step 1: Write the tests.** В модуль добавить помощники `_create_project(client, headers, year=YEAR, branch_id=1) -> int` и `_add_line(client, headers, project_id, year=YEAR) -> int | None` — копии `_create_project`/`_add_line` из `test_projects_api_smoke.py` (без `pytest.skip`: `None`, если строку добавить не удалось). Тесты: + - `test_project_report_matches_old_path[sections]` для `sections in (None, "q1,year")`: проект + строка, `GET /api/v1/projects/{pid}/report/{YEAR}/LIMIT`; эталон `ProjectService(db_session).get_report_rows(project_id=pid, year=YEAR, report_type="LIMIT", sections=, user=ADMIN)`; `result == old` (без `expected_sheet`: отчёты не чистятся и в B). + - `test_rf_rollup_matches_old_path`: `GET /api/v1/rf-rollup/1/{YEAR}` ↔ `get_rf_rollup_rows(branch_id=1, year=YEAR, sections=None, user=ADMIN)`. + - `test_project_summary_matches_old_path`: `GET /api/v1/projects/{pid}/summary` ↔ `get_project_summary_rows(project_id=pid, user=ADMIN)`. + - `test_projects_summaries_match_old_path[kind]` для `plan|fact|detail`: `GET /api/v1/projects/{kind}-summary?year={YEAR}` ↔ `get_{kind}_summary_rows(year=YEAR, branch_ids=None, user=ADMIN)`. + - `test_projects_get_does_not_use_old_path[method,url]` — monkeypatch `ProjectRepository.` на `boom` для каждого из шести `*_rows`, GET → 200. + - Характеризующие тесты ошибок (пишутся и прогоняются **до** правки роутеров, должны быть зелёными и до, и после): `test_project_report_not_found_unchanged` — `GET .../report/2099/LIMIT` у созданного проекта, assert статуса и `r.json()` равного зафиксированному на старом коде литералу; `test_rf_rollup_foreign_branch_unchanged` — `isp_tokens`, ветка не из оргединиц `isp1` (подобрать в `fixture.sql`), так же статус и тело литералом. + +- [ ] **Step 2: [замок] Run — характеризующие тесты PASS, `test_projects_get_does_not_use_old_path` FAIL** на старом коде. + +- [ ] **Step 3: Implement** репозиторий → сервис → роутеры по образцу Task 3/4 (декоратор с `response_model`, `-> Response`, без параметра `response`, `list_json_response(rows, headers={"X-DB-Time-Ms": ...})`, лог `db_time` с прежними полями, включая `handler` `get_project_report` у `get_rf_rollup` как сейчас). `_rows_to_payload` остаётся для правок FORM_3. + +- [ ] **Step 4: [замок] Run — PASS** `test_heavy_get_json.py` + `tests/integration/test_projects_api_smoke.py`. + +- [ ] **Step 5: Commit отдельным коммитом** (файлы проектов и тест) → `perf(api): отчёты и сводные проектов отдают готовый JSON из Postgres` + Co-Authored-By. + +--- + +### Task 6: `api_split.py` — размер на проводе + +**Исполнитель:** haiku (код готов в плане). + +**Files:** +- Modify: `perf/analysis/api_split.py` (вынос цикла замера в `measure`, поле `wire_bytes`, колонка в `.md`, docstring) +- Test: `perf/tests/test_api_split.py` + +**Interfaces:** +- Produces: `measure(c: httpx.Client, path: str, token: str, repeats: int) -> dict` с ключами `rows, total_ms, db_ms, bytes, wire_bytes, content_encoding` (медианы, как сейчас; первый запрос — прогрев). + +- [ ] **Step 1: Write the failing test** + +```python +import gzip +import json +import sys +from pathlib import Path + +import httpx + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "analysis")) + +from api_split import measure # noqa: E402 + + +def test_measure_reports_wire_and_decoded_bytes(): + raw = json.dumps({"result": [{"a": 1}] * 50}).encode() + gz = gzip.compress(raw) + + def handler(request): + assert request.headers["authorization"] == "Bearer tok" + return httpx.Response(200, headers={"content-encoding": "gzip", "x-db-time-ms": "12.50"}, content=gz) + + with httpx.Client(transport=httpx.MockTransport(handler), base_url="http://t") as c: + m = measure(c, "/x", "tok", repeats=3) + assert m["rows"] == 50 + assert m["bytes"] == len(raw) + assert m["wire_bytes"] == len(gz) + assert m["db_ms"] == 12.5 + assert m["content_encoding"] == "gzip" +``` + +- [ ] **Step 2: [замок] Run — FAIL** `cd perf && $PY/python -m pytest -q -p no:cacheprovider tests/test_api_split.py` → `ImportError: cannot import name 'measure'`. + +- [ ] **Step 3: Implement.** Тело цикла `for i in range(a.repeats + 1)` из `main` переносится в `measure` без изменения логики; добавляется `wire = r.num_bytes_downloaded` на тех же итерациях, что `size`, и `"wire_bytes": wire` в результат. `main` собирает `row` из `measure(...)` плюс `role`, `request`, `path` и прежние производные поля. В `.md` — колонка «на проводе, МБ» (`wire_bytes / 1e6`, `:.2f`) после «размер, МБ». Docstring: ссылки `forms.py:184, summary.py:74,126, projects.py:128` заменить на места установки `X-DB-Time-Ms` после Task 3–5 и упомянуть, что остаток после A — склейка строк, gzip, передача и распаковка (без pydantic). + +- [ ] **Step 4: [замок] Run — PASS** + весь `perf/tests -m "not stand"`. + +- [ ] **Step 5: Commit** `perf/analysis/api_split.py perf/tests/test_api_split.py` → `perf(analysis): api_split — размер ответа на проводе (wire_bytes)` + Co-Authored-By. + +--- + +### Task 7: `compare_responses.py` — сравнение ответов до и после + +**Исполнитель:** sonnet (интеграция: обход манифеста, роли, CLI). + +**Files:** +- Create: `perf/analysis/compare_responses.py` +- Test: `perf/tests/test_compare_responses.py` + +**Interfaces:** +- Produces: + - `build_requests(man: dict) -> list[tuple[str, str, str]]` — `(role, name, path_with_query)` + - `strip_nulls(value)` — рекурсивно удаляет поля объектов со значением `None`; элементы списков `None` остаются, `{}` остаётся (семантика `jsonb_strip_nulls` PG 15) + - `strip_response(body: dict) -> dict` — копия тела, где `strip_nulls` применён только к `row["data"]` каждой строки `result`; `success`, `message`, `count`, `row_type`, `depth`, `sort_order` не трогаются + - `is_stripped(name: str) -> bool` — `True` для листов и сводов (`name.startswith(("sheet-", "svod1-", "svod2-"))`) + - `first_diff(a, b, path: str = "$") -> str | None` — путь до первого отличия (`$.result[3].data.q1.m1`) с обоими значениями, `None` при равенстве + - `compare(before: Path, after: Path, mode: str) -> tuple[int, list[str]]` — число совпавших и список расхождений + - CLI: `dump --scale N --out DIR [--api URL]`, `compare BEFORE AFTER --mode exact|strip-nulls`; код возврата 1 при расхождении. + +Правила `build_requests` (манифест `perf/results/manifest-s{scale}.json`, ключи как в `perfkit/manifest.py`): +- листы: каждый `sheets[i]`, роль `rf` при `owner == "rf"`, иначе `dfip`; путь `/api/v1/form/{form_id}/sheet/{sheet}` + `?direction=`, если `direction` не `None`; имя `sheet-{form_type}-{sheet}-{direction or 'none'}-{form_id}`; плюс один вариант FORM_2/AHR (`owner == "rf"`) с `sections=plan,q1`, имя с суффиксом `-sections`; +- год сводов — `year` листа FORM_2/AHR роли `rf` (как в `api_split.py`); `svod1-{FORM_1..FORM_4,MAIN}` и `svod2-{GO,RF}` под `dfip`; +- отчёты: каждый `project_reports[i]` под `rf` (`report-{project_id}-{year}-{report_type}`); по каждому уникальному `project_id` — `/api/v1/projects/{id}/summary` под `rf` (`project-summary-{id}`); `plan|fact|detail-summary?year=` под `dfip` (`projects-{kind}-summary`); +- `rf-rollup` не снимается: в манифесте нет `branch_id` (он покрыт интеграционным тестом Task 5). + +`dump`: логин каждой роли (`man["users"][role]`), GET каждого запроса, тело (`r.content`, распакованное) — в `/.json.gz`, статусы — в `/index.json` (`{name: status}`). `compare`: статусы равны; для `mode == "strip-nulls"` и `is_stripped(name)` сравнивается `json.loads(after) == strip_response(json.loads(before))`, иначе точное равенство; отсутствующий в `after` файл — расхождение. Печать: `совпало N из M`, затем по строке на расхождение. + +- [ ] **Step 1: Write the failing tests** (`sys.path.insert(...)/"analysis"` как в `test_analysis.py`): + - `test_strip_nulls_pg_semantics`: `strip_nulls({"a": None, "b": {"c": None}, "d": [None, {"e": None, "f": 1}], "g": {}})` == `{"b": {}, "d": [None, {"f": 1}], "g": {}}`. + - `test_strip_response_keeps_wrapper_and_sort_order`: тело `{"success": True, "message": None, "result": [{"row_type": "INPUT", "depth": 3, "sort_order": None, "data": {"x": None, "y": 1}}], "count": 1}` → `message` и `sort_order` остаются `None`, `data == {"y": 1}`. + - `test_first_diff_path`: `first_diff({"result": [{"data": {"q1": {"m1": 1}}}]}, {"result": [{"data": {"q1": {"m1": 2}}}]})` начинается с `"$.result[0].data.q1.m1"`; для равных — `None`; разная длина списка и разный набор ключей тоже дают путь. + - `test_compare_exact_and_strip_modes(tmp_path)`: в `before/` и `after/` по `sheet-FORM_2-AHR-none-1.json.gz` (after без null-поля) и `report-5-2026-LIMIT.json.gz` (after тоже без null-поля), `index.json` с 200: `exact` → 0 совпало, 2 расхождения; `strip-nulls` → 1 совпало (лист), 1 расхождение (отчёт сравнивается точно). + - `test_compare_status_mismatch(tmp_path)`: одинаковые тела, статусы 200/500 → расхождение. + - `test_build_requests_minimal_manifest`: манифест из двух листов (`rf` FORM_2/AHR без direction, `ssp` FORM_1/AHR `Support`) и одного отчёта → есть `sheet-FORM_2-AHR-none-` под `rf`, его `-sections` вариант, `sheet-FORM_1-AHR-Support-` под `dfip` с `?direction=Support`, пять `svod1-*`, два `svod2-*`, `report-*`, `project-summary-*`, три `projects-*-summary`; нет `rf-rollup`. + +- [ ] **Step 2: [замок] Run — FAIL** `cd perf && $PY/python -m pytest -q -p no:cacheprovider tests/test_compare_responses.py` → `ModuleNotFoundError`. + +- [ ] **Step 3: Implement** по Interfaces; docstring модуля — порядок использования из D7.1 (`dump` на базовом коммите → `dump` на A → `compare --mode exact` → `dump` на B → `compare --mode strip-nulls` с дампом «до»). Стенд не запускать. + +- [ ] **Step 4: [замок] Run — PASS** + весь `perf/tests -m "not stand"`. + +- [ ] **Step 5: Commit** → `perf(analysis): compare_responses — снимок и сравнение ответов тяжёлых GET` + Co-Authored-By. + +--- + +### Task 8: Полный прогон фазы A, проверка инвариантов, передача на PG15 + +**Исполнитель:** контроллер (команды; правки — новой задачей-исправлением у haiku/sonnet). + +- [ ] **Step 1: [замок] Полный бэк** `cd api && DATABASE_URL=$DBURL $PY/pytest -q -p no:cacheprovider -W ignore` → только 3 известных падения. +- [ ] **Step 2: [замок] perf** `cd perf && $PY/python -m pytest -q -p no:cacheprovider -m "not stand" tests` → PASS. +- [ ] **Step 3: Инварианты** `git diff origin/test -- api/src/services/export_service/ api/src/api/v1/websocket.py api/alembic/` → пусто. `git diff origin/test -- api/src/repository/` — у старых методов меняется только вынос FROM-части, текст SQL эквивалентен. Расхождение — находка `O-n`, исправление отдельной задачей. +- [ ] **Step 4: Передать главному контроллеру на проверку PG15.** В ledger и в отчёте SDD-контроллера: SHA ветки A, новый SQL (`json_build_object(...)::text` для `v_form_view`, `v_summary_view`, `v_svod2_go/rf`, шести функций проектов), просьба проверить на `dfip-pg15:55434` (PG 15.12). Не блокирует открытие PR A; блокирует merge — пункт чек-листа PR A «PG15 — проверяет контроллер». + +--- + +### Task 9: Финальное ревью ветки A + +**Исполнитель:** opus (`superpowers:requesting-code-review`, ревью всей ветки `git diff origin/test...HEAD`). + +- [ ] **Step 1:** Ревьюеру передать: спеку (`openspec/changes/aurora-1848-sheet-json/`), этот план, раздел Global Constraints (включая замок), Review Focus; диапазон `origin/test...feat/233-aurora-1848-sheet-json-no-pydantic`. +- [ ] **Step 2:** Находки `C-n`/`I-n`/`M-n` → ledger. Critical и Important — исправить (задача-исправление: haiku, если правка однозначна, иначе sonnet), [замок] перепрогон затронутых тестов, коммит `fix(...)`. Minor — `fixed`, `deferred` или `rejected <причина>`. +- [ ] **Step 3:** Обновить таблицу `## Findings` в ledger; рулинги — в `design.md` → `## Decisions made without discussion` (коммит `docs(openspec): ...`). + +--- + +### Task 10: PR A + +**Исполнитель:** контроллер. + +- [ ] **Step 1:** `git push -u origin feat/233-aurora-1848-sheet-json-no-pydantic`. +- [ ] **Step 2:** `tea pulls create --base test --head feat/233-aurora-1848-sheet-json-no-pydantic --title "AURORA-1848: тяжёлые GET — готовый JSON из Postgres без pydantic-модели на строку (A)" --description "<описание>"`. Описание: + - `Part of #233` (issue закрывает PR B), Jira AURORA-1848; + - что сделано (лист, своды 1 и 2, отдельный коммит — шесть GET проектов; `api_split.py` `wire_bytes`; `compare_responses.py`); ответ по смыслу тот же, байты могут отличаться (пробелы, запись чисел); + - тесты: unit, интеграционные эквивалентности со старым путём на 9 эндпоинтах, OpenAPI, полный pytest (3 известных падения `test`); + - чек-лист до merge: сравнение ответов `compare --mode exact` на стенде; PG15 (контроллер); `A ≤ 1,05 × до` по `api_split.py` — справочно, не условие мержа; + - строка «Замер до/после — добавит контроллер после прогона на стенде»; + - «Rulings I made» (`R-n`), таблица находок (все маркеры со статусами); + - связь: AURORA-1846 (#232) — общий хунк `db_session` в conftest; PR B стеком на этой ветке; + - последняя строка `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. +- [ ] **Step 3:** Номер PR A — в ledger. Сразу переходить к фазе B, без остановки. + +--- + +# Фаза B — без null-полей в `data` листа и сводов + фронт + +Ветка `feat/233-aurora-1848-sheet-json-no-pydantic-b` стеком на ветке A, worktree `/Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/.claude/worktrees/aurora-1848-b`. + +### Task 11: Ветка B, ветка #223 внутри B, тесты `updateCells` на отсутствующие ключи + +**Исполнитель:** haiku (код готов в плане). + +**Files:** +- Modify: `web/src/components/RealtimeTable/utils/cellUtils.test.js` (файл приходит из #223) +- Modify (только если тест падает на версии #223): `web/src/components/RealtimeTable/utils/cellUtils.js` + +**Interfaces:** +- Consumes: `updateCells(data, newRows)` и `deepFreeze` из `cellUtils.test.js` ветки #223 (`origin/feat/212-aurora-1838-ws-immutable-update`). + +- [ ] **Step 1: Worktree B** (из основного checkout `/Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing`): + `git fetch origin && git worktree add .claude/worktrees/aurora-1848-b -b feat/233-aurora-1848-sheet-json-no-pydantic-b feat/233-aurora-1848-sheet-json-no-pydantic`; + `ln -s /Users/andrey.epifancev/projects/fomichev_claude/DFiP_Budget_planing/web/node_modules .claude/worktrees/aurora-1848-b/web/node_modules`; + **[замок]** `cd .claude/worktrees/aurora-1848-b && codegraph init`. +- [ ] **Step 2: Влить #223** `git merge --no-ff origin/feat/212-aurora-1838-ws-immutable-update -m "Merge AURORA-1838 (#223) в ветку B: updateCells вставляет отсутствующие ключи" -m "Co-Authored-By: Claude Opus 5.5 "`. Конфликтов быть не должно (A не трогает `web/`). После merge #223 и A в `test` ветка B перебазируется на `test`, и коммиты #223 уйдут из диффа (пишется в PR B). +- [ ] **Step 3: Тесты** — дописать в конец `cellUtils.test.js`: + +```js +const row = (data) => ({ row_type: 'INPUT', depth: 3, sort_order: 1, data }); + +test('AURORA-1848: нет блока q1 — блок с полем вставляется, остальное сохраняется', () => { + const data = deepFreeze([row({ line_id: 7, header: { name: 'X' } })]); + const out = updateCells(data, [['INPUT', 3, 1, { q1: { m1: 5 } }]]); + assert.equal(out[0].data.q1.m1, 5); + assert.deepEqual(out[0].data.header, { name: 'X' }); + assert.equal(out[0].data.line_id, 7); +}); + +test('AURORA-1848: блок q1 без поля m1 — поле вставляется, остальные поля q1 сохраняются', () => { + const data = deepFreeze([row({ line_id: 7, q1: { m2: 3 } })]); + const out = updateCells(data, [['INPUT', 3, 1, { q1: { m1: 5 } }]]); + assert.deepEqual(out[0].data.q1, { m2: 3, m1: 5 }); +}); + +test('AURORA-1848: очистка — null записывается явно', () => { + const data = deepFreeze([row({ line_id: 7, q1: { m1: 5 } })]); + const out = updateCells(data, [['INPUT', 3, 1, { q1: { m1: null } }]]); + assert.ok('m1' in out[0].data.q1); + assert.equal(out[0].data.q1.m1, null); +}); + +test('AURORA-1848: отсутствующий скалярный ключ верхнего уровня data вставляется', () => { + const data = deepFreeze([row({ header: { name: 'X' } })]); + const out = updateCells(data, [['INPUT', 3, 1, { line_id: 9 }]]); + assert.equal(out[0].data.line_id, 9); +}); +``` + +- [ ] **Step 4: [замок] Красный шаг на версии `test`:** `git show origin/test:web/src/components/RealtimeTable/utils/cellUtils.js > web/src/components/RealtimeTable/utils/cellUtils.js && (cd web && node --test src/components/RealtimeTable/utils/cellUtils.test.js)` → новые тесты 1, 2 и 4 FAIL (на `test` правка по отсутствующему ключу теряется). Вернуть файл: `git checkout HEAD -- web/src/components/RealtimeTable/utils/cellUtils.js`. +- [ ] **Step 5: [замок] Зелёный шаг:** `cd web && node --test src/components/RealtimeTable/utils/*.test.js` → PASS. Если какой-то из четырёх тестов падает на версии #223 — минимальная правка `cellUtils.js` в B (D5.1), рулинг `R-n` в `design.md`. +- [ ] **Step 6: Commit** `git add web/src/components/RealtimeTable/utils/cellUtils.test.js` (+ `cellUtils.js`, если правился) → `test(realtime): правка соавтора по ключу, которого нет в строке (AURORA-1848)` + Co-Authored-By. + +--- + +### Task 12: Сообщение `cell_updated` всегда содержит `value` + +**Исполнитель:** haiku (код готов в плане). + +**Files:** +- Create: `web/src/components/RealtimeTable/utils/messageUtils.js` +- Test: `web/src/components/RealtimeTable/utils/messageUtils.test.js` +- Modify: `web/src/components/RealtimeTable/contexts/RealtimeContext.js:304-330` (`updateCell`) + +**Interfaces:** +- Produces: `buildCellUpdatedMessage({ lineId, lineIdCode, column, value }) -> { event: 'cell_updated', data: { line_id, line_id_code, column, value } }`, где `value === undefined` → `null`. + +- [ ] **Step 1: Write the failing test** + +```js +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { buildCellUpdatedMessage } from './messageUtils.js'; + +const msg = (value) => buildCellUpdatedMessage({ lineId: 1, lineIdCode: '0.1', column: 'q1.m1', value }); + +test('buildCellUpdatedMessage: undefined → null, ключ value есть после JSON.stringify', () => { + const m = msg(undefined); + assert.deepEqual(m, { event: 'cell_updated', data: { line_id: 1, line_id_code: '0.1', column: 'q1.m1', value: null } }); + assert.match(JSON.stringify(m), /"value":null/); +}); + +test('buildCellUpdatedMessage: прочие значения не меняются', () => { + for (const v of [null, 0, '', '1.5', 42, 'текст']) { + assert.equal(msg(v).data.value, v); + } +}); +``` + +- [ ] **Step 2: [замок] Run — FAIL** `cd web && node --test src/components/RealtimeTable/utils/messageUtils.test.js` → `Cannot find module './messageUtils.js'`. +- [ ] **Step 3: Implement** `messageUtils.js` (одна экспортируемая функция по Interfaces, комментарий: «value всегда присутствует: сервер читает event_data["value"], AURORA-1848 D5.2»). В `RealtimeContext.updateCell` литерал `message` заменить на `buildCellUpdatedMessage({ lineId, lineIdCode: row.id, column: columnKey, value })`; импорт `import { buildCellUpdatedMessage } from '../utils/messageUtils';`. +- [ ] **Step 4: [замок] Run — PASS** все `utils/*.test.js`; `npx biome check src/components/RealtimeTable/utils/messageUtils.js src/components/RealtimeTable/utils/messageUtils.test.js src/components/RealtimeTable/contexts/RealtimeContext.js`. +- [ ] **Step 5: Commit** → `fix(realtime): пустая правка уходит как value: null, а не без ключа (AURORA-1848)` + Co-Authored-By. + +--- + +### Task 13: Бэк — `jsonb_strip_nulls` для листа и сводов + +**Исполнитель:** sonnet (интеграция: смена контракта, правка существующего smoke-теста). + +**Files:** +- Modify: `api/src/repository/row_json.py` (`strip_nulls`) +- Modify: `api/src/repository/sheet_repository.py`, `summary_repository.py` (`*_json` + `strip_nulls`) +- Modify: `api/src/services/sheet_service.py`, `summary_service.py` (проброс `strip_nulls`) +- Modify: `api/src/api/v1/forms.py`, `summary.py` (вызов со `strip_nulls=True`) +- Modify: `api/tests/unit/test_json_list_response.py`, `api/tests/integration/test_heavy_get_json.py` +- Modify (если падает): `api/tests/integration/test_summary_api_smoke.py::test_summary2_sheets_smoke` + +**Interfaces:** +- Consumes: Task 2–5. +- Produces: `row_json_select(row_type="row_type", depth="depth", strip_nulls: bool = False)` — при `True` выражение `data` → `jsonb_strip_nulls(data)`; `strip_nulls: bool = False` последним параметром у `SheetRepository.get_json`, `SummaryRepository.get_svod1_json/get_svod2_json`, `SheetService.get_json`, `SummaryService.get_svod1_json/get_svod2_json`. Методы проектов не меняются. + +- [ ] **Step 1: Write the failing tests** + - unit: `test_row_json_select_strip_nulls` — `row_json_select(strip_nulls=True)` содержит `"'data', jsonb_strip_nulls(data), "` и не содержит `"'data', data,"`; вызов без параметра — прежний текст. + - интеграционные: в `test_heavy_get_json.py` добавить `_strip_nulls(value)` (та же семантика, что `strip_nulls` в Task 7: поля объектов с `None` удаляются рекурсивно, `None` в списках и `{}` остаются) и `_has_null_field(value) -> bool`; тело `expected_sheet` заменить на `[{**r, "data": _strip_nulls(r["data"])} for r in rows]`. Новые тесты: + - `test_sheet_and_svod_have_no_null_fields` (лист 4/AHR, свод 1 MAIN, свод 2 GO): ни у одной строки `_has_null_field(row["data"])`; ключи строки — все четыре всегда; + - `test_project_report_keeps_nulls`: `GET /api/v1/projects/{pid}/report/{YEAR}/LIMIT` нового проекта — хотя бы у одной строки `_has_null_field(row["data"])`; + - `test_patch_cell_null_is_explicit`: `PATCH /api/v1/form/1/sheet/AHR/cell?direction=Support§ions=plan,contract_summary` с `{"line_id": 1, "column": "plan.q1", "value": None}` → 200, в `result` есть строка с `data["plan"]` содержащим ключ `"q1"` со значением `None`. +- [ ] **Step 2: [замок] Run — FAIL**: unit-тест и сравнения листа/сводов (`expected_sheet` теперь чистит null, а ответ ещё с null), `test_sheet_and_svod_have_no_null_fields`. `test_project_report_keeps_nulls` и `test_patch_cell_null_is_explicit` — PASS уже сейчас (страховка). +- [ ] **Step 3: Implement** по Interfaces; роутеры `get_sheet`, `get_summary`, `get_summary2` вызывают `*_json(..., strip_nulls=True)`. +- [ ] **Step 4: [замок] Run** `test_heavy_get_json.py`, `test_json_list_response.py`, `test_summary_api_smoke.py`, `test_forms_api_smoke.py`, `test_projects_api_smoke.py`. Если `test_summary2_sheets_smoke` падает на отсутствии ключей с `null` — ослабить по D6: проверять ключи `header` и блоки `data` только там, где у строки эталона старого пути значение не `null` (или сравнивать набор ключей с `strip_nulls`-эталоном); это ожидаемое изменение контракта, отметить для описания PR B. +- [ ] **Step 5: Commit** → `perf(api): лист и своды без null-полей в data (jsonb_strip_nulls)` + Co-Authored-By. + +--- + +### Task 14: Потребители GET в `perf/perfkit` — без падения на отсутствующих ключах + +**Исполнитель:** haiku (код готов в плане). + +**Files:** +- Modify: `perf/perfkit/gen/grow.py:63-67` +- Test: `perf/tests/test_gen_grow.py` + +**Interfaces:** +- Produces: `form4_ids(rows: list[dict]) -> tuple[list[int], list[int]]` — `(vsp_ids, project_ids)` в `perfkit.gen.grow`, используется в `grow_form4_sheet`. + +- [ ] **Step 1: Write the failing test** + +```python +from perfkit.gen.grow import form4_ids + + +def test_form4_ids_tolerates_absent_keys(): + rows = [ + {"row_type": "INPUT", "data": {"header": {"vsp_id": 3}}}, + {"row_type": "INPUT", "data": {"header": {}}}, + {"row_type": "INPUT", "data": {}}, + {"row_type": "ITEM", "data": {"header": {"project_id": 11}}}, + {"row_type": "ITEM", "data": {"header": {}}}, + {"row_type": "ITEM", "data": {}}, + ] + assert form4_ids(rows) == ([3], [11]) +``` + +- [ ] **Step 2: [замок] Run — FAIL** `cd perf && $PY/python -m pytest -q -p no:cacheprovider tests/test_gen_grow.py` → `ImportError`. +- [ ] **Step 3: Implement** `form4_ids` через `(r.get("data") or {}).get("header") or {}` и `.get(...)`, отбрасывая `None`; `grow_form4_sheet` вызывает её вместо двух выражений в строках 66–67. Обращения в `run.py:248, 467, 505` уже защищены `.get` в условии — не трогать. +- [ ] **Step 4: [замок] Run — PASS** + весь `perf/tests -m "not stand"`. +- [ ] **Step 5: Commit** → `perf(gen): grow не падает на ответах листа без null-полей` + Co-Authored-By. + +--- + +### Task 15: Аудит `null` / `undefined` на фронте + +**Исполнитель:** sonnet (поиск и суждение; правка — только если найдено место, где отсутствующий ключ ведёт себя иначе, чем `null`). + +**Files:** +- Read: `web/src` (страницы листа, отчёта проекта, свода 1 и 2) +- Modify (только при находке): соответствующий файл + тест в `*.test.js` рядом (чистую функцию вынести в `.js`, как в Task 12) + +- [ ] **Step 1:** Поиск по `web/src` на ветке B: `=== null`, `!== null`, `== null`, `typeof … === 'object'`, ` in ` по `data`, `hasOwnProperty`, `Object.keys/entries/values` по `data` или блокам `data`. Повторно проверить места из D5.3 (`validation.js:94-209`, `Cell.jsx:149-156`, `tableColumns.jsx:88`, `SummaryTable.jsx:25, 71-72`, `Svod2Page.jsx:38-39, 354-365`, `SvodPage.jsx:53-54`, `RealtimeTable.jsx:48-57`, `rowUtils.js`, `useRealtimeData.js:49-50`). +- [ ] **Step 2:** Для каждого найденного места — вывод «обрабатывает undefined как null: да/нет» со строкой кода. Перечень — в отчёт задачи и в ledger (уходит в описание PR B). +- [ ] **Step 3:** При «нет»: красный тест на чистую функцию с отсутствующим ключом → минимальная правка → [замок] `node --test` и `npx biome check <файлы>` → коммит `fix(...)`. При отсутствии находок — коммита нет. + +--- + +### Task 16: Полный прогон фазы B, сборка, передача на PG15 + +**Исполнитель:** контроллер. + +- [ ] **Step 1: [замок]** Полный бэк (как Task 8 Step 1) → только 3 известных падения. +- [ ] **Step 2: [замок]** perf `-m "not stand"` → PASS. +- [ ] **Step 3: [замок]** Фронт: `cd web && node --test src/components/RealtimeTable/utils/*.test.js`; `npx biome check` по всем изменённым в B файлам `web/src` (`git diff --name-only feat/233-aurora-1848-sheet-json-no-pydantic... -- web/src`); `npm run build` → успех. +- [ ] **Step 4:** Инварианты: `git diff feat/233-aurora-1848-sheet-json-no-pydantic... -- api/src/services/export_service/ api/src/api/v1/websocket.py api/alembic/` → пусто; у методов проектов нет `strip_nulls`. +- [ ] **Step 5: Передать главному контроллеру на проверку PG15**: SHA ветки B, новый SQL — `jsonb_strip_nulls(data)` внутри `json_build_object(...)::text` для листа и сводов; семантика на PG 15.12 должна совпадать с `_strip_nulls` теста (null в массивах и `{}` остаются). Не блокирует PR B; блокирует merge. + +--- + +### Task 17: Финальное ревью ветки B + +**Исполнитель:** opus. + +- [ ] **Step 1:** Диапазон ревью — коммиты B без коммитов #223: `git log --no-merges -p feat/233-aurora-1848-sheet-json-no-pydantic..HEAD --not origin/feat/212-aurora-1838-ws-immutable-update`. Ревьюеру передать спеку (оба `specs/`), этот план, Global Constraints (с замком), Review Focus, результат аудита Task 15. +- [ ] **Step 2–3:** Как Task 9 Step 2–3 (находки `C-n`/`I-n`/`M-n` продолжают сквозную нумерацию фичи). + +--- + +### Task 18: PR B + +**Исполнитель:** контроллер. + +- [ ] **Step 1:** `git push -u origin feat/233-aurora-1848-sheet-json-no-pydantic-b`. +- [ ] **Step 2:** `tea pulls create --base feat/233-aurora-1848-sheet-json-no-pydantic --head feat/233-aurora-1848-sheet-json-no-pydantic-b --title "AURORA-1848: лист и своды без null-полей + фронт (B)" --description "<описание>"`. Описание: + - первой строкой: **«Не мержить до merge PR A (#<номер A>) и PR #223 (AURORA-1838). Ветка содержит merge-коммит #223; после их merge ветка перебазируется на `test`, коммиты #223 уйдут из диффа.»**; + - `Closes #233`, Jira AURORA-1848; + - **BREAKING** для потребителя, который отличает «ключ есть и равен null» от «ключа нет»: в `data` листа и сводов 1/2 нет null-полей (на любой глубине, `{}` и null в массивах остаются); отчёты проектов, ответы путей записи и WebSocket отдают null явно; выгрузка Excel не меняется; + - фронт: тесты `updateCells` на отсутствующие ключи (версия #223 достаточна / правки), `value: null` вместо пропуска в `cell_updated`, результат аудита Task 15; + - изменение `test_summary2_sheets_smoke` (если было) — как ожидаемое изменение контракта; + - TanStack Table в dev-сборке пишет `console.warn` для `undefined` во вложенном `accessorKey` — не исправляем, в prod нет; + - чек-лист ручной проверки (D6): на FORM_2/AHR второй пользователь заполняет пустую ячейку — первый видит значение без перезагрузки; то же с очисткой; вход в пустую ячейку и выход без ввода — без ошибки; свод 2: пустые ячейки «—», поиск работает; отчёт проекта не изменился; сценарий соавтора на стенде `PERF_TARGETS='FORM_2/AHR/*' PERF_COAUTHOR_RATES=1 PERF_REPEATS=1`, `page_ws_updates > 0` (выполняет контроллер); + - до merge: `compare --mode strip-nulls` на стенде, PG15 (контроллер); + - строка «Замер до/после — добавит контроллер после прогона на стенде»; пороги (свод 2 GO −30 %, FORM_2/AHR −10 % и меньший распакованный размер) не условие мержа, недобор — follow-up; + - «Rulings I made», таблица находок; `openspec archive aurora-1848-sheet-json` — только после merge B; + - последняя строка `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. +- [ ] **Step 3:** Номер PR B — в ledger и комментарием в PR A («стек: PR B #<номер>»). + +--- + +### Task 19: Точка ожидания — замеры и сравнение ответов (главный контроллер) + +**Исполнитель:** главный контроллер после основных ночных замеров. SDD-контроллер здесь останавливается: отчёт с SHA A и B, номерами PR, таблицей находок и пометкой «ожидает замеров». PR A и B уже открыты без цифр. + +- [ ] **Step 1:** Данные: масштаб 1, одна БД `perf` для «до» и «после» (восстановить из дампа s1 перед серией); база «до» — `test` на момент замера; если до замера «после» в `test` влилась миграция или правка SQL листа (AURORA-1847, 1846, 1844) — «до» переснимается на новой базе. Не параллельно с другими замерами и тестами на `perf-pg`. +- [ ] **Step 2: Корректность (обязательна до merge):** `compare_responses.py dump` на базовом коммите → на A → `compare --mode exact` → `dump` на B → `compare --mode strip-nulls` (с дампом «до»). Код возврата 0. +- [ ] **Step 3: Время:** `cd perf && .venv/bin/python analysis/api_split.py --scale 1 --repeats 10 --out report-data/-1848-/analysis` на трёх коммитах, API перезапускается на каждом. Пороги: свод 2 GO `B ≤ 0,7 × до`; FORM_2/AHR `B ≤ 0,9 × до` и `bytes B < до`; все запросы `A ≤ 1,05 × до`; справочно `python_share`, `wire_bytes`. +- [ ] **Step 4:** Цифры и результат сравнения — в описания PR A и B. Недобор порога — находка `deferred` (уходит в follow-up issue при закрытии); корректность не прошла — PR не мержится, исправление в ветке. +- [ ] **Step 5:** Ручной сценарий соавтора на стенде (Task 18) — результат в PR B. diff --git a/openspec/changes/aurora-1848-sheet-json/.openspec.yaml b/openspec/changes/aurora-1848-sheet-json/.openspec.yaml new file mode 100644 index 0000000..1bb6456 --- /dev/null +++ b/openspec/changes/aurora-1848-sheet-json/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-28 diff --git a/openspec/changes/aurora-1848-sheet-json/design.md b/openspec/changes/aurora-1848-sheet-json/design.md new file mode 100644 index 0000000..8f0c63b --- /dev/null +++ b/openspec/changes/aurora-1848-sheet-json/design.md @@ -0,0 +1,244 @@ +# Design: тяжёлые GET — готовый JSON без pydantic-модели на строку и без null-полей (AURORA-1848, #233) + +Мотивация и цифры — в proposal.md («Why»). Требования — в `specs/sheet-read-api/spec.md` и `specs/realtime-sheet-updates/spec.md`. Здесь описано, как их выполнить. + +## Context + +Ветка `test` @ 2a28748. + +**Путь ответа сейчас** (FastAPI 0.111, pydantic 2.11, SQLAlchemy 2 + asyncpg): +1. Роутер замеряет `t0` и вызывает сервис, сервис вызывает репозиторий, репозиторий выполняет `SELECT row_type, depth, sort_order, data FROM v3.<функция>(...)`. Для jsonb SQLAlchemy регистрирует в asyncpg кодек с `json.loads`, поэтому каждая строка `data` разбирается в dict ещё до возврата из `execute`. Этот разбор попадает в окно `X-DB-Time-Ms`. +2. Роутер ставит `response.headers["X-DB-Time-Ms"]` и пишет лог `db_time`. Затем строит `SheetResponse` на каждую строку: `_rows_to_sheet_response` в `forms.py:27-35` и `summary.py:29-37`, `_rows_to_payload` в `projects.py:60-64` (dict, но всё равно через `response_model`). +3. Аннотация возврата `-> BaseListResponse[SheetResponse]` становится `response_model`. FastAPI валидирует результат, сериализует его, `JSONResponse` делает `json.dumps`, `GZipMiddleware(minimum_size=1000, compresslevel=1)` сжимает (`main.py:248`). + +**Затронутые обработчики:** + +| эндпоинт | роутер | сервис → репозиторий → SQL | +|---|---|---| +| GET `/form/{id}/sheet/{sheet}` | `forms.py:150-202` `get_sheet` | `SheetService.get` → `SheetRepository.get` (`sheet_repository.py:33-68`) → `v3.v_form_view(form, sheet, sections[, direction])` | +| GET `/svod/1/{sheet}/{year}` | `summary.py:50-90` | `SummaryService.get_svod1_rows` (проверка орг. `_allowed_org_ids`) → `SummaryRepository.get_svod1_rows` → `v3.v_summary_view` | +| GET `/svod/2/{sheet}/{year}` | `summary.py:93-146` | `get_svod2_rows` → `v3.v_svod2_go` / `v3.v_svod2_rf` (row_type и depth — константы в SELECT) | +| GET `/projects/{id}/report/{year}/{type}` | `projects.py:242-277` | `ProjectService.get_report_rows` (`resolve_report_id`) → `v3.v_form3_report_jsonb` | +| GET `/rf-rollup/{branch}/{year}` | `projects.py:280-312` | `get_rf_rollup_rows` → `v3.v_form3_rf_rollup_jsonb` | +| GET `/projects/{id}/summary` | `projects.py:315-338` | `get_project_summary_rows` → `v3.v_project_summary_jsonb` | +| GET `/projects/{plan,fact,detail}-summary` | `projects.py:142-226` + `_get_projects_summary_response` `:117-139` | `get_*_summary_rows` → `v3.v_project_{plan,fact,detail}_summary_jsonb` | + +У всех функций столбец `data` имеет тип `jsonb`. + +**Кто ещё читает те же данные** и не должен измениться: +- выгрузка Excel: `export_service.py:219, 236` — `SummaryService.get_svod1_rows/get_svod2_rows`; `:438` — `SheetService.get`; `:484, :561` — `ProjectService.get_report_rows`; +- WebSocket (`websocket.py`) целые листы через эти методы не читает. Он вызывает пути записи (`update_cell`, `add_line`, …), и они здесь не меняются; +- инструменты `perf/` читают GET листа: `perfkit/coauthor.py:52, :73`, `perfkit/gen/grow.py:45-67`, `perfkit/gen/run.py:248, 304, 467, 504-506`. + +**Фронт.** Начальная загрузка листа и отчёта проекта — `useRealtimeData.js:36-58`, в обоих случаях потом используется `buildRowHierarchy`. Входящие правки идут через `updateCells` (`cellUtils.js:46-100`). На `test` правка вливается в блок `data[key]`, только если `row.data[key] !== undefined` (`:79`), иначе она молча теряется. PR #223 (AURORA-1838, открыт, не в `test`) переписывает `updateCells`: отсутствующий ключ вставляется (`mergeIntoRow`, ветка `else if (hasData)`), а глубже `mergeImmutable` и раньше создавал недостающие узлы. + +## Goals / Non-Goals + +**Goals:** +- Тело тяжёлых GET собирается из текста, который вернул Postgres, без разбора в dict и без модели на строку (PR A). +- `data` листа и сводов без null-полей, фронт не теряет правки соавторов (PR B). +- Доказательства: сравнение ответов до и после на стенде, тесты эквивалентности, замер `api_split.py`. + +**Non-Goals:** +- Менять SQL-функции, данные в `data` (это делает AURORA-1846) и SQL листа FORM_2/AHR (это AURORA-1847). +- Постраничная загрузка свода 2 и листов (М10), смена уровня gzip, переход на orjson или другой сериализатор для остальных эндпоинтов. +- Менять ответы путей записи и сообщения WebSocket, выгрузку Excel. +- Убирать null из отчётов проектов (только PR A). + +## Decisions + +### Решения человека (согласовано 28.10) +1. GET листов и сводов потребляет только наш фронт. Выгрузка Excel и WebSocket идут своими путями (через репозитории и сервисы) и не меняются. Старые методы репозиториев для них остаются. +2. Два PR. **A** — готовый JSON из Postgres (`jsonb::text`) без pydantic-модели на строку. Ответ по смыслу тот же, это доказывается сравнением ответов всех листов и сводов на стенде до и после. **B** — без null-полей в `data` (`jsonb_strip_nulls`) и правка фронта, чтобы входящие правки соавторов по отсутствующим ключам не терялись. В B проверить, что `updateCells` из AURORA-1838 (PR #223) для этого достаточно, и покрыть тестом. B стеком на A. +3. Ответ на правку ячейки (PATCH `/cell(s)` и WebSocket `cell_updated`) продолжает отдавать `null` явно: очистка ячейки должна дойти до соавторов. +4. Отчёты проектов РФ (`projects.py`, `SheetResponse`) — тем же хелпером, отдельным коммитом в A. +5. Критерий успеха: полное время GET свода 2 GO и листа FORM_2/AHR на стенде (масштаб 1) падает не менее чем на 30 % (для листа уточнено в п. 7). «Доля Python ≤ 10 %» из Jira — справочная метрика. Заголовок `X-DB-Time-Ms` должен сохраниться: на нём держится методика `perf/analysis/api_split.py`. +6. `response_model` в декораторах остаётся ради OpenAPI. +7. Порог времени уточнён (заменяет порог для листа из п. 5). Свод 2 GO — полное время после B ниже минимум на 30 %. Лист FORM_2/AHR — минимум на 10 %, и распакованный размер меньше прежнего. Остальное ускорение листа — в AURORA-1847. +8. Пороги производительности не условие мержа. PR вливается по зелёным тестам и доказанной корректности (сравнение ответов exact / strip-nulls), цифры — в описании PR, недобор — в follow-up. Корректность — жёсткое условие. + +### D1. Строка ответа собирается в SQL функцией `json_build_object` +Новый метод репозитория выполняет: +```sql +SELECT json_build_object( + 'row_type', row_type, 'depth', depth, + 'data', data, -- PR B: jsonb_strip_nulls(data) + 'sort_order', sort_order)::text +FROM v3.<функция>(<те же аргументы, что в старом методе>) +``` +Для свода 2 `row_type` и `depth` — те же константы, что в старом SELECT (`'INPUT'`, `3`). +- `json_build_object` (тип `json`) сохраняет порядок ключей обёртки строки. Аргумент jsonb он выводит своим текстовым представлением, то есть `data` сериализуется один раз. +- `::text` даёт тип `text`, и кодек `json.loads` у asyncpg не срабатывает. +- Порядок строк — порядок вывода функции, без `ORDER BY`, как сейчас. + +Отвергнуто: +- `jsonb_build_object(...)::text`: пересобирает `data` во внутреннее представление jsonb и сортирует ключи обёртки (`data, depth, row_type, sort_order`). Это лишняя работа на 30–50 МБ. +- `format('{"row_type":%s,...}', ...)`: хрупко (экранирование строк, `NULL` → пустая подстановка), а выигрыш только в пробелах. +- `string_agg` всего ответа в одну строку в SQL: выигрыш на тысячах строк ничтожен, а `count` и порядок придётся выражать отдельно. +- Сериализация в Python через orjson без моделей: остаётся `json.loads` в драйвере, и нужна новая зависимость. + +### D2. Новые методы рядом со старыми +- Репозитории: `SheetRepository.get_json`, `SummaryRepository.get_svod1_json` / `get_svod2_json`, `ProjectRepository.get_report_json`, `get_rf_rollup_json`, `get_project_summary_json`, `get_{plan,fact,detail}_summary_json`. Параметр `strip_nulls: bool = False` добавляется в PR B только к методам листа и сводов. Возврат — `list[str]`. +- Сервисы: методы `*_json` с теми же проверками, что у старых (`_allowed_org_ids`, `_allowed_summary_branch_ids`, `resolve_report_id`, `get_instance`, `AccessDeniedException`, ранний `[]` при пустом списке организаций). +- Старые методы (`get`, `get_svod1_rows`, `get_svod2_rows`, `get_report_rows`, …) **не меняются ни в одной строке**: их используют выгрузка и сравнительные тесты. +- Дублирование FROM-части устраняет исполнитель: вынести построение FROM-фрагмента и параметров в приватную функцию, которую вызывают и старый, и новый метод. Текст SQL старого метода после выноса должен остаться эквивалентным. Валидация `SheetRepository.get` (`:40-49`, `SheetValidationError`) обязательна и в `get_json`. + +### D3. Хелпер ответа и роутеры +Новый модуль `api/src/api/v1/json_response.py`: +```python +def list_json_response(rows: list[str], headers: dict[str, str] | None = None) -> Response: + body = b'{"success":true,"message":null,"result":[' + ",".join(rows).encode() + b'],"count":' + str(len(rows)).encode() + b"}" + return Response(content=body, media_type="application/json", headers=headers) +``` +Точная форма кода — на усмотрение исполнителя. Инварианты: обёртка ровно как у `BaseListResponse` при успехе (`success`, `message`, `result`, `count`), UTF-8, `media_type="application/json"` (без `charset`, как у `JSONResponse`). + +Роутеры (`get_sheet`, `get_summary`, `get_summary2`, шесть GET в `projects.py`): +- аннотация возврата меняется на `Response`, а `response_model=BaseListResponse[SheetResponse]` переносится в декоратор `@router.get(...)`. Схема OpenAPI остаётся прежней (требование «Схема OpenAPI не меняется»); +- **`X-DB-Time-Ms` ставится на возвращаемый `Response`** (`headers=`). Заголовки, записанные в инъецированный параметр `response: Response`, FastAPI переносит только при возврате не-Response значения. Параметр `response` из сигнатуры убрать; +- окно замера то же: от `t0` до возврата строк из сервиса. Лог `db_time` с прежними полями; +- проверки до запроса (404 формы и листа, `_validate_sheet_query_params`, недопустимые `sections`, список листов свода) не меняются; +- `_rows_to_sheet_response` в `forms.py` остаётся: её используют PATCH, POST и DELETE. В `summary.py` она становится неиспользуемой и удаляется. `_rows_to_payload` в `projects.py` остаётся для правок FORM_3. + +`StreamingResponse` не используется (см. «Decisions made without discussion»). + +### D4. PR B: `jsonb_strip_nulls` для листа и сводов +- В `get_json`, `get_svod1_json`, `get_svod2_json` выражение `data` заменяется на `jsonb_strip_nulls(data)`. Роутеры листа и сводов вызывают методы со `strip_nulls=True`. Отчёты проектов — нет. +- Семантика PG 15: функция рекурсивно удаляет поля объектов со значением `null`, в том числе в объектах внутри массивов. Элементы массивов, равные `null`, остаются, пустые объекты `{}` остаются. Строка META (`editable[].closes_at = null`) тоже теряет null-поля, фронт читает из неё только `editable[].column` (`useRealtimeData.js:49-50`). +- Обёртка строки (`row_type`, `depth`, `sort_order`) не трогается: `sort_order` может остаться `null`. + +### D5. PR B: фронт +1. **`updateCells`.** Предусловие B — PR #223 смержен в `test`, и ветка B включает его (rebase на `test` после merge A и #223). B проверяет, что ветка `else if (hasData)` в `mergeIntoRow` и `mergeImmutable(undefined, …)` вставляют отсутствующий блок и отсутствующий лист, и добавляет тесты в `web/src/components/RealtimeTable/utils/cellUtils.test.js`: + - строка без блока `q1`, приходит `{q1: {m1: 5}}` → `row.data.q1.m1 === 5`; + - блок `q1` есть, но без `m1`, приходит `{q1: {m1: 5}}` → значение вставлено, остальные поля `q1` сохранены; + - строка с `q1.m1 = 5`, приходит `{q1: {m1: null}}` → `row.data.q1.m1 === null`; + - отсутствующий скалярный ключ верхнего уровня `data` (например, `line_id`) вставляется. + + Если какой-то тест падает на версии #223, правка вносится в `cellUtils.js` в B. +2. **Отправка правки с отсутствующим значением (найдено при сверке).** `EditCell.handleSave` (`EditCell.jsx:117-148`) при выходе из редактирования всегда вызывает `onChange(normalizeNumericInput(currentValue))`, а `RealtimeContext.updateCell` (`RealtimeContext.js:304-330`) кладёт значение в `data.value`. Для отсутствующего ключа `currentValue === undefined`, `JSON.stringify` опускает `value`, и сервер падает на `event_data["value"]` (`websocket.py:472`, для FORM_3 — `:703`): пользователь получит ошибку там, где раньше уходил `null`. Исправление на фронте: в `updateCell` нормализовать `undefined` в `null` (`value: value === undefined ? null : value`), чистую функцию вынести в `.js` и покрыть тестом. Сервер не трогаем (решение 1: WebSocket не меняется). +3. **Аудит отличий `null` от `undefined`** на страницах листа, отчёта проекта, свода 1 и свода 2. При сверке проверено: `validation.js:94-209`, `Cell.jsx:149-156`, `tableColumns.jsx:88`, `SummaryTable.jsx:25, 71-72`, `Svod2Page.jsx:38-39, 354-365`, `SvodPage.jsx:53-54`, `RealtimeTable.jsx:48-57`, `rowUtils.js` — все обрабатывают и `undefined`. Сортировка на клиенте в сводах выключена (`enableSorting: false`). Проверок `in` / `hasOwnProperty` по `data` нет. Исполнитель повторяет поиск (`=== null`, `!== null`, `typeof … === 'object'`, `in `, `hasOwnProperty`, `Object.keys/entries` по `data`) по `web/src` на ветке B, потому что за время работы в `test` могли влиться другие PR, и фиксирует результат в описании PR. +4. **Потребители в `perf/`.** `perfkit/gen/grow.py:67` (`r["data"]["header"]["project_id"]` для ITEM) и аналогичные обращения через `[...]` к полям, которые могут быть `null`, заменить на `.get(...)`, чтобы генератор стенда не падал на ответах B. + +### D6. Тесты +**Unit (`api/tests/unit/test_json_list_response.py`, PR A):** +- тело хелпера разбирается `json.loads` в `{"success": True, "message": None, "result": [...], "count": n}`; +- пустой список, одна строка, кириллица и экранированные символы в строке; +- `media_type`, переданные заголовки. + +**Интеграционные (`api/tests/integration/test_heavy_get_json.py`, PR A, дополняются в B).** Эталон — старый путь в той же сессии: `client.portal.call(...)` поверх `SheetRepository(db_session).get(...)`, `SummaryRepository.get_svod1_rows/get_svod2_rows`, `ProjectRepository.get_*_rows`, превращённый в `[{"row_type", "depth", "sort_order", "data"}]`. Так же устроен тест AURORA-1846, см. ниже про `db_session`. +- лист: FORM_1 AHR Support (форма 1 фикстуры), лист без `direction`, вариант с `sections`. `response.json()["result"] == эталон` (A) или `== strip_nulls(эталон)` (B). `count == len(result)`; +- свод 1 по всем `VALID_SUMMARY_SHEETS`, свод 2 GO/RF без параметров, с `org_ids`, с `offset/limit`, с `sort_by/sort_direction`; +- отчёты проектов (A): проект создаётся в тесте, как в `test_projects_api_smoke.py`, сравниваются report, project summary и plan/fact/detail-summary. В B — проверка, что `null` в отчётах остались; +- заголовок `X-DB-Time-Ms` есть и разбирается как число на всех девяти эндпоинтах; +- роль: `isp_tokens` получает свод только по своим организациям, как и через старый путь; +- OpenAPI: у девяти путей схема ответа 200 ссылается на ту же компоненту, что в эталонном снимке. Снимок снимается на базовом коммите и кладётся в тест как литерал (имя компоненты); +- B: PATCH ячейки со значением `null` → в строке ответа ключ присутствует со значением `null` (требование «Ответы на правку отдают null явно»); +- рекурсивный эталон `strip_nulls` на Python пишется в тесте так же, как работает PG: удаляются поля объектов, `null` в массивах остаются, `{}` остаётся. + +Сравнение чисел: эталон приходит через `json.loads` драйвера (float), новый ответ — через `response.json()` (float). Равенство точное. + +Фикстура `db_session`. AURORA-1846 (PR #232) добавляет её в `api/tests/integration/conftest.py` (`client` начинает зависеть от `db_session`). Если к началу работы #232 не в `test`, внести в A **тот же самый хунк**: при одинаковых правках git сливает без конфликта. + +Существующие тесты: `test_summary_api_smoke.py`, `test_forms_api_smoke.py`, `test_db_functions_integration.py:79-93` должны проходить в A без изменений. В B `test_summary2_sheets_smoke` проверяет наличие ключей `header.smeta_direction` и блоков `data`. Если у первой строки эти значения `null`, тест меняется так: проверять ключи только у строк, где значение не `null`, или проверять набор ключей по эталону со `strip_nulls`. Это ожидаемое изменение контракта, в PR его нужно оговорить. + +**Окружение тестов бэка.** Полный `pytest` гоняется на PG16: контейнер `perf-pg`, порт 55432, своя БД `dfip_1848`. +- Создать: `docker exec perf-pg createdb -U perf dfip_1848`. Пересоздать: `dropdb` + `createdb`, затем `--init_db`. +- БД `perf` не трогать. +- Пароли `OPENBAO__TEST_*` по умолчанию `admin123`. +- Команда: `cd api && DATABASE_URL=postgresql+asyncpg://perf:perf@localhost:55432/dfip_1848 /bin/pytest --init_db -q -p no:cacheprovider -W ignore`. +- Совместимость с PG 15.12 (`dfip-pg15:55434`) проверяет контроллер. Новый SQL этого change — `json_build_object`, `jsonb_strip_nulls`, `::text` — есть в PG 9.5+. + +**Фронт (B):** `cd web && node --test src/components/RealtimeTable/utils/*.test.js`, `npx biome check <изменённые файлы>`, `npm run build`. + +**Ручная проверка (B, чек-лист PR):** +- на FORM_2/AHR второй пользователь заполняет пустую ячейку, первый видит значение без перезагрузки; +- то же с очисткой; +- вход в пустую ячейку и выход без ввода — без ошибки; +- свод 2: пустые ячейки показывают «—», поиск работает; +- отчёт проекта не изменился. + +Сценарий соавтора на стенде — после ночного прогона: `PERF_TARGETS='FORM_2/AHR/*' PERF_COAUTHOR_RATES=1 PERF_REPEATS=1`, `page_ws_updates > 0`. + +### D7. Замер и сравнение на стенде +Замеры на стенде выполняет контроллер после основных ночных замеров. В плане это точка ожидания. PR A и B открываются без цифр, цифры дописываются в описание PR после замера. Пороги не блокируют мерж (решение человека 8), а сравнение ответов (п. 1 ниже) — обязательное доказательство корректности до мержа. Замеры не идут параллельно с другими замерами и тестами на `perf-pg`: общий `pg_stat_statements`. + +Данные: масштаб 1, одна и та же БД `perf` для «до» и «после» (восстановить из дампа s1 перед серией). База сравнения для порогов — `test` на момент замера «до» A. Миграции «до» и «после» одинаковые: у A и B миграций нет. Если до замера «после» в `test` влилась миграция или правка SQL листа (AURORA-1847, 1846, 1844), серия «до» переснимается на новой базе. + +1. **Сравнение ответов** — новый скрипт `perf/analysis/compare_responses.py` (PR A): + - `dump --scale 1 --out ` обходит по `results/manifest-s1.json` все листы (все `owner`, с `direction` где есть, плюс один вариант с `sections` для FORM_2/AHR), своды 1 (`FORM_1..4`, `MAIN`) и 2 (`GO`, `RF`) за год манифеста, отчёты и сводные проектов. Запросы идут под ролью из манифеста (`rf` для своих листов, `dfip` для сводов), ответы сохраняются как `.json.gz` по имени запроса; + - `compare --mode exact|strip-nulls` сравнивает `json.loads` ответов. В режиме `strip-nulls` сравниваются `after` и рекурсивно очищенный `before`, но только для листов и сводов, отчёты проектов сравниваются точно. Вывод — число совпавших и список расхождений с путём до первого отличия, код возврата ≠ 0 при расхождении. + + Порядок: `dump` на базовом коммите → `dump` на A → `compare --mode exact` → `dump` на B → `compare --mode strip-nulls` (с дампом «до»). +2. **Время.** `cd perf && .venv/bin/python analysis/api_split.py --scale 1 --repeats 10 --out report-data/-1848-/analysis` три раза: на базовом коммите, на A, на B. API перезапускается на каждом коммите. Метрики: + - `total_ms` свода 2 GO: порог «B ≤ 0,7 × до»; + - `total_ms` листа FORM_2/AHR: порог «B ≤ 0,9 × до»; + - `total_ms` всех запросов набора: «A ≤ 1,05 × до»; + - `bytes` FORM_2/AHR: «B < до»; + - справочно `python_share` и новое поле `wire_bytes`. +3. **`api_split.py`, PR A:** добавить поле `wire_bytes` (`r.num_bytes_downloaded`, размер на проводе) и колонку в `.md`. Существующие поля и их смысл не меняются. +4. Ссылка на `X-DB-Time-Ms` в docstring `api_split.py` (номера строк `forms.py:184` и др.) обновляется на новые места. + +Оценка (не замер): разбор jsonb драйвером синтетически ≈ 8 мс/МБ. Это ≈ 60 мс из 726 мс `X-DB-Time-Ms` листа FORM_2/AHR и ≈ 240 мс из 718 мс свода 2 GO. + +## Risks / Trade-offs + +- **[Порог для FORM_2/AHR]** По замеру 26.10: полное время 813 мс, `X-DB-Time-Ms` 726 мс, остаток 87 мс. Задача убирает остаток (кроме gzip, передачи и распаковки) и разбор jsonb (≈ 60 мс по оценке выше), ожидаемое снижение — порядка 12–20 %. Поэтому человек 28.10 снизил порог для листа до −10 % плюс уменьшение размера, а остальное отдал AURORA-1847. Для свода 2 GO порог −30 % реален: ≈ 240 мс разбора и большая часть 339 мс остатка. → Пороги не блокируют мерж. Недобор — follow-up с цифрами разложения. +- **[`jsonb::text` пишет `": "` и `", "`]** В A распакованный размер может вырасти примерно на 10–14 %, gzip уровня 1 обрабатывает больше байт. → Порог A — «не медленнее 5 %». Уменьшение размера даёт только B (критерий Jira). +- **[Ответ в обход `response_model` не валидируется]** Ошибка формы ответа не будет поймана FastAPI. → Тесты эквивалентности со старым путём на всех девяти эндпоинтах, проверка OpenAPI, сравнение на стенде. +- **[Заголовок теряется при возврате `Response`]** → Заголовок ставится на возвращаемый объект, тест на всех эндпоинтах, `api_split.py` падает на `nan` (сценарий в спеке). +- **[Потеря правок соавторов в B]** → Предусловие #223, тесты `cellUtils.test.js`, ручной сценарий. B не мержится раньше #223. +- **[`value` пропадает из сообщения WebSocket в B]** → Нормализация `undefined → null` в `updateCell` (D5.2) и тест. +- **[Скрытые проверки `=== null` на фронте]** → Аудит (D5.3) с перечнем проверенного, ручной чек-лист. +- **[В dev-сборке TanStack Table пишет `console.warn` для `undefined` во вложенном `accessorKey`]** В prod-сборке этого нет. Для `null`-блоков предупреждение появлялось и раньше. → Не исправляем, отмечаем в PR B. +- **[Выгрузка или WebSocket случайно переходят на новые методы]** → Старые методы не меняются. В ревью — дифф `export_service/` и `websocket.py` должен быть пустым (кроме D5.2, это фронт). Интеграционные тесты выгрузки остаются зелёными. +- **[Пиковая память]** Тело ответа 30–50 МБ (масштаб 2 — до ~200 МБ) собирается одним `bytes`, плюс список строк того же объёма. Это меньше, чем дерево dict, pydantic-модели и строка `json.dumps` сейчас. → Приемлемо, стриминг не нужен. +- **[Конфликты с параллельными PR]** `conftest.py` с #232 (одинаковый хунк), `cellUtils.js` с #223 (B после #223). Миграций нет, конфликта номеров 0050 у этого change нет. + +## Migration Plan + +- Один план superpowers в две фазы, A и B, под issue #233. +- Ветка A — `feat/233-aurora-1848-sheet-json-no-pydantic`. Ветка B — та же с суффиксом `-b`, стеком на ветке A. +- Порядок: PR A → merge в `test` → PR B (rebase на `test` после merge A и #223) → merge. B вливается только после A и PR #223. +- `openspec archive aurora-1848-sheet-json` — только после merge B. Иначе в `openspec/specs/` попадут требования PR B (без null-полей, фронт) без кода. +- Миграций БД нет, деплой — обычный выкат API и фронта. B выкатывается одновременно с фронтом B, потому что это одна сборка (фронт отдаёт API). +- Откат: revert PR B возвращает null-поля, фронт B совместим и с ними. Revert PR A возвращает модели. Данные не затрагиваются. + +## Decisions made without discussion + +| Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---| +| 2026-10-28 | Строка собирается `json_build_object(...)::text`, а не `jsonb_build_object` и не `format()` (D1) | Порядок ключей обёртки сохраняется, `data` сериализуется один раз, нет ручного экранирования | Низкая: замена выражения в одном SQL-фрагменте | +| 2026-10-28 | Ответ собирается из списка строк (по строке на ряд) в Python, а не одной `string_agg` в SQL | `count` и порядок как сейчас, проще эквивалентность; разница в стоимости ничтожна при тысячах строк | Низкая | +| 2026-10-28 | Обычный `Response`, без `StreamingResponse` | Сессия БД не держится открытой на время отправки; GZipMiddleware и Content-Length работают как раньше; память ниже текущей | Средняя только на масштабе 2+ (пиковая память); переделка локальна в хелпере | +| 2026-10-28 | Имена новых методов `*_json` и параметр `strip_nulls` в репозиториях листа и сводов | Явная пара «старый метод для выгрузки / новый для GET»; одно место включения B | Низкая: переименование | +| 2026-10-28 | «Отчёты проектов РФ (projects.py, SheetResponse)» понято как все шесть GET в `projects.py` с `BaseListResponse[SheetResponse]`: report, rf-rollup, project summary, plan/fact/detail-summary | Все объявляют `SheetResponse` и строятся одинаково | Низкая: лишние 2–3 эндпоинта в отдельном коммите, их легко исключить | +| 2026-10-28 | В PR B null убираются только у листа и сводов 1/2; отчёты проектов отдают null как раньше | Человек отнёс отчёты проектов к A; их ответ мал (1,5 МБ), а проверять фронт проектов отдельно — лишний объём | Недополученный выигрыш на отчётах проектов; можно сделать follow-up | +| 2026-10-28 | `jsonb_strip_nulls` применяется ко всем строкам ответа, включая META | Единое правило; фронт читает из META только `editable[].column` | Низкая: если что-то читает null-поля META, исключить её одним условием | +| 2026-10-28 | Пороги времени (−30 % свод 2 GO, −10 % FORM_2/AHR) проверяются на итоговом состоянии (после B); для A — «не медленнее 5 % ни на одном запросе `api_split.py`» | Человек задал критерий задачи целиком; A ради эквивалентности может увеличить размер ответа | Низкая: пороги не блокируют мерж (решение человека 8) | +| 2026-10-28 | Критерий Jira «размер ответа FORM_2/AHR уменьшился» включён в спеку как требование к B | Он есть в приёмке Jira, человек его не отменял | Низкая | +| 2026-10-28 | В `api_split.py` добавляется поле `wire_bytes` (размер на проводе), прежние поля не меняются | Эффект B на сжатый размер иначе не виден | Низкая | +| 2026-10-28 | Новый скрипт `perf/analysis/compare_responses.py` (dump/compare) в PR A | Доказательство эквивалентности на всех листах и сводах стенда требует повторяемой команды | Низкая | +| 2026-10-28 | Фикстура `db_session` вносится тем же хунком, что в AURORA-1846, если #232 ещё не в `test` | Тесту эквивалентности нужен старый путь в той же сессии; одинаковый хунк сливается без конфликта | Низкая: ручное слияние conftest | +| 2026-10-28 | PR B не мержится раньше PR #223 (AURORA-1838); при задержке #223 B ждёт, а не дублирует `updateCells` | Две реализации `updateCells` дадут конфликт и двойное ревью | Задержка B до #223 | +| 2026-10-28 | Пустое значение правки нормализуется в `null` на фронте (`updateCell`), сервер WebSocket не меняется (D5.2) | Человек зафиксировал «WebSocket не меняется»; дефект проявляется только из-за B | Если есть другой клиент WebSocket, он сохранит старое поведение (его нет) | +| 2026-10-28 | B правит потребителей GET в `perf/perfkit` (`[...]` → `.get`) для полей, которые могут исчезнуть | Генератор и бот стенда — наши инструменты замера; падение сорвёт повторный прогон | Низкая | +| 2026-10-28 | В B тест `test_summary2_sheets_smoke` ослабляется для ключей с `null`, если он на них падает | Это прямое следствие нового контракта | Низкая | +| 2026-10-28 | `_rows_to_sheet_response` в `summary.py` удаляется, в `forms.py` остаётся (её используют пути записи) | Мёртвый код | Нет | +| 2026-10-28 | Один план superpowers в две фазы (A, B); ветка B — стеком на ветке A, с суффиксом `-b`, под issue #233; B вливается после A и PR #223 (решение контроллера) | Одна задача Jira и одна issue; B зависит от кода A | Средняя: при долгом ревью A ветка B требует повторных rebase | +| 2026-10-28 | `openspec archive` — только после merge B (решение контроллера) | Иначе в `openspec/specs/` попадут требования PR B без кода | Низкая: архив откладывается до B | +| 2026-10-28 | База сравнения для порогов — `test` на момент замера «до» A; если AURORA-1847 (или другая правка SQL/миграция) вольётся раньше замера «после», «до» переснимается на новой базе (решение контроллера) | Иначе в разницу попадёт чужое ускорение или замедление | Лишняя серия замеров (~10 мин) | +| 2026-10-28 | Замеры на стенде выполняет контроллер после основных ночных замеров; в плане — точка ожидания; PR открываются без цифр, цифры дописываются (решение контроллера) | Стенд занят ночным прогоном, замеры нельзя вести параллельно (`pg_stat_statements`) | Цифры появляются в PR позже ревью кода | +| 2026-10-28 | Полный pytest — на PG16 (`perf-pg:55432`, БД `dfip_1848`, пароли `OPENBAO__TEST_*` = `admin123`); совместимость с PG 15.12 (`dfip-pg15:55434`) проверяет контроллер (решение контроллера) | `test` пока не накатывается на PG15 (AURORA-1922); новый SQL change совместим с PG 9.5+ | Низкая: несовместимость с PG15 ловит контроллер до мержа | +| 2026-10-28 | (план) Ветка B вливает ветку PR #223 (`feat/212-aurora-1838-ws-immutable-update`) merge-коммитом в начале фазы B, а не ждёт merge #223 в `test`; после merge #223 и A ветка B перебазируется на `test` | Человек задал «между фазами без остановки», а тесты фронта B без `updateCells` из #223 красные; merge-коммит не дублирует код #223 и уходит из диффа при rebase | Средняя: PR B до rebase показывает дифф #223; если #223 изменится на ревью — повторный merge или rebase B | +| 2026-10-28 | (план) Фаза B — в отдельном worktree `.claude/worktrees/aurora-1848-b`, а не переключением ветки в worktree A | Правки по ревью PR A (H-n) идут в A параллельно с работой над B | Низкая: лишний worktree, удаляется при закрытии | +| 2026-10-28 | (план) Красный шаг TDD для GET — тест «GET не вызывает старый метод» (monkeypatch старого метода репозитория на исключение); тесты эквивалентности со старым путём зелёные и до правки, они — страховка | Эквивалентность по определению не отличает старый код от нового; без такого теста шаг «падает» невозможен | Низкая | +| 2026-10-28 | (план) Общий модуль `api/src/repository/row_json.py` (`ROW_COLUMNS`, `row_json_select`, `fetch_row_texts`); `strip_nulls` добавляется в `row_json_select` в фазе B | Одно место для выражения строки (D1) и для включения B (D4) во всех репозиториях | Низкая: перенос функции | +| 2026-10-28 | (план) Нормализация `undefined → null` вынесена в `web/src/components/RealtimeTable/utils/messageUtils.js` (`buildCellUpdatedMessage`) | `RealtimeContext.js` импортирует React и не тестируется `node --test`; D5.2 требует чистую функцию в `.js` | Низкая | +| 2026-10-28 | (план) `compare_responses.py` не снимает `rf-rollup`: в манифесте стенда нет `branch_id`; эндпоинт покрыт интеграционным тестом эквивалентности | Требование «все листы, своды, отчёты проектов по манифесту» выполнимо только для того, что есть в манифесте | Низкая: регрессия rf-rollup на данных стенда не видна сравнению | +| 2026-10-28 | (план) В режиме `strip-nulls` `compare_responses.py` чистит только `data` строк (обёртка, `sort_order`, `message` не трогаются); `dump` пишет статусы в `index.json`, `compare` сравнивает и статусы | Спека: «из `data` которого рекурсивно удалены поля со значением null»; ошибки тоже должны совпадать | Низкая | +| 2026-10-28 | (план) Финальное ревью opus — по одному на каждую ветку фазы: A до PR A, B (без коммитов #223) до PR B | Два PR ревьюются и мержатся раздельно | Низкая: два прогона opus вместо одного | +| 2026-10-28 | (план) Проверка PG15 главным контроллером не блокирует открытие PR, блокирует merge; в PR A пишется `Part of #233`, `Closes #233` — в PR B | Замеры и PG15 у главного контроллера, PR открываются без них; issue закрывается только всем объёмом (A+B) | Низкая | +| 2026-10-28 | R-1 (design #1): тест экранирования листа вставляет `budget_line` (form 4, `name` = строка с кавычкой, обратным слэшем, переводом строки, табом и кириллицей) вместо `UPDATE justification` из плана; сравнение по декодированному значению `data.header.name` | В фикстуре у FORM_2 нет строк `budget_line`, `UPDATE` ничего не меняет; в `data` листа `name` попадает в `header.name` | Низкая: слабее покрытие экранирования на других полях `data` | +| 2026-10-28 | R-2 (design #2): фикстура `svod2_plan_line` (в откатываемой сессии теста) вместо запасного `POST .../line` из плана | `POST` на FORM_2 даёт 400 или строку с нулевым планом, которую свод 2 пропускает; без данных сравнения свода 2 пусты | Низкая: привязка к идентификаторам фикстуры `fixture.sql` | +| 2026-10-28 | R-3 (design #3): для `rf-rollup` вместо сравнения с старым путём — тест фиксации ответа об ошибке; `_add_line` в тестах проектов не реализован | `v3.v_form3_rf_rollup_jsonb` на `test` обращается к несуществующей колонке `r.year` и всегда отдаёт 400 (O-3, вне объёма, миграции запрещены); POST строки проекта даёт 400/409 и рвёт транзакцию теста. Исправляет прежнее утверждение выше о покрытии `rf-rollup` интеграционным тестом эквивалентности: успешный путь `rf-rollup` не покрыт ничем до исправления O-3 | Средняя: регрессия успешного пути `rf-rollup` невидна; follow-up на исправление `r.year` и перевод теста в эквивалентность | +| 2026-10-28 | R-4 (design #4): мок в `test_api_split.py` использует `stream=httpx.ByteStream(gz)` вместо `content=gz` | С `content=` httpx не считает `num_bytes_downloaded`, `wire_bytes` был бы 0 | Нет: только тест | +| 2026-10-28 | R-5 (design #5): после ревью задачи 7 в `compare_responses.py` `dump` принимает готовый `httpx.Client`, пишет `index.json` в `finally` и записывает сетевые ошибки как `error: `; `compare` сравнивает не-JSON тела побайтно | Ночной `dump` на стенде идёт без присмотра; падение на одном запросе не должно терять остальные | Низкая | diff --git a/openspec/changes/aurora-1848-sheet-json/proposal.md b/openspec/changes/aurora-1848-sheet-json/proposal.md new file mode 100644 index 0000000..b0582ed --- /dev/null +++ b/openspec/changes/aurora-1848-sheet-json/proposal.md @@ -0,0 +1,56 @@ +# Proposal: тяжёлые GET листов и сводов — готовый JSON без pydantic-модели на строку и без null-полей + +Jira: AURORA-1848 (эпик AURORA-1834). Gitea: #233. Источник цифр: `perf/REPORT.md`, разделы М9 и М10, и `perf/report-data/20261026-s1/analysis/api-split.md` (масштаб 1, медиана 10 повторов, напрямую на :8000). + +## Why + +Тяжёлые GET (лист формы, свод 1, свод 2) отдают тысячи строк, в каждой сотни полей, большинство из них `null`. Ответ проходит через Python 4–5 раз: драйвер разбирает jsonb в dict (`json.loads` внутри окна `X-DB-Time-Ms`), роутер строит `SheetResponse` на каждую строку (`api/src/api/v1/forms.py:27-35`, `summary.py:29-37`), FastAPI валидирует `response_model`, сериализует в JSON, затем `GZipMiddleware` сжимает (`api/src/main.py:248`). + +| запрос (масштаб 1) | полное, мс | `X-DB-Time-Ms` | остаток | доля остатка | размер | +|---|---|---|---|---|---| +| свод 2 GO, 5 563 строки | 1 057 | 718 | 339 | 32 % | 30,6 МБ | +| свод 2 RF, 9 497 строк | 1 808 | 1 229 | 578 | 32 % | 52,0 МБ | +| лист FORM_2/AHR, 2 092 строки | 813 | 726 | 87 | 11 % | 7,6 МБ | +| отчёт проекта LIMIT, 1 364 строки | 343 | 307 | 36 | 11 % | 1,5 МБ | + +Сами данные уже собраны в Postgres как jsonb. Разбирать их в Python, чтобы тут же собрать обратно, — лишняя работа. Явные `null` раздувают ответ и время разбора в браузере: на масштабе 2 свод 2 — 123–195 МБ. + +## What Changes + +Два PR, B стеком на A (согласовано 28.10). + +**PR A — готовый JSON из Postgres, ответ по смыслу тот же.** +- `GET /api/v1/form/{form_id}/sheet/{sheet}`, `GET /api/v1/svod/1/{sheet}/{year}`, `GET /api/v1/svod/2/{sheet}/{year}` отдают тело, собранное из текста строк, который вернул Postgres (`json_build_object(...)::text`). Нет pydantic-модели на строку и нет валидации `response_model`. Обёртка `{"success","message","result","count"}` прежняя. +- Отдельным коммитом то же для GET отчётов проектов в `api/src/api/v1/projects.py`, которые объявляют `BaseListResponse[SheetResponse]`. +- После разбора JSON значения те же, что и раньше. Байты могут отличаться: пробелы и запись чисел (`1.50` вместо `1.5`). +- Заголовок `X-DB-Time-Ms` сохраняется на всех этих ответах. `response_model` остаётся в декораторах ради OpenAPI. +- `perf/`: скрипт сравнения ответов до и после, плюс размер ответа на проводе в `api_split.py`. + +**PR B — без null-полей в `data` + правка фронта.** +- В ответах GET листа, свода 1 и свода 2 у объектов в `data` нет полей со значением `null`, на любой глубине (`jsonb_strip_nulls`). **BREAKING** для потребителя, который отличает «ключ есть и равен null» от «ключа нет». Единственный такой потребитель — наш фронт. Выгрузка Excel и WebSocket идут своими путями и не меняются. +- Фронт: входящая правка соавтора (WebSocket `cell_updated`, эхо PATCH) применяется и к ключу, которого нет в загруженной строке. В AURORA-1838 (PR #223) `updateCells` уже вставляет отсутствующий ключ. B проверяет, что этого достаточно, и закрепляет поведение тестом. + +**Критерии приёмки (согласовано 28.10).** Свод 2 GO — полное время ниже минимум на 30 % после B. Лист FORM_2/AHR — минимум на 10 %, и распакованный размер меньше. Пороги не условие мержа: PR вливается по зелёным тестам и доказанной корректности (сравнение ответов на стенде), цифры — в PR, недобор — в follow-up. + +**Что НЕ меняется** +- Ответы на правку — PATCH `/cell`, `/cells`, POST/DELETE `/line`, WebSocket `cell_updated` и правки FORM_3 — по-прежнему отдают `null` явно: очистка ячейки должна дойти до соавторов. +- Выгрузка Excel и WebSocket читают данные через прежние методы репозиториев и сервисов. Эти методы остаются без изменений. +- SQL-функции и миграции. Новой миграции нет. +- Отчёты проектов в PR B: в них `null` остаются. +- Сжатие (`GZipMiddleware`, уровень 1), постраничная загрузка (М10). + +## Capabilities + +### New Capabilities +- `sheet-read-api`: контракт ответа тяжёлых GET (лист, своды 1 и 2, отчёты проектов) — обёртка, строки, отсутствие null-полей, заголовок `X-DB-Time-Ms`, схема OpenAPI, неизменность путей записи и выгрузки, критерии производительности. +- `realtime-sheet-updates`: как фронт применяет входящие правки соавторов к строкам, загруженным без null-полей, и как показывает отсутствующие значения. + +### Modified Capabilities +Нет: `openspec/specs/` пуст. + +## Impact + +- Бэк: `api/src/api/v1/forms.py`, `summary.py`, `projects.py` (только GET), `api/src/repository/sheet_repository.py`, `summary_repository.py`, `project_repository.py`, `api/src/services/sheet_service.py`, `summary_service.py`, `project_service.py` (новые методы рядом со старыми), новый хелпер ответа в `api/src/api/v1/`, тесты в `api/tests/unit` и `api/tests/integration`. +- Фронт (PR B): `web/src/components/RealtimeTable/utils/cellUtils.js` (если #223 не покрывает всё), тесты `cellUtils.test.js`. +- `perf/`: `analysis/api_split.py` (новое поле), новый `analysis/compare_responses.py`. В PR B — потребители GET в `perf/perfkit`, если они обращаются к ключам, которые стали отсутствовать. +- Связь с задачами: AURORA-1838 (PR #223) — B без неё не мержится. AURORA-1844/1850 — контракт строк ответа на правку с явными `null`, этот change его не трогает. AURORA-1846 (PR #232) меняет содержимое `data` FORM_4, этот change — упаковку; общий участок — фикстура `db_session` в `api/tests/integration/conftest.py`. AURORA-1847 — SQL листа FORM_2/AHR: дальнейшее ускорение листа сверх порога этого change (−10 % и меньший размер) — там. diff --git a/openspec/changes/aurora-1848-sheet-json/specs/realtime-sheet-updates/spec.md b/openspec/changes/aurora-1848-sheet-json/specs/realtime-sheet-updates/spec.md new file mode 100644 index 0000000..7f0822b --- /dev/null +++ b/openspec/changes/aurora-1848-sheet-json/specs/realtime-sheet-updates/spec.md @@ -0,0 +1,44 @@ +# Spec Delta + +## Purpose + +Как редактируемая таблица фронта (лист формы, отчёт проекта) применяет входящие правки соавторов и эхо собственных правок к уже загруженным строкам и как она показывает значения, которых нет в строке. Цель — чтобы правки не терялись, даже если строки загружены без null-полей. + +## ADDED Requirements + +### Requirement: Входящая правка применяется к отсутствующему ключу +Когда приходит обновление строк — сообщение WebSocket `cell_updated` или ответ PATCH на собственную правку — фронт SHALL записать в найденную строку каждое поле из обновления, на любой глубине. Это делается и тогда, когда ключа этого поля или содержащего его блока в загруженной строке нет. Правка SHALL NOT теряться из-за того, что при загрузке листа поле было опущено. + +#### Scenario: Соавтор заполняет пустую ячейку +- **WHEN** лист загружен без ключа `data.q1.<поле>` у строки (ячейка пустая), а соавтор вводит в эту ячейку значение +- **THEN** у пользователя ячейка показывает новое значение без перезагрузки листа + +#### Scenario: Отсутствует весь блок +- **WHEN** в загруженной строке нет ключа блока `data.<блок>`, а приходит обновление с `data.<блок>.<поле>` +- **THEN** в строке появляется блок с этим полем, а ячейка показывает значение + +#### Scenario: Прочие поля строки сохраняются +- **WHEN** приходит обновление одного поля строки +- **THEN** остальные поля строки сохраняют прежние значения + +### Requirement: Очистка ячейки доходит до соавторов +Если в обновлении поле равно `null`, фронт SHALL записать `null`, и ячейка SHALL показываться пустой. + +#### Scenario: Соавтор очищает ячейку +- **WHEN** у пользователя ячейка показывает значение, а соавтор очищает её +- **THEN** у пользователя ячейка становится пустой без перезагрузки листа + +### Requirement: Отсутствующее значение показывается как null +Если ключа нет в `data`, редактируемая таблица, свод 1 и свод 2 SHALL показывать и обрабатывать значение так же, как явный `null`. Отображение: пустая ячейка в листе, «—» в сводах. Правила подсветки ошибок валидации не срабатывают. Поиск и фильтр по колонке не находят такую ячейку по непустому запросу. При входе в редактирование поле ввода пустое. Правка, которую фронт отправляет по WebSocket, SHALL всегда содержать ключ `value`; пустое значение передаётся как `null`, а не опускается. + +#### Scenario: Выход из пустой ячейки без ввода +- **WHEN** пользователь входит в редактирование ячейки, ключа которой нет в строке, и выходит без ввода +- **THEN** сообщение `cell_updated` содержит `"value": null`, как и для ячейки с явным `null`, а пользователь не получает ошибку + +#### Scenario: Пустая ячейка свода 2 +- **WHEN** свод 2 загружен без ключа `data.q1.<поле>` у строки +- **THEN** ячейка показывает «—», как и для `null` + +#### Scenario: Валидация не срабатывает +- **WHEN** у строки листа отсутствует ключ поля, к которому привязано правило валидации (например, остаток бронирования) +- **THEN** ячейка не подсвечивается как ошибочная diff --git a/openspec/changes/aurora-1848-sheet-json/specs/sheet-read-api/spec.md b/openspec/changes/aurora-1848-sheet-json/specs/sheet-read-api/spec.md new file mode 100644 index 0000000..8c09f78 --- /dev/null +++ b/openspec/changes/aurora-1848-sheet-json/specs/sheet-read-api/spec.md @@ -0,0 +1,133 @@ +# Spec Delta + +## Purpose + +Контракт ответа тяжёлых GET с табличными данными: лист формы, своды 1 и 2, отчёты и сводные проектов РФ. Здесь описано, что получает фронт: обёртка, строки, отсутствие null-полей, служебный заголовок времени БД. Описаны и неизменные пути записи и выгрузки, и пороги производительности. + +## ADDED Requirements + +### Requirement: Обёртка и строки ответа тяжёлых GET +Следующие эндпоинты (далее — «тяжёлые GET») SHALL отвечать при успехе статусом 200, `Content-Type: application/json` и телом-объектом `{"success": true, "message": null, "result": [...], "count": N}`: +- `GET /api/v1/form/{form_id}/sheet/{sheet}`, +- `GET /api/v1/svod/1/{sheet}/{year}`, +- `GET /api/v1/svod/2/{sheet}/{year}`, +- `GET /api/v1/projects/{project_id}/report/{year}/{report_type}`, +- `GET /api/v1/rf-rollup/{branch_id}/{year}`, +- `GET /api/v1/projects/{project_id}/summary`, +- `GET /api/v1/projects/plan-summary`, `GET /api/v1/projects/fact-summary`, `GET /api/v1/projects/detail-summary`. + +Каждый элемент `result` SHALL быть объектом с ключами `row_type` (строка), `depth` (целое), `sort_order` (целое или null) и `data` (объект). `count` SHALL равняться числу элементов `result`. Строки SHALL идти в том порядке, в каком их возвращает SQL-функция эндпоинта. Ответ SHALL сжиматься gzip так же, как остальные ответы API: при `Accept-Encoding: gzip` и размере от 1000 байт. + +#### Scenario: Лист формы +- **WHEN** пользователь с доступом к форме запрашивает `GET /api/v1/form/{id}/sheet/AHR` для формы FORM_2 +- **THEN** ответ 200, тело — объект с `success: true`, `message: null`, `result` — массив строк с ключами `row_type`, `depth`, `sort_order`, `data`, первая строка — `META` с `depth = -1`, `count` равен длине `result` + +#### Scenario: Пустой свод +- **WHEN** пользователь без доступа ни к одной из запрошенных `org_ids` запрашивает `GET /api/v1/svod/2/GO/{year}?org_ids=...` +- **THEN** ответ 200 с телом `{"success": true, "message": null, "result": [], "count": 0}` + +#### Scenario: Сжатие +- **WHEN** клиент запрашивает свод 2 с заголовком `Accept-Encoding: gzip` +- **THEN** ответ приходит с `Content-Encoding: gzip`, а после распаковки — тот же JSON + +### Requirement: Значения ответа совпадают с прежними +После разбора JSON тяжёлые GET SHALL отдавать те же строки и те же значения, что отдавались до этого изменения при тех же данных, параметрах и пользователе. Единственное допустимое отличие — правило про null-поля (см. требование «Ответ листа и сводов без null-полей»). Текстовая запись чисел MAY отличаться (`1.50` вместо `1.5`), если после разбора значения равны. Порядок ключей в объектах и пробелы MAY отличаться. + +Проверки доступа, ответы на ошибки (статус и тело) и обработка параметров (`direction`, `sections`, `org_ids`, `offset`, `limit`, `sort_by`, `sort_direction`, `branch_id`, `project_type`) SHALL остаться прежними. + +#### Scenario: Сравнение до и после на стенде +- **WHEN** на одних и тех же данных стенда `perf/` сняты ответы всех листов, сводов 1 и 2 и отчётов проектов по манифесту — сначала на версии до изменения, затем после PR A +- **THEN** для каждого запроса результат `json.loads` ответа «после» равен результату `json.loads` ответа «до» + +#### Scenario: Ошибки не изменились +- **WHEN** запрашивается лист несуществующей формы (`GET /api/v1/form/999/sheet/AHR`) или лист свода вне допустимого списка +- **THEN** статус и тело ответа такие же, как до изменения (404 с прежним `message`) + +#### Scenario: Ограничение организаций в своде +- **WHEN** пользователь с ролью EXECUTOR_DFIP запрашивает свод с `org_ids`, часть которых ему не назначена +- **THEN** в ответе только строки назначенных ему организаций, как и до изменения + +### Requirement: Ответ листа и сводов без null-полей +После PR B в ответах `GET /api/v1/form/{form_id}/sheet/{sheet}`, `GET /api/v1/svod/1/{sheet}/{year}` и `GET /api/v1/svod/2/{sheet}/{year}` объект `data` каждой строки, включая `META`, SHALL NOT содержать полей со значением `null` на любой глубине вложенности объектов. Объект, у которого все поля были `null`, SHALL остаться пустым объектом `{}`, а не исчезнуть. Элементы массивов, равные `null`, SHALL остаться. Ключи верхнего уровня строки (`row_type`, `depth`, `sort_order`, `data`) SHALL присутствовать всегда. Значения, отличные от `null`, SHALL совпадать с прежними. + +Отчёты и сводные проектов (эндпоинты `/api/v1/projects/...` и `/api/v1/rf-rollup/...`) SHALL по-прежнему отдавать `null` явно. + +#### Scenario: Пустая ячейка листа +- **WHEN** у строки INPUT листа FORM_2/AHR колонка `q1.<поле>` не заполнена и фронт запрашивает лист +- **THEN** в `data.q1` строки нет ключа этого поля, остальные поля `data.q1` присутствуют со своими значениями + +#### Scenario: Сравнение до и после на стенде +- **WHEN** на одних и тех же данных сняты ответы всех листов и сводов 1 и 2 до изменения и после PR B +- **THEN** ответ «после» после `json.loads` равен ответу «до», из `data` которого рекурсивно удалены поля со значением `null` + +#### Scenario: Отчёт проекта не меняется +- **WHEN** после PR B запрашивается `GET /api/v1/projects/{id}/report/{year}/LIMIT` +- **THEN** поля `data` со значением `null` присутствуют, как и до изменения + +### Requirement: Ответы на правку отдают null явно +Ответы путей записи SHALL содержать поля со значением `null` явно, как до изменения: `PATCH /api/v1/form/{id}/sheet/{sheet}/cell`, `PATCH .../cells`, `POST .../line`, `DELETE .../line/{row_id}`, правки FORM_3 в `/api/v1/projects/...` и сообщения WebSocket (`cell_updated` и остальные с `result`). Очищенная ячейка SHALL приходить в ответе как ключ со значением `null`. + +#### Scenario: Очистка ячейки +- **WHEN** пользователь отправляет `PATCH /api/v1/form/{id}/sheet/{sheet}/cell` со значением `null` для заполненной ячейки +- **THEN** в строке ответа есть ключ этой колонки со значением `null` + +#### Scenario: Эхо соавтору +- **WHEN** пользователь очищает ячейку через WebSocket +- **THEN** соавторы получают `cell_updated`, где у этой колонки значение `null`, а не отсутствующий ключ + +### Requirement: Выгрузка Excel не меняется +Выгрузки Excel листов, сводов 1 и 2 и отчётов проектов SHALL формировать то же содержимое, что и до изменения, при тех же данных и параметрах. + +#### Scenario: Выгрузка свода 2 +- **WHEN** после PR A и PR B выгружается свод 2 GO за год +- **THEN** содержимое файла (ячейки и значения) совпадает с выгрузкой до изменения + +### Requirement: Заголовок X-DB-Time-Ms +Каждый успешный ответ тяжёлого GET SHALL содержать заголовок `X-DB-Time-Ms` — неотрицательное число миллисекунд с двумя знаками после точки. Это время от начала обработчика до получения строк из БД. Обработчик SHALL по-прежнему писать в лог запись `db_time` с тем же набором полей. + +#### Scenario: Заголовок на ответе листа +- **WHEN** запрашивается `GET /api/v1/form/{id}/sheet/{sheet}` +- **THEN** в ответе есть заголовок `X-DB-Time-Ms`, его значение разбирается как число ≥ 0 + +#### Scenario: Методика api_split +- **WHEN** после изменения запускается `perf/analysis/api_split.py --scale 1` +- **THEN** для каждого запроса скрипт получает числовое `db_ms` (не `nan`) + +### Requirement: Схема OpenAPI не меняется +В схеме OpenAPI (`/openapi.json`) у тяжёлых GET схема успешного ответа SHALL остаться `BaseListResponse[SheetResponse]`, как до изменения. + +#### Scenario: Схема ответа листа +- **WHEN** запрашивается `/openapi.json` +- **THEN** у `GET /api/v1/form/{form_id}/sheet/{sheet}` схема ответа 200 ссылается на ту же компоненту, что и до изменения + +### Requirement: Время ответа тяжёлых GET +На стенде `perf/` (масштаб 1, те же данные, напрямую на API, медиана 10 повторов после прогрева) после PR B полное время ответа SHALL снизиться относительно версии до изменения: +- `GET /api/v1/svod/2/GO/{year}` (все организации, роль dfip) — не менее чем на 30 %; +- `GET /api/v1/form/{id}/sheet/AHR` для FORM_2 (роль rf) — не менее чем на 10 %. Дальнейшее ускорение этого листа — предмет AURORA-1847 (SQL листа). + +После PR A полное время ни одного запроса из набора `api_split.py` SHALL NOT вырасти больше чем на 5 % от версии до изменения. Доля Python (`(полное − X-DB-Time-Ms) / полное`) — справочная метрика, порогом она не является. + +Пороги этого требования и требования «Размер ответа листа FORM_2/AHR уменьшается» — критерии приёмки задачи, а не условие мержа PR. PR вливается по зелёным тестам и доказанной корректности (требования «Значения ответа совпадают с прежними» и «Ответ листа и сводов без null-полей»: сравнение ответов на стенде в режимах exact и strip-nulls). Корректность — жёсткое условие. Цифры замера дописываются в описание PR, недобор порога оформляется follow-up задачей. + +#### Scenario: Замер свода 2 GO +- **WHEN** `perf/analysis/api_split.py --scale 1 --repeats 10` запускается на версии до изменения и после PR B на одних и тех же данных +- **THEN** медиана `total_ms` свода 2 GO «после» не больше 0,7 × медианы «до» + +#### Scenario: Замер листа FORM_2/AHR +- **WHEN** тот же замер для листа FORM_2/AHR +- **THEN** медиана `total_ms` «после» не больше 0,9 × медианы «до» + +#### Scenario: PR A не замедляет +- **WHEN** тот же замер на версии после PR A +- **THEN** медиана `total_ms` каждого запроса набора не больше 1,05 × медианы «до» + +#### Scenario: Недобор порога +- **WHEN** тесты зелёные, сравнение ответов на стенде прошло, а замер показал недобор порога +- **THEN** PR вливается, цифры фиксируются в описании PR, недобор оформляется follow-up задачей + +### Requirement: Размер ответа листа FORM_2/AHR уменьшается +После PR B распакованный размер ответа `GET /api/v1/form/{id}/sheet/AHR` для FORM_2 на стенде (масштаб 1) SHALL быть меньше, чем до изменения (критерий Jira AURORA-1848; вместе с −10 % времени — критерий по этому листу, согласовано 28.10). + +#### Scenario: Сравнение размеров +- **WHEN** `api_split.py --scale 1` запускается до изменения и после PR B +- **THEN** значение `bytes` листа FORM_2/AHR «после» меньше, чем «до» diff --git a/perf/analysis/api_split.py b/perf/analysis/api_split.py index 4753277..972ae6c 100644 --- a/perf/analysis/api_split.py +++ b/perf/analysis/api_split.py @@ -2,16 +2,17 @@ cd perf && .venv/bin/python analysis/api_split.py --scale 1 --repeats 10 --out report-data/20261026-s1/analysis -Обработчики листов, сводов и отчётов проектов сами ставят заголовок `X-DB-Time-Ms` (api/src/api/v1/forms.py:184, -summary.py:74,126, projects.py:128): время от начала обработчика до получения строк из БД (поиск формы + SQL-функция + -разбор jsonb драйвером). Остаток `полное время - X-DB-Time-Ms` - сборка pydantic-моделей (`_rows_to_sheet_response`, -forms.py:27), валидация `response_model`, JSON-кодирование, сжатие GZipMiddleware (api/src/main.py:248, compresslevel=1), -передача и распаковка на клиенте (httpx). +Обработчики листов, сводов и отчётов проектов сами ставят заголовок `X-DB-Time-Ms` (GET: api/src/api/v1/forms.py:198 get_sheet; +summary.py:75,126; projects.py:139,272,305,329): время от начала обработчика до получения строк из БД (поиск +формы + SQL-функция + отдача готового JSON драйвером). После задач A ответ собирается из строк jsonb без pydantic, поэтому +остаток `полное время - X-DB-Time-Ms` - это склейка строк в `list_json_response` (api/src/api/v1/json_response.py), сжатие +GZipMiddleware (api/src/main.py:248, compresslevel=1), передача и распаковка на клиенте (httpx). py-spy на macOS требует root - этот способ его заменяет для вопроса «БД или Python». Запросы: самые тяжёлые из bench (api.jsonl) - листы FORM_2/AHR, FORM_4/CAP и FORM_4/AHR (rf), свод 2 GO и RF и свод 1 MAIN (dfip), отчёт проекта (rf), сводные проектов (dfip). Напрямую на :8000 (без proxy фронта), httpx, тело читается -целиком. Вывод: /api-split.json и api-split.md. +целиком. Колонка «на проводе» - `num_bytes_downloaded` httpx (байты после сжатия, как передано по сети), «размер» - декодированное тело. +Вывод: /api-split.json и api-split.md. """ from __future__ import annotations @@ -26,6 +27,27 @@ import httpx PERF = Path(__file__).resolve().parents[1] +def measure(c: httpx.Client, path: str, token: str, repeats: int) -> dict: + """Один запрос-прогрев и `repeats` замеров; медианы времени, размеры из последнего замера.""" + tot, db, size, wire = [], [], 0, 0 + rows, r = 0, None + for i in range(repeats + 1): + t0 = time.perf_counter() + r = c.get(path, headers={"Authorization": f"Bearer {token}"}) + body = r.content + dt = (time.perf_counter() - t0) * 1000 + r.raise_for_status() + if i == 0: + rows = len(r.json().get("result") or []) # фактическое число строк ответа (манифест - до grow) + continue # прогрев + tot.append(dt) + db.append(float(r.headers.get("x-db-time-ms", "nan"))) + size = len(body) + wire = r.num_bytes_downloaded + return {"rows": rows, "total_ms": statistics.median(tot), "db_ms": statistics.median(db), "bytes": size, + "wire_bytes": wire, "content_encoding": r.headers.get("content-encoding")} + + def main(argv=None): ap = argparse.ArgumentParser() ap.add_argument("--scale", type=int, default=1) @@ -61,21 +83,10 @@ def main(argv=None): tokens[role] = r.json()["access_token"] res = [] for role, label, path in reqs: - tot, db, size = [], [], 0 - for i in range(a.repeats + 1): - t0 = time.perf_counter() - r = c.get(path, headers={"Authorization": f"Bearer {tokens[role]}"}) - body = r.content - dt = (time.perf_counter() - t0) * 1000 - r.raise_for_status() - if i == 0: - rows = len(r.json().get("result") or []) # фактическое число строк ответа (манифест - до grow) - continue # прогрев - tot.append(dt) - db.append(float(r.headers.get("x-db-time-ms", "nan"))) - size = len(body) - row = {"role": role, "request": f"{label} ({rows} строк ответа)", "rows": rows, "path": path, "total_ms": round(statistics.median(tot), 1), - "db_ms": round(statistics.median(db), 1), "bytes": size, "content_encoding": r.headers.get("content-encoding")} + m = measure(c, path, tokens[role], a.repeats) + row = {"role": role, "request": f"{label} ({m['rows']} строк ответа)", "rows": m["rows"], "path": path, + "total_ms": round(m["total_ms"], 1), "db_ms": round(m["db_ms"], 1), "bytes": m["bytes"], + "wire_bytes": m["wire_bytes"], "content_encoding": m["content_encoding"]} row["python_ms"] = round(row["total_ms"] - row["db_ms"], 1) row["python_share"] = round(row["python_ms"] / row["total_ms"], 2) row["ratio_total_db"] = round(row["total_ms"] / row["db_ms"], 2) if row["db_ms"] else None @@ -85,10 +96,10 @@ def main(argv=None): out.mkdir(parents=True, exist_ok=True) (out / "api-split.json").write_text(json.dumps(res, ensure_ascii=False, indent=1)) md = [f"# API: БД против Python (api_split.py, масштаб {a.scale}, медиана {a.repeats} повторов после прогрева, напрямую :8000)", "", - "| запрос | роль | полное, мс | X-DB-Time-Ms | остаток (Python + передача), мс | доля остатка | полное / БД | размер, МБ | Content-Encoding |", - "|---|---|---|---|---|---|---|---|---|"] + "| запрос | роль | полное, мс | X-DB-Time-Ms | остаток (Python + передача), мс | доля остатка | полное / БД | размер, МБ | на проводе, МБ | Content-Encoding |", + "|---|---|---|---|---|---|---|---|---|---|"] md += [f"| {r['request']} | {r['role']} | {r['total_ms']} | {r['db_ms']} | {r['python_ms']} | {r['python_share']} | {r['ratio_total_db']} | " - f"{r['bytes'] / 1e6:.2f} | {r['content_encoding'] or 'нет'} |" for r in res] + f"{r['bytes'] / 1e6:.2f} | {r['wire_bytes'] / 1e6:.2f} | {r['content_encoding'] or 'нет'} |" for r in res] (out / "api-split.md").write_text("\n".join(md) + "\n") diff --git a/perf/analysis/compare_responses.py b/perf/analysis/compare_responses.py new file mode 100644 index 0000000..7693c8c --- /dev/null +++ b/perf/analysis/compare_responses.py @@ -0,0 +1,186 @@ +"""Сравнение ответов тяжёлых GET до и после задач A и B (AURORA-1848). + +Порядок использования (D7.1): + 1. `dump` на базовом коммите (до A): python analysis/compare_responses.py dump --scale 1 --out /path/base + 2. `dump` на коммите A: ... dump --scale 1 --out /path/a + 3. `compare /path/base /path/a --mode exact` - A не меняет ответы ни на байт значения + 4. `dump` на коммите B: ... dump --scale 1 --out /path/b + 5. `compare /path/base /path/b --mode strip-nulls` - B: листы и своды = «до» с вырезанными null в data + (семантика jsonb_strip_nulls PG 15), остальные ответы сравниваются точно. +Снимок: /<имя>.json.gz (распакованное тело) и /index.json ({имя: статус}). Код возврата compare: 1 при расхождении. +Стенд запускает человек; тесты работают на tmp_path и MockTransport. +""" +from __future__ import annotations + +import argparse +import gzip +import json +import sys +from pathlib import Path + +import httpx + +PERF = Path(__file__).resolve().parents[1] + + +def build_requests(man: dict) -> list[tuple[str, str, str]]: + """Список (роль, имя, путь с query) по манифесту.""" + out: list[tuple[str, str, str]] = [] + sheets = man.get("sheets", []) + for s in sheets: + role = "rf" if s.get("owner", "rf") == "rf" else "dfip" + d = s.get("direction") + path = f"/api/v1/form/{s['form_id']}/sheet/{s['sheet']}" + (f"?direction={d}" if d is not None else "") + name = f"sheet-{s['form_type']}-{s['sheet']}-{d or 'none'}-{s['form_id']}" + out.append((role, name, path)) + if s["form_type"] == "FORM_2" and s["sheet"] == "AHR" and role == "rf": + sep = "&" if "?" in path else "?" + out.append((role, f"{name}-sections", f"{path}{sep}sections=plan,q1")) + f2 = next((s for s in sheets if s["form_type"] == "FORM_2" and s["sheet"] == "AHR" and s.get("owner", "rf") == "rf"), None) + year = (f2 or sheets[0])["year"] if sheets else None + if year is not None: + for k in ("FORM_1", "FORM_2", "FORM_3", "FORM_4", "MAIN"): + out.append(("dfip", f"svod1-{k}", f"/api/v1/svod/1/{k}/{year}")) + for k in ("GO", "RF"): + out.append(("dfip", f"svod2-{k}", f"/api/v1/svod/2/{k}/{year}")) + for kind in ("plan", "fact", "detail"): + out.append(("dfip", f"projects-{kind}-summary", f"/api/v1/projects/{kind}-summary?year={year}")) + for r in man.get("project_reports", []): + out.append(("rf", f"report-{r['project_id']}-{r['year']}-{r['report_type']}", + f"/api/v1/projects/{r['project_id']}/report/{r['year']}/{r['report_type']}")) + for pid in dict.fromkeys(r["project_id"] for r in man.get("project_reports", [])): + out.append(("rf", f"project-summary-{pid}", f"/api/v1/projects/{pid}/summary")) + return out + + +def strip_nulls(value): + """Как jsonb_strip_nulls: убирает None-поля объектов рекурсивно; None в списках и {} остаются.""" + if isinstance(value, dict): + return {k: strip_nulls(v) for k, v in value.items() if v is not None} + if isinstance(value, list): + return [strip_nulls(v) for v in value] + return value + + +def strip_response(body: dict) -> dict: + """Копия тела, где strip_nulls применён только к row['data'] строк result.""" + out = dict(body) + if isinstance(body.get("result"), list): + out["result"] = [{**row, "data": strip_nulls(row["data"])} if isinstance(row, dict) and "data" in row else row + for row in body["result"]] + return out + + +def is_stripped(name: str) -> bool: + return name.startswith(("sheet-", "svod1-", "svod2-")) + + +def first_diff(a, b, path: str = "$") -> str | None: + """Путь до первого отличия с обоими значениями; None при равенстве.""" + if isinstance(a, dict) and isinstance(b, dict): + if set(a) != set(b): + return f"{path}: ключи {sorted(set(a) ^ set(b))} есть только в одном из ответов" + for k in a: + d = first_diff(a[k], b[k], f"{path}.{k}") + if d: + return d + return None + if isinstance(a, list) and isinstance(b, list): + if len(a) != len(b): + return f"{path}: длина {len(a)} != {len(b)}" + for i, (x, y) in enumerate(zip(a, b)): + d = first_diff(x, y, f"{path}[{i}]") + if d: + return d + return None + if type(a) is type(b) and a == b: + return None + return f"{path}: {a!r} != {b!r}" + + +def _raw(d: Path, name: str) -> bytes: + return gzip.decompress((d / f"{name}.json.gz").read_bytes()) + + +def compare(before: Path, after: Path, mode: str) -> tuple[int, list[str]]: + bi = json.loads((before / "index.json").read_text()) + ai_p = after / "index.json" + ai = json.loads(ai_p.read_text()) if ai_p.exists() else {} + ok, bad = 0, [] + for name, st in bi.items(): + if name not in ai or not (after / f"{name}.json.gz").exists() or not (before / f"{name}.json.gz").exists(): + bad.append(f"{name}: нет файла в before или after") + continue + if st != ai[name]: + bad.append(f"{name}: статус {st} != {ai[name]}") + continue + rb, ra = _raw(before, name), _raw(after, name) + try: + b, a = json.loads(rb), json.loads(ra) + except ValueError: + if rb == ra: + ok += 1 + else: + bad.append(f"{name}: тела не JSON и различаются ({len(rb)} и {len(ra)} байт)") + continue + if mode == "strip-nulls" and is_stripped(name) and isinstance(b, dict): + b = strip_response(b) + d = first_diff(b, a) + if d: + bad.append(f"{name}: {d}") + else: + ok += 1 + return ok, bad + + +def dump(man: dict, c: httpx.Client, out: Path) -> dict: + """Снимок всех запросов через готовый клиент; сбой одного запроса не прерывает дамп, index.json пишется всегда.""" + out.mkdir(parents=True, exist_ok=True) + index, tokens = {}, {} + reqs = build_requests(man) + try: + for role in sorted({r for r, _, _ in reqs}): + u = man["users"][role] + r = c.post("/api/v1/auth/login", json={"username": u["username"], "password": u["password"]}) + r.raise_for_status() + tokens[role] = r.json()["access_token"] + for role, name, path in reqs: + try: + r = c.get(path, headers={"Authorization": f"Bearer {tokens[role]}"}) + (out / f"{name}.json.gz").write_bytes(gzip.compress(r.content)) + index[name] = r.status_code + print(name, r.status_code, len(r.content), flush=True) + except httpx.HTTPError as e: + index[name] = f"error: {type(e).__name__}" + print(name, index[name], flush=True) + finally: + (out / "index.json").write_text(json.dumps(index, ensure_ascii=False, indent=1)) + return index + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser() + sub = ap.add_subparsers(dest="cmd", required=True) + d = sub.add_parser("dump") + d.add_argument("--scale", type=int, default=1) + d.add_argument("--out", required=True) + d.add_argument("--api", default="http://localhost:8000") + c = sub.add_parser("compare") + c.add_argument("before") + c.add_argument("after") + c.add_argument("--mode", choices=("exact", "strip-nulls"), default="exact") + a = ap.parse_args(argv) + if a.cmd == "dump": + man = json.loads((PERF / "results" / f"manifest-s{a.scale}.json").read_text()) + with httpx.Client(base_url=a.api, timeout=300) as c: + dump(man, c, Path(a.out)) + return 0 + ok, bad = compare(Path(a.before), Path(a.after), a.mode) + print(f"совпало {ok} из {ok + len(bad)}") + for line in bad: + print(line) + return 1 if bad else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/perf/tests/test_api_split.py b/perf/tests/test_api_split.py new file mode 100644 index 0000000..a749ef9 --- /dev/null +++ b/perf/tests/test_api_split.py @@ -0,0 +1,28 @@ +import gzip +import json +import sys +from pathlib import Path + +import httpx + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "analysis")) + +from api_split import measure # noqa: E402 + + +def test_measure_reports_wire_and_decoded_bytes(): + raw = json.dumps({"result": [{"a": 1}] * 50}).encode() + gz = gzip.compress(raw) + + def handler(request): + assert request.headers["authorization"] == "Bearer tok" + # stream, а не content=: у httpx num_bytes_downloaded считается при чтении потока (как у сетевого транспорта) + return httpx.Response(200, headers={"content-encoding": "gzip", "x-db-time-ms": "12.50"}, stream=httpx.ByteStream(gz)) + + with httpx.Client(transport=httpx.MockTransport(handler), base_url="http://t") as c: + m = measure(c, "/x", "tok", repeats=3) + assert m["rows"] == 50 + assert m["bytes"] == len(raw) + assert m["wire_bytes"] == len(gz) + assert m["db_ms"] == 12.5 + assert m["content_encoding"] == "gzip" diff --git a/perf/tests/test_compare_responses.py b/perf/tests/test_compare_responses.py new file mode 100644 index 0000000..d9a8522 --- /dev/null +++ b/perf/tests/test_compare_responses.py @@ -0,0 +1,133 @@ +"""Самопроверка compare_responses (без стенда).""" +import gzip +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "analysis")) + +import compare_responses as cr # noqa: E402 + + +def test_strip_nulls_pg_semantics(): + v = {"a": None, "b": {"c": None}, "d": [None, {"e": None, "f": 1}], "g": {}} + assert cr.strip_nulls(v) == {"b": {}, "d": [None, {"f": 1}], "g": {}} + + +def test_strip_response_keeps_wrapper_and_sort_order(): + body = {"success": True, "message": None, "count": 1, + "result": [{"row_type": "INPUT", "depth": 3, "sort_order": None, "data": {"x": None, "y": 1}}]} + out = cr.strip_response(body) + assert out["message"] is None + assert out["result"][0]["sort_order"] is None + assert out["result"][0]["data"] == {"y": 1} + assert body["result"][0]["data"] == {"x": None, "y": 1} # исходное не изменено + + +def test_first_diff_path(): + a = {"result": [{"data": {"q1": {"m1": 1}}}]} + b = {"result": [{"data": {"q1": {"m1": 2}}}]} + assert cr.first_diff(a, b).startswith("$.result[0].data.q1.m1") + assert cr.first_diff(a, a) is None + assert cr.first_diff({"a": [1]}, {"a": [1, 2]}).startswith("$.a") + assert cr.first_diff({"a": 1}, {"a": 1, "b": 2}).startswith("$") + + +def _write(d: Path, name: str, body: dict, status: int = 200): + d.mkdir(parents=True, exist_ok=True) + (d / f"{name}.json.gz").write_bytes(gzip.compress(json.dumps(body).encode())) + idx = d / "index.json" + cur = json.loads(idx.read_text()) if idx.exists() else {} + cur[name] = status + idx.write_text(json.dumps(cur)) + + +def _body(data): + return {"success": True, "message": None, "count": 1, + "result": [{"row_type": "INPUT", "depth": 1, "sort_order": 1, "data": data}]} + + +def test_compare_exact_and_strip_modes(tmp_path): + b, a = tmp_path / "before", tmp_path / "after" + for name in ("sheet-FORM_2-AHR-none-1", "report-5-2026-LIMIT"): + _write(b, name, _body({"x": None, "y": 1})) + _write(a, name, _body({"y": 1})) + n, bad = cr.compare(b, a, "exact") + assert n == 0 and len(bad) == 2 + n, bad = cr.compare(b, a, "strip-nulls") + assert n == 1 and len(bad) == 1 and "report-5-2026-LIMIT" in bad[0] + + +def test_compare_status_mismatch(tmp_path): + b, a = tmp_path / "before", tmp_path / "after" + _write(b, "svod1-MAIN", _body({"y": 1}), 200) + _write(a, "svod1-MAIN", _body({"y": 1}), 500) + n, bad = cr.compare(b, a, "exact") + assert n == 0 and len(bad) == 1 + + +def test_compare_missing_after(tmp_path): + b, a = tmp_path / "before", tmp_path / "after" + _write(b, "svod1-MAIN", _body({})) + a.mkdir() + n, bad = cr.compare(b, a, "exact") + assert n == 0 and len(bad) == 1 + + +def test_build_requests_minimal_manifest(): + man = {"sheets": [ + {"form_id": 1, "form_type": "FORM_2", "sheet": "AHR", "direction": None, "year": 2026, "url": "", "rows": 1, "owner": "rf"}, + {"form_id": 2, "form_type": "FORM_1", "sheet": "AHR", "direction": "Support", "year": 2026, "url": "", "rows": 1, "owner": "ssp"}, + ], "project_reports": [{"project_id": 5, "year": 2026, "report_type": "LIMIT", "url": "", "rows": 1}]} + reqs = cr.build_requests(man) + by = {n: (r, p) for r, n, p in reqs} + assert by["sheet-FORM_2-AHR-none-1"] == ("rf", "/api/v1/form/1/sheet/AHR") + assert by["sheet-FORM_2-AHR-none-1-sections"] == ("rf", "/api/v1/form/1/sheet/AHR?sections=plan,q1") + assert by["sheet-FORM_1-AHR-Support-2"] == ("dfip", "/api/v1/form/2/sheet/AHR?direction=Support") + assert sum(n.startswith("svod1-") for n in by) == 5 + assert sum(n.startswith("svod2-") for n in by) == 2 + assert by["svod1-MAIN"] == ("dfip", "/api/v1/svod/1/MAIN/2026") + assert by["report-5-2026-LIMIT"][0] == "rf" + assert by["project-summary-5"] == ("rf", "/api/v1/projects/5/summary") + assert {"projects-plan-summary", "projects-fact-summary", "projects-detail-summary"} <= set(by) + assert not any("rollup" in n for n in by) + assert cr.is_stripped("sheet-x") and cr.is_stripped("svod2-GO") and not cr.is_stripped("report-1") + + +def test_strip_response_without_result(): + assert cr.strip_response({"success": False}) == {"success": False} + assert cr.strip_response({"result": None}) == {"result": None} + + +def test_compare_non_json_bodies(tmp_path): + b, a = tmp_path / "before", tmp_path / "after" + for name, bb, ab in (("svod1-A", b"502", b"502"), ("svod1-B", b"502", b"503"), + ("svod1-C", b"", b'{"result": []}')): + for d, raw in ((b, bb), (a, ab)): + d.mkdir(exist_ok=True) + (d / f"{name}.json.gz").write_bytes(gzip.compress(raw)) + for d in (b, a): + (d / "index.json").write_text(json.dumps({"svod1-A": 502, "svod1-B": 502, "svod1-C": 502})) + n, bad = cr.compare(b, a, "strip-nulls") + assert n == 1 and len(bad) == 2 + + +def test_dump_survives_network_error(tmp_path): + import httpx + + def handler(req: httpx.Request) -> httpx.Response: + if req.url.path == "/api/v1/auth/login": + return httpx.Response(200, json={"access_token": "t"}) + if req.url.path.startswith("/api/v1/svod/1/MAIN"): + raise httpx.ConnectError("boom", request=req) + return httpx.Response(200, json={"result": []}) + + man = {"users": {"rf": {"username": "u", "password": "p"}, "dfip": {"username": "u", "password": "p"}}, + "sheets": [{"form_id": 1, "form_type": "FORM_2", "sheet": "AHR", "direction": None, "year": 2026, + "url": "", "rows": 1, "owner": "rf"}], "project_reports": []} + with httpx.Client(base_url="http://x", transport=httpx.MockTransport(handler)) as c: + idx = cr.dump(man, c, tmp_path / "o") + assert idx["svod1-MAIN"] == "error: ConnectError" + assert idx["sheet-FORM_2-AHR-none-1"] == 200 + assert json.loads((tmp_path / "o" / "index.json").read_text()) == idx + assert not (tmp_path / "o" / "svod1-MAIN.json.gz").exists()