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', ];