diff --git a/api/alembic/versions/0026_budget_line_stage_access.py b/api/alembic/versions/0026_budget_line_stage_access.py new file mode 100644 index 0000000..c4d6e5e --- /dev/null +++ b/api/alembic/versions/0026_budget_line_stage_access.py @@ -0,0 +1,15 @@ +from sql_migration import run_sql_file_migration + + +revision = "0026" +down_revision = "0025" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + run_sql_file_migration("0026_budget_line_stage_access.sql") + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/sql/0026_budget_line_stage_access.sql b/api/alembic/versions/sql/0026_budget_line_stage_access.sql new file mode 100644 index 0000000..61c0be1 --- /dev/null +++ b/api/alembic/versions/sql/0026_budget_line_stage_access.sql @@ -0,0 +1,681 @@ + + +-- ФОРМА 3 + +CREATE OR REPLACE FUNCTION v3.add_form3_line(p_report_id integer, p_expense_item_id integer) + RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb) + LANGUAGE plpgsql +AS $function$ +DECLARE + v_new_id INT; + v_user_id INT; + v_decision JSONB; + v_msg TEXT; +BEGIN + IF NOT EXISTS (SELECT 1 FROM v3.rf_project_report WHERE id = p_report_id) THEN + RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id; + END IF; + IF NOT EXISTS (SELECT 1 FROM v3.expense_item WHERE id = p_expense_item_id) 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 = p_expense_item_id AND form_type_code = 'FORM_3') THEN + RAISE EXCEPTION 'expense_item id=% не привязан к form_type FORM_3', p_expense_item_id; + END IF; + v_user_id := v3.current_user_id(); + v_decision := v3.can_edit3(p_report_id, 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; + + + INSERT INTO v3.rf_project_report_line (rf_project_report_id, expense_item_id) + VALUES (p_report_id, p_expense_item_id) + RETURNING id INTO v_new_id; + + PERFORM v3.log_event( + 'ROW_CREATE', 'ROW_CREATE', + jsonb_build_object( + 'sheet', 'FORM_3', + 'line_id', v_new_id, + 'report_id', p_report_id, + 'expense_item_id', p_expense_item_id + ) + ); + + DECLARE v_ei_path INT[]; + BEGIN + v_ei_path := v3._expense_item_path_set(ARRAY[p_expense_item_id]); + RETURN QUERY + SELECT v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form3_report_jsonb(p_report_id, NULL) 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.can_edit3(p_report_id integer, p_column_key text, p_user_id integer) + RETURNS jsonb + LANGUAGE plpgsql + STABLE +AS $function$ +DECLARE + v_role VARCHAR; + v_active_close TIMESTAMPTZ; + v_role_has_col BOOLEAN; + v_next_open TIMESTAMPTZ; + v_last_close TIMESTAMPTZ; +BEGIN + IF NOT EXISTS (SELECT 1 FROM v3.rf_project_report WHERE id = p_report_id) THEN + RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id; + END IF; + + v_role := v3.user_role_code(p_user_id); + IF v_role IS NULL THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'role_not_allowed', + 'detail', 'unknown or inactive user' + ); + END IF; + + IF v_role = 'ADMIN' THEN + RETURN jsonb_build_object('ok', true, 'admin', true); + END IF; + + IF v_role = 'EXECUTOR_RF' AND NOT v3.user_in_report_org(p_user_id, p_report_id) THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'org_not_assigned', + 'detail', p_column_key + ); + END IF; + + SELECT MAX(fp.closes_at) + INTO v_active_close + FROM v3.form3_phase fp + WHERE fp.rf_project_report_id = p_report_id + AND fp.role = v_role + AND (p_column_key is null or p_column_key = ANY(fp.column_keys)) + AND now() BETWEEN fp.opens_at AND fp.closes_at; + + IF v_active_close IS NOT NULL THEN + RETURN jsonb_build_object( + 'ok', true, + 'closes_at', v_active_close + ); + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM v3.form3_phase fp + WHERE fp.rf_project_report_id = p_report_id + AND fp.role = v_role + AND p_column_key = ANY(fp.column_keys) + ) INTO v_role_has_col; + + IF NOT v_role_has_col THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'role_not_allowed', + 'detail', p_column_key + ); + END IF; + + SELECT MIN(fp.opens_at) + INTO v_next_open + FROM v3.form3_phase fp + WHERE fp.rf_project_report_id = p_report_id + AND fp.role = v_role + AND p_column_key = ANY(fp.column_keys) + AND fp.opens_at > now(); + + IF v_next_open IS NOT NULL THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'window_closed', + 'detail', p_column_key, + 'opens_at', v_next_open + ); + END IF; + + SELECT MAX(fp.closes_at) + INTO v_last_close + FROM v3.form3_phase fp + WHERE fp.rf_project_report_id = p_report_id + AND fp.role = v_role + AND p_column_key = ANY(fp.column_keys) + AND fp.closes_at <= now(); + + RETURN jsonb_build_object( + 'ok', false, + 'code', 'window_closed', + 'detail', p_column_key, + 'closes_at', v_last_close + ); +END; +$function$ +; + +-- Конец форма 3 + +-- Формы 1 2 4 + +-- DROP FUNCTION v3.add_budget_line(int4, int4, varchar, varchar, varchar, varchar, varchar, varchar, int4, int4, varchar, varchar, date); + +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; +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 293 -- R061031001 "Аренда_ОН" + WHEN 'AHR_UTILITY' THEN 24 -- 1.05.1. "Коммунальные услуги" + WHEN 'AHR_SECURITY' THEN 779 -- R061062001 "Пультовая охр.ОН" + 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; + + SELECT id INTO v_parent_line + FROM v3.budget_line + WHERE budget_form_id = p_form_id AND expense_item_id = v_sat_eid + LIMIT 1; + IF v_parent_line IS NULL THEN + INSERT INTO v3.budget_line (budget_form_id, expense_item_id, direction) + VALUES ( + p_form_id, v_sat_eid, + CASE v_form_type WHEN 'FORM_2' THEN 'Support' WHEN 'FORM_4' THEN 'Development' ELSE NULL END + ) RETURNING id INTO v_parent_line; + END IF; + + 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; + + 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; + + INSERT INTO v3.budget_line ( + budget_form_id, expense_item_id, name, internal_order, + vsp_id, project_id, direction, justification + ) VALUES ( + p_form_id, v_eid, p_name, p_internal_order, + p_vsp_id, p_project_id, v_final_dir, p_justification + ) RETURNING id INTO v_new_id; + + PERFORM v3.log_event( + 'ROW_CREATE', 'ROW', + jsonb_build_object( + 'sheet', v_sheet, + 'line_id', v_new_id, + 'expense_item_id', v_eid, + 'direction', v_final_dir, + 'name', p_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$ +; + + + +-- DROP FUNCTION v3.add_form4_program(varchar, int4, varchar); + +CREATE OR REPLACE FUNCTION v3.add_form4_program(p_name character varying, p_form_id integer, p_section_code character varying DEFAULT NULL::character varying) + RETURNS TABLE(project_id integer, row_type character varying, depth integer, sort_order bigint, data jsonb) + LANGUAGE plpgsql +AS $function$ +#variable_conflict use_column +DECLARE + v_pid INT; + v_user_id INT; + v_decision JSONB; + v_msg TEXT; +BEGIN + IF p_name IS NULL OR length(trim(p_name)) = 0 THEN + RAISE EXCEPTION 'p_name обязателен'; + END IF; + IF p_form_id IS NULL THEN + RAISE EXCEPTION 'p_form_id обязателен'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM v3.budget_form bf + WHERE bf.form_type_code = 'FORM_4' AND bf.id = p_form_id + ) THEN + RAISE EXCEPTION 'Только FORM_4'; + END IF; + IF p_section_code IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM v3.expense_item + WHERE section_code = p_section_code AND depth = 0 + ) THEN + RAISE EXCEPTION 'section_code=% не существует в expense_item (depth=0)', p_section_code; + END IF; + + v_user_id := v3.current_user_id(); + v_decision := v3.can_edit(p_form_id, p_section_code, NULL, 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; + + INSERT INTO v3.form4_project (name, level, parent_id, form_id, section_code) + VALUES (p_name, 'program', NULL, p_form_id, p_section_code) + RETURNING id INTO v_pid; + + PERFORM v3.log_event( + 'PROJECT_CREATE', 'PROJECT', + jsonb_strip_nulls(jsonb_build_object( + 'project_id', v_pid, + 'name', p_name, + 'level', 'program', + 'form_id', p_form_id, + 'form_type', 'FORM_4' + )), + p_form_id, NULL, NULL + ); + + RETURN QUERY + SELECT v_pid, v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form4_sheet_jsonb(p_form_id, p_section_code, NULL) v + WHERE (v.row_type = 'GROUP' AND (v.data->'header'->>'program_id')::INT = v_pid) + OR (v.row_type = 'ROOT' AND v.data->'header'->>'section_code' = p_section_code) + ORDER BY v.sort_order; +END; +$function$ +; + + +-- DROP FUNCTION v3.add_form4_project(varchar, int4); + +CREATE OR REPLACE FUNCTION v3.add_form4_project(p_name character varying, p_program_id integer) + RETURNS TABLE(project_id integer, row_type character varying, depth integer, sort_order bigint, data jsonb) + LANGUAGE plpgsql +AS $function$ +#variable_conflict use_column +DECLARE + v_pid INT; + v_section_code VARCHAR; + v_form_id INT; + v_user_id INT; + v_decision JSONB; + v_msg TEXT; +BEGIN + IF p_name IS NULL OR length(trim(p_name)) = 0 THEN + RAISE EXCEPTION 'p_name обязателен'; + END IF; + IF p_program_id IS NULL THEN + RAISE EXCEPTION 'p_program_id обязателен (проект создаётся под программой)'; + END IF; + IF NOT EXISTS (SELECT 1 FROM v3.form4_project fp + WHERE fp.id = p_program_id AND fp.level = 'program') THEN + RAISE EXCEPTION 'program id=% не существует или не имеет level=program', p_program_id; + END IF; + + + SELECT fp.form_id, fp.section_code INTO v_form_id, v_section_code + FROM v3.form4_project fp + WHERE fp.id = p_program_id AND fp.level = 'program'; + + v_user_id := v3.current_user_id(); + v_decision := v3.can_edit(v_form_id, v_section_code, NULL, 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; + + INSERT INTO v3.form4_project (name, level, parent_id, form_id) + VALUES (p_name, 'project', p_program_id, v_form_id) + RETURNING id INTO v_pid; + + PERFORM v3.log_event( + 'PROJECT_CREATE', 'PROJECT', + jsonb_strip_nulls(jsonb_build_object( + 'project_id', v_pid, + 'name', p_name, + 'level', 'project', + 'parent_id', p_program_id, + 'form_id', v_form_id, + 'form_type', 'FORM_4' + )), + v_form_id, NULL, NULL + ); + + + + IF v_section_code IS NOT NULL THEN + RETURN QUERY + SELECT v_pid, v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form4_sheet_jsonb(v_form_id, v_section_code, NULL) v + WHERE (v.row_type = 'ITEM' AND (v.data->'header'->>'project_id')::INT = v_pid) + OR (v.row_type = 'GROUP' AND (v.data->'header'->>'program_id')::INT = p_program_id) + OR (v.row_type = 'ROOT' AND v.data->'header'->>'section_code' = v_section_code) + ORDER BY v.sort_order; + END IF; +END; +$function$ +; + +CREATE OR REPLACE FUNCTION v3.can_edit(p_form_id integer, p_sheet character varying, p_direction character varying, p_column_key text, p_user_id integer) + RETURNS jsonb + LANGUAGE plpgsql + STABLE +AS $function$ +DECLARE + v_role VARCHAR; + v_active_close TIMESTAMPTZ; + v_role_has_col BOOLEAN; + v_next_open TIMESTAMPTZ; + v_last_close TIMESTAMPTZ; +BEGIN + IF NOT EXISTS (SELECT 1 FROM v3.budget_form WHERE id = p_form_id) THEN + RAISE EXCEPTION 'budget_form id=% не существует', p_form_id; + END IF; + + v_role := v3.user_role_code(p_user_id); + IF v_role IS NULL THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'role_not_allowed', + 'detail', 'unknown or inactive user' + ); + END IF; + + IF v_role = 'ADMIN' THEN + RETURN jsonb_build_object('ok', true, 'admin', true); + END IF; + + IF v_role = 'EXECUTOR_RF' AND NOT v3.user_in_form_org(p_user_id, p_form_id) THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'org_not_assigned', + 'detail', p_column_key + ); + END IF; + + SELECT MAX(fp.closes_at) + INTO v_active_close + FROM v3.form_phase fp + WHERE fp.budget_form_id = p_form_id + AND fp.sheet = p_sheet + AND (fp.direction = p_direction OR (fp.direction IS NULL AND p_direction IS NULL)) + AND fp.role = v_role + AND (p_column_key IS NULL OR p_column_key = ANY(fp.column_keys)) + AND now() BETWEEN fp.opens_at AND fp.closes_at; + + IF v_active_close IS NOT NULL THEN + RETURN jsonb_build_object( + 'ok', true, + 'closes_at', v_active_close + ); + END IF; + + SELECT EXISTS ( + SELECT 1 + FROM v3.form_phase fp + WHERE fp.budget_form_id = p_form_id + AND fp.sheet = p_sheet + AND (fp.direction = p_direction OR (fp.direction IS NULL AND p_direction IS NULL)) + AND fp.role = v_role + AND p_column_key = ANY(fp.column_keys) + ) INTO v_role_has_col; + + IF NOT v_role_has_col THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'role_not_allowed', + 'detail', p_column_key + ); + END IF; + + SELECT MIN(fp.opens_at) + INTO v_next_open + FROM v3.form_phase fp + WHERE fp.budget_form_id = p_form_id + AND fp.sheet = p_sheet + AND (fp.direction = p_direction OR (fp.direction IS NULL AND p_direction IS NULL)) + AND fp.role = v_role + AND p_column_key = ANY(fp.column_keys) + AND fp.opens_at > now(); + + IF v_next_open IS NOT NULL THEN + RETURN jsonb_build_object( + 'ok', false, + 'code', 'window_closed', + 'detail', p_column_key, + 'opens_at', v_next_open + ); + END IF; + + SELECT MAX(fp.closes_at) + INTO v_last_close + FROM v3.form_phase fp + WHERE fp.budget_form_id = p_form_id + AND fp.sheet = p_sheet + AND (fp.direction = p_direction OR (fp.direction IS NULL AND p_direction IS NULL)) + AND fp.role = v_role + AND p_column_key = ANY(fp.column_keys) + AND fp.closes_at <= now(); + + RETURN jsonb_build_object( + 'ok', false, + 'code', 'window_closed', + 'detail', p_column_key, + 'closes_at', v_last_close + ); +END; +$function$ +; diff --git a/api/src/api/v1/websocket.py b/api/src/api/v1/websocket.py index c72b0af..bb4be5d 100644 --- a/api/src/api/v1/websocket.py +++ b/api/src/api/v1/websocket.py @@ -9,8 +9,7 @@ import time from typing import Any, Optional # -from asyncpg import UniqueViolationError -from sqlalchemy.exc import IntegrityError +from sqlalchemy.exc import SQLAlchemyError from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status from sqlalchemy.ext.asyncio import AsyncSession @@ -25,7 +24,8 @@ from src.db.models.form_type import FormTypeEnum from src.services.budget_form_service import BudgetFormService from src.services.project_service import ProjectService from src.services.sheet_service import SheetService -from src.core.errors import BasicAppException, ValidationsError +from src.core.errors import BasicAppException, ValidationsError +from src.core.exception_handlers import map_sqlalchemy_error from src.db.session import SessionLocal from src.services.user_service import UserService @@ -962,12 +962,17 @@ async def process_websocket( data=data, websocket=websocket, ) - except IntegrityError as e: - data["error"] = str(e) - await manager.send_back( - data=data, - websocket=websocket, - ) + except SQLAlchemyError as e: + _, code, message, field = map_sqlalchemy_error(e) + data["error"] = { + "code": code, + "field": field, + "message": message, + } + await manager.send_back( + data=data, + websocket=websocket, + ) except SheetValidationError as e: data["error"] = e.message await manager.send_back( diff --git a/api/src/services/user_service.py b/api/src/services/user_service.py index 091b2a9..4b14f51 100644 --- a/api/src/services/user_service.py +++ b/api/src/services/user_service.py @@ -105,14 +105,16 @@ class UserService: had_many_ssp_role = previous_role_id in ( UserRoleEnum.ADMIN.value, UserRoleEnum.EXECUTOR_DFIP.value, + UserRoleEnum.EXECUTOR_DURRS.value, ) has_many_ssp_role_now = role.value in ( UserRoleEnum.ADMIN.value, UserRoleEnum.EXECUTOR_DFIP.value, + UserRoleEnum.EXECUTOR_DURRS.value, ) is_executor_to_dfip_transition = ( previous_role_id == UserRoleEnum.EXECUTOR_RF.value - and role.value == UserRoleEnum.EXECUTOR_DFIP.value + and role.value in (UserRoleEnum.EXECUTOR_DFIP.value, UserRoleEnum.EXECUTOR_DURRS.value) ) if (had_many_ssp_role and not has_many_ssp_role_now) or is_executor_to_dfip_transition: await self.user_repo.clear_many_ssp(user_id)