projects-handlers: ручки со списком проектов со сметами и их обновление #90

Merged
tsygankoviva merged 1 commits from projects-handlers into test 2026-08-13 13:50:13 +03:00
17 changed files with 1574 additions and 46 deletions

View File

@ -0,0 +1,82 @@
import os
import re
from alembic import op
revision = "0016"
down_revision = "0015"
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", "0016_project_fields.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

View File

@ -0,0 +1,940 @@
-- ════════════════════════════════════════════════════════════════════════════
-- МИГРАЦИЯ: Поля project + реструктуризация rf_project_report
-- (year -> rf_project_year, атрибуты сметы -> smeta)
-- ════════════════════════════════════════════════════════════════════════════
-- Поля project
ALTER TABLE v3.project ADD COLUMN status VARCHAR DEFAULT 'created';
ALTER TABLE v3.project ADD COLUMN technical_number VARCHAR;
ALTER TABLE v3.project ADD COLUMN krf_decision_date DATE;
ALTER TABLE v3.project ADD COLUMN fk_decision_date DATE;
ALTER TABLE v3.project ADD COLUMN board_decision_date DATE;
ALTER TABLE v3.project ADD COLUMN open_relocate_close_date DATE;
ALTER TABLE v3.project ADD COLUMN funding_by_ko_decision VARCHAR;
ALTER TABLE v3.project DROP CONSTRAINT IF EXISTS project_status_check;
ALTER TABLE v3.project ADD CONSTRAINT project_status_check
CHECK (status IS NULL OR status IN ('created', 'agreed', 'archived'));
ALTER TABLE v3.project DROP CONSTRAINT IF EXISTS project_funding_ko_check;
ALTER TABLE v3.project ADD CONSTRAINT project_funding_ko_check
CHECK (funding_by_ko_decision IS NULL OR funding_by_ko_decision IN ('prrs_budget', 'bank_reserve'));
-- rf_project_year: год проекта как отдельная сущность
CREATE TABLE v3.rf_project_year (
id SERIAL PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES v3.project(id),
year INTEGER NOT NULL,
CONSTRAINT uq_v3_rf_project_year UNIQUE (project_id, year)
);
CREATE INDEX ix_v3_rf_project_year_project ON v3.rf_project_year(project_id);
-- Перенос существующих year из rf_project_report в rf_project_year
INSERT INTO v3.rf_project_year (project_id, year)
SELECT DISTINCT project_id, year FROM v3.rf_project_report;
-- Связь rf_project_report с rf_project_year
ALTER TABLE v3.rf_project_report ADD COLUMN rf_project_year_id INTEGER;
UPDATE v3.rf_project_report rpr
SET rf_project_year_id = rpy.id
FROM v3.rf_project_year rpy
WHERE rpy.project_id = rpr.project_id AND rpy.year = rpr.year;
ALTER TABLE v3.rf_project_report ALTER COLUMN rf_project_year_id SET NOT NULL;
ALTER TABLE v3.rf_project_report
ADD CONSTRAINT fk_v3_rf_project_report_year
FOREIGN KEY (rf_project_year_id) REFERENCES v3.rf_project_year(id);
-- Убираем year из rf_project_report
ALTER TABLE v3.rf_project_report DROP CONSTRAINT IF EXISTS uq_v3_rf_project_report;
DROP INDEX IF EXISTS v3.ix_v3_rf_project_report_proj_year;
ALTER TABLE v3.rf_project_report DROP COLUMN year;
-- Восстанавливаем уникальность и индекс на новой связке
ALTER TABLE v3.rf_project_report
ADD CONSTRAINT uq_v3_rf_project_report UNIQUE (project_id, rf_project_year_id, report_type);
CREATE INDEX ix_v3_rf_project_report_proj_year ON v3.rf_project_report(project_id, rf_project_year_id);
-- smeta: атрибуты сметы на год проекта
CREATE TABLE v3.smeta (
id SERIAL PRIMARY KEY,
rf_project_year_id INTEGER NOT NULL REFERENCES v3.rf_project_year(id),
is_in_plan BOOLEAN,
is_in_plan_q2 BOOLEAN,
is_in_plan_q3 BOOLEAN,
is_in_plan_q4 BOOLEAN,
development_block VARCHAR,
reserve_to_prrs_ahr NUMERIC,
reserve_to_prrs_kv NUMERIC,
CONSTRAINT smeta_development_block_check
CHECK (development_block IS NULL OR development_block IN ('out_of_base_plan', 'carryover', 'additional_funding'))
);
-- Funcs
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, rpry.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
JOIN v3.rf_project_year rpry ON rpr.rf_project_year_id = rpry.id
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$
;
-- DROP FUNCTION v3.add_project_year(int4, int4, bool);
CREATE OR REPLACE FUNCTION v3.add_project_year(p_project_id integer, p_year integer, p_emit_audit boolean DEFAULT true)
RETURNS TABLE(limit_report_id integer, current_expenses_report_id integer)
LANGUAGE plpgsql
AS $function$
DECLARE
v_ryid INT;
v_lim INT;
v_cur INT;
BEGIN
IF p_year IS NULL THEN
RAISE EXCEPTION 'p_year обязателен';
END IF;
IF NOT EXISTS (SELECT 1 FROM v3.project WHERE id = p_project_id) THEN
RAISE EXCEPTION 'project id=% не существует', p_project_id;
END IF;
IF EXISTS (SELECT 1 FROM v3.rf_project_year
WHERE project_id = p_project_id AND year = p_year) THEN
RAISE EXCEPTION 'год % у проекта id=% уже заведён', p_year, p_project_id;
END IF;
INSERT INTO v3.rf_project_year (project_id, year)
VALUES (p_project_id, p_year)
RETURNING id INTO v_ryid;
INSERT INTO v3.smeta (rf_project_year_id)
VALUES (v_ryid);
INSERT INTO v3.rf_project_report (project_id, rf_project_year_id, report_type)
VALUES (p_project_id, v_ryid, 'LIMIT') RETURNING id INTO v_lim;
INSERT INTO v3.rf_project_report (project_id, rf_project_year_id, report_type)
VALUES (p_project_id, v_ryid, 'CURRENT_EXPENSES') RETURNING id INTO v_cur;
PERFORM v3.copy_template_to_report(v_lim);
PERFORM v3.copy_template_to_report(v_cur);
PERFORM v3.seed_form3_report_lines(v_lim);
PERFORM v3.seed_form3_report_lines(v_cur);
IF p_emit_audit THEN
PERFORM v3.log_event(
'PROJECT_YEAR_ADD', 'PROJECT',
jsonb_build_object(
'project_id', p_project_id,
'year', p_year,
'limit_report_id', v_lim,
'current_expenses_report_id', v_cur
)
);
END IF;
RETURN QUERY SELECT v_lim, v_cur;
END;
$function$
;
-- DROP FUNCTION v3.del_form3_phase(int4, varchar);
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, rpry.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
JOIN v3.rf_project_year rpry ON rpr.rf_project_year_id = rpry.id
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$
;
-- DROP FUNCTION v3.upd_form3_phase(int4, varchar, varchar, _text, timestamp, timestamp);
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, rpry.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
JOIN v3.rf_project_year rpry ON rpr.rf_project_year_id = rpry.id
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$
;
-- DROP FUNCTION v3.v_form3_project_jsonb(int4, varchar, _text);
CREATE OR REPLACE FUNCTION v3.v_form3_project_jsonb(p_project_id integer, p_report_type character varying, p_sections text[] DEFAULT NULL::text[])
RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb)
LANGUAGE sql
STABLE
AS $function$
WITH reports AS (
SELECT r.id AS report_id, rpry.year
FROM v3.rf_project_report r
JOIN v3.rf_project_year rpry ON rpry.id = r.rf_project_year_id
WHERE r.project_id = p_project_id
AND r.report_type = p_report_type
),
-- Разворачиваем каждый год в строки его листа
per_year AS (
SELECT
v.row_type,
v.depth,
(v.data->'header'->>'expense_item_id')::INT AS eiid,
v.data->'header' AS header,
v.sort_order AS y_sort,
rp.year AS year,
v.data AS ydata
FROM reports rp
CROSS JOIN LATERAL v3.v_form3_report_jsonb(rp.report_id, p_sections) v
),
-- Сшиваем по идентичности строки (row_type + статья): сетка одинакова во всех годах
agg AS (
SELECT
py.row_type,
py.depth,
py.eiid,
MIN(py.y_sort) AS sort_order,
(array_agg(py.header ORDER BY py.year))[1] AS header,
jsonb_object_agg(py.year::text, py.ydata ORDER BY py.year) AS years,
SUM((py.ydata->'totals'->>'total_actual')::numeric) AS project_total
FROM per_year py
GROUP BY py.row_type, py.depth, py.eiid
)
SELECT
a.row_type,
a.depth,
ROW_NUMBER() OVER (ORDER BY a.sort_order) AS sort_order,
jsonb_build_object(
'header', a.header,
'years', a.years,
'project_total', a.project_total
) AS data
FROM agg a
ORDER BY a.sort_order;
$function$
;
-- DROP FUNCTION v3.v_form3_rf_rollup_sections(int4, int4, _text);
CREATE OR REPLACE FUNCTION v3.v_form3_rf_rollup_sections(p_branch_id integer, p_year integer, p_sections text[] DEFAULT NULL::text[])
RETURNS TABLE(row_type character varying, depth integer, col_section_code character varying, col_item_id character varying, col_num_group_id character varying, col_name character varying, col_q1_adj_by_items numeric, col_q1_adj_increase numeric, col_q1_total_corr numeric, col_q1_m1 numeric, col_q1_m2 numeric, col_q1_m3 numeric, col_q1_quarter_actual numeric, col_q1_economy numeric, col_q2_adj_by_items numeric, col_q2_adj_increase numeric, col_q2_total_corr numeric, col_q2_m1 numeric, col_q2_m2 numeric, col_q2_m3 numeric, col_q2_quarter_actual numeric, col_q2_economy numeric, col_q3_adj_by_items numeric, col_q3_adj_increase numeric, col_q3_total_corr numeric, col_q3_m1 numeric, col_q3_m2 numeric, col_q3_m3 numeric, col_q3_quarter_actual numeric, col_q3_economy numeric, col_q4_adj_by_items numeric, col_q4_adj_increase numeric, col_q4_total_corr numeric, col_q4_m1 numeric, col_q4_m2 numeric, col_q4_m3 numeric, col_q4_spod numeric, col_q4_quarter_actual numeric, col_q4_economy numeric, col_year_total_corr numeric, col_year_total_actual numeric, col_year_economy numeric, _sort_path integer[])
LANGUAGE plpgsql
STABLE
AS $function$
DECLARE
s_q1 BOOLEAN := p_sections IS NULL OR 'q1' = ANY(p_sections);
s_q2 BOOLEAN := p_sections IS NULL OR 'q2' = ANY(p_sections);
s_q3 BOOLEAN := p_sections IS NULL OR 'q3' = ANY(p_sections);
s_q4 BOOLEAN := p_sections IS NULL OR 'q4' = ANY(p_sections);
s_year BOOLEAN := p_sections IS NULL OR 'year' = ANY(p_sections);
BEGIN
RETURN QUERY
WITH
tw AS (
SELECT t.* FROM v3.mv_expense_item_tree t
JOIN v3.expense_item_form_type eft
ON eft.expense_item_id = t.id AND eft.form_type_code = 'FORM_3'
),
-- Агрегаты per (expense_item_id, quarter) — sum по обоим трекам всех проектов РФ × год
agg_q AS (
SELECT l.expense_item_id AS eid, q.quarter,
SUM(q.adj_by_items) AS abi, SUM(q.adj_increase) AS ai,
SUM(q.actual_m1) AS m1, SUM(q.actual_m2) AS m2, SUM(q.actual_m3) AS m3,
SUM(q.actual_spod) AS spod
FROM v3.rf_project_report r
JOIN v3.rf_project_year rpry ON rpry.id = r.rf_project_year_id
JOIN v3.project p ON p.id = r.project_id
JOIN v3.rf_project_report_line l ON l.rf_project_report_id = r.id
JOIN v3.rf_project_report_quarter q ON q.rf_project_report_line_id = l.id
WHERE p.org_unit_id = p_branch_id AND rpry.year = p_year
GROUP BY l.expense_item_id, q.quarter
),
tw_aq1 AS (SELECT tw.id, SUM(a.abi) AS abi, SUM(a.ai) AS ai,
SUM(a.m1) AS m1, SUM(a.m2) AS m2, SUM(a.m3) AS m3
FROM tw LEFT JOIN agg_q a ON a.eid = ANY(tw.desc_ids) AND a.quarter=1
GROUP BY tw.id),
tw_aq2 AS (SELECT tw.id, SUM(a.abi) AS abi, SUM(a.ai) AS ai,
SUM(a.m1) AS m1, SUM(a.m2) AS m2, SUM(a.m3) AS m3
FROM tw LEFT JOIN agg_q a ON a.eid = ANY(tw.desc_ids) AND a.quarter=2
GROUP BY tw.id),
tw_aq3 AS (SELECT tw.id, SUM(a.abi) AS abi, SUM(a.ai) AS ai,
SUM(a.m1) AS m1, SUM(a.m2) AS m2, SUM(a.m3) AS m3
FROM tw LEFT JOIN agg_q a ON a.eid = ANY(tw.desc_ids) AND a.quarter=3
GROUP BY tw.id),
tw_aq4 AS (SELECT tw.id, SUM(a.abi) AS abi, SUM(a.ai) AS ai,
SUM(a.m1) AS m1, SUM(a.m2) AS m2, SUM(a.m3) AS m3,
SUM(a.spod) AS spod
FROM tw LEFT JOIN agg_q a ON a.eid = ANY(tw.desc_ids) AND a.quarter=4
GROUP BY tw.id)
SELECT
CASE t.depth WHEN 0 THEN 'ROOT' WHEN 1 THEN 'GROUP'
WHEN 2 THEN 'ITEM' WHEN 3 THEN 'SUB_ITEM' END::VARCHAR,
t.depth,
CASE WHEN t.depth < 3 THEN t.item_id ELSE t.parent_item_id END,
CASE WHEN t.depth = 3 THEN t.item_id ELSE NULL END,
t.num_group_id, t.name,
-- Q1
CASE WHEN s_q1 THEN a1.abi END,
CASE WHEN s_q1 THEN a1.ai END,
CASE WHEN s_q1 THEN COALESCE(a1.abi,0)+COALESCE(a1.ai,0) END,
CASE WHEN s_q1 THEN a1.m1 END,
CASE WHEN s_q1 THEN a1.m2 END,
CASE WHEN s_q1 THEN a1.m3 END,
CASE WHEN s_q1 THEN COALESCE(a1.m1,0)+COALESCE(a1.m2,0)+COALESCE(a1.m3,0) END,
CASE WHEN s_q1 THEN ROUND((COALESCE(a1.abi,0)+COALESCE(a1.ai,0))
-(COALESCE(a1.m1,0)+COALESCE(a1.m2,0)+COALESCE(a1.m3,0)), 1) END,
-- Q2
CASE WHEN s_q2 THEN a2.abi END,
CASE WHEN s_q2 THEN a2.ai END,
CASE WHEN s_q2 THEN COALESCE(a2.abi,0)+COALESCE(a2.ai,0) END,
CASE WHEN s_q2 THEN a2.m1 END,
CASE WHEN s_q2 THEN a2.m2 END,
CASE WHEN s_q2 THEN a2.m3 END,
CASE WHEN s_q2 THEN COALESCE(a2.m1,0)+COALESCE(a2.m2,0)+COALESCE(a2.m3,0) END,
CASE WHEN s_q2 THEN ROUND((COALESCE(a2.abi,0)+COALESCE(a2.ai,0))
-(COALESCE(a2.m1,0)+COALESCE(a2.m2,0)+COALESCE(a2.m3,0)), 1) END,
-- Q3
CASE WHEN s_q3 THEN a3.abi END,
CASE WHEN s_q3 THEN a3.ai END,
CASE WHEN s_q3 THEN COALESCE(a3.abi,0)+COALESCE(a3.ai,0) END,
CASE WHEN s_q3 THEN a3.m1 END,
CASE WHEN s_q3 THEN a3.m2 END,
CASE WHEN s_q3 THEN a3.m3 END,
CASE WHEN s_q3 THEN COALESCE(a3.m1,0)+COALESCE(a3.m2,0)+COALESCE(a3.m3,0) END,
CASE WHEN s_q3 THEN ROUND((COALESCE(a3.abi,0)+COALESCE(a3.ai,0))
-(COALESCE(a3.m1,0)+COALESCE(a3.m2,0)+COALESCE(a3.m3,0)), 1) END,
-- Q4
CASE WHEN s_q4 THEN a4.abi END,
CASE WHEN s_q4 THEN a4.ai END,
CASE WHEN s_q4 THEN COALESCE(a4.abi,0)+COALESCE(a4.ai,0) END,
CASE WHEN s_q4 THEN a4.m1 END,
CASE WHEN s_q4 THEN a4.m2 END,
CASE WHEN s_q4 THEN a4.m3 END,
CASE WHEN s_q4 THEN a4.spod END,
CASE WHEN s_q4 THEN COALESCE(a4.m1,0)+COALESCE(a4.m2,0)+COALESCE(a4.m3,0)+COALESCE(a4.spod,0) END,
CASE WHEN s_q4 THEN ROUND((COALESCE(a4.abi,0)+COALESCE(a4.ai,0))
-(COALESCE(a4.m1,0)+COALESCE(a4.m2,0)+COALESCE(a4.m3,0)+COALESCE(a4.spod,0)), 1) END,
-- year
CASE WHEN s_year THEN COALESCE(a1.abi,0)+COALESCE(a1.ai,0)+COALESCE(a2.abi,0)+COALESCE(a2.ai,0)+
COALESCE(a3.abi,0)+COALESCE(a3.ai,0)+COALESCE(a4.abi,0)+COALESCE(a4.ai,0) END,
CASE WHEN s_year THEN COALESCE(a1.m1,0)+COALESCE(a1.m2,0)+COALESCE(a1.m3,0)+
COALESCE(a2.m1,0)+COALESCE(a2.m2,0)+COALESCE(a2.m3,0)+
COALESCE(a3.m1,0)+COALESCE(a3.m2,0)+COALESCE(a3.m3,0)+
COALESCE(a4.m1,0)+COALESCE(a4.m2,0)+COALESCE(a4.m3,0)+COALESCE(a4.spod,0) END,
CASE WHEN s_year THEN
ROUND(
(COALESCE(a1.abi,0)+COALESCE(a1.ai,0)+COALESCE(a2.abi,0)+COALESCE(a2.ai,0)+
COALESCE(a3.abi,0)+COALESCE(a3.ai,0)+COALESCE(a4.abi,0)+COALESCE(a4.ai,0))
-(COALESCE(a1.m1,0)+COALESCE(a1.m2,0)+COALESCE(a1.m3,0)+
COALESCE(a2.m1,0)+COALESCE(a2.m2,0)+COALESCE(a2.m3,0)+
COALESCE(a3.m1,0)+COALESCE(a3.m2,0)+COALESCE(a3.m3,0)+
COALESCE(a4.m1,0)+COALESCE(a4.m2,0)+COALESCE(a4.m3,0)+COALESCE(a4.spod,0)),
1)
END,
t.path
FROM tw t
LEFT JOIN tw_aq1 a1 ON a1.id = t.id
LEFT JOIN tw_aq2 a2 ON a2.id = t.id
LEFT JOIN tw_aq3 a3 ON a3.id = t.id
LEFT JOIN tw_aq4 a4 ON a4.id = t.id
ORDER BY t.path;
END;
$function$
;
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);
v_year INTEGER;
BEGIN
SELECT report_type, org_unit_id, "year" INTO v_report_type, v_org_unit, v_year
FROM v3.rf_project_report AS rpr
INNER JOIN v3.project AS p ON p.id = rpr.project_id
INNER JOIN v3.rf_project_year AS rpy ON rpy.id = rpr.rf_project_year_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 + INTERVAL '1 year' * v_year)::text || v_utc_offset)::timestamptz,
((pt.closes_at + INTERVAL '1 year' * v_year)::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$
;
-- DROP FUNCTION v3.del_project(int4);
CREATE OR REPLACE FUNCTION v3.del_project(p_project_id integer)
RETURNS integer
LANGUAGE plpgsql
AS $function$
DECLARE
v_old RECORD;
v_actor_id INTEGER;
v_role VARCHAR;
BEGIN
SELECT p.name, p.level, p.org_unit_id
INTO v_old
FROM v3.project p WHERE p.id = p_project_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'project #% не существует', p_project_id;
END IF;
IF NOT v3.can_delete_project(p_project_id) THEN
RAISE EXCEPTION 'project #% невозможно удалить', p_project_id;
END IF;
DELETE FROM v3.form3_phase f3p WHERE f3p.rf_project_report_id IN (
SELECT id FROM v3.rf_project_report rpr WHERE project_id = p_project_id
);
DELETE FROM v3.rf_project_report_quarter
WHERE rf_project_report_line_id IN (
SELECT l.id FROM v3.rf_project_report_line l
JOIN v3.rf_project_report r ON r.id = l.rf_project_report_id
WHERE r.project_id = p_project_id);
DELETE FROM v3.rf_project_report_line
WHERE rf_project_report_id IN (
SELECT id FROM v3.rf_project_report WHERE project_id = p_project_id);
DELETE FROM v3.rf_project_report WHERE project_id = p_project_id;
DELETE FROM v3.smeta WHERE rf_project_year_id IN (
SELECT id FROM v3.rf_project_year WHERE project_id = p_project_id
);
DELETE FROM v3.rf_project_year WHERE project_id = p_project_id;
DELETE FROM v3.project WHERE id = p_project_id;
PERFORM v3.log_event(
'PROJECT_DELETE', 'PROJECT',
jsonb_build_object(
'project_id', p_project_id,
'name', v_old.name,
'level', v_old.level,
'org_unit_id', v_old.org_unit_id), null, null, v_old.org_unit_id);
RETURN p_project_id;
END;
$function$
;
-- DROP FUNCTION v3.upd_project(int4, varchar, varchar, varchar, varchar, varchar, varchar, varchar, int4, numeric, int4, date, date, date, date, varchar);
CREATE OR REPLACE FUNCTION v3.upd_project(
p_project_id integer,
p_name character varying DEFAULT NULL,
p_status character varying DEFAULT NULL,
p_technical_number character varying DEFAULT NULL,
p_project_type character varying DEFAULT NULL,
p_vsp_format character varying DEFAULT NULL,
p_placement_type character varying DEFAULT NULL,
p_object_address character varying DEFAULT NULL,
p_staff_count integer DEFAULT NULL,
p_total_area numeric DEFAULT NULL,
p_org_unit_id integer DEFAULT NULL,
p_krf_decision_date date DEFAULT NULL,
p_fk_decision_date date DEFAULT NULL,
p_board_decision_date date DEFAULT NULL,
p_open_relocate_close_date date DEFAULT NULL,
p_funding_by_ko_decision character varying DEFAULT NULL
)
RETURNS void
LANGUAGE plpgsql
AS $function$
DECLARE
v_old v3.project%ROWTYPE;
v_changes JSONB := '{}'::jsonb;
BEGIN
SELECT * INTO v_old FROM v3.project WHERE id = p_project_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'project id=% не существует', p_project_id;
END IF;
IF p_name IS NOT NULL AND p_name IS DISTINCT FROM v_old.name THEN
UPDATE v3.project SET name = p_name WHERE id = p_project_id;
v_changes := v_changes || jsonb_build_object('name',
jsonb_build_object('before', v3._diff_val(v_old.name), 'after', v3._diff_val(p_name)));
END IF;
IF p_status IS NOT NULL AND p_status IS DISTINCT FROM v_old.status THEN
UPDATE v3.project SET status = p_status WHERE id = p_project_id;
v_changes := v_changes || jsonb_build_object('status',
jsonb_build_object('before', v3._diff_val(v_old.status), 'after', v3._diff_val(p_status)));
END IF;
IF p_technical_number IS NOT NULL AND p_technical_number IS DISTINCT FROM v_old.technical_number THEN
UPDATE v3.project SET technical_number = p_technical_number WHERE id = p_project_id;
v_changes := v_changes || jsonb_build_object('technical_number',
jsonb_build_object('before', v3._diff_val(v_old.technical_number), 'after', v3._diff_val(p_technical_number)));
END IF;
IF p_project_type IS NOT NULL AND p_project_type IS DISTINCT FROM v_old.project_type THEN
UPDATE v3.project SET project_type = p_project_type WHERE id = p_project_id;
v_changes := v_changes || jsonb_build_object('project_type',
jsonb_build_object('before', v3._diff_val(v_old.project_type), 'after', v3._diff_val(p_project_type)));
END IF;
IF p_vsp_format IS NOT NULL AND p_vsp_format IS DISTINCT FROM v_old.vsp_format THEN
UPDATE v3.project SET vsp_format = p_vsp_format WHERE id = p_project_id;
v_changes := v_changes || jsonb_build_object('vsp_format',
jsonb_build_object('before', v3._diff_val(v_old.vsp_format), 'after', v3._diff_val(p_vsp_format)));
END IF;
IF p_placement_type IS NOT NULL AND p_placement_type IS DISTINCT FROM v_old.placement_type THEN
UPDATE v3.project SET placement_type = p_placement_type WHERE id = p_project_id;
v_changes := v_changes || jsonb_build_object('placement_type',
jsonb_build_object('before', v3._diff_val(v_old.placement_type), 'after', v3._diff_val(p_placement_type)));
END IF;
IF p_object_address IS NOT NULL AND p_object_address IS DISTINCT FROM v_old.object_address THEN
UPDATE v3.project SET object_address = p_object_address WHERE id = p_project_id;
v_changes := v_changes || jsonb_build_object('object_address',
jsonb_build_object('before', v3._diff_val(v_old.object_address), 'after', v3._diff_val(p_object_address)));
END IF;
IF p_staff_count IS NOT NULL AND p_staff_count IS DISTINCT FROM v_old.staff_count THEN
UPDATE v3.project SET staff_count = p_staff_count WHERE id = p_project_id;
v_changes := v_changes || jsonb_build_object('staff_count',
jsonb_build_object('before', v3._diff_val(v_old.staff_count), 'after', v3._diff_val(p_staff_count)));
END IF;
IF p_total_area IS NOT NULL AND p_total_area IS DISTINCT FROM v_old.total_area THEN
UPDATE v3.project SET total_area = p_total_area WHERE id = p_project_id;
v_changes := v_changes || jsonb_build_object('total_area',
jsonb_build_object('before', v3._diff_val(v_old.total_area), 'after', v3._diff_val(p_total_area)));
END IF;
IF p_org_unit_id IS NOT NULL AND p_org_unit_id IS DISTINCT FROM v_old.org_unit_id THEN
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;
UPDATE v3.project SET org_unit_id = p_org_unit_id WHERE id = p_project_id;
v_changes := v_changes || jsonb_build_object('org_unit_id',
jsonb_build_object('before', v3._diff_val(v_old.org_unit_id), 'after', v3._diff_val(p_org_unit_id)));
END IF;
IF p_krf_decision_date IS NOT NULL AND p_krf_decision_date IS DISTINCT FROM v_old.krf_decision_date THEN
UPDATE v3.project SET krf_decision_date = p_krf_decision_date WHERE id = p_project_id;
v_changes := v_changes || jsonb_build_object('krf_decision_date',
jsonb_build_object('before', v3._diff_val(v_old.krf_decision_date), 'after', v3._diff_val(p_krf_decision_date)));
END IF;
IF p_fk_decision_date IS NOT NULL AND p_fk_decision_date IS DISTINCT FROM v_old.fk_decision_date THEN
UPDATE v3.project SET fk_decision_date = p_fk_decision_date WHERE id = p_project_id;
v_changes := v_changes || jsonb_build_object('fk_decision_date',
jsonb_build_object('before', v3._diff_val(v_old.fk_decision_date), 'after', v3._diff_val(p_fk_decision_date)));
END IF;
IF p_board_decision_date IS NOT NULL AND p_board_decision_date IS DISTINCT FROM v_old.board_decision_date THEN
UPDATE v3.project SET board_decision_date = p_board_decision_date WHERE id = p_project_id;
v_changes := v_changes || jsonb_build_object('board_decision_date',
jsonb_build_object('before', v3._diff_val(v_old.board_decision_date), 'after', v3._diff_val(p_board_decision_date)));
END IF;
IF p_open_relocate_close_date IS NOT NULL AND p_open_relocate_close_date IS DISTINCT FROM v_old.open_relocate_close_date THEN
UPDATE v3.project SET open_relocate_close_date = p_open_relocate_close_date WHERE id = p_project_id;
v_changes := v_changes || jsonb_build_object('open_relocate_close_date',
jsonb_build_object('before', v3._diff_val(v_old.open_relocate_close_date), 'after', v3._diff_val(p_open_relocate_close_date)));
END IF;
IF p_funding_by_ko_decision IS NOT NULL AND p_funding_by_ko_decision IS DISTINCT FROM v_old.funding_by_ko_decision THEN
UPDATE v3.project SET funding_by_ko_decision = p_funding_by_ko_decision WHERE id = p_project_id;
v_changes := v_changes || jsonb_build_object('funding_by_ko_decision',
jsonb_build_object('before', v3._diff_val(v_old.funding_by_ko_decision), 'after', v3._diff_val(p_funding_by_ko_decision)));
END IF;
IF v_changes <> '{}'::jsonb THEN
PERFORM v3.log_event(
'PROJECT_UPDATE', 'PROJECT',
jsonb_build_object(
'project_id', p_project_id,
'entity_id', p_project_id,
'changes', v_changes
), null, null, COALESCE(p_org_unit_id, v_old.org_unit_id)
);
END IF;
END;
$function$
;
-- DROP FUNCTION v3.upd_smeta(int4, bool, bool, bool, bool, varchar, numeric, numeric);
CREATE OR REPLACE FUNCTION v3.upd_smeta(
p_rf_project_year_id integer,
p_is_in_plan boolean DEFAULT NULL,
p_is_in_plan_q2 boolean DEFAULT NULL,
p_is_in_plan_q3 boolean DEFAULT NULL,
p_is_in_plan_q4 boolean DEFAULT NULL,
p_development_block character varying DEFAULT NULL,
p_reserve_to_prrs_ahr numeric DEFAULT NULL,
p_reserve_to_prrs_kv numeric DEFAULT NULL
)
RETURNS void
LANGUAGE plpgsql
AS $function$
DECLARE
v_old v3.smeta%ROWTYPE;
v_changes JSONB := '{}'::jsonb;
v_project_id INT;
v_org_unit_id INT;
BEGIN
IF NOT EXISTS (SELECT 1 FROM v3.rf_project_year WHERE id = p_rf_project_year_id) THEN
RAISE EXCEPTION 'rf_project_year id=% не существует', p_rf_project_year_id;
END IF;
INSERT INTO v3.smeta (rf_project_year_id)
SELECT p_rf_project_year_id
WHERE NOT EXISTS (SELECT 1 FROM v3.smeta WHERE rf_project_year_id = p_rf_project_year_id);
SELECT * INTO v_old FROM v3.smeta WHERE rf_project_year_id = p_rf_project_year_id;
IF p_is_in_plan IS NOT NULL AND p_is_in_plan IS DISTINCT FROM v_old.is_in_plan THEN
UPDATE v3.smeta SET is_in_plan = p_is_in_plan WHERE id = v_old.id;
v_changes := v_changes || jsonb_build_object('is_in_plan',
jsonb_build_object('before', v3._diff_val(v_old.is_in_plan), 'after', v3._diff_val(p_is_in_plan)));
END IF;
IF p_is_in_plan_q2 IS NOT NULL AND p_is_in_plan_q2 IS DISTINCT FROM v_old.is_in_plan_q2 THEN
UPDATE v3.smeta SET is_in_plan_q2 = p_is_in_plan_q2 WHERE id = v_old.id;
v_changes := v_changes || jsonb_build_object('is_in_plan_q2',
jsonb_build_object('before', v3._diff_val(v_old.is_in_plan_q2), 'after', v3._diff_val(p_is_in_plan_q2)));
END IF;
IF p_is_in_plan_q3 IS NOT NULL AND p_is_in_plan_q3 IS DISTINCT FROM v_old.is_in_plan_q3 THEN
UPDATE v3.smeta SET is_in_plan_q3 = p_is_in_plan_q3 WHERE id = v_old.id;
v_changes := v_changes || jsonb_build_object('is_in_plan_q3',
jsonb_build_object('before', v3._diff_val(v_old.is_in_plan_q3), 'after', v3._diff_val(p_is_in_plan_q3)));
END IF;
IF p_is_in_plan_q4 IS NOT NULL AND p_is_in_plan_q4 IS DISTINCT FROM v_old.is_in_plan_q4 THEN
UPDATE v3.smeta SET is_in_plan_q4 = p_is_in_plan_q4 WHERE id = v_old.id;
v_changes := v_changes || jsonb_build_object('is_in_plan_q4',
jsonb_build_object('before', v3._diff_val(v_old.is_in_plan_q4), 'after', v3._diff_val(p_is_in_plan_q4)));
END IF;
IF p_development_block IS NOT NULL AND p_development_block IS DISTINCT FROM v_old.development_block THEN
UPDATE v3.smeta SET development_block = p_development_block WHERE id = v_old.id;
v_changes := v_changes || jsonb_build_object('development_block',
jsonb_build_object('before', v3._diff_val(v_old.development_block), 'after', v3._diff_val(p_development_block)));
END IF;
IF p_reserve_to_prrs_ahr IS NOT NULL AND p_reserve_to_prrs_ahr IS DISTINCT FROM v_old.reserve_to_prrs_ahr THEN
UPDATE v3.smeta SET reserve_to_prrs_ahr = p_reserve_to_prrs_ahr WHERE id = v_old.id;
v_changes := v_changes || jsonb_build_object('reserve_to_prrs_ahr',
jsonb_build_object('before', v3._diff_val(v_old.reserve_to_prrs_ahr), 'after', v3._diff_val(p_reserve_to_prrs_ahr)));
END IF;
IF p_reserve_to_prrs_kv IS NOT NULL AND p_reserve_to_prrs_kv IS DISTINCT FROM v_old.reserve_to_prrs_kv THEN
UPDATE v3.smeta SET reserve_to_prrs_kv = p_reserve_to_prrs_kv WHERE id = v_old.id;
v_changes := v_changes || jsonb_build_object('reserve_to_prrs_kv',
jsonb_build_object('before', v3._diff_val(v_old.reserve_to_prrs_kv), 'after', v3._diff_val(p_reserve_to_prrs_kv)));
END IF;
SELECT rpy.project_id, p.org_unit_id
INTO v_project_id, v_org_unit_id
FROM v3.rf_project_year rpy
JOIN v3.project p ON p.id = rpy.project_id
WHERE rpy.id = p_rf_project_year_id;
IF v_changes <> '{}'::jsonb THEN
PERFORM v3.log_event(
'SMETA_UPDATE', 'PROJECT',
jsonb_build_object(
'rf_project_year_id', p_rf_project_year_id,
'project_id', v_project_id,
'changes', v_changes
), null, null, v_org_unit_id
);
END IF;
END;
$function$
;

View File

@ -198,6 +198,7 @@ async def get_form3_phases(
project_id=project_id,
year=year,
report_type=report_type,
load_year=True,
)
phases = await f3p_service.get_list(

View File

@ -18,6 +18,7 @@ from src.domain.schemas import (
ResponseBase,
SheetResponse,
UpdProjectBody,
UpdSmetaBody,
)
from src.services.project_service import ProjectService
@ -67,6 +68,27 @@ async def get_projects(
return BaseListResponse(result=projects, count=count)
@router.get("/projects/with-reports")
async def get_projects_with_reports(
offset: int = 0,
limit: int = 100,
year: Optional[int] = None,
branch_id: Optional[int] = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[dict]:
project_service = ProjectService(db)
count, projects = await project_service.get_list_with_reports(
user=current_user,
with_count=True,
year=year,
branch_id=branch_id,
offset=offset,
limit=limit,
)
return BaseListResponse(result=projects, count=count)
@router.get("/projects/{project_id}")
async def get_project(
project_id: int,
@ -243,13 +265,28 @@ async def upd_project(
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> dict:
project_service = ProjectService(db)
result = await project_service.upd_project(
return await project_service.upd_project(
project_id=project_id,
column=body.column,
value=body.value,
data=body.model_dump(exclude_unset=True),
user=current_user,
)
@router.patch("/projects/{project_id}/smeta/{year}")
async def upd_smeta(
project_id: int,
year: int,
body: UpdSmetaBody,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> dict:
project_service = ProjectService(db)
return await project_service.upd_smeta(
project_id=project_id,
year=year,
data=body.model_dump(exclude_unset=True),
user=current_user,
)
return result
@router.delete("/projects/{project_id}")

View File

@ -25,9 +25,11 @@ from src.db.models.reserve import Reserve
from src.db.models.rf_project_report import RfProjectReport
from src.db.models.rf_project_report_line import RfProjectReportLine
from src.db.models.rf_project_report_quarter import RfProjectReportQuarter
from src.db.models.rf_project_year import RfProjectYear
from src.db.models.role import Role, UserRoleEnum
from src.db.models.security_detail import SecurityDetail
from src.db.models.sequestration import Sequestration
from src.db.models.smeta import Smeta
from src.db.models.user_org import UserOrg
from src.db.models.utility_detail import UtilityDetail
from src.db.models.vsp import Vsp

View File

@ -1,8 +1,8 @@
from __future__ import annotations
from datetime import datetime
from datetime import date, datetime
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, Integer, Numeric, String, func, text
from sqlalchemy import CheckConstraint, Date, DateTime, ForeignKey, Index, Integer, Numeric, String, func, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
@ -28,6 +28,14 @@ class Project(Base):
text("name IS NOT NULL AND name <> ''"),
name="chk_v3_project_name",
),
CheckConstraint(
"status IS NULL OR status IN ('created', 'agreed', 'archived')",
name="project_status_check",
),
CheckConstraint(
"funding_by_ko_decision IS NULL OR funding_by_ko_decision IN ('prrs_budget', 'bank_reserve')",
name="project_funding_ko_check",
),
Index("ix_v3_project_ssp", "org_unit_id"),
{"schema": "v3"},
)
@ -44,6 +52,13 @@ class Project(Base):
total_area: Mapped[float | None] = mapped_column(Numeric)
org_unit_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.org_unit.id"))
created_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now())
status: Mapped[str | None] = mapped_column(String, server_default=text("'created'"))
technical_number: Mapped[str | None] = mapped_column(String)
krf_decision_date: Mapped[date | None] = mapped_column(Date)
fk_decision_date: Mapped[date | None] = mapped_column(Date)
board_decision_date: Mapped[date | None] = mapped_column(Date)
open_relocate_close_date: Mapped[date | None] = mapped_column(Date)
funding_by_ko_decision: Mapped[str | None] = mapped_column(String)
# parent = relationship("Project", remote_side="Project.id", back_populates="children")
# children: Mapped[list["Project"]] = relationship("Project", back_populates="parent") # type: ignore

View File

@ -11,14 +11,14 @@ from src.db.base import Base
class RfProjectReport(Base):
__tablename__ = "rf_project_report"
__table_args__ = (
UniqueConstraint("project_id", "year", "report_type", name="uq_v3_rf_project_report"),
Index("ix_v3_rf_project_report_proj_year", "project_id", "year"),
UniqueConstraint("project_id", "rf_project_year_id", "report_type", name="uq_v3_rf_project_report"),
Index("ix_v3_rf_project_report_proj_year", "project_id", "rf_project_year_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.project.id"))
year: Mapped[int] = mapped_column(Integer)
rf_project_year_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.rf_project_year.id"))
report_type: Mapped[str] = mapped_column(String, CheckConstraint("report_type IN ('LIMIT','CURRENT_EXPENSES')"))
created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
created_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now())
@ -26,6 +26,11 @@ class RfProjectReport(Base):
updated_at: Mapped[datetime | None] = mapped_column(DateTime, server_default=func.now())
project: Mapped["Project"] = relationship("Project", back_populates="rf_project_reports") # type: ignore
rf_project_year: Mapped["RfProjectYear"] = relationship("RfProjectYear") # type: ignore
@property
def year(self):
return self.rf_project_year.year
# creator: Mapped[AppUser | None] = relationship("AppUser", foreign_keys=[created_by])
# updater: Mapped[AppUser | None] = relationship("AppUser", foreign_keys=[updated_by])
# lines: Mapped[list[RfProjectReportLine]] = relationship("RfProjectReportLine", back_populates="report")

View File

@ -0,0 +1,22 @@
from __future__ import annotations
from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class RfProjectYear(Base):
__tablename__ = "rf_project_year"
__table_args__ = (
UniqueConstraint("project_id", "year", name="uq_v3_rf_project_year"),
Index("ix_v3_rf_project_year_project", "project_id"),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.project.id"))
year: Mapped[int] = mapped_column(Integer)
project: Mapped["Project"] = relationship("Project") # type: ignore
smetas: Mapped[list["Smeta"]] = relationship("Smeta", back_populates="rf_project_year") # type: ignore

View File

@ -0,0 +1,29 @@
from __future__ import annotations
from sqlalchemy import Boolean, CheckConstraint, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.base import Base
class Smeta(Base):
__tablename__ = "smeta"
__table_args__ = (
CheckConstraint(
"development_block IS NULL OR development_block IN ('out_of_base_plan', 'carryover', 'additional_funding')",
name="smeta_development_block_check",
),
{"schema": "v3"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
rf_project_year_id: Mapped[int] = mapped_column(Integer, ForeignKey("v3.rf_project_year.id"))
is_in_plan: Mapped[bool | None] = mapped_column(Boolean)
is_in_plan_q2: Mapped[bool | None] = mapped_column(Boolean)
is_in_plan_q3: Mapped[bool | None] = mapped_column(Boolean)
is_in_plan_q4: Mapped[bool | None] = mapped_column(Boolean)
development_block: Mapped[str | None] = mapped_column(String)
reserve_to_prrs_ahr: Mapped[float | None] = mapped_column(Numeric)
reserve_to_prrs_kv: Mapped[float | None] = mapped_column(Numeric)
rf_project_year: Mapped["RfProjectYear"] = relationship("RfProjectYear", back_populates="smetas") # type: ignore

View File

@ -37,6 +37,28 @@ PlacementTypeLiteral = Literal[
"Субаренда",
]
PLACEMENT_TYPE_TO_DB: dict[str, str] = {
"Собственность": "own",
"Аренда": "rent",
"Субаренда": "other",
}
PLACEMENT_TYPE_FROM_DB: dict[str, str] = {
db_value: label for label, db_value in PLACEMENT_TYPE_TO_DB.items()
}
def placement_type_to_db(value: str | None) -> str | None:
if value is None:
return None
return PLACEMENT_TYPE_TO_DB.get(value, value)
def placement_type_from_db(value: str | None) -> str | None:
if value is None:
return None
return PLACEMENT_TYPE_FROM_DB.get(value, value)
# def serialize_missing_greenlet(cls, obj):
# result = {}
@ -356,8 +378,35 @@ class AddForm3LineBody(BaseModel):
class UpdProjectBody(BaseModel):
column: str
value: Any = None
name: Optional[str] = Field(
None,
max_length=30,
pattern=r"^[^+\-\/\\=&*\s]{1,30}$",
)
status: Optional[Literal["created", "agreed", "archived"]] = None
technical_number: Optional[str] = None
project_type: Optional[ProjectTypeLiteral] = None
vsp_format: Optional[VspFormatLiteral] = None
placement_type: Optional[PlacementTypeLiteral] = None
object_address: Optional[str] = None
staff_count: Optional[int] = None
total_area: Optional[float] = None
org_unit_id: Optional[int] = None
krf_decision_date: Optional[date] = None
fk_decision_date: Optional[date] = None
board_decision_date: Optional[date] = None
open_relocate_close_date: Optional[date] = None
funding_by_ko_decision: Optional[Literal["prrs_budget", "bank_reserve"]] = None
class UpdSmetaBody(BaseModel):
is_in_plan: Optional[bool] = None
is_in_plan_q2: Optional[bool] = None
is_in_plan_q3: Optional[bool] = None
is_in_plan_q4: Optional[bool] = None
development_block: Optional[Literal["out_of_base_plan", "carryover", "additional_funding"]] = None
reserve_to_prrs_ahr: Optional[float] = None
reserve_to_prrs_kv: Optional[float] = None
class AddProjectBody(BaseModel):

View File

@ -7,6 +7,9 @@ from src.db.models.org_unit import OrgUnit
from src.db.models.project import Project
from src.db.models.rf_project_report import RfProjectReport
from src.db.models.rf_project_report_line import RfProjectReportLine
from src.db.models.rf_project_year import RfProjectYear
from src.db.models.smeta import Smeta
from src.domain.schemas import placement_type_from_db
class ProjectRepository:
@ -22,7 +25,7 @@ class ProjectRepository:
"parent_id": project.parent_id,
"project_type": project.project_type,
"vsp_format": project.vsp_format,
"placement_type": project.placement_type,
"placement_type": placement_type_from_db(project.placement_type),
"object_address": project.object_address,
"staff_count": project.staff_count,
"total_area": float(project.total_area) if project.total_area is not None else None,
@ -38,9 +41,12 @@ class ProjectRepository:
if year is not None:
query = query.where(
exists(
select(1).where(
select(1)
.select_from(RfProjectReport)
.join(RfProjectYear, RfProjectYear.id == RfProjectReport.rf_project_year_id)
.where(
RfProjectReport.project_id == Project.id,
RfProjectReport.year == year,
RfProjectYear.year == year,
)
)
)
@ -69,7 +75,9 @@ class ProjectRepository:
)
report_years_subq = (
select(RfProjectReport.year)
select(RfProjectYear.year)
.select_from(RfProjectReport)
.join(RfProjectYear, RfProjectYear.id == RfProjectReport.rf_project_year_id)
.where(RfProjectReport.project_id == Project.id)
.distinct()
.scalar_subquery()
@ -101,6 +109,88 @@ class ProjectRepository:
total = int((await self.db.execute(count_query)).scalar() or 0)
return total, payload
@staticmethod
def _serialize_project_with_reports(project: Project, years: list[tuple[RfProjectYear, Smeta | None]]) -> dict:
return {
"id": project.id,
"name": project.name,
"level": project.level,
"parent_id": project.parent_id,
"project_type": project.project_type,
"vsp_format": project.vsp_format,
"placement_type": placement_type_from_db(project.placement_type),
"object_address": project.object_address,
"staff_count": project.staff_count,
"total_area": float(project.total_area) if project.total_area is not None else None,
"org_unit_id": project.org_unit_id,
"status": project.status,
"technical_number": project.technical_number,
"krf_decision_date": project.krf_decision_date.isoformat() if project.krf_decision_date else None,
"fk_decision_date": project.fk_decision_date.isoformat() if project.fk_decision_date else None,
"board_decision_date": project.board_decision_date.isoformat() if project.board_decision_date else None,
"open_relocate_close_date": project.open_relocate_close_date.isoformat() if project.open_relocate_close_date else None,
"funding_by_ko_decision": project.funding_by_ko_decision,
"created_at": project.created_at.isoformat() if project.created_at else None,
"sub_rows": [
{
"id": rpy.id,
"year": rpy.year,
"project": f"Смета_{rpy.year}",
"is_in_plan": smeta.is_in_plan if smeta else None,
"is_in_plan_q2": smeta.is_in_plan_q2 if smeta else None,
"is_in_plan_q3": smeta.is_in_plan_q3 if smeta else None,
"is_in_plan_q4": smeta.is_in_plan_q4 if smeta else None,
"development_block": smeta.development_block if smeta else None,
"reserve_to_prrs_ahr": float(smeta.reserve_to_prrs_ahr) if smeta and smeta.reserve_to_prrs_ahr is not None else None,
"reserve_to_prrs_kv": float(smeta.reserve_to_prrs_kv) if smeta and smeta.reserve_to_prrs_kv is not None else None,
}
for rpy, smeta in years
],
}
async def get_with_reports(
self,
year: int | None = None,
branch_id: int | None = None,
offset: int | None = None,
limit: int | None = None,
with_count: bool = False,
org_unit_ids: list[int] | None = None,
) -> list[dict] | tuple[int, list[dict]]:
project_query = select(Project).order_by(Project.id)
project_query = self._apply_project_filters(project_query, year, branch_id, org_unit_ids)
if offset is not None:
project_query = project_query.offset(offset)
if limit is not None:
project_query = project_query.limit(limit)
projects = (await self.db.execute(project_query)).scalars().all()
years_by_project: dict[int, list[tuple[RfProjectYear, Smeta | None]]] = {}
project_ids = [p.id for p in projects]
if project_ids:
years_query = (
select(RfProjectYear, Smeta)
.outerjoin(Smeta, Smeta.rf_project_year_id == RfProjectYear.id)
.where(RfProjectYear.project_id.in_(project_ids))
.order_by(RfProjectYear.year)
)
for rpy, smeta in (await self.db.execute(years_query)).all():
years_by_project.setdefault(rpy.project_id, []).append((rpy, smeta))
payload = [
self._serialize_project_with_reports(p, years_by_project.get(p.id, []))
for p in projects
]
if not with_count:
return payload
count_query = select(func.count(Project.id))
count_query = self._apply_project_filters(count_query, year, branch_id, org_unit_ids)
total = int((await self.db.execute(count_query)).scalar() or 0)
return total, payload
async def get(
self,
project_id: int,
@ -112,7 +202,9 @@ class ProjectRepository:
.scalar_subquery()
)
report_years_subq = (
select(RfProjectReport.year)
select(RfProjectYear.year)
.select_from(RfProjectReport)
.join(RfProjectYear, RfProjectYear.id == RfProjectReport.rf_project_year_id)
.where(RfProjectReport.project_id == Project.id)
.distinct()
.scalar_subquery()
@ -162,7 +254,7 @@ class ProjectRepository:
query = (
select(
RfProjectReport.id,
RfProjectReport.year,
RfProjectYear.year,
RfProjectReport.report_type,
RfProjectReport.created_by,
RfProjectReport.created_at,
@ -170,8 +262,9 @@ class ProjectRepository:
RfProjectReport.updated_at,
line_count_subq.label("line_count"),
)
.join(RfProjectYear, RfProjectYear.id == RfProjectReport.rf_project_year_id)
.where(RfProjectReport.project_id == project_id)
.order_by(RfProjectReport.year, RfProjectReport.report_type)
.order_by(RfProjectYear.year, RfProjectReport.report_type)
)
rows = (await self.db.execute(query)).all()
reports: list[dict] = []
@ -200,9 +293,10 @@ class ProjectRepository:
query = (
select(RfProjectReport.id)
.join(Project, Project.id == RfProjectReport.project_id)
.join(RfProjectYear, RfProjectYear.id == RfProjectReport.rf_project_year_id)
.where(
Project.id == project_id,
RfProjectReport.year == year,
RfProjectYear.year == year,
RfProjectReport.report_type == report_type,
)
)
@ -326,23 +420,120 @@ class ProjectRepository:
result = (await self.db.execute(query, {"project_id": project_id})).scalar_one()
return result
async def upd_project(self, project_id: int, column: str, value):
query = text(
"""
SELECT v3.upd_project(CAST(:project_id AS INT), CAST(:column AS TEXT), CAST(:value AS JSONB))
"""
)
result = (
async def upd_project(self, project_id: int, data: dict) -> None:
await self.db.execute(
query,
text(
"""
SELECT v3.upd_project(
CAST(:project_id AS INT),
CAST(:name AS VARCHAR),
CAST(:status AS VARCHAR),
CAST(:technical_number AS VARCHAR),
CAST(:project_type AS VARCHAR),
CAST(:vsp_format AS VARCHAR),
CAST(:placement_type AS VARCHAR),
CAST(:object_address AS VARCHAR),
CAST(:staff_count AS INT),
CAST(:total_area AS NUMERIC),
CAST(:org_unit_id AS INT),
CAST(:krf_decision_date AS DATE),
CAST(:fk_decision_date AS DATE),
CAST(:board_decision_date AS DATE),
CAST(:open_relocate_close_date AS DATE),
CAST(:funding_by_ko_decision AS VARCHAR)
)
"""
),
{
"project_id": project_id,
"column": column,
"value": json.dumps(value),
"name": data.get("name"),
"status": data.get("status"),
"technical_number": data.get("technical_number"),
"project_type": data.get("project_type"),
"vsp_format": data.get("vsp_format"),
"placement_type": data.get("placement_type"),
"object_address": data.get("object_address"),
"staff_count": data.get("staff_count"),
"total_area": data.get("total_area"),
"org_unit_id": data.get("org_unit_id"),
"krf_decision_date": data.get("krf_decision_date"),
"fk_decision_date": data.get("fk_decision_date"),
"board_decision_date": data.get("board_decision_date"),
"open_relocate_close_date": data.get("open_relocate_close_date"),
"funding_by_ko_decision": data.get("funding_by_ko_decision"),
},
)
).scalar_one()
return result
await self.db.flush()
async def resolve_rf_project_year_id(
self,
project_id: int,
year: int,
org_unit_ids: list[int] | None = None,
) -> int | None:
query = (
select(RfProjectYear.id)
.join(Project, Project.id == RfProjectYear.project_id)
.where(Project.id == project_id, RfProjectYear.year == year)
)
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 upd_smeta(self, rf_project_year_id: int, data: dict) -> None:
await self.db.execute(
text(
"""
SELECT v3.upd_smeta(
CAST(:rf_project_year_id AS INT),
CAST(:is_in_plan AS BOOLEAN),
CAST(:is_in_plan_q2 AS BOOLEAN),
CAST(:is_in_plan_q3 AS BOOLEAN),
CAST(:is_in_plan_q4 AS BOOLEAN),
CAST(:development_block AS VARCHAR),
CAST(:reserve_to_prrs_ahr AS NUMERIC),
CAST(:reserve_to_prrs_kv AS NUMERIC)
)
"""
),
{
"rf_project_year_id": rf_project_year_id,
"is_in_plan": data.get("is_in_plan"),
"is_in_plan_q2": data.get("is_in_plan_q2"),
"is_in_plan_q3": data.get("is_in_plan_q3"),
"is_in_plan_q4": data.get("is_in_plan_q4"),
"development_block": data.get("development_block"),
"reserve_to_prrs_ahr": data.get("reserve_to_prrs_ahr"),
"reserve_to_prrs_kv": data.get("reserve_to_prrs_kv"),
},
)
await self.db.flush()
@staticmethod
def _serialize_smeta(smeta: Smeta | None) -> dict | None:
if smeta is None:
return None
return {
"id": smeta.id,
"rf_project_year_id": smeta.rf_project_year_id,
"is_in_plan": smeta.is_in_plan,
"is_in_plan_q2": smeta.is_in_plan_q2,
"is_in_plan_q3": smeta.is_in_plan_q3,
"is_in_plan_q4": smeta.is_in_plan_q4,
"development_block": smeta.development_block,
"reserve_to_prrs_ahr": float(smeta.reserve_to_prrs_ahr) if smeta.reserve_to_prrs_ahr is not None else None,
"reserve_to_prrs_kv": float(smeta.reserve_to_prrs_kv) if smeta.reserve_to_prrs_kv is not None else None,
}
async def get_smeta(self, rf_project_year_id: int) -> dict | None:
smeta = (
await self.db.execute(
select(Smeta).where(Smeta.rf_project_year_id == rf_project_year_id)
)
).scalar_one_or_none()
return self._serialize_smeta(smeta)
async def add_project(
self,

View File

@ -3,6 +3,7 @@ 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_year import RfProjectYear
@ -25,8 +26,10 @@ class RfProjectReportRepository:
if report_id is not None:
query = query.where(RfProjectReport.id == report_id)
else:
query = query.where(
RfProjectReport.year == year,
query = query.join(
RfProjectYear, RfProjectYear.id == RfProjectReport.rf_project_year_id
).where(
RfProjectYear.year == year,
RfProjectReport.report_type == report_type,
RfProjectReport.project_id == project_id
)
@ -42,14 +45,19 @@ class RfProjectReportRepository:
year: int | None = None,
report_type: str | None = None,
project_id: int | None = None,
load_year: bool = False
) -> list[RfProjectReport]:
query = select(
RfProjectReport
)
if report_id is not None:
query = query.where(RfProjectReport.id == report_id)
if load_year or year:
query = query.options(
joinedload(RfProjectReport.rf_project_year)
)
if year is not None:
query = query.where(RfProjectReport.year == year)
query = query.where(RfProjectYear.year == year)
if report_type is not None:
query = query.where(RfProjectReport.report_type == report_type)
if project_id is not None:

View File

@ -4,6 +4,7 @@ from src.db.models.project import Project
from src.db.models.app_user import AppUser
from src.db.models.role import UserRoleEnum
from src.core.errors import AccessDeniedException, ValidationException
from src.domain.schemas import placement_type_to_db
from src.repository.project_repository import ProjectRepository
from src.repository.user_repository import UserRepository
@ -34,6 +35,25 @@ class ProjectService:
org_unit_ids=org_unit_ids,
)
async def get_list_with_reports(
self,
user: AppUser,
year: int | None = None,
branch_id: int | None = None,
offset: int | None = None,
limit: int | None = None,
with_count: bool = False,
) -> list[dict] | tuple[int, list[dict]]:
org_unit_ids = await self._allowed_org_unit_ids(user)
return await self.project_repo.get_with_reports(
year=year,
branch_id=branch_id,
offset=offset,
limit=limit,
with_count=with_count,
org_unit_ids=org_unit_ids,
)
async def get(self, project_id: int, user: AppUser) -> dict | None:
org_unit_ids = await self._allowed_org_unit_ids(user)
project = await self.project_repo.get(project_id=project_id, org_unit_ids=org_unit_ids)
@ -156,14 +176,34 @@ class ProjectService:
async def upd_project(
self,
project_id: int,
column: str,
value,
data: dict,
user: AppUser,
):
project = await self.get(project_id, user)
if not project:
raise ValidationException("Проект не найден")
return await self.project_repo.upd_project(project_id, column, value)
if "placement_type" in data:
data["placement_type"] = placement_type_to_db(data["placement_type"])
await self.project_repo.upd_project(project_id, data)
return await self.get(project_id, user)
async def upd_smeta(
self,
project_id: int,
year: int,
data: dict,
user: AppUser,
):
org_unit_ids = await self._allowed_org_unit_ids(user)
rf_project_year_id = await self.project_repo.resolve_rf_project_year_id(
project_id=project_id,
year=year,
org_unit_ids=org_unit_ids,
)
if rf_project_year_id is None:
raise ValidationException("Смета не найдена")
await self.project_repo.upd_smeta(rf_project_year_id, data)
return await self.project_repo.get_smeta(rf_project_year_id)
async def add_project(
self,
@ -187,7 +227,7 @@ class ProjectService:
parent_id=parent_id,
project_type=project_type,
vsp_format=vsp_format,
placement_type=placement_type,
placement_type=placement_type_to_db(placement_type),
object_address=object_address,
staff_count=staff_count,
total_area=total_area,

View File

@ -36,10 +36,12 @@ class RfProjectReportService:
year: int | None = None,
report_type: str | None = None,
project_id: int | None = None,
load_year: bool = False,
) -> list[RfProjectReport]:
return await self.pr_repo.get_list(
report_id=report_id,
year=year,
report_type=report_type,
project_id=project_id,
load_year=load_year,
)

View File

@ -184,6 +184,14 @@ def test_form3_phases_list_filter_smoke(client, admin_tokens):
assert "count" in payload
assert payload["count"] <= count
response = client.get(
"/api/v1/stages/project/2?&year=2026", 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(

View File

@ -79,7 +79,7 @@ def test_projects_write_smoke(client, admin_tokens, auth_headers):
upd_project_response = client.patch(
f"/api/v1/project/{project_id}",
json={"column": "name", "value": f"{name}_U"},
json={"name": f"{name}_U"},
headers=auth_headers(admin_tokens),
)
assert upd_project_response.status_code == 200
@ -248,3 +248,92 @@ def test_delete_seed_project_smoke(client, admin_tokens, auth_headers):
payload = response.json()
assert payload["success"] is True
assert payload["message"] == "Проект удалён"
def _get_project_with_reports(client, auth_headers, admin_tokens, project_id):
response = client.get(
"/api/v1/projects/with-reports?limit=1000",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
return next((p for p in response.json()["result"] if p["id"] == project_id), None)
def test_upd_project_smoke(client, admin_tokens, auth_headers):
project_id, name = _create_project(client, admin_tokens, auth_headers)
response = client.patch(
f"/api/v1/project/{project_id}",
json={
"name": f"{name}_U",
"status": "agreed",
"technical_number": "2606_180801",
"krf_decision_date": "2026-06-11",
"fk_decision_date": "2026-06-18",
"board_decision_date": "2026-07-02",
"open_relocate_close_date": "2026-07-15",
"funding_by_ko_decision": "prrs_budget",
"placement_type": "Собственность",
},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
project = _get_project_with_reports(client, auth_headers, admin_tokens, project_id)
assert project is not None
assert project["name"] == f"{name}_U"
assert project["status"] == "agreed"
assert project["technical_number"] == "2606_180801"
assert project["krf_decision_date"] == "2026-06-11"
assert project["fk_decision_date"] == "2026-06-18"
assert project["board_decision_date"] == "2026-07-02"
assert project["open_relocate_close_date"] == "2026-07-15"
assert project["funding_by_ko_decision"] == "prrs_budget"
# placement_type сконвертирован в БД-код и обратно в строковое представление
assert project["placement_type"] == "Собственность"
def test_upd_smeta_smoke(client, admin_tokens, auth_headers):
project_id, _ = _create_project(client, admin_tokens, auth_headers, year=2026)
response = client.patch(
f"/api/v1/projects/{project_id}/smeta/2026",
json={
"is_in_plan": True,
"is_in_plan_q2": True,
"is_in_plan_q3": False,
"is_in_plan_q4": False,
"development_block": "out_of_base_plan",
"reserve_to_prrs_ahr": 1500.5,
"reserve_to_prrs_kv": 2300,
},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200
payload = response.json()
assert payload["is_in_plan"] is True
assert payload["is_in_plan_q2"] is True
assert payload["is_in_plan_q3"] is False
assert payload["is_in_plan_q4"] is False
assert payload["development_block"] == "out_of_base_plan"
assert payload["reserve_to_prrs_ahr"] == 1500.5
assert payload["reserve_to_prrs_kv"] == 2300
project = _get_project_with_reports(client, auth_headers, admin_tokens, project_id)
assert project is not None
sub_row = next((sr for sr in project["sub_rows"] if sr["year"] == 2026), None)
assert sub_row is not None
assert sub_row["is_in_plan"] is True
assert sub_row["development_block"] == "out_of_base_plan"
def test_upd_smeta_not_found(client, admin_tokens, auth_headers):
project_id, _ = _create_project(client, admin_tokens, auth_headers, year=2026)
response = client.patch(
f"/api/v1/projects/{project_id}/smeta/2099",
json={"is_in_plan": True},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 400
payload = response.json()
assert payload.get("message") == "Смета не найдена"

View File

@ -34,13 +34,21 @@ INSERT INTO v3.vsp (id,branch_id,reg_number,address,format,opened_at,placement_t
(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',1,'3434',NULL,'2','примечание','567','2027-07-01');
SELECT setval('v3.vsp_id_seq', 3);
INSERT INTO v3.project ("id","name","level",parent_id,project_type,vsp_format,placement_type,object_address,staff_count,total_area,org_unit_id) VALUES
(2,'PT6d112310_U','project',NULL,NULL,NULL,NULL,NULL,NULL,NULL,1);
INSERT INTO v3.project ("id","name","level",parent_id,project_type,vsp_format,placement_type,object_address,staff_count,total_area,org_unit_id,status,technical_number,krf_decision_date,fk_decision_date,board_decision_date,open_relocate_close_date,funding_by_ko_decision) VALUES
(2,'PT6d112310_U','project',NULL,NULL,NULL,NULL,NULL,NULL,NULL,1,'created','2606_180801','2026-06-11','2026-06-18','2026-07-02','2026-07-15','prrs_budget');
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');
INSERT INTO v3.rf_project_year ("id",project_id,"year") VALUES
(1,2,2026);
SELECT setval('v3.rf_project_year_id_seq', 1);
INSERT INTO v3.smeta ("id",rf_project_year_id,is_in_plan,is_in_plan_q2,is_in_plan_q3,is_in_plan_q4,development_block,reserve_to_prrs_ahr,reserve_to_prrs_kv) VALUES
(1,1,true,false,false,false,'out_of_base_plan',1500.00,2300.00);
SELECT setval('v3.smeta_id_seq', 1);
INSERT INTO v3.rf_project_report (id,project_id,rf_project_year_id,report_type,created_by,created_at,updated_by,updated_at) VALUES
(1,2,1,'LIMIT',NULL,'2026-05-12 12:43:56.20138',NULL,'2026-05-12 12:43:56.20138'),
(2,2,1,'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