From c976ae8c9294fbc36f26ddea2eb83c3d46c3e166 Mon Sep 17 00:00:00 2001 From: tsygankoviva Date: Tue, 21 Jul 2026 14:55:39 +0300 Subject: [PATCH 1/7] =?UTF-8?q?form-3-phases-timezones:=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=B0=D0=BA=D1=82=D0=B8=D1=80=D0=B2=D0=BE=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=8D=D1=82=D0=B0=D0=BF=D0=BE=D0=B2=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=203=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B,=20=D1=82?= =?UTF-8?q?=D0=B0=D0=B9=D0=BC=D0=B7=D0=BE=D0=BD=D1=8B=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=8D=D1=82=D0=B0=D0=BF=D0=BE=D0=B2=203=20=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D1=8B,=20=D0=BF=D0=BE=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20entity=5Ftype=20=D0=B4=D0=BB=D1=8F=20=D1=8D?= =?UTF-8?q?=D1=82=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'); From 0aa053c0c4300d19c2f8442bbd0d2fc121f25671 Mon Sep 17 00:00:00 2001 From: tsygankoviva Date: Tue, 21 Jul 2026 15:45:15 +0300 Subject: [PATCH 2/7] =?UTF-8?q?entity-type-hotfix:=20=D0=BF=D0=BE=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8E=20=D1=81=20entity-type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/alembic/versions/0009_phase3_timestamps.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/alembic/versions/0009_phase3_timestamps.py b/api/alembic/versions/0009_phase3_timestamps.py index 3a315fe..9b503ec 100644 --- a/api/alembic/versions/0009_phase3_timestamps.py +++ b/api/alembic/versions/0009_phase3_timestamps.py @@ -527,7 +527,7 @@ def upgrade() -> None: PERFORM v3.log_event( 'ACCESS_WINDOW_CHANGE', 'ACCESS', jsonb_build_object( - 'entity_type', 'form', + 'entity_type', 'task', 'entity_id', p_budget_form_id, 'core_entity_type', 'form_phase', 'core_entity_id', v_phase_id, @@ -595,7 +595,7 @@ def upgrade() -> None: PERFORM v3.log_event( 'ACCESS_WINDOW_CHANGE', 'ACCESS', jsonb_build_object( - 'entity_type', 'form', + 'entity_type', 'task', 'entity_id', p_budget_form_id, 'core_entity_type', 'form_phase', 'core_entity_id', v_phase_id, @@ -726,7 +726,7 @@ def upgrade() -> None: PERFORM v3.log_event( 'ACCESS_EXTEND', 'ACCESS', jsonb_build_object( - 'entity_type', 'form', + 'entity_type', 'task', 'entity_id', p_budget_form_id, 'core_entity_type', 'form_phase', 'core_entity_id', v_phase_id, @@ -752,7 +752,7 @@ def upgrade() -> None: PERFORM v3.log_event( 'ACCESS_WINDOW_CHANGE', 'ACCESS', jsonb_build_object( - 'entity_type', 'form', + 'entity_type', 'task', 'entity_id', p_budget_form_id, 'core_entity_type', 'form_phase', 'core_entity_id', v_phase_id, From ed0e7c805b11b72f5b0dc8dd3a070509ef4b4c3f Mon Sep 17 00:00:00 2001 From: tsygankoviva Date: Tue, 21 Jul 2026 17:38:16 +0300 Subject: [PATCH 3/7] =?UTF-8?q?form-4-expense-item-filters:=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D1=80=D1=8B=20=D0=B4=D0=BB=D1=8F=20expense-i?= =?UTF-8?q?tem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/src/api/v1/expense_item.py | 11 +++++++- api/src/db/models/__init__.py | 2 +- api/src/db/models/expense_item.py | 9 ++++++ api/src/repository/expense_item_repository.py | 28 +++++++++++++++++-- api/src/services/expense_item_service.py | 11 +++++++- 5 files changed, 55 insertions(+), 6 deletions(-) diff --git a/api/src/api/v1/expense_item.py b/api/src/api/v1/expense_item.py index 27ec439..b919b96 100644 --- a/api/src/api/v1/expense_item.py +++ b/api/src/api/v1/expense_item.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession +from src.db.models.form_type import FormTypeEnum from src.services.expense_item_service import ExpenseItemService from src.db.models.app_user import AppUser from src.api.v1.deps import require_admin, require_executor @@ -41,10 +42,18 @@ async def get_expense_item_by_id( db: AsyncSession = Depends(get_db), current_user: AppUser = Depends(require_executor), r_start: bool | None = None, + sheet: str | None = None, + direction: str | None = None, + form_type: FormTypeEnum | str | None = None ): """Получение записей expense item""" service = ExpenseItemService(db) - result = await service.get_list(r_start=r_start) + result = await service.get_list( + r_start=r_start, + sheet=sheet, + direction=direction, + form_type=form_type, + ) return BaseListResponse( success=True, diff --git a/api/src/db/models/__init__.py b/api/src/db/models/__init__.py index d8e60fa..b54b077 100644 --- a/api/src/db/models/__init__.py +++ b/api/src/db/models/__init__.py @@ -9,7 +9,7 @@ from src.db.models.ckk import Ckk from src.db.models.collegial_approval import CollegialApproval from src.db.models.contract_detail import ContractDetail from src.db.models.contract_summary import ContractSummary -from src.db.models.expense_item import ExpenseItem +from src.db.models.expense_item import ExpenseItem, ExpenseItemFormType from src.db.models.form3_phase import Form3Phase from src.db.models.form_limit import FormLimit from src.db.models.form_phase import FormPhase diff --git a/api/src/db/models/expense_item.py b/api/src/db/models/expense_item.py index b052809..6f1dc62 100644 --- a/api/src/db/models/expense_item.py +++ b/api/src/db/models/expense_item.py @@ -25,3 +25,12 @@ class ExpenseItem(Base): direction: Mapped[str | None] = mapped_column(String) parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.expense_item.id")) depth: Mapped[int | None] = mapped_column(Integer, default=0) + + + +class ExpenseItemFormType(Base): + __tablename__ = "expense_item_form_type" + __table_args__ = {"schema": "v3"} + + form_type_code: Mapped[str] = mapped_column(String, primary_key=True) + expense_item_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.expense_item.id"), primary_key=True) diff --git a/api/src/repository/expense_item_repository.py b/api/src/repository/expense_item_repository.py index 7b109e6..7d115c4 100644 --- a/api/src/repository/expense_item_repository.py +++ b/api/src/repository/expense_item_repository.py @@ -1,7 +1,8 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from src.db.models.expense_item import ExpenseItem +from src.db.models.form_type import FormTypeEnum +from src.db.models.expense_item import ExpenseItem, ExpenseItemFormType class ExpenseItemRepository: @@ -12,10 +13,31 @@ class ExpenseItemRepository: query = select(ExpenseItem).where(ExpenseItem.id == item_id).limit(1) return (await self.db.execute(query)).scalar_one_or_none() - async def get_list(self, r_start: bool | None = None) -> list[ExpenseItem]: + async def get_list( + self, + r_start: bool | None = None, + sheet: str | None = None, + direction: str | None = None, + form_type: FormTypeEnum | str | None = None, + ) -> list[ExpenseItem]: where = [] + query = select(ExpenseItem) + if form_type is not None: + query = query.join( + ExpenseItemFormType, + ExpenseItemFormType.expense_item_id == ExpenseItem.id, + ).where( + ExpenseItemFormType.form_type_code == (form_type.value if isinstance(form_type, FormTypeEnum) else form_type) + ) if r_start is not None: where.append(ExpenseItem.item_id.startswith("R")) + if sheet is not None: + where.append(ExpenseItem.sheet == sheet) + if direction is not None: + where.append(ExpenseItem.direction == direction) + query = query.where(*where) - query = select(ExpenseItem).where(*where) + + + return (await self.db.execute(query)).scalars().all() diff --git a/api/src/services/expense_item_service.py b/api/src/services/expense_item_service.py index e3982d8..d3b82cd 100644 --- a/api/src/services/expense_item_service.py +++ b/api/src/services/expense_item_service.py @@ -1,5 +1,6 @@ from sqlalchemy.ext.asyncio import AsyncSession +from src.db.models.form_type import FormTypeEnum from src.db.models.role import UserRoleEnum from src.db.models.app_user import AppUser from src.db.models.expense_item import ExpenseItem @@ -22,5 +23,13 @@ class ExpenseItemService: async def get_list( self, r_start: bool | None = None, + sheet: str | None = None, + direction: str | None = None, + form_type: FormTypeEnum | str | None = None, ) -> list[ExpenseItem]: - return await self.repo.get_list(r_start=r_start) + return await self.repo.get_list( + r_start=r_start, + sheet=sheet, + direction=direction, + form_type=form_type, + ) From 3c7aafc8bb942d5d68ec8a837cae7a9ea5f6fa63 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Fri, 24 Jul 2026 12:58:15 +0300 Subject: [PATCH 4/7] =?UTF-8?q?=D0=9E=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RealtimeTable/Cell/Cell/Cell.jsx | 308 +++++++++--------- .../RealtimeTable/Cell/EditCell/EditCell.jsx | 291 +++++++++-------- .../RealtimeTable/RealtimeTable.jsx | 79 ++--- .../components/RealtimeTable/tableColumns.jsx | 234 ++++++++----- 4 files changed, 498 insertions(+), 414 deletions(-) diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx index 3392699..b2e84e1 100644 --- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx +++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx @@ -1,41 +1,67 @@ -import React, { useEffect, useState } from 'react'; -import { useMemo } from 'react'; -import styled from '@emotion/styled'; +import React, { memo, useMemo, useCallback } from 'react'; import { useRealtime } from '../../contexts/RealtimeContext'; -import { toast } from 'react-toastify'; -const ContainerForText = styled.span` - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; - overflow: hidden; - white-space: pre-wrap; -`; +// Константы вне компонента +const BASE_CELL_STYLES = { + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + boxSizing: 'border-box', + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + border: '2px solid transparent', + padding: '8px', +}; -// Стилизованный компонент для плашки блокировки -const LockBadge = styled.div` - position: absolute; - top: 2px; - right: 2px; - background: rgba(0, 0, 0, 0.7); - color: white; - font-size: 9px; - padding: 1px 4px; - border-radius: 3px; - pointer-events: none; - z-index: 10; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - letter-spacing: 0.3px; - backdrop-filter: blur(4px); - border: 1px solid rgba(255, 255, 255, 0.1); -`; +const LOCKED_STYLES = { + border: '2px solid #e0e0e0', + backgroundColor: '#f5f5f5', + cursor: 'not-allowed', + opacity: 0.85, +}; + +const LOCK_BADGE_STYLES = { + position: 'absolute', + top: '2px', + right: '2px', + background: 'rgba(0, 0, 0, 0.7)', + color: 'white', + fontSize: '9px', + padding: '1px 4px', + borderRadius: '3px', + pointerEvents: 'none', + zIndex: 10, + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + letterSpacing: '0.3px', + backdropFilter: 'blur(4px)', + border: '1px solid rgba(255, 255, 255, 0.1)', +}; + +const CONTAINER_STYLES = { + display: '-webkit-box', + WebkitBoxOrient: 'vertical', + WebkitLineClamp: 3, + overflow: 'hidden', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', +}; + +const INTEGER_COLUMN_IDS = new Set([ + 'data.header.num_group', + 'data.nomenclature_group_id', + 'data.header.item_id', + 'data.article_id', + 'data.code_razdela', +]); -// Функция для определения яркости цвета const getColorBrightness = (hexColor) => { if (!hexColor) return 255; - const color = hexColor.replace('#', ''); - let r, g, b; if (color.length === 3) { r = parseInt(color[0] + color[0], 16); @@ -48,174 +74,132 @@ const getColorBrightness = (hexColor) => { } else { return 255; } - return (0.299 * r + 0.587 * g + 0.114 * b); }; -// Функция для подсветки текста -const highlightText = (text, searchQueries) => { - if (!text) return text; - const cleanSearchQueries = searchQueries.filter(q => (q !== undefined && q !== '')); - if (cleanSearchQueries.length == 0) return text; - - const escapedQueries = cleanSearchQueries.map(query => query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); - const regex = new RegExp(`(${escapedQueries.join('|')})`, 'gi'); - const parts = text.split(regex); - - return parts.map((part, index) => - regex.test(part) ? ( - - {part} - - ) : ( - part - ), - ); +const formatNumber = (value, asInteger = false) => { + if (value === null || value === undefined || value === '') return String(value || ''); + const num = Number(value); + if (isNaN(num)) return String(value); + return num.toLocaleString('ru-RU', { + minimumFractionDigits: asInteger ? 0 : 1, + maximumFractionDigits: asInteger ? 0 : 1, + useGrouping: true, + }); }; -const highlightWithFormatting = (originalValue, formattedValue, searchQueries) => { - if (!originalValue && originalValue !== 0) return formattedValue; +// Оптимизированная функция подсветки +const createHighlightedContent = (originalValue, displayValue, searchQueries) => { + const cleanQueries = searchQueries.filter(Boolean); + if (cleanQueries.length === 0) return displayValue; - const cleanSearchQueries = searchQueries.filter(q => (q !== undefined && q !== '')); - if (cleanSearchQueries.length == 0) return formattedValue; - - const originalString = String(originalValue); - const escapedQueries = cleanSearchQueries.map(query => query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + const searchStr = String(originalValue ?? displayValue); + const escapedQueries = cleanQueries.map(q => q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); const regex = new RegExp(`(${escapedQueries.join('|')})`, 'gi'); - if (!regex.test(originalString)) { - return formattedValue; - } + if (!regex.test(searchStr)) return displayValue; - return ( - { + if (!regex.test(part)) return part; + return React.createElement('mark', { + key: index, + style: { backgroundColor: '#ffeb3b', color: '#000', fontWeight: 'bold', padding: '0 2px', borderRadius: '2px', - }} - > - {formattedValue} - - ); -}; - -const INTEGER_COLUMN_IDS = new Set([ - 'data.header.num_group', - 'data.nomenclature_group_id', - 'data.header.item_id', - 'data.article_id', - 'data.code_razdela', -]); - -// Функция для форматирования числа в финансовый формат -const formatNumber = (value, asInteger = false) => { - if (value === null || value === undefined || value === '') return String(value || ''); - - const num = Number(value); - if (isNaN(num)) return String(value); - - if (asInteger) { - return num.toLocaleString('ru-RU', { - minimumFractionDigits: 0, - maximumFractionDigits: 0, - useGrouping: true, - }); - } - - return num.toLocaleString('ru-RU', { - minimumFractionDigits: 1, - maximumFractionDigits: 1, - useGrouping: true, + }, + }, part); }); }; -const Cell = React.memo(({ cell, row, column, onClick, globalFilter, columnFilter, backgroundColor, color, isEditable }) => { +const CellComponent = ({ + cell, + row, + column, + onClick, + globalFilter, + columnFilter, + backgroundColor, + color, + isEditable, + isUpdating, + onCellNumberClick, +}) => { const { lockedCells } = useRealtime(); const cellKey = `${row.id}_${column.id}`; - const isLocked = lockedCells.includes(cellKey); - const cellValue = cell.getValue(); - let originalValue = cellValue; - let displayValue = String(cellValue || ''); + // Мемоизация значения + const { rawValue, displayValue, isNumeric } = useMemo(() => { + const value = cell.getValue(); + const isNum = !isNaN(Number(value)) && value !== null && value !== undefined && value !== ''; + let display = String(value || ''); - const isNumeric = !isNaN(Number(cellValue)) && cellValue !== null && cellValue !== undefined && cellValue !== ''; - if (isNumeric) { - const asInteger = INTEGER_COLUMN_IDS.has(column.id); - displayValue = formatNumber(cellValue, asInteger); - } + if (isNum) { + const asInteger = INTEGER_COLUMN_IDS.has(column.id); + display = formatNumber(value, asInteger); + } + + return { rawValue: value, displayValue: display, isNumeric: isNum }; + }, [cell, column.id]); const highlightedContent = useMemo(() => { - if (!isNumeric) { - return highlightText(displayValue, [globalFilter, columnFilter]); - } - return highlightWithFormatting(originalValue, displayValue, [globalFilter, columnFilter]); - }, [originalValue, displayValue, globalFilter, columnFilter, isNumeric]); + const searchQueries = [globalFilter, columnFilter]; + return createHighlightedContent(rawValue, displayValue, searchQueries); + }, [rawValue, displayValue, globalFilter, columnFilter]); - // Определяем цвет текста на основе яркости фона - const getTextColor = () => { + const textColor = useMemo(() => { + if (isLocked) return '#999999'; const brightness = getColorBrightness(backgroundColor); - - // Если яркость меньше 128 (темный фон) - используем белый текст, иначе черный return brightness < 128 ? '#ffffff' : '#000000'; - }; + }, [backgroundColor, isLocked]); - // Базовые стили для заблокированной ячейки - const lockedStyles = isLocked ? { - border: '2px solid #e0e0e0', - backgroundColor: '#f5f5f5', - cursor: 'not-allowed', - opacity: 0.85, - } : {}; + const cellStyles = useMemo(() => ({ + ...BASE_CELL_STYLES, + backgroundColor: isLocked ? '#f5f5f5' : backgroundColor, + color: textColor, + ...(isLocked ? LOCKED_STYLES : {}), + }), [backgroundColor, isLocked, textColor]); + const lockBadge = useMemo(() => { + if (!isLocked) return null; + return
🔒
; + }, [isLocked]); - - // Определяем финальный цвет текста - const finalTextColor = isLocked ? '#999999' : getTextColor(); + const handleClick = useCallback(() => { + if (!isLocked && onClick) { + onClick(); + } + }, [isLocked, onClick]); return (
- {highlightedContent} - {isLocked && ( - - 🔒 - - )} + {highlightedContent} + {lockBadge}
); +}; + +const Cell = React.memo(CellComponent, (prevProps, nextProps) => { + // Возвращаем true если пропсы равны (не нужно перерендеривать) + return ( + prevProps.cell.getValue() === nextProps.cell.getValue() && + prevProps.row.id === nextProps.row.id && + prevProps.column.id === nextProps.column.id && + prevProps.globalFilter === nextProps.globalFilter && + prevProps.columnFilter === nextProps.columnFilter && + prevProps.backgroundColor === nextProps.backgroundColor && + prevProps.isEditable === nextProps.isEditable && + prevProps.isUpdating === nextProps.isUpdating && + prevProps.onCellNumberClick === nextProps.onCellNumberClick + ); }); export default Cell; \ No newline at end of file diff --git a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx index 0f87650..b9b4554 100644 --- a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx +++ b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx @@ -1,195 +1,228 @@ -// EditCell.js - обновленная версия -import { useEffect, useRef, useLayoutEffect, useState } from 'react'; +import React, { memo, useEffect, useRef, useLayoutEffect, useState, useCallback, useMemo } from 'react'; import { isFormula, extractFormula, calculateFormula, formatNumber } from '../../utils/formulaUtils'; import { saveToStorage, loadFromStorage } from '../../utils/localStorageUtils'; import { useRealtime } from '../../contexts/RealtimeContext'; -const EditCell = ({ refCell, onChange, disabled, value, tableId, cellId, table }) => { +const EDITOR_STYLES = { + position: 'absolute', + zIndex: 12, + top: 0, +}; + +const FORMULA_INDICATOR_STYLES = { + position: 'absolute', + top: '-20px', + left: '4px', + fontSize: '10px', + color: '#666', + background: '#f0f0f0', + padding: '2px 6px', + borderRadius: '3px', + pointerEvents: 'none', +}; + +const TEXTAREA_STYLES = { + width: '100%', + height: 'auto', + fieldSizing: 'content', + fontSize: 'inherit', +}; + +const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, table }) => { const { endEditing: contextEndEditing } = useRealtime(); - - const [curValue, setCurValue] = useState(value); - const [displayValue, setDisplayValue] = useState(value); - const [isFormulaMode, setIsFormulaMode] = useState(false); - - // Ключ для хранения формул в localStorage const storageKey = `formula_${tableId}_${cellId}`; - const trFrag = refCell.current.offsetParent.offsetParent; - const tdFrag = refCell.current.offsetParent; - const left = tdFrag.offsetLeft; - const width = refCell.current.offsetParent.offsetWidth; - const translateY = trFrag.style.transform; + // Объединенное состояние + const [state, setState] = useState(() => ({ + currentValue: value, + displayValue: value, + isFormulaMode: false, + })); - // Загружаем сохраненную формулу при монтировании + // Позиция ячейки + const [position, setPosition] = useState({ left: 0, width: 0, translateY: '' }); + const textareaRef = useRef(null); + + const refFinished = useRef(false); + + // Загрузка сохраненной формулы useEffect(() => { const savedFormula = loadFromStorage(storageKey); if (savedFormula && isFormula(savedFormula)) { - setIsFormulaMode(true); - setCurValue(savedFormula); - // Вычисляем и отображаем результат try { const formula = extractFormula(savedFormula); const result = calculateFormula(formula); - setDisplayValue(formatNumber(result)); - } catch (error) { - setDisplayValue('#ОШИБКА'); + setState({ + currentValue: savedFormula, + displayValue: formatNumber(result), + isFormulaMode: true, + }); + } catch { + setState({ + currentValue: savedFormula, + displayValue: '#ОШИБКА', + isFormulaMode: true, + }); } - } else { - setDisplayValue(value); } - }, [storageKey, value]); + }, [storageKey]); - function useAutoResize(value) { - const ref = useRef(null); + // Вычисление позиции + useLayoutEffect(() => { + if (!refCell?.current) return; - useLayoutEffect(() => { - const el = ref.current; - if (!el) return; + const td = refCell.current.offsetParent; + const tr = td?.offsetParent; + if (td && tr) { + setPosition({ + left: td.offsetLeft, + width: td.offsetWidth, + translateY: tr.style.transform || '', + }); + } + }, [refCell]); + + // Auto-resize + useLayoutEffect(() => { + const el = textareaRef.current; + if (!el) return; + + const resize = () => { el.style.height = 'auto'; el.style.minHeight = '3rem'; el.style.height = el.scrollHeight + 'px'; el.style.maxHeight = '180px'; - }, [value]); + }; - return ref; - } + resize(); + const observer = new ResizeObserver(resize); + observer.observe(el); - const ref = useAutoResize(value); + return () => observer.disconnect(); + }, [state.currentValue]); + // Фокус при монтировании useEffect(() => { - if (ref.current && !disabled) { - ref.current.focus(); - ref.current.setSelectionRange( - ref.current.value.length, - ref.current.value.length, - ); + const el = textareaRef.current; + if (el && !disabled) { + el.focus(); + el.setSelectionRange(el.value.length, el.value.length); } - }, [disabled, ref]); + }, [disabled]); - const handleSave = () => { - // Проверяем, является ли введенное значение формулой - if (isFormula(curValue)) { - // Сохраняем формулу в localStorage - saveToStorage(storageKey, curValue); - // Вычисляем и сохраняем результат + // Сохранение + const handleSave = useCallback(() => { + const { currentValue } = state; + + if (isFormula(currentValue)) { + saveToStorage(storageKey, currentValue); try { - const formula = extractFormula(curValue); + const formula = extractFormula(currentValue); const result = calculateFormula(formula); const formattedResult = formatNumber(result); - setDisplayValue(formattedResult); + setState(prev => ({ + ...prev, + displayValue: formattedResult, + isFormulaMode: true, + })); onChange?.(formattedResult); - setIsFormulaMode(true); - return; - } catch (error) { - setDisplayValue('#ОШИБКА'); - onChange?.(curValue); // Сохраняем как текст, если ошибка - return; + } catch { + setState(prev => ({ + ...prev, + displayValue: '#ОШИБКА', + isFormulaMode: true, + })); + onChange?.(currentValue); } } else { - // Если это не формула, удаляем сохраненную формулу localStorage.removeItem(storageKey); - setIsFormulaMode(false); - setDisplayValue(curValue); - onChange?.(curValue); + setState(prev => ({ + ...prev, + displayValue: currentValue, + isFormulaMode: false, + })); + onChange?.(currentValue); } - }; + }, [state, storageKey, onChange]); - const handleChangeValue = (event) => { - const input = event.target.value; - setCurValue(input); - // При редактировании показываем введенный текст - setDisplayValue(input); - }; - - // Функция для отображения значения в ячейке - const getDisplayValue = () => { - if (isFormulaMode && isFormula(curValue)) { - return displayValue; - } - return displayValue; - }; - - const finishEditing = () => { + // Завершение редактирования + const finishEditing = useCallback(() => { + if(refFinished.current) return; + refFinished.current = true; if (!disabled) { handleSave(); } - const editingCell = table.getState().editingCell || null; + const editingCell = table.getState().editingCell; if (editingCell) { contextEndEditing?.(editingCell.row, editingCell.column); } - }; + }, [disabled, handleSave, table, contextEndEditing]); useEffect(() => { const handleClickOutside = (event) => { - // Проверяем, был ли клик внутри элемента редактирования - const editElement = ref.current; - if (editElement && !editElement.contains(event.target)) { + const el = textareaRef.current; + if (el && !el.contains(event.target)) { finishEditing(); } }; - document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [finishEditing]); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [ref, finishEditing]); + // Обработчики событий + const handleChange = useCallback((e) => { + const value = e.target.value; + setState(prev => ({ + ...prev, + currentValue: value, + displayValue: value, + })); + }, []); + + const handleKeyDown = useCallback((e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + finishEditing(); + } + }, [finishEditing]); + + // Мемоизация стилей + const editorStyles = useMemo(() => ({ + ...EDITOR_STYLES, + left: position.left, + width: position.width, + transform: position.translateY, + }), [position]); + + const textareaStyles = useMemo(() => ({ + ...TEXTAREA_STYLES, + backgroundColor: state.isFormulaMode ? '#f8f9fa' : 'white', + border: state.isFormulaMode ? '2px solid #4a90d9' : undefined, + }), [state.isFormulaMode]); return ( -
- {isFormulaMode && ( -
- fx -
+
+ {state.isFormulaMode && ( +
fx
)}