Merge remote-tracking branch 'origin/test' into feat/249-aurora-1936-row-delete-confirm
This commit is contained in:
commit
3fb1a3b162
@ -43,6 +43,10 @@ async def get_form_phases(
|
|||||||
org_unit_service = OrgUnitService(db)
|
org_unit_service = OrgUnitService(db)
|
||||||
bf_service = BudgetFormService(db)
|
bf_service = BudgetFormService(db)
|
||||||
|
|
||||||
|
form = await bf_service.get(user=current_user, budget_form_id=form_id)
|
||||||
|
if not form:
|
||||||
|
raise HTTPException(404, "Форма не найдена")
|
||||||
|
|
||||||
phases = await fp_service.get_list(
|
phases = await fp_service.get_list(
|
||||||
budget_form_id=form_id,
|
budget_form_id=form_id,
|
||||||
user=current_user,
|
user=current_user,
|
||||||
@ -50,22 +54,18 @@ async def get_form_phases(
|
|||||||
direction=direction,
|
direction=direction,
|
||||||
phase_code=phase_code,
|
phase_code=phase_code,
|
||||||
)
|
)
|
||||||
if phases:
|
org_unit = await org_unit_service.get(org_unit_id=form.org_unit_id)
|
||||||
form = await bf_service.get(user=current_user, budget_form_id=form_id)
|
result = [FormPhaseResponse.model_validate(p) for p in phases]
|
||||||
org_unit = await org_unit_service.get(org_unit_id=form.org_unit_id)
|
if org_unit.utc_offset:
|
||||||
result = [FormPhaseResponse.model_validate(p) for p in phases]
|
tz = timezone_from_offset(org_unit.utc_offset)
|
||||||
if org_unit.utc_offset:
|
for element in result:
|
||||||
tz = timezone_from_offset(org_unit.utc_offset)
|
if element.opens_at:
|
||||||
for element in result:
|
element.opens_at = element.opens_at.astimezone(tz)
|
||||||
if element.opens_at:
|
if element.closes_at:
|
||||||
element.opens_at = element.opens_at.astimezone(tz)
|
element.closes_at = element.closes_at.astimezone(tz)
|
||||||
if element.closes_at:
|
|
||||||
element.closes_at = element.closes_at.astimezone(tz)
|
|
||||||
else:
|
|
||||||
result = []
|
|
||||||
return BaseListResponse(
|
return BaseListResponse(
|
||||||
result=result,
|
result=result,
|
||||||
count=len(phases),
|
count=len(result),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -186,6 +186,10 @@ async def get_form3_phases(
|
|||||||
project_service = ProjectService(db)
|
project_service = ProjectService(db)
|
||||||
org_unit_service = OrgUnitService(db)
|
org_unit_service = OrgUnitService(db)
|
||||||
|
|
||||||
|
project = await project_service.get_instance(user=current_user, project_id=project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(404, "Проект не найден")
|
||||||
|
|
||||||
reports = await report_service.get_list(
|
reports = await report_service.get_list(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
year=year,
|
year=year,
|
||||||
@ -195,21 +199,18 @@ async def get_form3_phases(
|
|||||||
|
|
||||||
phases = await f3p_service.get_list(
|
phases = await f3p_service.get_list(
|
||||||
report_id=[el.id for el in reports],
|
report_id=[el.id for el in reports],
|
||||||
|
user=current_user,
|
||||||
phase_code=phase_code,
|
phase_code=phase_code,
|
||||||
)
|
)
|
||||||
if phases:
|
result = [Form3PhaseResponse.model_validate(p) for p in phases]
|
||||||
project = await project_service.get_instance(user=current_user, project_id=project_id)
|
org_unit = await org_unit_service.get(org_unit_id=project.org_unit_id)
|
||||||
org_unit = await org_unit_service.get(org_unit_id=project.org_unit_id)
|
if org_unit.utc_offset:
|
||||||
result = [Form3PhaseResponse.model_validate(p) for p in phases]
|
tz = timezone_from_offset(org_unit.utc_offset)
|
||||||
if org_unit.utc_offset:
|
for element in result:
|
||||||
tz = timezone_from_offset(org_unit.utc_offset)
|
if element.opens_at:
|
||||||
for element in result:
|
element.opens_at = element.opens_at.astimezone(tz)
|
||||||
if element.opens_at:
|
if element.closes_at:
|
||||||
element.opens_at = element.opens_at.astimezone(tz)
|
element.closes_at = element.closes_at.astimezone(tz)
|
||||||
if element.closes_at:
|
|
||||||
element.closes_at = element.closes_at.astimezone(tz)
|
|
||||||
else:
|
|
||||||
result = []
|
|
||||||
if result:
|
if result:
|
||||||
reports = {report.id: report for report in reports}
|
reports = {report.id: report for report in reports}
|
||||||
for i, element in enumerate(result):
|
for i, element in enumerate(result):
|
||||||
@ -217,7 +218,7 @@ async def get_form3_phases(
|
|||||||
element.report_type = reports[phases[i].rf_project_report_id].report_type
|
element.report_type = reports[phases[i].rf_project_report_id].report_type
|
||||||
return BaseListResponse(
|
return BaseListResponse(
|
||||||
result=result,
|
result=result,
|
||||||
count=len(phases),
|
count=len(result),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ class Form3PhaseRepository:
|
|||||||
self,
|
self,
|
||||||
report_id: int | list[int],
|
report_id: int | list[int],
|
||||||
phase_code: str | None = None,
|
phase_code: str | None = None,
|
||||||
|
role: str | None = None,
|
||||||
) -> list[Form3Phase]:
|
) -> list[Form3Phase]:
|
||||||
if isinstance(report_id, int):
|
if isinstance(report_id, int):
|
||||||
query = select(Form3Phase).where(Form3Phase.rf_project_report_id == report_id)
|
query = select(Form3Phase).where(Form3Phase.rf_project_report_id == report_id)
|
||||||
@ -21,6 +22,8 @@ class Form3PhaseRepository:
|
|||||||
query = select(Form3Phase).where(Form3Phase.rf_project_report_id.in_(report_id))
|
query = select(Form3Phase).where(Form3Phase.rf_project_report_id.in_(report_id))
|
||||||
if phase_code is not None:
|
if phase_code is not None:
|
||||||
query = query.where(Form3Phase.phase_code == phase_code)
|
query = query.where(Form3Phase.phase_code == phase_code)
|
||||||
|
if role is not None:
|
||||||
|
query = query.where(Form3Phase.role == role)
|
||||||
return (await self.db.execute(query)).scalars().all()
|
return (await self.db.execute(query)).scalars().all()
|
||||||
|
|
||||||
async def get(
|
async def get(
|
||||||
|
|||||||
@ -16,6 +16,7 @@ class FormPhaseRepository:
|
|||||||
sheet: str | None = None,
|
sheet: str | None = None,
|
||||||
direction: str | None = None,
|
direction: str | None = None,
|
||||||
phase_code: str | None = None,
|
phase_code: str | None = None,
|
||||||
|
role: str | None = None,
|
||||||
) -> list[FormPhase]:
|
) -> list[FormPhase]:
|
||||||
query = select(FormPhase).where(FormPhase.budget_form_id == budget_form_id)
|
query = select(FormPhase).where(FormPhase.budget_form_id == budget_form_id)
|
||||||
if sheet is not None:
|
if sheet is not None:
|
||||||
@ -23,9 +24,11 @@ class FormPhaseRepository:
|
|||||||
FormPhase.sheet == sheet,
|
FormPhase.sheet == sheet,
|
||||||
FormPhase.direction.is_(None) if direction is None else FormPhase.direction == direction
|
FormPhase.direction.is_(None) if direction is None else FormPhase.direction == direction
|
||||||
)
|
)
|
||||||
|
|
||||||
if phase_code is not None:
|
if phase_code is not None:
|
||||||
query = query.where(FormPhase.phase_code == phase_code)
|
query = query.where(FormPhase.phase_code == phase_code)
|
||||||
|
if role is not None:
|
||||||
|
query = query.where(FormPhase.role == role)
|
||||||
return (await self.db.execute(query)).scalars().all()
|
return (await self.db.execute(query)).scalars().all()
|
||||||
|
|
||||||
async def get(
|
async def get(
|
||||||
|
|||||||
@ -14,6 +14,17 @@ class VSPRepository:
|
|||||||
def __init__(self, db: AsyncSession):
|
def __init__(self, db: AsyncSession):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
|
async def get_reg_numbers_by_ids(self, vsp_ids: list[int]) -> dict[int, str]:
|
||||||
|
"""Номера ВСП по id, включая закрытые (is_active=False), для подписи в экспорте."""
|
||||||
|
if not vsp_ids:
|
||||||
|
return {}
|
||||||
|
rows = (
|
||||||
|
await self.db.execute(
|
||||||
|
select(Vsp.id, Vsp.reg_number).where(Vsp.id.in_(vsp_ids), Vsp.is_deleted.is_(False))
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return {row.id: row.reg_number for row in rows}
|
||||||
|
|
||||||
async def get(
|
async def get(
|
||||||
self,
|
self,
|
||||||
vsp_id: int,
|
vsp_id: int,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -249,6 +249,34 @@ class SmetaRowNormalizer:
|
|||||||
return cur
|
return cur
|
||||||
|
|
||||||
|
|
||||||
|
class Form2DepthRowNormalizer:
|
||||||
|
"""Для FORM_2 (Операц/АХР/КВ Сметы РФ): в отличие от Form1DepthRowNormalizer,
|
||||||
|
поля header.* не поднимаются на верхний уровень — FORM_2_DEPTH_COLUMNS
|
||||||
|
ожидает их как header.section_code, header.vsp_id и т.д."""
|
||||||
|
|
||||||
|
def __init__(self, serializer: ExcelValueSerializer):
|
||||||
|
self.serializer = serializer
|
||||||
|
|
||||||
|
def normalize(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
normalized: dict[str, Any] = {}
|
||||||
|
self._normalize_depth(normalized=normalized, original=data)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _normalize_depth(
|
||||||
|
self,
|
||||||
|
normalized: dict[str, Any],
|
||||||
|
original: dict[str, Any],
|
||||||
|
prefix: str = "",
|
||||||
|
) -> None:
|
||||||
|
for key, value in original.items():
|
||||||
|
if key == "line_id":
|
||||||
|
continue
|
||||||
|
if isinstance(value, dict):
|
||||||
|
self._normalize_depth(normalized=normalized, original=value, prefix=f"{prefix}{key}.")
|
||||||
|
else:
|
||||||
|
normalized[f"{prefix}{key}"] = value
|
||||||
|
|
||||||
|
|
||||||
class Form3DepthRowNormalizer:
|
class Form3DepthRowNormalizer:
|
||||||
def __init__(self, serializer: ExcelValueSerializer):
|
def __init__(self, serializer: ExcelValueSerializer):
|
||||||
self.serializer = serializer
|
self.serializer = serializer
|
||||||
|
|||||||
@ -27,13 +27,18 @@ from src.services.budget_form_service import BudgetFormService
|
|||||||
from src.services.export_service.export_normalizers import (
|
from src.services.export_service.export_normalizers import (
|
||||||
ExcelValueSerializer,
|
ExcelValueSerializer,
|
||||||
Form1DepthRowNormalizer,
|
Form1DepthRowNormalizer,
|
||||||
|
Form2DepthRowNormalizer,
|
||||||
Form3DepthRowNormalizer,
|
Form3DepthRowNormalizer,
|
||||||
SmetaRowNormalizer,
|
SmetaRowNormalizer,
|
||||||
)
|
)
|
||||||
from src.services.export_service.export_mappings import (
|
from src.services.export_service.export_mappings import (
|
||||||
FORM1_COLORS,
|
FORM1_COLORS,
|
||||||
FORM1_DEPTH_COLUMNS,
|
FORM1_DEPTH_COLUMNS,
|
||||||
|
FORM1_OPER_DEPTH_COLUMNS,
|
||||||
|
FORM2_COLORS,
|
||||||
FORM2_DEPTH_AHR_SUB_COLUMNS,
|
FORM2_DEPTH_AHR_SUB_COLUMNS,
|
||||||
|
FORM2_AHR_DEPTH_COLUMNS,
|
||||||
|
FORM2_DEPTH_COLUMNS,
|
||||||
FORM3_COLORS,
|
FORM3_COLORS,
|
||||||
FORM4_COLORS,
|
FORM4_COLORS,
|
||||||
FORM4_DEPTH_COLUMNS,
|
FORM4_DEPTH_COLUMNS,
|
||||||
@ -59,6 +64,7 @@ from src.services.export_service.export_writers import (
|
|||||||
from src.services.project_service import ProjectService
|
from src.services.project_service import ProjectService
|
||||||
from src.services.sheet_service import SheetService
|
from src.services.sheet_service import SheetService
|
||||||
from src.services.summary_service import SummaryService
|
from src.services.summary_service import SummaryService
|
||||||
|
from src.repository.vsp_repository import VSPRepository
|
||||||
|
|
||||||
XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
ZIP_MEDIA_TYPE = "application/zip"
|
ZIP_MEDIA_TYPE = "application/zip"
|
||||||
@ -114,8 +120,10 @@ class ExportService:
|
|||||||
self.sheet_service = SheetService(db)
|
self.sheet_service = SheetService(db)
|
||||||
self.project_service = ProjectService(db)
|
self.project_service = ProjectService(db)
|
||||||
self.summary_service = SummaryService(db)
|
self.summary_service = SummaryService(db)
|
||||||
|
self.vsp_repo = VSPRepository(db)
|
||||||
self.value_serializer = ExcelValueSerializer()
|
self.value_serializer = ExcelValueSerializer()
|
||||||
self.form1_row_normalizer = Form1DepthRowNormalizer(self.value_serializer)
|
self.form1_row_normalizer = Form1DepthRowNormalizer(self.value_serializer)
|
||||||
|
self.form2_row_normalizer = Form2DepthRowNormalizer(self.value_serializer)
|
||||||
self.form3_row_normalizer = Form3DepthRowNormalizer(self.value_serializer)
|
self.form3_row_normalizer = Form3DepthRowNormalizer(self.value_serializer)
|
||||||
self.smeta_row_normalizer = SmetaRowNormalizer()
|
self.smeta_row_normalizer = SmetaRowNormalizer()
|
||||||
self.sheet_writers: dict[str, TabularSheetWriter] = {
|
self.sheet_writers: dict[str, TabularSheetWriter] = {
|
||||||
@ -455,6 +463,8 @@ class ExportService:
|
|||||||
direction=plan.effective_direction,
|
direction=plan.effective_direction,
|
||||||
sections=plan.effective_sections,
|
sections=plan.effective_sections,
|
||||||
)
|
)
|
||||||
|
if form.form_type.code == FormTypeEnum.FORM_2.value and plan.sheet_name in ("OPER", "AHR", "CAP"):
|
||||||
|
await self._resolve_form2_vsp_numbers(rows)
|
||||||
sheet_loaded_at = perf_counter()
|
sheet_loaded_at = perf_counter()
|
||||||
|
|
||||||
# Этап 4: рендер листа уводим с event loop, чтобы экспорт не блокировал API.
|
# Этап 4: рендер листа уводим с event loop, чтобы экспорт не блокировал API.
|
||||||
@ -911,6 +921,28 @@ class ExportService:
|
|||||||
"right_color": "#9EAFA3" if is_group_end else "#EDF0F2",
|
"right_color": "#9EAFA3" if is_group_end else "#EDF0F2",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def _resolve_form2_vsp_numbers(self, rows: list[tuple]) -> None:
|
||||||
|
"""На экране колонка «ВСП РФ» показывает registration_number, а не id,
|
||||||
|
который реально лежит в data.header.vsp_id — подменяем перед записью
|
||||||
|
в Excel, одним запросом на все встретившиеся id (включая закрытые ВСП)."""
|
||||||
|
headers_with_vsp: list[dict] = []
|
||||||
|
vsp_ids: set[int] = set()
|
||||||
|
for row in rows:
|
||||||
|
if len(row) <= 3 or not isinstance(row[3], dict):
|
||||||
|
continue
|
||||||
|
header = row[3].get("header")
|
||||||
|
vsp_id = header.get("vsp_id") if isinstance(header, dict) else None
|
||||||
|
if isinstance(vsp_id, int):
|
||||||
|
headers_with_vsp.append(header)
|
||||||
|
vsp_ids.add(vsp_id)
|
||||||
|
if not vsp_ids:
|
||||||
|
return
|
||||||
|
reg_numbers = await self.vsp_repo.get_reg_numbers_by_ids(list(vsp_ids))
|
||||||
|
for header in headers_with_vsp:
|
||||||
|
reg_number = reg_numbers.get(header["vsp_id"])
|
||||||
|
if reg_number is not None:
|
||||||
|
header["vsp_id"] = reg_number
|
||||||
|
|
||||||
def _create_xlsxwriter_sheet_writers(
|
def _create_xlsxwriter_sheet_writers(
|
||||||
self,
|
self,
|
||||||
workbook: XlsxWorkbook,
|
workbook: XlsxWorkbook,
|
||||||
@ -936,34 +968,86 @@ class ExportService:
|
|||||||
}
|
}
|
||||||
if form_type_code == FormTypeEnum.FORM_4.value:
|
if form_type_code == FormTypeEnum.FORM_4.value:
|
||||||
writer_configs = {
|
writer_configs = {
|
||||||
"OPER": (FORM4_OPER_DEPTH_COLUMNS, FORM4_OPER_COLORS, _vat_placeholders(FORM4_SHEET_VAT_WORDING)),
|
"OPER": (
|
||||||
"AHR": (FORM4_DEPTH_COLUMNS, FORM4_COLORS["AHR"], _vat_placeholders(FORM4_SHEET_VAT_WORDING)),
|
FORM4_OPER_DEPTH_COLUMNS,
|
||||||
"CAP": (FORM4_DEPTH_COLUMNS, FORM4_COLORS["CAP"], _vat_placeholders(FORM4_SHEET_VAT_WORDING)),
|
FORM4_OPER_COLORS,
|
||||||
|
_vat_placeholders(FORM4_SHEET_VAT_WORDING),
|
||||||
|
self.form1_row_normalizer,
|
||||||
|
),
|
||||||
|
"AHR": (
|
||||||
|
FORM4_DEPTH_COLUMNS,
|
||||||
|
FORM4_COLORS["AHR"],
|
||||||
|
_vat_placeholders(FORM4_SHEET_VAT_WORDING),
|
||||||
|
self.form1_row_normalizer,
|
||||||
|
),
|
||||||
|
"CAP": (
|
||||||
|
FORM4_DEPTH_COLUMNS,
|
||||||
|
FORM4_COLORS["CAP"],
|
||||||
|
_vat_placeholders(FORM4_SHEET_VAT_WORDING),
|
||||||
|
self.form1_row_normalizer,
|
||||||
|
),
|
||||||
"STRUCTURE": (
|
"STRUCTURE": (
|
||||||
FORM4_STRUCTURE_DEPTH_COLUMNS,
|
FORM4_STRUCTURE_DEPTH_COLUMNS,
|
||||||
FORM4_STRUCTURE_COLORS,
|
FORM4_STRUCTURE_COLORS,
|
||||||
_vat_placeholders(FORM4_SHEET_VAT_WORDING),
|
_vat_placeholders(FORM4_SHEET_VAT_WORDING),
|
||||||
|
self.form1_row_normalizer,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
elif form_type_code == FormTypeEnum.FORM_2.value:
|
||||||
|
# AURORA-1902: FORM_2 (Операц/АХР/КВ Сметы РФ) имеет свою раскладку колонок
|
||||||
|
# (v3.v_form2_view), раньше ошибочно экспортировалась раскладкой формы 1.
|
||||||
|
# Заголовок группы «Планирование годовых показателей, {unit} (без НДС)» — как на экране.
|
||||||
|
form2_extra = {"unit": "тыс. руб."}
|
||||||
|
writer_configs = {
|
||||||
|
"OPER": (FORM2_DEPTH_COLUMNS, FORM2_COLORS["OPER"], form2_extra, self.form2_row_normalizer),
|
||||||
|
"AHR": (FORM2_AHR_DEPTH_COLUMNS, FORM2_COLORS["AHR"], form2_extra, self.form2_row_normalizer),
|
||||||
|
"CAP": (FORM2_DEPTH_COLUMNS, FORM2_COLORS["CAP"], form2_extra, self.form2_row_normalizer),
|
||||||
|
"AHR_RENT": (FORM2_DEPTH_AHR_SUB_COLUMNS, None, None, self.form1_row_normalizer),
|
||||||
|
"AHR_UTILITY": (FORM2_DEPTH_AHR_SUB_COLUMNS, None, None, self.form1_row_normalizer),
|
||||||
|
"AHR_SECURITY": (FORM2_DEPTH_AHR_SUB_COLUMNS, None, None, self.form1_row_normalizer),
|
||||||
|
"STRUCTURE": (
|
||||||
|
FORM4_STRUCTURE_DEPTH_COLUMNS,
|
||||||
|
FORM4_STRUCTURE_COLORS,
|
||||||
|
_vat_placeholders(FORM4_SHEET_VAT_WORDING),
|
||||||
|
self.form1_row_normalizer,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
writer_configs = {
|
writer_configs = {
|
||||||
"OPER": (FORM1_DEPTH_COLUMNS, FORM1_COLORS["OPER"], _vat_placeholders(FORM1_SHEET_VAT_WORDING["OPER"])),
|
# AURORA-1905: на экране «Операц» формы 1 нет «Платежи в {year+2} году».
|
||||||
"AHR": (FORM1_DEPTH_COLUMNS, FORM1_COLORS["AHR"], _vat_placeholders(FORM1_SHEET_VAT_WORDING["AHR"])),
|
"OPER": (
|
||||||
"CAP": (FORM1_DEPTH_COLUMNS, FORM1_COLORS["CAP"], _vat_placeholders(FORM1_SHEET_VAT_WORDING["CAP"])),
|
FORM1_OPER_DEPTH_COLUMNS,
|
||||||
"AHR_RENT": (FORM2_DEPTH_AHR_SUB_COLUMNS, None, None),
|
FORM1_COLORS["OPER"],
|
||||||
"AHR_UTILITY": (FORM2_DEPTH_AHR_SUB_COLUMNS, None, None),
|
_vat_placeholders(FORM1_SHEET_VAT_WORDING["OPER"]),
|
||||||
"AHR_SECURITY": (FORM2_DEPTH_AHR_SUB_COLUMNS, None, None),
|
self.form1_row_normalizer,
|
||||||
|
),
|
||||||
|
"AHR": (
|
||||||
|
FORM1_DEPTH_COLUMNS,
|
||||||
|
FORM1_COLORS["AHR"],
|
||||||
|
_vat_placeholders(FORM1_SHEET_VAT_WORDING["AHR"]),
|
||||||
|
self.form1_row_normalizer,
|
||||||
|
),
|
||||||
|
"CAP": (
|
||||||
|
FORM1_DEPTH_COLUMNS,
|
||||||
|
FORM1_COLORS["CAP"],
|
||||||
|
_vat_placeholders(FORM1_SHEET_VAT_WORDING["CAP"]),
|
||||||
|
self.form1_row_normalizer,
|
||||||
|
),
|
||||||
|
"AHR_RENT": (FORM2_DEPTH_AHR_SUB_COLUMNS, None, None, self.form1_row_normalizer),
|
||||||
|
"AHR_UTILITY": (FORM2_DEPTH_AHR_SUB_COLUMNS, None, None, self.form1_row_normalizer),
|
||||||
|
"AHR_SECURITY": (FORM2_DEPTH_AHR_SUB_COLUMNS, None, None, self.form1_row_normalizer),
|
||||||
"STRUCTURE": (
|
"STRUCTURE": (
|
||||||
FORM4_STRUCTURE_DEPTH_COLUMNS,
|
FORM4_STRUCTURE_DEPTH_COLUMNS,
|
||||||
FORM4_STRUCTURE_COLORS,
|
FORM4_STRUCTURE_COLORS,
|
||||||
_vat_placeholders(FORM4_SHEET_VAT_WORDING),
|
_vat_placeholders(FORM4_SHEET_VAT_WORDING),
|
||||||
|
self.form1_row_normalizer,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
for sheet_name, (columns, colors, extra) in writer_configs.items():
|
for sheet_name, (columns, colors, extra, row_normalizer) in writer_configs.items():
|
||||||
writers[sheet_name] = XlsxDepthSheetWriter(
|
writers[sheet_name] = XlsxDepthSheetWriter(
|
||||||
workbook=workbook,
|
workbook=workbook,
|
||||||
columns_dict=render_column_years(columns, year, extra),
|
columns_dict=render_column_years(columns, year, extra),
|
||||||
row_normalizer=self.form1_row_normalizer.normalize,
|
row_normalizer=row_normalizer.normalize,
|
||||||
value_serializer=self.value_serializer.to_excel_value,
|
value_serializer=self.value_serializer.to_excel_value,
|
||||||
colors_config=colors,
|
colors_config=colors,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -8,6 +8,7 @@ from src.core.errors import AccessDeniedException, ValidationsError
|
|||||||
from src.db.models.role import UserRoleEnum
|
from src.db.models.role import UserRoleEnum
|
||||||
from src.db.models.app_user import AppUser
|
from src.db.models.app_user import AppUser
|
||||||
from src.domain.schemas import Form3PhaseCreate, Form3PhaseUpdate
|
from src.domain.schemas import Form3PhaseCreate, Form3PhaseUpdate
|
||||||
|
from src.services.form_phase_service import role_code_for_user
|
||||||
|
|
||||||
|
|
||||||
class Form3PhaseService:
|
class Form3PhaseService:
|
||||||
@ -18,11 +19,13 @@ class Form3PhaseService:
|
|||||||
async def get_list(
|
async def get_list(
|
||||||
self,
|
self,
|
||||||
report_id: int | list[int],
|
report_id: int | list[int],
|
||||||
|
user: AppUser,
|
||||||
phase_code: str | None = None,
|
phase_code: str | None = None,
|
||||||
) -> list[Form3Phase]:
|
) -> list[Form3Phase]:
|
||||||
return await self.f3p_repo.get_list(
|
return await self.f3p_repo.get_list(
|
||||||
report_id=report_id,
|
report_id=report_id,
|
||||||
phase_code=phase_code,
|
phase_code=phase_code,
|
||||||
|
role=role_code_for_user(user),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get(
|
async def get(
|
||||||
|
|||||||
@ -11,6 +11,21 @@ from src.domain.schemas import FormPhaseCreate, FormPhaseUpdate
|
|||||||
from src.repository.form_phase_repository import FormPhaseRepository
|
from src.repository.form_phase_repository import FormPhaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
# Код роли этапа (v3.role.code) по роли пользователя. Единственное место сопоставления,
|
||||||
|
# используется и FormPhaseService, и Form3PhaseService. Для ADMIN фильтр не нужен — его в
|
||||||
|
# словаре нет, role_code_for_user возвращает None.
|
||||||
|
ROLE_CODE_BY_USER_ROLE: dict[UserRoleEnum, str] = {
|
||||||
|
UserRoleEnum.EXECUTOR_DFIP: "DFIP",
|
||||||
|
UserRoleEnum.EXECUTOR_RF: "EXECUTOR_RF",
|
||||||
|
UserRoleEnum.EXECUTOR_DURRS: "DURRS",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def role_code_for_user(user: AppUser) -> str | None:
|
||||||
|
"""Код роли этапа для фильтрации по пользователю; None — без фильтра (ADMIN)."""
|
||||||
|
return ROLE_CODE_BY_USER_ROLE.get(user.role_id)
|
||||||
|
|
||||||
|
|
||||||
class FormPhaseService:
|
class FormPhaseService:
|
||||||
def __init__(self, db: AsyncSession):
|
def __init__(self, db: AsyncSession):
|
||||||
self.db = db
|
self.db = db
|
||||||
@ -30,6 +45,7 @@ class FormPhaseService:
|
|||||||
sheet=sheet,
|
sheet=sheet,
|
||||||
direction=direction,
|
direction=direction,
|
||||||
phase_code=phase_code,
|
phase_code=phase_code,
|
||||||
|
role=role_code_for_user(user),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get(
|
async def get(
|
||||||
|
|||||||
@ -327,6 +327,58 @@ def test_form3_phases_delete(client, admin_tokens):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_phases_role_filter_for_executor(client, admin_tokens, isp_tokens):
|
||||||
|
# isp1 (role_id=2=DFIP) видит на своей форме (budget_form 4, org_unit 2) только этапы роли DFIP.
|
||||||
|
response = client.get("/api/v1/stages/form/4", headers=_auth_headers(isp_tokens))
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["result"], "в фикстуре должен быть хотя бы один этап DFIP на форме 4"
|
||||||
|
assert all(phase["role"] == "DFIP" for phase in payload["result"])
|
||||||
|
|
||||||
|
# Админ в той же транзакции добавляет на этой же форме этап с ролью EXECUTOR_RF.
|
||||||
|
create_body = {
|
||||||
|
"sheet": "AHR",
|
||||||
|
"phase_code": "role_filter_check",
|
||||||
|
"role": "EXECUTOR_RF",
|
||||||
|
"column_keys": ["plan.q1"],
|
||||||
|
"opens_at": "2026-01-01T00:00:00",
|
||||||
|
"closes_at": "2026-12-31T00:00:00",
|
||||||
|
"direction": None,
|
||||||
|
}
|
||||||
|
create_response = client.post(
|
||||||
|
"/api/v1/stages/form/4", json=create_body, headers=_auth_headers(admin_tokens)
|
||||||
|
)
|
||||||
|
assert create_response.status_code == 200
|
||||||
|
|
||||||
|
# isp1 по-прежнему видит только роль DFIP, новый этап EXECUTOR_RF ему не виден.
|
||||||
|
isp_response = client.get("/api/v1/stages/form/4", headers=_auth_headers(isp_tokens))
|
||||||
|
assert isp_response.status_code == 200
|
||||||
|
isp_payload = isp_response.json()
|
||||||
|
assert all(phase["role"] == "DFIP" for phase in isp_payload["result"])
|
||||||
|
assert not any(phase["phase_code"] == "role_filter_check" for phase in isp_payload["result"])
|
||||||
|
|
||||||
|
# Админ видит все этапы формы: список не урезан по роли.
|
||||||
|
admin_response = client.get("/api/v1/stages/form/4", headers=_auth_headers(admin_tokens))
|
||||||
|
assert admin_response.status_code == 200
|
||||||
|
admin_payload = admin_response.json()
|
||||||
|
admin_roles = {phase["role"] for phase in admin_payload["result"]}
|
||||||
|
assert "DFIP" in admin_roles
|
||||||
|
assert "EXECUTOR_RF" in admin_roles
|
||||||
|
assert admin_payload["count"] == isp_payload["count"] + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_form_phases_foreign_org_404(client, isp_tokens):
|
||||||
|
# budget_form 1 принадлежит org_unit 1, isp1 состоит только в org_unit 2.
|
||||||
|
response = client.get("/api/v1/stages/form/1", headers=_auth_headers(isp_tokens))
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_form3_phases_foreign_org_404(client, isp_tokens):
|
||||||
|
# project 2 принадлежит org_unit 1, isp1 состоит только в org_unit 2.
|
||||||
|
response = client.get("/api/v1/stages/project/2", headers=_auth_headers(isp_tokens))
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _instant(value: str):
|
def _instant(value: str):
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|||||||
107
api/tests/unit/test_export_form1_form4_mapping.py
Normal file
107
api/tests/unit/test_export_form1_form4_mapping.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import importlib
|
||||||
|
|
||||||
|
from xlsxwriter import Workbook as XlsxWorkbook
|
||||||
|
|
||||||
|
from src.services.export_service import export_mappings
|
||||||
|
from src.services.export_service.export_mappings import (
|
||||||
|
FORM1_DEPTH_COLUMNS,
|
||||||
|
FORM1_OPER_DEPTH_COLUMNS,
|
||||||
|
FORM4_DEPTH_COLUMNS,
|
||||||
|
FORM4_OPER_DEPTH_COLUMNS,
|
||||||
|
FORM4_STRUCTURE_DEPTH_COLUMNS,
|
||||||
|
render_column_years,
|
||||||
|
)
|
||||||
|
from src.services.export_service.export_writers import XlsxDepthSheetWriter
|
||||||
|
|
||||||
|
|
||||||
|
def _flat(columns_dict):
|
||||||
|
writer = XlsxDepthSheetWriter(
|
||||||
|
workbook=XlsxWorkbook(),
|
||||||
|
columns_dict=columns_dict,
|
||||||
|
row_normalizer=lambda row: row,
|
||||||
|
value_serializer=lambda value: value,
|
||||||
|
)
|
||||||
|
return writer.columns
|
||||||
|
|
||||||
|
|
||||||
|
def _tree_node(tree, path):
|
||||||
|
segments = path.split(".")
|
||||||
|
node = tree[segments[0]]
|
||||||
|
for segment in segments[1:]:
|
||||||
|
node = node["children"][segment]
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_stray_collegial_empty_column():
|
||||||
|
for columns in (FORM1_DEPTH_COLUMNS, FORM4_DEPTH_COLUMNS, FORM4_STRUCTURE_DEPTH_COLUMNS):
|
||||||
|
assert "collegial.empty" not in _flat(columns)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_shift_sum_columns_in_base_form1():
|
||||||
|
flat = _flat(FORM1_DEPTH_COLUMNS)
|
||||||
|
assert not [key for key in flat if key.endswith(".sum")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_form4_has_fact_q1_q2_q3_and_fact_4_plus_spod_before_totals():
|
||||||
|
flat = _flat(FORM4_DEPTH_COLUMNS)
|
||||||
|
# 1-3 кв. показывают то же значение, что q{n}.actual_quarter внутри квартала
|
||||||
|
# (сознательный дубль ключа — влияет только на цвет, см. AURORA-1905); 4 кв.
|
||||||
|
# использует свой собственный q4.actual_rsbu_spod (SQL отдаёт только его).
|
||||||
|
assert flat.count("q1.actual_quarter") == 2
|
||||||
|
assert flat.count("q2.actual_quarter") == 2
|
||||||
|
assert flat.count("q3.actual_quarter") == 2
|
||||||
|
assert flat.count("q4.actual_rsbu_spod") == 1
|
||||||
|
totals_index = flat.index("totals.fact_year")
|
||||||
|
assert flat.index("q4.actual_rsbu_spod") < totals_index
|
||||||
|
|
||||||
|
|
||||||
|
def test_adj_comment_immediately_follows_adj_reserve():
|
||||||
|
for quarter in ("q2", "q3", "q4"):
|
||||||
|
for columns in (FORM1_DEPTH_COLUMNS, FORM4_DEPTH_COLUMNS, FORM4_STRUCTURE_DEPTH_COLUMNS):
|
||||||
|
flat = _flat(columns)
|
||||||
|
reserve_index = flat.index(f"{quarter}.adj_reserve")
|
||||||
|
comment_index = flat.index(f"{quarter}.adj_comment")
|
||||||
|
assert comment_index == reserve_index + 1, (columns is FORM1_DEPTH_COLUMNS, quarter)
|
||||||
|
|
||||||
|
|
||||||
|
def test_form4_oper_ckk_procurement_before_comment():
|
||||||
|
flat = _flat(FORM4_OPER_DEPTH_COLUMNS)
|
||||||
|
assert flat.index("ckk.procurement_plan") < flat.index("ckk.comment")
|
||||||
|
assert flat.index("ckk.procurement_method") < flat.index("ckk.comment")
|
||||||
|
assert flat.index("ckk.deadline") < flat.index("ckk.procurement_plan")
|
||||||
|
|
||||||
|
|
||||||
|
def test_form1_oper_has_no_future_y2_but_ahr_cap_do():
|
||||||
|
assert "contract_summary.future_y2" in _flat(FORM1_DEPTH_COLUMNS)
|
||||||
|
assert "contract_summary.future_y2" not in _flat(FORM1_OPER_DEPTH_COLUMNS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_column_years_form4():
|
||||||
|
rendered = render_column_years(FORM4_DEPTH_COLUMNS, 2026)
|
||||||
|
assert _tree_node(rendered, "upper_ssp_smeta.contract_summary.future_y2")["name"] == "Платежи в 2028 году"
|
||||||
|
assert _tree_node(rendered, "approved.plan")["name"] == "План 2026"
|
||||||
|
assert _tree_node(rendered, "q4.quarter_correct")["name"] == "Факт РСБУ 4 кв 26"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_column_years_form4_structure():
|
||||||
|
rendered = render_column_years(FORM4_STRUCTURE_DEPTH_COLUMNS, 2026)
|
||||||
|
assert (
|
||||||
|
_tree_node(rendered, "upper_ssp_smeta.contract_summary.future_payments_y2")["name"]
|
||||||
|
== "Платежи в 2028 году"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_column_years_form1():
|
||||||
|
rendered = render_column_years(FORM1_DEPTH_COLUMNS, 2026)
|
||||||
|
assert _tree_node(rendered, "approved.plan")["name"] == "План 2026"
|
||||||
|
assert _tree_node(rendered, "q4.quarter_correct")["name"] == "Факт РСБУ 4 кв 26"
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_reload_is_idempotent():
|
||||||
|
# Патчи FORM_4 применяются один раз при импорте модуля; повторный импорт
|
||||||
|
# не должен снова звать патчи и удваивать/ломать раскладку.
|
||||||
|
before = _flat(export_mappings.FORM4_DEPTH_COLUMNS)
|
||||||
|
importlib.reload(export_mappings)
|
||||||
|
after = _flat(export_mappings.FORM4_DEPTH_COLUMNS)
|
||||||
|
assert before == after
|
||||||
|
importlib.reload(export_mappings)
|
||||||
202
api/tests/unit/test_export_form2_mapping.py
Normal file
202
api/tests/unit/test_export_form2_mapping.py
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from xlsxwriter import Workbook as XlsxWorkbook
|
||||||
|
|
||||||
|
from src.db.models.form_type import FormTypeEnum
|
||||||
|
from src.services.export_service import ExportService
|
||||||
|
from src.services.export_service.export_mappings import (
|
||||||
|
FORM1_OPER_DEPTH_COLUMNS,
|
||||||
|
FORM2_AHR_DEPTH_COLUMNS,
|
||||||
|
FORM2_COLORS,
|
||||||
|
FORM2_DEPTH_COLUMNS,
|
||||||
|
render_column_years,
|
||||||
|
)
|
||||||
|
from src.services.export_service.export_normalizers import (
|
||||||
|
ExcelValueSerializer,
|
||||||
|
Form2DepthRowNormalizer,
|
||||||
|
)
|
||||||
|
from src.services.export_service.export_writers import XlsxDepthSheetWriter
|
||||||
|
|
||||||
|
# Ожидаемый порядок листьев = порядок колонок экрана (web/src/.../FORM_2/OPER.js),
|
||||||
|
# минус "header.vsp_address" (AURORA-1902: лишний столбец, которого нет в форме РФ).
|
||||||
|
EXPECTED_LEAF_ORDER = [
|
||||||
|
"header.section_code", "header.item_id", "header.num_group", "header.name", "header.vsp_id",
|
||||||
|
"plan.q1", "plan.q2", "plan.q3", "plan.q4", "plan.year", "plan.comment",
|
||||||
|
"seq_dfip.q1", "seq_dfip.q2", "seq_dfip.q3", "seq_dfip.q4", "seq_dfip.year", "seq_dfip.justification",
|
||||||
|
"seq_ssp.q1", "seq_ssp.q2", "seq_ssp.q3", "seq_ssp.q4", "seq_ssp.year", "seq_ssp.justification",
|
||||||
|
"approved.q1", "approved.q2", "approved.q3", "approved.q4", "approved.year",
|
||||||
|
"contract.counterparty", "contract.reference", "contract.date", "contract.subject",
|
||||||
|
"contract.currency", "contract.ceiling", "contract.vat_rate", "contract.deadline",
|
||||||
|
"contract.scheme", "contract.act", "contract.comment",
|
||||||
|
"booking.y2026.q1", "booking.y2026.q2", "booking.y2026.q3", "booking.y2026.q4",
|
||||||
|
"booking.y2027.q1", "booking.y2027.q2", "booking.y2027.q3", "booking.y2027.q4",
|
||||||
|
"q1.adj_current", "q1.adj_ssp", "q1.adj_reserve", "q1.adj_comment", "q1.corrected_plan",
|
||||||
|
"q1.pay_date", "q1.pay_amount", "q1.pay_comment", "q1.pay_act",
|
||||||
|
"q1.booking", "q1.actual_m1", "q1.actual_m2", "q1.actual_m3", "q1.actual_quarter",
|
||||||
|
"q1.residual_after_booking", "q1.residual_after_actual",
|
||||||
|
"q1.transfer_q2", "q1.transfer_q2_delay_acts", "q1.transfer_q2_delay_procurement",
|
||||||
|
"q1.transfer_q2_economy_rf", "q1.transfer_next_comment", "q1.transfer_q3", "q1.transfer_q4",
|
||||||
|
"q1.transfer_far_comment", "q1.transfer_econ", "q1.total",
|
||||||
|
"q2.target_change", "q2.base_correction", "q2.base_correction_comment",
|
||||||
|
"q2.revision_inc", "q2.revision_seq", "q2.revision_comment", "q2.new_plan",
|
||||||
|
"q2.adj_current", "q2.adj_ssp", "q2.adj_reserve", "q2.adj_comment", "q2.corrected_plan",
|
||||||
|
"q2.pay_date", "q2.pay_amount", "q2.pay_comment", "q2.pay_act",
|
||||||
|
"q2.booking", "q2.actual_m1", "q2.actual_m2", "q2.actual_m3", "q2.actual_quarter",
|
||||||
|
"q2.residual_after_booking", "q2.residual_after_actual",
|
||||||
|
"q2.transfer_q3", "q2.transfer_q3_delay_acts", "q2.transfer_q3_delay_procurement",
|
||||||
|
"q2.transfer_q3_economy_rf", "q2.transfer_next_comment", "q2.transfer_q4",
|
||||||
|
"q2.transfer_far_comment", "q2.transfer_econ", "q2.total",
|
||||||
|
"q3.target_change", "q3.base_correction", "q3.base_correction_comment",
|
||||||
|
"q3.revision_inc", "q3.revision_seq", "q3.revision_comment", "q3.new_plan",
|
||||||
|
"q3.adj_current", "q3.adj_ssp", "q3.adj_reserve", "q3.adj_comment", "q3.corrected_plan",
|
||||||
|
"q3.pay_date", "q3.pay_amount", "q3.pay_comment", "q3.pay_act",
|
||||||
|
"q3.booking", "q3.actual_m1", "q3.actual_m2", "q3.actual_m3", "q3.actual_quarter",
|
||||||
|
"q3.residual_after_booking", "q3.residual_after_actual",
|
||||||
|
"q3.transfer_q4", "q3.transfer_q4_delay_acts", "q3.transfer_q4_delay_procurement",
|
||||||
|
"q3.transfer_q4_economy_rf", "q3.transfer_next_comment", "q3.transfer_econ", "q3.total",
|
||||||
|
"q4.target_change", "q4.base_correction", "q4.base_correction_comment",
|
||||||
|
"q4.revision_inc", "q4.revision_seq", "q4.revision_comment", "q4.new_plan",
|
||||||
|
"q4.adj_current", "q4.adj_ssp", "q4.adj_reserve", "q4.adj_comment", "q4.corrected_plan",
|
||||||
|
"q4.pay_date", "q4.pay_amount", "q4.pay_comment", "q4.pay_act",
|
||||||
|
"q4.booking", "q4.actual_m1", "q4.actual_m2", "q4.actual_m3", "q4.actual_spod", "q4.actual_quarter",
|
||||||
|
"q4.residual_after_booking", "q4.residual_after_actual", "q4.transfer_econ",
|
||||||
|
"totals.fact_year", "totals.pay_year",
|
||||||
|
]
|
||||||
|
|
||||||
|
# AURORA-1907: на листе АХР экрана после "plan.year" идут две вычисляемые колонки лимита.
|
||||||
|
EXPECTED_AHR_LEAF_ORDER = [
|
||||||
|
*EXPECTED_LEAF_ORDER[:EXPECTED_LEAF_ORDER.index("plan.year") + 1],
|
||||||
|
"plan.limit_year", "plan.over_limit_year",
|
||||||
|
*EXPECTED_LEAF_ORDER[EXPECTED_LEAF_ORDER.index("plan.year") + 1:],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_form2_depth_columns_leaf_order_matches_screen_config():
|
||||||
|
writer = XlsxDepthSheetWriter(
|
||||||
|
workbook=XlsxWorkbook(),
|
||||||
|
columns_dict=FORM2_DEPTH_COLUMNS,
|
||||||
|
row_normalizer=lambda row: row,
|
||||||
|
value_serializer=lambda value: value,
|
||||||
|
)
|
||||||
|
assert writer.columns == EXPECTED_LEAF_ORDER
|
||||||
|
|
||||||
|
|
||||||
|
def test_form2_depth_columns_has_no_vsp_address():
|
||||||
|
assert "header.vsp_address" not in EXPECTED_LEAF_ORDER
|
||||||
|
writer = XlsxDepthSheetWriter(
|
||||||
|
workbook=XlsxWorkbook(),
|
||||||
|
columns_dict=FORM2_DEPTH_COLUMNS,
|
||||||
|
row_normalizer=lambda row: row,
|
||||||
|
value_serializer=lambda value: value,
|
||||||
|
)
|
||||||
|
assert "header.vsp_address" not in writer.columns
|
||||||
|
|
||||||
|
|
||||||
|
def test_form2_ahr_depth_columns_have_limit_columns_oper_cap_do_not():
|
||||||
|
writer = XlsxDepthSheetWriter(
|
||||||
|
workbook=XlsxWorkbook(),
|
||||||
|
columns_dict=FORM2_AHR_DEPTH_COLUMNS,
|
||||||
|
row_normalizer=lambda row: row,
|
||||||
|
value_serializer=lambda value: value,
|
||||||
|
)
|
||||||
|
assert writer.columns == EXPECTED_AHR_LEAF_ORDER
|
||||||
|
assert "plan.limit_year" not in EXPECTED_LEAF_ORDER
|
||||||
|
|
||||||
|
|
||||||
|
def test_form2_plan_group_header_has_unit_and_vat_like_screen():
|
||||||
|
for columns in (FORM2_DEPTH_COLUMNS, FORM2_AHR_DEPTH_COLUMNS):
|
||||||
|
rendered = render_column_years(columns, 2026, {"unit": "тыс. руб."})
|
||||||
|
plan_group = rendered["god"]["children"]["planirovanie_godovykh_pokazateley"]
|
||||||
|
assert plan_group["name"] == "Планирование годовых показателей, тыс. руб. (без НДС)"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("sheet", ["OPER", "AHR", "CAP"])
|
||||||
|
def test_form2_colors_cover_every_leaf(sheet):
|
||||||
|
expected = EXPECTED_AHR_LEAF_ORDER if sheet == "AHR" else EXPECTED_LEAF_ORDER
|
||||||
|
missing = [key for key in expected if key not in FORM2_COLORS[sheet]]
|
||||||
|
assert missing == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_column_years_resolves_form2_top_group_names():
|
||||||
|
rendered = render_column_years(FORM2_DEPTH_COLUMNS, 2026)
|
||||||
|
names = {node["name"] for node in rendered.values()}
|
||||||
|
assert "2026 год" in names
|
||||||
|
assert "2027 год" in names
|
||||||
|
assert "1 квартал 2026 года" in names
|
||||||
|
# Original templates are untouched (render_column_years deep-copies).
|
||||||
|
assert any("{year}" in node["name"] for node in FORM2_DEPTH_COLUMNS.values() if "{year}" in node.get("name", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_xlsxwriter_sheet_writers_form2_uses_its_own_layout():
|
||||||
|
service = ExportService(AsyncMock())
|
||||||
|
workbook = XlsxWorkbook()
|
||||||
|
writers = service._create_xlsxwriter_sheet_writers(workbook, FormTypeEnum.FORM_2.value, 2026)
|
||||||
|
|
||||||
|
for sheet, expected in (("OPER", EXPECTED_LEAF_ORDER), ("AHR", EXPECTED_AHR_LEAF_ORDER), ("CAP", EXPECTED_LEAF_ORDER)):
|
||||||
|
assert writers[sheet].columns == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_xlsxwriter_sheet_writers_form1_is_unaffected():
|
||||||
|
service = ExportService(AsyncMock())
|
||||||
|
workbook = XlsxWorkbook()
|
||||||
|
writers = service._create_xlsxwriter_sheet_writers(workbook, FormTypeEnum.FORM_1.value, 2026)
|
||||||
|
|
||||||
|
form1_writer = XlsxDepthSheetWriter(
|
||||||
|
workbook=XlsxWorkbook(),
|
||||||
|
columns_dict=FORM1_OPER_DEPTH_COLUMNS, # AURORA-1905: на «Операц» формы 1 нет future_y2
|
||||||
|
row_normalizer=lambda row: row,
|
||||||
|
value_serializer=lambda value: value,
|
||||||
|
)
|
||||||
|
assert writers["OPER"].columns == form1_writer.columns
|
||||||
|
assert writers["OPER"].columns != EXPECTED_LEAF_ORDER
|
||||||
|
|
||||||
|
|
||||||
|
def test_form2_row_normalizer_keeps_header_nesting():
|
||||||
|
# В отличие от Form1DepthRowNormalizer, поля header.* НЕ поднимаются наверх:
|
||||||
|
# FORM2_DEPTH_COLUMNS ожидает их как header.section_code, header.vsp_id и т.д.
|
||||||
|
normalizer = Form2DepthRowNormalizer(ExcelValueSerializer())
|
||||||
|
row = {
|
||||||
|
"header": {"section_code": "01", "vsp_id": 5},
|
||||||
|
"plan": {"q1": 10},
|
||||||
|
"q4": {"actual_spod": 1},
|
||||||
|
"line_id": 999,
|
||||||
|
}
|
||||||
|
normalized = normalizer.normalize(row)
|
||||||
|
assert normalized["header.section_code"] == "01"
|
||||||
|
assert normalized["header.vsp_id"] == 5
|
||||||
|
assert normalized["plan.q1"] == 10
|
||||||
|
assert normalized["q4.actual_spod"] == 1
|
||||||
|
assert "line_id" not in normalized
|
||||||
|
assert "header" not in normalized
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_form2_vsp_numbers_replaces_id_with_registration_number():
|
||||||
|
service = ExportService(AsyncMock())
|
||||||
|
service.vsp_repo.get_reg_numbers_by_ids = AsyncMock(return_value={5: "1234/0001", 7: "5678/0002"})
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
("INPUT", 1, 0, {"header": {"vsp_id": 5}, "plan": {"q1": 10}}),
|
||||||
|
("INPUT", 1, 1, {"header": {"vsp_id": 7}, "plan": {"q1": 20}}),
|
||||||
|
("GROUP", 0, 0, {"header": {"vsp_id": None}}),
|
||||||
|
("META", 0, 0, None),
|
||||||
|
]
|
||||||
|
|
||||||
|
await service._resolve_form2_vsp_numbers(rows)
|
||||||
|
|
||||||
|
assert rows[0][3]["header"]["vsp_id"] == "1234/0001"
|
||||||
|
assert rows[1][3]["header"]["vsp_id"] == "5678/0002"
|
||||||
|
assert rows[2][3]["header"]["vsp_id"] is None
|
||||||
|
service.vsp_repo.get_reg_numbers_by_ids.assert_awaited_once_with([5, 7])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_form2_vsp_numbers_skips_db_call_when_no_vsp_ids():
|
||||||
|
service = ExportService(AsyncMock())
|
||||||
|
service.vsp_repo.get_reg_numbers_by_ids = AsyncMock(return_value={})
|
||||||
|
|
||||||
|
rows = [("INPUT", 1, 0, {"plan": {"q1": 10}})]
|
||||||
|
await service._resolve_form2_vsp_numbers(rows)
|
||||||
|
|
||||||
|
service.vsp_repo.get_reg_numbers_by_ids.assert_not_awaited()
|
||||||
62
api/tests/unit/test_form_phase_service.py
Normal file
62
api/tests/unit/test_form_phase_service.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.db.models.role import UserRoleEnum
|
||||||
|
from src.services.form_phase_service import FormPhaseService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service_with_mock_repo(monkeypatch):
|
||||||
|
mock_repo = MagicMock()
|
||||||
|
mock_repo.get_list = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
repo_cls = MagicMock(return_value=mock_repo)
|
||||||
|
monkeypatch.setattr("src.services.form_phase_service.FormPhaseRepository", repo_cls)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"src.services.form_phase_service.BudgetFormRepository", MagicMock(return_value=MagicMock())
|
||||||
|
)
|
||||||
|
|
||||||
|
service = FormPhaseService(AsyncMock())
|
||||||
|
return service, mock_repo
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def executor_dfip_user():
|
||||||
|
return SimpleNamespace(id=2, role_id=UserRoleEnum.EXECUTOR_DFIP.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_user():
|
||||||
|
return SimpleNamespace(id=1, role_id=UserRoleEnum.ADMIN.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_list_filters_by_role_for_executor_dfip(service_with_mock_repo, executor_dfip_user):
|
||||||
|
service, mock_repo = service_with_mock_repo
|
||||||
|
|
||||||
|
await service.get_list(budget_form_id=4, user=executor_dfip_user)
|
||||||
|
|
||||||
|
mock_repo.get_list.assert_awaited_once_with(
|
||||||
|
budget_form_id=4,
|
||||||
|
sheet=None,
|
||||||
|
direction=None,
|
||||||
|
phase_code=None,
|
||||||
|
role="DFIP",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_list_no_role_filter_for_admin(service_with_mock_repo, admin_user):
|
||||||
|
service, mock_repo = service_with_mock_repo
|
||||||
|
|
||||||
|
await service.get_list(budget_form_id=4, user=admin_user)
|
||||||
|
|
||||||
|
mock_repo.get_list.assert_awaited_once_with(
|
||||||
|
budget_form_id=4,
|
||||||
|
sheet=None,
|
||||||
|
direction=None,
|
||||||
|
phase_code=None,
|
||||||
|
role=None,
|
||||||
|
)
|
||||||
@ -3568,14 +3568,14 @@ export const config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Скорректированный план 4 кв. {year:short}',
|
header: 'Комментарий',
|
||||||
accessorKey: '4_kvartal_2026_skorr_plan_4_kv_26',
|
accessorKey: '4_kvartal_2024_kommentariy',
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
header: 'Сумма, {unit} (без НДС)',
|
header: 'Сумма, {unit} (с учётом НДС)',
|
||||||
accessorKey: 'data.q4.corrected_plan',
|
accessorKey: 'data.q4.adj_comment',
|
||||||
columnLetter: 'GH',
|
columnLetter: 'GG',
|
||||||
size: 250,
|
size: 300,
|
||||||
filterFn: 'contains',
|
filterFn: 'contains',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -3587,14 +3587,14 @@ export const config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Комментарий',
|
header: 'Скорректированный план 4 кв. {year:short}',
|
||||||
accessorKey: '4_kvartal_2024_kommentariy',
|
accessorKey: '4_kvartal_2026_skorr_plan_4_kv_26',
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
header: 'Сумма, {unit} (с учётом НДС)',
|
header: 'Сумма, {unit} (без НДС)',
|
||||||
accessorKey: 'data.q4.adj_comment',
|
accessorKey: 'data.q4.corrected_plan',
|
||||||
columnLetter: 'GG',
|
columnLetter: 'GH',
|
||||||
size: 300,
|
size: 250,
|
||||||
filterFn: 'contains',
|
filterFn: 'contains',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -3582,13 +3582,13 @@ export const config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Скорректированный план 4 кв. {year:short}',
|
header: 'Комментарий',
|
||||||
accessorKey: '4_kvartal_2024_skorr_plan_4_kv_24',
|
accessorKey: '4_kvartal_2024_kommentariy',
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
header: 'Сумма, {unit} (с учётом НДС)',
|
header: 'Сумма, {unit} (с учётом НДС)',
|
||||||
accessorKey: 'data.q4.corrected_plan',
|
accessorKey: 'data.q4.adj_comment',
|
||||||
columnLetter: 'GH',
|
columnLetter: 'GG',
|
||||||
size: 300,
|
size: 300,
|
||||||
filterFn: 'contains',
|
filterFn: 'contains',
|
||||||
},
|
},
|
||||||
@ -3601,13 +3601,13 @@ export const config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Комментарий',
|
header: 'Скорректированный план 4 кв. {year:short}',
|
||||||
accessorKey: '4_kvartal_2024_kommentariy',
|
accessorKey: '4_kvartal_2024_skorr_plan_4_kv_24',
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
header: 'Сумма, {unit} (с учётом НДС)',
|
header: 'Сумма, {unit} (с учётом НДС)',
|
||||||
accessorKey: 'data.q4.adj_comment',
|
accessorKey: 'data.q4.corrected_plan',
|
||||||
columnLetter: 'GG',
|
columnLetter: 'GH',
|
||||||
size: 300,
|
size: 300,
|
||||||
filterFn: 'contains',
|
filterFn: 'contains',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -3566,13 +3566,13 @@ export const config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Скорректированный план 4 кв. {year:short}',
|
header: 'Комментарий',
|
||||||
accessorKey: '4_kvartal_2024_skorr_plan_4_kv_24',
|
accessorKey: '4_kvartal_2024_kommentariy',
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
header: 'Сумма, {unit} (с учётом НДС)',
|
header: 'Сумма, {unit} (с учётом НДС)',
|
||||||
accessorKey: 'data.q4.corrected_plan',
|
accessorKey: 'data.q4.adj_comment',
|
||||||
columnLetter: 'GH',
|
columnLetter: 'GG',
|
||||||
size: 300,
|
size: 300,
|
||||||
filterFn: 'contains',
|
filterFn: 'contains',
|
||||||
},
|
},
|
||||||
@ -3585,13 +3585,13 @@ export const config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Комментарий',
|
header: 'Скорректированный план 4 кв. {year:short}',
|
||||||
accessorKey: '4_kvartal_2024_kommentariy',
|
accessorKey: '4_kvartal_2024_skorr_plan_4_kv_24',
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
header: 'Сумма, {unit} (с учётом НДС)',
|
header: 'Сумма, {unit} (с учётом НДС)',
|
||||||
accessorKey: 'data.q4.adj_comment',
|
accessorKey: 'data.q4.corrected_plan',
|
||||||
columnLetter: 'GG',
|
columnLetter: 'GH',
|
||||||
size: 300,
|
size: 300,
|
||||||
filterFn: 'contains',
|
filterFn: 'contains',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -689,10 +689,6 @@ export const config = {
|
|||||||
color_type: blueColumn,
|
color_type: blueColumn,
|
||||||
accessorKey: "data.totals.pay_year",
|
accessorKey: "data.totals.pay_year",
|
||||||
},
|
},
|
||||||
"data.header.vsp_address": {
|
|
||||||
color_type: greenColumn,
|
|
||||||
accessorKey: "data.header.vsp_address",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
@ -739,14 +735,6 @@ export const config = {
|
|||||||
filterFn: "contains",
|
filterFn: "contains",
|
||||||
editType: "vsp_dropdown",
|
editType: "vsp_dropdown",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
header: "Адрес ВСП",
|
|
||||||
accessorKey: "data.header.vsp_address",
|
|
||||||
columnLetter: "E1",
|
|
||||||
size: 220,
|
|
||||||
filterFn: "contains",
|
|
||||||
editType: "vsp_dropdown",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
muiTableHeadCellProps: {
|
muiTableHeadCellProps: {
|
||||||
sx: {
|
sx: {
|
||||||
|
|||||||
@ -681,10 +681,6 @@ export const config = {
|
|||||||
color_type: blueColumn,
|
color_type: blueColumn,
|
||||||
accessorKey: "data.totals.pay_year",
|
accessorKey: "data.totals.pay_year",
|
||||||
},
|
},
|
||||||
"data.header.vsp_address": {
|
|
||||||
color_type: orangeColumn,
|
|
||||||
accessorKey: "data.header.vsp_address",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
@ -731,14 +727,6 @@ export const config = {
|
|||||||
filterFn: "contains",
|
filterFn: "contains",
|
||||||
editType: "vsp_dropdown",
|
editType: "vsp_dropdown",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
header: "Адрес ВСП",
|
|
||||||
accessorKey: "data.header.vsp_address",
|
|
||||||
columnLetter: "E1",
|
|
||||||
size: 220,
|
|
||||||
filterFn: "contains",
|
|
||||||
editType: "vsp_dropdown",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
muiTableHeadCellProps: {
|
muiTableHeadCellProps: {
|
||||||
sx: {
|
sx: {
|
||||||
|
|||||||
@ -681,10 +681,6 @@ export const config = {
|
|||||||
color_type: blueColumn,
|
color_type: blueColumn,
|
||||||
accessorKey: "data.totals.pay_year",
|
accessorKey: "data.totals.pay_year",
|
||||||
},
|
},
|
||||||
"data.header.vsp_address": {
|
|
||||||
color_type: blueColumn,
|
|
||||||
accessorKey: "data.header.vsp_address",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
@ -731,14 +727,6 @@ export const config = {
|
|||||||
filterFn: "contains",
|
filterFn: "contains",
|
||||||
editType: "vsp_dropdown",
|
editType: "vsp_dropdown",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
header: "Адрес ВСП",
|
|
||||||
accessorKey: "data.header.vsp_address",
|
|
||||||
columnLetter: "E1",
|
|
||||||
size: 220,
|
|
||||||
filterFn: "contains",
|
|
||||||
editType: "vsp_dropdown",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
muiTableHeadCellProps: {
|
muiTableHeadCellProps: {
|
||||||
sx: {
|
sx: {
|
||||||
|
|||||||
@ -141,6 +141,26 @@ export const config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: "Итого: Поддержка",
|
||||||
|
accessorKey:
|
||||||
|
"field_raskhody_planiruemye_rf_na_2026_god_itogopodderzhka",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
header: "",
|
||||||
|
accessorKey: "data.plan.support.year",
|
||||||
|
columnLetter: "H",
|
||||||
|
size: 150,
|
||||||
|
filterFn: "contains",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
muiTableHeadCellProps: {
|
||||||
|
sx: {
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
color: "#000000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
muiTableHeadCellProps: {
|
muiTableHeadCellProps: {
|
||||||
sx: {
|
sx: {
|
||||||
@ -233,6 +253,26 @@ export const config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: "Итого: Поддержка",
|
||||||
|
accessorKey:
|
||||||
|
"field_utverzhdennye_bazovye_raskhody_na_2026_god_itogopodderzhka",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
header: "",
|
||||||
|
accessorKey: "data.approved.support.year",
|
||||||
|
columnLetter: "N",
|
||||||
|
size: 150,
|
||||||
|
filterFn: "contains",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
muiTableHeadCellProps: {
|
||||||
|
sx: {
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
color: "#000000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
muiTableHeadCellProps: {
|
muiTableHeadCellProps: {
|
||||||
sx: {
|
sx: {
|
||||||
@ -325,6 +365,26 @@ export const config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: "Итого: Поддержка",
|
||||||
|
accessorKey:
|
||||||
|
"field_fakticheskie_raskhody_za_2026_god_itogopodderzhka",
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
header: "",
|
||||||
|
accessorKey: "data.fact.support.year",
|
||||||
|
columnLetter: "T",
|
||||||
|
size: 150,
|
||||||
|
filterFn: "contains",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
muiTableHeadCellProps: {
|
||||||
|
sx: {
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
color: "#000000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
muiTableHeadCellProps: {
|
muiTableHeadCellProps: {
|
||||||
sx: {
|
sx: {
|
||||||
@ -377,114 +437,10 @@ export const config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
|
||||||
muiTableHeadCellProps: {
|
|
||||||
sx: {
|
|
||||||
backgroundColor: "#FFFFFF",
|
|
||||||
color: "#000000",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
muiTableHeadCellProps: {
|
|
||||||
sx: {
|
|
||||||
backgroundColor: "#FFFFFF",
|
|
||||||
color: "#000000",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "В {unit} (без НДС)",
|
|
||||||
accessorKey: "v_tys_rubley",
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
header: "Расходы, планируемые РФ на {year} год",
|
|
||||||
accessorKey: "v_tys_rubley_raskhody_planiruemye_rf_na_2026_god",
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
header: "Итого: Поддержка",
|
|
||||||
accessorKey:
|
|
||||||
"v_tys_rubley_raskhody_planiruemye_rf_na_2026_god_itogopodderzhka",
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
header: "",
|
|
||||||
accessorKey: "data.plan.support.year",
|
|
||||||
columnLetter: "H",
|
|
||||||
size: 150,
|
|
||||||
filterFn: "contains",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
muiTableHeadCellProps: {
|
|
||||||
sx: {
|
|
||||||
backgroundColor: "#FFFFFF",
|
|
||||||
color: "#000000",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Утверждённые базовые расходы на {year} год",
|
|
||||||
accessorKey:
|
|
||||||
"v_tys_rubley_utverzhdennye_bazovye_raskhody_na_2026_god",
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
header: "Итого: Поддержка",
|
|
||||||
accessorKey:
|
|
||||||
"v_tys_rubley_utverzhdennye_bazovye_raskhody_na_2026_god_itogopodderzhka",
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
header: "",
|
|
||||||
accessorKey: "data.approved.support.year",
|
|
||||||
columnLetter: "N",
|
|
||||||
size: 150,
|
|
||||||
filterFn: "contains",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
muiTableHeadCellProps: {
|
|
||||||
sx: {
|
|
||||||
backgroundColor: "#FFFFFF",
|
|
||||||
color: "#000000",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Фактические расходы за {year} год",
|
|
||||||
accessorKey: "v_tys_rubley_fakticheskie_raskhody_za_2026_god",
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
header: "Итого: Поддержка",
|
|
||||||
accessorKey:
|
|
||||||
"v_tys_rubley_fakticheskie_raskhody_za_2026_god_itogopodderzhka",
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
header: "",
|
|
||||||
accessorKey: "data.fact.support.year",
|
|
||||||
columnLetter: "T",
|
|
||||||
size: 150,
|
|
||||||
filterFn: "contains",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
muiTableHeadCellProps: {
|
|
||||||
sx: {
|
|
||||||
backgroundColor: "#FFFFFF",
|
|
||||||
color: "#000000",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
header: "Скорректированная смета расходов на {year} год",
|
|
||||||
accessorKey:
|
|
||||||
"v_tys_rubley_skorrektirovannaya_smeta_raskhodov_na_2026_god",
|
|
||||||
columns: [
|
|
||||||
{
|
{
|
||||||
header: "IV квартал",
|
header: "IV квартал",
|
||||||
accessorKey:
|
accessorKey:
|
||||||
"v_tys_rubley_skorrektirovannaya_smeta_raskhodov_na_2026_god_iv_kvartal",
|
"field_skorrektirovannaya_smeta_raskhodov_na_2026_god_iv_kvartal",
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
header: "",
|
header: "",
|
||||||
@ -502,6 +458,12 @@ export const config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
muiTableHeadCellProps: {
|
||||||
|
sx: {
|
||||||
|
backgroundColor: "#FFFFFF",
|
||||||
|
color: "#000000",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
muiTableHeadCellProps: {
|
muiTableHeadCellProps: {
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
// AURORA-1905: в 1-3 кв. "Комментарий" (adj_comment) уже шёл сразу после
|
||||||
|
// "Корректировки из резерва" (adj_reserve), перед "Скорректированный план"
|
||||||
|
// (corrected_plan); в 4 кв. порядок был нарушен (corrected_plan раньше adj_comment).
|
||||||
|
const SHEETS = ['AHR', 'CAP', 'OPER'];
|
||||||
|
|
||||||
|
function extractDataAccessorKeys(source) {
|
||||||
|
// Пропускаем секцию "colors" (плоская карта, свой, не относящийся к
|
||||||
|
// расположению столбцов порядок) — берём только дерево "columns".
|
||||||
|
const columnsStart = source.indexOf('\n\t\tcolumns: [');
|
||||||
|
const columnsSource = columnsStart === -1 ? source : source.slice(columnsStart);
|
||||||
|
const matches = columnsSource.matchAll(/accessorKey:\s*'(data\.[^']+)'/g);
|
||||||
|
return [...matches].map((m) => m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sheet of SHEETS) {
|
||||||
|
test(`FORM_1/${sheet}.js: q4 adj_reserve -> adj_comment -> corrected_plan`, () => {
|
||||||
|
const path = fileURLToPath(new URL(`../constants/FORM_1/${sheet}.js`, import.meta.url));
|
||||||
|
const keys = extractDataAccessorKeys(readFileSync(path, 'utf8'));
|
||||||
|
|
||||||
|
const reserveIndex = keys.indexOf('data.q4.adj_reserve');
|
||||||
|
const commentIndex = keys.indexOf('data.q4.adj_comment');
|
||||||
|
const planIndex = keys.indexOf('data.q4.corrected_plan');
|
||||||
|
|
||||||
|
assert.notEqual(reserveIndex, -1, 'data.q4.adj_reserve не найден');
|
||||||
|
assert.notEqual(commentIndex, -1, 'data.q4.adj_comment не найден');
|
||||||
|
assert.notEqual(planIndex, -1, 'data.q4.corrected_plan не найден');
|
||||||
|
assert.ok(reserveIndex < commentIndex, 'adj_comment должен идти после adj_reserve');
|
||||||
|
assert.ok(commentIndex < planIndex, 'adj_comment должен идти перед corrected_plan');
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
// FORM_2/SMETA.js импортирует '../columnColors' без расширения, поэтому node
|
||||||
|
// не может загрузить модуль напрямую — читаем файл как текст.
|
||||||
|
const configPath = fileURLToPath(new URL('../constants/FORM_2/SMETA.js', import.meta.url));
|
||||||
|
const configSource = readFileSync(configPath, 'utf8');
|
||||||
|
|
||||||
|
const EXPECTED_LEAF_ORDER = [
|
||||||
|
'data.section_code',
|
||||||
|
'data.name',
|
||||||
|
'data.plan.support.q1',
|
||||||
|
'data.plan.support.q2',
|
||||||
|
'data.plan.support.q3',
|
||||||
|
'data.plan.support.q4',
|
||||||
|
'data.plan.support.year',
|
||||||
|
'data.approved.support.q1',
|
||||||
|
'data.approved.support.q2',
|
||||||
|
'data.approved.support.q3',
|
||||||
|
'data.approved.support.q4',
|
||||||
|
'data.approved.support.year',
|
||||||
|
'data.fact.support.q1',
|
||||||
|
'data.fact.support.q2',
|
||||||
|
'data.fact.support.q3',
|
||||||
|
'data.fact.support.q4',
|
||||||
|
'data.fact.support.year',
|
||||||
|
'data.corrected.support.q2',
|
||||||
|
'data.corrected.support.q3',
|
||||||
|
'data.corrected.support.q4',
|
||||||
|
];
|
||||||
|
|
||||||
|
function extractLeafAccessorKeys(source) {
|
||||||
|
const matches = source.matchAll(/accessorKey:\s*"(data\.[^"]+)"/g);
|
||||||
|
return [...matches].map((m) => m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Смета РФ: итоги групп и IV квартал скорректированной сметы идут в конце своих групп', () => {
|
||||||
|
assert.deepEqual(extractLeafAccessorKeys(configSource), EXPECTED_LEAF_ORDER);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Смета РФ: группы "В тыс. рублей" больше нет', () => {
|
||||||
|
assert.ok(!configSource.includes('В тыс. рублей'), 'заголовок "В тыс. рублей" не должен встречаться в конфиге');
|
||||||
|
});
|
||||||
@ -70,9 +70,11 @@ export const StagesTable = ({ stages, onEdit, onDelete }) => {
|
|||||||
<Typography variant='body2'>
|
<Typography variant='body2'>
|
||||||
{SHEET_NAME[stage.sheet]} - {stage.phase_code}
|
{SHEET_NAME[stage.sheet]} - {stage.phase_code}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant='caption' color='text.secondary' sx={{ fontSize: '0.65rem' }}>
|
{isAdmin && (
|
||||||
{STAGE_ROLE_RUSSIAN_NAME[stage.role] ?? stage.role}
|
<Typography variant='caption' color='text.secondary' sx={{ fontSize: '0.65rem' }}>
|
||||||
</Typography>
|
{STAGE_ROLE_RUSSIAN_NAME[stage.role] ?? stage.role}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Typography variant='body2'>{formatDate(stage.opens_at)}</Typography>
|
<Typography variant='body2'>{formatDate(stage.opens_at)}</Typography>
|
||||||
|
|||||||
@ -8,7 +8,6 @@ export const stageColumnsHiddenFromPicker = [
|
|||||||
// 'data.header.name',
|
// 'data.header.name',
|
||||||
'data.header.internal_order',
|
'data.header.internal_order',
|
||||||
// 'data.header.vsp_id',
|
// 'data.header.vsp_id',
|
||||||
'data.header.vsp_address',
|
|
||||||
'data.header.section_code',
|
'data.header.section_code',
|
||||||
// 'data.header.justification',
|
// 'data.header.justification',
|
||||||
];
|
];
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user