From 91cbe99c326d8043af60d46630e9d6f93e7ba1c2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 31 Oct 2026 15:07:26 +0400 Subject: [PATCH] =?UTF-8?q?fix(form2):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=B8=20=D0=BD=D0=B0=20=D1=81=D0=B0=D1=82=D0=B5=D0=BB=D0=BB?= =?UTF-8?q?=D0=B8=D1=82=D0=B5=20=D0=90=D0=A5=D0=A0=20=D0=BD=D0=B5=20=D0=B7?= =?UTF-8?q?=D0=B0=D1=85=D0=B2=D0=B0=D1=82=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D1=80=D1=83=D1=87=D0=BD=D1=83=D1=8E=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v3.add_budget_line (ветка сателлитов AHR_RENT/AHR_UTILITY/AHR_SECURITY) искала любую существующую строку статьи без ORDER BY и без исключения ручных/уже привязанных строк, и переиспользовала её как заготовку для новой записи. Если у статьи ещё не было строк в v3.form2_ahr_satellite_line_source (типично — первая запись при наличии ручной строки той же статьи), sync переименовывал и перезаписывал план/факт чужой ручной строки, блокируя её для правки. Теперь для каждой новой записи сателлита всегда создаётся отдельная пустая строка-заготовка — sync забирает её как строку именно этой записи. Заодно удалён мёртвый код (_ensure_form2_ahr_sync_line, _upsert_form2_ahr_sync_values), не имевший вызывающих мест. AURORA-1907 (лимитная часть) и AURORA-1887 (статьи 314/24/779 в коде) — отдельные задачи, не затронуты. Closes #265. Co-Authored-By: Claude Sonnet 5 --- .../0051_form2_ahr_satellite_new_line.py | 15 ++ .../sql/0051_form2_ahr_satellite_new_line.sql | 228 ++++++++++++++++++ .../test_form2_ahr_satellite_integration.py | 163 +++++++++++++ 3 files changed, 406 insertions(+) create mode 100644 api/alembic/versions/0051_form2_ahr_satellite_new_line.py create mode 100644 api/alembic/versions/sql/0051_form2_ahr_satellite_new_line.sql create mode 100644 api/tests/integration/test_form2_ahr_satellite_integration.py diff --git a/api/alembic/versions/0051_form2_ahr_satellite_new_line.py b/api/alembic/versions/0051_form2_ahr_satellite_new_line.py new file mode 100644 index 0000000..610f829 --- /dev/null +++ b/api/alembic/versions/0051_form2_ahr_satellite_new_line.py @@ -0,0 +1,15 @@ +from sql_migration import run_sql_file_migration + + +revision = "0051" +down_revision = "0050" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + run_sql_file_migration("0051_form2_ahr_satellite_new_line.sql") + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/sql/0051_form2_ahr_satellite_new_line.sql b/api/alembic/versions/sql/0051_form2_ahr_satellite_new_line.sql new file mode 100644 index 0000000..9ee6b09 --- /dev/null +++ b/api/alembic/versions/sql/0051_form2_ahr_satellite_new_line.sql @@ -0,0 +1,228 @@ +-- AURORA-1909: добавление записи на сателлите АХР (аренда/коммуналка/охрана) +-- не должно захватывать существующую (в т.ч. ручную) строку статьи 314/24/779. +-- +-- v3.add_budget_line, ветка p_sheet IN ('AHR_RENT','AHR_UTILITY','AHR_SECURITY'): +-- раньше искало ЛЮБУЮ существующую строку статьи (без ORDER BY, без исключения +-- ручных и уже привязанных строк) и переиспользовало её как v_parent_line. +-- Если строка не была зарегистрирована в v3.form2_ahr_satellite_line_source, +-- sync (_ensure_form2_ahr_detail_sync_line, 0031) переименовывал и перезаписывал +-- её план/факт значениями новой записи — в т.ч. ручные строки исполнителя. +-- Теперь всегда создаётся новая пустая строка-заготовка; sync заберёт её себе +-- как строку именно этой записи (её ещё нет в form2_ahr_satellite_line_source). +-- +-- Лимитная часть (_ensure_form2_ahr_limit_sync_line) здесь не трогается — +-- это AURORA-1907. Статьи 314/24/779 не трогаются — это AURORA-1887. + +CREATE OR REPLACE FUNCTION v3.add_budget_line(p_form_id integer, p_expense_item_id integer DEFAULT NULL::integer, p_sheet character varying DEFAULT NULL::character varying, p_item_id character varying DEFAULT NULL::character varying, p_section_code character varying DEFAULT NULL::character varying, p_direction character varying DEFAULT NULL::character varying, p_name character varying DEFAULT NULL::character varying, p_internal_order character varying DEFAULT NULL::character varying, p_vsp_id integer DEFAULT NULL::integer, p_project_id integer DEFAULT NULL::integer, p_justification character varying DEFAULT NULL::character varying, p_contract_number character varying DEFAULT NULL::character varying, p_contract_end_date date DEFAULT NULL::date) + RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb) + LANGUAGE plpgsql +AS $function$ +DECLARE + v_form_type VARCHAR; + v_eid INT; + v_sheet VARCHAR; + v_auto_dir VARCHAR; + v_final_dir VARCHAR; + v_new_id INT; + v_cnt INT; + v_sat_table TEXT; + v_sat_eid INT; + v_parent_line INT; + v_decision JSONB; + v_msg TEXT; + v_user_id INT; + v_line_name VARCHAR; +BEGIN + SELECT form_type_code INTO v_form_type FROM v3.budget_form WHERE id = p_form_id; + IF v_form_type IS NULL THEN + RAISE EXCEPTION 'budget_form id=% не существует', p_form_id; + END IF; + IF v_form_type = 'FORM_3' THEN + RAISE EXCEPTION 'FORM_3 не использует budget_line — см. add_form3_line (TBD)'; + END IF; + + v_user_id := v3.current_user_id(); + v_decision := v3.can_edit(p_form_id, p_sheet, p_direction, NULL, v_user_id); + + IF NOT (v_decision->>'ok')::boolean THEN + IF v_decision->>'code' = 'role_not_allowed' THEN + RAISE EXCEPTION 'role_not_allowed: Невозможно вставить строку'; + ELSIF v_decision->>'code' = 'org_not_assigned' THEN + RAISE EXCEPTION 'org_not_assigned: Невозможно вставить строку'; + ELSIF v_decision->>'code' = 'window_closed' THEN + IF v_decision ? 'opens_at' THEN + v_msg := format('window_closed: opens %s', v_decision->>'opens_at'); + ELSIF v_decision->>'closes_at' IS NOT NULL THEN + v_msg := format('window_closed: closed %s', v_decision->>'closes_at'); + ELSE + v_msg := 'window_closed: no phase'; + END IF; + RAISE EXCEPTION '%', v_msg; + ELSE + RAISE EXCEPTION '%', v_decision->>'code'; + END IF; + END IF; + + IF p_sheet IN ('AHR_RENT','AHR_UTILITY','AHR_SECURITY') THEN + v_sat_table := CASE p_sheet + WHEN 'AHR_RENT' THEN 'rent_detail' + WHEN 'AHR_UTILITY' THEN 'utility_detail' + WHEN 'AHR_SECURITY' THEN 'security_detail' + END; + v_sat_eid := CASE p_sheet + WHEN 'AHR_RENT' THEN 314 + WHEN 'AHR_UTILITY' THEN 24 + WHEN 'AHR_SECURITY' THEN 779 + END; + + IF p_vsp_id IS NULL THEN + RAISE EXCEPTION 'p_vsp_id обязателен для сателлитного листа %', p_sheet; + END IF; + IF NOT EXISTS (SELECT 1 FROM v3.vsp WHERE id = p_vsp_id) THEN + RAISE EXCEPTION 'vsp id=% не существует', p_vsp_id; + END IF; + + -- AURORA-1909: раньше здесь искалась и переиспользовалась любая + -- существующая строка статьи (в т.ч. ручная — см. комментарий в начале + -- файла). Каждая новая запись сателлита получает свою строку-заготовку; + -- sync_form2_ahr_satellite_line(s) заберёт её как строку именно этой + -- записи, т.к. её ещё нет в v3.form2_ahr_satellite_line_source. + INSERT INTO v3.budget_line (budget_form_id, expense_item_id, name, direction) + VALUES ( + p_form_id, v_sat_eid, NULL, + CASE v_form_type WHEN 'FORM_2' THEN 'Support' WHEN 'FORM_4' THEN 'Development' ELSE NULL END + ) RETURNING id INTO v_parent_line; + + EXECUTE format( + 'INSERT INTO v3.%I (line_id, vsp_id, contract_number, contract_end_date) ' + || 'VALUES ($1, $2, $3, $4) RETURNING id', + v_sat_table + ) USING v_parent_line, p_vsp_id, p_contract_number, p_contract_end_date + INTO v_new_id; + + PERFORM v3.sync_form2_ahr_satellite_line(p_form_id, p_sheet, v_new_id); + + RETURN QUERY + SELECT v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form_view(p_form_id, p_sheet, NULL, NULL) v + WHERE v.row_type = 'INPUT' AND (v.data->>'id')::INT = v_new_id; + RETURN; + END IF; + + IF p_expense_item_id IS NOT NULL THEN + SELECT ei.id, ei.sheet INTO v_eid, v_sheet + FROM v3.expense_item ei WHERE ei.id = p_expense_item_id; + IF v_eid IS NULL THEN + RAISE EXCEPTION 'expense_item id=% не существует', p_expense_item_id; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM v3.expense_item_form_type + WHERE expense_item_id = v_eid AND form_type_code = v_form_type + ) THEN + RAISE EXCEPTION 'expense_item id=% не привязан к form_type %', v_eid, v_form_type; + END IF; + ELSE + IF p_sheet IS NULL OR p_item_id IS NULL THEN + RAISE EXCEPTION 'нужен либо p_expense_item_id, либо (p_sheet + p_item_id)'; + END IF; + + SELECT count(*) INTO v_cnt + FROM v3.expense_item ei + JOIN v3.expense_item_form_type eift ON eift.expense_item_id = ei.id + WHERE ei.item_id = p_item_id + AND ei.sheet = p_sheet + AND eift.form_type_code = v_form_type + AND (p_section_code IS NULL OR ei.section_code = p_section_code) + AND (p_direction IS NULL OR ei.direction IS NULL OR ei.direction = p_direction); + + IF v_cnt = 0 THEN + RAISE EXCEPTION 'expense_item не найден: sheet=%, item_id=%, section=%, direction=%, form=%', + p_sheet, p_item_id, p_section_code, p_direction, v_form_type; + END IF; + IF v_cnt > 1 THEN + RAISE EXCEPTION 'expense_item не уникален (% совпадений): sheet=%, item_id=%, section=%, direction=%. Уточните p_section_code или передайте p_expense_item_id напрямую', + v_cnt, p_sheet, p_item_id, p_section_code, p_direction; + END IF; + + SELECT ei.id, ei.sheet INTO v_eid, v_sheet + FROM v3.expense_item ei + JOIN v3.expense_item_form_type eift ON eift.expense_item_id = ei.id + WHERE ei.item_id = p_item_id + AND ei.sheet = p_sheet + AND eift.form_type_code = v_form_type + AND (p_section_code IS NULL OR ei.section_code = p_section_code) + AND (p_direction IS NULL OR ei.direction IS NULL OR ei.direction = p_direction); + END IF; + + v_auto_dir := CASE + WHEN v_form_type = 'FORM_2' THEN 'Support' + WHEN v_form_type = 'FORM_4' THEN 'Development' + WHEN v_form_type = 'FORM_1' AND v_sheet = 'OPER' THEN NULL + ELSE NULL + END; + + IF v_form_type = 'FORM_1' AND v_sheet IN ('AHR','CAP') AND p_direction IS NULL THEN + RAISE EXCEPTION 'p_direction обязателен для FORM_1 / sheet=% (Support|Development)', v_sheet; + END IF; + + IF v_form_type IN ('FORM_2','FORM_4') AND p_direction IS NOT NULL AND p_direction <> v_auto_dir THEN + RAISE EXCEPTION 'p_direction=% несовместим с form_type=% (фиксирован=%)', + p_direction, v_form_type, v_auto_dir; + END IF; + + IF v_form_type = 'FORM_1' AND v_sheet = 'OPER' AND p_direction IS NOT NULL THEN + RAISE EXCEPTION 'p_direction должен быть NULL для FORM_1 / sheet=OPER (получено: %)', p_direction; + END IF; + + v_final_dir := COALESCE(p_direction, v_auto_dir); + + IF p_vsp_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM v3.vsp WHERE id = p_vsp_id) THEN + RAISE EXCEPTION 'vsp id=% не существует', p_vsp_id; + END IF; + IF p_project_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM v3.form4_project WHERE id = p_project_id) THEN + RAISE EXCEPTION 'form4_project id=% не существует', p_project_id; + END IF; + + v_line_name := CASE WHEN v_form_type = 'FORM_4' THEN NULL ELSE '' END; + + INSERT INTO v3.budget_line ( + budget_form_id, expense_item_id, name, internal_order, + vsp_id, project_id, direction, justification + ) VALUES ( + p_form_id, v_eid, v_line_name, p_internal_order, + p_vsp_id, p_project_id, v_final_dir, p_justification + ) RETURNING id INTO v_new_id; + + PERFORM v3.log_event( + 'ROW_CREATE', 'ROW', + jsonb_build_object( + 'sheet', v_sheet, + 'line_id', v_new_id, + 'expense_item_id', v_eid, + 'direction', v_final_dir, + 'name', v_line_name, + 'vsp_id', p_vsp_id, + 'project_id', p_project_id + ), + p_form_id + ); + + DECLARE v_ei_path INT[]; + BEGIN + v_ei_path := v3._expense_item_path_set(ARRAY[v_eid]); + RETURN QUERY + SELECT v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form_view(p_form_id, v_sheet, NULL, v_final_dir) v + WHERE (v.row_type IN ('ROOT','GROUP','ITEM','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 = v_new_id) + ORDER BY v.sort_order; + END; +END; +$function$; + +-- AURORA-1909: мёртвый код (0031) — _ensure_form2_ahr_sync_line вызывается +-- только из _upsert_form2_ahr_sync_values, а саму _upsert_form2_ahr_sync_values +-- нигде не вызывают (актуальный путь — ...detail_sync_line/...detail_sync_values). +DROP FUNCTION IF EXISTS v3._upsert_form2_ahr_sync_values(INT, INT, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC); +DROP FUNCTION IF EXISTS v3._ensure_form2_ahr_sync_line(INT, INT); diff --git a/api/tests/integration/test_form2_ahr_satellite_integration.py b/api/tests/integration/test_form2_ahr_satellite_integration.py new file mode 100644 index 0000000..5a33240 --- /dev/null +++ b/api/tests/integration/test_form2_ahr_satellite_integration.py @@ -0,0 +1,163 @@ +"""AURORA-1909: добавление записи на сателлите АХР (аренда/коммуналка/охрана) +не должно захватывать существующую — в т.ч. ручную — строку статьи 314/24/779. + +До фикса v3.add_budget_line (api/alembic/versions/sql/0051_form2_ahr_satellite_new_line.sql) +искала ЛЮБУЮ существующую строку статьи на листе-источнике (без ORDER BY, +без исключения ручных и уже привязанных строк) и переиспользовала её как +"заготовку" для новой записи сателлита. Если у статьи ещё не было ни одной +строки в v3.form2_ahr_satellite_line_source (типично — первая запись сателлита +при наличии ручной строки той же статьи), sync (_ensure_form2_ahr_detail_sync_line, +0031) переименовывал и перезаписывал план/факт чужой строки — в т.ч. ручной, +созданной исполнителем на листе AHR. + +Сессия тестов работает в rollback-транзакции (см. tests/integration/conftest.py +client fixture), поэтому изменения не переживают отдельный тест — на каждый +тест создаётся собственная форма FORM_2 через POST /api/v1/form/. +""" + +import pytest + +# статья листа-источника (AHR) для каждого сателлита — зашита в v3.add_budget_line +# (AURORA-1887, здесь не трогается). +SATELLITE_PARENT_EXPENSE_ITEM = { + "AHR_RENT": 314, + "AHR_UTILITY": 24, + "AHR_SECURITY": 779, +} + + +def _create_form2(client, admin_tokens, auth_headers, year: int) -> int: + response = client.post( + "/api/v1/form/", + json={"year": year, "form_type_code": "FORM_2", "org_unit_ids": [1]}, + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200, response.text + return response.json()["result"][0]["id"] + + +def _add_manual_ahr_line(client, admin_tokens, auth_headers, form_id, expense_item_id) -> int: + response = client.post( + f"/api/v1/form/{form_id}/sheet/AHR/line", + json={"expense_item_id": expense_item_id}, + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200, response.text + input_rows = [r for r in response.json()["result"] if r["row_type"] == "INPUT"] + assert len(input_rows) == 1, input_rows + return input_rows[0]["data"]["line_id"] + + +def _patch_ahr_cell(client, admin_tokens, auth_headers, form_id, line_id, column, value): + response = client.patch( + f"/api/v1/form/{form_id}/sheet/AHR/cell", + json={"line_id": line_id, "column": column, "value": value}, + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200, response.text + + +def _add_satellite_line(client, admin_tokens, auth_headers, form_id, sheet, vsp_id) -> dict: + response = client.post( + f"/api/v1/form/{form_id}/sheet/{sheet}/line", + json={"vsp_id": vsp_id}, + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200, response.text + rows = response.json()["result"] + assert len(rows) == 1, rows + return rows[0]["data"] + + +def _get_ahr_rows(client, admin_tokens, auth_headers, form_id) -> list[dict]: + response = client.get( + f"/api/v1/form/{form_id}/sheet/AHR", + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200, response.text + return response.json()["result"] + + +def _find_ahr_row_by_line_id(rows: list[dict], line_id: int) -> dict: + for row in rows: + if row["row_type"] == "INPUT" and row["data"].get("line_id") == line_id: + return row + raise AssertionError(f"line_id={line_id} не найден на листе AHR: {rows}") + + +@pytest.mark.parametrize("sheet", sorted(SATELLITE_PARENT_EXPENSE_ITEM)) +def test_satellite_line_add_does_not_capture_existing_manual_line( + client, admin_tokens, auth_headers, sheet +): + """Основной сценарий бага: у статьи уже есть ручная строка (и больше ни + одной), затем на лист-сателлит добавляется запись. Ручная строка не + должна быть переименована/перезаписана/привязана — запись должна + получить свою собственную строку АХР.""" + expense_item_id = SATELLITE_PARENT_EXPENSE_ITEM[sheet] + form_id = _create_form2(client, admin_tokens, auth_headers, year=2030) + + manual_line_id = _add_manual_ahr_line( + client, admin_tokens, auth_headers, form_id, expense_item_id + ) + _patch_ahr_cell( + client, admin_tokens, auth_headers, form_id, manual_line_id, "plan.q1", 500 + ) + _patch_ahr_cell( + client, admin_tokens, auth_headers, form_id, manual_line_id, "header.name", "моя строка" + ) + + satellite_data = _add_satellite_line( + client, admin_tokens, auth_headers, form_id, sheet, vsp_id=2 + ) + satellite_line_id = satellite_data["line_id"] + + # запись сателлита получила свою строку, а не строку ручной записи + assert satellite_line_id != manual_line_id + + ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) + + # ручная строка не тронута: тот же line_id, то же имя, тот же план + manual_row = _find_ahr_row_by_line_id(ahr_rows, manual_line_id) + assert manual_row["data"]["header"]["name"] == "моя строка" + assert manual_row["data"]["plan"]["q1"] == 500 + + # у записи сателлита — отдельная строка на листе AHR (строка синка) + satellite_row = _find_ahr_row_by_line_id(ahr_rows, satellite_line_id) + assert satellite_row["data"]["line_id"] == satellite_line_id + + +@pytest.mark.parametrize("sheet", sorted(SATELLITE_PARENT_EXPENSE_ITEM)) +def test_manual_line_stays_deletable_after_satellite_add( + client, admin_tokens, auth_headers, sheet +): + """Ручная строка остаётся удаляемой (не заблокирована + is_form2_ahr_satellite_line, т.к. в form2_ahr_satellite_line_source её нет).""" + expense_item_id = SATELLITE_PARENT_EXPENSE_ITEM[sheet] + form_id = _create_form2(client, admin_tokens, auth_headers, year=2031) + + manual_line_id = _add_manual_ahr_line( + client, admin_tokens, auth_headers, form_id, expense_item_id + ) + _add_satellite_line(client, admin_tokens, auth_headers, form_id, sheet, vsp_id=2) + + response = client.delete( + f"/api/v1/form/{form_id}/sheet/AHR/line/{manual_line_id}", + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200, response.text + + +@pytest.mark.parametrize("sheet", sorted(SATELLITE_PARENT_EXPENSE_ITEM)) +def test_two_satellite_records_on_empty_item_get_two_lines( + client, admin_tokens, auth_headers, sheet +): + """Две записи подряд на пустой статье (без ручной строки) — уже до + AURORA-1887 (сводная строка источника) должны получать по своей строке + АХР каждая; ни одна из них не должна быть ручной (на статье ручных + строк нет вовсе).""" + form_id = _create_form2(client, admin_tokens, auth_headers, year=2032) + + first = _add_satellite_line(client, admin_tokens, auth_headers, form_id, sheet, vsp_id=1) + second = _add_satellite_line(client, admin_tokens, auth_headers, form_id, sheet, vsp_id=2) + + assert first["line_id"] != second["line_id"]