fix(forms): add_budget_line проставляет created_by, NULL-автор удаляется только ADMIN (O-1)
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 <noreply@anthropic.com>
This commit is contained in:
parent
84be3d42f3
commit
36d19f1e2f
@ -2,6 +2,221 @@
|
|||||||
-- (budget_line.created_by = текущий пользователь).
|
-- (budget_line.created_by = текущий пользователь).
|
||||||
-- 0056: тело пересобрано поверх 0053_form2_ahr_source_sheet (AURORA-1907: блок удаления АХР-строк
|
-- 0056: тело пересобрано поверх 0053_form2_ahr_source_sheet (AURORA-1907: блок удаления АХР-строк
|
||||||
-- по budget_line.source_sheet IS NOT NULL, ручные строки с NULL удаляются) + только owner-guard.
|
-- по 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)
|
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)
|
RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb)
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
@ -39,6 +254,7 @@ BEGIN
|
|||||||
RAISE EXCEPTION '%.id=% не существует', v_sat_table, p_id;
|
RAISE EXCEPTION '%.id=% не существует', v_sat_table, p_id;
|
||||||
END IF;
|
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
|
IF v_role != 'ADMIN' AND v_created_by IS DISTINCT FROM v_actor_id THEN
|
||||||
RAISE EXCEPTION 'role_not_allowed: можно удалить только добавленную вами строку';
|
RAISE EXCEPTION 'role_not_allowed: можно удалить только добавленную вами строку';
|
||||||
END IF;
|
END IF;
|
||||||
@ -90,6 +306,7 @@ BEGIN
|
|||||||
RAISE EXCEPTION 'budget_line id=% не существует', p_id;
|
RAISE EXCEPTION 'budget_line id=% не существует', p_id;
|
||||||
END IF;
|
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
|
IF v_role != 'ADMIN' AND v_created_by IS DISTINCT FROM v_actor_id THEN
|
||||||
RAISE EXCEPTION 'role_not_allowed: можно удалить только добавленную вами строку';
|
RAISE EXCEPTION 'role_not_allowed: можно удалить только добавленную вами строку';
|
||||||
END IF;
|
END IF;
|
||||||
|
|||||||
@ -196,15 +196,6 @@ def test_forms_delete_line_forbidden_for_non_owner_executor(client, isp2_tokens,
|
|||||||
assert forbidden_response.status_code == 403
|
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):
|
def test_forms_delete_line_allowed_for_owner_executor(client, isp2_tokens, auth_headers):
|
||||||
marker = "aurora-1936-own-delete"
|
marker = "aurora-1936-own-delete"
|
||||||
add_response = client.post(
|
add_response = client.post(
|
||||||
@ -304,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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user