Compare commits
15 Commits
562707f418
...
653ede59c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
653ede59c1 | ||
| 3a2bb6c8aa | |||
| 45710ff58e | |||
| efe6df992d | |||
| 15477cf6ce | |||
| 68fd2a7646 | |||
| 18ec053e44 | |||
|
|
e4726b6284 | ||
| e6133b82fe | |||
|
|
3c7aafc8bb | ||
| ed0e7c805b | |||
| 9f652e5b94 | |||
| 0aa053c0c4 | |||
| 74d191ba20 | |||
| c976ae8c92 |
785
api/alembic/versions/0009_phase3_timestamps.py
Normal file
785
api/alembic/versions/0009_phase3_timestamps.py
Normal file
@ -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
|
||||||
84
api/alembic/versions/0010_form4.py
Normal file
84
api/alembic/versions/0010_form4.py
Normal file
@ -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
|
||||||
852
api/alembic/versions/sql/0010_form4.sql
Normal file
852
api/alembic/versions/sql/0010_form4.sql
Normal file
@ -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;
|
||||||
@ -2,6 +2,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.db.models.form_type import FormTypeEnum
|
||||||
from src.services.expense_item_service import ExpenseItemService
|
from src.services.expense_item_service import ExpenseItemService
|
||||||
from src.db.models.app_user import AppUser
|
from src.db.models.app_user import AppUser
|
||||||
from src.api.v1.deps import require_admin, require_executor
|
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),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: AppUser = Depends(require_executor),
|
current_user: AppUser = Depends(require_executor),
|
||||||
r_start: bool | None = None,
|
r_start: bool | None = None,
|
||||||
|
sheet: str | None = None,
|
||||||
|
direction: str | None = None,
|
||||||
|
form_type: FormTypeEnum | str | None = None
|
||||||
):
|
):
|
||||||
"""Получение записей expense item"""
|
"""Получение записей expense item"""
|
||||||
service = ExpenseItemService(db)
|
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(
|
return BaseListResponse(
|
||||||
success=True,
|
success=True,
|
||||||
|
|||||||
@ -3,11 +3,17 @@ import datetime
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.services.org_unit_service import OrgUnitService
|
||||||
from src.db.models.app_user import AppUser
|
from src.db.models.app_user import AppUser
|
||||||
from src.domain.schemas import (
|
from src.domain.schemas import (
|
||||||
BaseListResponse,
|
BaseListResponse,
|
||||||
BaseSingleResponse,
|
BaseSingleResponse,
|
||||||
|
Form3PhaseCreate,
|
||||||
|
Form3PhaseResponse,
|
||||||
|
Form3PhaseUpdate,
|
||||||
FormPhaseCreate,
|
FormPhaseCreate,
|
||||||
FormPhaseResponse,
|
FormPhaseResponse,
|
||||||
FormPhaseUpdate,
|
FormPhaseUpdate,
|
||||||
@ -66,8 +72,6 @@ async def get_form_phases(
|
|||||||
element.closes_at = element.closes_at.astimezone(tz)
|
element.closes_at = element.closes_at.astimezone(tz)
|
||||||
else:
|
else:
|
||||||
result = []
|
result = []
|
||||||
for element in result:
|
|
||||||
pass
|
|
||||||
return BaseListResponse(
|
return BaseListResponse(
|
||||||
result=result,
|
result=result,
|
||||||
count=len(phases),
|
count=len(phases),
|
||||||
@ -168,3 +172,173 @@ async def delete_form_phase(
|
|||||||
)
|
)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(404, "Этап не найден")
|
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, "Этап не найден")
|
||||||
|
|||||||
@ -14,6 +14,7 @@ from sqlalchemy.exc import IntegrityError
|
|||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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_service import RfProjectReportService
|
||||||
from src.services.rf_project_report_line_service import RfProjectReportLineService
|
from src.services.rf_project_report_line_service import RfProjectReportLineService
|
||||||
from src.repository.user_repository import UserRepository
|
from src.repository.user_repository import UserRepository
|
||||||
@ -376,6 +377,18 @@ class FormEventProcess:
|
|||||||
sheet=sheet,
|
sheet=sheet,
|
||||||
direction=direction,
|
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 _:
|
case _:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -462,6 +475,41 @@ class FormEventProcess:
|
|||||||
return final_result
|
return final_result
|
||||||
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(
|
async def __del_row(
|
||||||
self,
|
self,
|
||||||
@ -824,6 +872,8 @@ async def process_websocket(
|
|||||||
if data.get("event") in [
|
if data.get("event") in [
|
||||||
"cell_updated",
|
"cell_updated",
|
||||||
"row_added",
|
"row_added",
|
||||||
|
"program_added",
|
||||||
|
"project_added",
|
||||||
"row_deleted",
|
"row_deleted",
|
||||||
]:
|
]:
|
||||||
await manager.broadcast_to_all(
|
await manager.broadcast_to_all(
|
||||||
@ -852,6 +902,12 @@ async def process_websocket(
|
|||||||
data=data,
|
data=data,
|
||||||
websocket=websocket,
|
websocket=websocket,
|
||||||
)
|
)
|
||||||
|
except SheetValidationError as e:
|
||||||
|
data["error"] = e.message
|
||||||
|
await manager.send_back(
|
||||||
|
data=data,
|
||||||
|
websocket=websocket,
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
tp = type(e)
|
tp = type(e)
|
||||||
|
|||||||
@ -9,8 +9,9 @@ from src.db.models.ckk import Ckk
|
|||||||
from src.db.models.collegial_approval import CollegialApproval
|
from src.db.models.collegial_approval import CollegialApproval
|
||||||
from src.db.models.contract_detail import ContractDetail
|
from src.db.models.contract_detail import ContractDetail
|
||||||
from src.db.models.contract_summary import ContractSummary
|
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.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_limit import FormLimit
|
||||||
from src.db.models.form_phase import FormPhase
|
from src.db.models.form_phase import FormPhase
|
||||||
from src.db.models.form_type import FormType
|
from src.db.models.form_type import FormType
|
||||||
|
|||||||
@ -25,3 +25,12 @@ class ExpenseItem(Base):
|
|||||||
direction: Mapped[str | None] = mapped_column(String)
|
direction: Mapped[str | None] = mapped_column(String)
|
||||||
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.expense_item.id"))
|
parent_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.expense_item.id"))
|
||||||
depth: Mapped[int | None] = mapped_column(Integer, default=0)
|
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)
|
||||||
|
|||||||
54
api/src/db/models/form4_project.py
Normal file
54
api/src/db/models/form4_project.py
Normal file
@ -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
|
||||||
@ -46,4 +46,4 @@ class Project(Base):
|
|||||||
# children: Mapped[list["Project"]] = relationship("Project", back_populates="parent") # type: ignore
|
# children: Mapped[list["Project"]] = relationship("Project", back_populates="parent") # type: ignore
|
||||||
# org_unit = relationship("OrgUnit", back_populates="projects")
|
# org_unit = relationship("OrgUnit", back_populates="projects")
|
||||||
# budget_lines: Mapped[list["BudgetLine"]] = relationship("BudgetLine", back_populates="project") # type: ignore
|
# 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
|
||||||
|
|||||||
@ -25,7 +25,7 @@ class RfProjectReport(Base):
|
|||||||
updated_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
|
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())
|
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])
|
# creator: Mapped[AppUser | None] = relationship("AppUser", foreign_keys=[created_by])
|
||||||
# updater: Mapped[AppUser | None] = relationship("AppUser", foreign_keys=[updated_by])
|
# updater: Mapped[AppUser | None] = relationship("AppUser", foreign_keys=[updated_by])
|
||||||
# lines: Mapped[list[RfProjectReportLine]] = relationship("RfProjectReportLine", back_populates="report")
|
# lines: Mapped[list[RfProjectReportLine]] = relationship("RfProjectReportLine", back_populates="report")
|
||||||
|
|||||||
@ -425,6 +425,35 @@ class FormPhaseUpdate(BaseModel):
|
|||||||
closes_at: datetime | None = None
|
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):
|
class AuditLogBase(BaseModel):
|
||||||
"""Базовая схема записи аудита (единый формат вывода как у auditlog)."""
|
"""Базовая схема записи аудита (единый формат вывода как у auditlog)."""
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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:
|
class ExpenseItemRepository:
|
||||||
@ -12,10 +13,31 @@ class ExpenseItemRepository:
|
|||||||
query = select(ExpenseItem).where(ExpenseItem.id == item_id).limit(1)
|
query = select(ExpenseItem).where(ExpenseItem.id == item_id).limit(1)
|
||||||
return (await self.db.execute(query)).scalar_one_or_none()
|
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 = []
|
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:
|
if r_start is not None:
|
||||||
where.append(ExpenseItem.item_id.startswith("R"))
|
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()
|
return (await self.db.execute(query)).scalars().all()
|
||||||
|
|||||||
116
api/src/repository/form3_phase_repository.py
Normal file
116
api/src/repository/form3_phase_repository.py
Normal file
@ -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)
|
||||||
65
api/src/repository/form4_project_repository.py
Normal file
65
api/src/repository/form4_project_repository.py
Normal file
@ -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()
|
||||||
@ -135,6 +135,23 @@ class ProjectRepository:
|
|||||||
return None
|
return None
|
||||||
return self._serialize_project(row[0], row[1], row[2], row[3])
|
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]:
|
async def get_reports(self, project_id: int) -> list[dict]:
|
||||||
line_count_subq = (
|
line_count_subq = (
|
||||||
select(func.count(RfProjectReportLine.id))
|
select(func.count(RfProjectReportLine.id))
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from src.db.models.rf_project_report import RfProjectReport
|
from src.db.models.rf_project_report import RfProjectReport
|
||||||
|
|
||||||
@ -15,6 +16,7 @@ class RfProjectReportRepository:
|
|||||||
year: int | None = None,
|
year: int | None = None,
|
||||||
report_type: str | None = None,
|
report_type: str | None = None,
|
||||||
project_id: int | None = None,
|
project_id: int | None = None,
|
||||||
|
load_project: bool = False,
|
||||||
) -> RfProjectReport | None:
|
) -> RfProjectReport | None:
|
||||||
assert report_id is not None or all((el is not None for el in (year, report_type, project_id)))
|
assert report_id is not None or all((el is not None for el in (year, report_type, project_id)))
|
||||||
query = select(
|
query = select(
|
||||||
@ -29,5 +31,28 @@ class RfProjectReportRepository:
|
|||||||
RfProjectReport.project_id == project_id
|
RfProjectReport.project_id == project_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if load_project:
|
||||||
|
query = query.options(joinedload(RfProjectReport.project))
|
||||||
query = query.limit(1)
|
query = query.limit(1)
|
||||||
return (await self.db.execute(query)).scalar_one_or_none()
|
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()
|
||||||
|
|||||||
@ -161,6 +161,59 @@ class SheetRepository:
|
|||||||
).all()
|
).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(
|
async def add_line(
|
||||||
self,
|
self,
|
||||||
form_id: int,
|
form_id: int,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.role import UserRoleEnum
|
||||||
from src.db.models.app_user import AppUser
|
from src.db.models.app_user import AppUser
|
||||||
from src.db.models.expense_item import ExpenseItem
|
from src.db.models.expense_item import ExpenseItem
|
||||||
@ -22,5 +23,13 @@ class ExpenseItemService:
|
|||||||
async def get_list(
|
async def get_list(
|
||||||
self,
|
self,
|
||||||
r_start: bool | None = None,
|
r_start: bool | None = None,
|
||||||
|
sheet: str | None = None,
|
||||||
|
direction: str | None = None,
|
||||||
|
form_type: FormTypeEnum | str | None = None,
|
||||||
) -> list[ExpenseItem]:
|
) -> 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,
|
||||||
|
)
|
||||||
|
|||||||
@ -40,6 +40,17 @@ ZIP_MEDIA_TYPE = "application/zip"
|
|||||||
FORM1_DIRECTION_REQUIRED_SHEETS = {"AHR", "CAP"}
|
FORM1_DIRECTION_REQUIRED_SHEETS = {"AHR", "CAP"}
|
||||||
SHEETS_WITH_SECTIONS = {"AHR", "CAP", "OPER"}
|
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)
|
@dataclass(frozen=True)
|
||||||
class ExportSheetPlan:
|
class ExportSheetPlan:
|
||||||
@ -233,6 +244,9 @@ class ExportService:
|
|||||||
workbook = self._create_workbook_with_metadata(form=form)
|
workbook = self._create_workbook_with_metadata(form=form)
|
||||||
exported_sheets = 0
|
exported_sheets = 0
|
||||||
for plan in plans:
|
for plan in plans:
|
||||||
|
if not plan.validation_direction and plan.sheet_name in SKIP_SHEETS:
|
||||||
|
continue
|
||||||
|
|
||||||
self._validate_sheet_query_params(
|
self._validate_sheet_query_params(
|
||||||
form_type_code=form.form_type.code,
|
form_type_code=form.form_type.code,
|
||||||
sheet=plan.sheet_name,
|
sheet=plan.sheet_name,
|
||||||
@ -245,7 +259,7 @@ class ExportService:
|
|||||||
direction=plan.effective_direction,
|
direction=plan.effective_direction,
|
||||||
sections=plan.effective_sections,
|
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(
|
self._write_sheet_rows(
|
||||||
worksheet=worksheet,
|
worksheet=worksheet,
|
||||||
rows=rows,
|
rows=rows,
|
||||||
@ -420,7 +434,15 @@ class ExportService:
|
|||||||
return form_type_code == FormTypeEnum.FORM_1 and sheet in FORM1_DIRECTION_REQUIRED_SHEETS
|
return form_type_code == FormTypeEnum.FORM_1 and sheet in FORM1_DIRECTION_REQUIRED_SHEETS
|
||||||
|
|
||||||
@staticmethod
|
@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
|
safe = value
|
||||||
for char in ("[", "]", ":", "*", "?", "/", "\\"):
|
for char in ("[", "]", ":", "*", "?", "/", "\\"):
|
||||||
safe = safe.replace(char, "_")
|
safe = safe.replace(char, "_")
|
||||||
|
|||||||
106
api/src/services/form3_phase_service.py
Normal file
106
api/src/services/form3_phase_service.py
Normal file
@ -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,
|
||||||
|
)
|
||||||
@ -1,5 +1,6 @@
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.app_user import AppUser
|
||||||
from src.db.models.role import UserRoleEnum
|
from src.db.models.role import UserRoleEnum
|
||||||
from src.core.errors import AccessDeniedException, ValidationException
|
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)
|
project["reports"] = await self.project_repo.get_reports(project_id=project_id)
|
||||||
return project
|
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(
|
async def resolve_report_id(
|
||||||
self,
|
self,
|
||||||
project_id: int,
|
project_id: int,
|
||||||
|
|||||||
@ -3,8 +3,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from src.db.models.rf_project_report import RfProjectReport
|
from src.db.models.rf_project_report import RfProjectReport
|
||||||
from src.repository.rf_project_report_repository import RfProjectReportRepository
|
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
|
from src.db.models.app_user import AppUser
|
||||||
|
|
||||||
|
|
||||||
@ -22,10 +20,26 @@ class RfProjectReportService:
|
|||||||
year: int | None = None,
|
year: int | None = None,
|
||||||
report_type: str | None = None,
|
report_type: str | None = None,
|
||||||
project_id: int | None = None,
|
project_id: int | None = None,
|
||||||
|
load_project: bool = False,
|
||||||
) -> RfProjectReport | None:
|
) -> RfProjectReport | None:
|
||||||
return await self.pr_repo.get(
|
return await self.pr_repo.get(
|
||||||
report_id=report_id,
|
report_id=report_id,
|
||||||
year=year,
|
year=year,
|
||||||
report_type=report_type,
|
report_type=report_type,
|
||||||
project_id=project_id,
|
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,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.app_user import AppUser
|
||||||
from src.db.models.budget_form import BudgetForm
|
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):
|
def __init__(self, db: AsyncSession):
|
||||||
self.db = db
|
self.db = db
|
||||||
self.sheet_repo = SheetRepository(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(
|
async def get(
|
||||||
self,
|
self,
|
||||||
@ -27,28 +35,6 @@ class SheetService:
|
|||||||
sections=sections,
|
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(
|
async def update_cells(
|
||||||
self,
|
self,
|
||||||
form_id: int,
|
form_id: int,
|
||||||
@ -124,6 +110,66 @@ class SheetService:
|
|||||||
user_id=user.id if user else None,
|
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(
|
async def delete_line(
|
||||||
self,
|
self,
|
||||||
form_id: int,
|
form_id: int,
|
||||||
|
|||||||
@ -154,3 +154,165 @@ def test_form_phases_delete(client, admin_tokens):
|
|||||||
)
|
)
|
||||||
assert response.status_code == 200
|
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
|
||||||
|
|
||||||
|
|||||||
@ -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]
|
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):
|
def test_org_units_update_not_found_error_shape(client, admin_tokens, auth_headers):
|
||||||
response = client.patch(
|
response = client.patch(
|
||||||
"/api/v1/org-unit/99999",
|
"/api/v1/org-unit/99999",
|
||||||
|
|||||||
@ -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'),
|
(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');
|
(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);
|
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');
|
||||||
|
|||||||
@ -1,41 +1,67 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { memo, useMemo, useCallback } from 'react';
|
||||||
import { useMemo } from 'react';
|
|
||||||
import styled from '@emotion/styled';
|
|
||||||
import { useRealtime } from '../../contexts/RealtimeContext';
|
import { useRealtime } from '../../contexts/RealtimeContext';
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
|
|
||||||
const ContainerForText = styled.span`
|
// Константы вне компонента
|
||||||
display: -webkit-box;
|
const BASE_CELL_STYLES = {
|
||||||
-webkit-box-orient: vertical;
|
width: '100%',
|
||||||
-webkit-line-clamp: 3;
|
height: '100%',
|
||||||
overflow: hidden;
|
display: 'flex',
|
||||||
white-space: pre-wrap;
|
alignItems: 'center',
|
||||||
`;
|
justifyContent: 'center',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
border: '2px solid transparent',
|
||||||
|
padding: '8px',
|
||||||
|
};
|
||||||
|
|
||||||
// Стилизованный компонент для плашки блокировки
|
const LOCKED_STYLES = {
|
||||||
const LockBadge = styled.div`
|
border: '2px solid #e0e0e0',
|
||||||
position: absolute;
|
backgroundColor: '#f5f5f5',
|
||||||
top: 2px;
|
cursor: 'not-allowed',
|
||||||
right: 2px;
|
opacity: 0.85,
|
||||||
background: rgba(0, 0, 0, 0.7);
|
};
|
||||||
color: white;
|
|
||||||
font-size: 9px;
|
const LOCK_BADGE_STYLES = {
|
||||||
padding: 1px 4px;
|
position: 'absolute',
|
||||||
border-radius: 3px;
|
top: '2px',
|
||||||
pointer-events: none;
|
right: '2px',
|
||||||
z-index: 10;
|
background: 'rgba(0, 0, 0, 0.7)',
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
color: 'white',
|
||||||
letter-spacing: 0.3px;
|
fontSize: '9px',
|
||||||
backdrop-filter: blur(4px);
|
padding: '1px 4px',
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
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) => {
|
const getColorBrightness = (hexColor) => {
|
||||||
if (!hexColor) return 255;
|
if (!hexColor) return 255;
|
||||||
|
|
||||||
const color = hexColor.replace('#', '');
|
const color = hexColor.replace('#', '');
|
||||||
|
|
||||||
let r, g, b;
|
let r, g, b;
|
||||||
if (color.length === 3) {
|
if (color.length === 3) {
|
||||||
r = parseInt(color[0] + color[0], 16);
|
r = parseInt(color[0] + color[0], 16);
|
||||||
@ -48,174 +74,132 @@ const getColorBrightness = (hexColor) => {
|
|||||||
} else {
|
} else {
|
||||||
return 255;
|
return 255;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (0.299 * r + 0.587 * g + 0.114 * b);
|
return (0.299 * r + 0.587 * g + 0.114 * b);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Функция для подсветки текста
|
const formatNumber = (value, asInteger = false) => {
|
||||||
const highlightText = (text, searchQueries) => {
|
if (value === null || value === undefined || value === '') return String(value || '');
|
||||||
if (!text) return text;
|
const num = Number(value);
|
||||||
const cleanSearchQueries = searchQueries.filter(q => (q !== undefined && q !== ''));
|
if (isNaN(num)) return String(value);
|
||||||
if (cleanSearchQueries.length == 0) return text;
|
return num.toLocaleString('ru-RU', {
|
||||||
|
minimumFractionDigits: asInteger ? 0 : 1,
|
||||||
const escapedQueries = cleanSearchQueries.map(query => query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
maximumFractionDigits: asInteger ? 0 : 1,
|
||||||
const regex = new RegExp(`(${escapedQueries.join('|')})`, 'gi');
|
useGrouping: true,
|
||||||
const parts = text.split(regex);
|
});
|
||||||
|
|
||||||
return parts.map((part, index) =>
|
|
||||||
regex.test(part) ? (
|
|
||||||
<mark
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
backgroundColor: '#ffeb3b',
|
|
||||||
color: '#000',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
padding: '0 2px',
|
|
||||||
borderRadius: '2px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{part}
|
|
||||||
</mark>
|
|
||||||
) : (
|
|
||||||
part
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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 !== ''));
|
const searchStr = String(originalValue ?? displayValue);
|
||||||
if (cleanSearchQueries.length == 0) return formattedValue;
|
const escapedQueries = cleanQueries.map(q => q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||||
|
|
||||||
const originalString = String(originalValue);
|
|
||||||
const escapedQueries = cleanSearchQueries.map(query => query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
||||||
const regex = new RegExp(`(${escapedQueries.join('|')})`, 'gi');
|
const regex = new RegExp(`(${escapedQueries.join('|')})`, 'gi');
|
||||||
|
|
||||||
if (!regex.test(originalString)) {
|
if (!regex.test(searchStr)) return displayValue;
|
||||||
return formattedValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
const parts = searchStr.split(regex);
|
||||||
<mark
|
return parts.map((part, index) => {
|
||||||
style={{
|
if (!regex.test(part)) return part;
|
||||||
|
return React.createElement('mark', {
|
||||||
|
key: index,
|
||||||
|
style: {
|
||||||
backgroundColor: '#ffeb3b',
|
backgroundColor: '#ffeb3b',
|
||||||
color: '#000',
|
color: '#000',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
padding: '0 2px',
|
padding: '0 2px',
|
||||||
borderRadius: '2px',
|
borderRadius: '2px',
|
||||||
}}
|
},
|
||||||
>
|
}, part);
|
||||||
{formattedValue}
|
|
||||||
</mark>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
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 { lockedCells } = useRealtime();
|
||||||
const cellKey = `${row.id}_${column.id}`;
|
const cellKey = `${row.id}_${column.id}`;
|
||||||
|
|
||||||
const isLocked = lockedCells.includes(cellKey);
|
const isLocked = lockedCells.includes(cellKey);
|
||||||
|
|
||||||
const cellValue = cell.getValue();
|
// Мемоизация значения
|
||||||
let originalValue = cellValue;
|
const { rawValue, displayValue, isNumeric } = useMemo(() => {
|
||||||
let displayValue = String(cellValue || '');
|
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 (isNum) {
|
||||||
if (isNumeric) {
|
const asInteger = INTEGER_COLUMN_IDS.has(column.id);
|
||||||
const asInteger = INTEGER_COLUMN_IDS.has(column.id);
|
display = formatNumber(value, asInteger);
|
||||||
displayValue = formatNumber(cellValue, asInteger);
|
}
|
||||||
}
|
|
||||||
|
return { rawValue: value, displayValue: display, isNumeric: isNum };
|
||||||
|
}, [cell, column.id]);
|
||||||
|
|
||||||
const highlightedContent = useMemo(() => {
|
const highlightedContent = useMemo(() => {
|
||||||
if (!isNumeric) {
|
const searchQueries = [globalFilter, columnFilter];
|
||||||
return highlightText(displayValue, [globalFilter, columnFilter]);
|
return createHighlightedContent(rawValue, displayValue, searchQueries);
|
||||||
}
|
}, [rawValue, displayValue, globalFilter, columnFilter]);
|
||||||
return highlightWithFormatting(originalValue, displayValue, [globalFilter, columnFilter]);
|
|
||||||
}, [originalValue, displayValue, globalFilter, columnFilter, isNumeric]);
|
|
||||||
|
|
||||||
// Определяем цвет текста на основе яркости фона
|
const textColor = useMemo(() => {
|
||||||
const getTextColor = () => {
|
if (isLocked) return '#999999';
|
||||||
const brightness = getColorBrightness(backgroundColor);
|
const brightness = getColorBrightness(backgroundColor);
|
||||||
|
|
||||||
// Если яркость меньше 128 (темный фон) - используем белый текст, иначе черный
|
|
||||||
return brightness < 128 ? '#ffffff' : '#000000';
|
return brightness < 128 ? '#ffffff' : '#000000';
|
||||||
};
|
}, [backgroundColor, isLocked]);
|
||||||
|
|
||||||
// Базовые стили для заблокированной ячейки
|
const cellStyles = useMemo(() => ({
|
||||||
const lockedStyles = isLocked ? {
|
...BASE_CELL_STYLES,
|
||||||
border: '2px solid #e0e0e0',
|
backgroundColor: isLocked ? '#f5f5f5' : backgroundColor,
|
||||||
backgroundColor: '#f5f5f5',
|
color: textColor,
|
||||||
cursor: 'not-allowed',
|
...(isLocked ? LOCKED_STYLES : {}),
|
||||||
opacity: 0.85,
|
}), [backgroundColor, isLocked, textColor]);
|
||||||
} : {};
|
|
||||||
|
|
||||||
|
const lockBadge = useMemo(() => {
|
||||||
|
if (!isLocked) return null;
|
||||||
|
return <div style={LOCK_BADGE_STYLES}>🔒</div>;
|
||||||
|
}, [isLocked]);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
// Определяем финальный цвет текста
|
if (!isLocked && onClick) {
|
||||||
const finalTextColor = isLocked ? '#999999' : getTextColor();
|
onClick();
|
||||||
|
}
|
||||||
|
}, [isLocked, onClick]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="cell"
|
className="cell"
|
||||||
style={{
|
style={cellStyles}
|
||||||
width: '100%',
|
onClick={handleClick}
|
||||||
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',
|
|
||||||
backgroundColor: isLocked ? '#f5f5f5' : backgroundColor,
|
|
||||||
color: finalTextColor,
|
|
||||||
...lockedStyles,
|
|
||||||
}}
|
|
||||||
onClick={isLocked ? undefined : onClick}
|
|
||||||
>
|
>
|
||||||
<ContainerForText>{highlightedContent}</ContainerForText>
|
<span style={CONTAINER_STYLES}>{highlightedContent}</span>
|
||||||
{isLocked && (
|
{lockBadge}
|
||||||
<LockBadge>
|
|
||||||
🔒
|
|
||||||
</LockBadge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Cell = React.memo(CellComponent, (prevProps, nextProps) => {
|
||||||
|
// Возвращаем true если пропсы равны (не нужно перерендеривать)
|
||||||
|
return (
|
||||||
|
prevProps.cell.getValue() === nextProps.cell.getValue() &&
|
||||||
|
prevProps.row.id === nextProps.row.id &&
|
||||||
|
prevProps.column.id === nextProps.column.id &&
|
||||||
|
prevProps.globalFilter === nextProps.globalFilter &&
|
||||||
|
prevProps.columnFilter === nextProps.columnFilter &&
|
||||||
|
prevProps.backgroundColor === nextProps.backgroundColor &&
|
||||||
|
prevProps.isEditable === nextProps.isEditable &&
|
||||||
|
prevProps.isUpdating === nextProps.isUpdating &&
|
||||||
|
prevProps.onCellNumberClick === nextProps.onCellNumberClick
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default Cell;
|
export default Cell;
|
||||||
@ -1,195 +1,217 @@
|
|||||||
// EditCell.js - обновленная версия
|
import React, { memo, useEffect, useRef, useLayoutEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { useEffect, useRef, useLayoutEffect, useState } from 'react';
|
|
||||||
import { isFormula, extractFormula, calculateFormula, formatNumber } from '../../utils/formulaUtils';
|
import { isFormula, extractFormula, calculateFormula, formatNumber } from '../../utils/formulaUtils';
|
||||||
import { saveToStorage, loadFromStorage } from '../../utils/localStorageUtils';
|
import { saveToStorage, loadFromStorage } from '../../utils/localStorageUtils';
|
||||||
import { useRealtime } from '../../contexts/RealtimeContext';
|
import { useRealtime } from '../../contexts/RealtimeContext';
|
||||||
|
|
||||||
const EditCell = ({ refCell, onChange, disabled, value, tableId, cellId, table }) => {
|
const EDITOR_STYLES = {
|
||||||
|
position: 'absolute',
|
||||||
|
zIndex: 12,
|
||||||
|
top: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FORMULA_INDICATOR_STYLES = {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-20px',
|
||||||
|
left: '4px',
|
||||||
|
fontSize: '10px',
|
||||||
|
color: '#666',
|
||||||
|
background: '#f0f0f0',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEXTAREA_STYLES = {
|
||||||
|
width: '100%',
|
||||||
|
height: '3rem',
|
||||||
|
fieldSizing: 'content',
|
||||||
|
fontSize: 'inherit',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, table }) => {
|
||||||
const { endEditing: contextEndEditing } = useRealtime();
|
const { endEditing: contextEndEditing } = useRealtime();
|
||||||
|
|
||||||
const [curValue, setCurValue] = useState(value);
|
|
||||||
const [displayValue, setDisplayValue] = useState(value);
|
|
||||||
const [isFormulaMode, setIsFormulaMode] = useState(false);
|
|
||||||
|
|
||||||
// Ключ для хранения формул в localStorage
|
|
||||||
const storageKey = `formula_${tableId}_${cellId}`;
|
const storageKey = `formula_${tableId}_${cellId}`;
|
||||||
|
|
||||||
const trFrag = refCell.current.offsetParent.offsetParent;
|
const [state, setState] = useState(() => ({
|
||||||
const tdFrag = refCell.current.offsetParent;
|
currentValue: value,
|
||||||
const left = tdFrag.offsetLeft;
|
displayValue: value,
|
||||||
const width = refCell.current.offsetParent.offsetWidth;
|
isFormulaMode: false,
|
||||||
const translateY = trFrag.style.transform;
|
}));
|
||||||
|
|
||||||
// Загружаем сохраненную формулу при монтировании
|
// Позиция ячейки
|
||||||
|
const [position, setPosition] = useState();
|
||||||
|
const textareaRef = useRef(null);
|
||||||
|
|
||||||
|
const refFinished = useRef(false);
|
||||||
|
|
||||||
|
// Загрузка сохраненной формулы
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedFormula = loadFromStorage(storageKey);
|
const savedFormula = loadFromStorage(storageKey);
|
||||||
if (savedFormula && isFormula(savedFormula)) {
|
if (savedFormula && isFormula(savedFormula)) {
|
||||||
setIsFormulaMode(true);
|
|
||||||
setCurValue(savedFormula);
|
|
||||||
// Вычисляем и отображаем результат
|
|
||||||
try {
|
try {
|
||||||
const formula = extractFormula(savedFormula);
|
const formula = extractFormula(savedFormula);
|
||||||
const result = calculateFormula(formula);
|
const result = calculateFormula(formula);
|
||||||
setDisplayValue(formatNumber(result));
|
setState({
|
||||||
} catch (error) {
|
currentValue: savedFormula,
|
||||||
setDisplayValue('#ОШИБКА');
|
displayValue: formatNumber(result),
|
||||||
|
isFormulaMode: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setState({
|
||||||
|
currentValue: savedFormula,
|
||||||
|
displayValue: '#ОШИБКА',
|
||||||
|
isFormulaMode: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
setDisplayValue(value);
|
|
||||||
}
|
}
|
||||||
}, [storageKey, value]);
|
}, [storageKey]);
|
||||||
|
|
||||||
function useAutoResize(value) {
|
// Вычисление позиции
|
||||||
const ref = useRef(null);
|
useLayoutEffect(() => {
|
||||||
|
if (!refCell?.current) return;
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
const td = refCell.current.offsetParent;
|
||||||
const el = ref.current;
|
const tr = td?.offsetParent;
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
el.style.height = 'auto';
|
if (td && tr) {
|
||||||
|
setPosition({
|
||||||
|
left: td.offsetLeft,
|
||||||
|
width: td.offsetWidth,
|
||||||
|
translateY: tr.style.transform || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [refCell]);
|
||||||
|
|
||||||
|
// Auto-resize
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
el.style.minHeight = '3rem';
|
el.style.minHeight = '3rem';
|
||||||
|
el.style.height = '3rem';
|
||||||
el.style.height = el.scrollHeight + 'px';
|
el.style.height = el.scrollHeight + 'px';
|
||||||
el.style.maxHeight = '180px';
|
el.style.maxHeight = '180px';
|
||||||
}, [value]);
|
};
|
||||||
|
|
||||||
return ref;
|
resize();
|
||||||
}
|
const observer = new ResizeObserver(resize);
|
||||||
|
observer.observe(el);
|
||||||
|
|
||||||
const ref = useAutoResize(value);
|
return () => observer.disconnect();
|
||||||
|
}, [state.currentValue]);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleSave = useCallback(() => {
|
||||||
if (ref.current && !disabled) {
|
const { currentValue } = state;
|
||||||
ref.current.focus();
|
|
||||||
ref.current.setSelectionRange(
|
|
||||||
ref.current.value.length,
|
|
||||||
ref.current.value.length,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [disabled, ref]);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
if (isFormula(currentValue)) {
|
||||||
// Проверяем, является ли введенное значение формулой
|
saveToStorage(storageKey, currentValue);
|
||||||
if (isFormula(curValue)) {
|
|
||||||
// Сохраняем формулу в localStorage
|
|
||||||
saveToStorage(storageKey, curValue);
|
|
||||||
// Вычисляем и сохраняем результат
|
|
||||||
try {
|
try {
|
||||||
const formula = extractFormula(curValue);
|
const formula = extractFormula(currentValue);
|
||||||
const result = calculateFormula(formula);
|
const result = calculateFormula(formula);
|
||||||
const formattedResult = formatNumber(result);
|
const formattedResult = formatNumber(result);
|
||||||
setDisplayValue(formattedResult);
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
displayValue: formattedResult,
|
||||||
|
isFormulaMode: true,
|
||||||
|
}));
|
||||||
onChange?.(formattedResult);
|
onChange?.(formattedResult);
|
||||||
setIsFormulaMode(true);
|
} catch {
|
||||||
return;
|
setState(prev => ({
|
||||||
} catch (error) {
|
...prev,
|
||||||
setDisplayValue('#ОШИБКА');
|
displayValue: '#ОШИБКА',
|
||||||
onChange?.(curValue); // Сохраняем как текст, если ошибка
|
isFormulaMode: true,
|
||||||
return;
|
}));
|
||||||
|
onChange?.(currentValue);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Если это не формула, удаляем сохраненную формулу
|
|
||||||
localStorage.removeItem(storageKey);
|
localStorage.removeItem(storageKey);
|
||||||
setIsFormulaMode(false);
|
setState(prev => ({
|
||||||
setDisplayValue(curValue);
|
...prev,
|
||||||
onChange?.(curValue);
|
displayValue: currentValue,
|
||||||
|
isFormulaMode: false,
|
||||||
|
}));
|
||||||
|
onChange?.(currentValue);
|
||||||
}
|
}
|
||||||
};
|
}, [state, storageKey, onChange]);
|
||||||
|
|
||||||
const handleChangeValue = (event) => {
|
const finishEditing = useCallback(() => {
|
||||||
const input = event.target.value;
|
if (refFinished.current) return;
|
||||||
setCurValue(input);
|
refFinished.current = true;
|
||||||
// При редактировании показываем введенный текст
|
|
||||||
setDisplayValue(input);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Функция для отображения значения в ячейке
|
|
||||||
const getDisplayValue = () => {
|
|
||||||
if (isFormulaMode && isFormula(curValue)) {
|
|
||||||
return displayValue;
|
|
||||||
}
|
|
||||||
return displayValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
const finishEditing = () => {
|
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
handleSave();
|
handleSave();
|
||||||
}
|
}
|
||||||
const editingCell = table.getState().editingCell || null;
|
const editingCell = table.getState().editingCell;
|
||||||
if (editingCell) {
|
if (editingCell) {
|
||||||
contextEndEditing?.(editingCell.row, editingCell.column);
|
contextEndEditing?.(editingCell.row, editingCell.column);
|
||||||
}
|
}
|
||||||
};
|
}, [disabled, handleSave, table, contextEndEditing]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event) => {
|
const handleClickOutside = (event) => {
|
||||||
// Проверяем, был ли клик внутри элемента редактирования
|
const el = textareaRef.current;
|
||||||
const editElement = ref.current;
|
if (el && !el.contains(event.target)) {
|
||||||
if (editElement && !editElement.contains(event.target)) {
|
|
||||||
finishEditing();
|
finishEditing();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [finishEditing]);
|
||||||
|
|
||||||
return () => {
|
const handleChange = useCallback((e) => {
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
const value = e.target.value;
|
||||||
};
|
setState(prev => ({
|
||||||
}, [ref, finishEditing]);
|
...prev,
|
||||||
|
currentValue: value,
|
||||||
|
displayValue: value,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
finishEditing();
|
||||||
|
}
|
||||||
|
}, [finishEditing]);
|
||||||
|
|
||||||
|
const editorStyles = useMemo(() => ({
|
||||||
|
...EDITOR_STYLES,
|
||||||
|
left: position?.left || 0,
|
||||||
|
width: position?.width || 0,
|
||||||
|
transform: position?.translateY || 0,
|
||||||
|
}), [position]);
|
||||||
|
|
||||||
|
const textareaStyles = useMemo(() => ({
|
||||||
|
...TEXTAREA_STYLES,
|
||||||
|
backgroundColor: state.isFormulaMode ? '#f8f9fa' : 'white',
|
||||||
|
border: state.isFormulaMode ? '2px solid #4a90d9' : undefined,
|
||||||
|
}), [state.isFormulaMode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div
|
{position &&
|
||||||
key="blockForEdit"
|
<div style={editorStyles}>
|
||||||
style={{
|
{state.isFormulaMode && (
|
||||||
position: 'absolute',
|
<div style={FORMULA_INDICATOR_STYLES}>fx</div>
|
||||||
left: left,
|
)}
|
||||||
zIndex: 12,
|
<textarea
|
||||||
width: width,
|
ref={textareaRef}
|
||||||
transform: translateY,
|
disabled={disabled}
|
||||||
top: 0,
|
style={textareaStyles}
|
||||||
}}
|
onChange={handleChange}
|
||||||
>
|
value={state.currentValue}
|
||||||
{isFormulaMode && (
|
onKeyDown={handleKeyDown}
|
||||||
<div style={{
|
placeholder={state.isFormulaMode ? 'Введите формулу (начинается с =)' : ''}
|
||||||
position: 'absolute',
|
/>
|
||||||
top: '-20px',
|
</div>
|
||||||
left: '4px',
|
}
|
||||||
fontSize: '10px',
|
|
||||||
color: '#666',
|
|
||||||
background: '#f0f0f0',
|
|
||||||
padding: '2px 6px',
|
|
||||||
borderRadius: '3px',
|
|
||||||
pointerEvents: 'none'
|
|
||||||
}}>
|
|
||||||
fx
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<textarea
|
|
||||||
ref={ref}
|
|
||||||
disabled={disabled}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: 'auto',
|
|
||||||
fieldSizing: 'content',
|
|
||||||
fontSize: 'inherit',
|
|
||||||
backgroundColor: isFormulaMode ? '#f8f9fa' : 'white',
|
|
||||||
border: isFormulaMode ? '2px solid #4a90d9' : undefined,
|
|
||||||
}}
|
|
||||||
onChange={handleChangeValue}
|
|
||||||
value={curValue}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
finishEditing();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={isFormulaMode ? 'Введите формулу (начинается с =)' : ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
EditCell.displayName = 'EditCell';
|
||||||
|
|
||||||
export default EditCell;
|
export default EditCell;
|
||||||
@ -24,11 +24,10 @@ import {
|
|||||||
import { useRealtime } from './contexts/RealtimeContext';
|
import { useRealtime } from './contexts/RealtimeContext';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { CircularProgress } from '@mui/material';
|
import { CircularProgress } from '@mui/material';
|
||||||
import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig';
|
import { additionVspRowTable } from './constants/addingRowConfig';
|
||||||
import { SelectVspModal } from './Modals/SelectVspModal';
|
import { SelectVspModal } from './Modals/SelectVspModal';
|
||||||
import { SelectExpenseItemModal } from './Modals/SelectExpenseItemModal';
|
|
||||||
import { getRowId } from './utils/rowUtils';
|
import { getRowId } from './utils/rowUtils';
|
||||||
import { FORM_TYPE_OPTIONS } from '../../constants/constants';
|
import { debounce } from '@mui/material';
|
||||||
|
|
||||||
const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||||
const { data, setData, isLoading: isTableLoading, editingCells: dataEditingCells } = useRealtimeData(formId, sheetName, direction, formType, year);
|
const { data, setData, isLoading: isTableLoading, editingCells: dataEditingCells } = useRealtimeData(formId, sheetName, direction, formType, year);
|
||||||
@ -41,7 +40,6 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
const [editingCell, setEditingCell] = useState(null);
|
const [editingCell, setEditingCell] = useState(null);
|
||||||
|
|
||||||
const [isOpenModalSelectVsp, setIsOpenModalSelectVsp] = useState(false);
|
const [isOpenModalSelectVsp, setIsOpenModalSelectVsp] = useState(false);
|
||||||
const [isOpenModalSelectExpenseItem, setIsOpenModalSelectExpenseItem] = useState(false);
|
|
||||||
|
|
||||||
const isVspAdditionRow = useMemo(() => {
|
const isVspAdditionRow = useMemo(() => {
|
||||||
if (!formType || !sheetName) return false;
|
if (!formType || !sheetName) return false;
|
||||||
@ -49,17 +47,14 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
return additionVspRowTable[formType].includes(sheetName);
|
return additionVspRowTable[formType].includes(sheetName);
|
||||||
}, [formType, sheetName]);
|
}, [formType, sheetName]);
|
||||||
|
|
||||||
const isAdditionExpenseItem = useMemo(() => {
|
const handleGlobalFilterChange = useCallback(
|
||||||
if (!formType || !sheetName) return false;
|
debounce((value) => {
|
||||||
if (!additionExpenseRowTable[formType]) return false;
|
startTransition(() => {
|
||||||
return additionExpenseRowTable[formType].includes(sheetName);
|
setGlobalFilter(value);
|
||||||
}, [formType, sheetName]);
|
});
|
||||||
|
}, 300),
|
||||||
const handleGlobalFilterChange = (value) => {
|
[]
|
||||||
startTransition(() => {
|
);
|
||||||
setGlobalFilter(value);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isConnected,
|
isConnected,
|
||||||
@ -97,17 +92,27 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
|
|
||||||
const [columnsConfig, setColumnsConfig] = useState(null);
|
const [columnsConfig, setColumnsConfig] = useState(null);
|
||||||
|
|
||||||
|
const configCache = useRef(new Map());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!formType || !sheetName) return;
|
||||||
|
const cacheKey = `${formType}_${sheetName}`;
|
||||||
|
|
||||||
|
if (configCache.current.has(cacheKey)) {
|
||||||
|
setColumnsConfig(configCache.current.get(cacheKey));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const loadConfig = async () => {
|
const loadConfig = async () => {
|
||||||
try {
|
try {
|
||||||
const { config } = await import(`./constants/${formType}/${sheetName}.js`);
|
const { config } = await import(`./constants/${formType}/${sheetName}.js`);
|
||||||
|
configCache.current.set(cacheKey, config.config);
|
||||||
setColumnsConfig(config.config);
|
setColumnsConfig(config.config);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to load config for ${formType}/${sheetName}:`, error);
|
console.error(`Failed to load config for ${formType}/${sheetName}:`, error);
|
||||||
setColumnsConfig({ columns: [], colors: {} });
|
setColumnsConfig({ columns: [], colors: {} });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadConfig();
|
loadConfig();
|
||||||
}, [formType, sheetName]);
|
}, [formType, sheetName]);
|
||||||
|
|
||||||
@ -162,7 +167,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
console.error('Error creating columns:', error);
|
console.error('Error creating columns:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [columnsConfig, handleUpdateCell, handleClickRowCell, handleCellUpdateError]);
|
}, [columnsConfig]);
|
||||||
|
|
||||||
const selectedColumnIdRef = useRef(selectedColumnId);
|
const selectedColumnIdRef = useRef(selectedColumnId);
|
||||||
|
|
||||||
@ -176,7 +181,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
}), [handleUpdateCell]);
|
}), [handleUpdateCell]);
|
||||||
|
|
||||||
const ROW_VIRTUALIZER_OPTIONS = {
|
const ROW_VIRTUALIZER_OPTIONS = {
|
||||||
overscan: 30,
|
overscan: 5,
|
||||||
scrollPaddingStart: 0,
|
scrollPaddingStart: 0,
|
||||||
scrollPaddingEnd: 0,
|
scrollPaddingEnd: 0,
|
||||||
estimateSize: () => TABLE_ROW_HEIGHT,
|
estimateSize: () => TABLE_ROW_HEIGHT,
|
||||||
@ -314,43 +319,34 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
const table = useMaterialReactTable(tableConfig);
|
const table = useMaterialReactTable(tableConfig);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dataEditingCells || !dataEditingCells.line_id) {
|
if (!dataEditingCells?.line_id) {
|
||||||
setEditingCell(null);
|
setEditingCell(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (editingCell) return;
|
if (editingCell) return;
|
||||||
const rowId = dataEditingCells.line_id;
|
|
||||||
const columnId = dataEditingCells.column;
|
// Отложить поиск до следующего тика
|
||||||
try {
|
requestAnimationFrame(() => {
|
||||||
const row = table.getRow(rowId);
|
try {
|
||||||
const column = table.getColumn(columnId);
|
const row = table.getRow(dataEditingCells.line_id);
|
||||||
const cell = row?.getVisibleCells().find(c => c.column.id === columnId);
|
const cell = row?.getVisibleCells().find(
|
||||||
if (cell) {
|
c => c.column.id === dataEditingCells.column
|
||||||
setEditingCell(cell);
|
);
|
||||||
|
if (cell) setEditingCell(cell);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
});
|
||||||
console.error(error);
|
}, [dataEditingCells, editingCell, table]);
|
||||||
toast.error('Ошибка редактирования ячейки');
|
|
||||||
}
|
const [isFirstLoad, setIsFirstLoad] = useState(true);
|
||||||
}, [dataEditingCells, editingCell]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (table && data && columns.length !== 0) {
|
if (isFirstLoad && table && data && columns.length !== 0) {
|
||||||
setIsLoadingData(true)
|
setIsFirstLoad(false);
|
||||||
|
setIsLoadingData(true);
|
||||||
}
|
}
|
||||||
}, [data, columns, table])
|
}, [data, columns, table, isFirstLoad]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Принудительно обновляем состояние закрепленных колонок
|
|
||||||
// если так не сделать при скролле будут пропадать закрепленные колонки
|
|
||||||
if (columnPinning && Object.keys(columnPinning).length > 0) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setColumnPinning(prev => ({ ...prev }));
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAddRow = useCallback(() => {
|
const handleAddRow = useCallback(() => {
|
||||||
const allRows = table.getRowModel().rows;
|
const allRows = table.getRowModel().rows;
|
||||||
@ -367,19 +363,11 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
|
|
||||||
const handleAddVspRow = useCallback((vsp_id) => {
|
const handleAddVspRow = useCallback((vsp_id) => {
|
||||||
contextAddRow({ vsp_id: vsp_id });
|
contextAddRow({ vsp_id: vsp_id });
|
||||||
}, []);
|
}, [rowSelection, table]);
|
||||||
|
|
||||||
const handleAddExpenseItemRow = useCallback((expense_item) => {
|
|
||||||
contextAddRow({ expense_item_id: expense_item.id });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleOpenModalSelectVsp = useCallback(() => {
|
const handleOpenModalSelectVsp = useCallback(() => {
|
||||||
setIsOpenModalSelectVsp(true);
|
setIsOpenModalSelectVsp(true);
|
||||||
}, [])
|
}, [rowSelection, table])
|
||||||
|
|
||||||
const handleOpenModalSelectExpenseItem = useCallback(() => {
|
|
||||||
setIsOpenModalSelectExpenseItem(true);
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleDeleteRow = useCallback(() => {
|
const handleDeleteRow = useCallback(() => {
|
||||||
const allRows = table.getRowModel().rows;
|
const allRows = table.getRowModel().rows;
|
||||||
@ -395,17 +383,6 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
setRowSelection({});
|
setRowSelection({});
|
||||||
}, [rowSelection, table])
|
}, [rowSelection, table])
|
||||||
|
|
||||||
const addRow = useCallback(() => {
|
|
||||||
|
|
||||||
if (isVspAdditionRow) {
|
|
||||||
return handleOpenModalSelectVsp();
|
|
||||||
}
|
|
||||||
if (isAdditionExpenseItem) {
|
|
||||||
return handleOpenModalSelectExpenseItem();
|
|
||||||
}
|
|
||||||
return handleAddRow();
|
|
||||||
}, [isVspAdditionRow, isAdditionExpenseItem, handleOpenModalSelectVsp, handleAddRow]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
setData([]);
|
setData([]);
|
||||||
@ -432,7 +409,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
onGlobalFilterChange={handleGlobalFilterChange}
|
onGlobalFilterChange={handleGlobalFilterChange}
|
||||||
sizeMult={sizeMult}
|
sizeMult={sizeMult}
|
||||||
onChangeShowColumnFilters={setShowColumnFilters}
|
onChangeShowColumnFilters={setShowColumnFilters}
|
||||||
onAddRow={addRow}
|
onAddRow={isVspAdditionRow ? handleOpenModalSelectVsp : handleAddRow}
|
||||||
onDeleteRow={handleDeleteRow}
|
onDeleteRow={handleDeleteRow}
|
||||||
isLoadingData={isLoadingData}
|
isLoadingData={isLoadingData}
|
||||||
formId={formId}
|
formId={formId}
|
||||||
@ -502,14 +479,6 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
onSelect={handleAddVspRow}
|
onSelect={handleAddVspRow}
|
||||||
title="Выбор ВСП"
|
title="Выбор ВСП"
|
||||||
/>
|
/>
|
||||||
<SelectExpenseItemModal
|
|
||||||
isOpen={isOpenModalSelectExpenseItem}
|
|
||||||
onClose={() => setIsOpenModalSelectExpenseItem(false)}
|
|
||||||
formId={formId}
|
|
||||||
onSelect={handleAddExpenseItemRow}
|
|
||||||
title="Добавление строки"
|
|
||||||
sheet={sheetName}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,11 +1,9 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { columns as mockColumns } from '../../mocks/mockTable';
|
|
||||||
import { config } from './constants/FORM_2/AHR';
|
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
import { useRealtime } from './contexts/RealtimeContext';
|
import { useRealtime } from './contexts/RealtimeContext';
|
||||||
|
|
||||||
function EditCellPortal({
|
const EditCellPortal = React.memo(({
|
||||||
cell,
|
cell,
|
||||||
table,
|
table,
|
||||||
EditCell,
|
EditCell,
|
||||||
@ -13,88 +11,71 @@ function EditCellPortal({
|
|||||||
onSaveEnd,
|
onSaveEnd,
|
||||||
onError,
|
onError,
|
||||||
isEditable
|
isEditable
|
||||||
}) {
|
}) => {
|
||||||
const { formId, formType, sheetName, direction } = useParams();
|
const { formId, formType, sheetName, direction } = useParams();
|
||||||
const tableKey = `${formId}_${formType}_${sheetName}_${direction}`;
|
const tableKey = `${formId}_${formType}_${sheetName}_${direction}`;
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
const [refTbody, setRefTbody] = useState(false);
|
const [refTbody, setRefTbody] = useState(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const isDisabled = false;
|
|
||||||
|
|
||||||
const { endEditing: contextEndEditing } = useRealtime();
|
const { endEditing: contextEndEditing } = useRealtime();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ref.current) {
|
if (ref.current) {
|
||||||
const tbodyRef = ref.current.offsetParent?.offsetParent?.offsetParent;
|
const tbodyRef = ref.current.offsetParent?.offsetParent?.offsetParent;
|
||||||
setRefTbody(tbodyRef);
|
if (tbodyRef && tbodyRef !== refTbody) {
|
||||||
|
setRefTbody(tbodyRef);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleChange = async (val) => {
|
const handleChange = useCallback( (val) => {
|
||||||
table.setEditingCell(null);
|
table.setEditingCell(null);
|
||||||
try {
|
try {
|
||||||
if (table.options.meta?.updateCell) {
|
if (table.options.meta?.updateCell) {
|
||||||
const success = await table.options.meta.updateCell(
|
const success = table.options.meta.updateCell(
|
||||||
cell.row,
|
cell.row,
|
||||||
cell.column,
|
cell.column,
|
||||||
val
|
val
|
||||||
);
|
);
|
||||||
if (success) {
|
if (!success) {
|
||||||
console.log('Cell updated successfully via WebSocket');
|
|
||||||
} else {
|
|
||||||
console.error('Failed to update cell via WebSocket');
|
console.error('Failed to update cell via WebSocket');
|
||||||
const errorMsg = 'Не удалось сохранить изменение';
|
onError?.('Не удалось сохранить изменение');
|
||||||
onError?.(errorMsg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating cell:', error);
|
console.error('Error updating cell:', error);
|
||||||
onError?.(error.message);
|
onError?.(error.message);
|
||||||
}
|
}
|
||||||
};
|
}, [table, cell, onError]);
|
||||||
|
|
||||||
|
const editCellProps = useMemo(() => ({
|
||||||
|
refCell: ref,
|
||||||
|
cell,
|
||||||
|
value: cell.getValue(),
|
||||||
|
disabled: !isEditable,
|
||||||
|
onChange: handleChange,
|
||||||
|
tableId: tableKey,
|
||||||
|
table,
|
||||||
|
cellId: `${cell.row.id}_${cell.column.id}`,
|
||||||
|
}), [cell, isEditable, handleChange, tableKey, table]);
|
||||||
|
|
||||||
|
const portalContent = useMemo(() => {
|
||||||
|
if (!refTbody || isSaving) return null;
|
||||||
|
return createPortal(
|
||||||
|
<EditCell {...editCellProps} />,
|
||||||
|
refTbody
|
||||||
|
);
|
||||||
|
}, [refTbody, isSaving, editCellProps, EditCell]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div ref={ref} style={{ width: '100%', height: '100%' }} />
|
||||||
ref={ref}
|
{portalContent}
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{refTbody && !isSaving &&
|
|
||||||
createPortal(
|
|
||||||
<EditCell
|
|
||||||
refCell={ref}
|
|
||||||
cell={cell}
|
|
||||||
value={cell.getValue()}
|
|
||||||
disabled={!isEditable}
|
|
||||||
onChange={handleChange}
|
|
||||||
tableId={tableKey}
|
|
||||||
table={table}
|
|
||||||
cellId={cell.row.id + '_' + cell.column.id}
|
|
||||||
/>,
|
|
||||||
refTbody,
|
|
||||||
)}
|
|
||||||
{isSaving && (
|
|
||||||
createPortal(
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
background: 'rgba(0,0,0,0.5)',
|
|
||||||
color: 'white',
|
|
||||||
padding: '4px 8px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '12px',
|
|
||||||
zIndex: 1000
|
|
||||||
}}>
|
|
||||||
Saving...
|
|
||||||
</div>,
|
|
||||||
refTbody
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
|
||||||
export const getTableColumns = ({
|
export const getTableColumns = ({
|
||||||
Cell,
|
Cell,
|
||||||
@ -104,44 +85,105 @@ export const getTableColumns = ({
|
|||||||
onCellNumberClick,
|
onCellNumberClick,
|
||||||
}) => {
|
}) => {
|
||||||
const columnColors = { ...columnsConfig.colors };
|
const columnColors = { ...columnsConfig.colors };
|
||||||
const columns = [...columnsConfig.columns];
|
const columns = structuredClone(columnsConfig.columns);
|
||||||
|
|
||||||
const processColumns = (columns, EditCell, Cell) => {
|
const cellPropsCache = new WeakMap();
|
||||||
|
const editPropsCache = new WeakMap();
|
||||||
|
|
||||||
|
const getCachedCellProps = (row, column, table) => {
|
||||||
|
const key = `${row.id}_${column.id}`;
|
||||||
|
|
||||||
|
if (!cellPropsCache.has(row)) {
|
||||||
|
cellPropsCache.set(row, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowCache = cellPropsCache.get(row);
|
||||||
|
if (rowCache[key]) {
|
||||||
|
const cached = rowCache[key];
|
||||||
|
const currentGlobalFilter = table.getState().globalFilter;
|
||||||
|
const currentColumnFilter = table.getState().columnFilters?.find(f => f.id === column.id)?.value;
|
||||||
|
|
||||||
|
if (cached.globalFilter === currentGlobalFilter &&
|
||||||
|
cached.columnFilter === currentColumnFilter) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalFilter = table.getState().globalFilter;
|
||||||
|
const columnFilter = table.getState().columnFilters?.find(f => f.id === column.id)?.value;
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
globalFilter,
|
||||||
|
columnFilter,
|
||||||
|
isEditable: row.original?.row_type === 'INPUT' || false,
|
||||||
|
backgroundColor: columnColors[column.id]?.color_type?.[row.original?.row_type || row.row_type],
|
||||||
|
isUpdating: table.options.meta?.updatingCells?.[`${row.id}_${column.id}`],
|
||||||
|
_hash: `${globalFilter}_${columnFilter}_${row.id}_${column.id}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
rowCache[key] = props;
|
||||||
|
return props;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCachedEditProps = (row, column, table) => {
|
||||||
|
const key = `${row.id}_${column.id}`;
|
||||||
|
|
||||||
|
if (!editPropsCache.has(row)) {
|
||||||
|
editPropsCache.set(row, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowCache = editPropsCache.get(row);
|
||||||
|
if (rowCache[key]) {
|
||||||
|
return rowCache[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
isEditable: row.original?.row_type === 'INPUT' || false,
|
||||||
|
};
|
||||||
|
|
||||||
|
rowCache[key] = props;
|
||||||
|
return props;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Оптимизированная функция processColumns
|
||||||
|
const processColumns = (columns) => {
|
||||||
columns.forEach((col) => {
|
columns.forEach((col) => {
|
||||||
col.Cell = ({ cell, table, column, row }) => (
|
col.Cell = ({ cell, table, column, row }) => {
|
||||||
<Cell
|
const props = getCachedCellProps(row, column, table);
|
||||||
row={row}
|
|
||||||
column={column}
|
|
||||||
cell={cell}
|
|
||||||
globalFilter={
|
|
||||||
table.getState().globalFilter
|
|
||||||
}
|
|
||||||
columnFilter={table.getState().columnFilters?.find(f => f.id === column.id)?.value}
|
|
||||||
isEditable={row.original?.row_type === 'INPUT' || false}
|
|
||||||
backgroundColor={columnColors[column.id]?.color_type?.[row.original?.row_type || row.row_type]}
|
|
||||||
color={columnColors[column.id]?.color_type?.['COLOR']}
|
|
||||||
isUpdating={table.options.meta?.updatingCells?.[`${row.id}_${column.id}`]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
col.Edit = ({ cell, table, row, column }) => (
|
const cellProps = useMemo(() => ({
|
||||||
<EditCellPortal
|
row,
|
||||||
cell={cell}
|
column,
|
||||||
table={table}
|
cell,
|
||||||
EditCell={EditCell}
|
...props,
|
||||||
onError={onCellUpdateError}
|
}), [row.id, column.id, cell.getValue(), props._hash]);
|
||||||
isEditable={row.original?.row_type === 'INPUT' || false}
|
|
||||||
/>
|
return <Cell {...cellProps} />;
|
||||||
);
|
};
|
||||||
|
|
||||||
|
col.Edit = ({ cell, table, row, column }) => {
|
||||||
|
const props = getCachedEditProps(row, column, table);
|
||||||
|
|
||||||
|
const editComponent = useMemo(() => (
|
||||||
|
<EditCellPortal
|
||||||
|
cell={cell}
|
||||||
|
table={table}
|
||||||
|
EditCell={EditCell}
|
||||||
|
onError={onCellUpdateError}
|
||||||
|
isEditable={props.isEditable}
|
||||||
|
/>
|
||||||
|
), [cell, table, EditCell, onCellUpdateError, props.isEditable]);
|
||||||
|
|
||||||
|
return editComponent;
|
||||||
|
};
|
||||||
|
|
||||||
// Рекурсивно обрабатываем вложенные колонки
|
|
||||||
if (col.columns?.length) {
|
if (col.columns?.length) {
|
||||||
processColumns(col.columns, EditCell, Cell);
|
processColumns(col.columns);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
processColumns(columns, EditCell, Cell);
|
processColumns(columns);
|
||||||
|
|
||||||
const rowNumber = {
|
const rowNumber = {
|
||||||
accessorKey: 'sort_order',
|
accessorKey: 'sort_order',
|
||||||
@ -149,9 +191,11 @@ export const getTableColumns = ({
|
|||||||
size: 50,
|
size: 50,
|
||||||
enableEditing: false,
|
enableEditing: false,
|
||||||
Cell: ({ cell, row }) => {
|
Cell: ({ cell, row }) => {
|
||||||
return <button onClick={() => { onCellNumberClick(row.id) }}>
|
const handleClick = useCallback(() => {
|
||||||
{cell.getValue() + ' '}
|
onCellNumberClick(row.id);
|
||||||
</button>
|
}, [row.id, onCellNumberClick]);
|
||||||
|
|
||||||
|
return <button onClick={handleClick}>{cell.getValue() + ' '}</button>;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user