diff --git a/api/alembic/versions/0056_budget_line_delete_owner_guard.py b/api/alembic/versions/0056_budget_line_delete_owner_guard.py
new file mode 100644
index 0000000..4ee393a
--- /dev/null
+++ b/api/alembic/versions/0056_budget_line_delete_owner_guard.py
@@ -0,0 +1,15 @@
+from sql_migration import run_sql_file_migration
+
+
+revision = "0056"
+down_revision = "0055"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ run_sql_file_migration("0056_budget_line_delete_owner_guard.sql")
+
+
+def downgrade() -> None:
+ pass
diff --git a/api/alembic/versions/sql/0056_budget_line_delete_owner_guard.sql b/api/alembic/versions/sql/0056_budget_line_delete_owner_guard.sql
new file mode 100644
index 0000000..126c3ae
--- /dev/null
+++ b/api/alembic/versions/sql/0056_budget_line_delete_owner_guard.sql
@@ -0,0 +1,359 @@
+-- AURORA-1936: v3.del_budget_line — исполнитель (не ADMIN) удаляет только строки, созданные им самим
+-- (budget_line.created_by = текущий пользователь).
+-- 0056: тело пересобрано поверх 0053_form2_ahr_source_sheet (AURORA-1907: блок удаления АХР-строк
+-- по budget_line.source_sheet IS NOT NULL, ручные строки с NULL удаляются) + только owner-guard.
+-- AURORA-1936 (R-4, решение пользователя 08.11): v3.add_budget_line проставляет budget_line.created_by
+-- = v3.current_user_id() (тот же источник, что v_actor_id в del_budget_line). Тело = 0051_form2_ahr_satellite_new_line
+-- + только created_by в двух INSERT (запись сателлита и обычная строка). Авто-зеркальные строки
+-- (_ensure_form2_ahr_*_sync_line, source_sheet NOT NULL) остаются с created_by = NULL. Backfill legacy-строк не делается.
+-- del_budget_line: created_by IS NULL -> удалить может только ADMIN (IS DISTINCT FROM).
+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.
+ -- AURORA-1936: автор — текущий пользователь (запись сателлита добавил человек).
+ INSERT INTO v3.budget_line (budget_form_id, expense_item_id, name, direction, created_by)
+ 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,
+ v_user_id
+ ) 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, created_by
+ ) VALUES (
+ p_form_id, v_eid, v_line_name, p_internal_order,
+ p_vsp_id, p_project_id, v_final_dir, p_justification, v_user_id
+ ) 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$;
+
+CREATE OR REPLACE FUNCTION v3.del_budget_line(p_id integer, p_direction character varying DEFAULT NULL::character varying, p_sheet character varying DEFAULT NULL::character varying)
+ RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb)
+ LANGUAGE plpgsql
+AS $function$
+DECLARE
+ v_form_id INT;
+ v_org_id INT;
+ v_sheet VARCHAR;
+ v_sat_table TEXT;
+ v_eid INT;
+ v_line_id INT;
+ v_ei_path INT[];
+ v_source_sheet VARCHAR;
+ v_actor_id INT;
+ v_role VARCHAR;
+ v_created_by INT;
+BEGIN
+ v_actor_id := v3.current_user_id();
+ v_role := v3.user_role_code(v_actor_id);
+
+ 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;
+
+ EXECUTE format(
+ 'SELECT bl.budget_form_id, bl.created_by FROM v3.%I s
+ JOIN v3.budget_line bl ON bl.id = s.line_id
+ WHERE s.id = $1', v_sat_table
+ ) USING p_id INTO v_form_id, v_created_by;
+
+ IF v_form_id IS NULL THEN
+ RAISE EXCEPTION '%.id=% не существует', v_sat_table, p_id;
+ END IF;
+
+ -- created_by IS NULL (legacy / авто-зеркальные строки): IS DISTINCT FROM даёт TRUE -> удалить может только ADMIN.
+ IF v_role != 'ADMIN' AND v_created_by IS DISTINCT FROM v_actor_id THEN
+ RAISE EXCEPTION 'role_not_allowed: можно удалить только добавленную вами строку';
+ END IF;
+
+ SELECT m.target_line_id
+ INTO v_line_id
+ FROM v3.form2_ahr_satellite_line_source m
+ WHERE m.source_sheet = p_sheet
+ AND m.source_id = p_id;
+
+ EXECUTE format('DELETE FROM v3.%I WHERE id = $1', v_sat_table) USING p_id;
+
+ IF v_line_id IS NOT NULL THEN
+ SELECT bl.expense_item_id
+ INTO v_eid
+ FROM v3.budget_line bl
+ WHERE bl.id = v_line_id;
+
+ DELETE FROM v3.plan WHERE line_id = v_line_id;
+ DELETE FROM v3.budget_line_quarter WHERE line_id = v_line_id;
+ DELETE FROM v3.budget_line WHERE id = v_line_id;
+
+ SELECT org_unit_id INTO v_org_id FROM v3.budget_form WHERE id = v_form_id;
+
+ PERFORM v3.log_event(
+ 'ROW_DELETE', 'ROW',
+ jsonb_build_object(
+ 'sheet', 'AHR',
+ 'line_id', v_line_id,
+ 'entity_id', v_line_id,
+ 'expense_item_id', v_eid,
+ 'direction', 'Support',
+ 'source_sheet', p_sheet,
+ 'source_id', p_id
+ ),
+ v_form_id, NULL, v_org_id
+ );
+ END IF;
+ RETURN;
+ END IF;
+
+ SELECT bl.budget_form_id, ei.sheet, bl.expense_item_id, bl.source_sheet, bl.created_by
+ INTO v_form_id, v_sheet, v_eid, v_source_sheet, v_created_by
+ FROM v3.budget_line bl
+ JOIN v3.expense_item ei ON ei.id = bl.expense_item_id
+ WHERE bl.id = p_id;
+
+ IF v_form_id IS NULL THEN
+ RAISE EXCEPTION 'budget_line id=% не существует', p_id;
+ END IF;
+
+ -- created_by IS NULL (legacy / авто-зеркальные строки): IS DISTINCT FROM даёт TRUE -> удалить может только ADMIN.
+ IF v_role != 'ADMIN' AND v_created_by IS DISTINCT FROM v_actor_id THEN
+ RAISE EXCEPTION 'role_not_allowed: можно удалить только добавленную вами строку';
+ END IF;
+
+ IF v_sheet = 'AHR' AND v_source_sheet IS NOT NULL THEN
+ RAISE EXCEPTION 'Строку АХР, созданную из листа-сателлита или АХР Лимиты, нельзя удалить напрямую'
+ USING ERRCODE = '23503',
+ HINT = 'Удалите строку на исходном листе АХР Аренда / АХР Коммунальные услуги / АХР Безопасность либо измените строку на листе АХР Лимиты';
+ END IF;
+
+ v_ei_path := v3._expense_item_path_set(ARRAY[v_eid]);
+
+ DELETE FROM v3.plan WHERE line_id = p_id;
+ DELETE FROM v3.sequestration WHERE line_id = p_id;
+ DELETE FROM v3.reserve WHERE line_id = p_id;
+ DELETE FROM v3.allocation WHERE line_id = p_id;
+ DELETE FROM v3.contract_summary WHERE line_id = p_id;
+ DELETE FROM v3.collegial_approval WHERE line_id = p_id;
+ DELETE FROM v3.ckk WHERE line_id = p_id;
+ DELETE FROM v3.contract_detail WHERE line_id = p_id;
+ DELETE FROM v3.booking WHERE line_id = p_id;
+ DELETE FROM v3.budget_line_quarter WHERE line_id = p_id;
+ DELETE FROM v3.rent_detail WHERE line_id = p_id;
+ DELETE FROM v3.utility_detail WHERE line_id = p_id;
+ DELETE FROM v3.security_detail WHERE line_id = p_id;
+
+ DELETE FROM v3.budget_line WHERE id = p_id;
+
+ SELECT org_unit_id INTO v_org_id FROM v3.budget_form WHERE id = v_form_id;
+
+ PERFORM v3.log_event(
+ 'ROW_DELETE', 'ROW',
+ jsonb_build_object(
+ 'sheet', v_sheet,
+ 'line_id', p_id,
+ 'entity_id', p_id,
+ 'expense_item_id', v_eid,
+ 'direction', p_direction
+ ),
+ v_form_id, NULL, v_org_id
+ );
+
+ RETURN QUERY
+ SELECT v.row_type, v.depth, v.sort_order, v.data
+ FROM v3.v_form_view(v_form_id, v_sheet, NULL, p_direction) v
+ WHERE v.row_type IN ('ROOT','GROUP','ITEM','SUB_ITEM')
+ AND (v.data->'header'->>'expense_item_id')::INT = ANY(v_ei_path)
+ ORDER BY v.sort_order;
+END;
+$function$;
diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py
index 6c067f5..383ca63 100644
--- a/api/tests/integration/conftest.py
+++ b/api/tests/integration/conftest.py
@@ -86,3 +86,27 @@ def isp_tokens(client, isp_password: str):
)
assert response.status_code == 200
return response.json()
+
+
+@pytest.fixture
+def isp2_tokens(client, isp_password: str):
+ # Исполнитель в org_unit формы id=1 (isp1 в нём намеренно не состоит — на этом
+ # держатся тесты «исполнитель из чужой организации»). Нужен вместе с isp3_tokens,
+ # чтобы проверять, что исполнитель не может удалить чужую строку.
+ response = client.post(
+ "/api/v1/auth/login",
+ json={"username": "isp2", "password": isp_password},
+ )
+ assert response.status_code == 200
+ return response.json()
+
+
+@pytest.fixture
+def isp3_tokens(client, isp_password: str):
+ # Второй исполнитель в том же org_unit, что и isp2 (форма id=1).
+ response = client.post(
+ "/api/v1/auth/login",
+ json={"username": "isp3", "password": isp_password},
+ )
+ assert response.status_code == 200
+ return response.json()
diff --git a/api/tests/integration/test_form2_ahr_satellite_integration.py b/api/tests/integration/test_form2_ahr_satellite_integration.py
index 39ca5d7..9fb08cf 100644
--- a/api/tests/integration/test_form2_ahr_satellite_integration.py
+++ b/api/tests/integration/test_form2_ahr_satellite_integration.py
@@ -760,3 +760,24 @@ def test_manual_line_add_still_works_for_ordinary_statja_on_ahr(client, admin_to
assert manual_row["data"]["header"]["source"] == "MANUAL"
assert manual_row["data"]["header"]["can_add_row"] is True
assert manual_row["data"]["plan"]["q1"] == 500
+
+
+@pytest.mark.parametrize("sheet", sorted(SATELLITE_PARENT_EXPENSE_ITEM))
+def test_satellite_sourced_ahr_line_not_deletable_directly_after_owner_guard(
+ client, admin_tokens, auth_headers, sheet
+):
+ """AURORA-1907 + AURORA-1936: миграция 0056 (owner-guard в v3.del_budget_line)
+ пересобрана поверх 0053 — строку АХР, созданную из сателлита
+ (budget_line.source_sheet IS NOT NULL), по-прежнему нельзя удалить напрямую
+ даже ADMIN'у, а ручную строку (source_sheet NULL) — можно
+ (см. test_manual_line_stays_deletable_after_satellite_add)."""
+ form_id = _create_form2(client, admin_tokens, auth_headers, year=2061)
+ satellite_data = _add_satellite_line(client, admin_tokens, auth_headers, form_id, sheet, vsp_id=1)
+ line_id = satellite_data["line_id"]
+
+ response = client.delete(
+ f"/api/v1/form/{form_id}/sheet/AHR/line/{line_id}",
+ headers=auth_headers(admin_tokens),
+ )
+ assert response.status_code != 200, response.text
+ assert "нельзя удалить напрямую" in response.text, response.text
diff --git a/api/tests/integration/test_forms_api_smoke.py b/api/tests/integration/test_forms_api_smoke.py
index 4fb199a..393732a 100644
--- a/api/tests/integration/test_forms_api_smoke.py
+++ b/api/tests/integration/test_forms_api_smoke.py
@@ -167,6 +167,69 @@ def test_forms_delete_line_not_found_error_shape(client, admin_tokens, auth_head
assert "message" in payload
+def _added_line_id(payload: dict, marker: str) -> int:
+ # add_line возвращает ветку дерева статьи и ровно одну INPUT-строку — только что созданную.
+ # internal_order в data INPUT-строки не приходит (лежит во вложенных/скрытых полях),
+ # поэтому по маркеру строку не найти — marker оставлен для читаемости теста.
+ input_rows = [row for row in payload["result"] if row.get("row_type") == "INPUT"]
+ assert len(input_rows) == 1, f"ожидалась одна INPUT-строка для {marker!r}, получено {len(input_rows)}"
+ return input_rows[0]["data"]["line_id"]
+
+
+def test_forms_delete_line_forbidden_for_non_owner_executor(client, isp2_tokens, isp3_tokens, auth_headers):
+ # AURORA-1936: исполнитель не может удалить чужую строку.
+ # Ошибка БД откатывает общую транзакцию тестового клиента (см. conftest), поэтому
+ # «свою строку удалить можно» проверяется отдельным тестом ниже.
+ marker = "aurora-1936-owner-guard"
+ add_response = client.post(
+ "/api/v1/form/1/sheet/AHR/line?direction=Support§ions=plan,contract_summary",
+ json={"expense_item_id": 120, "direction": "Support", "internal_order": marker},
+ headers=auth_headers(isp2_tokens),
+ )
+ assert add_response.status_code == 200, add_response.text
+ line_id = _added_line_id(add_response.json(), marker)
+
+ forbidden_response = client.delete(
+ f"/api/v1/form/1/sheet/AHR/line/{line_id}?direction=Support",
+ headers=auth_headers(isp3_tokens),
+ )
+ assert forbidden_response.status_code == 403
+
+
+def test_forms_delete_line_allowed_for_owner_executor(client, isp2_tokens, auth_headers):
+ marker = "aurora-1936-own-delete"
+ add_response = client.post(
+ "/api/v1/form/1/sheet/AHR/line?direction=Support§ions=plan,contract_summary",
+ json={"expense_item_id": 120, "direction": "Support", "internal_order": marker},
+ headers=auth_headers(isp2_tokens),
+ )
+ assert add_response.status_code == 200, add_response.text
+ line_id = _added_line_id(add_response.json(), marker)
+
+ own_delete_response = client.delete(
+ f"/api/v1/form/1/sheet/AHR/line/{line_id}?direction=Support",
+ headers=auth_headers(isp2_tokens),
+ )
+ assert own_delete_response.status_code == 200, own_delete_response.text
+
+
+def test_forms_delete_line_allowed_for_admin_regardless_of_owner(client, isp2_tokens, admin_tokens, auth_headers):
+ marker = "aurora-1936-admin-bypass"
+ add_response = client.post(
+ "/api/v1/form/1/sheet/AHR/line?direction=Support§ions=plan,contract_summary",
+ json={"expense_item_id": 120, "direction": "Support", "internal_order": marker},
+ headers=auth_headers(isp2_tokens),
+ )
+ assert add_response.status_code == 200, add_response.text
+ line_id = _added_line_id(add_response.json(), marker)
+
+ response = client.delete(
+ f"/api/v1/form/1/sheet/AHR/line/{line_id}?direction=Support",
+ headers=auth_headers(admin_tokens),
+ )
+ assert response.status_code == 200
+
+
def test_forms_add_form_success(client, admin_tokens, auth_headers):
response = client.post(
"/api/v1/form/",
@@ -232,3 +295,21 @@ def test_forms_add_form_forbidden_not_admin(client, isp_tokens, auth_headers):
payload = response.json()
assert isinstance(payload, dict)
assert "message" in payload
+
+
+def test_forms_delete_legacy_null_author_line_forbidden_for_executor(client, isp2_tokens, auth_headers):
+ # AURORA-1936 (R-4): строка без автора (created_by IS NULL, legacy: fixture budget_line id=1) —
+ # исполнитель удалить не может.
+ response = client.delete(
+ "/api/v1/form/1/sheet/AHR/line/1?direction=Support",
+ headers=auth_headers(isp2_tokens),
+ )
+ assert response.status_code == 403, response.text
+
+
+def test_forms_delete_legacy_null_author_line_allowed_for_admin(client, admin_tokens, auth_headers):
+ response = client.delete(
+ "/api/v1/form/1/sheet/AHR/line/1?direction=Support",
+ headers=auth_headers(admin_tokens),
+ )
+ assert response.status_code == 200, response.text
diff --git a/api/tests/sqls/fixture.sql b/api/tests/sqls/fixture.sql
index e9652c7..eeb6e92 100644
--- a/api/tests/sqls/fixture.sql
+++ b/api/tests/sqls/fixture.sql
@@ -1,7 +1,9 @@
INSERT INTO v3.app_user (id,email,username,hashed_password,full_name,role_id,is_active,created_at,updated_at) VALUES
(1,'admin@rshb.ru','admin','{admin_password}','admin',1,true,'2026-05-20 11:09:16.987331+03',NULL),
- (2,'ispolnitel@rshb.ru','isp1','{isp_password}','Роль Исполнитель Первый',2,true,'2026-05-20 11:00:51.811967+03','2026-06-17 11:51:01.123054+03');
-SELECT setval('v3.app_user_id_seq', 2);
+ (2,'ispolnitel@rshb.ru','isp1','{isp_password}','Роль Исполнитель Первый',2,true,'2026-05-20 11:00:51.811967+03','2026-06-17 11:51:01.123054+03'),
+ (3,'ispolnitel2@rshb.ru','isp2','{isp_password}','Роль Исполнитель Второй',2,true,'2026-05-20 11:00:51.811967+03','2026-06-17 11:51:01.123054+03'),
+ (4,'ispolnitel3@rshb.ru','isp3','{isp_password}','Роль Исполнитель Третий',2,true,'2026-05-20 11:00:51.811967+03','2026-06-17 11:51:01.123054+03');
+SELECT setval('v3.app_user_id_seq', 4);
INSERT INTO v3.org_unit (id,title,is_active,is_ssp,utc_offset) VALUES
(1,'Test_SSP',true,true,'+03'),
@@ -11,8 +13,10 @@ SELECT setval('v3.org_unit_id_seq', 2);
INSERT INTO v3.user_org (id,user_id,org_unit_id) VALUES
(1,1,2),
(2,1,1),
- (3,2,2);
-SELECT setval('v3.user_org_id_seq', 3);
+ (3,2,2),
+ (4,3,1),
+ (5,4,1);
+SELECT setval('v3.user_org_id_seq', 5);
INSERT INTO v3.budget_form (id,form_type_code,"year",created_by,created_at,updated_by,updated_at,org_unit_id) VALUES
(1,'FORM_1',2027,NULL,'2026-05-06 16:57:38.066371',NULL,'2026-06-11 14:35:53.783017',1),
@@ -24,8 +28,8 @@ INSERT INTO v3.budget_line (id,budget_form_id,expense_item_id,"name",internal_or
SELECT setval('v3.budget_line_id_seq', 1);
INSERT INTO v3.form_phase (budget_form_id,sheet,phase_code,"role",column_keys,opens_at,closes_at,direction) VALUES
- (1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2026-06-06 03:00:00+03','Support'),
- (1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2026-06-06 03:00:00+03','Development'),
+ (1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2099-06-06 03:00:00+03','Support'),
+ (1,'AHR','test','DFIP','{{plan.q1}}','2026-05-05 03:00:00+03','2099-06-06 03:00:00+03','Development'),
(4,'AHR','test','DFIP','{{plan.q1}}','2029-05-05 03:00:00+05','2029-06-06 03:00:00+05',NULL);
INSERT INTO v3.vsp (id,branch_id,reg_number,address,format,opened_at,placement_type,staff_count,total_area,closed_at,is_active,updated_at,is_deleted,created_at,created_by,system_code,updated_by,vsp_type,notes,rent_contract_num,rent_end_date) VALUES
diff --git a/web/src/components/RealtimeTable/Modals/ConfirmDeleteRowModal.jsx b/web/src/components/RealtimeTable/Modals/ConfirmDeleteRowModal.jsx
new file mode 100644
index 0000000..6d712db
--- /dev/null
+++ b/web/src/components/RealtimeTable/Modals/ConfirmDeleteRowModal.jsx
@@ -0,0 +1,19 @@
+import { Alert } from '@mui/material';
+import { DangerOutlinedButton, SecondaryButton } from '../../common/Buttons/Buttons';
+import Modal from '../../common/Modal/Modal';
+
+export const ConfirmDeleteRowModal = ({ isOpen, onClose, onConfirm }) => (
+
+ Отмена
+ Удалить
+ >
+ }>
+ Удалить выбранную строку? Данные строки будут потеряны.
+
+);
diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx
index 9300ae3..b45871d 100644
--- a/web/src/components/RealtimeTable/RealtimeTable.jsx
+++ b/web/src/components/RealtimeTable/RealtimeTable.jsx
@@ -12,6 +12,7 @@ import { getTableColumns } from './tableColumns';
import { CircularProgress } from '@mui/material';
import { debounce } from '@mui/material';
import { toast } from 'react-toastify';
+import { ConfirmDeleteRowModal } from './Modals/ConfirmDeleteRowModal';
import { SheetModals } from './Modals/SheetModals';
import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig';
import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, TABLE_CONTAINER_SX, getTablePaperStyles, getTableRowHeightPx } from './constants/tableConfig';
@@ -120,6 +121,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const [expanded, setExpanded] = useState(true);
const [expenseItemsVisibilityAction, setExpenseItemsVisibilityAction] = useState('hide');
+ const [rowIdPendingDelete, setRowIdPendingDelete] = useState(null);
const modalsRef = useRef(null);
const isFormNoCanAddRow = useMemo(() => FormsNoCanAddRow[formType]?.includes(sheetName) ?? false, [formType, sheetName]);
@@ -930,9 +932,17 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
toast.error('Выделите строку для удаления');
return;
}
- contextDeleteRow(getRowId(row));
+ setRowIdPendingDelete(getRowId(row));
+ }, [canModifyRows, getSelectedRow]);
+
+ const closeConfirmDeleteRow = useCallback(() => setRowIdPendingDelete(null), []);
+
+ const confirmDeleteRow = useCallback(() => {
+ if (rowIdPendingDelete == null) return;
+ contextDeleteRow(rowIdPendingDelete);
+ setRowIdPendingDelete(null);
setRowSelection({});
- }, [canModifyRows, contextDeleteRow, getSelectedRow]);
+ }, [contextDeleteRow, rowIdPendingDelete]);
const addRow = useCallback(() => {
if (!canModifyRows) {
@@ -1102,6 +1112,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
+