Merge remote-tracking branch 'origin/test' into feat/210-aurora-1835-svod1-formatter
This commit is contained in:
commit
e5edcab01e
15
api/alembic/versions/0056_budget_line_delete_owner_guard.py
Normal file
15
api/alembic/versions/0056_budget_line_delete_owner_guard.py
Normal file
@ -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
|
||||||
359
api/alembic/versions/sql/0056_budget_line_delete_owner_guard.sql
Normal file
359
api/alembic/versions/sql/0056_budget_line_delete_owner_guard.sql
Normal file
@ -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$;
|
||||||
@ -86,3 +86,27 @@ def isp_tokens(client, isp_password: str):
|
|||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
return response.json()
|
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()
|
||||||
|
|||||||
@ -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"]["source"] == "MANUAL"
|
||||||
assert manual_row["data"]["header"]["can_add_row"] is True
|
assert manual_row["data"]["header"]["can_add_row"] is True
|
||||||
assert manual_row["data"]["plan"]["q1"] == 500
|
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
|
||||||
|
|||||||
@ -167,6 +167,69 @@ def test_forms_delete_line_not_found_error_shape(client, admin_tokens, auth_head
|
|||||||
assert "message" in payload
|
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):
|
def test_forms_add_form_success(client, admin_tokens, auth_headers):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/form/",
|
"/api/v1/form/",
|
||||||
@ -232,3 +295,21 @@ def test_forms_add_form_forbidden_not_admin(client, isp_tokens, auth_headers):
|
|||||||
payload = response.json()
|
payload = response.json()
|
||||||
assert isinstance(payload, dict)
|
assert isinstance(payload, dict)
|
||||||
assert "message" in payload
|
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
|
||||||
|
|||||||
@ -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
|
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),
|
(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');
|
(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);
|
(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
|
INSERT INTO v3.org_unit (id,title,is_active,is_ssp,utc_offset) VALUES
|
||||||
(1,'Test_SSP',true,true,'+03'),
|
(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
|
INSERT INTO v3.user_org (id,user_id,org_unit_id) VALUES
|
||||||
(1,1,2),
|
(1,1,2),
|
||||||
(2,1,1),
|
(2,1,1),
|
||||||
(3,2,2);
|
(3,2,2),
|
||||||
SELECT setval('v3.user_org_id_seq', 3);
|
(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
|
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),
|
(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);
|
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
|
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','2099-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','Development'),
|
||||||
(4,'AHR','test','DFIP','{{plan.q1}}','2029-05-05 03:00:00+05','2029-06-06 03:00:00+05',NULL);
|
(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
|
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
|
||||||
|
|||||||
@ -2,8 +2,25 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat
|
|||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { nonNegativeColumnKeys } from '../../constants/validation';
|
import { nonNegativeColumnKeys } from '../../constants/validation';
|
||||||
import { useRealtimeActions } from '../../contexts/RealtimeContext';
|
import { useRealtimeActions } from '../../contexts/RealtimeContext';
|
||||||
import { calculateFormula, extractFormula, formatNumber, isFormula, isSameCellValue, normalizeNumericInput } from '../../utils/formulaUtils';
|
import {
|
||||||
import { loadFromStorage, saveToStorage } from '../../utils/localStorageUtils';
|
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 = {
|
const EDITOR_STYLES = {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@ -33,7 +50,7 @@ const TEXTAREA_STYLES = {
|
|||||||
|
|
||||||
const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, table, cell }) => {
|
const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, table, cell }) => {
|
||||||
const { endEditing: contextEndEditing } = useRealtimeActions();
|
const { endEditing: contextEndEditing } = useRealtimeActions();
|
||||||
const storageKey = `formula_${tableId}_${cellId}`;
|
const storageKey = `formula_v2_${tableId}_${cellId}`;
|
||||||
|
|
||||||
// AURORA-1891: цифра/Backspace/Delete, нажатые на выделенной (не
|
// AURORA-1891: цифра/Backspace/Delete, нажатые на выделенной (не
|
||||||
// редактируемой пока) ячейке в RealtimeTable.jsx, открывают редактор и
|
// редактируемой пока) ячейке в RealtimeTable.jsx, открывают редактор и
|
||||||
@ -43,14 +60,27 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
|
|||||||
// применяется для getSelectedColumnId/getColumnTitle в этом файле.
|
// применяется для getSelectedColumnId/getColumnTitle в этом файле.
|
||||||
// Инициализатор useState вызывается один раз при монтировании — ровно
|
// Инициализатор useState вызывается один раз при монтировании — ровно
|
||||||
// тогда, когда этот редактор открылся именно так.
|
// тогда, когда этот редактор открылся именно так.
|
||||||
|
// hasInitialInputRef: редактор открыт набором символа на выделенной ячейке —
|
||||||
|
// пользователь заменяет значение с нуля, сохранённую формулу подставлять нельзя.
|
||||||
|
const hasInitialInputRef = useRef(false);
|
||||||
const [state, setState] = useState(() => {
|
const [state, setState] = useState(() => {
|
||||||
const initialInput = table?.options?.meta?.getEditorInitialInput?.();
|
const initialInput = table?.options?.meta?.getEditorInitialInput?.();
|
||||||
if (initialInput !== null && initialInput !== undefined) {
|
if (initialInput !== null && initialInput !== undefined) {
|
||||||
|
hasInitialInputRef.current = true;
|
||||||
return { currentValue: initialInput, displayValue: initialInput, isFormulaMode: false };
|
return { currentValue: initialInput, displayValue: initialInput, isFormulaMode: false };
|
||||||
}
|
}
|
||||||
return { currentValue: value, displayValue: value, isFormulaMode: false };
|
return { currentValue: value, displayValue: value, isFormulaMode: false };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Исходное значение редактора на момент открытия — чтобы не отправлять
|
||||||
|
// на сервер открытие и закрытие ячейки без правки (AURORA-1895).
|
||||||
|
const initialEditorValueRef = useRef(value);
|
||||||
|
// Актуальное значение ячейки для эффекта разрешения формулы: эффект
|
||||||
|
// срабатывает один раз при открытии и не должен затирать набранный текст,
|
||||||
|
// если другой пользователь изменит ячейку, пока она открыта.
|
||||||
|
const valueRef = useRef(value);
|
||||||
|
valueRef.current = value;
|
||||||
|
|
||||||
// Позиция ячейки
|
// Позиция ячейки
|
||||||
const [position, setPosition] = useState();
|
const [position, setPosition] = useState();
|
||||||
const textareaRef = useRef(null);
|
const textareaRef = useRef(null);
|
||||||
@ -61,25 +91,24 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
|
|||||||
|
|
||||||
const refFinished = useRef(false);
|
const refFinished = useRef(false);
|
||||||
|
|
||||||
// Загрузка сохраненной формулы
|
// Загрузка сохраненной формулы (AURORA-1895): подставляем её только если
|
||||||
|
// результат совпадает с текущим значением ячейки, иначе формула устарела
|
||||||
|
// (stale: значение изменил другой пользователь) и удаляется из хранилища.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (hasInitialInputRef.current) return;
|
||||||
|
|
||||||
|
const currentCellValue = valueRef.current;
|
||||||
const savedFormula = loadFromStorage(storageKey);
|
const savedFormula = loadFromStorage(storageKey);
|
||||||
if (savedFormula && isFormula(savedFormula)) {
|
const { currentValue, isFormulaMode, stale } = resolveEditorState(savedFormula, currentCellValue);
|
||||||
try {
|
|
||||||
const formula = extractFormula(savedFormula);
|
if (stale) {
|
||||||
const result = calculateFormula(formula);
|
removeFromStorage(storageKey);
|
||||||
setState({
|
}
|
||||||
currentValue: savedFormula,
|
|
||||||
displayValue: formatNumber(result),
|
initialEditorValueRef.current = currentValue;
|
||||||
isFormulaMode: true,
|
|
||||||
});
|
if (isFormulaMode) {
|
||||||
} catch {
|
setState({ currentValue, displayValue: computeFormulaDisplay(currentValue), isFormulaMode: true });
|
||||||
setState({
|
|
||||||
currentValue: savedFormula,
|
|
||||||
displayValue: '#ОШИБКА',
|
|
||||||
isFormulaMode: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [storageKey]);
|
}, [storageKey]);
|
||||||
|
|
||||||
@ -157,45 +186,56 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
|
|||||||
[cell, table],
|
[cell, table],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Возвращает true, если можно закрывать редактор (сохранено либо не было
|
||||||
|
// изменений), false — если формула некорректна и редактор должен остаться
|
||||||
|
// открытым (ничего не отправляем на сервер в этом случае).
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(() => {
|
||||||
const { currentValue } = state;
|
const { currentValue } = state;
|
||||||
|
|
||||||
|
// AURORA-1891/1895: ячейку открыли и закрыли без правки — на сервер
|
||||||
|
// ничего не шлём (ни cell_updated, ни запись в аудит).
|
||||||
|
if (currentValue === initialEditorValueRef.current) return true;
|
||||||
|
|
||||||
if (isFormula(currentValue)) {
|
if (isFormula(currentValue)) {
|
||||||
saveToStorage(storageKey, currentValue);
|
let result;
|
||||||
try {
|
try {
|
||||||
const formula = extractFormula(currentValue);
|
result = calculateFormula(extractFormula(currentValue));
|
||||||
const result = calculateFormula(formula);
|
|
||||||
const formattedResult = formatNumber(result);
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
displayValue: formattedResult,
|
|
||||||
isFormulaMode: true,
|
|
||||||
}));
|
|
||||||
warnIfNegativeNotAllowed(formattedResult);
|
|
||||||
onChange?.(formattedResult);
|
|
||||||
} catch {
|
} catch {
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
displayValue: '#ОШИБКА',
|
displayValue: '#ОШИБКА',
|
||||||
isFormulaMode: true,
|
isFormulaMode: true,
|
||||||
}));
|
}));
|
||||||
onChange?.(currentValue);
|
toast.error('Формула не вычисляется, проверьте выражение');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
localStorage.removeItem(storageKey);
|
const formattedResult = formatNumber(result);
|
||||||
|
saveToStorage(storageKey, currentValue);
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
displayValue: currentValue,
|
displayValue: formattedResult,
|
||||||
isFormulaMode: false,
|
isFormulaMode: true,
|
||||||
}));
|
}));
|
||||||
const normalizedValue = normalizeNumericInput(currentValue);
|
warnIfNegativeNotAllowed(formattedResult);
|
||||||
warnIfNegativeNotAllowed(normalizedValue);
|
onChange?.(formattedResult);
|
||||||
// AURORA-1891: значение не изменилось — не шлём onChange (а значит и
|
return true;
|
||||||
// cell_updated): открытие и закрытие ячейки без правки не должно
|
|
||||||
// создавать запись в журнале аудита.
|
|
||||||
if (isSameCellValue(normalizedValue, value)) return;
|
|
||||||
onChange?.(normalizedValue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeFromStorage(storageKey);
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
displayValue: currentValue,
|
||||||
|
isFormulaMode: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const normalizedValue = normalizeNumericInput(currentValue);
|
||||||
|
warnIfNegativeNotAllowed(normalizedValue);
|
||||||
|
// Значение фактически не изменилось (напр. '150' вместо 150) — не шлём
|
||||||
|
// onChange (а значит и cell_updated).
|
||||||
|
if (isSameCellValue(normalizedValue, value)) return true;
|
||||||
|
onChange?.(normalizedValue);
|
||||||
|
return true;
|
||||||
}, [state, storageKey, onChange, warnIfNegativeNotAllowed, value]);
|
}, [state, storageKey, onChange, warnIfNegativeNotAllowed, value]);
|
||||||
|
|
||||||
// Завершает редактирование текущей ячейки (cell_edit_end) — читает
|
// Завершает редактирование текущей ячейки (cell_edit_end) — читает
|
||||||
@ -212,13 +252,14 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
|
|||||||
}
|
}
|
||||||
}, [table, contextEndEditing]);
|
}, [table, contextEndEditing]);
|
||||||
|
|
||||||
|
// Возвращает true, если редактор закрыт; false — если остался открытым
|
||||||
|
// (формула не вычисляется) либо уже был закрыт ранее.
|
||||||
const finishEditing = useCallback(() => {
|
const finishEditing = useCallback(() => {
|
||||||
if (refFinished.current) return;
|
if (refFinished.current) return false;
|
||||||
|
if (!disabled && !handleSave()) return false;
|
||||||
refFinished.current = true;
|
refFinished.current = true;
|
||||||
if (!disabled) {
|
|
||||||
handleSave();
|
|
||||||
}
|
|
||||||
endCurrentEditing();
|
endCurrentEditing();
|
||||||
|
return true;
|
||||||
}, [disabled, handleSave, endCurrentEditing]);
|
}, [disabled, handleSave, endCurrentEditing]);
|
||||||
|
|
||||||
// AURORA-1891: Esc — отмена, handleSave вообще не вызывается (никакого
|
// AURORA-1891: Esc — отмена, handleSave вообще не вызывается (никакого
|
||||||
@ -267,8 +308,11 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
|
|||||||
// текущей (см. ruling в PR) — здесь мы только помечаем цель и сохраняем.
|
// текущей (см. ruling в PR) — здесь мы только помечаем цель и сохраняем.
|
||||||
if (e.key === 'Tab') {
|
if (e.key === 'Tab') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
table.options.meta?.openNextEditableCell?.(cell.row, cell.column, e.shiftKey ? 'left' : 'right');
|
// AURORA-1895: цель перехода помечаем только если редактор реально
|
||||||
finishEditing();
|
// закрылся — при невычисляемой формуле он остаётся открытым.
|
||||||
|
if (finishEditing()) {
|
||||||
|
table.options.meta?.openNextEditableCell?.(cell.row, cell.column, e.shiftKey ? 'left' : 'right');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -276,8 +320,9 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
|
|||||||
// e.preventDefault() не вызывается).
|
// e.preventDefault() не вызывается).
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
table.options.meta?.openNextEditableCell?.(cell.row, cell.column, 'down');
|
if (finishEditing()) {
|
||||||
finishEditing();
|
table.options.meta?.openNextEditableCell?.(cell.row, cell.column, 'down');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[cancelEditing, finishEditing, table, cell],
|
[cancelEditing, finishEditing, table, cell],
|
||||||
|
|||||||
@ -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 }) => (
|
||||||
|
<Modal
|
||||||
|
title='Удаление строки'
|
||||||
|
open={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
customSize={25}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<SecondaryButton onClick={onClose}>Отмена</SecondaryButton>
|
||||||
|
<DangerOutlinedButton onClick={onConfirm}>Удалить</DangerOutlinedButton>
|
||||||
|
</>
|
||||||
|
}>
|
||||||
|
<Alert severity='warning'>Удалить выбранную строку? Данные строки будут потеряны.</Alert>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
@ -12,6 +12,7 @@ import { getTableColumns } from './tableColumns';
|
|||||||
import { CircularProgress } from '@mui/material';
|
import { CircularProgress } from '@mui/material';
|
||||||
import { debounce } from '@mui/material';
|
import { debounce } from '@mui/material';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
import { ConfirmDeleteRowModal } from './Modals/ConfirmDeleteRowModal';
|
||||||
import { SheetModals } from './Modals/SheetModals';
|
import { SheetModals } from './Modals/SheetModals';
|
||||||
import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig';
|
import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig';
|
||||||
import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, TABLE_CONTAINER_SX, getTablePaperStyles, getTableRowHeightPx } from './constants/tableConfig';
|
import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, TABLE_CONTAINER_SX, getTablePaperStyles, getTableRowHeightPx } from './constants/tableConfig';
|
||||||
@ -28,6 +29,7 @@ import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/c
|
|||||||
import { isCellEditable } from './utils/cellEditability';
|
import { isCellEditable } from './utils/cellEditability';
|
||||||
import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation';
|
import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation';
|
||||||
import { canAddRowForHeader, getRowId } from './utils/rowUtils';
|
import { canAddRowForHeader, getRowId } from './utils/rowUtils';
|
||||||
|
import { clearLegacyFormulaKeys } from './utils/localStorageUtils';
|
||||||
|
|
||||||
import { DictVspApi } from '../../api/dict-vsp';
|
import { DictVspApi } from '../../api/dict-vsp';
|
||||||
import { useAuth } from '../../app/context/AuthProvider';
|
import { useAuth } from '../../app/context/AuthProvider';
|
||||||
@ -119,6 +121,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
const [expenseItemsVisibilityAction, setExpenseItemsVisibilityAction] = useState('hide');
|
const [expenseItemsVisibilityAction, setExpenseItemsVisibilityAction] = useState('hide');
|
||||||
|
|
||||||
|
const [rowIdPendingDelete, setRowIdPendingDelete] = useState(null);
|
||||||
const modalsRef = useRef(null);
|
const modalsRef = useRef(null);
|
||||||
|
|
||||||
const isFormNoCanAddRow = useMemo(() => FormsNoCanAddRow[formType]?.includes(sheetName) ?? false, [formType, sheetName]);
|
const isFormNoCanAddRow = useMemo(() => FormsNoCanAddRow[formType]?.includes(sheetName) ?? false, [formType, sheetName]);
|
||||||
@ -224,6 +227,12 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
columnVirtualizerRef.current?.measure?.();
|
columnVirtualizerRef.current?.measure?.();
|
||||||
}, [sizeMult]);
|
}, [sizeMult]);
|
||||||
|
|
||||||
|
// Разовая очистка устаревших ключей формул, привязанных к позиции строки
|
||||||
|
// (AURORA-1895): они стали недостоверны после перехода на ключи formula_v2_*.
|
||||||
|
useEffect(() => {
|
||||||
|
clearLegacyFormulaKeys();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!formType || !sheetName) return;
|
if (!formType || !sheetName) return;
|
||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
@ -923,9 +932,17 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
toast.error('Выделите строку для удаления');
|
toast.error('Выделите строку для удаления');
|
||||||
return;
|
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({});
|
setRowSelection({});
|
||||||
}, [canModifyRows, contextDeleteRow, getSelectedRow]);
|
}, [contextDeleteRow, rowIdPendingDelete]);
|
||||||
|
|
||||||
const addRow = useCallback(() => {
|
const addRow = useCallback(() => {
|
||||||
if (!canModifyRows) {
|
if (!canModifyRows) {
|
||||||
@ -1095,6 +1112,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ConfirmDeleteRowModal isOpen={rowIdPendingDelete != null} onClose={closeConfirmDeleteRow} onConfirm={confirmDeleteRow} />
|
||||||
<SheetModals
|
<SheetModals
|
||||||
ref={modalsRef}
|
ref={modalsRef}
|
||||||
formId={formId}
|
formId={formId}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { isCellEditable } from './utils/cellEditability';
|
|||||||
import { getColumnValue } from './utils/columnUtils';
|
import { getColumnValue } from './utils/columnUtils';
|
||||||
|
|
||||||
import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell';
|
import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell';
|
||||||
|
import { getRowId } from './utils/rowUtils';
|
||||||
|
|
||||||
const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable }) => {
|
const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable }) => {
|
||||||
const { formId, formType, sheetName, direction } = useParams();
|
const { formId, formType, sheetName, direction } = useParams();
|
||||||
@ -54,7 +55,9 @@ const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable
|
|||||||
onChange: handleChange,
|
onChange: handleChange,
|
||||||
tableId: tableKey,
|
tableId: tableKey,
|
||||||
table,
|
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],
|
[cell, isEditable, handleChange, tableKey, table],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -123,6 +123,37 @@ export const isSameCellValue = (a, b) => {
|
|||||||
return normalizeComparableValue(a) === normalizeComparableValue(b);
|
return normalizeComparableValue(a) === normalizeComparableValue(b);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Начальное состояние редактора ячейки.
|
||||||
|
* Решает, показывать ли сохранённую формулу или текущее значение ячейки,
|
||||||
|
* и помечает формулу как устаревшую (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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Форматирует число для отображения
|
* Форматирует число для отображения
|
||||||
* @param {number} value - Число для форматирования
|
* @param {number} value - Число для форматирования
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { isSameCellValue } from './formulaUtils.js';
|
import { isSameCellValue, resolveEditorState } from './formulaUtils.js';
|
||||||
|
|
||||||
// AURORA-1891: handleSave (EditCell.jsx) не должен слать cell_updated, если
|
// AURORA-1891: handleSave (EditCell.jsx) не должен слать cell_updated, если
|
||||||
// значение фактически не изменилось — иначе лишняя запись в БД/аудите при
|
// значение фактически не изменилось — иначе лишняя запись в БД/аудите при
|
||||||
@ -36,3 +36,51 @@ test('different values (numeric or text) are not the same', () => {
|
|||||||
test('non-numeric text compares as-is after trimming surrounding whitespace', () => {
|
test('non-numeric text compares as-is after trimming surrounding whitespace', () => {
|
||||||
assert.equal(isSameCellValue(' текст ', 'текст'), true);
|
assert.equal(isSameCellValue(' текст ', 'текст'), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// AURORA-1895: формула из localStorage не перезаписывает чужое значение.
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@ -81,6 +81,23 @@ export const removeFromStorage = (key) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет устаревшие ключи формул вида `formula_<tableId>_<cellId>`,
|
||||||
|
* привязанные к позиции строки в дереве таблицы (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
|
* Очистка всех данных приложения из localStorage
|
||||||
* @param {string[]} keysToKeep - Массив ключей, которые нужно сохранить
|
* @param {string[]} keysToKeep - Массив ключей, которые нужно сохранить
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user