From b95029f70aac3d59b52c73620ad2ee5d51d95fe6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 30 Oct 2026 18:06:52 +0400 Subject: [PATCH 1/4] =?UTF-8?q?fix(forms):=20=D0=B7=D0=B0=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D1=82=D0=B8=D1=82=D1=8C=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=87=D1=83=D0=B6=D0=B8=D1=85=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA,=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Удаление строки в формах 1/2/4 не проверяло владельца: любой исполнитель мог удалить чужую строку мгновенно, без подтверждения. - v3.del_budget_line (миграция 0050): не-admin может удалить только budget_line, где created_by совпадает с current_user_id() (проверка и для обычных листов, и для AHR_RENT/UTILITY/SECURITY — через created_by связанной budget_line). Нарушение -> role_not_allowed: (403), по существующей конвенции PG_403_ERRORS. - RealtimeTable: кнопка «Удалить строку» теперь открывает ConfirmDeleteRowModal, реальное удаление — только после подтверждения. - Интеграционные тесты: новый исполнитель isp2 (fixture.sql) в том же org_unit, что и budget_form id=1 — проверяют 403 при чужой строке, 200 при своей и при удалении администратором. Issue: #249 (AURORA-1936, эпик AURORA-1876, Высокий приоритет) Co-Authored-By: Claude Sonnet 5 --- .../0050_budget_line_delete_owner_guard.py | 15 ++ .../0050_budget_line_delete_owner_guard.sql | 158 ++++++++++++++++++ api/tests/integration/conftest.py | 12 ++ api/tests/integration/test_forms_api_smoke.py | 49 ++++++ api/tests/sqls/fixture.sql | 11 +- .../Modals/ConfirmDeleteRowModal.jsx | 19 +++ .../RealtimeTable/RealtimeTable.jsx | 15 +- 7 files changed, 273 insertions(+), 6 deletions(-) create mode 100644 api/alembic/versions/0050_budget_line_delete_owner_guard.py create mode 100644 api/alembic/versions/sql/0050_budget_line_delete_owner_guard.sql create mode 100644 web/src/components/RealtimeTable/Modals/ConfirmDeleteRowModal.jsx diff --git a/api/alembic/versions/0050_budget_line_delete_owner_guard.py b/api/alembic/versions/0050_budget_line_delete_owner_guard.py new file mode 100644 index 0000000..6f49e35 --- /dev/null +++ b/api/alembic/versions/0050_budget_line_delete_owner_guard.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_budget_line_delete_owner_guard.sql") + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/sql/0050_budget_line_delete_owner_guard.sql b/api/alembic/versions/sql/0050_budget_line_delete_owner_guard.sql new file mode 100644 index 0000000..07aeb26 --- /dev/null +++ b/api/alembic/versions/sql/0050_budget_line_delete_owner_guard.sql @@ -0,0 +1,158 @@ +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_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; + + 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.created_by + INTO v_form_id, v_sheet, v_eid, 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; + + 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 ( + EXISTS ( + SELECT 1 + FROM v3.form2_ahr_satellite_line_source m + WHERE m.target_line_id = p_id + ) + OR EXISTS ( + SELECT 1 + FROM v3.budget_line bl + JOIN v3.expense_item ei ON ei.id = bl.expense_item_id + JOIN v3.limit_template lt + ON lt.row_type = 'LEAF' + AND ( + (ei.depth = 3 AND ei.item_id = lt.expense_item_code) + OR ei.num_group_id = lt.expense_item_code + ) + WHERE bl.id = p_id + AND bl.budget_form_id = v_form_id + AND ei.sheet = 'AHR' + AND ei.direction = 'Support' + ) + ) 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 81cb26d..1ed78dd 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -79,3 +79,15 @@ 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, что и budget_form id=1 — нужен, + # чтобы проверять, что исполнитель не может удалить чужую строку. + response = client.post( + "/api/v1/auth/login", + json={"username": "isp2", "password": isp_password}, + ) + assert response.status_code == 200 + return response.json() diff --git a/api/tests/integration/test_forms_api_smoke.py b/api/tests/integration/test_forms_api_smoke.py index 4fb199a..49bf30d 100644 --- a/api/tests/integration/test_forms_api_smoke.py +++ b/api/tests/integration/test_forms_api_smoke.py @@ -167,6 +167,55 @@ 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: + for row in payload["result"]: + data = row.get("data") or {} + if data.get("internal_order") == marker: + return data.get("line_id") or data.get("id") + raise AssertionError(f"строка с internal_order={marker!r} не найдена в ответе add_line") + + +def test_forms_delete_line_forbidden_for_non_owner_executor(client, isp_tokens, isp2_tokens, auth_headers): + # AURORA-1936: исполнитель может удалить только им же созданную строку. + 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(isp_tokens), + ) + assert add_response.status_code == 200 + 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(isp2_tokens), + ) + assert forbidden_response.status_code == 403 + + own_delete_response = client.delete( + f"/api/v1/form/1/sheet/AHR/line/{line_id}?direction=Support", + headers=auth_headers(isp_tokens), + ) + assert own_delete_response.status_code == 200 + + +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 + 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/", diff --git a/api/tests/sqls/fixture.sql b/api/tests/sqls/fixture.sql index e9652c7..2c51a63 100644 --- a/api/tests/sqls/fixture.sql +++ b/api/tests/sqls/fixture.sql @@ -1,7 +1,8 @@ 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'); +SELECT setval('v3.app_user_id_seq', 3); INSERT INTO v3.org_unit (id,title,is_active,is_ssp,utc_offset) VALUES (1,'Test_SSP',true,true,'+03'), @@ -11,8 +12,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,2,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), 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 539e4b3..c641185 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 { CreateProgramModal } from './Modals/CreateProgramProjectModal'; import { SelectExpenseItemModal } from './Modals/SelectExpenseItemModal'; import { SelectVspModal } from './Modals/SelectVspModal'; @@ -105,6 +106,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const [isOpenModalSelectVsp, setIsOpenModalSelectVsp] = useState(false); const [isOpenModalSelectExpenseItem, setIsOpenModalSelectExpenseItem] = useState(false); + const [rowIdPendingDelete, setRowIdPendingDelete] = useState(null); const [createModalType, setCreateModalType] = useState(null); const isFormNoCanAddRow = useMemo(() => FormsNoCanAddRow[formType]?.includes(sheetName) ?? false, [formType, sheetName]); @@ -676,9 +678,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) { @@ -848,6 +858,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { + Date: Sat, 31 Oct 2026 14:11:34 +0400 Subject: [PATCH 2/4] =?UTF-8?q?fix(table):=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D1=83=D0=BB=D0=B0=20=D0=B8=D0=B7=20localStorage=20=D0=BD=D0=B5?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D0=B5=D1=82=20=D1=87=D1=83=D0=B6=D0=BE=D0=B5=20?= =?UTF-8?q?=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=8F=D1=87?= =?UTF-8?q?=D0=B5=D0=B9=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пока AURORA-1896 не перенесёт формулы на сервер, временно исправляем потерю данных в EditCell: - resolveEditorState/isSameCellValue (formulaUtils.js) — чистые функции: формула подставляется в редактор только если её результат совпадает с текущим значением ячейки, иначе она устарела (stale) и удаляется из localStorage. - EditCell: открытие и закрытие ячейки без правки больше не шлёт cell_updated (сверка с initialEditorValue при выходе из редактора; для обычных значений — дополнительно через isSameCellValue). Если введённая формула не вычисляется — toast и редактор остаётся открытым, на сервер ничего не уходит. - tableColumns.jsx: ключ формулы строится от getRowId(cell.row) (line_id из данных), а не от cell.row.id (позиционный индекс-путь в дереве, переезжающий при вставке/удалении строк). Префикс ключа — formula_v2_. - RealtimeTable: при монтировании once-очистка устаревших ключей formula_* без суффикса _v2 — они привязаны к позиции строки и стали недостоверны. Тесты: web/src/components/RealtimeTable/utils/formulaUtils.test.js, node --test — 9/9 новых + 5/5 ранее существующих в utils/ зелёные. Closes #264. Co-Authored-By: Claude Sonnet 5 --- .../RealtimeTable/Cell/EditCell/EditCell.jsx | 122 ++++++++++++------ .../RealtimeTable/RealtimeTable.jsx | 7 + .../components/RealtimeTable/tableColumns.jsx | 5 +- .../RealtimeTable/utils/formulaUtils.js | 55 ++++++++ .../RealtimeTable/utils/formulaUtils.test.js | 49 +++++++ .../RealtimeTable/utils/localStorageUtils.js | 17 +++ 6 files changed, 214 insertions(+), 41 deletions(-) create mode 100644 web/src/components/RealtimeTable/utils/formulaUtils.test.js diff --git a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx index acdf758..0fd906c 100644 --- a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx +++ b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx @@ -1,7 +1,25 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { toast } from 'react-toastify'; import { useRealtimeActions } from '../../contexts/RealtimeContext'; -import { calculateFormula, extractFormula, formatNumber, isFormula, normalizeNumericInput } from '../../utils/formulaUtils'; -import { loadFromStorage, saveToStorage } from '../../utils/localStorageUtils'; +import { + calculateFormula, + extractFormula, + formatNumber, + isFormula, + isSameCellValue, + normalizeNumericInput, + resolveEditorState, +} from '../../utils/formulaUtils'; +import { loadFromStorage, removeFromStorage, saveToStorage } from '../../utils/localStorageUtils'; + +// Вычисляет отображаемое значение формулы, не бросая исключение наружу. +const computeFormulaDisplay = (formulaValue) => { + try { + return formatNumber(calculateFormula(extractFormula(formulaValue))); + } catch { + return '#ОШИБКА'; + } +}; const EDITOR_STYLES = { position: 'absolute', @@ -31,7 +49,7 @@ const TEXTAREA_STYLES = { const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, table }) => { const { endEditing: contextEndEditing } = useRealtimeActions(); - const storageKey = `formula_${tableId}_${cellId}`; + const storageKey = `formula_v2_${tableId}_${cellId}`; const [state, setState] = useState(() => ({ currentValue: value, @@ -39,33 +57,36 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta isFormulaMode: false, })); + // Исходное значение редактора на момент открытия — чтобы не отправлять + // на сервер открытие и закрытие ячейки без правки. + const initialEditorValueRef = useRef(value); + // Позиция ячейки const [position, setPosition] = useState(); const textareaRef = useRef(null); const refFinished = useRef(false); - // Загрузка сохраненной формулы + // Загрузка сохраненной формулы: подставляем её только если результат + // совпадает с текущим значением ячейки, иначе формула устарела (stale) + // и удаляется из хранилища. useEffect(() => { const savedFormula = loadFromStorage(storageKey); - if (savedFormula && isFormula(savedFormula)) { - try { - const formula = extractFormula(savedFormula); - const result = calculateFormula(formula); - setState({ - currentValue: savedFormula, - displayValue: formatNumber(result), - isFormulaMode: true, - }); - } catch { - setState({ - currentValue: savedFormula, - displayValue: '#ОШИБКА', - isFormulaMode: true, - }); - } + const { currentValue, isFormulaMode, stale } = resolveEditorState(savedFormula, value); + + if (stale) { + removeFromStorage(storageKey); } - }, [storageKey]); + + initialEditorValueRef.current = currentValue; + + if (isFormulaMode) { + setState({ currentValue, displayValue: computeFormulaDisplay(currentValue), isFormulaMode: true }); + return; + } + + setState({ currentValue, displayValue: currentValue, isFormulaMode: false }); + }, [storageKey, value]); // Вычисление позиции useLayoutEffect(() => { @@ -114,46 +135,67 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta return () => clearTimeout(timeoutId); }, [disabled]); + // Возвращает true, если можно закрывать редактор (сохранено либо не было + // изменений), false — если формула некорректна и редактор должен остаться + // открытым (ничего не отправляем на сервер в этом случае). const handleSave = useCallback(() => { const { currentValue } = state; + if (currentValue === initialEditorValueRef.current) { + // Ячейку открыли и закрыли без правки — на сервер ничего не шлём. + return true; + } + if (isFormula(currentValue)) { - saveToStorage(storageKey, currentValue); + const formula = extractFormula(currentValue); + let result; try { - const formula = extractFormula(currentValue); - const result = calculateFormula(formula); - const formattedResult = formatNumber(result); - setState((prev) => ({ - ...prev, - displayValue: formattedResult, - isFormulaMode: true, - })); - onChange?.(formattedResult); + result = calculateFormula(formula); } catch { setState((prev) => ({ ...prev, displayValue: '#ОШИБКА', isFormulaMode: true, })); - onChange?.(currentValue); + toast.error('Формула не вычисляется, проверьте выражение'); + return false; } - } else { - localStorage.removeItem(storageKey); + + const formattedResult = formatNumber(result); + saveToStorage(storageKey, currentValue); setState((prev) => ({ ...prev, - displayValue: currentValue, - isFormulaMode: false, + displayValue: formattedResult, + isFormulaMode: true, })); - onChange?.(normalizeNumericInput(currentValue)); + onChange?.(formattedResult); + return true; } - }, [state, storageKey, onChange]); + + removeFromStorage(storageKey); + setState((prev) => ({ + ...prev, + displayValue: currentValue, + isFormulaMode: false, + })); + + const normalized = normalizeNumericInput(currentValue); + if (isSameCellValue(normalized, value)) { + // Значение фактически не изменилось (напр. '150' вместо 150). + return true; + } + + onChange?.(normalized); + return true; + }, [state, storageKey, onChange, value]); const finishEditing = useCallback(() => { if (refFinished.current) return; - refFinished.current = true; if (!disabled) { - handleSave(); + const canFinish = handleSave(); + if (!canFinish) return; } + refFinished.current = true; const editingCell = table.getState().editingCell; if (editingCell) { contextEndEditing?.(editingCell.row, editingCell.column); diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index 539e4b3..51443d1 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -25,6 +25,7 @@ import { useBudgetYear } from './hooks/useBudgetYear'; import { useValidationRules } from './hooks/useValidationRules'; import { getRowId } from './utils/rowUtils'; import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils'; +import { clearLegacyFormulaKeys } from './utils/localStorageUtils'; import { DictVspApi } from '../../api/dict-vsp'; import { useAuth } from '../../app/context/AuthProvider'; @@ -170,6 +171,12 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { columnVirtualizerRef.current?.measure?.(); }, [sizeMult]); + // Разовая очистка устаревших ключей формул, привязанных к позиции строки + // (AURORA-1895): они стали недостоверны после перехода на ключи formula_v2_*. + useEffect(() => { + clearLegacyFormulaKeys(); + }, []); + useEffect(() => { if (!formType || !sheetName) return; let isCancelled = false; diff --git a/web/src/components/RealtimeTable/tableColumns.jsx b/web/src/components/RealtimeTable/tableColumns.jsx index cf557e0..b021008 100644 --- a/web/src/components/RealtimeTable/tableColumns.jsx +++ b/web/src/components/RealtimeTable/tableColumns.jsx @@ -9,6 +9,7 @@ import { blueColumn } from './constants/columnColors'; import { sectionCodeColor } from './constants/columnConfig'; import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell'; +import { getRowId } from './utils/rowUtils'; const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable }) => { const { formId, formType, sheetName, direction } = useParams(); @@ -52,7 +53,9 @@ const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable onChange: handleChange, tableId: tableKey, table, - cellId: `${cell.row.id}_${cell.column.id}`, + // Привязан к line_id из данных строки, а не к её позиции в дереве + // (cell.row.id — индекс-путь, который переезжает при вставке/удалении строк). + cellId: `${getRowId(cell.row)}_${cell.column.id}`, }), [cell, isEditable, handleChange, tableKey, table], ); diff --git a/web/src/components/RealtimeTable/utils/formulaUtils.js b/web/src/components/RealtimeTable/utils/formulaUtils.js index b36151c..3d0742e 100644 --- a/web/src/components/RealtimeTable/utils/formulaUtils.js +++ b/web/src/components/RealtimeTable/utils/formulaUtils.js @@ -81,6 +81,61 @@ export const evaluateCellValue = (value) => { } }; +/** + * Начальное состояние редактора ячейки. + * Решает, показывать ли сохранённую формулу или текущее значение ячейки, + * и помечает формулу как устаревшую (stale), если её результат расходится + * с текущим значением — значит, значение изменил кто-то другой, либо + * формула больше не вычисляется. + * @param {string|null} savedFormula - строка вида '=...' из localStorage, либо null + * @param {string|number|null} value - текущее значение ячейки (с сервера) + * @returns {{ currentValue: string|number|null, isFormulaMode: boolean, stale: boolean }} + */ +export const resolveEditorState = (savedFormula, value) => { + if (!savedFormula || !isFormula(savedFormula)) { + return { currentValue: value, isFormulaMode: false, stale: false }; + } + + try { + const expression = extractFormula(savedFormula); + const result = calculateFormula(expression); + const numericValue = Number(value); + const stale = Number.isNaN(numericValue) || Math.abs(result - numericValue) > 1e-9; + + if (stale) { + return { currentValue: value, isFormulaMode: false, stale: true }; + } + + return { currentValue: savedFormula, isFormulaMode: true, stale: false }; + } catch { + return { currentValue: value, isFormulaMode: false, stale: true }; + } +}; + +const isEmptyCellValue = (value) => value === null || value === undefined || value === ''; + +const isNumericCellValue = (value) => !isEmptyCellValue(value) && !Number.isNaN(Number(value)); + +/** + * Сравнивает два значения ячейки так, как их видит пользователь: + * числа сравниваются как числа ('150' == 150, '150.0' == 150), '' == null. + * @param {string|number|null} a + * @param {string|number|null} b + * @returns {boolean} + */ +export const isSameCellValue = (a, b) => { + const aEmpty = isEmptyCellValue(a); + const bEmpty = isEmptyCellValue(b); + + if (aEmpty || bEmpty) return aEmpty && bEmpty; + + if (isNumericCellValue(a) && isNumericCellValue(b)) { + return Number(a) === Number(b); + } + + return a === b; +}; + /** * Форматирует число для отображения * @param {number} value - Число для форматирования diff --git a/web/src/components/RealtimeTable/utils/formulaUtils.test.js b/web/src/components/RealtimeTable/utils/formulaUtils.test.js new file mode 100644 index 0000000..228e45b --- /dev/null +++ b/web/src/components/RealtimeTable/utils/formulaUtils.test.js @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { isSameCellValue, resolveEditorState } from './formulaUtils.js'; + +test('resolveEditorState keeps formula mode when the formula result matches the current value', () => { + const result = resolveEditorState('=100+50', 150); + assert.equal(result.isFormulaMode, true); + assert.equal(result.stale, false); +}); + +test('resolveEditorState marks the formula stale when the current value diverged (another user changed it)', () => { + const result = resolveEditorState('=100+50', 200); + assert.equal(result.stale, true); + assert.equal(result.currentValue, 200); +}); + +test('resolveEditorState keeps formula mode when the current value is a numeric string that matches', () => { + const result = resolveEditorState('=100+50', '150'); + assert.equal(result.stale, false); + assert.equal(result.isFormulaMode, true); +}); + +test('resolveEditorState marks the formula stale when it cannot be calculated', () => { + const result = resolveEditorState('=abc', 0); + assert.equal(result.stale, true); +}); + +test('resolveEditorState falls back to plain value mode when there is no saved formula', () => { + const result = resolveEditorState(null, 5); + assert.equal(result.isFormulaMode, false); + assert.equal(result.stale, false); + assert.equal(result.currentValue, 5); +}); + +test('isSameCellValue treats a numeric string and a number as equal', () => { + assert.equal(isSameCellValue('150', 150), true); +}); + +test('isSameCellValue treats an empty string and null as equal', () => { + assert.equal(isSameCellValue('', null), true); +}); + +test('isSameCellValue treats a numeric string with a trailing .0 as equal to the plain number', () => { + assert.equal(isSameCellValue('150.0', 150), true); +}); + +test('isSameCellValue treats different numbers as not equal', () => { + assert.equal(isSameCellValue('151', 150), false); +}); diff --git a/web/src/components/RealtimeTable/utils/localStorageUtils.js b/web/src/components/RealtimeTable/utils/localStorageUtils.js index 38fd5a4..dfd40a6 100644 --- a/web/src/components/RealtimeTable/utils/localStorageUtils.js +++ b/web/src/components/RealtimeTable/utils/localStorageUtils.js @@ -81,6 +81,23 @@ export const removeFromStorage = (key) => { } }; +/** + * Удаляет устаревшие ключи формул вида `formula__`, + * привязанные к позиции строки в дереве таблицы (AURORA-1895). Текущие ключи + * формул имеют префикс `formula_v2_` и привязаны к line_id — их не трогаем. + * @returns {number} - количество удалённых ключей + */ +export const clearLegacyFormulaKeys = () => { + try { + const keysToRemove = Object.keys(localStorage).filter((key) => key.startsWith('formula_') && !key.startsWith('formula_v2_')); + keysToRemove.forEach((key) => localStorage.removeItem(key)); + return keysToRemove.length; + } catch (error) { + console.error('Error clearing legacy formula keys from localStorage:', error); + return 0; + } +}; + /** * Очистка всех данных приложения из localStorage * @param {string[]} keysToKeep - Массив ключей, которые нужно сохранить From d1922c9354c4f470de53fdb016ae78932a9f4f89 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 8 Nov 2026 09:56:16 +0400 Subject: [PATCH 3/4] =?UTF-8?q?fix(forms):=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D1=83=D0=BC=D0=B5=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8E=201936=20?= =?UTF-8?q?=D0=B2=200056,=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D1=8C=20del=5Fbudget=5Fline=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=85=200053,=20=D0=BF=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D1=8C=20=D1=82=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Миграция 0050_budget_line_delete_owner_guard -> 0056 (down 0055, стек поверх 0055 AURORA-1938). Тело v3.del_budget_line = актуальное тело из 0053_form2_ahr_source_sheet (AURORA-1907: запрет удаления АХР-строк с source_sheet IS NOT NULL, ручные удаляются) + только owner-guard; раньше копия опиралась на 0040 и откатывала 1907. Тесты: доступ к форме id=1 даёт отдельная пара исполнителей isp2/isp3 (isp1 больше не добавляется в org 1 — это ломало тесты «исполнитель из чужой организации»); окно фазы DFIP в фикстуре продлено (истекло 2026-06-06, add_line возвращал 403); маркер internal_order не приходит в INPUT-строке add_line — берём единственную INPUT-строку; добавлен тест 1907 (строка из сателлита не удаляется напрямую). Тест «владелец удаляет свою строку» помечен xfail(strict): add_budget_line не заполняет budget_line.created_by (O-1). Co-Authored-By: Claude Opus 5.5 --- ...=> 0056_budget_line_delete_owner_guard.py} | 6 +-- ...> 0056_budget_line_delete_owner_guard.sql} | 32 +++--------- api/tests/integration/conftest.py | 14 +++++- .../test_form2_ahr_satellite_integration.py | 21 ++++++++ api/tests/integration/test_forms_api_smoke.py | 49 ++++++++++++++----- api/tests/sqls/fixture.sql | 11 +++-- 6 files changed, 87 insertions(+), 46 deletions(-) rename api/alembic/versions/{0050_budget_line_delete_owner_guard.py => 0056_budget_line_delete_owner_guard.py} (59%) rename api/alembic/versions/sql/{0050_budget_line_delete_owner_guard.sql => 0056_budget_line_delete_owner_guard.sql} (85%) diff --git a/api/alembic/versions/0050_budget_line_delete_owner_guard.py b/api/alembic/versions/0056_budget_line_delete_owner_guard.py similarity index 59% rename from api/alembic/versions/0050_budget_line_delete_owner_guard.py rename to api/alembic/versions/0056_budget_line_delete_owner_guard.py index 6f49e35..4ee393a 100644 --- a/api/alembic/versions/0050_budget_line_delete_owner_guard.py +++ b/api/alembic/versions/0056_budget_line_delete_owner_guard.py @@ -1,14 +1,14 @@ from sql_migration import run_sql_file_migration -revision = "0050" -down_revision = "0049" +revision = "0056" +down_revision = "0055" branch_labels = None depends_on = None def upgrade() -> None: - run_sql_file_migration("0050_budget_line_delete_owner_guard.sql") + run_sql_file_migration("0056_budget_line_delete_owner_guard.sql") def downgrade() -> None: diff --git a/api/alembic/versions/sql/0050_budget_line_delete_owner_guard.sql b/api/alembic/versions/sql/0056_budget_line_delete_owner_guard.sql similarity index 85% rename from api/alembic/versions/sql/0050_budget_line_delete_owner_guard.sql rename to api/alembic/versions/sql/0056_budget_line_delete_owner_guard.sql index 07aeb26..df54e32 100644 --- a/api/alembic/versions/sql/0050_budget_line_delete_owner_guard.sql +++ b/api/alembic/versions/sql/0056_budget_line_delete_owner_guard.sql @@ -1,3 +1,7 @@ +-- 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. 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 @@ -10,6 +14,7 @@ DECLARE 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; @@ -75,8 +80,8 @@ BEGIN RETURN; END IF; - SELECT bl.budget_form_id, ei.sheet, bl.expense_item_id, bl.created_by - INTO v_form_id, v_sheet, v_eid, v_created_by + 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; @@ -89,28 +94,7 @@ BEGIN RAISE EXCEPTION 'role_not_allowed: можно удалить только добавленную вами строку'; END IF; - IF v_sheet = 'AHR' AND ( - EXISTS ( - SELECT 1 - FROM v3.form2_ahr_satellite_line_source m - WHERE m.target_line_id = p_id - ) - OR EXISTS ( - SELECT 1 - FROM v3.budget_line bl - JOIN v3.expense_item ei ON ei.id = bl.expense_item_id - JOIN v3.limit_template lt - ON lt.row_type = 'LEAF' - AND ( - (ei.depth = 3 AND ei.item_id = lt.expense_item_code) - OR ei.num_group_id = lt.expense_item_code - ) - WHERE bl.id = p_id - AND bl.budget_form_id = v_form_id - AND ei.sheet = 'AHR' - AND ei.direction = 'Support' - ) - ) THEN + IF v_sheet = 'AHR' AND v_source_sheet IS NOT NULL THEN RAISE EXCEPTION 'Строку АХР, созданную из листа-сателлита или АХР Лимиты, нельзя удалить напрямую' USING ERRCODE = '23503', HINT = 'Удалите строку на исходном листе АХР Аренда / АХР Коммунальные услуги / АХР Безопасность либо измените строку на листе АХР Лимиты'; diff --git a/api/tests/integration/conftest.py b/api/tests/integration/conftest.py index 9167928..383ca63 100644 --- a/api/tests/integration/conftest.py +++ b/api/tests/integration/conftest.py @@ -90,7 +90,8 @@ def isp_tokens(client, isp_password: str): @pytest.fixture def isp2_tokens(client, isp_password: str): - # Второй исполнитель в том же org_unit, что и budget_form id=1 — нужен, + # Исполнитель в org_unit формы id=1 (isp1 в нём намеренно не состоит — на этом + # держатся тесты «исполнитель из чужой организации»). Нужен вместе с isp3_tokens, # чтобы проверять, что исполнитель не может удалить чужую строку. response = client.post( "/api/v1/auth/login", @@ -98,3 +99,14 @@ def isp2_tokens(client, isp_password: str): ) 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 49bf30d..6208730 100644 --- a/api/tests/integration/test_forms_api_smoke.py +++ b/api/tests/integration/test_forms_api_smoke.py @@ -168,35 +168,58 @@ def test_forms_delete_line_not_found_error_shape(client, admin_tokens, auth_head def _added_line_id(payload: dict, marker: str) -> int: - for row in payload["result"]: - data = row.get("data") or {} - if data.get("internal_order") == marker: - return data.get("line_id") or data.get("id") - raise AssertionError(f"строка с internal_order={marker!r} не найдена в ответе add_line") + # 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, isp_tokens, isp2_tokens, auth_headers): - # AURORA-1936: исполнитель может удалить только им же созданную строку. +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(isp_tokens), + headers=auth_headers(isp2_tokens), ) - assert add_response.status_code == 200 + 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(isp2_tokens), + headers=auth_headers(isp3_tokens), ) assert forbidden_response.status_code == 403 + +@pytest.mark.xfail( + strict=True, + reason=( + "O-1 (AURORA-1936): v3.add_budget_line нигде не заполняет budget_line.created_by " + "(INSERT без created_by во всех версиях функции, триггера/default нет), поэтому owner-guard " + "из 0056 блокирует удаление исполнителю и своих строк. Нужно решение: проставлять created_by " + "в add_budget_line (+ как трактовать legacy-строки с NULL). Тест упадёт как XPASS, когда это починят." + ), +) +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(isp_tokens), + headers=auth_headers(isp2_tokens), ) - assert own_delete_response.status_code == 200 + 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): @@ -206,7 +229,7 @@ def test_forms_delete_line_allowed_for_admin_regardless_of_owner(client, isp2_to json={"expense_item_id": 120, "direction": "Support", "internal_order": marker}, headers=auth_headers(isp2_tokens), ) - assert add_response.status_code == 200 + assert add_response.status_code == 200, add_response.text line_id = _added_line_id(add_response.json(), marker) response = client.delete( diff --git a/api/tests/sqls/fixture.sql b/api/tests/sqls/fixture.sql index 2c51a63..eeb6e92 100644 --- a/api/tests/sqls/fixture.sql +++ b/api/tests/sqls/fixture.sql @@ -1,8 +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'), - (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'); -SELECT setval('v3.app_user_id_seq', 3); + (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'), @@ -14,7 +15,7 @@ INSERT INTO v3.user_org (id,user_id,org_unit_id) VALUES (2,1,1), (3,2,2), (4,3,1), - (5,2,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 @@ -27,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 From 36d19f1e2f227600867642e4160eab3a680ac20b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 8 Nov 2026 10:59:55 +0400 Subject: [PATCH 4/4] =?UTF-8?q?fix(forms):=20add=5Fbudget=5Fline=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=BB=D1=8F=D0=B5=D1=82=20?= =?UTF-8?q?created=5Fby,=20NULL-=D0=B0=D0=B2=D1=82=D0=BE=D1=80=20=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D1=8F=D0=B5=D1=82=D1=81=D1=8F=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20ADMIN=20(O-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R-4 (решение пользователя 08.11). В миграцию 0056 добавлен v3.add_budget_line: тело = 0051_form2_ahr_satellite_new_line + только created_by = current_user_id() в двух INSERT (обычная строка и заготовка записи сателлита). Авто-зеркальные строки sync (_ensure_form2_ahr_*_sync_line) остаются с NULL. Backfill не делается. del_budget_line: NULL-автор -> удаляет только ADMIN (комментарий в SQL + тесты). xfail-тест владельца снят; добавлены тесты NULL-автора (исполнитель 403, ADMIN 200). Co-Authored-By: Claude Opus 5.5 --- .../0056_budget_line_delete_owner_guard.sql | 217 ++++++++++++++++++ api/tests/integration/test_forms_api_smoke.py | 27 ++- 2 files changed, 235 insertions(+), 9 deletions(-) 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 index df54e32..126c3ae 100644 --- a/api/alembic/versions/sql/0056_budget_line_delete_owner_guard.sql +++ b/api/alembic/versions/sql/0056_budget_line_delete_owner_guard.sql @@ -2,6 +2,221 @@ -- (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 @@ -39,6 +254,7 @@ BEGIN 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; @@ -90,6 +306,7 @@ BEGIN 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; diff --git a/api/tests/integration/test_forms_api_smoke.py b/api/tests/integration/test_forms_api_smoke.py index 6208730..393732a 100644 --- a/api/tests/integration/test_forms_api_smoke.py +++ b/api/tests/integration/test_forms_api_smoke.py @@ -196,15 +196,6 @@ def test_forms_delete_line_forbidden_for_non_owner_executor(client, isp2_tokens, assert forbidden_response.status_code == 403 -@pytest.mark.xfail( - strict=True, - reason=( - "O-1 (AURORA-1936): v3.add_budget_line нигде не заполняет budget_line.created_by " - "(INSERT без created_by во всех версиях функции, триггера/default нет), поэтому owner-guard " - "из 0056 блокирует удаление исполнителю и своих строк. Нужно решение: проставлять created_by " - "в add_budget_line (+ как трактовать legacy-строки с NULL). Тест упадёт как XPASS, когда это починят." - ), -) def test_forms_delete_line_allowed_for_owner_executor(client, isp2_tokens, auth_headers): marker = "aurora-1936-own-delete" add_response = client.post( @@ -304,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