diff --git a/api/alembic/versions/0009_phase3_timestamps.py b/api/alembic/versions/0009_phase3_timestamps.py new file mode 100644 index 0000000..9b503ec --- /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', 'task', + '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', 'task', + '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', 'task', + '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', 'task', + '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/alembic/versions/0010_form4.py b/api/alembic/versions/0010_form4.py new file mode 100644 index 0000000..3ab795b --- /dev/null +++ b/api/alembic/versions/0010_form4.py @@ -0,0 +1,84 @@ +import os +import re +import time + +from alembic import op +import sqlalchemy as sa + + +revision = "0010" +down_revision = "0009" +branch_labels = None +depends_on = None + + +_DOLLAR_TAG_RE = re.compile(r"\$\w+\$") + + +def _find_dollar_tag(line: str) -> str | None: + m = _DOLLAR_TAG_RE.search(line.strip()) + return m.group(0) if m else None + + +def _split_statements(sql: str) -> list[str]: + statements: list[str] = [] + current: list[str] = [] + in_dollar = False + dollar_tag: str | None = None + + for line in sql.split("\n"): + stripped = line.strip() + if stripped.startswith("--"): + continue + + if not in_dollar: + tag = _find_dollar_tag(stripped) + if tag and tag.endswith("$") and tag.startswith("$"): + dollar_tag = tag + in_dollar = True + current.append(line) + continue + + if in_dollar and dollar_tag and stripped.startswith(dollar_tag): + after = stripped[len(dollar_tag):].strip() + if after == ";" or after == "": + in_dollar = False + dollar_tag = None + if after == ";": + current.append(line) + statements.append("\n".join(current)) + current = [] + continue + + if not in_dollar and stripped.rstrip().endswith(";"): + current.append(line) + statements.append("\n".join(current)) + current = [] + continue + + current.append(line) + + remaining = "\n".join(current).strip() + if remaining: + statements.append(remaining) + + return statements + + +def upgrade() -> None: + ddl_path = os.path.join(os.path.dirname(__file__), "sql", "0010_form4.sql") + with open(ddl_path) as f: + content = f.read() + + statements = _split_statements(content) + for stmt in statements: + stripped = stmt.strip().rstrip(";").strip() + if not stripped: + continue + if all(l.strip().startswith("--") or not l.strip() for l in stripped.split("\n")): + continue + op.execute(stripped) + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/sql/0010_form4.sql b/api/alembic/versions/sql/0010_form4.sql new file mode 100644 index 0000000..55821cf --- /dev/null +++ b/api/alembic/versions/sql/0010_form4.sql @@ -0,0 +1,852 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- migrate_form4_project.sql — выделение FORM_4 программ/проектов в отдельную +-- сущность v3.form4_project (перестают делить таблицу с FORM_3 v3.project). +-- ════════════════════════════════════════════════════════════════════════════ +-- Мотивация: v3.project исторически был FORM_4-таблицей (program/project + +-- parent_id), FORM_3-редизайн навесил сверху свои атрибуты (project_type, +-- vsp_format, address, area, staff, org_unit_id) и строгий chk_v3_project_name. +-- Два разных концепта под одним словом «project» → разреженные колонки, нет +-- защиты FK, констрейнт вынужден ветвиться по «виду» строки. Разводим. +-- +-- Новая таблица v3.form4_project — точный костяк без FORM_3-полей: +-- program (level='program', parent_id=NULL) +-- project (level='project', parent_id=program) +-- +-- Дискриминатор FORM_4 vs FORM_3: FORM_3-строки — те, что ссылаются из +-- v3.rf_project_report (add_project всегда создаёт отчёты для КАЖДОЙ строки, +-- включая программы). Всё остальное в v3.project — FORM_4. +-- +-- Порядок: после init.sql + всех migrate_form3*/migrate_to_identity (нужна +-- финальная форма v3.project с org_unit_id). Один прогон — copy защищён +-- проверкой на пустоту form4_project. +-- ════════════════════════════════════════════════════════════════════════════ + +-- ───── 1. Новая таблица ───────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS v3.form4_project ( + id SERIAL PRIMARY KEY, + name VARCHAR NOT NULL, + level VARCHAR CHECK (level IN ('program', 'project')), + section_code VARCHAR, + parent_id INTEGER REFERENCES v3.form4_project(id), + -- org_unit_id INTEGER REFERENCES v3.org_unit(id), + form_id INTEGER REFERENCES v3.budget_form(id), + -- project (level='project') обязан иметь родителя-программу; program — нет. + CONSTRAINT chk_form4_project_parent CHECK ( + (level = 'program' AND parent_id IS NULL) + OR (level = 'project') + ), + CONSTRAINT chk_form4_project_section_code CHECK ( + (level = 'program') = (section_code IS NOT NULL) + ) +); + +CREATE INDEX ix_form4_project_section_code + ON v3.form4_project(section_code) WHERE level = 'program'; +CREATE INDEX IF NOT EXISTS ix_v3_form4_project_parent ON v3.form4_project(parent_id); +CREATE INDEX IF NOT EXISTS ix_v3_form4_project_form ON v3.form4_project(form_id); + +-- ───── 2. Перенос FORM_4-строк из v3.project (с сохранением id) ────────────── +-- FK budget_line.project_id ссылается по id → id сохраняем 1:1. +-- Копируем обе роли (program + project) одним INSERT: self-FK parent_id внутри +-- одного оператора проверяется в конце, порядок строк не важен. +-- DO $migrate$ +-- BEGIN +-- IF NOT EXISTS (SELECT 1 FROM v3.form4_project) THEN +-- INSERT INTO v3.form4_project (id, name, level, parent_id, org_unit_id) +-- SELECT p.id, p.name, p.level, p.parent_id, (p.org_unit_id +-- FROM v3.project p +-- WHERE p.id NOT IN ( +-- SELECT project_id FROM v3.rf_project_report WHERE project_id IS NOT NULL +-- ); + +-- -- Синхронизируем sequence, чтобы новые INSERT не конфликтовали по id. +-- PERFORM setval('v3.form4_project_id_seq', +-- GREATEST((SELECT COALESCE(MAX(id), 0) FROM v3.form4_project), 1)); + +-- RAISE NOTICE 'form4_project: перенесено % строк из v3.project', +-- (SELECT count(*) FROM v3.form4_project); +-- ELSE +-- RAISE NOTICE 'form4_project уже наполнена — перенос пропущен'; +-- END IF; +-- END +-- $migrate$; + +-- ───── 3. Перецепка FK budget_line.project_id на новую таблицу ────────────── +ALTER TABLE v3.budget_line DROP CONSTRAINT IF EXISTS budget_line_project_id_fkey; +ALTER TABLE v3.budget_line ADD CONSTRAINT budget_line_project_id_fkey FOREIGN KEY (project_id) REFERENCES v3.form4_project(id); + +-- ───── 4. Удаление перенесённых строк из v3.project ───────────────────────── +-- После перецепки budget_line на v3.project больше не ссылается ничего FORM_4. +-- Остаются только FORM_3-строки (referenced by rf_project_report). +DELETE FROM v3.project p + WHERE p.id NOT IN ( + SELECT project_id FROM v3.rf_project_report WHERE project_id IS NOT NULL + ); + +-- ───── 5. Восстановление chk_v3_project_name в строгой форме ───────────────── +-- FORM_4-строки ушли — v3.project теперь чисто FORM_3, правило коротких имён +-- ВСП применимо ко всей таблице (как до form4_write.sql). NOT VALID сохраняем. +ALTER TABLE v3.project DROP CONSTRAINT IF EXISTS chk_v3_project_name; +ALTER TABLE v3.project + ADD CONSTRAINT chk_v3_project_name + CHECK (name IS NULL OR ( + length(name) <= 30 AND + name !~ E'[+\\-/\\\\=&* ]' + )) NOT VALID; + + +-- ════════════════════════════════════════════════════════════════════════════ +-- v3.add_form4_program / v3.add_form4_project — создание программ и проектов +-- FORM_4 в выделенной таблице v3.form4_project (иерархия program → project). +-- ════════════════════════════════════════════════════════════════════════════ +-- FORM_4 хранит иерархию отдельно от FORM_3 (v3.project) — см. +-- migrate_form4_project.sql. Костяк: +-- program (level='program', parent_id=NULL) → depth=1 (GROUP) в v_form4 +-- project (level='project', parent_id=program) → depth=2 (ITEM) в v_form4 +-- Строки бюджета привязываются к проекту через budget_line.project_id. +-- +-- Никаких FORM_3-атрибутов / rf_project_report / snapshot фаз — это ветка +-- budget_form. p_org_unit_id опционален (read-path FORM_4 по нему не фильтрует, +-- заполняется для аудита/принадлежности). +-- +-- Порядок применения: после migrate_form4_project.sql (нужна таблица +-- v3.form4_project) и audit.sql (нужна v3.log_event). +-- ════════════════════════════════════════════════════════════════════════════ + +-- ───── 1. add_form4_program ───────────────────────────────────────────────── +DROP FUNCTION IF EXISTS v3.add_form4_program(VARCHAR, INT, VARCHAR, INT); + +CREATE FUNCTION v3.add_form4_program( + p_name VARCHAR, + p_form_id INT, + p_section_code VARCHAR DEFAULT NULL +) +RETURNS TABLE ( + project_id INT, + row_type VARCHAR, + depth INT, + sort_order BIGINT, + data JSONB +) AS $function$ +#variable_conflict use_column +DECLARE + v_pid INT; +BEGIN + IF p_name IS NULL OR length(trim(p_name)) = 0 THEN + RAISE EXCEPTION 'p_name обязателен'; + END IF; + IF p_form_id IS NULL THEN + RAISE EXCEPTION 'p_form_id обязателен'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM v3.budget_form bf + WHERE bf.form_type_code = 'FORM_4' AND bf.id = p_form_id + ) THEN + RAISE EXCEPTION 'Только FORM_4'; + END IF; + IF p_section_code IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM v3.expense_item + WHERE section_code = p_section_code AND depth = 0 + ) THEN + RAISE EXCEPTION 'section_code=% не существует в expense_item (depth=0)', p_section_code; + END IF; + + INSERT INTO v3.form4_project (name, level, parent_id, form_id, section_code) + VALUES (p_name, 'program', NULL, p_form_id, p_section_code) + RETURNING id INTO v_pid; + + PERFORM v3.log_event( + 'PROJECT_CREATE', 'PROJECT', + jsonb_strip_nulls(jsonb_build_object( + 'project_id', v_pid, + 'name', p_name, + 'level', 'program', + 'form_id', p_form_id, + 'form_type', 'FORM_4' + )), + p_form_id, NULL, NULL + ); + + RETURN QUERY + SELECT v_pid, v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form4_sheet_jsonb(p_form_id, p_section_code, NULL) v + WHERE (v.row_type = 'GROUP' AND (v.data->'header'->>'program_id')::INT = v_pid) + OR (v.row_type = 'ROOT' AND v.data->'header'->>'section_code' = p_section_code) + ORDER BY v.sort_order; +END; +$function$ +LANGUAGE plpgsql VOLATILE; + +-- ───── 2. add_form4_project ───────────────────────────────────────────────── +DROP FUNCTION IF EXISTS v3.add_form4_project(VARCHAR, INT); + +CREATE FUNCTION v3.add_form4_project( + p_name VARCHAR, + p_program_id INT +) +RETURNS TABLE ( + project_id INT, + row_type VARCHAR, + depth INT, + sort_order BIGINT, + data JSONB +) +AS $function$ +#variable_conflict use_column +DECLARE + v_pid INT; + v_section_code VARCHAR; + v_form_id INT; +BEGIN + IF p_name IS NULL OR length(trim(p_name)) = 0 THEN + RAISE EXCEPTION 'p_name обязателен'; + END IF; + IF p_program_id IS NULL THEN + RAISE EXCEPTION 'p_program_id обязателен (проект создаётся под программой)'; + END IF; + IF NOT EXISTS (SELECT 1 FROM v3.form4_project fp + WHERE fp.id = p_program_id AND fp.level = 'program') THEN + RAISE EXCEPTION 'program id=% не существует или не имеет level=program', p_program_id; + END IF; + + SELECT form_id INTO v_form_id FROM v3.form4_project WHERE id = p_program_id; + + INSERT INTO v3.form4_project (name, level, parent_id, form_id) + VALUES (p_name, 'project', p_program_id, v_form_id) + RETURNING id INTO v_pid; + + PERFORM v3.log_event( + 'PROJECT_CREATE', 'PROJECT', + jsonb_strip_nulls(jsonb_build_object( + 'project_id', v_pid, + 'name', p_name, + 'level', 'project', + 'parent_id', p_program_id, + 'form_id', v_form_id, + 'form_type', 'FORM_4' + )), + v_form_id, NULL, NULL + ); + + SELECT fp.section_code INTO v_section_code + FROM v3.form4_project fp + WHERE fp.id = p_program_id AND fp.level = 'program'; + + IF v_section_code IS NOT NULL THEN + RETURN QUERY + SELECT v_pid, v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form4_sheet_jsonb(v_form_id, v_section_code, NULL) v + WHERE (v.row_type = 'ITEM' AND (v.data->'header'->>'project_id')::INT = v_pid) + OR (v.row_type = 'GROUP' AND (v.data->'header'->>'program_id')::INT = p_program_id) + OR (v.row_type = 'ROOT' AND v.data->'header'->>'section_code' = v_section_code) + ORDER BY v.sort_order; + END IF; +END; +$function$ +LANGUAGE plpgsql VOLATILE; + + +-- ════════════════════════════════════════════════════════════════════════════ +-- v3.v_form4_sheet_jsonb — JSONB-выдача FORM_4 для АХР_Р / КВ_Р / Операц +-- ════════════════════════════════════════════════════════════════════════════ +-- Иерархия (4 уровня): +-- ROOT depth=0 — section_code (1.00.0/3.00.0/4.00.0/5.00.0) +-- PROGRAM depth=1 — v3.form4_project level=program +-- PROJECT depth=2 — v3.form4_project level=project (parent_id=program) +-- INPUT depth=3 — budget_line (R-код) +-- +-- Финансовые блоки (plan, seq_dfip, approved, booking, q1-q4, totals) +-- агрегируются на уровнях ROOT/PROGRAM/PROJECT через v3.jsonb_numeric_sum. +-- Текстовые поля (counterparty, comment, justification…) на агрегатных +-- уровнях NULL'ятся (jsonb_numeric_sum их игнорирует). +-- +-- p_form_id — id формы FORM_4 +-- p_sheet — 'AHR' / 'CAP' / 'OPER' +-- p_sections — массив включаемых блоков (как в v_form4_sheet_lines_jsonb) +-- ════════════════════════════════════════════════════════════════════════════ + +-- ───── 1. Хелпер: рекурсивная сумма численных листьев двух JSONB ────────── +CREATE OR REPLACE FUNCTION v3.jsonb_numeric_sum(a JSONB, b JSONB) +RETURNS JSONB LANGUAGE sql IMMUTABLE AS $function$ + SELECT CASE + WHEN a IS NULL THEN b + WHEN b IS NULL THEN a + WHEN jsonb_typeof(a) = 'number' AND jsonb_typeof(b) = 'number' THEN + to_jsonb((a)::text::numeric + (b)::text::numeric) + WHEN jsonb_typeof(a) = 'object' AND jsonb_typeof(b) = 'object' THEN ( + SELECT jsonb_object_agg(k, + v3.jsonb_numeric_sum(a -> k, b -> k) + ) + FROM ( + SELECT k FROM jsonb_object_keys(a) k + UNION + SELECT k FROM jsonb_object_keys(b) k + ) keys + ) + -- одна сторона число/объект, другая нет → берём числовую/объектную + WHEN jsonb_typeof(a) IN ('number','object') THEN a + WHEN jsonb_typeof(b) IN ('number','object') THEN b + ELSE NULL + END; +$function$; + +DROP AGGREGATE IF EXISTS v3.jsonb_sum(JSONB); +CREATE AGGREGATE v3.jsonb_sum(JSONB) ( + SFUNC = v3.jsonb_numeric_sum, + STYPE = JSONB +); + +-- ───── 2. Основная функция ───────────────────────────────────────────────── +CREATE OR REPLACE FUNCTION v3.v_form4_sheet_jsonb( + p_form_id INT, + p_sheet VARCHAR, + p_sections TEXT[] DEFAULT NULL +) +RETURNS TABLE ( + row_type VARCHAR, + depth INT, + sort_order BIGINT, + data JSONB +) +AS $function$ + -- ═══ 1. Базовые INPUT-строки с расширенной иерархией ═══════════════════════ + WITH inp_raw AS ( + SELECT j.* + FROM v3.v_form4_sheet_lines_jsonb(p_form_id, p_sheet, p_sections) j + WHERE j.row_type = 'INPUT' + ), + inp AS ( + SELECT + i.*, + bl.project_id AS line_project_id, + + -- --- разрешение проекта --- + -- A. bl.project_id → project (level='project') + -- B. bl.project_id → program (level='program') — тогда проекта нет + CASE WHEN prj.id IS NOT NULL THEN prj.id END AS prj_id, + CASE WHEN prj.id IS NOT NULL THEN prj.name END AS prj_name, + + -- --- разрешение программы --- + -- A. через проект: prog_via_prj + -- B. напрямую: prog_direct + COALESCE(prog_via_prj.id, prog_direct.id) AS prog_id, + COALESCE(prog_via_prj.name, prog_direct.name) AS prog_name, + + -- --- section (depth=0) --- + sec.id AS sec_id, + sec.section_code AS sec_code, + sec.name AS sec_name + + FROM inp_raw i + JOIN v3.budget_line bl ON bl.id = i.line_id + + -- проект (level='project') + LEFT JOIN v3.form4_project prj + ON prj.id = bl.project_id AND prj.level = 'project' + + -- программа через проект + LEFT JOIN v3.form4_project prog_via_prj + ON prog_via_prj.id = prj.parent_id AND prog_via_prj.level = 'program' + + -- программа напрямую (bl.project_id → program) + LEFT JOIN v3.form4_project prog_direct + ON prog_direct.id = bl.project_id AND prog_direct.level = 'program' + + -- раздел depth=0 (через expense_item) + JOIN v3.expense_item ei ON ei.id = bl.expense_item_id + LEFT JOIN LATERAL ( + WITH RECURSIVE up AS ( + SELECT id, parent_id, name, section_code, depth + FROM v3.expense_item WHERE id = ei.id + UNION ALL + SELECT e.id, e.parent_id, e.name, e.section_code, e.depth + FROM v3.expense_item e JOIN up u ON u.parent_id = e.id + ) + SELECT id, name, section_code FROM up WHERE depth = 0 LIMIT 1 + ) sec ON TRUE + ), + + -- ═══ 2. Все программы (включая пустые) ═════════════════════════════════════ + all_programs AS ( + SELECT + fp.id AS prog_id, + fp.name AS prog_name, + fp.section_code, + sec.id AS sec_id, + sec.section_code AS sec_code, + sec.name AS sec_name + FROM v3.form4_project fp + LEFT JOIN LATERAL ( + SELECT id, section_code, name + FROM v3.expense_item + WHERE section_code = fp.section_code AND depth = 0 + LIMIT 1 + ) sec ON TRUE + WHERE fp.level = 'program' + AND (p_sections IS NULL OR fp.section_code = ANY(p_sections)) + AND (fp.section_code = p_sheet) AND fp.form_id = p_form_id + ), + + -- ═══ 3. Все проекты под программами (включая пустые) ═══════════════════════ + all_projects AS ( + SELECT + prj.id AS prj_id, + prj.name AS prj_name, + prj.parent_id AS prog_id, + ap.sec_id, ap.sec_code, ap.sec_name + FROM v3.form4_project prj + JOIN all_programs ap ON ap.prog_id = prj.parent_id + WHERE prj.level = 'project' AND (ap.section_code = p_sheet) + ), + + -- ═══ 4. Агрегаты с финансовыми данными (только из inp) ════════════════════ + + -- PROJECT-агрегаты + prj_agg AS ( + SELECT + sec_id, MAX(sec_code) AS sec_code, MAX(sec_name) AS sec_name, + prog_id, MAX(prog_name) AS prog_name, + prj_id, MAX(prj_name) AS prj_name, + v3.jsonb_sum(plan_data) AS plan_data, + v3.jsonb_sum(seq_dfip_data) AS seq_dfip_data, + v3.jsonb_sum(approved_data) AS approved_data, + v3.jsonb_sum(contract_summary_data) AS contract_summary_data, + v3.jsonb_sum(allocation_data) AS allocation_data, + v3.jsonb_sum(reserve_data) AS reserve_data, + v3.jsonb_sum(collegial_data) AS collegial_data, + v3.jsonb_sum(ckk_data) AS ckk_data, + v3.jsonb_sum(contract_data) AS contract_data, + v3.jsonb_sum(booking_data) AS booking_data, + v3.jsonb_sum(q1_data) AS q1_data, + v3.jsonb_sum(q2_data) AS q2_data, + v3.jsonb_sum(q3_data) AS q3_data, + v3.jsonb_sum(q4_data) AS q4_data, + v3.jsonb_sum(totals_data) AS totals_data + FROM inp WHERE prj_id IS NOT NULL + GROUP BY sec_id, prog_id, prj_id + ), + + -- PROGRAM-агрегаты + prog_agg AS ( + SELECT + sec_id, MAX(sec_code) AS sec_code, MAX(sec_name) AS sec_name, + prog_id, MAX(prog_name) AS prog_name, + v3.jsonb_sum(plan_data) AS plan_data, + v3.jsonb_sum(seq_dfip_data) AS seq_dfip_data, + v3.jsonb_sum(approved_data) AS approved_data, + v3.jsonb_sum(contract_summary_data) AS contract_summary_data, + v3.jsonb_sum(allocation_data) AS allocation_data, + v3.jsonb_sum(reserve_data) AS reserve_data, + v3.jsonb_sum(collegial_data) AS collegial_data, + v3.jsonb_sum(ckk_data) AS ckk_data, + v3.jsonb_sum(contract_data) AS contract_data, + v3.jsonb_sum(booking_data) AS booking_data, + v3.jsonb_sum(q1_data) AS q1_data, + v3.jsonb_sum(q2_data) AS q2_data, + v3.jsonb_sum(q3_data) AS q3_data, + v3.jsonb_sum(q4_data) AS q4_data, + v3.jsonb_sum(totals_data) AS totals_data + FROM inp WHERE prog_id IS NOT NULL + GROUP BY sec_id, prog_id + ), + + -- ROOT-агрегаты + root_agg AS ( + SELECT + sec_id, MAX(sec_code) AS sec_code, MAX(sec_name) AS sec_name, + v3.jsonb_sum(plan_data) AS plan_data, + v3.jsonb_sum(seq_dfip_data) AS seq_dfip_data, + v3.jsonb_sum(approved_data) AS approved_data, + v3.jsonb_sum(contract_summary_data) AS contract_summary_data, + v3.jsonb_sum(allocation_data) AS allocation_data, + v3.jsonb_sum(reserve_data) AS reserve_data, + v3.jsonb_sum(collegial_data) AS collegial_data, + v3.jsonb_sum(ckk_data) AS ckk_data, + v3.jsonb_sum(contract_data) AS contract_data, + v3.jsonb_sum(booking_data) AS booking_data, + v3.jsonb_sum(q1_data) AS q1_data, + v3.jsonb_sum(q2_data) AS q2_data, + v3.jsonb_sum(q3_data) AS q3_data, + v3.jsonb_sum(q4_data) AS q4_data, + v3.jsonb_sum(totals_data) AS totals_data + FROM inp + GROUP BY sec_id + ), + + -- Нулевой JSONB-шаблон (для пустых программ/проектов) + zero AS ( + SELECT '{}'::jsonb AS z + ), + + -- ═══ 5. UNION всех уровней ═════════════════════════════════════════════════ + unioned AS ( + -- ROOT — только из агрегатов (не имеет смысла пустой ROOT без строк) + SELECT + 'ROOT'::VARCHAR AS row_type, 0 AS depth, NULL::INT AS line_id, + jsonb_build_object('section_code', sec_code, 'name', sec_name, 'expense_item_id', sec_id) AS header, + plan_data, seq_dfip_data, approved_data, + contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data, + contract_data, booking_data, + q1_data, q2_data, q3_data, q4_data, totals_data, + ARRAY[sec_id, 0, 0, 0]::INT[] AS sort_path + FROM root_agg + + UNION ALL + + -- GROUP — из агрегатов (программы с бюджетными строками) + SELECT + 'GROUP', 1, NULL, + jsonb_build_object('section_code', sec_code, 'name', prog_name, 'program_id', prog_id, 'expense_item_id', sec_id), + plan_data, seq_dfip_data, approved_data, + contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data, + contract_data, booking_data, + q1_data, q2_data, q3_data, q4_data, totals_data, + ARRAY[sec_id, prog_id, 0, 0]::INT[] + FROM prog_agg + + UNION ALL + + -- GROUP — пустые программы (нет ни одной budget_line) + SELECT + 'GROUP', 1, NULL, + jsonb_build_object('section_code', sec_code, 'name', prog_name, 'program_id', prog_id, 'expense_item_id', sec_id), + NULL::jsonb, NULL::jsonb, NULL::jsonb, -- plan, seq_dfip, approved + NULL::jsonb, NULL::jsonb, NULL::jsonb, -- contract_summary, allocation, reserve + NULL::jsonb, NULL::jsonb, -- collegial, ckk + NULL::jsonb, NULL::jsonb, -- contract, booking + NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, -- q1-q4, totals + ARRAY[sec_id, prog_id, 0, 0]::INT[] + FROM all_programs ap + WHERE NOT EXISTS ( + SELECT 1 FROM v3.budget_line bl + WHERE bl.budget_form_id = p_form_id + AND ( + bl.project_id = ap.prog_id -- прямая привязка к программе + OR bl.project_id IN (SELECT id FROM v3.form4_project pr -- через проект + WHERE pr.parent_id = ap.prog_id AND pr.level = 'project') + ) + ) + + UNION ALL + + -- ITEM — из агрегатов (проекты с бюджетными строками) + SELECT + 'ITEM', 2, NULL, + jsonb_build_object('section_code', sec_code, 'name', prj_name, + 'program_id', prog_id, 'project_id', prj_id, 'expense_item_id', sec_id), + plan_data, seq_dfip_data, approved_data, + contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data, + contract_data, booking_data, + q1_data, q2_data, q3_data, q4_data, totals_data, + ARRAY[sec_id, prog_id, prj_id, 0]::INT[] + FROM prj_agg + + UNION ALL + + -- ITEM — пустые проекты (нет budget_line) + SELECT + 'ITEM', 2, NULL, + jsonb_build_object('section_code', sec_code, 'name', prj_name, + 'program_id', prog_id, 'project_id', prj_id, 'expense_item_id', sec_id), + NULL::jsonb, NULL::jsonb, NULL::jsonb, + NULL::jsonb, NULL::jsonb, NULL::jsonb, + NULL::jsonb, NULL::jsonb, + NULL::jsonb, NULL::jsonb, + NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, NULL::jsonb, + ARRAY[sec_id, prog_id, prj_id, 0]::INT[] + FROM all_projects apr + WHERE NOT EXISTS ( + SELECT 1 FROM v3.budget_line bl + WHERE bl.budget_form_id = p_form_id + AND bl.project_id = apr.prj_id + ) + + UNION ALL + + -- INPUT — все строки бюджета + SELECT + 'INPUT', 3, line_id, + header || jsonb_build_object( + 'project_id', prj_id, 'project_name', prj_name, + 'program_id', prog_id, 'program_name', prog_name + ), + plan_data, seq_dfip_data, approved_data, + contract_summary_data, allocation_data, reserve_data, collegial_data, ckk_data, + contract_data, booking_data, + q1_data, q2_data, q3_data, q4_data, totals_data, + ARRAY[sec_id, COALESCE(prog_id, 999999), COALESCE(prj_id, 999999), line_id]::INT[] + FROM inp + ) + + SELECT + u.row_type, u.depth, + ROW_NUMBER() OVER (ORDER BY u.sort_path) AS sort_order, + jsonb_build_object( + 'line_id', u.line_id, + 'header', u.header, + 'plan', u.plan_data, + 'seq_dfip', u.seq_dfip_data, + 'approved', u.approved_data, + 'contract_summary', u.contract_summary_data, + 'allocation', u.allocation_data, + 'reserve', u.reserve_data, + 'collegial', u.collegial_data, + 'ckk', u.ckk_data, + 'contract', u.contract_data, + 'booking', u.booking_data, + 'q1', u.q1_data, + 'q2', u.q2_data, + 'q3', u.q3_data, + 'q4', u.q4_data, + 'totals', u.totals_data + ) AS data + FROM unioned u + ORDER BY u.sort_path; +$function$ +LANGUAGE sql STABLE; + + + +-- ════════════════════════════════════════════════════════════════════════════ +-- v3.add_budget_line — создать новую INPUT-строку (budget_line) для FORM_1/2/4. +-- +-- Способы передать expense_item: +-- путь A — численный id: p_expense_item_id (берётся из header.expense_item_id) +-- путь B — резолв по коду: p_sheet + p_item_id [+ p_section_code для коллизий] +-- [+ p_direction для FORM_1] +-- +-- Авто-direction: +-- FORM_2 → 'Support' +-- FORM_4 → 'Development' +-- FORM_1/OPER → NULL +-- FORM_1/AHR|CAP → p_direction обязателен (Support|Development) +-- Если p_direction передан и противоречит авто-значению — ошибка. +-- +-- Дубликаты на одном expense_item разрешены (UNIQUE не накладывается). +-- vsp_id опционален — для FORM_2 фронт может не знать ВСП на момент +-- создания, дозаполнит позже через upd_form_cell(.., 'header.vsp_id', ..). +-- +-- FORM_3 не поддерживается — там строки живут в rf_project_report_line, +-- API будет в add_form3_line.sql (отдельная итерация). +-- +-- Возврат: вся иерархия (ROOT/GROUP/ITEM/SUB_ITEM/SECTION/LEAF) + новая +-- INPUT (отфильтрована по новому line_id). Новый id извлекается из +-- (data->>'line_id')::INT первой INPUT-строки. +-- ════════════════════════════════════════════════════════════════════════════ + +DROP FUNCTION IF EXISTS v3.add_budget_line(INT, INT, VARCHAR, VARCHAR, VARCHAR, VARCHAR, VARCHAR, VARCHAR, INT, INT, VARCHAR); +DROP FUNCTION IF EXISTS v3.add_budget_line(INT, INT, VARCHAR, VARCHAR, VARCHAR, VARCHAR, VARCHAR, VARCHAR, INT, INT, VARCHAR, VARCHAR, DATE); + +CREATE FUNCTION v3.add_budget_line( + p_form_id INT, + p_expense_item_id INT DEFAULT NULL, + p_sheet VARCHAR DEFAULT NULL, + p_item_id VARCHAR DEFAULT NULL, + p_section_code VARCHAR DEFAULT NULL, + p_direction VARCHAR DEFAULT NULL, + p_name VARCHAR DEFAULT NULL, + p_internal_order VARCHAR DEFAULT NULL, + p_vsp_id INT DEFAULT NULL, + p_project_id INT DEFAULT NULL, + p_justification VARCHAR DEFAULT NULL, + -- сателлитные листы (AHR_RENT/UTILITY/SECURITY) используют дополнительно: + p_contract_number VARCHAR DEFAULT NULL, + p_contract_end_date DATE DEFAULT NULL +) +RETURNS TABLE ( + row_type VARCHAR, + depth INT, + sort_order BIGINT, + data JSONB +) +AS $function$ +DECLARE + v_form_type VARCHAR; + v_eid INT; + v_sheet VARCHAR; + v_auto_dir VARCHAR; + v_final_dir VARCHAR; + v_new_id INT; + v_cnt INT; + v_sat_table TEXT; + v_sat_eid INT; + v_parent_line INT; +BEGIN + -- 1. form_id → form_type + SELECT form_type_code INTO v_form_type FROM v3.budget_form WHERE id = p_form_id; + IF v_form_type IS NULL THEN + RAISE EXCEPTION 'budget_form id=% не существует', p_form_id; + END IF; + IF v_form_type = 'FORM_3' THEN + RAISE EXCEPTION 'FORM_3 не использует budget_line — см. add_form3_line (TBD)'; + END IF; + + -- ═══ Сателлитные листы (AHR_RENT/UTILITY/SECURITY) ═══════════════════ + -- Фиксированный expense_item_id на лист — UI не передаёт R-код. + -- p_vsp_id обязателен. Опц.: p_contract_number, p_contract_end_date. + -- Внутри: находим/создаём родительскую budget_line с фиксированным ei, + -- затем INSERT в *_detail. Возврат — одна новая LEAF. + IF p_sheet IN ('AHR_RENT','AHR_UTILITY','AHR_SECURITY') THEN + v_sat_table := CASE p_sheet + WHEN 'AHR_RENT' THEN 'rent_detail' + WHEN 'AHR_UTILITY' THEN 'utility_detail' + WHEN 'AHR_SECURITY' THEN 'security_detail' + END; + v_sat_eid := CASE p_sheet + WHEN 'AHR_RENT' THEN 293 -- R061031001 "Аренда_ОН" + WHEN 'AHR_UTILITY' THEN 24 -- 1.05.1. "Коммунальные услуги" + WHEN 'AHR_SECURITY' THEN 779 -- R061062001 "Пультовая охр.ОН" + END; + + IF p_vsp_id IS NULL THEN + RAISE EXCEPTION 'p_vsp_id обязателен для сателлитного листа %', p_sheet; + END IF; + IF NOT EXISTS (SELECT 1 FROM v3.vsp WHERE id = p_vsp_id) THEN + RAISE EXCEPTION 'vsp id=% не существует', p_vsp_id; + END IF; + + -- Находим/создаём родительскую budget_line под фиксированным ei + SELECT id INTO v_parent_line + FROM v3.budget_line + WHERE budget_form_id = p_form_id AND expense_item_id = v_sat_eid + LIMIT 1; + IF v_parent_line IS NULL THEN + INSERT INTO v3.budget_line (budget_form_id, expense_item_id, direction) + VALUES ( + p_form_id, v_sat_eid, + CASE v_form_type WHEN 'FORM_2' THEN 'Support' WHEN 'FORM_4' THEN 'Development' ELSE NULL END + ) RETURNING id INTO v_parent_line; + END IF; + + -- INSERT в сателлит + EXECUTE format( + 'INSERT INTO v3.%I (line_id, vsp_id, contract_number, contract_end_date) ' + || 'VALUES ($1, $2, $3, $4) RETURNING id', + v_sat_table + ) USING v_parent_line, p_vsp_id, p_contract_number, p_contract_end_date + INTO v_new_id; + + -- Возврат — одна новая INPUT-строка (отфильтрована по новому id сателлита) + RETURN QUERY + SELECT v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form_view(p_form_id, p_sheet, NULL, NULL) v + WHERE v.row_type = 'INPUT' AND (v.data->>'id')::INT = v_new_id; + RETURN; + END IF; + + -- 2. Резолв expense_item_id + IF p_expense_item_id IS NOT NULL THEN + SELECT ei.id, ei.sheet INTO v_eid, v_sheet + FROM v3.expense_item ei WHERE ei.id = p_expense_item_id; + IF v_eid IS NULL THEN + RAISE EXCEPTION 'expense_item id=% не существует', p_expense_item_id; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM v3.expense_item_form_type + WHERE expense_item_id = v_eid AND form_type_code = v_form_type + ) THEN + RAISE EXCEPTION 'expense_item id=% не привязан к form_type %', v_eid, v_form_type; + END IF; + ELSE + IF p_sheet IS NULL OR p_item_id IS NULL THEN + RAISE EXCEPTION 'нужен либо p_expense_item_id, либо (p_sheet + p_item_id)'; + END IF; + + SELECT count(*) INTO v_cnt + FROM v3.expense_item ei + JOIN v3.expense_item_form_type eift ON eift.expense_item_id = ei.id + WHERE ei.item_id = p_item_id + AND ei.sheet = p_sheet + AND eift.form_type_code = v_form_type + AND (p_section_code IS NULL OR ei.section_code = p_section_code) + AND (p_direction IS NULL OR ei.direction IS NULL OR ei.direction = p_direction); + + IF v_cnt = 0 THEN + RAISE EXCEPTION 'expense_item не найден: sheet=%, item_id=%, section=%, direction=%, form=%', + p_sheet, p_item_id, p_section_code, p_direction, v_form_type; + END IF; + IF v_cnt > 1 THEN + RAISE EXCEPTION 'expense_item не уникален (% совпадений): sheet=%, item_id=%, section=%, direction=%. Уточните p_section_code или передайте p_expense_item_id напрямую', + v_cnt, p_sheet, p_item_id, p_section_code, p_direction; + END IF; + + SELECT ei.id, ei.sheet INTO v_eid, v_sheet + FROM v3.expense_item ei + JOIN v3.expense_item_form_type eift ON eift.expense_item_id = ei.id + WHERE ei.item_id = p_item_id + AND ei.sheet = p_sheet + AND eift.form_type_code = v_form_type + AND (p_section_code IS NULL OR ei.section_code = p_section_code) + AND (p_direction IS NULL OR ei.direction IS NULL OR ei.direction = p_direction); + END IF; + + -- 3. Авто-direction (для FORM_1/AHR|CAP — обязателен у юзера) + v_auto_dir := CASE + WHEN v_form_type = 'FORM_2' THEN 'Support' + WHEN v_form_type = 'FORM_4' THEN 'Development' + WHEN v_form_type = 'FORM_1' AND v_sheet = 'OPER' THEN NULL + ELSE NULL + END; + + IF v_form_type = 'FORM_1' AND v_sheet IN ('AHR','CAP') AND p_direction IS NULL THEN + RAISE EXCEPTION 'p_direction обязателен для FORM_1 / sheet=% (Support|Development)', v_sheet; + END IF; + + IF v_form_type IN ('FORM_2','FORM_4') AND p_direction IS NOT NULL AND p_direction <> v_auto_dir THEN + RAISE EXCEPTION 'p_direction=% несовместим с form_type=% (фиксирован=%)', + p_direction, v_form_type, v_auto_dir; + END IF; + + IF v_form_type = 'FORM_1' AND v_sheet = 'OPER' AND p_direction IS NOT NULL THEN + RAISE EXCEPTION 'p_direction должен быть NULL для FORM_1 / sheet=OPER (получено: %)', p_direction; + END IF; + + v_final_dir := COALESCE(p_direction, v_auto_dir); + + -- 4. Sanity на vsp/project + IF p_vsp_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM v3.vsp WHERE id = p_vsp_id) THEN + RAISE EXCEPTION 'vsp id=% не существует', p_vsp_id; + END IF; + IF p_project_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM v3.form4_project WHERE id = p_project_id) THEN + RAISE EXCEPTION 'form4_project id=% не существует', p_project_id; + END IF; + + -- 5. INSERT + INSERT INTO v3.budget_line ( + budget_form_id, expense_item_id, name, internal_order, + vsp_id, project_id, direction, justification + ) VALUES ( + p_form_id, v_eid, p_name, p_internal_order, + p_vsp_id, p_project_id, v_final_dir, p_justification + ) RETURNING id INTO v_new_id; + + -- 5а. Аудит: ROW_CREATE + PERFORM v3.log_event( + 'ROW_CREATE', 'ROW_CREATE', + jsonb_build_object( + 'sheet', v_sheet, + 'line_id', v_new_id, + 'expense_item_id', v_eid, + 'direction', v_final_dir, + 'name', p_name, + 'vsp_id', p_vsp_id, + 'project_id', p_project_id + ), + p_form_id + ); + + -- 6. Возврат: только иерархия по пути нового ei + новая INPUT + DECLARE v_ei_path INT[]; + BEGIN + v_ei_path := v3._expense_item_path_set(ARRAY[v_eid]); + RETURN QUERY + SELECT v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form_view(p_form_id, v_sheet, NULL, v_final_dir) v + WHERE (v.row_type IN ('ROOT','GROUP','ITEM','SUB_ITEM') + AND (v.data->'header'->>'expense_item_id')::INT = ANY(v_ei_path)) + OR (v.row_type = 'INPUT' AND (v.data->>'line_id')::INT = v_new_id) + ORDER BY v.sort_order; + END; +END; +$function$ +LANGUAGE plpgsql VOLATILE; 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/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/api/v1/websocket.py b/api/src/api/v1/websocket.py index 0f00d49..a4c16c8 100644 --- a/api/src/api/v1/websocket.py +++ b/api/src/api/v1/websocket.py @@ -14,6 +14,7 @@ from sqlalchemy.exc import IntegrityError from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status from sqlalchemy.ext.asyncio import AsyncSession +from src.repository.sheet_repository import SheetValidationError from src.services.rf_project_report_service import RfProjectReportService from src.services.rf_project_report_line_service import RfProjectReportLineService from src.repository.user_repository import UserRepository @@ -376,6 +377,18 @@ class FormEventProcess: sheet=sheet, direction=direction, ) + case "program_added": + return await self.__add_program( + event_data=event_data["data"], + form_id=form_id, + user=curr_user, + sheet=sheet, + ) + case "project_added": + return await self.__add_project( + event_data=event_data["data"], + user=curr_user, + ) case _: pass @@ -461,8 +474,43 @@ class FormEventProcess: final_result["new_line_id"] = el[3]["line_id"] return final_result return final_result + + async def __add_program( + self, + event_data: dict, + form_id: int, + sheet: str, + user: AppUser, + ) -> list[tuple]: + """ + data = { + name: str + } + """ + return await self.sheet_service.add_program( + form_id=form_id, + sheet=sheet, + name=event_data["name"], + user=user, + ) - + async def __add_project( + self, + event_data: dict, + user: AppUser, + ) -> list[tuple]: + """ + data = { + name: str, + program_id: int + } + """ + return await self.sheet_service.add_project( + name=event_data["name"], + program_id=event_data["program_id"], + user=user, + ) + async def __del_row( self, event_data: dict, @@ -824,6 +872,8 @@ async def process_websocket( if data.get("event") in [ "cell_updated", "row_added", + "program_added", + "project_added", "row_deleted", ]: await manager.broadcast_to_all( @@ -852,6 +902,12 @@ async def process_websocket( data=data, websocket=websocket, ) + except SheetValidationError as e: + data["error"] = e.message + await manager.send_back( + data=data, + websocket=websocket, + ) except Exception as e: tp = type(e) diff --git a/api/src/db/models/__init__.py b/api/src/db/models/__init__.py index d8e60fa..a0e643d 100644 --- a/api/src/db/models/__init__.py +++ b/api/src/db/models/__init__.py @@ -9,8 +9,9 @@ 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.form4_project import Form4Project from src.db.models.form_limit import FormLimit from src.db.models.form_phase import FormPhase from src.db.models.form_type import FormType 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/db/models/form4_project.py b/api/src/db/models/form4_project.py new file mode 100644 index 0000000..fe74414 --- /dev/null +++ b/api/src/db/models/form4_project.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import enum + +from sqlalchemy import CheckConstraint, ForeignKey, Index, Integer, String, text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.db.base import Base + + +class Form4ProjectLevelEnum(str, enum.Enum): + PROGRAM = "program" + PROJECT = "project" + + +class Form4Project(Base): + __tablename__ = "form4_project" + __table_args__ = ( + CheckConstraint( + "level IN ('program', 'project')", name="chk_form4_project_level" + ), + CheckConstraint( + "(level = 'program' AND parent_id IS NULL) OR (level = 'project')", + name="chk_form4_project_parent", + ), + CheckConstraint( + "(level = 'program') = (section_code IS NOT NULL)", + name="chk_form4_project_section_code", + ), + Index( + "ix_form4_project_section_code", + "section_code", + postgresql_where=text("level = 'program'"), + ), + Index("ix_v3_form4_project_parent", "parent_id"), + Index("ix_v3_form4_project_form", "form_id"), + {"schema": "v3"}, + ) + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String) + level: Mapped[Form4ProjectLevelEnum] = mapped_column(String) # type: ignore + section_code: Mapped[str | None] = mapped_column(String) + parent_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("v3.form4_project.id") + ) + form_id: Mapped[int] = mapped_column( + Integer, ForeignKey("v3.budget_form.id") + ) + + # parent = relationship("Form4Project", remote_side="Form4Project.id", back_populates="children") + # children: Mapped[list["Form4Project"]] = relationship("Form4Project", back_populates="parent") # type: ignore + # budget_form = relationship("BudgetForm") + # budget_lines: Mapped[list["BudgetLine"]] = relationship("BudgetLine", back_populates="form4_project") # type: ignore 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/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/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/form4_project_repository.py b/api/src/repository/form4_project_repository.py new file mode 100644 index 0000000..2236e8c --- /dev/null +++ b/api/src/repository/form4_project_repository.py @@ -0,0 +1,65 @@ +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.db.models.form4_project import Form4Project, Form4ProjectLevelEnum + + +class Form4ProjectRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def get( + self, + project_id: int, + level: Form4ProjectLevelEnum | str | None = None, + ) -> Form4Project | None: + where = [Form4Project.id == project_id] + if level is not None: + where.append(Form4Project.level == level) + + return ( + (await self.db.execute(select(Form4Project).where(*where).limit(1))) + .scalars() + .first() + ) + + async def get_list( + self, + level: Form4ProjectLevelEnum | None = None, + form_id: int | None = None, + parent_id: int | None = None, + offset: int | None = None, + limit: int | None = None, + with_count: bool = False, + ) -> list[Form4Project] | tuple[int, list[Form4Project]]: + if with_count: + query = select(func.count().over().label("total_count"), Form4Project) + else: + query = select(Form4Project) + + where = [] + if level is not None: + where.append(Form4Project.level == level) + if form_id is not None: + where.append(Form4Project.form_id == form_id) + if parent_id is not None: + where.append(Form4Project.parent_id == parent_id) + + query = query.where(*where).order_by(Form4Project.id) + + if offset is not None: + query = query.offset(offset) + if limit is not None: + query = query.limit(limit) + + if with_count: + result = (await self.db.execute(query)).all() + if result: + return result[0][0], [res[1] for res in result] + else: + count_query = select(func.count(Form4Project.id)) + if query.whereclause is not None: + count_query = count_query.where(query.whereclause) + return (await self.db.execute(count_query)).scalar(), [] + else: + return (await self.db.execute(query)).scalars().all() 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/repository/sheet_repository.py b/api/src/repository/sheet_repository.py index cad3323..e17fd5e 100644 --- a/api/src/repository/sheet_repository.py +++ b/api/src/repository/sheet_repository.py @@ -160,6 +160,59 @@ class SheetRepository: ) ).all() ] + + async def add_program( + self, + form_id: int, + sheet: str, + name: str, + ) -> list[tuple]: + return [ + tuple(r) for r in ( + await self.db.execute( + text( + """ + SELECT project_id, row_type, depth, sort_order, data + FROM v3.add_form4_program( + :p_name, + :p_form_id, + :p_sheet + ) + """ + ), + { + "p_form_id": form_id, + "p_name": name, + "p_sheet": sheet, + } + ) + ).all() + ] + + async def add_project( + self, + name: str, + program_id: int, + ) -> list[tuple]: + return [ + tuple(r) for r in ( + await self.db.execute( + text( + """ + SELECT project_id, row_type, depth, sort_order, data + FROM v3.add_form4_project( + :p_name, + :p_program_id + ) + """ + ), + { + "p_program_id": program_id, + "p_name": name, + } + ) + ).all() + ] async def add_line( self, 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, + ) diff --git a/api/src/services/export_service/export_service.py b/api/src/services/export_service/export_service.py index a5b6968..bbf33cf 100644 --- a/api/src/services/export_service/export_service.py +++ b/api/src/services/export_service/export_service.py @@ -40,6 +40,17 @@ ZIP_MEDIA_TYPE = "application/zip" FORM1_DIRECTION_REQUIRED_SHEETS = {"AHR", "CAP"} SHEETS_WITH_SECTIONS = {"AHR", "CAP", "OPER"} +SHEET_NAMES = { + 'AHR': 'АХР', + 'CAP': 'КВП', + 'OPER': 'Операц', + 'AHR_LIMIT': 'АХР Лимиты', + 'AHR_RENT': 'АХР Аренда', + 'AHR_SECURITY': 'АХР Безопасность', + 'AHR_UTILITY': 'АХР Коммунальные услуги', + 'SMETA': 'Смета', +} +SKIP_SHEETS = ('OTCH9F',) @dataclass(frozen=True) class ExportSheetPlan: @@ -233,6 +244,9 @@ class ExportService: workbook = self._create_workbook_with_metadata(form=form) exported_sheets = 0 for plan in plans: + if not plan.validation_direction and plan.sheet_name in SKIP_SHEETS: + continue + self._validate_sheet_query_params( form_type_code=form.form_type.code, sheet=plan.sheet_name, @@ -245,7 +259,7 @@ class ExportService: direction=plan.effective_direction, sections=plan.effective_sections, ) - worksheet = workbook.create_sheet(title=self._safe_sheet_name(plan.sheet_name)) + worksheet = workbook.create_sheet(title=self._safe_sheet_name(plan.sheet_name, plan.effective_direction)) self._write_sheet_rows( worksheet=worksheet, rows=rows, @@ -420,7 +434,15 @@ class ExportService: return form_type_code == FormTypeEnum.FORM_1 and sheet in FORM1_DIRECTION_REQUIRED_SHEETS @staticmethod - def _safe_sheet_name(value: str) -> str: + def _safe_sheet_name(value: str, direction: str | None = None) -> str: + if value in SHEET_NAMES: + sheet_name = SHEET_NAMES[value] + match direction: + case 'Support': + sheet_name = f'{sheet_name}_П' + case 'Development': + sheet_name = f'{sheet_name}_Р' + return sheet_name safe = value for char in ("[", "]", ":", "*", "?", "/", "\\"): safe = safe.replace(char, "_") 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/src/services/sheet_service.py b/api/src/services/sheet_service.py index 3c9ba78..a4a0fd0 100644 --- a/api/src/services/sheet_service.py +++ b/api/src/services/sheet_service.py @@ -1,9 +1,14 @@ from sqlalchemy.ext.asyncio import AsyncSession +from src.db.models.form4_project import Form4ProjectLevelEnum +from src.db.models.role import UserRoleEnum +from src.repository.budget_form_repository import BudgetFormRepository +from src.repository.form4_project_repository import Form4ProjectRepository +from src.repository.user_repository import UserRepository from src.db.models.app_user import AppUser from src.db.models.budget_form import BudgetForm -from src.repository.sheet_repository import SheetRepository +from src.repository.sheet_repository import SheetRepository, SheetValidationError @@ -11,6 +16,9 @@ class SheetService: def __init__(self, db: AsyncSession): self.db = db self.sheet_repo = SheetRepository(db) + self.bf_repo = BudgetFormRepository(db) + self.user_repo = UserRepository(db) + self.f4_project_repo = Form4ProjectRepository(db) async def get( self, @@ -26,28 +34,6 @@ class SheetService: direction=direction, sections=sections, ) - - async def update_cell( - self, - form_id: int, - sheet: str, - direction: str | None, - sections: list[str] | None, - line_id: int, - column: str, - value: str | int | float | None = None, - user: AppUser | None = None, - ) -> list[tuple]: - return await self.sheet_repo.update_cell( - form_id=form_id, - sheet=sheet, - direction=direction, - sections=sections, - line_id=line_id, - column=column, - value=value, - user_id=user.id if user else None, - ) async def update_cells( self, @@ -123,6 +109,66 @@ class SheetService: contract_end_date=contract_end_date, user_id=user.id if user else None, ) + + async def add_program( + self, + form_id: int, + sheet: str, + name: str, + user: AppUser | None = None, + ) -> tuple[int, list[tuple]]: + if user.role_id != UserRoleEnum.ADMIN: + user = await self.user_repo.get( + user_id=user.id, + load_orgs=True, + ) + form = await self.bf_repo.get( + budget_form_id=form_id, + org_unit=[ou.id for ou in user.org_units], + ) + if not form: + raise SheetValidationError("Форма не найдена") + + result = await self.sheet_repo.add_program( + form_id=form_id, + sheet=sheet, + name=name, + ) + if result: + return (result[0][0], [tuple(r[1:]) for r in result]) + return None + + async def add_project( + self, + name: str, + program_id: int, + user: AppUser | None = None, + ) -> tuple[int, list[tuple]]: + if user.role_id != UserRoleEnum.ADMIN: + program = await self.f4_project_repo.get( + project_id=program_id, + level=Form4ProjectLevelEnum.PROGRAM, + ) + if not program: + raise SheetValidationError("Программа не найдена") + + user = await self.user_repo.get( + user_id=user.id, + load_orgs=True, + ) + form = await self.bf_repo.get( + budget_form_id=program.form_id, + org_unit=[ou.id for ou in user.org_units], + ) + if not form: + raise SheetValidationError("Программа не найдена") + result = await self.sheet_repo.add_project( + program_id=program_id, + name=name, + ) + if result: + return (result[0][0], [tuple(r[1:]) for r in result]) + return None async def delete_line( self, 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'); 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