perf(sheet): upd_form_cells возвращает затронутые строки целиком, без снимка до и _jsonb_diff (AURORA-1844)

Выборка строк вынесена в v3._form_affected_rows; FORM_4 отбирает ROOT, GROUP и ITEM только изменённых строк; при sections убираются ключи исключённых разделов.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 18:18:07 +04:00
parent 2a287480b6
commit 6855e9163a
3 changed files with 210 additions and 1 deletions

View File

@ -2,7 +2,7 @@
Монорепозиторий: `api/` (FastAPI) отдаёт REST, WebSocket и собранный фронт; `web/` — SPA на React. Основная бизнес-логика — PL/pgSQL-функции схемы `v3` в Postgres. Python-слой проверяет роли, валидирует запрос, вызывает функцию и маппит её ошибки.
Поток редактирования ячейки: `RealtimeTable` → WebSocket `/api/v1/ws/form/{id}/sheet/{sheet}` (`cell_edit_start` — блокировка, `cell_updated`) → `SheetService` → `v3.upd_form_cell(s)`. Функция проверяет роль и окно этапа и пишет аудит `v3.log_event`, после чего изменение рассылается остальным подключённым.
Поток редактирования ячейки: `RealtimeTable` → WebSocket `/api/v1/ws/form/{id}/sheet/{sheet}` (`cell_edit_start` — блокировка, `cell_updated`) → `SheetService` → `v3.upd_form_cell(s)`. Функция проверяет роль и окно этапа и пишет аудит `v3.log_event`, после чего изменение рассылается остальным подключённым. Ответ `upd_form_cells` (HTTP и `cell_updated`) — полные строки (`data` как в GET листа, с явными `null`): изменённые INPUT и все их предки-агрегаты, в том числе неизменившиеся; при `sections` ключи исключённых разделов убираются. Выборку делает `v3._form_affected_rows` (миграция 0050).
## Модули

View File

@ -0,0 +1,15 @@
from sql_migration import run_sql_file_migration
revision = "0050"
down_revision = "0049"
branch_labels = None
depends_on = None
def upgrade() -> None:
run_sql_file_migration("0050_upd_form_cells_full_rows.sql")
def downgrade() -> None:
pass

View File

@ -0,0 +1,194 @@
-- AURORA-1844: v3.upd_form_cells возвращает затронутые строки целиком.
-- Убран снимок «до» и _jsonb_diff: после применения правок выполняется один
-- вызов v_form_view (внутри v3._form_affected_rows), ответ — полные строки
-- изменённых INPUT и всех их предков-агрегатов (включая неизменившиеся).
-- Обёртка с p_user_id (0031) и upd_form_cell не меняются.
CREATE OR REPLACE FUNCTION v3._form_affected_rows(p_form_id integer, p_sheet character varying, p_ids integer[], p_direction character varying DEFAULT NULL::character varying, p_sections text[] DEFAULT NULL::text[])
RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb)
LANGUAGE plpgsql
AS $function$
DECLARE
v_form_type VARCHAR;
v_strip TEXT[] := ARRAY[]::TEXT[];
v_lc_path INT[];
v_eid_set INT[];
v_ei_path INT[];
BEGIN
SELECT bf.form_type_code INTO v_form_type
FROM v3.budget_form bf
WHERE bf.id = p_form_id;
-- Ключи верхнего уровня исключённых разделов (при p_sections они приходят как null
-- и затёрли бы данные клиента). Вложенные null не трогаем: это очистка ячейки.
IF p_sections IS NOT NULL THEN
SELECT COALESCE(array_agg(k), ARRAY[]::TEXT[])
INTO v_strip
FROM unnest(ARRAY['plan','seq_dfip','seq_ssp','approved','contract','booking',
'contract_summary','allocation','collegial','ckk',
'sequestration','reserve','contract_detail',
'q1','q2','q3','q4','totals',
'fact_q1','fact_q2','fact_q3','fact_q4']) AS k
WHERE NOT (k = ANY(p_sections));
END IF;
IF p_sheet IN ('AHR_RENT','AHR_UTILITY','AHR_SECURITY') THEN
RETURN QUERY
SELECT v.row_type, v.depth, v.sort_order, v.data - v_strip
FROM v3.v_form_view(p_form_id, p_sheet, p_sections, p_direction) v
WHERE v.row_type = 'INPUT' AND (v.data->>'id')::INT = ANY(p_ids)
ORDER BY v.sort_order;
ELSIF p_sheet = 'AHR_LIMIT' THEN
v_lc_path := v3._limit_template_path_set(p_ids);
RETURN QUERY
SELECT v.row_type, v.depth, v.sort_order, v.data - v_strip
FROM v3.v_form_view(p_form_id, p_sheet, p_sections, p_direction) v
WHERE (v.data->>'id')::INT = ANY(v_lc_path)
ORDER BY v.sort_order;
ELSIF p_sheet = 'OTCH9F' THEN
RETURN QUERY
SELECT v.row_type, v.depth, v.sort_order, v.data - v_strip
FROM v3.v_form_view(p_form_id, p_sheet, p_sections, p_direction) v
WHERE EXISTS (
SELECT 1 FROM v3.fixed_asset_report far
WHERE far.id = ANY(p_ids)
AND far.expense_item_id = (v.data->>'expense_item_id')::INT
AND far.equipment_name IS NOT DISTINCT FROM (v.data->>'equipment_name'))
ORDER BY v.sort_order;
ELSE
SELECT array_agg(DISTINCT bl.expense_item_id)
INTO v_eid_set
FROM v3.budget_line bl
WHERE bl.id = ANY(p_ids);
v_ei_path := v3._expense_item_path_set(v_eid_set);
IF v_form_type = 'FORM_4' THEN
-- ROOT раздела, GROUP по program_id и ITEM по project_id изменённых строк
-- (у GROUP/ITEM FORM_4 expense_item_id = id раздела, поэтому фильтр
-- по expense_item_id захватил бы все программы и проекты раздела).
RETURN QUERY
WITH vv AS MATERIALIZED (
SELECT f.row_type, f.depth, f.sort_order, f.data
FROM v3.v_form_view(p_form_id, p_sheet, p_sections, p_direction) f
WHERE f.row_type <> 'META'
),
chg AS (
SELECT DISTINCT (x.data->'header'->>'program_id')::INT AS program_id,
(x.data->'header'->>'project_id')::INT AS project_id
FROM vv x
WHERE x.row_type = 'INPUT' AND (x.data->>'line_id')::INT = ANY(p_ids)
)
SELECT v.row_type, v.depth, v.sort_order, v.data - v_strip
FROM vv v
WHERE (v.row_type = 'INPUT' AND (v.data->>'line_id')::INT = ANY(p_ids))
OR (v.row_type = 'ROOT'
AND (v.data->'header'->>'expense_item_id')::INT = ANY(v_ei_path))
OR (v.row_type = 'GROUP'
AND (v.data->'header'->>'expense_item_id')::INT = ANY(v_ei_path)
AND (v.data->'header'->>'program_id')::INT IN (SELECT c.program_id FROM chg c))
OR (v.row_type = 'ITEM'
AND (v.data->'header'->>'expense_item_id')::INT = ANY(v_ei_path)
AND (v.data->'header'->>'project_id')::INT IN (SELECT c.project_id FROM chg c))
ORDER BY v.sort_order;
ELSE
RETURN QUERY
SELECT v.row_type, v.depth, v.sort_order, v.data - v_strip
FROM v3.v_form_view(p_form_id, p_sheet, p_sections, p_direction) v
WHERE (v.row_type IN ('ROOT','GROUP','ITEM','SUB_ITEM','SUB_SUB_ITEM')
AND (v.data->'header'->>'expense_item_id')::INT = ANY(v_ei_path))
OR (v.row_type = 'INPUT'
AND (v.data->>'line_id')::INT = ANY(p_ids))
ORDER BY v.sort_order;
END IF;
END IF;
END;
$function$
;
CREATE OR REPLACE FUNCTION v3.upd_form_cells(p_form_id integer, p_sheet character varying, p_changes jsonb, p_direction character varying DEFAULT NULL::character varying, p_sections text[] DEFAULT NULL::text[])
RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb)
LANGUAGE plpgsql
AS $function$
DECLARE
v_change JSONB;
v_line_id INT;
v_column TEXT;
v_changed_lines INT[] := ARRAY[]::INT[];
BEGIN
IF p_changes IS NULL OR jsonb_typeof(p_changes) <> 'array' THEN
RAISE EXCEPTION 'p_changes must be a JSONB array';
END IF;
IF NOT EXISTS (SELECT 1 FROM v3.budget_form WHERE id = p_form_id) THEN
RAISE EXCEPTION 'budget_form id=% не существует', p_form_id;
END IF;
FOR v_change IN SELECT * FROM jsonb_array_elements(p_changes)
LOOP
v_line_id := (v_change->>'line_id')::INT;
v_column := v_change->>'column';
IF v_line_id IS NULL OR v_column IS NULL THEN
RAISE EXCEPTION 'change must have line_id and column: %', v_change;
END IF;
IF p_sheet = 'AHR_LIMIT' THEN
IF NOT EXISTS (SELECT 1 FROM v3.limit_template lt
WHERE lt.id = v_line_id AND lt.row_type = 'LEAF') THEN
RAISE EXCEPTION 'limit_template.id=% не существует или не LEAF', v_line_id;
END IF;
ELSIF p_sheet = 'OTCH9F' THEN
IF NOT EXISTS (SELECT 1 FROM v3.fixed_asset_report
WHERE id = v_line_id AND budget_form_id = p_form_id) THEN
RAISE EXCEPTION 'fixed_asset_report.id=% не принадлежит budget_form id=%', v_line_id, p_form_id;
END IF;
ELSIF p_sheet = 'AHR_RENT' THEN
IF NOT EXISTS (SELECT 1 FROM v3.rent_detail rd
JOIN v3.budget_line bl ON bl.id = rd.line_id
WHERE rd.id = v_line_id AND bl.budget_form_id = p_form_id) THEN
RAISE EXCEPTION 'rent_detail id=% не принадлежит budget_form id=%', v_line_id, p_form_id;
END IF;
ELSIF p_sheet = 'AHR_UTILITY' THEN
IF NOT EXISTS (SELECT 1 FROM v3.utility_detail ud
JOIN v3.budget_line bl ON bl.id = ud.line_id
WHERE ud.id = v_line_id AND bl.budget_form_id = p_form_id) THEN
RAISE EXCEPTION 'utility_detail id=% не принадлежит budget_form id=%', v_line_id, p_form_id;
END IF;
ELSIF p_sheet = 'AHR_SECURITY' THEN
IF NOT EXISTS (SELECT 1 FROM v3.security_detail sd
JOIN v3.budget_line bl ON bl.id = sd.line_id
WHERE sd.id = v_line_id AND bl.budget_form_id = p_form_id) THEN
RAISE EXCEPTION 'security_detail id=% не принадлежит budget_form id=%', v_line_id, p_form_id;
END IF;
ELSE
IF NOT EXISTS (SELECT 1 FROM v3.budget_line
WHERE id = v_line_id AND budget_form_id = p_form_id) THEN
RAISE EXCEPTION 'budget_line id=% не принадлежит budget_form id=%', v_line_id, p_form_id;
END IF;
END IF;
IF NOT (v_line_id = ANY(v_changed_lines)) THEN
v_changed_lines := v_changed_lines || v_line_id;
END IF;
END LOOP;
FOR v_change IN SELECT * FROM jsonb_array_elements(p_changes)
LOOP
PERFORM v3._apply_form_cell(
(v_change->>'line_id')::INT,
v_change->>'column',
v_change->'value',
p_sheet,
p_form_id);
END LOOP;
RETURN QUERY
SELECT r.row_type, r.depth, r.sort_order, r.data
FROM v3._form_affected_rows(p_form_id, p_sheet, v_changed_lines, p_direction, p_sections) r
ORDER BY r.sort_order;
END;
$function$
;