fix(export): выгрузка форм 1 и 4 — порядок столбцов и заголовки как на экране

Выгрузка Excel листов АХР/КВ/Операц/Структура форм 1 и 4 расходилась с экраном
по нескольким независимым причинам:

- FORM_4: _patch_form4_regular_columns() вызывался дважды — второй вызов
  стирал только что добавленные actual_rsbu_spod_q1..q3, поэтому в выгрузке
  были только «Факт 4 кв + СПОД», без «Факт 1/2/3 кв». Убрал второй вызов
  и больше не удаляю эти ключи в самом патче.
- _patch_form4_actual_rsbu_spod_columns: SQL отдаёт actual_rsbu_spod только
  для 4 кв.; для 1-3 кв. ключ теперь q{n}.actual_quarter (как на экране),
  не q{n}.actual_rsbu_spod (всегда пустой).
- Базовая раскладка FORM1_DEPTH_COLUMNS (отражается и в FORM_4, т.к. она из неё
  строится): убрал collegial.empty и группы "shift" (ключ sum, SQL не отдаёт);
  в q2/q3 добавил comment_correct (adj_comment) сразу после reserve_correct —
  раньше его не было вовсе; в q4 то же поле вынес из rev_correct в отдельный
  comment_correct на то же место (было в начале квартала вместе с комментарием
  корректировки плана). group "approved.plan" переименована из скопированного
  "Резерв Финансового Комитета" в "План {year}"; q4 "quarter_correct" — из
  ошибочного "Факт РСБУ 3 кв" в "Факт РСБУ 4 кв {year_short}".
- render_column_years: добавлен плейсхолдер year_plus_2 (year+2); contract_summary
  получил future_y2 "Платежи в {year_plus_2} году" (раньше его не было, и FORM_4
  patch всё равно дублировал его с неверным {next_year}). На экране «Операц»
  формы 1 такой колонки нет — для неё новая FORM1_OPER_DEPTH_COLUMNS без future_y2.
- FORM4_OPER: proc_plan/proc_method после переименования уезжали в конец блока
  ckk (pop+переприсвоение добавляет ключ в конец словаря) — вернул порядок
  экрана через _reorder_children.
- web/.../FORM_1/{AHR,CAP,OPER}.js: в q4 узел "Комментарий" (adj_comment, GG)
  шёл после "Скорректированный план" (corrected_plan, GH) — переставил перед,
  как в 1-3 кв.

Issue: #253 (AURORA-1905, эпик AURORA-1876, Высокий приоритет)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-30 22:04:02 +04:00
parent 2a287480b6
commit b04c87f47c
7 changed files with 214 additions and 76 deletions

View File

@ -90,6 +90,9 @@ FORM1_DEPTH_COLUMNS: dict = {
"future_y1": {
"name": "Платежи в {next_year} году",
},
"future_y2": {
"name": "Платежи в {year_plus_2} году",
},
"other_ssp": {
"name": "Сумма в других ССП",
},
@ -170,7 +173,7 @@ FORM1_DEPTH_COLUMNS: dict = {
"name": "Итоговая смета расходов",
"children": {
"plan": {
"name": "Резерв Финансового Комитета",
"name": "План {year}",
"key": None,
"children": {
"q1": {
@ -205,9 +208,6 @@ FORM1_DEPTH_COLUMNS: dict = {
"protocol": {
"name": "Реквизиты протокола (дата, номер)",
},
"empty": {
"name": "",
},
"note": {
"name": "Примечание",
},
@ -352,15 +352,6 @@ FORM1_DEPTH_COLUMNS: dict = {
}
}
},
"shift": {
"name": "Перенос во 2 кв {year_short}",
"key": None,
"children": {
"sum": {
"name": "Сумма, тыс. руб. (с учетом НДC)",
}
}
},
"comment_correct": {
"name": "Комментарий",
"key": None,
@ -595,11 +586,11 @@ FORM1_DEPTH_COLUMNS: dict = {
}
}
},
"shift": {
"name": "Перенос в 3 кв {year_short}",
"comment_correct": {
"name": "Комментарий",
"key": None,
"children": {
"sum": {
"adj_comment": {
"name": "Сумма, тыс. руб. (с учетом НДC)",
}
}
@ -822,11 +813,11 @@ FORM1_DEPTH_COLUMNS: dict = {
}
}
},
"shift": {
"name": "Перенос в 4 кв {year_short}",
"comment_correct": {
"name": "Комментарий",
"key": None,
"children": {
"sum": {
"adj_comment": {
"name": "Сумма, тыс. руб. (с учетом НДC)",
}
}
@ -994,9 +985,6 @@ FORM1_DEPTH_COLUMNS: dict = {
"rev_comment": {
"name": "Сумма, тыс. руб. (с учетом НДC)",
},
"adj_comment": {
"name": "Сумма, тыс. руб. (с учетом НДC)",
},
}
},
"new_plan_correct": {
@ -1049,6 +1037,15 @@ FORM1_DEPTH_COLUMNS: dict = {
}
}
},
"comment_correct": {
"name": "Комментарий",
"key": None,
"children": {
"adj_comment": {
"name": "Сумма, тыс. руб. (с учетом НДC)",
}
}
},
"plan_correct": {
"name": "Скорр. план 4 кв. {year_short}",
"key": None,
@ -1122,7 +1119,7 @@ FORM1_DEPTH_COLUMNS: dict = {
}
},
"quarter_correct": {
"name": "Факт РСБУ 3 кв {year_short}",
"name": "Факт РСБУ 4 кв {year_short}",
"key": None,
"children": {
"actual_quarter": {
@ -3243,6 +3240,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,
@ -3283,26 +3286,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)
@ -3326,7 +3311,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,
@ -3338,7 +3323,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,
(
@ -3349,14 +3334,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": "",
},
},
@ -3368,7 +3356,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"] = (
@ -3377,6 +3364,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"],
{
@ -4004,6 +3997,7 @@ def render_column_years(columns: dict, year: int) -> dict:
"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),
}
_replace_column_year_placeholders(rendered, values)

View File

@ -33,6 +33,7 @@ from src.services.export_service.export_normalizers import (
from src.services.export_service.export_mappings import (
FORM1_COLORS,
FORM1_DEPTH_COLUMNS,
FORM1_OPER_DEPTH_COLUMNS,
FORM2_DEPTH_AHR_SUB_COLUMNS,
FORM3_COLORS,
FORM4_COLORS,
@ -927,7 +928,8 @@ class ExportService:
}
else:
writer_configs = {
"OPER": (FORM1_DEPTH_COLUMNS, FORM1_COLORS["OPER"]),
# AURORA-1905: на экране «Операц» формы 1 нет «Платежи в {year+2} году».
"OPER": (FORM1_OPER_DEPTH_COLUMNS, FORM1_COLORS["OPER"]),
"AHR": (FORM1_DEPTH_COLUMNS, FORM1_COLORS["AHR"]),
"CAP": (FORM1_DEPTH_COLUMNS, FORM1_COLORS["CAP"]),
"AHR_RENT": (FORM2_DEPTH_AHR_SUB_COLUMNS, None),

View File

@ -0,0 +1,107 @@
import importlib
from xlsxwriter import Workbook as XlsxWorkbook
from src.services.export_service import export_mappings
from src.services.export_service.export_mappings import (
FORM1_DEPTH_COLUMNS,
FORM1_OPER_DEPTH_COLUMNS,
FORM4_DEPTH_COLUMNS,
FORM4_OPER_DEPTH_COLUMNS,
FORM4_STRUCTURE_DEPTH_COLUMNS,
render_column_years,
)
from src.services.export_service.export_writers import XlsxDepthSheetWriter
def _flat(columns_dict):
writer = XlsxDepthSheetWriter(
workbook=XlsxWorkbook(),
columns_dict=columns_dict,
row_normalizer=lambda row: row,
value_serializer=lambda value: value,
)
return writer.columns
def _tree_node(tree, path):
segments = path.split(".")
node = tree[segments[0]]
for segment in segments[1:]:
node = node["children"][segment]
return node
def test_no_stray_collegial_empty_column():
for columns in (FORM1_DEPTH_COLUMNS, FORM4_DEPTH_COLUMNS, FORM4_STRUCTURE_DEPTH_COLUMNS):
assert "collegial.empty" not in _flat(columns)
def test_no_shift_sum_columns_in_base_form1():
flat = _flat(FORM1_DEPTH_COLUMNS)
assert not [key for key in flat if key.endswith(".sum")]
def test_form4_has_fact_q1_q2_q3_and_fact_4_plus_spod_before_totals():
flat = _flat(FORM4_DEPTH_COLUMNS)
# 1-3 кв. показывают то же значение, что q{n}.actual_quarter внутри квартала
# (сознательный дубль ключа — влияет только на цвет, см. AURORA-1905); 4 кв.
# использует свой собственный q4.actual_rsbu_spod (SQL отдаёт только его).
assert flat.count("q1.actual_quarter") == 2
assert flat.count("q2.actual_quarter") == 2
assert flat.count("q3.actual_quarter") == 2
assert flat.count("q4.actual_rsbu_spod") == 1
totals_index = flat.index("totals.fact_year")
assert flat.index("q4.actual_rsbu_spod") < totals_index
def test_adj_comment_immediately_follows_adj_reserve():
for quarter in ("q2", "q3", "q4"):
for columns in (FORM1_DEPTH_COLUMNS, FORM4_DEPTH_COLUMNS, FORM4_STRUCTURE_DEPTH_COLUMNS):
flat = _flat(columns)
reserve_index = flat.index(f"{quarter}.adj_reserve")
comment_index = flat.index(f"{quarter}.adj_comment")
assert comment_index == reserve_index + 1, (columns is FORM1_DEPTH_COLUMNS, quarter)
def test_form4_oper_ckk_procurement_before_comment():
flat = _flat(FORM4_OPER_DEPTH_COLUMNS)
assert flat.index("ckk.procurement_plan") < flat.index("ckk.comment")
assert flat.index("ckk.procurement_method") < flat.index("ckk.comment")
assert flat.index("ckk.deadline") < flat.index("ckk.procurement_plan")
def test_form1_oper_has_no_future_y2_but_ahr_cap_do():
assert "contract_summary.future_y2" in _flat(FORM1_DEPTH_COLUMNS)
assert "contract_summary.future_y2" not in _flat(FORM1_OPER_DEPTH_COLUMNS)
def test_render_column_years_form4():
rendered = render_column_years(FORM4_DEPTH_COLUMNS, 2026)
assert _tree_node(rendered, "upper_ssp_smeta.contract_summary.future_y2")["name"] == "Платежи в 2028 году"
assert _tree_node(rendered, "approved.plan")["name"] == "План 2026"
assert _tree_node(rendered, "q4.quarter_correct")["name"] == "Факт РСБУ 4 кв 26"
def test_render_column_years_form4_structure():
rendered = render_column_years(FORM4_STRUCTURE_DEPTH_COLUMNS, 2026)
assert (
_tree_node(rendered, "upper_ssp_smeta.contract_summary.future_payments_y2")["name"]
== "Платежи в 2028 году"
)
def test_render_column_years_form1():
rendered = render_column_years(FORM1_DEPTH_COLUMNS, 2026)
assert _tree_node(rendered, "approved.plan")["name"] == "План 2026"
assert _tree_node(rendered, "q4.quarter_correct")["name"] == "Факт РСБУ 4 кв 26"
def test_module_reload_is_idempotent():
# Патчи FORM_4 применяются один раз при импорте модуля; повторный импорт
# не должен снова звать патчи и удваивать/ломать раскладку.
before = _flat(export_mappings.FORM4_DEPTH_COLUMNS)
importlib.reload(export_mappings)
after = _flat(export_mappings.FORM4_DEPTH_COLUMNS)
assert before == after
importlib.reload(export_mappings)

View File

@ -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: 'Сумма, тыс. руб. (без НДС)',
accessorKey: 'data.q4.corrected_plan',
columnLetter: 'GH',
size: 250,
header: 'Сумма, тыс. руб. (с учётом НДС)',
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: 'Сумма, тыс. руб. (с учётом НДС)',
accessorKey: 'data.q4.adj_comment',
columnLetter: 'GG',
size: 300,
header: 'Сумма, тыс. руб. (без НДС)',
accessorKey: 'data.q4.corrected_plan',
columnLetter: 'GH',
size: 250,
filterFn: 'contains',
},
],

View File

@ -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: 'Сумма, тыс. руб. (с учётом НДС)',
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: 'Сумма, тыс. руб. (с учётом НДС)',
accessorKey: 'data.q4.adj_comment',
columnLetter: 'GG',
accessorKey: 'data.q4.corrected_plan',
columnLetter: 'GH',
size: 300,
filterFn: 'contains',
},

View File

@ -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: 'Сумма, тыс. руб. (с учётом НДС)',
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: 'Сумма, тыс. руб. (с учётом НДС)',
accessorKey: 'data.q4.adj_comment',
columnLetter: 'GG',
accessorKey: 'data.q4.corrected_plan',
columnLetter: 'GH',
size: 300,
filterFn: 'contains',
},

View File

@ -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');
});
}