From c976ae8c9294fbc36f26ddea2eb83c3d46c3e166 Mon Sep 17 00:00:00 2001 From: tsygankoviva Date: Tue, 21 Jul 2026 14:55:39 +0300 Subject: [PATCH] =?UTF-8?q?form-3-phases-timezones:=20=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D0=B2=D0=BE=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=8D=D1=82=D0=B0=D0=BF=D0=BE=D0=B2=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=203=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B,=20=D1=82=D0=B0?= =?UTF-8?q?=D0=B9=D0=BC=D0=B7=D0=BE=D0=BD=D1=8B=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D1=8D=D1=82=D0=B0=D0=BF=D0=BE=D0=B2=203=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D1=8B,=20=D0=BF=D0=BE=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20entity=5Ftype=20=D0=B4=D0=BB=D1=8F=20=D1=8D=D1=82?= =?UTF-8?q?=D0=B0=D0=BF=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../versions/0009_phase3_timestamps.py | 785 ++++++++++++++++++ api/src/api/v1/form_phases.py | 178 +++- api/src/db/models/project.py | 2 +- api/src/db/models/rf_project_report.py | 2 +- api/src/domain/schemas.py | 29 + api/src/repository/form3_phase_repository.py | 116 +++ api/src/repository/project_repository.py | 17 + .../rf_project_report_repository.py | 25 + api/src/services/form3_phase_service.py | 106 +++ api/src/services/project_service.py | 5 + api/src/services/rf_project_report_service.py | 18 +- .../integration/test_form_phases_api_smoke.py | 162 ++++ .../integration/test_org_units_integration.py | 42 + api/tests/sqls/fixture.sql | 13 + 14 files changed, 1494 insertions(+), 6 deletions(-) create mode 100644 api/alembic/versions/0009_phase3_timestamps.py create mode 100644 api/src/repository/form3_phase_repository.py create mode 100644 api/src/services/form3_phase_service.py diff --git a/api/alembic/versions/0009_phase3_timestamps.py b/api/alembic/versions/0009_phase3_timestamps.py new file mode 100644 index 0000000..3a315fe --- /dev/null +++ b/api/alembic/versions/0009_phase3_timestamps.py @@ -0,0 +1,785 @@ +from alembic import op +import sqlalchemy as sa + + +revision = "0009" +down_revision = "0008" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + CREATE OR REPLACE FUNCTION v3.copy_template_to_report(p_report_id integer) + RETURNS integer + LANGUAGE plpgsql + AS $function$ + DECLARE + v_report_type VARCHAR; + v_inserted INTEGER; + v_org_unit INTEGER; + v_utc_offset VARCHAR(3); + BEGIN + SELECT report_type, org_unit_id INTO v_report_type, v_org_unit + FROM v3.rf_project_report AS rpr + INNER JOIN v3.project AS p ON p.id = rpr.project_id + WHERE rpr.id = p_report_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'rf_project_report id=% не существует', p_report_id; + END IF; + + SELECT utc_offset INTO v_utc_offset + FROM v3.org_unit + WHERE id = v_org_unit; + + INSERT INTO v3.form3_phase + (rf_project_report_id, phase_code, role, column_keys, opens_at, closes_at) + SELECT p_report_id, pt.phase_code, pt.role, + pt.column_keys, (pt.opens_at::text || v_utc_offset)::timestamptz, + (pt.closes_at::text || v_utc_offset)::timestamptz + FROM v3.phase_template pt + WHERE pt.form_type = 'FORM_3' + AND pt.sheet = v_report_type + ON CONFLICT (rf_project_report_id, phase_code) DO NOTHING; + + GET DIAGNOSTICS v_inserted = ROW_COUNT; + RETURN v_inserted; + END; + $function$ + ; + """ + ) + op.execute( + """ + CREATE OR REPLACE FUNCTION v3.add_form3_phase(p_rf_project_report_id integer, p_phase_code character varying, p_role character varying, p_column_keys text[], p_opens_at timestamp without time zone, p_closes_at timestamp without time zone) + RETURNS v3.form3_phase + LANGUAGE plpgsql + AS $function$ + DECLARE + v_phase v3.form3_phase; + v_org_unit_id INT; + v_org_unit_title VARCHAR; + v_year INT; + v_task_name TEXT; + v_phase_id TEXT; + v_utc_offset VARCHAR(3); + v_report_type VARCHAR; + v_project_id INT; + BEGIN + SELECT p.org_unit_id, ou.title, rpr.year, ou.utc_offset, rpr.report_type, rpr.project_id + INTO v_org_unit_id, v_org_unit_title, v_year, v_utc_offset, v_report_type, v_project_id + FROM v3.rf_project_report rpr + LEFT JOIN v3.project p ON p.id = rpr.project_id + LEFT JOIN v3.org_unit ou ON ou.id = p.org_unit_id + WHERE rpr.id = p_rf_project_report_id; + + INSERT INTO v3.form3_phase + (rf_project_report_id, phase_code, role, column_keys, opens_at, closes_at) + VALUES ( + p_rf_project_report_id, + p_phase_code, + p_role, + p_column_keys, + CASE WHEN p_opens_at IS NOT NULL THEN (p_opens_at::text || v_utc_offset)::timestamptz ELSE NULL END, + CASE WHEN p_closes_at IS NOT NULL THEN (p_closes_at::text || v_utc_offset)::timestamptz ELSE NULL END + + ) + RETURNING * INTO v_phase; + + + v_task_name := trim(concat( + 'FORM_3', + CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END, + CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END + )); + v_phase_id := format('%s:%s', p_rf_project_report_id, p_phase_code); + + PERFORM v3.log_event( + 'ACCESS_WINDOW_CHANGE', 'ACCESS', + jsonb_build_object( + 'entity_type', 'project', + 'entity_id', v_project_id, + 'core_entity_type', 'form3_phase', + 'core_entity_id', v_phase_id, + 'action', 'create', + 'phase_id', v_phase_id, + 'task_name', NULLIF(v_task_name, ''), + 'report_id', p_rf_project_report_id, + 'org_unit_id', v_org_unit_id, + 'org_unit_name', v_org_unit_title, + 'year', v_year, + 'project_id', v_project_id, + 'report_type', v_report_type, + 'phase_code', p_phase_code, + 'role', v_phase.role, + 'column_keys', v_phase.column_keys, + 'opens_at', v_phase.opens_at, + 'closes_at', v_phase.closes_at + ), + null, null, v_org_unit_id + ); + + RETURN v_phase; + END; + $function$ + ; + """ + ) + op.execute( + """ + CREATE OR REPLACE FUNCTION v3.upd_form3_phase(p_rf_project_report_id integer, p_phase_code character varying, p_role character varying DEFAULT NULL::character varying, p_column_keys text[] DEFAULT NULL::text[], p_opens_at timestamp without time zone DEFAULT NULL::timestamp without time zone, p_closes_at timestamp without time zone DEFAULT NULL::timestamp without time zone) + RETURNS v3.form3_phase + LANGUAGE plpgsql + AS $function$ + DECLARE + v_old v3.form3_phase; + v_new v3.form3_phase; + v_is_extend BOOLEAN := FALSE; + v_has_non_extend_change BOOLEAN := FALSE; + v_has_window_change BOOLEAN := FALSE; + v_changes JSONB := '{}'::jsonb; + v_org_unit_id INT; + v_org_unit_title VARCHAR; + v_form_type_code VARCHAR; + v_year INT; + v_task_name TEXT; + v_phase_id TEXT; + v_utc_offset VARCHAR(3); + v_opens_at timestamptz; + v_closes_at timestamptz; + v_report_type VARCHAR; + v_project_id INT; + BEGIN + SELECT * INTO v_old + FROM v3.form3_phase + WHERE rf_project_report_id = p_rf_project_report_id + AND phase_code = p_phase_code; + + IF NOT FOUND THEN + RAISE EXCEPTION 'form3_phase не найден (rf_project_report_id=%, phase=%)', + p_rf_project_report_id, p_phase_code; + END IF; + + SELECT p.org_unit_id, ou.title, rpr.year, ou.utc_offset, rpr.report_type, rpr.project_id + INTO v_org_unit_id, v_org_unit_title, v_year, v_utc_offset, v_report_type, v_project_id + FROM v3.rf_project_report rpr + LEFT JOIN v3.project p ON p.id = rpr.project_id + LEFT JOIN v3.org_unit ou ON ou.id = p.org_unit_id + WHERE rpr.id = p_rf_project_report_id; + v_task_name := trim(concat( + 'FORM_3', + CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END, + CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END + )); + + IF p_opens_at is not null THEN + v_opens_at = (p_opens_at::text || v_utc_offset)::timestamptz; + END IF; + IF p_closes_at is not null THEN + v_closes_at = (p_closes_at::text || v_utc_offset)::timestamptz; + END IF; + + UPDATE v3.form3_phase + SET role = COALESCE(p_role, role), + column_keys = COALESCE(p_column_keys, column_keys), + opens_at = COALESCE(v_opens_at, opens_at), + closes_at = COALESCE(v_closes_at, closes_at) + WHERE rf_project_report_id = p_rf_project_report_id + AND phase_code = p_phase_code + RETURNING * INTO v_new; + + + v_phase_id := format('%s:%s', p_rf_project_report_id, p_phase_code); + + v_is_extend := v_new.closes_at > v_old.closes_at; + v_has_non_extend_change := v_old.role IS DISTINCT FROM v_new.role + OR v_old.column_keys IS DISTINCT FROM v_new.column_keys + OR v_old.opens_at IS DISTINCT FROM v_new.opens_at; + v_has_window_change := v_has_non_extend_change + OR (v_old.closes_at IS DISTINCT FROM v_new.closes_at AND NOT v_is_extend); + + IF v_old.role IS DISTINCT FROM v_new.role THEN + v_changes := v_changes || jsonb_build_object( + 'role', jsonb_build_object('before', v_old.role, 'after', v_new.role) + ); + END IF; + IF v_old.column_keys IS DISTINCT FROM v_new.column_keys THEN + v_changes := v_changes || jsonb_build_object( + 'column_keys', jsonb_build_object('before', v_old.column_keys, 'after', v_new.column_keys) + ); + END IF; + IF v_old.opens_at IS DISTINCT FROM v_new.opens_at THEN + v_changes := v_changes || jsonb_build_object( + 'opens_at', jsonb_build_object( + 'before', v_old.opens_at, + 'after', v_new.opens_at + ) + ); + END IF; + IF v_old.closes_at IS DISTINCT FROM v_new.closes_at THEN + v_changes := v_changes || jsonb_build_object( + 'closes_at', jsonb_build_object( + 'before', v_old.closes_at, + 'after', v_new.closes_at + ) + ); + END IF; + + IF v_is_extend THEN + PERFORM v3.log_event( + 'ACCESS_EXTEND', 'ACCESS', + jsonb_build_object( + 'entity_type', 'project', + 'entity_id', v_project_id, + 'core_entity_type', 'form3_phase', + 'core_entity_id', v_phase_id, + 'action', 'update', + 'phase_id', v_phase_id, + 'phase_name', p_phase_code, + 'task_name', NULLIF(v_task_name, ''), + 'report_id', p_rf_project_report_id, + 'report_type', v_report_type, + 'project_id', v_project_id, + 'org_unit_id', v_org_unit_id, + 'org_unit_name', v_org_unit_title, + 'year', v_year, + 'phase_code', p_phase_code, + 'closes_at_before', v_old.closes_at, + 'closes_at_after', v_new.closes_at + ), + null, null, v_org_unit_id + ); + END IF; + + IF v_has_window_change THEN + PERFORM v3.log_event( + 'ACCESS_WINDOW_CHANGE', 'ACCESS', + jsonb_build_object( + 'entity_type', 'project', + 'entity_id', v_project_id, + 'core_entity_type', 'form3_phase', + 'core_entity_id', v_phase_id, + 'action', 'update', + 'phase_id', v_phase_id, + 'phase_name', p_phase_code, + 'task_id', p_rf_project_report_id, + 'task_name', NULLIF(v_task_name, ''), + 'project_id', v_project_id, + 'report_type', v_report_type, + 'year', v_year, + 'org_unit_id', v_org_unit_id, + 'org_unit_name', v_org_unit_title, + 'phase_code', p_phase_code, + 'changes', v_changes + ), + null, null, v_org_unit_id + ); + END IF; + + RETURN v_new; + END; + $function$; + """ + ) + op.execute( + """ + CREATE OR REPLACE FUNCTION v3.del_form3_phase(p_rf_project_report_id integer, p_phase_code character varying) + RETURNS boolean + LANGUAGE plpgsql + AS $function$ + DECLARE + v_old v3.form3_phase; + v_org_unit_id INT; + v_org_unit_title VARCHAR; + v_year INT; + v_task_name TEXT; + v_phase_id TEXT; + v_report_type VARCHAR; + v_project_id INT; + BEGIN + DELETE FROM v3.form3_phase + WHERE rf_project_report_id = p_rf_project_report_id + AND phase_code = p_phase_code + RETURNING * INTO v_old; + + IF NOT FOUND THEN + RETURN FALSE; + END IF; + + SELECT p.org_unit_id, ou.title, rpr.year, rpr.report_type, rpr.project_id + INTO v_org_unit_id, v_org_unit_title, v_year, v_report_type, v_project_id + FROM v3.rf_project_report rpr + LEFT JOIN v3.project p ON p.id = rpr.project_id + LEFT JOIN v3.org_unit ou ON ou.id = p.org_unit_id + WHERE rpr.id = p_rf_project_report_id; + v_task_name := trim(concat( + 'FORM_3', + CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END, + CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END + )); + v_phase_id := format('%s:%s', p_rf_project_report_id, p_phase_code); + + PERFORM v3.log_event( + 'ACCESS_WINDOW_CHANGE', 'ACCESS', + jsonb_build_object( + 'entity_type', 'project', + 'entity_id', v_project_id, + 'core_entity_type', 'form3_phase', + 'core_entity_id', v_phase_id, + 'action', 'delete', + 'phase_id', v_phase_id, + 'phase_name', p_phase_code, + 'task_name', NULLIF(v_task_name, ''), + 'project_id', v_project_id, + 'year', v_year, + 'report_type', v_report_type, + 'org_unit_id', v_org_unit_id, + 'org_unit_name', v_org_unit_title, + 'phase_code', p_phase_code, + 'role', v_old.role, + 'column_keys', v_old.column_keys, + 'opens_at', v_old.opens_at, + 'closes_at', v_old.closes_at + ), + null, null, v_org_unit_id + ); + RETURN TRUE; + END; + $function$ + ; + """ + ) + op.execute( + """ + CREATE OR REPLACE FUNCTION v3.add_project(p_name character varying, p_year integer, p_org_unit_id integer, p_level character varying DEFAULT 'project'::character varying, p_parent_id integer DEFAULT NULL::integer, p_project_type character varying DEFAULT NULL::character varying, p_vsp_format character varying DEFAULT NULL::character varying, p_placement_type character varying DEFAULT NULL::character varying, p_object_address character varying DEFAULT NULL::character varying, p_staff_count integer DEFAULT NULL::integer, p_total_area numeric DEFAULT NULL::numeric, p_system_code character varying DEFAULT NULL::character varying) + RETURNS TABLE(project_id integer, limit_report_id integer, current_expenses_report_id integer) + LANGUAGE plpgsql + AS $function$ + DECLARE + v_pid INT; + v_lim INT; + v_cur INT; + BEGIN + IF p_name IS NULL OR length(trim(p_name)) = 0 THEN + RAISE EXCEPTION 'p_name обязателен'; + END IF; + IF p_year IS NULL THEN + RAISE EXCEPTION 'p_year обязателен'; + END IF; + IF p_org_unit_id IS NULL THEN + RAISE EXCEPTION 'p_org_unit_id обязателен'; + END IF; + IF NOT EXISTS (SELECT 1 FROM v3.org_unit WHERE id = p_org_unit_id) THEN + RAISE EXCEPTION 'org_unit id=% не существует', p_org_unit_id; + END IF; + IF p_parent_id IS NOT NULL AND + NOT EXISTS (SELECT 1 FROM v3.project WHERE id = p_parent_id AND level = 'program') THEN + RAISE EXCEPTION 'parent project id=% не существует или не имеет level=program', p_parent_id; + END IF; + + -- INSERT project (CHECK-ы name/project_type/vsp_format/placement_type + -- из migrate_form3_v1.sql применятся автоматически). + INSERT INTO v3.project ( + name, level, parent_id, org_unit_id, + project_type, vsp_format, placement_type, object_address, staff_count, total_area, + system_code + ) VALUES ( + p_name, p_level, p_parent_id, p_org_unit_id, + p_project_type, p_vsp_format, p_placement_type, p_object_address, p_staff_count, p_total_area, + p_system_code + ) RETURNING id INTO v_pid; + + -- Первый год: 2 отчёта (LIMIT + CURRENT_EXPENSES) + snapshot фаз + сетка статей. + -- p_emit_audit=false — событие года покрыто PROJECT_CREATE ниже. Дальнейшие + -- годы добавляются отдельно через v3.add_project_year. + SELECT y.limit_report_id, y.current_expenses_report_id + INTO v_lim, v_cur + FROM v3.add_project_year(v_pid, p_year, false) y; + + -- Аудит: PROJECT_CREATE — все заполненные поля + id отчётов + PERFORM v3.log_event( + 'PROJECT_CREATE', 'PROJECT', + jsonb_strip_nulls(jsonb_build_object( + 'project_id', v_pid, + 'name', p_name, + 'year', p_year, + 'level', p_level, + 'parent_id', p_parent_id, + 'org_unit_id', p_org_unit_id, + 'project_type', p_project_type, + 'vsp_format', p_vsp_format, + 'placement_type', p_placement_type, + 'object_address', p_object_address, + 'system_code', p_system_code, + 'staff_count', p_staff_count, + 'total_area', p_total_area, + 'limit_report_id', v_lim, + 'current_expenses_report_id', v_cur + )) + ); + + RETURN QUERY SELECT v_pid, v_lim, v_cur; + END; + $function$ + ; + """ + ) + op.execute( + """ + CREATE OR REPLACE FUNCTION v3.upd_phases_timezones(p_org_unit_id integer, p_old_tz character varying, p_new_tz character varying) + RETURNS integer + LANGUAGE plpgsql + AS $function$ + DECLARE + v_result integer; + v_result3 integer; + BEGIN + IF p_old_tz = p_new_tz THEN + RETURN 0; + END IF; + WITH updated AS ( + UPDATE v3.form_phase + SET opens_at = ((opens_at AT TIME zone (p_old_tz || '::00')::interval)::text || p_new_tz)::timestamptz, + closes_at = ((closes_at AT TIME zone (p_old_tz || '::00')::interval)::text || p_new_tz)::timestamptz + WHERE budget_form_id IN (SELECT id FROM v3.budget_form WHERE org_unit_id = p_org_unit_id) + AND ( + opens_at >= CURRENT_TIMESTAMP + OR closes_at >= CURRENT_TIMESTAMP + ) + RETURNING * + ) + SELECT COUNT(*) INTO v_result FROM updated; + + WITH updated AS ( + UPDATE v3.form3_phase + SET opens_at = ((opens_at AT TIME zone (p_old_tz || '::00')::interval)::text || p_new_tz)::timestamptz, + closes_at = ((closes_at AT TIME zone (p_old_tz || '::00')::interval)::text || p_new_tz)::timestamptz + WHERE rf_project_report_id IN ( + SELECT id FROM V3.rf_project_report rpr WHERE project_id IN ( + SELECT id FROM v3.project WHERE org_unit_id = p_org_unit_id + ) + ) + AND ( + opens_at >= CURRENT_TIMESTAMP + OR closes_at >= CURRENT_TIMESTAMP + ) + RETURNING * + ) + SELECT COUNT(*) INTO v_result3 FROM updated; + RETURN v_result + v_result3; + + END; + $function$ + ; + """ + ) + op.execute( + """ + CREATE OR REPLACE FUNCTION v3.add_form_phase(p_budget_form_id integer, p_sheet character varying, p_phase_code character varying, p_role character varying, p_column_keys text[], p_opens_at timestamp without time zone, p_closes_at timestamp without time zone) + RETURNS v3.form_phase + LANGUAGE plpgsql + AS $function$ + DECLARE + v_phase v3.form_phase; + v_org_unit_id INT; + v_org_unit_title VARCHAR; + v_form_type_code VARCHAR; + v_year INT; + v_task_name TEXT; + v_phase_id TEXT; + v_utc_offset VARCHAR(3); + BEGIN + SELECT bf.org_unit_id, ou.title, bf.form_type_code, bf.year + INTO v_org_unit_id, v_org_unit_title, v_form_type_code, v_year + FROM v3.budget_form bf + LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id + WHERE bf.id = p_budget_form_id; + + SELECT utc_offset INTO v_utc_offset + FROM v3.org_unit + WHERE id = v_org_unit_id; + + INSERT INTO v3.form_phase( + budget_form_id, sheet, phase_code, role, column_keys, opens_at, closes_at + ) + VALUES ( + p_budget_form_id, + p_sheet, + p_phase_code, + p_role, + p_column_keys, + CASE WHEN p_opens_at IS NOT NULL THEN (p_opens_at::text || v_utc_offset)::timestamptz ELSE NULL END, + CASE WHEN p_closes_at IS NOT NULL THEN (p_closes_at::text || v_utc_offset)::timestamptz ELSE NULL END + + ) + RETURNING * INTO v_phase; + + + v_task_name := trim(concat( + COALESCE(v_form_type_code, ''), + CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END, + CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END + )); + v_phase_id := format('%s:%s:%s', p_budget_form_id, p_sheet, p_phase_code); + + PERFORM v3.log_event( + 'ACCESS_WINDOW_CHANGE', 'ACCESS', + jsonb_build_object( + 'entity_type', 'form', + 'entity_id', p_budget_form_id, + 'core_entity_type', 'form_phase', + 'core_entity_id', v_phase_id, + 'action', 'create', + 'phase_id', v_phase_id, + 'phase_name', p_phase_code, + 'task_id', p_budget_form_id, + 'form_id', p_budget_form_id, + 'budget_form_id', p_budget_form_id, + 'org_unit_id', v_org_unit_id, + 'org_unit_name', v_org_unit_title, + 'sheet', p_sheet, + 'phase_code', p_phase_code, + 'role', v_phase.role, + 'column_keys', v_phase.column_keys, + 'opens_at', v_phase.opens_at, + 'closes_at', v_phase.closes_at + ), + p_budget_form_id, null, v_org_unit_id + ); + + RETURN v_phase; + END; + $function$ + ; + """ + ) + op.execute( + """ + CREATE OR REPLACE FUNCTION v3.del_form_phase(p_budget_form_id integer, p_sheet character varying, p_phase_code character varying) + RETURNS boolean + LANGUAGE plpgsql + AS $function$ + DECLARE + v_old v3.form_phase; + v_org_unit_id INT; + v_org_unit_title VARCHAR; + v_form_type_code VARCHAR; + v_year INT; + v_task_name TEXT; + v_phase_id TEXT; + BEGIN + DELETE FROM v3.form_phase + WHERE budget_form_id = p_budget_form_id + AND sheet = p_sheet + AND phase_code = p_phase_code + RETURNING * INTO v_old; + + IF NOT FOUND THEN + RETURN FALSE; + END IF; + + SELECT bf.org_unit_id, ou.title, bf.form_type_code, bf.year + INTO v_org_unit_id, v_org_unit_title, v_form_type_code, v_year + FROM v3.budget_form bf + LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id + WHERE bf.id = p_budget_form_id; + v_task_name := trim(concat( + COALESCE(v_form_type_code, ''), + CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END, + CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END + )); + v_phase_id := format('%s:%s:%s', p_budget_form_id, p_sheet, p_phase_code); + + PERFORM v3.log_event( + 'ACCESS_WINDOW_CHANGE', 'ACCESS', + jsonb_build_object( + 'entity_type', 'form', + 'entity_id', p_budget_form_id, + 'core_entity_type', 'form_phase', + 'core_entity_id', v_phase_id, + 'action', 'delete', + 'phase_id', v_phase_id, + 'phase_name', p_phase_code, + 'task_id', p_budget_form_id, + 'task_name', NULLIF(v_task_name, ''), + 'form_id', p_budget_form_id, + 'budget_form_id', p_budget_form_id, + 'org_unit_id', v_org_unit_id, + 'org_unit_name', v_org_unit_title, + 'sheet', p_sheet, + 'phase_code', p_phase_code, + 'role', v_old.role, + 'column_keys', v_old.column_keys, + 'opens_at', v_old.opens_at, + 'closes_at', v_old.closes_at + ), + p_budget_form_id, null, v_org_unit_id + ); + RETURN TRUE; + END; + $function$ + ; + """ + ) + op.execute( + """ + CREATE OR REPLACE FUNCTION v3.upd_form_phase(p_budget_form_id integer, p_sheet character varying, p_phase_code character varying, p_role character varying DEFAULT NULL::character varying, p_column_keys text[] DEFAULT NULL::text[], p_opens_at timestamp without time zone DEFAULT NULL::timestamp without time zone, p_closes_at timestamp without time zone DEFAULT NULL::timestamp without time zone) + RETURNS v3.form_phase + LANGUAGE plpgsql + AS $function$ + DECLARE + v_old v3.form_phase; + v_new v3.form_phase; + v_is_extend BOOLEAN := FALSE; + v_has_non_extend_change BOOLEAN := FALSE; + v_has_window_change BOOLEAN := FALSE; + v_changes JSONB := '{}'::jsonb; + v_org_unit_id INT; + v_org_unit_title VARCHAR; + v_form_type_code VARCHAR; + v_year INT; + v_task_name TEXT; + v_phase_id TEXT; + v_utc_offset VARCHAR(3); + v_opens_at timestamptz; + v_closes_at timestamptz; + BEGIN + SELECT * INTO v_old + FROM v3.form_phase + WHERE budget_form_id = p_budget_form_id + AND sheet = p_sheet + AND phase_code = p_phase_code; + + IF NOT FOUND THEN + RAISE EXCEPTION 'form_phase не найден (form_id=%, sheet=%, phase=%)', + p_budget_form_id, p_sheet, p_phase_code; + END IF; + + SELECT bf.org_unit_id, ou.title, bf.form_type_code, bf.year, ou.utc_offset + INTO v_org_unit_id, v_org_unit_title, v_form_type_code, v_year, v_utc_offset + FROM v3.budget_form bf + LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id + WHERE bf.id = p_budget_form_id; + v_task_name := trim(concat( + COALESCE(v_form_type_code, ''), + CASE WHEN v_year IS NOT NULL THEN ' ' || v_year::TEXT ELSE '' END, + CASE WHEN v_org_unit_title IS NOT NULL THEN ' [' || v_org_unit_title || ']' ELSE '' END + )); + + IF p_opens_at is not null THEN + v_opens_at = (p_opens_at::text || v_utc_offset)::timestamptz; + END IF; + IF p_closes_at is not null THEN + v_closes_at = (p_closes_at::text || v_utc_offset)::timestamptz; + END IF; + + UPDATE v3.form_phase + SET role = COALESCE(p_role, role), + column_keys = COALESCE(p_column_keys, column_keys), + opens_at = COALESCE(v_opens_at, opens_at), + closes_at = COALESCE(v_closes_at, closes_at) + WHERE budget_form_id = p_budget_form_id + AND sheet = p_sheet + AND phase_code = p_phase_code + RETURNING * INTO v_new; + + + v_phase_id := format('%s:%s:%s', p_budget_form_id, p_sheet, p_phase_code); + + v_is_extend := v_new.closes_at > v_old.closes_at; + v_has_non_extend_change := v_old.role IS DISTINCT FROM v_new.role + OR v_old.column_keys IS DISTINCT FROM v_new.column_keys + OR v_old.opens_at IS DISTINCT FROM v_new.opens_at; + v_has_window_change := v_has_non_extend_change + OR (v_old.closes_at IS DISTINCT FROM v_new.closes_at AND NOT v_is_extend); + + IF v_old.role IS DISTINCT FROM v_new.role THEN + v_changes := v_changes || jsonb_build_object( + 'role', jsonb_build_object('before', v_old.role, 'after', v_new.role) + ); + END IF; + IF v_old.column_keys IS DISTINCT FROM v_new.column_keys THEN + v_changes := v_changes || jsonb_build_object( + 'column_keys', jsonb_build_object('before', v_old.column_keys, 'after', v_new.column_keys) + ); + END IF; + IF v_old.opens_at IS DISTINCT FROM v_new.opens_at THEN + v_changes := v_changes || jsonb_build_object( + 'opens_at', jsonb_build_object( + 'before', v_old.opens_at, + 'after', v_new.opens_at + ) + ); + END IF; + IF v_old.closes_at IS DISTINCT FROM v_new.closes_at THEN + v_changes := v_changes || jsonb_build_object( + 'closes_at', jsonb_build_object( + 'before', v_old.closes_at, + 'after', v_new.closes_at + ) + ); + END IF; + + IF v_is_extend THEN + PERFORM v3.log_event( + 'ACCESS_EXTEND', 'ACCESS', + jsonb_build_object( + 'entity_type', 'form', + 'entity_id', p_budget_form_id, + 'core_entity_type', 'form_phase', + 'core_entity_id', v_phase_id, + + 'phase_id', v_phase_id, + 'phase_name', p_phase_code, + 'task_id', p_budget_form_id, + 'task_name', NULLIF(v_task_name, ''), + 'form_id', p_budget_form_id, + 'budget_form_id', p_budget_form_id, + 'org_unit_id', v_org_unit_id, + 'org_unit_name', v_org_unit_title, + 'sheet', p_sheet, + 'phase_code', p_phase_code, + 'closes_at_before', v_old.closes_at, + 'closes_at_after', v_new.closes_at + ), + p_budget_form_id + ); + END IF; + + IF v_has_window_change THEN + PERFORM v3.log_event( + 'ACCESS_WINDOW_CHANGE', 'ACCESS', + jsonb_build_object( + 'entity_type', 'form', + 'entity_id', p_budget_form_id, + 'core_entity_type', 'form_phase', + 'core_entity_id', v_phase_id, + 'action', 'update', + 'phase_id', v_phase_id, + 'phase_name', p_phase_code, + 'task_id', p_budget_form_id, + 'task_name', NULLIF(v_task_name, ''), + 'form_id', p_budget_form_id, + 'budget_form_id', p_budget_form_id, + 'org_unit_id', v_org_unit_id, + 'org_unit_name', v_org_unit_title, + 'sheet', p_sheet, + 'phase_code', p_phase_code, + 'changes', v_changes + ), + p_budget_form_id, null, v_org_unit_id + ); + END IF; + + RETURN v_new; + END; + $function$ + ; + """ + ) + + +def downgrade() -> None: + pass diff --git a/api/src/api/v1/form_phases.py b/api/src/api/v1/form_phases.py index 615b017..79bd8cb 100644 --- a/api/src/api/v1/form_phases.py +++ b/api/src/api/v1/form_phases.py @@ -3,11 +3,17 @@ import datetime from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession +from src.services.project_service import ProjectService +from src.services.form3_phase_service import Form3PhaseService +from src.services.rf_project_report_service import RfProjectReportService from src.services.org_unit_service import OrgUnitService from src.db.models.app_user import AppUser from src.domain.schemas import ( BaseListResponse, BaseSingleResponse, + Form3PhaseCreate, + Form3PhaseResponse, + Form3PhaseUpdate, FormPhaseCreate, FormPhaseResponse, FormPhaseUpdate, @@ -66,8 +72,6 @@ async def get_form_phases( element.closes_at = element.closes_at.astimezone(tz) else: result = [] - for element in result: - pass return BaseListResponse( result=result, count=len(phases), @@ -168,3 +172,173 @@ async def delete_form_phase( ) if not deleted: raise HTTPException(404, "Этап не найден") + + +@router.get("/project/{project_id}") +async def get_form3_phases( + project_id: int, + year: int | None = None, + report_type: str | None = None, + phase_code: str | None = None, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(get_current_active_user_with_set_db), +) -> BaseListResponse[Form3PhaseResponse]: + f3p_service = Form3PhaseService(db) + report_service = RfProjectReportService(db) + project_service = ProjectService(db) + org_unit_service = OrgUnitService(db) + + reports = await report_service.get_list( + project_id=project_id, + year=year, + report_type=report_type, + ) + + phases = await f3p_service.get_list( + report_id=[el.id for el in reports], + phase_code=phase_code, + ) + if phases: + project = await project_service.get_instance(user=current_user, project_id=project_id) + org_unit = await org_unit_service.get(user=current_user, org_unit_id=project.org_unit_id) + result = [Form3PhaseResponse.model_validate(p) for p in phases] + if org_unit.utc_offset: + tz = timezone_from_offset(org_unit.utc_offset) + for element in result: + if element.opens_at: + element.opens_at = element.opens_at.astimezone(tz) + if element.closes_at: + element.closes_at = element.closes_at.astimezone(tz) + else: + result = [] + if result: + reports = {report.id: report for report in reports} + for i, element in enumerate(result): + element.year = reports[phases[i].rf_project_report_id].year + element.report_type = reports[phases[i].rf_project_report_id].report_type + return BaseListResponse( + result=result, + count=len(phases), + ) + + +@router.post("/project/{project_id}") +async def create_form3_phase( + project_id: int, + body: Form3PhaseCreate, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(require_admin), +) -> BaseSingleResponse[Form3PhaseResponse]: + report_service = RfProjectReportService(db) + report = await report_service.get( + project_id=project_id, + report_type=body.report_type, + year=body.year, + user=current_user, + load_project=True, + ) + if not report: + raise HTTPException(404, "Форма не найдена") + + org_unit_service = OrgUnitService(db) + org_unit = await org_unit_service.get(user=current_user, org_unit_id=report.project.org_unit_id) + + f3p_service = Form3PhaseService(db) + phase = await f3p_service.create( + report_id=report.id, + body=body, + user=current_user, + ) + + result = Form3PhaseResponse.model_validate(phase) + + if org_unit.utc_offset: + tz = timezone_from_offset(org_unit.utc_offset) + if result.opens_at: + result.opens_at = result.opens_at.astimezone(tz) + if result.closes_at: + result.closes_at = result.closes_at.astimezone(tz) + + result.year = body.year + result.report_type = body.report_type + + return BaseSingleResponse(result=result) + + +@router.patch("/project/{project_id}/{year}/{report_type}/{phase_code}") +async def update_form3_phase( + project_id: int, + year: int, + report_type: str, + phase_code: str, + body: Form3PhaseUpdate, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(require_admin), +) -> BaseSingleResponse[Form3PhaseResponse]: + report_service = RfProjectReportService(db) + report = await report_service.get( + project_id=project_id, + report_type=report_type, + year=year, + user=current_user, + load_project=True, + ) + if not report: + raise HTTPException(404, "Форма не найдена") + + f3p_service = Form3PhaseService(db) + phase = await f3p_service.update( + report_id=report.id, + phase_code=phase_code, + body=body, + user=current_user, + ) + + if not phase: + raise HTTPException(404, "Этап не найден") + + org_unit_service = OrgUnitService(db) + org_unit = await org_unit_service.get(user=current_user, org_unit_id=report.project.org_unit_id) + result = Form3PhaseResponse.model_validate(phase) + + if org_unit.utc_offset: + tz = timezone_from_offset(org_unit.utc_offset) + if result.opens_at: + result.opens_at = result.opens_at.astimezone(tz) + if result.closes_at: + result.closes_at = result.closes_at.astimezone(tz) + + result.year = year + result.report_type = report_type + + return BaseSingleResponse(result=result) + + +@router.delete("/project/{project_id}/{year}/{report_type}/{phase_code}") +async def delete_form3_phase( + project_id: int, + year: int, + report_type: str, + phase_code: str, + db: AsyncSession = Depends(get_db), + current_user: AppUser = Depends(require_admin), +): + report_service = RfProjectReportService(db) + report = await report_service.get( + project_id=project_id, + report_type=report_type, + year=year, + user=current_user, + load_project=True, + ) + if not report: + raise HTTPException(404, "Форма не найдена") + + f3p_service = Form3PhaseService(db) + deleted = await f3p_service.delete( + report_id=report.id, + phase_code=phase_code, + user=current_user, + ) + if not deleted: + raise HTTPException(404, "Этап не найден") diff --git a/api/src/db/models/project.py b/api/src/db/models/project.py index 42ffdde..cba2a1a 100644 --- a/api/src/db/models/project.py +++ b/api/src/db/models/project.py @@ -46,4 +46,4 @@ class Project(Base): # children: Mapped[list["Project"]] = relationship("Project", back_populates="parent") # type: ignore # org_unit = relationship("OrgUnit", back_populates="projects") # budget_lines: Mapped[list["BudgetLine"]] = relationship("BudgetLine", back_populates="project") # type: ignore -# rf_project_reports: Mapped[list["RfProjectReport"]] = relationship("RfProjectReport", back_populates="project") # type: ignore + rf_project_reports: Mapped[list["RfProjectReport"]] = relationship("RfProjectReport", back_populates="project") # type: ignore diff --git a/api/src/db/models/rf_project_report.py b/api/src/db/models/rf_project_report.py index 572686a..e4d1f2c 100644 --- a/api/src/db/models/rf_project_report.py +++ b/api/src/db/models/rf_project_report.py @@ -25,7 +25,7 @@ class RfProjectReport(Base): updated_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id")) updated_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now()) -# project: Mapped[Project] = relationship("Project", back_populates="rf_project_reports") + project: Mapped["Project"] = relationship("Project", back_populates="rf_project_reports") # type: ignore # creator: Mapped[AppUser | None] = relationship("AppUser", foreign_keys=[created_by]) # updater: Mapped[AppUser | None] = relationship("AppUser", foreign_keys=[updated_by]) # lines: Mapped[list[RfProjectReportLine]] = relationship("RfProjectReportLine", back_populates="report") diff --git a/api/src/domain/schemas.py b/api/src/domain/schemas.py index c473f9f..af51cc8 100644 --- a/api/src/domain/schemas.py +++ b/api/src/domain/schemas.py @@ -425,6 +425,35 @@ class FormPhaseUpdate(BaseModel): closes_at: datetime | None = None +class Form3PhaseResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + phase_code: str + role: str + column_keys: list[str] + opens_at: datetime + closes_at: datetime + year: int | None = None + report_type: str | None = None + + +class Form3PhaseCreate(BaseModel): + year: int + report_type: str + phase_code: str + role: FormPhaseRole + column_keys: list[str] + opens_at: datetime + closes_at: datetime + + +class Form3PhaseUpdate(BaseModel): + role: FormPhaseRole | None = None + column_keys: list[str] | None = None + opens_at: datetime | None = None + closes_at: datetime | None = None + + class AuditLogBase(BaseModel): """Базовая схема записи аудита (единый формат вывода как у auditlog).""" diff --git a/api/src/repository/form3_phase_repository.py b/api/src/repository/form3_phase_repository.py new file mode 100644 index 0000000..39b1488 --- /dev/null +++ b/api/src/repository/form3_phase_repository.py @@ -0,0 +1,116 @@ +from datetime import datetime + +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from src.db.models.form3_phase import Form3Phase + + +class Form3PhaseRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get_list( + self, + report_id: int | list[int], + phase_code: str | None = None, + ) -> list[Form3Phase]: + if isinstance(report_id, int): + query = select(Form3Phase).where(Form3Phase.rf_project_report_id == report_id) + else: + query = select(Form3Phase).where(Form3Phase.rf_project_report_id.in_(report_id)) + if phase_code is not None: + query = query.where(Form3Phase.phase_code == phase_code) + return (await self.db.execute(query)).scalars().all() + + async def get( + self, report_id: int, phase_code: str + ) -> Form3Phase | None: + query = ( + select(Form3Phase) + .where(Form3Phase.rf_project_report_id == report_id) + .where(Form3Phase.phase_code == phase_code) + .limit(1) + ) + return (await self.db.execute(query)).scalar_one_or_none() + + async def create( + self, + report_id: int, + phase_code: str, + role: str, + column_keys: list[str], + opens_at: datetime, + closes_at: datetime, + ) -> Form3Phase: + result = await self.db.execute( + text( + """ + SELECT + (v3.add_form3_phase( + :report_id, + :phase_code, + :role, + :column_keys, + CAST(:opens_at AS TIMESTAMP WITHOUT TIME ZONE), + CAST(:closes_at AS TIMESTAMP WITHOUT TIME ZONE) + )).rf_project_report_id + """ + ), + { + "report_id": report_id, + "phase_code": phase_code, + "role": role, + "column_keys": column_keys, + "opens_at": opens_at, + "closes_at": closes_at, + }, + ) + if result.scalar_one_or_none() is None: + return None + return await self.get(report_id, phase_code) + + async def update( + self, report_id: int, phase_code: str, data: dict + ) -> Form3Phase | None: + result = await self.db.execute( + text( + """ + SELECT + (v3.upd_form3_phase( + :report_id, + :phase_code, + :role, + :column_keys, + CAST(:opens_at AS TIMESTAMP WITHOUT TIME ZONE), + CAST(:closes_at AS TIMESTAMP WITHOUT TIME ZONE) + )).rf_project_report_id + """ + ), + { + "report_id": report_id, + "phase_code": phase_code, + "role": data.get("role"), + "column_keys": data.get("column_keys"), + "opens_at": data.get("opens_at"), + "closes_at": data.get("closes_at"), + }, + ) + if result.scalar_one_or_none() is None: + return None + return await self.get(report_id, phase_code) + + async def delete( + self, report_id: int, phase_code: str + ) -> bool: + result = await self.db.execute( + text( + "SELECT v3.del_form3_phase(:report_id, :phase_code)" + ), + { + "report_id": report_id, + "phase_code": phase_code, + }, + ) + deleted = result.scalar_one_or_none() + return bool(deleted) diff --git a/api/src/repository/project_repository.py b/api/src/repository/project_repository.py index f0492bf..d22c7c0 100644 --- a/api/src/repository/project_repository.py +++ b/api/src/repository/project_repository.py @@ -134,6 +134,23 @@ class ProjectRepository: if not row: return None return self._serialize_project(row[0], row[1], row[2], row[3]) + + async def get_instance( + self, + project_id: int, + org_unit_ids: list[int] | None = None, + ) -> Project | None: + query = ( + select( + Project, + ) + .where(Project.id == project_id) + ) + if org_unit_ids is not None: + if len(org_unit_ids) == 0: + return None + query = query.where(Project.org_unit_id.in_(org_unit_ids)) + return (await self.db.execute(query)).scalar_one_or_none() async def get_reports(self, project_id: int) -> list[dict]: line_count_subq = ( diff --git a/api/src/repository/rf_project_report_repository.py b/api/src/repository/rf_project_report_repository.py index 6337a80..90a1fea 100644 --- a/api/src/repository/rf_project_report_repository.py +++ b/api/src/repository/rf_project_report_repository.py @@ -1,5 +1,6 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload from src.db.models.rf_project_report import RfProjectReport @@ -15,6 +16,7 @@ class RfProjectReportRepository: year: int | None = None, report_type: str | None = None, project_id: int | None = None, + load_project: bool = False, ) -> RfProjectReport | None: assert report_id is not None or all((el is not None for el in (year, report_type, project_id))) query = select( @@ -29,5 +31,28 @@ class RfProjectReportRepository: RfProjectReport.project_id == project_id ) + if load_project: + query = query.options(joinedload(RfProjectReport.project)) query = query.limit(1) return (await self.db.execute(query)).scalar_one_or_none() + + async def get_list( + self, + report_id: int | None = None, + year: int | None = None, + report_type: str | None = None, + project_id: int | None = None, + ) -> list[RfProjectReport]: + query = select( + RfProjectReport + ) + if report_id is not None: + query = query.where(RfProjectReport.id == report_id) + if year is not None: + query = query.where(RfProjectReport.year == year) + if report_type is not None: + query = query.where(RfProjectReport.report_type == report_type) + if project_id is not None: + query = query.where(RfProjectReport.project_id == project_id) + + return (await self.db.execute(query)).scalars().all() diff --git a/api/src/services/form3_phase_service.py b/api/src/services/form3_phase_service.py new file mode 100644 index 0000000..969fd14 --- /dev/null +++ b/api/src/services/form3_phase_service.py @@ -0,0 +1,106 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from src.db.models.form3_phase import Form3Phase +from src.db.models.form_type import FormType, FormTypeEnum +from src.repository.form3_phase_repository import Form3PhaseRepository +from src.core.errors import AccessDeniedException, ValidationsError +from src.db.models.role import UserRoleEnum +from src.db.models.app_user import AppUser +from src.domain.schemas import Form3PhaseCreate, Form3PhaseUpdate + + +class Form3PhaseService: + def __init__(self, db: AsyncSession): + self.db = db + self.f3p_repo = Form3PhaseRepository(db) + + async def get_list( + self, + report_id: int | list[int], + phase_code: str | None = None, + ) -> list[Form3Phase]: + return await self.f3p_repo.get_list( + report_id=report_id, + phase_code=phase_code, + ) + + async def get( + self, report_id: int, phase_code: str, user: AppUser + ) -> Form3Phase | None: + return await self.f3p_repo.get( + report_id=report_id, + phase_code=phase_code, + ) + + def _validate_form3_phase( + self, + form_phase: Form3PhaseCreate | Form3PhaseUpdate, + ) -> None: + errors = {} + if form_phase.column_keys: + form_type = FormType(code=FormTypeEnum.FORM_3) + for column_key in form_phase.column_keys: + section = column_key.split(".") + if section[0] not in form_type.section_list: + if "column_keys" in errors: + errors["column_keys"].append(f"Колонка {column_key} некорректна") + else: + errors["column_keys"] = [f"Колонка {column_key} некорректна"] + if errors: + raise ValidationsError(errors=errors) + + + async def create( + self, + report_id: int, + body: Form3PhaseCreate, + user: AppUser, + ) -> Form3Phase: + if user.role_id != UserRoleEnum.ADMIN: + raise AccessDeniedException + + self._validate_form3_phase( + form_phase=body, + ) + return await self.f3p_repo.create( + report_id=report_id, + phase_code=body.phase_code, + role=body.role, + column_keys=body.column_keys, + opens_at=body.opens_at, + closes_at=body.closes_at, + ) + + async def update( + self, + report_id: int, + phase_code: str, + body: Form3PhaseUpdate, + user: AppUser, + ) -> Form3Phase: + if user.role_id != UserRoleEnum.ADMIN: + raise AccessDeniedException + + self._validate_form3_phase( + form_phase=body, + ) + + data = body.model_dump(exclude_unset=True) + if not data: + return await self.f3p_repo.get( + report_id=report_id, + phase_code=phase_code, + ) + return await self.f3p_repo.update( + report_id=report_id, + phase_code=phase_code, + data=data, + ) + + async def delete( + self, report_id: int, phase_code: str, user: AppUser, + ) -> bool: + return await self.f3p_repo.delete( + report_id=report_id, + phase_code=phase_code, + ) diff --git a/api/src/services/project_service.py b/api/src/services/project_service.py index 0f7f62e..170ac99 100644 --- a/api/src/services/project_service.py +++ b/api/src/services/project_service.py @@ -1,5 +1,6 @@ from sqlalchemy.ext.asyncio import AsyncSession +from src.db.models.project import Project from src.db.models.app_user import AppUser from src.db.models.role import UserRoleEnum from src.core.errors import AccessDeniedException, ValidationException @@ -41,6 +42,10 @@ class ProjectService: project["reports"] = await self.project_repo.get_reports(project_id=project_id) return project + async def get_instance(self, project_id: int, user: AppUser) -> Project | None: + org_unit_ids = await self._allowed_org_unit_ids(user) + return await self.project_repo.get_instance(project_id=project_id, org_unit_ids=org_unit_ids) + async def resolve_report_id( self, project_id: int, diff --git a/api/src/services/rf_project_report_service.py b/api/src/services/rf_project_report_service.py index 293bf03..9debf94 100644 --- a/api/src/services/rf_project_report_service.py +++ b/api/src/services/rf_project_report_service.py @@ -3,8 +3,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.db.models.rf_project_report import RfProjectReport from src.repository.rf_project_report_repository import RfProjectReportRepository -from src.db.models.rf_project_report_line import RfProjectReportLine -from src.repository.rf_project_report_line_repository import RfProjectReportLineRepository from src.db.models.app_user import AppUser @@ -22,10 +20,26 @@ class RfProjectReportService: year: int | None = None, report_type: str | None = None, project_id: int | None = None, + load_project: bool = False, ) -> RfProjectReport | None: return await self.pr_repo.get( report_id=report_id, year=year, report_type=report_type, project_id=project_id, + load_project=load_project, + ) + + async def get_list( + self, + report_id: int | None = None, + year: int | None = None, + report_type: str | None = None, + project_id: int | None = None, + ) -> list[RfProjectReport]: + return await self.pr_repo.get_list( + report_id=report_id, + year=year, + report_type=report_type, + project_id=project_id, ) diff --git a/api/tests/integration/test_form_phases_api_smoke.py b/api/tests/integration/test_form_phases_api_smoke.py index 3bd120d..9e760bf 100644 --- a/api/tests/integration/test_form_phases_api_smoke.py +++ b/api/tests/integration/test_form_phases_api_smoke.py @@ -154,3 +154,165 @@ def test_form_phases_delete(client, admin_tokens): ) assert response.status_code == 200 + +def test_form3_phases_list_smoke(client, admin_tokens): + response = client.get( + "/api/v1/stages/project/2", headers=_auth_headers(admin_tokens) + ) + assert response.status_code == 200 + payload = response.json() + assert "result" in payload + assert "count" in payload + assert isinstance(payload["result"], list) + + +def test_form3_phases_list_filter_smoke(client, admin_tokens): + + response = client.get( + "/api/v1/stages/project/2", headers=_auth_headers(admin_tokens) + ) + assert response.status_code == 200 + payload = response.json() + assert "count" in payload + count = payload["count"] + + response = client.get( + "/api/v1/stages/project/2?&phase_code=test", headers=_auth_headers(admin_tokens) + ) + assert response.status_code == 200 + payload = response.json() + assert "count" in payload + assert payload["count"] <= count + + +def test_form3_phases_create_forbidden(client, isp_tokens): + response = client.post( + "/api/v1/stages/project/2", + json={ + "year": 2026, + "report_type": "LIMIT", + "phase_code": "CODE1", + "role": "DFIP", + "column_keys": [ + "q1.adj_by_items", + "q1.adj_increase" + ], + "opens_at": "2027-07-20T00:00:00", + "closes_at": "2027-09-21T00:00:00" + }, + headers=_auth_headers(isp_tokens), + ) + assert response.status_code == 403 + payload = response.json() + assert isinstance(payload, dict) + assert "message" in payload + + +def test_form3_phases_update_forbidden(client, isp_tokens): + response = client.patch( + "/api/v1/stages/project/2/2026/LIMIT/RF_FILL_2026", + json={ + "role": "DFIP", + "column_keys": [ + "q1.adj_by_items", + "q1.adj_increase" + ], + "opens_at": "2027-07-20T00:00:00", + "closes_at": "2027-09-21T00:00:00" + }, + headers=_auth_headers(isp_tokens), + ) + assert response.status_code == 403 + payload = response.json() + assert isinstance(payload, dict) + assert "message" in payload + + +def test_form3_phases_delete_forbidden(client, isp_tokens): + response = client.delete( + "/api/v1/stages/project/2/2026/LIMIT/RF_FILL_2026", + headers=_auth_headers(isp_tokens), + ) + assert response.status_code == 403 + payload = response.json() + assert isinstance(payload, dict) + assert "message" in payload + + +def test_form3_phases_create(client, admin_tokens): + body = { + "year": 2026, + "report_type": "LIMIT", + "phase_code": "CODE1", + "role": "DFIP", + "column_keys": [ + "q1.adj_by_items", + "q1.adj_increase" + ], + "opens_at": "2027-07-20T00:00:00", + "closes_at": "2027-09-21T00:00:00" + } + response = client.post( + "/api/v1/stages/project/2", + json=body, + headers=_auth_headers(admin_tokens), + ) + assert response.status_code == 200 + payload = response.json() + assert "success" in payload + assert payload["success"] + assert "result" in payload + result = payload["result"] + assert isinstance(result, dict) + opens_at = result.pop("opens_at", None) + assert "+03" in opens_at + assert body.pop("opens_at") == opens_at.split("+")[0] + closes_at = result.pop("closes_at", None) + assert "+03" in closes_at + assert body.pop("closes_at") == closes_at.split("+")[0] + assert result == body + + assert payload["result"] == body + + +def test_form3_phases_update(client, admin_tokens): + body = { + "role": "DFIP", + "column_keys": [ + "q1.adj_by_items", + "q1.adj_increase" + ], + "opens_at": "2027-07-20T00:00:00", + "closes_at": "2027-09-21T00:00:00" + } + response = client.patch( + "/api/v1/stages/project/2/2026/LIMIT/RF_FILL_2026", + json=body, + headers=_auth_headers(admin_tokens), + ) + assert response.status_code == 200 + payload = response.json() + + assert "success" in payload + assert payload["success"] + assert "result" in payload + result = payload["result"] + assert isinstance(result, dict) + opens_at = result.pop("opens_at", None) + assert "+03" in opens_at + assert body.pop("opens_at") == opens_at.split("+")[0] + closes_at = result.pop("closes_at", None) + assert "+03" in closes_at + assert body.pop("closes_at") == closes_at.split("+")[0] + + body.update({'phase_code': 'RF_FILL_2026', 'report_type': 'LIMIT', 'year': 2026}) + assert result == body + + +def test_form3_phases_delete(client, admin_tokens): + response = client.delete( + "/api/v1/stages/project/2/2026/LIMIT/RF_FILL_2026", + headers=_auth_headers(admin_tokens), + ) + assert response.status_code == 200 + diff --git a/api/tests/integration/test_org_units_integration.py b/api/tests/integration/test_org_units_integration.py index 41e7720..ca41b96 100644 --- a/api/tests/integration/test_org_units_integration.py +++ b/api/tests/integration/test_org_units_integration.py @@ -119,6 +119,48 @@ def test_org_units_update_offset_smoke(client, admin_tokens, auth_headers): assert phase_old["closes_at"].split(old_offset)[0] == phase_new["closes_at"].split(new_offset)[0] + +def test_org_units_form3_update_offset_smoke(client, admin_tokens, auth_headers): + old_offset = "+03" + new_offset = "+04" + response = client.get( + "/api/v1/stages/project/2", headers=auth_headers(admin_tokens) + ) + assert response.status_code == 200 + payload = response.json() + assert "result" in payload + phases = payload["result"] + assert isinstance(phases, list) + assert len(phases) + phase_old = phases[0] + + response = client.patch( + "/api/v1/org-unit/1", + json={"utc_offset": new_offset}, + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200 + payload = response.json() + assert payload["result"]["id"] == 1 + assert payload["result"]["utc_offset"] == new_offset + + response = client.get( + "/api/v1/stages/project/2", headers=auth_headers(admin_tokens) + ) + assert response.status_code == 200 + payload = response.json() + assert "result" in payload + phases = payload["result"] + assert isinstance(phases, list) + assert len(phases) + phase_new = phases[0] + + assert new_offset in phase_new["opens_at"] + assert new_offset in phase_new["closes_at"] + assert phase_old["opens_at"].split(old_offset)[0] == phase_new["opens_at"].split(new_offset)[0] + assert phase_old["closes_at"].split(old_offset)[0] == phase_new["closes_at"].split(new_offset)[0] + + def test_org_units_update_not_found_error_shape(client, admin_tokens, auth_headers): response = client.patch( "/api/v1/org-unit/99999", diff --git a/api/tests/sqls/fixture.sql b/api/tests/sqls/fixture.sql index 6366b72..da9efeb 100644 --- a/api/tests/sqls/fixture.sql +++ b/api/tests/sqls/fixture.sql @@ -32,3 +32,16 @@ INSERT INTO v3.vsp (id,branch_id,reg_number,address,format,opened_at,placement_t (2,2,'Test12 -----','Тестовая 125','формат 15 ','2026-06-08',NULL,NULL,58,NULL,true,'2026-06-16 18:53:17.986651',false,'2026-06-09 09:04:33.29858',91,'123-1234',91,'субаренда','Работает ','12-45','2027-10-02'), (3,2,'3434','победы 12','формат','2026-06-01','субаренда', NULL,34,NULL,true,'2026-07-01 13:32:35.327097',false,'2026-06-29 15:53:50.236062',91,'3434',NULL,'2','примечание','567','2027-07-01'); SELECT setval('v3.vsp_id_seq', 3); + +INSERT INTO v3.project ("id","name","level",parent_id,project_type,vsp_format,placement_type,object_address,staff_count,total_area,org_unit_id) VALUES + (2,'PT6d112310_U','project',NULL,NULL,NULL,NULL,NULL,NULL,NULL,1); +SELECT setval('v3.project_id_seq', 3); + +INSERT INTO v3.rf_project_report (id,project_id,"year",report_type,created_by,created_at,updated_by,updated_at) VALUES + (1,2,2026,'LIMIT',NULL,'2026-05-12 12:43:56.20138',NULL,'2026-05-12 12:43:56.20138'), + (2,2,2026,'CURRENT_EXPENSES',NULL,'2026-05-12 12:43:56.20138',NULL,'2026-05-12 12:43:56.20138'); +SELECT setval('v3.rf_project_report_id_seq', 3); + +INSERT INTO v3.form3_phase (rf_project_report_id,phase_code,"role",column_keys,opens_at,closes_at) VALUES + (1,'RF_FILL_2026','EXECUTOR_RF','{{q1.adj_by_items,q1.adj_increase,q1.m1,q1.m2,q1.m3,q2.adj_by_items,q2.adj_increase,q2.m1,q2.m2,q2.m3,q3.adj_by_items,q3.adj_increase,q3.m1,q3.m2,q3.m3,q4.adj_by_items,q4.adj_increase,q4.m1,q4.m2,q4.m3,q4.spod}}','2030-01-01 03:00:00+03','2031-01-01 02:59:59+03'), + (2,'RF_FILL_2026','EXECUTOR_RF','{{q1.adj_by_items,q1.adj_increase,q1.m1,q1.m2,q1.m3,q2.adj_by_items,q2.adj_increase,q2.m1,q2.m2,q2.m3,q3.adj_by_items,q3.adj_increase,q3.m1,q3.m2,q3.m3,q4.adj_by_items,q4.adj_increase,q4.m1,q4.m2,q4.m3,q4.spod}}','2030-01-01 03:00:00+03','2031-01-01 02:59:59+03'); -- 2.47.2