diff --git a/api/src/api/v1/form_phases.py b/api/src/api/v1/form_phases.py
index 359bcc3..e839c59 100644
--- a/api/src/api/v1/form_phases.py
+++ b/api/src/api/v1/form_phases.py
@@ -43,6 +43,10 @@ async def get_form_phases(
org_unit_service = OrgUnitService(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(
budget_form_id=form_id,
user=current_user,
@@ -50,22 +54,18 @@ async def get_form_phases(
direction=direction,
phase_code=phase_code,
)
- if phases:
- form = await bf_service.get(user=current_user, budget_form_id=form_id)
- org_unit = await org_unit_service.get(org_unit_id=form.org_unit_id)
- result = [FormPhaseResponse.model_validate(p) for p in phases]
- if org_unit.utc_offset:
- tz = timezone_from_offset(org_unit.utc_offset)
- for element in result:
- if element.opens_at:
- element.opens_at = element.opens_at.astimezone(tz)
- if element.closes_at:
- element.closes_at = element.closes_at.astimezone(tz)
- else:
- result = []
+ org_unit = await org_unit_service.get(org_unit_id=form.org_unit_id)
+ result = [FormPhaseResponse.model_validate(p) for p in phases]
+ if org_unit.utc_offset:
+ tz = timezone_from_offset(org_unit.utc_offset)
+ for element in result:
+ if element.opens_at:
+ element.opens_at = element.opens_at.astimezone(tz)
+ if element.closes_at:
+ element.closes_at = element.closes_at.astimezone(tz)
return BaseListResponse(
result=result,
- count=len(phases),
+ count=len(result),
)
@@ -186,6 +186,10 @@ async def get_form3_phases(
project_service = ProjectService(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(
project_id=project_id,
year=year,
@@ -195,21 +199,18 @@ async def get_form3_phases(
phases = await f3p_service.get_list(
report_id=[el.id for el in reports],
+ user=current_user,
phase_code=phase_code,
)
- if 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)
- result = [Form3PhaseResponse.model_validate(p) for p in phases]
- if org_unit.utc_offset:
- tz = timezone_from_offset(org_unit.utc_offset)
- for element in result:
- if element.opens_at:
- element.opens_at = element.opens_at.astimezone(tz)
- if element.closes_at:
- element.closes_at = element.closes_at.astimezone(tz)
- else:
- result = []
+ result = [Form3PhaseResponse.model_validate(p) for p in phases]
+ org_unit = await org_unit_service.get(org_unit_id=project.org_unit_id)
+ if org_unit.utc_offset:
+ tz = timezone_from_offset(org_unit.utc_offset)
+ for element in result:
+ if element.opens_at:
+ element.opens_at = element.opens_at.astimezone(tz)
+ if element.closes_at:
+ element.closes_at = element.closes_at.astimezone(tz)
if result:
reports = {report.id: report for report in reports}
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
return BaseListResponse(
result=result,
- count=len(phases),
+ count=len(result),
)
diff --git a/api/src/repository/form3_phase_repository.py b/api/src/repository/form3_phase_repository.py
index 39b1488..94e6a1b 100644
--- a/api/src/repository/form3_phase_repository.py
+++ b/api/src/repository/form3_phase_repository.py
@@ -14,6 +14,7 @@ class Form3PhaseRepository:
self,
report_id: int | list[int],
phase_code: str | None = None,
+ role: str | None = None,
) -> list[Form3Phase]:
if isinstance(report_id, int):
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))
if phase_code is not None:
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()
async def get(
diff --git a/api/src/repository/form_phase_repository.py b/api/src/repository/form_phase_repository.py
index 38bf8a4..0c96c9f 100644
--- a/api/src/repository/form_phase_repository.py
+++ b/api/src/repository/form_phase_repository.py
@@ -16,6 +16,7 @@ class FormPhaseRepository:
sheet: str | None = None,
direction: str | None = None,
phase_code: str | None = None,
+ role: str | None = None,
) -> list[FormPhase]:
query = select(FormPhase).where(FormPhase.budget_form_id == budget_form_id)
if sheet is not None:
@@ -23,9 +24,11 @@ class FormPhaseRepository:
FormPhase.sheet == sheet,
FormPhase.direction.is_(None) if direction is None else FormPhase.direction == direction
)
-
+
if phase_code is not None:
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()
async def get(
diff --git a/api/src/repository/vsp_repository.py b/api/src/repository/vsp_repository.py
index 4858f04..87c0de5 100644
--- a/api/src/repository/vsp_repository.py
+++ b/api/src/repository/vsp_repository.py
@@ -14,6 +14,17 @@ class VSPRepository:
def __init__(self, db: AsyncSession):
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(
self,
vsp_id: int,
diff --git a/api/src/services/export_service/export_mappings.py b/api/src/services/export_service/export_mappings.py
index 4ed6a2c..52210c7 100644
--- a/api/src/services/export_service/export_mappings.py
+++ b/api/src/services/export_service/export_mappings.py
@@ -102,6 +102,9 @@ FORM1_DEPTH_COLUMNS: dict = {
"future_y1": {
"name": "Платежи в {next_year} году",
},
+ "future_y2": {
+ "name": "Платежи в {year_plus_2} году",
+ },
"other_ssp": {
"name": "Сумма в других ССП",
},
@@ -182,7 +185,7 @@ FORM1_DEPTH_COLUMNS: dict = {
"name": "Итоговая смета расходов",
"children": {
"plan": {
- "name": "Резерв Финансового Комитета",
+ "name": "План {year}",
"key": None,
"children": {
"q1": {
@@ -217,9 +220,6 @@ FORM1_DEPTH_COLUMNS: dict = {
"protocol": {
"name": "Реквизиты протокола (дата, номер)",
},
- "empty": {
- "name": "",
- },
"note": {
"name": "Примечание",
},
@@ -364,15 +364,6 @@ FORM1_DEPTH_COLUMNS: dict = {
}
}
},
- "shift": {
- "name": "Перенос во 2 кв {year_short}",
- "key": None,
- "children": {
- "sum": {
- "name": "Сумма, тыс. руб. ({vat})",
- }
- }
- },
"comment_correct": {
"name": "Комментарий",
"key": None,
@@ -607,11 +598,11 @@ FORM1_DEPTH_COLUMNS: dict = {
}
}
},
- "shift": {
- "name": "Перенос в 3 кв {year_short}",
+ "comment_correct": {
+ "name": "Комментарий",
"key": None,
"children": {
- "sum": {
+ "adj_comment": {
"name": "Сумма, тыс. руб. ({vat})",
}
}
@@ -834,11 +825,11 @@ FORM1_DEPTH_COLUMNS: dict = {
}
}
},
- "shift": {
- "name": "Перенос в 4 кв {year_short}",
+ "comment_correct": {
+ "name": "Комментарий",
"key": None,
"children": {
- "sum": {
+ "adj_comment": {
"name": "Сумма, тыс. руб. ({vat})",
}
}
@@ -1006,9 +997,6 @@ FORM1_DEPTH_COLUMNS: dict = {
"rev_comment": {
"name": "Сумма, тыс. руб. ({vat})",
},
- "adj_comment": {
- "name": "Сумма, тыс. руб. ({vat})",
- },
}
},
"new_plan_correct": {
@@ -1061,6 +1049,15 @@ FORM1_DEPTH_COLUMNS: dict = {
}
}
},
+ "comment_correct": {
+ "name": "Комментарий",
+ "key": None,
+ "children": {
+ "adj_comment": {
+ "name": "Сумма, тыс. руб. ({vat})",
+ }
+ }
+ },
"plan_correct": {
"name": "Скорр. план 4 кв. {year_short}",
"key": None,
@@ -1134,7 +1131,7 @@ FORM1_DEPTH_COLUMNS: dict = {
}
},
"quarter_correct": {
- "name": "Факт РСБУ 3 кв {year_short}",
+ "name": "Факт РСБУ 4 кв {year_short}",
"key": None,
"children": {
"actual_quarter": {
@@ -3255,6 +3252,12 @@ def _rename_color_paths(colors: dict, renames: dict[str, str]) -> dict:
return renamed
+# На экране «Операц» формы 1 нет колонки «Платежи в {year+2} году» (она есть
+# только на АХР/КВ) — отдельная раскладка без contract_summary.future_y2 (AURORA-1905).
+FORM1_OPER_DEPTH_COLUMNS: dict = copy.deepcopy(FORM1_DEPTH_COLUMNS)
+FORM1_OPER_DEPTH_COLUMNS["upper_ssp_smeta"]["children"]["contract_summary"]["children"].pop("future_y2", None)
+
+
FORM4_DEPTH_COLUMNS: dict = _rename_depth_columns(
FORM1_DEPTH_COLUMNS,
FORM4_COLUMN_RENAMES,
@@ -3295,26 +3298,8 @@ def _patch_form4_regular_columns() -> None:
FORM4_DEPTH_COLUMNS["header"]["children"].pop(key, None)
FORM4_STRUCTURE_DEPTH_COLUMNS["header"]["children"].pop("justification", None)
- for quarter in ("q2", "q3"):
- FORM4_DEPTH_COLUMNS[quarter]["children"].setdefault(
- "comment_correct",
- {
- "key": None,
- "name": "Комментарий",
- "children": {"adj_comment": {"name": "Сумма"}},
- },
- )
- FORM4_STRUCTURE_DEPTH_COLUMNS[quarter]["children"].setdefault(
- "comment_correct",
- {
- "key": None,
- "name": "Комментарий",
- "children": {"adj_comment": {"name": "Сумма"}},
- },
- )
-
- for quarter in (1, 2, 3):
- FORM4_DEPTH_COLUMNS.pop(f"actual_rsbu_spod_q{quarter}", None)
+ # comment_correct (q2/q3) теперь приходит из FORM1_DEPTH_COLUMNS уже на нужном
+ # месте — отдельный setdefault здесь больше не нужен (AURORA-1905).
FORM4_DEPTH_COLUMNS["totals"]["children"].pop("pay_year", None)
@@ -3338,7 +3323,7 @@ def _patch_form4_contract_summary_columns() -> None:
summary_parent = FORM4_DEPTH_COLUMNS["upper_ssp_smeta"]["children"]["contract_summary"]
summary = summary_parent["children"]
summary["reference"] = {"name": "Реквизиты договора"}
- summary["future_y2"] = {"name": "Платежи в {next_year} году"}
+ summary["future_y2"] = {"name": "Платежи в {year_plus_2} году"}
summary["centralized_flag"] = {"name": "Централизованный договор"}
summary_parent["children"] = _reorder_children(
summary,
@@ -3350,7 +3335,7 @@ def _patch_form4_contract_summary_columns() -> None:
structure_summary_parent = FORM4_STRUCTURE_DEPTH_COLUMNS["upper_ssp_smeta"]["children"]["contract_summary"]
structure_summary = structure_summary_parent["children"]
- structure_summary["future_payments_y2"] = {"name": "Платежи в {next_year} году"}
+ structure_summary["future_payments_y2"] = {"name": "Платежи в {year_plus_2} году"}
structure_summary_parent["children"] = _reorder_children(
structure_summary,
(
@@ -3361,14 +3346,17 @@ def _patch_form4_contract_summary_columns() -> None:
def _patch_form4_actual_rsbu_spod_columns() -> None:
+ # SQL отдаёт actual_rsbu_spod только для 4 кв.; 1-3 кв. показывают то же
+ # значение, что и q{n}.actual_quarter внутри самого квартала (AURORA-1905).
totals = FORM4_DEPTH_COLUMNS.pop("totals")
for quarter in range(1, 5):
+ leaf_key = f"q{quarter}.actual_rsbu_spod" if quarter == 4 else f"q{quarter}.actual_quarter"
FORM4_DEPTH_COLUMNS[f"actual_rsbu_spod_q{quarter}"] = {
"key": None,
"name": f"Факт {quarter} кв" if quarter < 4 else "Факт 4 кв + СПОД",
"children": {
"actual_rsbu_spod": {
- "key": f"q{quarter}.actual_rsbu_spod",
+ "key": leaf_key,
"name": "",
},
},
@@ -3380,7 +3368,6 @@ _patch_form4_regular_columns()
_patch_form4_header_columns()
_patch_form4_contract_summary_columns()
_patch_form4_actual_rsbu_spod_columns()
-_patch_form4_regular_columns()
FORM4_OPER_DEPTH_COLUMNS: dict = copy.deepcopy(FORM4_DEPTH_COLUMNS)
FORM4_OPER_DEPTH_COLUMNS["empty_upper"]["children"]["ckk"]["children"]["procurement_plan"] = (
@@ -3389,6 +3376,12 @@ FORM4_OPER_DEPTH_COLUMNS["empty_upper"]["children"]["ckk"]["children"]["procurem
FORM4_OPER_DEPTH_COLUMNS["empty_upper"]["children"]["ckk"]["children"]["procurement_method"] = (
FORM4_OPER_DEPTH_COLUMNS["empty_upper"]["children"]["ckk"]["children"].pop("proc_method")
)
+# Переименование выше (pop+присвоение) уезжает в конец словаря — возвращаем
+# порядок экрана: ceiling, q1-q4, rf_schedule, deadline, затем закупки, comment (AURORA-1905).
+FORM4_OPER_DEPTH_COLUMNS["empty_upper"]["children"]["ckk"]["children"] = _reorder_children(
+ FORM4_OPER_DEPTH_COLUMNS["empty_upper"]["children"]["ckk"]["children"],
+ ("ceiling", "q1", "q2", "q3", "q4", "rf_schedule", "deadline", "procurement_plan", "procurement_method", "comment"),
+)
FORM4_OPER_COLORS: dict = _rename_color_paths(
FORM4_COLORS["OPER"],
{
@@ -4080,9 +4073,1380 @@ def render_column_years(columns: dict, year: int, extra: dict[str, str] | None =
"year_short": f"{year % 100:02d}",
"next_year": str(year + 1),
"next_year_short": f"{(year + 1) % 100:02d}",
+ "year_plus_2": str(year + 2),
}
if extra:
values.update(extra)
_replace_column_year_placeholders(rendered, values)
return rendered
+
+
+# FORM_2 (Операц/АХР/КВ Сметы РФ): дерево колонок и цвета для выгрузки Excel, сгенерированы
+# из web/src/components/RealtimeTable/constants/FORM_2/{OPER,AHR,CAP}.js (колонки у всех трёх
+# листов идентичны, различаются только цвета). header.vsp_address («Адрес ВСП») исключён —
+# его нет в исходной форме РФ (AURORA-1902). ВСП в выгрузке подставляется номером
+# (registration_number), а не id — см. ExportService._resolve_form2_vsp_numbers.
+FORM2_DEPTH_COLUMNS: dict = {
+ "section_code": {
+ "name": "Код раздела",
+ "key": "header.section_code",
+ },
+ "item_id": {
+ "name": "ID Статьи",
+ "key": "header.item_id",
+ },
+ "num_group": {
+ "name": "ID Группы номенклатуры",
+ "key": "header.num_group",
+ },
+ "name": {
+ "name": "Наименование",
+ "key": "header.name",
+ },
+ "vsp_id": {
+ "name": "ВСП РФ",
+ "key": "header.vsp_id",
+ },
+ "god": {
+ "name": "{year} год",
+ "key": None,
+ "children": {
+ "planirovanie_godovykh_pokazateley": {
+ "name": "Планирование годовых показателей, {unit} (без НДС)",
+ "key": None,
+ "children": {
+ "q1": {
+ "name": "1 квартал",
+ "key": "plan.q1",
+ },
+ "q2": {
+ "name": "2 квартал",
+ "key": "plan.q2",
+ },
+ "q3": {
+ "name": "3 квартал",
+ "key": "plan.q3",
+ },
+ "q4": {
+ "name": "4 квартал",
+ "key": "plan.q4",
+ },
+ "year": {
+ "name": "Год",
+ "key": "plan.year",
+ },
+ "comment": {
+ "name": "Комментарий",
+ "key": "plan.comment",
+ },
+ },
+ },
+ "sekvestirovanie_dfip": {
+ "name": "Секвестирование ДФиП",
+ "key": None,
+ "children": {
+ "q1": {
+ "name": "1 квартал",
+ "key": "seq_dfip.q1",
+ },
+ "q2": {
+ "name": "2 квартал",
+ "key": "seq_dfip.q2",
+ },
+ "q3": {
+ "name": "3 квартал",
+ "key": "seq_dfip.q3",
+ },
+ "q4": {
+ "name": "4 квартал",
+ "key": "seq_dfip.q4",
+ },
+ "year": {
+ "name": "Год",
+ "key": "seq_dfip.year",
+ },
+ "justification": {
+ "name": "Обоснование",
+ "key": "seq_dfip.justification",
+ },
+ },
+ },
+ "sekvestirovanie_ssp_go": {
+ "name": "Секвестирование ССП ГО",
+ "key": None,
+ "children": {
+ "q1": {
+ "name": "1 квартал",
+ "key": "seq_ssp.q1",
+ },
+ "q2": {
+ "name": "2 квартал",
+ "key": "seq_ssp.q2",
+ },
+ "q3": {
+ "name": "3 квартал",
+ "key": "seq_ssp.q3",
+ },
+ "q4": {
+ "name": "4 квартал",
+ "key": "seq_ssp.q4",
+ },
+ "year": {
+ "name": "Год",
+ "key": "seq_ssp.year",
+ },
+ "justification": {
+ "name": "Обоснование",
+ "key": "seq_ssp.justification",
+ },
+ },
+ },
+ "utverzhdennaya_smeta_raskhodov": {
+ "name": "Утвержденная смета расходов",
+ "key": None,
+ "children": {
+ "q1": {
+ "name": "1 квартал",
+ "key": "approved.q1",
+ },
+ "q2": {
+ "name": "2 квартал",
+ "key": "approved.q2",
+ },
+ "q3": {
+ "name": "3 квартал",
+ "key": "approved.q3",
+ },
+ "q4": {
+ "name": "4 квартал",
+ "key": "approved.q4",
+ },
+ "year": {
+ "name": "Год",
+ "key": "approved.year",
+ },
+ },
+ },
+ "parametry_zaklyuchennogo_khozyaystvennogo_dogovora": {
+ "name": "Параметры заключенного хозяйственного договора",
+ "key": None,
+ "children": {
+ "counterparty": {
+ "name": "Контрагент",
+ "key": "contract.counterparty",
+ },
+ "reference": {
+ "name": "Номер",
+ "key": "contract.reference",
+ },
+ "date": {
+ "name": "Дата",
+ "key": "contract.date",
+ },
+ "subject": {
+ "name": "Предмет",
+ "key": "contract.subject",
+ },
+ "currency": {
+ "name": "Валюта",
+ "key": "contract.currency",
+ },
+ "ceiling": {
+ "name": "Предельная стоимость",
+ "key": "contract.ceiling",
+ },
+ "vat_rate": {
+ "name": "Ставка НДС (%)",
+ "key": "contract.vat_rate",
+ },
+ "deadline": {
+ "name": "Срок",
+ "key": "contract.deadline",
+ },
+ "scheme": {
+ "name": "Схема платежа",
+ "key": "contract.scheme",
+ },
+ "act": {
+ "name": "АКТ",
+ "key": "contract.act",
+ },
+ "comment": {
+ "name": "Комментарий",
+ "key": "contract.comment",
+ },
+ "q1": {
+ "name": "Бронь средств в 1 квартале",
+ "key": "booking.y2026.q1",
+ },
+ "q2": {
+ "name": "Бронь средств во 2 квартале",
+ "key": "booking.y2026.q2",
+ },
+ "q3": {
+ "name": "Бронь средств в 3 квартале",
+ "key": "booking.y2026.q3",
+ },
+ "q4": {
+ "name": "Бронь средств в 4 квартале",
+ "key": "booking.y2026.q4",
+ },
+ },
+ },
+ },
+ },
+ "god_2": {
+ "name": "{next_year} год",
+ "key": None,
+ "children": {
+ "q1": {
+ "name": "Бронь средств в 1 квартале",
+ "key": "booking.y2027.q1",
+ },
+ "q2": {
+ "name": "Бронь средств во 2 квартале",
+ "key": "booking.y2027.q2",
+ },
+ "q3": {
+ "name": "Бронь средств в 3 квартале",
+ "key": "booking.y2027.q3",
+ },
+ "q4": {
+ "name": "Бронь средств в 4 квартале",
+ "key": "booking.y2027.q4",
+ },
+ },
+ },
+ "kvartal_2026_goda": {
+ "name": "1 квартал {year} года",
+ "key": None,
+ "children": {
+ "korrektirovki_planovykh_znacheniy": {
+ "name": "Корректировки плановых значений",
+ "key": None,
+ "children": {
+ "adj_current": {
+ "name": "Текущие корректировки (= 0)",
+ "key": "q1.adj_current",
+ },
+ "adj_ssp": {
+ "name": "Корректировка из сметы ГО",
+ "key": "q1.adj_ssp",
+ },
+ "adj_reserve": {
+ "name": "Корректировка из резерва",
+ "key": "q1.adj_reserve",
+ },
+ "adj_comment": {
+ "name": "Комментарий",
+ "key": "q1.adj_comment",
+ },
+ "corrected_plan": {
+ "name": "Базовый план с учётом корректировок",
+ "key": "q1.corrected_plan",
+ },
+ },
+ },
+ "soglasovanie_platezhey": {
+ "name": "Согласование платежей",
+ "key": None,
+ "children": {
+ "pay_date": {
+ "name": "Дата платежа",
+ "key": "q1.pay_date",
+ },
+ "pay_amount": {
+ "name": "Сумма платежа",
+ "key": "q1.pay_amount",
+ },
+ "pay_comment": {
+ "name": "Комментарий",
+ "key": "q1.pay_comment",
+ },
+ "pay_act": {
+ "name": "Предоставление акта",
+ "key": "q1.pay_act",
+ },
+ },
+ },
+ "fakticheskie_raskhody": {
+ "name": "Фактические расходы",
+ "key": None,
+ "children": {
+ "booking": {
+ "name": "Бронь квартал",
+ "key": "q1.booking",
+ },
+ "actual_m1": {
+ "name": "Факт январь",
+ "key": "q1.actual_m1",
+ },
+ "actual_m2": {
+ "name": "Факт февраль",
+ "key": "q1.actual_m2",
+ },
+ "actual_m3": {
+ "name": "Факт март",
+ "key": "q1.actual_m3",
+ },
+ "actual_quarter": {
+ "name": "Факт квартал",
+ "key": "q1.actual_quarter",
+ },
+ "residual_after_booking": {
+ "name": "Остаток после брони 1 квартала",
+ "key": "q1.residual_after_booking",
+ },
+ "residual_after_actual": {
+ "name": "Остаток после факта 1 квартала",
+ "key": "q1.residual_after_actual",
+ },
+ },
+ },
+ "korrektirovka_ostatka_v_posleduyuschie_kvartaly": {
+ "name": "Корректировка остатка в последующие кварталы",
+ "key": None,
+ "children": {
+ "transfer_q2": {
+ "name": "Перенос во 2 квартал",
+ "key": "q1.transfer_q2",
+ },
+ "transfer_q2_delay_acts": {
+ "name": "Задержка списания расходов / предоставления актов",
+ "key": "q1.transfer_q2_delay_acts",
+ },
+ "transfer_q2_delay_procurement": {
+ "name": "Задержка закупочных процедур",
+ "key": "q1.transfer_q2_delay_procurement",
+ },
+ "transfer_q2_economy_rf": {
+ "name": "Экономия (будет использована РФ)",
+ "key": "q1.transfer_q2_economy_rf",
+ },
+ "transfer_next_comment": {
+ "name": "Комментарий переноса во 2 квартал",
+ "key": "q1.transfer_next_comment",
+ },
+ "transfer_q3": {
+ "name": "Перенос в 3 квартал",
+ "key": "q1.transfer_q3",
+ },
+ "transfer_q4": {
+ "name": "Перенос в 4 квартал",
+ "key": "q1.transfer_q4",
+ },
+ "transfer_far_comment": {
+ "name": "Комментарий переноса в 3 и 4 кварталах",
+ "key": "q1.transfer_far_comment",
+ },
+ "transfer_econ": {
+ "name": "Перенос в фонд экономии",
+ "key": "q1.transfer_econ",
+ },
+ "total": {
+ "name": "Всего",
+ "key": "q1.total",
+ },
+ },
+ },
+ },
+ },
+ "kvartal_2026_goda_2": {
+ "name": "2 квартал {year} года",
+ "key": None,
+ "children": {
+ "korrektirovka_bazovogo_plana": {
+ "name": "Корректировка базового плана",
+ "key": None,
+ "children": {
+ "target_change": {
+ "name": "Изменение целевого назначения перенесённой экономии (= 0)",
+ "key": "q2.target_change",
+ },
+ "base_correction": {
+ "name": "Корректировка статей базового плана (= 0)",
+ "key": "q2.base_correction",
+ },
+ "base_correction_comment": {
+ "name": "Комментарий изменения базового плана",
+ "key": "q2.base_correction_comment",
+ },
+ "revision_inc": {
+ "name": "ДФиП: увеличение плана (> 0)",
+ "key": "q2.revision_inc",
+ },
+ "revision_seq": {
+ "name": "ДФиП: секвестр плана (< 0)",
+ "key": "q2.revision_seq",
+ },
+ "revision_comment": {
+ "name": "Комментарий",
+ "key": "q2.revision_comment",
+ },
+ "new_plan": {
+ "name": "Скорректированный план на подпись",
+ "key": "q2.new_plan",
+ },
+ },
+ },
+ "korrektirovki_planovykh_znacheniy": {
+ "name": "Корректировки плановых значений",
+ "key": None,
+ "children": {
+ "adj_current": {
+ "name": "Текущие корректировки (= 0)",
+ "key": "q2.adj_current",
+ },
+ "adj_ssp": {
+ "name": "Корректировка из сметы ГО",
+ "key": "q2.adj_ssp",
+ },
+ "adj_reserve": {
+ "name": "Корректировка из резерва",
+ "key": "q2.adj_reserve",
+ },
+ "adj_comment": {
+ "name": "Комментарий",
+ "key": "q2.adj_comment",
+ },
+ "corrected_plan": {
+ "name": "Скорректированный план с учётом корректировок",
+ "key": "q2.corrected_plan",
+ },
+ },
+ },
+ "soglasovanie_platezhey": {
+ "name": "Согласование платежей",
+ "key": None,
+ "children": {
+ "pay_date": {
+ "name": "Дата платежа",
+ "key": "q2.pay_date",
+ },
+ "pay_amount": {
+ "name": "Сумма платежа",
+ "key": "q2.pay_amount",
+ },
+ "pay_comment": {
+ "name": "Комментарий",
+ "key": "q2.pay_comment",
+ },
+ "pay_act": {
+ "name": "Предоставление акта",
+ "key": "q2.pay_act",
+ },
+ },
+ },
+ "fakticheskie_raskhody": {
+ "name": "Фактические расходы",
+ "key": None,
+ "children": {
+ "booking": {
+ "name": "Бронь квартал",
+ "key": "q2.booking",
+ },
+ "actual_m1": {
+ "name": "Факт апрель",
+ "key": "q2.actual_m1",
+ },
+ "actual_m2": {
+ "name": "Факт май",
+ "key": "q2.actual_m2",
+ },
+ "actual_m3": {
+ "name": "Факт июнь",
+ "key": "q2.actual_m3",
+ },
+ "actual_quarter": {
+ "name": "Факт квартал",
+ "key": "q2.actual_quarter",
+ },
+ "residual_after_booking": {
+ "name": "Остаток после брони 2 квартала",
+ "key": "q2.residual_after_booking",
+ },
+ "residual_after_actual": {
+ "name": "Остаток после факта 2 квартала",
+ "key": "q2.residual_after_actual",
+ },
+ },
+ },
+ "korrektirovka_ostatka_v_posleduyuschie_kvartaly": {
+ "name": "Корректировка остатка в последующие кварталы",
+ "key": None,
+ "children": {
+ "transfer_q3": {
+ "name": "Перенос в 3 квартал",
+ "key": "q2.transfer_q3",
+ },
+ "transfer_q3_delay_acts": {
+ "name": "Задержка списания расходов / предоставления актов",
+ "key": "q2.transfer_q3_delay_acts",
+ },
+ "transfer_q3_delay_procurement": {
+ "name": "Задержка закупочных процедур",
+ "key": "q2.transfer_q3_delay_procurement",
+ },
+ "transfer_q3_economy_rf": {
+ "name": "Экономия (будет использована РФ)",
+ "key": "q2.transfer_q3_economy_rf",
+ },
+ "transfer_next_comment": {
+ "name": "Комментарий переноса в 3 квартал",
+ "key": "q2.transfer_next_comment",
+ },
+ "transfer_q4": {
+ "name": "Перенос в 4 квартал",
+ "key": "q2.transfer_q4",
+ },
+ "transfer_far_comment": {
+ "name": "Комментарий переноса в 4 квартал",
+ "key": "q2.transfer_far_comment",
+ },
+ "transfer_econ": {
+ "name": "Перенос в фонд экономии",
+ "key": "q2.transfer_econ",
+ },
+ "total": {
+ "name": "Всего",
+ "key": "q2.total",
+ },
+ },
+ },
+ },
+ },
+ "kvartal_2026_goda_3": {
+ "name": "3 квартал {year} года",
+ "key": None,
+ "children": {
+ "korrektirovka_bazovogo_plana": {
+ "name": "Корректировка базового плана",
+ "key": None,
+ "children": {
+ "target_change": {
+ "name": "Изменение целевого назначения перенесённой экономии (= 0)",
+ "key": "q3.target_change",
+ },
+ "base_correction": {
+ "name": "Корректировка статей базового плана (= 0)",
+ "key": "q3.base_correction",
+ },
+ "base_correction_comment": {
+ "name": "Комментарий изменения базового плана",
+ "key": "q3.base_correction_comment",
+ },
+ "revision_inc": {
+ "name": "ДФиП: увеличение плана (> 0)",
+ "key": "q3.revision_inc",
+ },
+ "revision_seq": {
+ "name": "ДФиП: секвестр плана (< 0)",
+ "key": "q3.revision_seq",
+ },
+ "revision_comment": {
+ "name": "Комментарий",
+ "key": "q3.revision_comment",
+ },
+ "new_plan": {
+ "name": "Скорректированный план на подпись",
+ "key": "q3.new_plan",
+ },
+ },
+ },
+ "korrektirovki_planovykh_znacheniy": {
+ "name": "Корректировки плановых значений",
+ "key": None,
+ "children": {
+ "adj_current": {
+ "name": "Текущие корректировки (= 0)",
+ "key": "q3.adj_current",
+ },
+ "adj_ssp": {
+ "name": "Корректировка из сметы ГО",
+ "key": "q3.adj_ssp",
+ },
+ "adj_reserve": {
+ "name": "Корректировка из резерва",
+ "key": "q3.adj_reserve",
+ },
+ "adj_comment": {
+ "name": "Комментарий",
+ "key": "q3.adj_comment",
+ },
+ "corrected_plan": {
+ "name": "Скорректированный план с учётом корректировок",
+ "key": "q3.corrected_plan",
+ },
+ },
+ },
+ "soglasovanie_platezhey": {
+ "name": "Согласование платежей",
+ "key": None,
+ "children": {
+ "pay_date": {
+ "name": "Дата платежа",
+ "key": "q3.pay_date",
+ },
+ "pay_amount": {
+ "name": "Сумма платежа",
+ "key": "q3.pay_amount",
+ },
+ "pay_comment": {
+ "name": "Комментарий",
+ "key": "q3.pay_comment",
+ },
+ "pay_act": {
+ "name": "Предоставление акта",
+ "key": "q3.pay_act",
+ },
+ },
+ },
+ "fakticheskie_raskhody": {
+ "name": "Фактические расходы",
+ "key": None,
+ "children": {
+ "booking": {
+ "name": "Бронь квартал",
+ "key": "q3.booking",
+ },
+ "actual_m1": {
+ "name": "Факт июль",
+ "key": "q3.actual_m1",
+ },
+ "actual_m2": {
+ "name": "Факт август",
+ "key": "q3.actual_m2",
+ },
+ "actual_m3": {
+ "name": "Факт сентябрь",
+ "key": "q3.actual_m3",
+ },
+ "actual_quarter": {
+ "name": "Факт квартал",
+ "key": "q3.actual_quarter",
+ },
+ "residual_after_booking": {
+ "name": "Остаток после брони 3 квартала",
+ "key": "q3.residual_after_booking",
+ },
+ "residual_after_actual": {
+ "name": "Остаток после факта 3 квартала",
+ "key": "q3.residual_after_actual",
+ },
+ },
+ },
+ "korrektirovka_ostatka_v_posleduyuschiy_kvartal": {
+ "name": "Корректировка остатка в последующий квартал",
+ "key": None,
+ "children": {
+ "transfer_q4": {
+ "name": "Перенос в 4 квартал",
+ "key": "q3.transfer_q4",
+ },
+ "transfer_q4_delay_acts": {
+ "name": "Задержка списания расходов / предоставления актов",
+ "key": "q3.transfer_q4_delay_acts",
+ },
+ "transfer_q4_delay_procurement": {
+ "name": "Задержка закупочных процедур",
+ "key": "q3.transfer_q4_delay_procurement",
+ },
+ "transfer_q4_economy_rf": {
+ "name": "Экономия (будет использована РФ)",
+ "key": "q3.transfer_q4_economy_rf",
+ },
+ "transfer_next_comment": {
+ "name": "Комментарий переноса в 4 квартал",
+ "key": "q3.transfer_next_comment",
+ },
+ "transfer_econ": {
+ "name": "Перенос в фонд экономии",
+ "key": "q3.transfer_econ",
+ },
+ "total": {
+ "name": "Всего",
+ "key": "q3.total",
+ },
+ },
+ },
+ },
+ },
+ "kvartal_2026_goda_4": {
+ "name": "4 квартал {year} года",
+ "key": None,
+ "children": {
+ "korrektirovka_bazovogo_plana": {
+ "name": "Корректировка базового плана",
+ "key": None,
+ "children": {
+ "target_change": {
+ "name": "Изменение целевого назначения перенесённой экономии (= 0)",
+ "key": "q4.target_change",
+ },
+ "base_correction": {
+ "name": "Корректировка статей базового плана (= 0)",
+ "key": "q4.base_correction",
+ },
+ "base_correction_comment": {
+ "name": "Комментарий изменения базового плана",
+ "key": "q4.base_correction_comment",
+ },
+ "revision_inc": {
+ "name": "ДФиП: увеличение плана (> 0)",
+ "key": "q4.revision_inc",
+ },
+ "revision_seq": {
+ "name": "ДФиП: секвестр плана (< 0)",
+ "key": "q4.revision_seq",
+ },
+ "revision_comment": {
+ "name": "Комментарий",
+ "key": "q4.revision_comment",
+ },
+ "new_plan": {
+ "name": "Скорректированный план на подпись",
+ "key": "q4.new_plan",
+ },
+ },
+ },
+ "korrektirovki_planovykh_znacheniy": {
+ "name": "Корректировки плановых значений",
+ "key": None,
+ "children": {
+ "adj_current": {
+ "name": "Текущие корректировки (= 0)",
+ "key": "q4.adj_current",
+ },
+ "adj_ssp": {
+ "name": "Корректировка из сметы ГО",
+ "key": "q4.adj_ssp",
+ },
+ "adj_reserve": {
+ "name": "Корректировка из резерва",
+ "key": "q4.adj_reserve",
+ },
+ "adj_comment": {
+ "name": "Комментарий",
+ "key": "q4.adj_comment",
+ },
+ "corrected_plan": {
+ "name": "Скорректированный план с учётом корректировок",
+ "key": "q4.corrected_plan",
+ },
+ },
+ },
+ "soglasovanie_platezhey": {
+ "name": "Согласование платежей",
+ "key": None,
+ "children": {
+ "pay_date": {
+ "name": "Дата платежа",
+ "key": "q4.pay_date",
+ },
+ "pay_amount": {
+ "name": "Сумма платежа",
+ "key": "q4.pay_amount",
+ },
+ "pay_comment": {
+ "name": "Комментарий",
+ "key": "q4.pay_comment",
+ },
+ "pay_act": {
+ "name": "Предоставление акта",
+ "key": "q4.pay_act",
+ },
+ },
+ },
+ "fakticheskie_raskhody": {
+ "name": "Фактические расходы",
+ "key": None,
+ "children": {
+ "booking": {
+ "name": "Бронь квартал",
+ "key": "q4.booking",
+ },
+ "actual_m1": {
+ "name": "Факт октябрь",
+ "key": "q4.actual_m1",
+ },
+ "actual_m2": {
+ "name": "Факт ноябрь",
+ "key": "q4.actual_m2",
+ },
+ "actual_m3": {
+ "name": "Факт декабрь",
+ "key": "q4.actual_m3",
+ },
+ "actual_spod": {
+ "name": "СПОД (январь)",
+ "key": "q4.actual_spod",
+ },
+ "actual_quarter": {
+ "name": "Факт квартал (с учётом СПОД)",
+ "key": "q4.actual_quarter",
+ },
+ "residual_after_booking": {
+ "name": "Остаток после брони 4 квартала (с учётом СПОД)",
+ "key": "q4.residual_after_booking",
+ },
+ "residual_after_actual": {
+ "name": "Остаток после факта 4 квартала (с учётом СПОД)",
+ "key": "q4.residual_after_actual",
+ },
+ },
+ },
+ "ekonomiya": {
+ "name": "Экономия",
+ "key": None,
+ "children": {
+ "transfer_econ": {
+ "name": "Перенос в фонд экономии",
+ "key": "q4.transfer_econ",
+ },
+ },
+ },
+ },
+ },
+ "god_43": {
+ "name": "{year} год",
+ "key": None,
+ "children": {
+ "fact_year": {
+ "name": "Сумма фактических расходов за год",
+ "key": "totals.fact_year",
+ },
+ "pay_year": {
+ "name": "Сумма оплаты за год",
+ "key": "totals.pay_year",
+ },
+ },
+ },
+}
+FORM2_COLORS: dict = {
+ "OPER": {
+ "header.section_code": {"palette": "blue"},
+ "header.item_id": {"palette": "blue"},
+ "header.num_group": {"palette": "blue"},
+ "header.name": {"palette": "blue"},
+ "header.vsp_id": {"palette": "blue"},
+ "plan.q1": {"palette": "blue"},
+ "plan.q2": {"palette": "blue"},
+ "plan.q3": {"palette": "blue"},
+ "plan.q4": {"palette": "blue"},
+ "plan.year": {"palette": "blue"},
+ "plan.comment": {"palette": "blue"},
+ "seq_dfip.q1": {"palette": "blue"},
+ "seq_dfip.q2": {"palette": "blue"},
+ "seq_dfip.q3": {"palette": "blue"},
+ "seq_dfip.q4": {"palette": "blue"},
+ "seq_dfip.year": {"palette": "blue"},
+ "seq_dfip.justification": {"palette": "blue"},
+ "seq_ssp.q1": {"palette": "blue"},
+ "seq_ssp.q2": {"palette": "blue"},
+ "seq_ssp.q3": {"palette": "blue"},
+ "seq_ssp.q4": {"palette": "blue"},
+ "seq_ssp.year": {"palette": "blue"},
+ "seq_ssp.justification": {"palette": "blue"},
+ "approved.q1": {"palette": "blue"},
+ "approved.q2": {"palette": "blue"},
+ "approved.q3": {"palette": "blue"},
+ "approved.q4": {"palette": "blue"},
+ "approved.year": {"palette": "blue"},
+ "contract.counterparty": {"palette": "orange"},
+ "contract.reference": {"palette": "orange"},
+ "contract.date": {"palette": "orange"},
+ "contract.subject": {"palette": "orange"},
+ "contract.currency": {"palette": "orange"},
+ "contract.ceiling": {"palette": "orange"},
+ "contract.vat_rate": {"palette": "orange"},
+ "contract.deadline": {"palette": "orange"},
+ "contract.scheme": {"palette": "orange"},
+ "contract.act": {"palette": "orange"},
+ "contract.comment": {"palette": "orange"},
+ "booking.y2026.q1": {"palette": "orange"},
+ "booking.y2026.q2": {"palette": "orange"},
+ "booking.y2026.q3": {"palette": "orange"},
+ "booking.y2026.q4": {"palette": "orange"},
+ "booking.y2027.q1": {"palette": "orange"},
+ "booking.y2027.q2": {"palette": "orange"},
+ "booking.y2027.q3": {"palette": "orange"},
+ "booking.y2027.q4": {"palette": "orange"},
+ "q1.adj_current": {"palette": "yellow"},
+ "q1.adj_ssp": {"palette": "yellow"},
+ "q1.adj_reserve": {"palette": "yellow"},
+ "q1.adj_comment": {"palette": "yellow"},
+ "q1.corrected_plan": {"palette": "yellow"},
+ "q1.pay_date": {"palette": "blue"},
+ "q1.pay_amount": {"palette": "blue"},
+ "q1.pay_comment": {"palette": "blue"},
+ "q1.pay_act": {"palette": "blue"},
+ "q1.booking": {"palette": "orange"},
+ "q1.actual_m1": {"palette": "green"},
+ "q1.actual_m2": {"palette": "green"},
+ "q1.actual_m3": {"palette": "green"},
+ "q1.actual_quarter": {"palette": "green"},
+ "q1.residual_after_booking": {"palette": "orange"},
+ "q1.residual_after_actual": {"palette": "green"},
+ "q1.transfer_q2": {"palette": "red"},
+ "q1.transfer_q2_delay_acts": {"palette": "red"},
+ "q1.transfer_q2_delay_procurement": {"palette": "red"},
+ "q1.transfer_q2_economy_rf": {"palette": "red"},
+ "q1.transfer_next_comment": {"palette": "red"},
+ "q1.transfer_q3": {"palette": "red"},
+ "q1.transfer_q4": {"palette": "red"},
+ "q1.transfer_far_comment": {"palette": "red"},
+ "q1.transfer_econ": {"palette": "red"},
+ "q1.total": {"palette": "red"},
+ "q2.target_change": {"palette": "red"},
+ "q2.base_correction": {"palette": "red"},
+ "q2.base_correction_comment": {"palette": "red"},
+ "q2.revision_inc": {"palette": "yellow"},
+ "q2.revision_seq": {"palette": "yellow"},
+ "q2.revision_comment": {"palette": "yellow"},
+ "q2.new_plan": {"palette": "yellow"},
+ "q2.adj_current": {"palette": "yellow"},
+ "q2.adj_ssp": {"palette": "yellow"},
+ "q2.adj_reserve": {"palette": "yellow"},
+ "q2.adj_comment": {"palette": "yellow"},
+ "q2.corrected_plan": {"palette": "yellow"},
+ "q2.pay_date": {"palette": "blue"},
+ "q2.pay_amount": {"palette": "blue"},
+ "q2.pay_comment": {"palette": "blue"},
+ "q2.pay_act": {"palette": "blue"},
+ "q2.booking": {"palette": "orange"},
+ "q2.actual_m1": {"palette": "green"},
+ "q2.actual_m2": {"palette": "green"},
+ "q2.actual_m3": {"palette": "green"},
+ "q2.actual_quarter": {"palette": "green"},
+ "q2.residual_after_booking": {"palette": "orange"},
+ "q2.residual_after_actual": {"palette": "green"},
+ "q2.transfer_q3": {"palette": "red"},
+ "q2.transfer_q3_delay_acts": {"palette": "red"},
+ "q2.transfer_q3_delay_procurement": {"palette": "red"},
+ "q2.transfer_q3_economy_rf": {"palette": "red"},
+ "q2.transfer_next_comment": {"palette": "red"},
+ "q2.transfer_q4": {"palette": "red"},
+ "q2.transfer_far_comment": {"palette": "red"},
+ "q2.transfer_econ": {"palette": "red"},
+ "q2.total": {"palette": "red"},
+ "q3.target_change": {"palette": "red"},
+ "q3.base_correction": {"palette": "red"},
+ "q3.base_correction_comment": {"palette": "red"},
+ "q3.revision_inc": {"palette": "yellow"},
+ "q3.revision_seq": {"palette": "yellow"},
+ "q3.revision_comment": {"palette": "yellow"},
+ "q3.new_plan": {"palette": "yellow"},
+ "q3.adj_current": {"palette": "yellow"},
+ "q3.adj_ssp": {"palette": "yellow"},
+ "q3.adj_reserve": {"palette": "yellow"},
+ "q3.adj_comment": {"palette": "yellow"},
+ "q3.corrected_plan": {"palette": "yellow"},
+ "q3.pay_date": {"palette": "blue"},
+ "q3.pay_amount": {"palette": "blue"},
+ "q3.pay_comment": {"palette": "blue"},
+ "q3.pay_act": {"palette": "blue"},
+ "q3.booking": {"palette": "orange"},
+ "q3.actual_m1": {"palette": "green"},
+ "q3.actual_m2": {"palette": "green"},
+ "q3.actual_m3": {"palette": "green"},
+ "q3.actual_quarter": {"palette": "green"},
+ "q3.residual_after_booking": {"palette": "orange"},
+ "q3.residual_after_actual": {"palette": "green"},
+ "q3.transfer_q4": {"palette": "red"},
+ "q3.transfer_q4_delay_acts": {"palette": "red"},
+ "q3.transfer_q4_delay_procurement": {"palette": "red"},
+ "q3.transfer_q4_economy_rf": {"palette": "red"},
+ "q3.transfer_next_comment": {"palette": "red"},
+ "q3.transfer_econ": {"palette": "red"},
+ "q3.total": {"palette": "red"},
+ "q4.target_change": {"palette": "red"},
+ "q4.base_correction": {"palette": "red"},
+ "q4.base_correction_comment": {"palette": "red"},
+ "q4.revision_inc": {"palette": "yellow"},
+ "q4.revision_seq": {"palette": "yellow"},
+ "q4.revision_comment": {"palette": "yellow"},
+ "q4.new_plan": {"palette": "yellow"},
+ "q4.adj_current": {"palette": "yellow"},
+ "q4.adj_ssp": {"palette": "yellow"},
+ "q4.adj_reserve": {"palette": "yellow"},
+ "q4.adj_comment": {"palette": "yellow"},
+ "q4.corrected_plan": {"palette": "yellow"},
+ "q4.pay_date": {"palette": "blue"},
+ "q4.pay_amount": {"palette": "blue"},
+ "q4.pay_comment": {"palette": "blue"},
+ "q4.pay_act": {"palette": "blue"},
+ "q4.booking": {"palette": "orange"},
+ "q4.actual_m1": {"palette": "green"},
+ "q4.actual_m2": {"palette": "green"},
+ "q4.actual_m3": {"palette": "green"},
+ "q4.actual_spod": {"palette": "green"},
+ "q4.actual_quarter": {"palette": "green"},
+ "q4.residual_after_booking": {"palette": "orange"},
+ "q4.residual_after_actual": {"palette": "green"},
+ "q4.transfer_econ": {"palette": "red"},
+ "totals.fact_year": {"palette": "green"},
+ "totals.pay_year": {"palette": "blue"},
+ },
+ "AHR": {
+ "header.section_code": {"palette": "green"},
+ "header.item_id": {"palette": "green"},
+ "header.num_group": {"palette": "green"},
+ "header.name": {"palette": "green"},
+ "header.vsp_id": {"palette": "green"},
+ "plan.q1": {"palette": "green"},
+ "plan.q2": {"palette": "green"},
+ "plan.q3": {"palette": "green"},
+ "plan.q4": {"palette": "green"},
+ "plan.year": {"palette": "green"},
+ "plan.comment": {"palette": "green"},
+ "seq_dfip.q1": {"palette": "green"},
+ "seq_dfip.q2": {"palette": "green"},
+ "seq_dfip.q3": {"palette": "green"},
+ "seq_dfip.q4": {"palette": "green"},
+ "seq_dfip.year": {"palette": "green"},
+ "seq_dfip.justification": {"palette": "green"},
+ "seq_ssp.q1": {"palette": "green"},
+ "seq_ssp.q2": {"palette": "green"},
+ "seq_ssp.q3": {"palette": "green"},
+ "seq_ssp.q4": {"palette": "green"},
+ "seq_ssp.year": {"palette": "green"},
+ "seq_ssp.justification": {"palette": "green"},
+ "approved.q1": {"palette": "green"},
+ "approved.q2": {"palette": "green"},
+ "approved.q3": {"palette": "green"},
+ "approved.q4": {"palette": "green"},
+ "approved.year": {"palette": "green"},
+ "contract.counterparty": {"palette": "orange"},
+ "contract.reference": {"palette": "orange"},
+ "contract.date": {"palette": "orange"},
+ "contract.subject": {"palette": "orange"},
+ "contract.currency": {"palette": "orange"},
+ "contract.ceiling": {"palette": "orange"},
+ "contract.vat_rate": {"palette": "orange"},
+ "contract.deadline": {"palette": "orange"},
+ "contract.scheme": {"palette": "orange"},
+ "contract.act": {"palette": "orange"},
+ "contract.comment": {"palette": "orange"},
+ "booking.y2026.q1": {"palette": "orange"},
+ "booking.y2026.q2": {"palette": "orange"},
+ "booking.y2026.q3": {"palette": "orange"},
+ "booking.y2026.q4": {"palette": "orange"},
+ "booking.y2027.q1": {"palette": "orange"},
+ "booking.y2027.q2": {"palette": "orange"},
+ "booking.y2027.q3": {"palette": "orange"},
+ "booking.y2027.q4": {"palette": "orange"},
+ "q1.adj_current": {"palette": "yellow"},
+ "q1.adj_ssp": {"palette": "yellow"},
+ "q1.adj_reserve": {"palette": "yellow"},
+ "q1.adj_comment": {"palette": "yellow"},
+ "q1.corrected_plan": {"palette": "yellow"},
+ "q1.pay_date": {"palette": "blue"},
+ "q1.pay_amount": {"palette": "blue"},
+ "q1.pay_comment": {"palette": "blue"},
+ "q1.pay_act": {"palette": "blue"},
+ "q1.booking": {"palette": "orange"},
+ "q1.actual_m1": {"palette": "green"},
+ "q1.actual_m2": {"palette": "green"},
+ "q1.actual_m3": {"palette": "green"},
+ "q1.actual_quarter": {"palette": "green"},
+ "q1.residual_after_booking": {"palette": "orange"},
+ "q1.residual_after_actual": {"palette": "green"},
+ "q1.transfer_q2": {"palette": "red"},
+ "q1.transfer_q2_delay_acts": {"palette": "red"},
+ "q1.transfer_q2_delay_procurement": {"palette": "red"},
+ "q1.transfer_q2_economy_rf": {"palette": "red"},
+ "q1.transfer_next_comment": {"palette": "red"},
+ "q1.transfer_q3": {"palette": "red"},
+ "q1.transfer_q4": {"palette": "red"},
+ "q1.transfer_far_comment": {"palette": "red"},
+ "q1.transfer_econ": {"palette": "red"},
+ "q1.total": {"palette": "red"},
+ "q2.target_change": {"palette": "red"},
+ "q2.base_correction": {"palette": "red"},
+ "q2.base_correction_comment": {"palette": "red"},
+ "q2.revision_inc": {"palette": "yellow"},
+ "q2.revision_seq": {"palette": "yellow"},
+ "q2.revision_comment": {"palette": "yellow"},
+ "q2.new_plan": {"palette": "yellow"},
+ "q2.adj_current": {"palette": "yellow"},
+ "q2.adj_ssp": {"palette": "yellow"},
+ "q2.adj_reserve": {"palette": "yellow"},
+ "q2.adj_comment": {"palette": "yellow"},
+ "q2.corrected_plan": {"palette": "yellow"},
+ "q2.pay_date": {"palette": "blue"},
+ "q2.pay_amount": {"palette": "blue"},
+ "q2.pay_comment": {"palette": "blue"},
+ "q2.pay_act": {"palette": "blue"},
+ "q2.booking": {"palette": "orange"},
+ "q2.actual_m1": {"palette": "green"},
+ "q2.actual_m2": {"palette": "green"},
+ "q2.actual_m3": {"palette": "green"},
+ "q2.actual_quarter": {"palette": "green"},
+ "q2.residual_after_booking": {"palette": "orange"},
+ "q2.residual_after_actual": {"palette": "green"},
+ "q2.transfer_q3": {"palette": "red"},
+ "q2.transfer_q3_delay_acts": {"palette": "red"},
+ "q2.transfer_q3_delay_procurement": {"palette": "red"},
+ "q2.transfer_q3_economy_rf": {"palette": "red"},
+ "q2.transfer_next_comment": {"palette": "red"},
+ "q2.transfer_q4": {"palette": "red"},
+ "q2.transfer_far_comment": {"palette": "red"},
+ "q2.transfer_econ": {"palette": "red"},
+ "q2.total": {"palette": "red"},
+ "q3.target_change": {"palette": "red"},
+ "q3.base_correction": {"palette": "red"},
+ "q3.base_correction_comment": {"palette": "red"},
+ "q3.revision_inc": {"palette": "yellow"},
+ "q3.revision_seq": {"palette": "yellow"},
+ "q3.revision_comment": {"palette": "yellow"},
+ "q3.new_plan": {"palette": "yellow"},
+ "q3.adj_current": {"palette": "yellow"},
+ "q3.adj_ssp": {"palette": "yellow"},
+ "q3.adj_reserve": {"palette": "yellow"},
+ "q3.adj_comment": {"palette": "yellow"},
+ "q3.corrected_plan": {"palette": "yellow"},
+ "q3.pay_date": {"palette": "blue"},
+ "q3.pay_amount": {"palette": "blue"},
+ "q3.pay_comment": {"palette": "blue"},
+ "q3.pay_act": {"palette": "blue"},
+ "q3.booking": {"palette": "orange"},
+ "q3.actual_m1": {"palette": "green"},
+ "q3.actual_m2": {"palette": "green"},
+ "q3.actual_m3": {"palette": "green"},
+ "q3.actual_quarter": {"palette": "green"},
+ "q3.residual_after_booking": {"palette": "orange"},
+ "q3.residual_after_actual": {"palette": "green"},
+ "q3.transfer_q4": {"palette": "red"},
+ "q3.transfer_q4_delay_acts": {"palette": "red"},
+ "q3.transfer_q4_delay_procurement": {"palette": "red"},
+ "q3.transfer_q4_economy_rf": {"palette": "red"},
+ "q3.transfer_next_comment": {"palette": "red"},
+ "q3.transfer_econ": {"palette": "red"},
+ "q3.total": {"palette": "red"},
+ "q4.target_change": {"palette": "red"},
+ "q4.base_correction": {"palette": "red"},
+ "q4.base_correction_comment": {"palette": "red"},
+ "q4.revision_inc": {"palette": "yellow"},
+ "q4.revision_seq": {"palette": "yellow"},
+ "q4.revision_comment": {"palette": "yellow"},
+ "q4.new_plan": {"palette": "yellow"},
+ "q4.adj_current": {"palette": "yellow"},
+ "q4.adj_ssp": {"palette": "yellow"},
+ "q4.adj_reserve": {"palette": "yellow"},
+ "q4.adj_comment": {"palette": "yellow"},
+ "q4.corrected_plan": {"palette": "yellow"},
+ "q4.pay_date": {"palette": "blue"},
+ "q4.pay_amount": {"palette": "blue"},
+ "q4.pay_comment": {"palette": "blue"},
+ "q4.pay_act": {"palette": "blue"},
+ "q4.booking": {"palette": "orange"},
+ "q4.actual_m1": {"palette": "green"},
+ "q4.actual_m2": {"palette": "green"},
+ "q4.actual_m3": {"palette": "green"},
+ "q4.actual_spod": {"palette": "green"},
+ "q4.actual_quarter": {"palette": "green"},
+ "q4.residual_after_booking": {"palette": "orange"},
+ "q4.residual_after_actual": {"palette": "green"},
+ "q4.transfer_econ": {"palette": "red"},
+ "totals.fact_year": {"palette": "green"},
+ "totals.pay_year": {"palette": "blue"},
+ },
+ "CAP": {
+ "header.section_code": {"palette": "orange"},
+ "header.item_id": {"palette": "orange"},
+ "header.num_group": {"palette": "orange"},
+ "header.name": {"palette": "orange"},
+ "header.vsp_id": {"palette": "orange"},
+ "plan.q1": {"palette": "orange"},
+ "plan.q2": {"palette": "orange"},
+ "plan.q3": {"palette": "orange"},
+ "plan.q4": {"palette": "orange"},
+ "plan.year": {"palette": "orange"},
+ "plan.comment": {"palette": "orange"},
+ "seq_dfip.q1": {"palette": "orange"},
+ "seq_dfip.q2": {"palette": "orange"},
+ "seq_dfip.q3": {"palette": "orange"},
+ "seq_dfip.q4": {"palette": "orange"},
+ "seq_dfip.year": {"palette": "orange"},
+ "seq_dfip.justification": {"palette": "orange"},
+ "seq_ssp.q1": {"palette": "orange"},
+ "seq_ssp.q2": {"palette": "orange"},
+ "seq_ssp.q3": {"palette": "orange"},
+ "seq_ssp.q4": {"palette": "orange"},
+ "seq_ssp.year": {"palette": "orange"},
+ "seq_ssp.justification": {"palette": "orange"},
+ "approved.q1": {"palette": "orange"},
+ "approved.q2": {"palette": "orange"},
+ "approved.q3": {"palette": "orange"},
+ "approved.q4": {"palette": "orange"},
+ "approved.year": {"palette": "orange"},
+ "contract.counterparty": {"palette": "orange"},
+ "contract.reference": {"palette": "orange"},
+ "contract.date": {"palette": "orange"},
+ "contract.subject": {"palette": "orange"},
+ "contract.currency": {"palette": "orange"},
+ "contract.ceiling": {"palette": "orange"},
+ "contract.vat_rate": {"palette": "orange"},
+ "contract.deadline": {"palette": "orange"},
+ "contract.scheme": {"palette": "orange"},
+ "contract.act": {"palette": "orange"},
+ "contract.comment": {"palette": "orange"},
+ "booking.y2026.q1": {"palette": "orange"},
+ "booking.y2026.q2": {"palette": "orange"},
+ "booking.y2026.q3": {"palette": "orange"},
+ "booking.y2026.q4": {"palette": "orange"},
+ "booking.y2027.q1": {"palette": "orange"},
+ "booking.y2027.q2": {"palette": "orange"},
+ "booking.y2027.q3": {"palette": "orange"},
+ "booking.y2027.q4": {"palette": "orange"},
+ "q1.adj_current": {"palette": "yellow"},
+ "q1.adj_ssp": {"palette": "yellow"},
+ "q1.adj_reserve": {"palette": "yellow"},
+ "q1.adj_comment": {"palette": "yellow"},
+ "q1.corrected_plan": {"palette": "yellow"},
+ "q1.pay_date": {"palette": "blue"},
+ "q1.pay_amount": {"palette": "blue"},
+ "q1.pay_comment": {"palette": "blue"},
+ "q1.pay_act": {"palette": "blue"},
+ "q1.booking": {"palette": "orange"},
+ "q1.actual_m1": {"palette": "green"},
+ "q1.actual_m2": {"palette": "green"},
+ "q1.actual_m3": {"palette": "green"},
+ "q1.actual_quarter": {"palette": "green"},
+ "q1.residual_after_booking": {"palette": "orange"},
+ "q1.residual_after_actual": {"palette": "green"},
+ "q1.transfer_q2": {"palette": "red"},
+ "q1.transfer_q2_delay_acts": {"palette": "red"},
+ "q1.transfer_q2_delay_procurement": {"palette": "red"},
+ "q1.transfer_q2_economy_rf": {"palette": "red"},
+ "q1.transfer_next_comment": {"palette": "red"},
+ "q1.transfer_q3": {"palette": "red"},
+ "q1.transfer_q4": {"palette": "red"},
+ "q1.transfer_far_comment": {"palette": "red"},
+ "q1.transfer_econ": {"palette": "red"},
+ "q1.total": {"palette": "red"},
+ "q2.target_change": {"palette": "red"},
+ "q2.base_correction": {"palette": "red"},
+ "q2.base_correction_comment": {"palette": "red"},
+ "q2.revision_inc": {"palette": "yellow"},
+ "q2.revision_seq": {"palette": "yellow"},
+ "q2.revision_comment": {"palette": "yellow"},
+ "q2.new_plan": {"palette": "yellow"},
+ "q2.adj_current": {"palette": "yellow"},
+ "q2.adj_ssp": {"palette": "yellow"},
+ "q2.adj_reserve": {"palette": "yellow"},
+ "q2.adj_comment": {"palette": "yellow"},
+ "q2.corrected_plan": {"palette": "yellow"},
+ "q2.pay_date": {"palette": "blue"},
+ "q2.pay_amount": {"palette": "blue"},
+ "q2.pay_comment": {"palette": "blue"},
+ "q2.pay_act": {"palette": "blue"},
+ "q2.booking": {"palette": "orange"},
+ "q2.actual_m1": {"palette": "green"},
+ "q2.actual_m2": {"palette": "green"},
+ "q2.actual_m3": {"palette": "green"},
+ "q2.actual_quarter": {"palette": "green"},
+ "q2.residual_after_booking": {"palette": "orange"},
+ "q2.residual_after_actual": {"palette": "green"},
+ "q2.transfer_q3": {"palette": "red"},
+ "q2.transfer_q3_delay_acts": {"palette": "red"},
+ "q2.transfer_q3_delay_procurement": {"palette": "red"},
+ "q2.transfer_q3_economy_rf": {"palette": "red"},
+ "q2.transfer_next_comment": {"palette": "red"},
+ "q2.transfer_q4": {"palette": "red"},
+ "q2.transfer_far_comment": {"palette": "red"},
+ "q2.transfer_econ": {"palette": "red"},
+ "q2.total": {"palette": "red"},
+ "q3.target_change": {"palette": "red"},
+ "q3.base_correction": {"palette": "red"},
+ "q3.base_correction_comment": {"palette": "red"},
+ "q3.revision_inc": {"palette": "yellow"},
+ "q3.revision_seq": {"palette": "yellow"},
+ "q3.revision_comment": {"palette": "yellow"},
+ "q3.new_plan": {"palette": "yellow"},
+ "q3.adj_current": {"palette": "yellow"},
+ "q3.adj_ssp": {"palette": "yellow"},
+ "q3.adj_reserve": {"palette": "yellow"},
+ "q3.adj_comment": {"palette": "yellow"},
+ "q3.corrected_plan": {"palette": "yellow"},
+ "q3.pay_date": {"palette": "blue"},
+ "q3.pay_amount": {"palette": "blue"},
+ "q3.pay_comment": {"palette": "blue"},
+ "q3.pay_act": {"palette": "blue"},
+ "q3.booking": {"palette": "orange"},
+ "q3.actual_m1": {"palette": "green"},
+ "q3.actual_m2": {"palette": "green"},
+ "q3.actual_m3": {"palette": "green"},
+ "q3.actual_quarter": {"palette": "green"},
+ "q3.residual_after_booking": {"palette": "orange"},
+ "q3.residual_after_actual": {"palette": "green"},
+ "q3.transfer_q4": {"palette": "red"},
+ "q3.transfer_q4_delay_acts": {"palette": "red"},
+ "q3.transfer_q4_delay_procurement": {"palette": "red"},
+ "q3.transfer_q4_economy_rf": {"palette": "red"},
+ "q3.transfer_next_comment": {"palette": "red"},
+ "q3.transfer_econ": {"palette": "red"},
+ "q3.total": {"palette": "red"},
+ "q4.target_change": {"palette": "red"},
+ "q4.base_correction": {"palette": "red"},
+ "q4.base_correction_comment": {"palette": "red"},
+ "q4.revision_inc": {"palette": "yellow"},
+ "q4.revision_seq": {"palette": "yellow"},
+ "q4.revision_comment": {"palette": "yellow"},
+ "q4.new_plan": {"palette": "yellow"},
+ "q4.adj_current": {"palette": "yellow"},
+ "q4.adj_ssp": {"palette": "yellow"},
+ "q4.adj_reserve": {"palette": "yellow"},
+ "q4.adj_comment": {"palette": "yellow"},
+ "q4.corrected_plan": {"palette": "yellow"},
+ "q4.pay_date": {"palette": "blue"},
+ "q4.pay_amount": {"palette": "blue"},
+ "q4.pay_comment": {"palette": "blue"},
+ "q4.pay_act": {"palette": "blue"},
+ "q4.booking": {"palette": "orange"},
+ "q4.actual_m1": {"palette": "green"},
+ "q4.actual_m2": {"palette": "green"},
+ "q4.actual_m3": {"palette": "green"},
+ "q4.actual_spod": {"palette": "green"},
+ "q4.actual_quarter": {"palette": "green"},
+ "q4.residual_after_booking": {"palette": "orange"},
+ "q4.residual_after_actual": {"palette": "green"},
+ "q4.transfer_econ": {"palette": "red"},
+ "totals.fact_year": {"palette": "green"},
+ "totals.pay_year": {"palette": "blue"},
+ },
+}
+
+
+# AURORA-1907 (порт в раскладку FORM_2, AURORA-1902): на экране листа АХР формы «Смета РФ»
+# (web/.../FORM_2/AHR.js) после "Год" идут вычисляемые колонки «В пределах лимита, год» и
+# «Сверх лимита, год»; у OPER/CAP их нет. Поэтому для АХР — отдельная копия дерева.
+def _form2_ahr_depth_columns() -> dict:
+ columns = copy.deepcopy(FORM2_DEPTH_COLUMNS)
+ plan_children = columns["god"]["children"]["planirovanie_godovykh_pokazateley"]["children"]
+ rebuilt: dict = {}
+ for key, value in plan_children.items():
+ if key == "comment":
+ rebuilt["limit_year"] = {"name": "В пределах лимита, год", "key": "plan.limit_year"}
+ rebuilt["over_limit_year"] = {"name": "Сверх лимита, год", "key": "plan.over_limit_year"}
+ rebuilt[key] = value
+ plan_children.clear()
+ plan_children.update(rebuilt)
+ return columns
+
+
+FORM2_AHR_DEPTH_COLUMNS: dict = _form2_ahr_depth_columns()
+
+
+def _form2_ahr_colors() -> dict:
+ colors: dict = {}
+ for key, value in FORM2_COLORS["AHR"].items():
+ if key == "plan.comment":
+ colors["plan.limit_year"] = dict(FORM2_COLORS["AHR"]["plan.year"])
+ colors["plan.over_limit_year"] = dict(FORM2_COLORS["AHR"]["plan.year"])
+ colors[key] = value
+ return colors
+
+
+FORM2_COLORS["AHR"] = _form2_ahr_colors()
diff --git a/api/src/services/export_service/export_normalizers.py b/api/src/services/export_service/export_normalizers.py
index dea1bbd..1f67d87 100644
--- a/api/src/services/export_service/export_normalizers.py
+++ b/api/src/services/export_service/export_normalizers.py
@@ -249,6 +249,34 @@ class SmetaRowNormalizer:
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:
def __init__(self, serializer: ExcelValueSerializer):
self.serializer = serializer
diff --git a/api/src/services/export_service/export_service.py b/api/src/services/export_service/export_service.py
index cb98a3e..8fcadd7 100644
--- a/api/src/services/export_service/export_service.py
+++ b/api/src/services/export_service/export_service.py
@@ -27,13 +27,18 @@ from src.services.budget_form_service import BudgetFormService
from src.services.export_service.export_normalizers import (
ExcelValueSerializer,
Form1DepthRowNormalizer,
+ Form2DepthRowNormalizer,
Form3DepthRowNormalizer,
SmetaRowNormalizer,
)
from src.services.export_service.export_mappings import (
FORM1_COLORS,
FORM1_DEPTH_COLUMNS,
+ FORM1_OPER_DEPTH_COLUMNS,
+ FORM2_COLORS,
FORM2_DEPTH_AHR_SUB_COLUMNS,
+ FORM2_AHR_DEPTH_COLUMNS,
+ FORM2_DEPTH_COLUMNS,
FORM3_COLORS,
FORM4_COLORS,
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.sheet_service import SheetService
from src.services.summary_service import SummaryService
+from src.repository.vsp_repository import VSPRepository
XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
ZIP_MEDIA_TYPE = "application/zip"
@@ -114,8 +120,10 @@ class ExportService:
self.sheet_service = SheetService(db)
self.project_service = ProjectService(db)
self.summary_service = SummaryService(db)
+ self.vsp_repo = VSPRepository(db)
self.value_serializer = ExcelValueSerializer()
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.smeta_row_normalizer = SmetaRowNormalizer()
self.sheet_writers: dict[str, TabularSheetWriter] = {
@@ -455,6 +463,8 @@ class ExportService:
direction=plan.effective_direction,
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()
# Этап 4: рендер листа уводим с event loop, чтобы экспорт не блокировал API.
@@ -911,6 +921,28 @@ class ExportService:
"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(
self,
workbook: XlsxWorkbook,
@@ -936,34 +968,86 @@ class ExportService:
}
if form_type_code == FormTypeEnum.FORM_4.value:
writer_configs = {
- "OPER": (FORM4_OPER_DEPTH_COLUMNS, FORM4_OPER_COLORS, _vat_placeholders(FORM4_SHEET_VAT_WORDING)),
- "AHR": (FORM4_DEPTH_COLUMNS, FORM4_COLORS["AHR"], _vat_placeholders(FORM4_SHEET_VAT_WORDING)),
- "CAP": (FORM4_DEPTH_COLUMNS, FORM4_COLORS["CAP"], _vat_placeholders(FORM4_SHEET_VAT_WORDING)),
+ "OPER": (
+ FORM4_OPER_DEPTH_COLUMNS,
+ 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": (
FORM4_STRUCTURE_DEPTH_COLUMNS,
FORM4_STRUCTURE_COLORS,
_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:
writer_configs = {
- "OPER": (FORM1_DEPTH_COLUMNS, FORM1_COLORS["OPER"], _vat_placeholders(FORM1_SHEET_VAT_WORDING["OPER"])),
- "AHR": (FORM1_DEPTH_COLUMNS, FORM1_COLORS["AHR"], _vat_placeholders(FORM1_SHEET_VAT_WORDING["AHR"])),
- "CAP": (FORM1_DEPTH_COLUMNS, FORM1_COLORS["CAP"], _vat_placeholders(FORM1_SHEET_VAT_WORDING["CAP"])),
- "AHR_RENT": (FORM2_DEPTH_AHR_SUB_COLUMNS, None, None),
- "AHR_UTILITY": (FORM2_DEPTH_AHR_SUB_COLUMNS, None, None),
- "AHR_SECURITY": (FORM2_DEPTH_AHR_SUB_COLUMNS, None, None),
+ # AURORA-1905: на экране «Операц» формы 1 нет «Платежи в {year+2} году».
+ "OPER": (
+ FORM1_OPER_DEPTH_COLUMNS,
+ FORM1_COLORS["OPER"],
+ _vat_placeholders(FORM1_SHEET_VAT_WORDING["OPER"]),
+ 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": (
FORM4_STRUCTURE_DEPTH_COLUMNS,
FORM4_STRUCTURE_COLORS,
_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(
workbook=workbook,
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,
colors_config=colors,
)
diff --git a/api/src/services/form3_phase_service.py b/api/src/services/form3_phase_service.py
index caca682..fb20d48 100644
--- a/api/src/services/form3_phase_service.py
+++ b/api/src/services/form3_phase_service.py
@@ -8,6 +8,7 @@ from src.core.errors import AccessDeniedException, ValidationsError
from src.db.models.role import UserRoleEnum
from src.db.models.app_user import AppUser
from src.domain.schemas import Form3PhaseCreate, Form3PhaseUpdate
+from src.services.form_phase_service import role_code_for_user
class Form3PhaseService:
@@ -18,11 +19,13 @@ class Form3PhaseService:
async def get_list(
self,
report_id: int | list[int],
+ user: AppUser,
phase_code: str | None = None,
) -> list[Form3Phase]:
return await self.f3p_repo.get_list(
report_id=report_id,
phase_code=phase_code,
+ role=role_code_for_user(user),
)
async def get(
diff --git a/api/src/services/form_phase_service.py b/api/src/services/form_phase_service.py
index abc613b..e76bfd1 100644
--- a/api/src/services/form_phase_service.py
+++ b/api/src/services/form_phase_service.py
@@ -11,6 +11,21 @@ from src.domain.schemas import FormPhaseCreate, FormPhaseUpdate
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:
def __init__(self, db: AsyncSession):
self.db = db
@@ -30,6 +45,7 @@ class FormPhaseService:
sheet=sheet,
direction=direction,
phase_code=phase_code,
+ role=role_code_for_user(user),
)
async def get(
diff --git a/api/tests/integration/test_form_phases_api_smoke.py b/api/tests/integration/test_form_phases_api_smoke.py
index adffca8..bd3901a 100644
--- a/api/tests/integration/test_form_phases_api_smoke.py
+++ b/api/tests/integration/test_form_phases_api_smoke.py
@@ -327,6 +327,58 @@ def test_form3_phases_delete(client, admin_tokens):
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):
from datetime import datetime
diff --git a/api/tests/unit/test_export_form1_form4_mapping.py b/api/tests/unit/test_export_form1_form4_mapping.py
new file mode 100644
index 0000000..ad707ac
--- /dev/null
+++ b/api/tests/unit/test_export_form1_form4_mapping.py
@@ -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)
diff --git a/api/tests/unit/test_export_form2_mapping.py b/api/tests/unit/test_export_form2_mapping.py
new file mode 100644
index 0000000..b7a9d7b
--- /dev/null
+++ b/api/tests/unit/test_export_form2_mapping.py
@@ -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()
diff --git a/api/tests/unit/test_form_phase_service.py b/api/tests/unit/test_form_phase_service.py
new file mode 100644
index 0000000..a89db12
--- /dev/null
+++ b/api/tests/unit/test_form_phase_service.py
@@ -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,
+ )
diff --git a/web/src/components/RealtimeTable/constants/FORM_1/AHR.js b/web/src/components/RealtimeTable/constants/FORM_1/AHR.js
index e3db978..a5756ba 100644
--- a/web/src/components/RealtimeTable/constants/FORM_1/AHR.js
+++ b/web/src/components/RealtimeTable/constants/FORM_1/AHR.js
@@ -3568,14 +3568,14 @@ export const config = {
},
},
{
- header: 'Скорректированный план 4 кв. {year:short}',
- accessorKey: '4_kvartal_2026_skorr_plan_4_kv_26',
+ header: 'Комментарий',
+ accessorKey: '4_kvartal_2024_kommentariy',
columns: [
{
- header: 'Сумма, {unit} (без НДС)',
- accessorKey: 'data.q4.corrected_plan',
- columnLetter: 'GH',
- size: 250,
+ header: 'Сумма, {unit} (с учётом НДС)',
+ accessorKey: 'data.q4.adj_comment',
+ columnLetter: 'GG',
+ size: 300,
filterFn: 'contains',
},
],
@@ -3587,14 +3587,14 @@ export const config = {
},
},
{
- header: 'Комментарий',
- accessorKey: '4_kvartal_2024_kommentariy',
+ header: 'Скорректированный план 4 кв. {year:short}',
+ accessorKey: '4_kvartal_2026_skorr_plan_4_kv_26',
columns: [
{
- header: 'Сумма, {unit} (с учётом НДС)',
- accessorKey: 'data.q4.adj_comment',
- columnLetter: 'GG',
- size: 300,
+ header: 'Сумма, {unit} (без НДС)',
+ accessorKey: 'data.q4.corrected_plan',
+ columnLetter: 'GH',
+ size: 250,
filterFn: 'contains',
},
],
diff --git a/web/src/components/RealtimeTable/constants/FORM_1/CAP.js b/web/src/components/RealtimeTable/constants/FORM_1/CAP.js
index f20f23a..1670988 100644
--- a/web/src/components/RealtimeTable/constants/FORM_1/CAP.js
+++ b/web/src/components/RealtimeTable/constants/FORM_1/CAP.js
@@ -3582,13 +3582,13 @@ export const config = {
},
},
{
- header: 'Скорректированный план 4 кв. {year:short}',
- accessorKey: '4_kvartal_2024_skorr_plan_4_kv_24',
+ header: 'Комментарий',
+ accessorKey: '4_kvartal_2024_kommentariy',
columns: [
{
header: 'Сумма, {unit} (с учётом НДС)',
- accessorKey: 'data.q4.corrected_plan',
- columnLetter: 'GH',
+ accessorKey: 'data.q4.adj_comment',
+ columnLetter: 'GG',
size: 300,
filterFn: 'contains',
},
@@ -3601,13 +3601,13 @@ export const config = {
},
},
{
- header: 'Комментарий',
- accessorKey: '4_kvartal_2024_kommentariy',
+ header: 'Скорректированный план 4 кв. {year:short}',
+ accessorKey: '4_kvartal_2024_skorr_plan_4_kv_24',
columns: [
{
header: 'Сумма, {unit} (с учётом НДС)',
- accessorKey: 'data.q4.adj_comment',
- columnLetter: 'GG',
+ accessorKey: 'data.q4.corrected_plan',
+ columnLetter: 'GH',
size: 300,
filterFn: 'contains',
},
diff --git a/web/src/components/RealtimeTable/constants/FORM_1/OPER.js b/web/src/components/RealtimeTable/constants/FORM_1/OPER.js
index 08b864c..eb743cc 100644
--- a/web/src/components/RealtimeTable/constants/FORM_1/OPER.js
+++ b/web/src/components/RealtimeTable/constants/FORM_1/OPER.js
@@ -3566,13 +3566,13 @@ export const config = {
},
},
{
- header: 'Скорректированный план 4 кв. {year:short}',
- accessorKey: '4_kvartal_2024_skorr_plan_4_kv_24',
+ header: 'Комментарий',
+ accessorKey: '4_kvartal_2024_kommentariy',
columns: [
{
header: 'Сумма, {unit} (с учётом НДС)',
- accessorKey: 'data.q4.corrected_plan',
- columnLetter: 'GH',
+ accessorKey: 'data.q4.adj_comment',
+ columnLetter: 'GG',
size: 300,
filterFn: 'contains',
},
@@ -3585,13 +3585,13 @@ export const config = {
},
},
{
- header: 'Комментарий',
- accessorKey: '4_kvartal_2024_kommentariy',
+ header: 'Скорректированный план 4 кв. {year:short}',
+ accessorKey: '4_kvartal_2024_skorr_plan_4_kv_24',
columns: [
{
header: 'Сумма, {unit} (с учётом НДС)',
- accessorKey: 'data.q4.adj_comment',
- columnLetter: 'GG',
+ accessorKey: 'data.q4.corrected_plan',
+ columnLetter: 'GH',
size: 300,
filterFn: 'contains',
},
diff --git a/web/src/components/RealtimeTable/constants/FORM_2/AHR.js b/web/src/components/RealtimeTable/constants/FORM_2/AHR.js
index a54571f..7ae9ea1 100644
--- a/web/src/components/RealtimeTable/constants/FORM_2/AHR.js
+++ b/web/src/components/RealtimeTable/constants/FORM_2/AHR.js
@@ -689,10 +689,6 @@ export const config = {
color_type: blueColumn,
accessorKey: "data.totals.pay_year",
},
- "data.header.vsp_address": {
- color_type: greenColumn,
- accessorKey: "data.header.vsp_address",
- },
},
columns: [
{
@@ -739,14 +735,6 @@ export const config = {
filterFn: "contains",
editType: "vsp_dropdown",
},
- {
- header: "Адрес ВСП",
- accessorKey: "data.header.vsp_address",
- columnLetter: "E1",
- size: 220,
- filterFn: "contains",
- editType: "vsp_dropdown",
- },
],
muiTableHeadCellProps: {
sx: {
diff --git a/web/src/components/RealtimeTable/constants/FORM_2/CAP.js b/web/src/components/RealtimeTable/constants/FORM_2/CAP.js
index a01a9ef..b26f20e 100644
--- a/web/src/components/RealtimeTable/constants/FORM_2/CAP.js
+++ b/web/src/components/RealtimeTable/constants/FORM_2/CAP.js
@@ -681,10 +681,6 @@ export const config = {
color_type: blueColumn,
accessorKey: "data.totals.pay_year",
},
- "data.header.vsp_address": {
- color_type: orangeColumn,
- accessorKey: "data.header.vsp_address",
- },
},
columns: [
{
@@ -731,14 +727,6 @@ export const config = {
filterFn: "contains",
editType: "vsp_dropdown",
},
- {
- header: "Адрес ВСП",
- accessorKey: "data.header.vsp_address",
- columnLetter: "E1",
- size: 220,
- filterFn: "contains",
- editType: "vsp_dropdown",
- },
],
muiTableHeadCellProps: {
sx: {
diff --git a/web/src/components/RealtimeTable/constants/FORM_2/OPER.js b/web/src/components/RealtimeTable/constants/FORM_2/OPER.js
index d1e593c..3d21fb1 100644
--- a/web/src/components/RealtimeTable/constants/FORM_2/OPER.js
+++ b/web/src/components/RealtimeTable/constants/FORM_2/OPER.js
@@ -681,10 +681,6 @@ export const config = {
color_type: blueColumn,
accessorKey: "data.totals.pay_year",
},
- "data.header.vsp_address": {
- color_type: blueColumn,
- accessorKey: "data.header.vsp_address",
- },
},
columns: [
{
@@ -731,14 +727,6 @@ export const config = {
filterFn: "contains",
editType: "vsp_dropdown",
},
- {
- header: "Адрес ВСП",
- accessorKey: "data.header.vsp_address",
- columnLetter: "E1",
- size: 220,
- filterFn: "contains",
- editType: "vsp_dropdown",
- },
],
muiTableHeadCellProps: {
sx: {
diff --git a/web/src/components/RealtimeTable/constants/FORM_2/SMETA.js b/web/src/components/RealtimeTable/constants/FORM_2/SMETA.js
index 423b4ea..356ccc3 100644
--- a/web/src/components/RealtimeTable/constants/FORM_2/SMETA.js
+++ b/web/src/components/RealtimeTable/constants/FORM_2/SMETA.js
@@ -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: {
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: {
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: {
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 квартал",
accessorKey:
- "v_tys_rubley_skorrektirovannaya_smeta_raskhodov_na_2026_god_iv_kvartal",
+ "field_skorrektirovannaya_smeta_raskhodov_na_2026_god_iv_kvartal",
columns: [
{
header: "",
@@ -502,6 +458,12 @@ export const config = {
},
},
],
+ muiTableHeadCellProps: {
+ sx: {
+ backgroundColor: "#FFFFFF",
+ color: "#000000",
+ },
+ },
},
],
muiTableHeadCellProps: {
diff --git a/web/src/components/RealtimeTable/utils/form1Q4CommentOrder.test.js b/web/src/components/RealtimeTable/utils/form1Q4CommentOrder.test.js
new file mode 100644
index 0000000..26fb8e2
--- /dev/null
+++ b/web/src/components/RealtimeTable/utils/form1Q4CommentOrder.test.js
@@ -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');
+ });
+}
diff --git a/web/src/components/RealtimeTable/utils/form2SmetaConfig.test.js b/web/src/components/RealtimeTable/utils/form2SmetaConfig.test.js
new file mode 100644
index 0000000..d0639be
--- /dev/null
+++ b/web/src/components/RealtimeTable/utils/form2SmetaConfig.test.js
@@ -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('В тыс. рублей'), 'заголовок "В тыс. рублей" не должен встречаться в конфиге');
+});
diff --git a/web/src/components/Stages/StagesTable/StagesTable.jsx b/web/src/components/Stages/StagesTable/StagesTable.jsx
index d42c6ac..bac0e40 100644
--- a/web/src/components/Stages/StagesTable/StagesTable.jsx
+++ b/web/src/components/Stages/StagesTable/StagesTable.jsx
@@ -70,9 +70,11 @@ export const StagesTable = ({ stages, onEdit, onDelete }) => {
{SHEET_NAME[stage.sheet]} - {stage.phase_code}
-
- {STAGE_ROLE_RUSSIAN_NAME[stage.role] ?? stage.role}
-
+ {isAdmin && (
+
+ {STAGE_ROLE_RUSSIAN_NAME[stage.role] ?? stage.role}
+
+ )}
{formatDate(stage.opens_at)}
diff --git a/web/src/components/Stages/constants.js b/web/src/components/Stages/constants.js
index 1979f6b..c118adf 100644
--- a/web/src/components/Stages/constants.js
+++ b/web/src/components/Stages/constants.js
@@ -8,7 +8,6 @@ export const stageColumnsHiddenFromPicker = [
// 'data.header.name',
'data.header.internal_order',
// 'data.header.vsp_id',
- 'data.header.vsp_address',
'data.header.section_code',
// 'data.header.justification',
];