AURORA-1984: проекты ПД без программы (+1979, 1980, 1989) #306

Merged
aep merged 13 commits from feat/305-aurora-1984-pd-standalone-projects into test 2026-11-11 18:12:24 +03:00
11 changed files with 88 additions and 5 deletions
Showing only changes of commit 321943db56 - Show all commits

View File

@ -232,6 +232,24 @@ class Form1DepthRowNormalizer:
normalized[f"{prefix}{key}"] = value
class Form4DepthRowNormalizer(Form1DepthRowNormalizer):
"""AURORA-1979: подпись «программа»/«проект» вместо кода в колонке header.section_code.
Нормализатор не получает row_type, поэтому узел определяется по line_id и id в header.
Исходные данные не меняются: раскраска читает header.section_code из исходной строки.
"""
def normalize(self, data: dict[str, Any]) -> dict[str, Any]:
normalized = super().normalize(data)
header = data.get("header")
if data.get("line_id") is None and isinstance(header, dict):
if header.get("project_id") is not None:
normalized["header.section_code"] = "проект"
elif header.get("program_id") is not None:
normalized["header.section_code"] = "программа"
return normalized
class SmetaRowNormalizer:
def normalize(self, data: dict[str, Any]) -> dict[str, Any]:
return {

View File

@ -28,6 +28,7 @@ from src.services.export_service.export_normalizers import (
ExcelValueSerializer,
Form1DepthRowNormalizer,
Form2DepthRowNormalizer,
Form4DepthRowNormalizer,
Form3DepthRowNormalizer,
SmetaRowNormalizer,
)
@ -125,6 +126,7 @@ class ExportService:
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.form4_row_normalizer = Form4DepthRowNormalizer(self.value_serializer)
self.smeta_row_normalizer = SmetaRowNormalizer()
self.sheet_writers: dict[str, TabularSheetWriter] = {
"__default__": DepthSheetWriter(
@ -972,19 +974,19 @@ class ExportService:
FORM4_OPER_DEPTH_COLUMNS,
FORM4_OPER_COLORS,
_vat_placeholders(FORM4_SHEET_VAT_WORDING),
self.form1_row_normalizer,
self.form4_row_normalizer,
),
"AHR": (
FORM4_DEPTH_COLUMNS,
FORM4_COLORS["AHR"],
_vat_placeholders(FORM4_SHEET_VAT_WORDING),
self.form1_row_normalizer,
self.form4_row_normalizer,
),
"CAP": (
FORM4_DEPTH_COLUMNS,
FORM4_COLORS["CAP"],
_vat_placeholders(FORM4_SHEET_VAT_WORDING),
self.form1_row_normalizer,
self.form4_row_normalizer,
),
"STRUCTURE": (
FORM4_STRUCTURE_DEPTH_COLUMNS,

View File

@ -1,5 +1,8 @@
import pytest
from src.services.export_service.export_normalizers import (
ExcelValueSerializer,
Form4DepthRowNormalizer,
Form1RowNormalizer,
Form3DepthRowNormalizer,
SmetaRowNormalizer,
@ -78,3 +81,16 @@ def test_form3_depth_normalizer_flattens_nested_data():
assert normalized["totals.total_actual"] == 40
assert normalized["totals.economy"] == 2.1
assert "line_id" not in normalized
@pytest.mark.parametrize("data, expected", [
({"line_id": None, "header": {"section_code": "CAP", "program_id": 1}}, "программа"),
({"line_id": None, "header": {"section_code": "CAP", "program_id": None, "project_id": 2}}, "проект"),
({"line_id": None, "header": {"section_code": "CAP", "program_id": 1, "project_id": 2}}, "проект"),
({"line_id": None, "header": {"section_code": "CAP"}}, "CAP"),
({"line_id": 9, "header": {"section_code": "3.01.", "program_id": 1, "project_id": 2}}, "3.01."),
])
def test_form4_depth_normalizer_type_label(data, expected):
normalized = Form4DepthRowNormalizer(ExcelValueSerializer()).normalize(data)
assert normalized["header.section_code"] == expected
assert data["header"]["section_code"] != "программа"

View File

@ -16,6 +16,7 @@ from src.services.export_service.export_writers import (
TabularSheetWriter,
XlsxDepthSheetWriter,
XlsxTabularSheetWriter,
_hierarchical_rows,
)
# Эталонные порядки колонок листа "Смета" экрана каждой формы (AURORA-1903).
@ -231,3 +232,10 @@ def test_export_service_uses_sheet_writer_strategy():
assert default_writer.calls == 1
assert smeta_writer.calls == 1
def test_standalone_project_outline_without_group():
rows = [("ROOT", 0, 1, {}), ("ITEM", 2, 2, {}), ("INPUT", 3, 3, {}), ("GROUP", 1, 4, {})]
assert [(row[0], level) for row, level in _hierarchical_rows(rows)] == [
("ROOT", 0), ("ITEM", 1), ("INPUT", 2), ("GROUP", 1),
]

View File

@ -790,6 +790,7 @@ export const config = {
{
header: "",
accessorKey: "data.header.section_code",
valueLabel: "form4NodeType",
columnLetter: "C",
size: 150,
filterFn: "contains",

View File

@ -790,6 +790,7 @@ export const config = {
{
header: "",
accessorKey: "data.header.section_code",
valueLabel: "form4NodeType",
columnLetter: "C",
size: 150,
filterFn: "contains",

View File

@ -791,6 +791,7 @@ export const config = {
{
header: "",
accessorKey: "data.header.section_code",
valueLabel: "form4NodeType",
columnLetter: "C",
size: 150,
filterFn: "contains",

View File

@ -11,7 +11,7 @@ import { isCellEditable } from './utils/cellEditability';
import { getColumnValue } from './utils/columnUtils';
import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell';
import { getRowId } from './utils/rowUtils';
import { getForm4TypeLabel, getRowId } from './utils/rowUtils';
const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable }) => {
const { formId, formType, sheetName, direction } = useParams();
@ -252,7 +252,12 @@ export const getTableColumns = ({
// Группирующие колонки-заголовки (есть col.columns) не рендерят Cell/Edit
// по строкам сами — enableEditing им не нужен, в отличие от листовых.
const isLeafColumn = !col.columns?.length;
if (isLeafColumn && !col.accessorFn && typeof col.accessorKey === 'string' && col.accessorKey.includes('.')) {
if (isLeafColumn && !col.accessorFn && col.valueLabel === 'form4NodeType') {
// AURORA-1979: подпись «программа»/«проект»; функция назначается здесь,
// а не в конфиге — structuredClone конфига не переносит функции.
col.id = col.id ?? col.accessorKey;
col.accessorFn = (row) => getForm4TypeLabel(row) ?? getColumnValue(row, col.accessorKey);
} else if (isLeafColumn && !col.accessorFn && typeof col.accessorKey === 'string' && col.accessorKey.includes('.')) {
// MRT предупреждает об undefined для вложенных путей, отсутствующих
// у отдельных типов строк. Сохраняем исходный id для настроек и прав.
col.id = col.id ?? col.accessorKey;

View File

@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
// Конфиги читаются как текст (импорт без расширений не грузится в node).
for (const sheet of ['AHR', 'CAP', 'OPER']) {
test(`AURORA-1979: FORM_4/${sheet} — признак подписи без функций в конфиге`, () => {
const source = readFileSync(fileURLToPath(new URL(`../constants/FORM_4/${sheet}.js`, import.meta.url)), 'utf8');
assert.match(source, /accessorKey: "data\.header\.section_code",\s*valueLabel: "form4NodeType"/);
assert.doesNotMatch(source, /accessorFn/);
});
}

View File

@ -122,3 +122,11 @@ export function isRenameResponse(msg, target) {
if (!msg || !target || msg.is_self !== true) return false;
return msg.event === `${target.kind}_updated` && msg.data?.[`${target.kind}_id`] === target.id;
}
// AURORA-1979: подпись типа узла в колонке «Код статьи / подстатьи / позиции» формы 4.
export function getForm4TypeLabel(row) {
const header = row?.data?.header;
if (row?.row_type === 'GROUP' && header?.program_id != null) return 'программа';
if (row?.row_type === 'ITEM' && header?.project_id != null) return 'проект';
return null;
}

View File

@ -5,6 +5,7 @@ import {
canAddRowForHeader,
getDataRowId,
getDeleteTarget,
getForm4TypeLabel,
getProjectAddTarget,
getRenameTarget,
isRenameResponse,
@ -124,3 +125,12 @@ test('AURORA-1980: isRenameResponse — только свой ответ на с
assert.equal(isRenameResponse({ ...msg, event: 'program_updated', data: { program_id: 21 } }, target), false);
assert.equal(isRenameResponse(msg, null), false);
});
test('AURORA-1979: getForm4TypeLabel', () => {
assert.equal(getForm4TypeLabel(form4Row('GROUP', { program_id: 11 })), 'программа');
assert.equal(getForm4TypeLabel(form4Row('ITEM', { project_id: 21 })), 'проект');
assert.equal(getForm4TypeLabel(form4Row('ITEM', { program_id: 11, project_id: 21 })), 'проект');
assert.equal(getForm4TypeLabel(form4Row('ROOT', { section_code: 'CAP' })), null);
assert.equal(getForm4TypeLabel(form4Row('INPUT', { program_id: 11, project_id: 21 })), null);
assert.equal(getForm4TypeLabel(form4Row('GROUP', { section_code: '1.' })), null);
});