From 6855e9163add9cf5fda2a327b1fc5da8fdaada16 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 18:18:07 +0400 Subject: [PATCH] =?UTF-8?q?perf(sheet):=20upd=5Fform=5Fcells=20=D0=B2?= =?UTF-8?q?=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=89=D0=B0=D0=B5=D1=82=20=D0=B7?= =?UTF-8?q?=D0=B0=D1=82=D1=80=D0=BE=D0=BD=D1=83=D1=82=D1=8B=D0=B5=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20=D1=86=D0=B5=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC,=20=D0=B1=D0=B5=D0=B7=20=D1=81=D0=BD=D0=B8=D0=BC?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B4=D0=BE=20=D0=B8=20=5Fjsonb=5Fdiff=20(AUROR?= =?UTF-8?q?A-1844)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Выборка строк вынесена в v3._form_affected_rows; FORM_4 отбирает ROOT, GROUP и ITEM только изменённых строк; при sections убираются ключи исключённых разделов. Co-Authored-By: Claude Sonnet 5.5 --- ARCHITECTURE.md | 2 +- .../versions/0050_upd_form_cells_full_rows.py | 15 ++ .../sql/0050_upd_form_cells_full_rows.sql | 194 ++++++++++++++++++ 3 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 api/alembic/versions/0050_upd_form_cells_full_rows.py create mode 100644 api/alembic/versions/sql/0050_upd_form_cells_full_rows.sql diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9d27e1c..bc57c07 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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). ## Модули diff --git a/api/alembic/versions/0050_upd_form_cells_full_rows.py b/api/alembic/versions/0050_upd_form_cells_full_rows.py new file mode 100644 index 0000000..e39ce05 --- /dev/null +++ b/api/alembic/versions/0050_upd_form_cells_full_rows.py @@ -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 diff --git a/api/alembic/versions/sql/0050_upd_form_cells_full_rows.sql b/api/alembic/versions/sql/0050_upd_form_cells_full_rows.sql new file mode 100644 index 0000000..388d821 --- /dev/null +++ b/api/alembic/versions/sql/0050_upd_form_cells_full_rows.sql @@ -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$ +;