Compare commits

...

9 Commits

Author SHA1 Message Date
PotapovaA
056d00b2b4 Merge branch 'test' into ui-tasks 2026-09-28 17:38:04 +03:00
e2481d8094 Merge pull request 'svod-fix: свод 2 из формы 2, новый свод 1 для формы 3' (#180) from svod-fix into test
Reviewed-on: #180
2026-09-28 17:07:41 +03:00
18faf6a724 svod-fix: свод 2 из формы 2, новый свод 1 для формы 3 2026-09-28 17:01:07 +03:00
cd2882dd61 Merge pull request 'fix-columns: добавил комментарий, строки из Лимита не удаляются' (#179) from fix-columns into test
Reviewed-on: #179
2026-09-28 13:31:25 +03:00
1e02b0ca61 fix-columns: добавил комментарий, строки из Лимита не удаляются 2026-09-28 13:19:26 +03:00
9500071fa4 Merge pull request 'form3-expense: добавил новые статьи в форму 3, возможность удаления задач' (#178) from form3-expense into test
Reviewed-on: #178
2026-09-25 14:22:25 +03:00
d6da54ecf4 form3-expense: добавил новые статьи в форму 3, возможность удаления задач 2026-09-25 14:20:04 +03:00
94c18b4ca7 Merge pull request 'sattelite-fix: добавил формулу для принтеров и мфу' (#177) from sattelite-fix into test
Reviewed-on: #177
2026-09-25 10:30:24 +03:00
909c16bf9b sattelite-fix: добавил формулу для принтеров и мфу 2026-09-25 10:26:33 +03:00
26 changed files with 1260 additions and 45 deletions

View File

@ -0,0 +1,15 @@
from sql_migration import run_sql_file_migration
revision = "0037"
down_revision = "0036"
branch_labels = None
depends_on = None
def upgrade() -> None:
run_sql_file_migration("0037_form2_ahr_limit_depth_fix.sql")
def downgrade() -> None:
pass

View File

@ -0,0 +1,15 @@
from sql_migration import run_sql_file_migration
revision = "0038"
down_revision = "0037"
branch_labels = None
depends_on = None
def upgrade() -> None:
run_sql_file_migration("0038_form3_expense_item_links.sql")
def downgrade() -> None:
pass

View File

@ -0,0 +1,15 @@
from sql_migration import run_sql_file_migration
revision = "0039"
down_revision = "0038"
branch_labels = None
depends_on = None
def upgrade() -> None:
run_sql_file_migration("0039_budget_form_delete_workflow.sql")
def downgrade() -> None:
pass

View File

@ -0,0 +1,15 @@
from sql_migration import run_sql_file_migration
revision = "0040"
down_revision = "0039"
branch_labels = None
depends_on = None
def upgrade() -> None:
run_sql_file_migration("0040_ahr_limit_delete_guard.sql")
def downgrade() -> None:
pass

View File

@ -0,0 +1,15 @@
from sql_migration import run_sql_file_migration
revision = "0041"
down_revision = "0040"
branch_labels = None
depends_on = None
def upgrade() -> None:
run_sql_file_migration("0041_svod2_rf_form2_only.sql")
def downgrade() -> None:
pass

View File

@ -0,0 +1,113 @@
-- After 0033 some AHR expense items with num_group_id became depth=4.
-- AHR_LIMIT synchronization still required depth=3, so limit rows such as
-- "принтеры", "МФУ", and "копировальные аппараты" stopped matching
-- expense_item.num_group_id = '204620030'.
--
-- The same three limit rows also use a special quarterly amount formula:
-- qty * 0.57 * 10900 / 4. Other limit rows keep the previous formula.
CREATE OR REPLACE FUNCTION v3._form2_ahr_limit_amount(
p_name character varying,
p_unit character varying,
p_limit_with_vat numeric,
p_qty numeric
)
RETURNS numeric
LANGUAGE sql
IMMUTABLE
AS $function$
SELECT COALESCE(p_qty, 0) *
CASE
WHEN regexp_replace(
COALESCE(p_name, ''),
'^[[:space:]]*-[[:space:]]*',
''
) IN ('принтеры', 'МФУ', 'копировальные аппараты')
THEN 0.57 * 10900 / 4
ELSE COALESCE(p_limit_with_vat, 0) *
CASE
WHEN p_unit LIKE '%/год' THEN 0.25
WHEN p_unit LIKE '%/месяц' THEN 3
ELSE 1
END
END;
$function$;
DO $do$
DECLARE
v_signature regprocedure;
v_sql text;
v_next_sql text;
BEGIN
FOREACH v_signature IN ARRAY ARRAY[
'v3.v_form2_view(integer,character varying,text[])'::regprocedure,
'v3.sync_form2_ahr_satellite_lines(integer)'::regprocedure,
'v3.sync_form2_ahr_satellite_line(integer,character varying,integer)'::regprocedure
]
LOOP
v_sql := pg_get_functiondef(v_signature);
v_next_sql := regexp_replace(
v_sql,
$re$COALESCE\(fl\.qty_q([1-4]),[[:space:]]*0\)[[:space:]]*\*[[:space:]]*COALESCE\(lt\.limit_with_vat,[[:space:]]*0\)[[:space:]]*\*[[:space:]]*\(?[[:space:]]*CASE[[:space:]]+WHEN[[:space:]]+lt\.unit[[:space:]]+LIKE[[:space:]]+'%/год'[[:space:]]+THEN[[:space:]]+0\.25[[:space:]]+WHEN[[:space:]]+lt\.unit[[:space:]]+LIKE[[:space:]]+'%/месяц'[[:space:]]+THEN[[:space:]]+3[[:space:]]+ELSE[[:space:]]+1[[:space:]]+END[[:space:]]*\)?$re$,
$replace$v3._form2_ahr_limit_amount(lt.name, lt.unit, lt.limit_with_vat, fl.qty_q\1)$replace$,
'g'
);
IF v_next_sql = v_sql THEN
RAISE EXCEPTION 'Expected AHR limit amount expression was not found in %', v_signature;
END IF;
EXECUTE v_next_sql;
END LOOP;
END;
$do$;
DO $do$
DECLARE
v_signature regprocedure;
v_sql text;
v_next_sql text;
BEGIN
FOREACH v_signature IN ARRAY ARRAY[
'v3.sync_form2_ahr_satellite_lines(integer)'::regprocedure,
'v3.sync_form2_ahr_satellite_line(integer,character varying,integer)'::regprocedure,
'v3.get_form2_ahr_satellite_delta(integer,character varying,integer)'::regprocedure,
'v3.is_form2_ahr_satellite_line(integer,integer)'::regprocedure
]
LOOP
v_sql := pg_get_functiondef(v_signature);
v_next_sql := regexp_replace(
v_sql,
'AND\s+ei\.depth\s*=\s*3\s+AND\s*\(\s*ei\.item_id\s*=\s*lt\.expense_item_code\s+OR\s+ei\.num_group_id\s*=\s*lt\.expense_item_code\s*\)',
E'AND (\n (ei.depth = 3 AND ei.item_id = lt.expense_item_code)\n OR ei.num_group_id = lt.expense_item_code\n )',
'g'
);
IF v_next_sql = v_sql THEN
IF v_sql ~ 'ei\.depth[[:space:]]*=[[:space:]]*3[[:space:]]+AND[[:space:]]+ei\.item_id[[:space:]]*=[[:space:]]*lt\.expense_item_code[[:space:]]*\)[[:space:]]+OR[[:space:]]+ei\.num_group_id[[:space:]]*=[[:space:]]*lt\.expense_item_code' THEN
CONTINUE;
END IF;
RAISE EXCEPTION 'Expected depth=3 limit-template matcher was not found in %', v_signature;
END IF;
EXECUTE v_next_sql;
END LOOP;
END;
$do$;
DO $do$
DECLARE
v_form_id integer;
BEGIN
FOR v_form_id IN
SELECT id
FROM v3.budget_form
WHERE form_type_code = 'FORM_2'
LOOP
PERFORM v3.sync_form2_ahr_satellite_lines(v_form_id);
END LOOP;
END;
$do$;

View File

@ -0,0 +1,22 @@
insert into v3.expense_item_form_type (expense_item_id, form_type_code)
values
(84, 'FORM_3'),
(85, 'FORM_3'),
(86, 'FORM_3'),
(87, 'FORM_3'),
(88, 'FORM_3'),
(89, 'FORM_3'),
(90, 'FORM_3'),
(91, 'FORM_3'),
(92, 'FORM_3'),
(93, 'FORM_3'),
(94, 'FORM_3'),
(95, 'FORM_3'),
(97, 'FORM_3'),
(98, 'FORM_3'),
(99, 'FORM_3'),
(100, 'FORM_3'),
(101, 'FORM_3'),
(83, 'FORM_3'),
(96, 'FORM_3')
on conflict (expense_item_id, form_type_code) do nothing;

View File

@ -0,0 +1,75 @@
ALTER TABLE v3.budget_form
ADD COLUMN IF NOT EXISTS to_delete boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS deleted_by integer NULL REFERENCES v3.app_user(id),
ADD COLUMN IF NOT EXISTS deleted_at timestamp with time zone NULL;
CREATE INDEX IF NOT EXISTS ix_v3_budget_form_to_delete
ON v3.budget_form (to_delete);
CREATE OR REPLACE FUNCTION v3.hard_delete_budget_form(p_form_id integer)
RETURNS void
LANGUAGE plpgsql
AS $function$
BEGIN
IF NOT EXISTS (SELECT 1 FROM v3.budget_form WHERE id = p_form_id) THEN
RAISE EXCEPTION 'budget_form id=% не существует', p_form_id;
END IF;
DELETE FROM v3.booking
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.sequestration
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.budget_line_quarter
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.rent_detail
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.utility_detail
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.security_detail
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.contract_detail
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.contract_summary
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.plan
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.reserve
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.allocation
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.ckk
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.collegial_approval
WHERE line_id IN (SELECT id FROM v3.budget_line WHERE budget_form_id = p_form_id);
DELETE FROM v3.fixed_asset_report
WHERE budget_form_id = p_form_id;
DELETE FROM v3.form_limit
WHERE budget_form_id = p_form_id;
DELETE FROM v3.form_phase
WHERE budget_form_id = p_form_id;
DELETE FROM v3.budget_line
WHERE budget_form_id = p_form_id;
DELETE FROM v3.form4_project
WHERE form_id = p_form_id;
DELETE FROM v3.budget_form
WHERE id = p_form_id;
END;
$function$;

View File

@ -0,0 +1,145 @@
CREATE OR REPLACE FUNCTION v3.del_budget_line(p_id integer, p_direction character varying DEFAULT NULL::character varying, p_sheet character varying DEFAULT NULL::character varying)
RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb)
LANGUAGE plpgsql
AS $function$
DECLARE
v_form_id INT;
v_org_id INT;
v_sheet VARCHAR;
v_sat_table TEXT;
v_eid INT;
v_line_id INT;
v_ei_path INT[];
BEGIN
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;
EXECUTE format(
'SELECT bl.budget_form_id FROM v3.%I s
JOIN v3.budget_line bl ON bl.id = s.line_id
WHERE s.id = $1', v_sat_table
) USING p_id INTO v_form_id;
IF v_form_id IS NULL THEN
RAISE EXCEPTION '%.id=% не существует', v_sat_table, p_id;
END IF;
SELECT m.target_line_id
INTO v_line_id
FROM v3.form2_ahr_satellite_line_source m
WHERE m.source_sheet = p_sheet
AND m.source_id = p_id;
EXECUTE format('DELETE FROM v3.%I WHERE id = $1', v_sat_table) USING p_id;
IF v_line_id IS NOT NULL THEN
SELECT bl.expense_item_id
INTO v_eid
FROM v3.budget_line bl
WHERE bl.id = v_line_id;
DELETE FROM v3.plan WHERE line_id = v_line_id;
DELETE FROM v3.budget_line_quarter WHERE line_id = v_line_id;
DELETE FROM v3.budget_line WHERE id = v_line_id;
SELECT org_unit_id INTO v_org_id FROM v3.budget_form WHERE id = v_form_id;
PERFORM v3.log_event(
'ROW_DELETE', 'ROW',
jsonb_build_object(
'sheet', 'AHR',
'line_id', v_line_id,
'entity_id', v_line_id,
'expense_item_id', v_eid,
'direction', 'Support',
'source_sheet', p_sheet,
'source_id', p_id
),
v_form_id, NULL, v_org_id
);
END IF;
RETURN;
END IF;
SELECT bl.budget_form_id, ei.sheet, bl.expense_item_id
INTO v_form_id, v_sheet, v_eid
FROM v3.budget_line bl
JOIN v3.expense_item ei ON ei.id = bl.expense_item_id
WHERE bl.id = p_id;
IF v_form_id IS NULL THEN
RAISE EXCEPTION 'budget_line id=% не существует', p_id;
END IF;
IF v_sheet = 'AHR' AND (
EXISTS (
SELECT 1
FROM v3.form2_ahr_satellite_line_source m
WHERE m.target_line_id = p_id
)
OR EXISTS (
SELECT 1
FROM v3.budget_line bl
JOIN v3.expense_item ei ON ei.id = bl.expense_item_id
JOIN v3.limit_template lt
ON lt.row_type = 'LEAF'
AND (
(ei.depth = 3 AND ei.item_id = lt.expense_item_code)
OR ei.num_group_id = lt.expense_item_code
)
WHERE bl.id = p_id
AND bl.budget_form_id = v_form_id
AND ei.sheet = 'AHR'
AND ei.direction = 'Support'
)
) THEN
RAISE EXCEPTION 'Строку АХР, созданную из листа-сателлита или АХР Лимиты, нельзя удалить напрямую'
USING ERRCODE = '23503',
HINT = 'Удалите строку на исходном листе АХР Аренда / АХР Коммунальные услуги / АХР Безопасность либо измените строку на листе АХР Лимиты';
END IF;
v_ei_path := v3._expense_item_path_set(ARRAY[v_eid]);
DELETE FROM v3.plan WHERE line_id = p_id;
DELETE FROM v3.sequestration WHERE line_id = p_id;
DELETE FROM v3.reserve WHERE line_id = p_id;
DELETE FROM v3.allocation WHERE line_id = p_id;
DELETE FROM v3.contract_summary WHERE line_id = p_id;
DELETE FROM v3.collegial_approval WHERE line_id = p_id;
DELETE FROM v3.ckk WHERE line_id = p_id;
DELETE FROM v3.contract_detail WHERE line_id = p_id;
DELETE FROM v3.booking WHERE line_id = p_id;
DELETE FROM v3.budget_line_quarter WHERE line_id = p_id;
DELETE FROM v3.rent_detail WHERE line_id = p_id;
DELETE FROM v3.utility_detail WHERE line_id = p_id;
DELETE FROM v3.security_detail WHERE line_id = p_id;
DELETE FROM v3.budget_line WHERE id = p_id;
SELECT org_unit_id INTO v_org_id FROM v3.budget_form WHERE id = v_form_id;
PERFORM v3.log_event(
'ROW_DELETE', 'ROW',
jsonb_build_object(
'sheet', v_sheet,
'line_id', p_id,
'entity_id', p_id,
'expense_item_id', v_eid,
'direction', p_direction
),
v_form_id, NULL, v_org_id
);
RETURN QUERY
SELECT v.row_type, v.depth, v.sort_order, v.data
FROM v3.v_form_view(v_form_id, v_sheet, NULL, p_direction) v
WHERE v.row_type IN ('ROOT','GROUP','ITEM','SUB_ITEM')
AND (v.data->'header'->>'expense_item_id')::INT = ANY(v_ei_path)
ORDER BY v.sort_order;
END;
$function$;

View File

@ -0,0 +1,299 @@
CREATE OR REPLACE FUNCTION v3.v_svod2_rf(
p_year integer,
p_org_unit_ids integer[] DEFAULT NULL,
p_offset integer DEFAULT NULL,
p_limit integer DEFAULT NULL,
p_sort_by varchar DEFAULT 'org_name',
p_sort_direction varchar DEFAULT 'asc'
)
RETURNS TABLE(
source_form varchar, source_id integer, source_org_unit_id integer,
org_name varchar, smeta_type varchar,
source_line_id integer, sort_order bigint, data jsonb
)
LANGUAGE sql STABLE SET search_path TO 'v3', 'pg_catalog'
AS $function$
WITH empty_sections AS MATERIALIZED (
SELECT
(to_jsonb(jsonb_populate_record(NULL::v3.plan, '{}'::jsonb)) - 'line_id')
|| jsonb_build_object('plan_year', NULL) AS plan,
(to_jsonb(jsonb_populate_record(NULL::v3.sequestration, '{}'::jsonb))
- 'id' - 'line_id' - 'actor') || jsonb_build_object('adj_year', NULL) AS sequestration,
(to_jsonb(jsonb_populate_record(NULL::v3.reserve, '{}'::jsonb)) - 'line_id')
|| jsonb_build_object('amount_year', NULL) AS reserve,
jsonb_build_object(
'approved_q1', NULL, 'approved_q2', NULL,
'approved_q3', NULL, 'approved_q4', NULL,
'approved_year', NULL
) AS approved,
to_jsonb(jsonb_populate_record(NULL::v3.contract_summary, '{}'::jsonb)) - 'line_id' AS contract_summary,
to_jsonb(jsonb_populate_record(NULL::v3.allocation, '{}'::jsonb)) - 'line_id' AS allocation,
to_jsonb(jsonb_populate_record(NULL::v3.collegial_approval, '{}'::jsonb)) - 'line_id' AS collegial,
to_jsonb(jsonb_populate_record(NULL::v3.ckk, '{}'::jsonb)) - 'line_id' AS ckk,
to_jsonb(jsonb_populate_record(NULL::v3.contract_detail, '{}'::jsonb)) - 'line_id' AS contract_detail,
to_jsonb(jsonb_populate_record(NULL::v3.booking, '{}'::jsonb)) - 'id' - 'line_id' AS booking,
to_jsonb(jsonb_populate_record(NULL::v3.budget_line_quarter, '{}'::jsonb))
- 'line_id' - 'quarter' AS budget_quarter
), eligible_lines AS (
SELECT
'FORM_2'::varchar AS form_type_code,
bl.id AS line_id,
bf.id AS source_id,
bf.org_unit_id,
ou.title AS org_name,
ei.section_code,
ei.item_id,
CASE ei.sheet WHEN 'AHR' THEN 'АХР' WHEN 'CAP' THEN 'КВ'
WHEN 'OPER' THEN 'Операц' END::varchar AS smeta_type
FROM v3.budget_form bf
JOIN v3.budget_line bl ON bl.budget_form_id = bf.id
JOIN v3.expense_item ei ON ei.id = bl.expense_item_id
JOIN v3.contract_detail cd ON cd.line_id = bl.id
LEFT JOIN v3.org_unit ou ON ou.id = bf.org_unit_id
WHERE bf.year = p_year AND bf.form_type_code = 'FORM_2'
AND ei.sheet IN ('AHR', 'CAP', 'OPER')
AND (p_org_unit_ids IS NULL OR cardinality(p_org_unit_ids) = 0
OR bf.org_unit_id = ANY(p_org_unit_ids))
), numbered_lines AS (
SELECT el.*,
row_number() OVER (
ORDER BY
CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'asc'
THEN el.org_name END ASC NULLS LAST,
CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'desc'
THEN el.org_name END DESC NULLS LAST,
CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'asc'
THEN el.item_id END ASC NULLS LAST,
CASE WHEN p_sort_by = 'org_name' AND lower(p_sort_direction) = 'desc'
THEN el.item_id END DESC NULLS LAST,
CASE WHEN p_sort_by = 'section_code' AND lower(p_sort_direction) = 'asc'
THEN el.section_code END ASC NULLS LAST,
CASE WHEN p_sort_by = 'section_code' AND lower(p_sort_direction) = 'desc'
THEN el.section_code END DESC NULLS LAST,
el.form_type_code, el.source_id, el.line_id
) AS sort_order
FROM eligible_lines el
), page AS MATERIALIZED (
SELECT * FROM numbered_lines
ORDER BY sort_order
LIMIT p_limit OFFSET COALESCE(p_offset, 0)
), booking_data AS (
SELECT b.line_id,
jsonb_agg(to_jsonb(b) - 'id' - 'line_id' ORDER BY b.quarter, b.source) AS booking
FROM page pg
JOIN v3.booking b ON b.line_id = pg.line_id
GROUP BY b.line_id
)
SELECT
pg.form_type_code AS source_form, pg.source_id,
pg.org_unit_id AS source_org_unit_id, pg.org_name, pg.smeta_type,
pg.line_id AS source_line_id, pg.sort_order,
jsonb_build_object(
'line_id', bl.id,
'header', jsonb_build_object(
'section_code', ei.section_code, 'item_id', ei.item_id,
'num_group', ei.num_group_id, 'name', COALESCE(bl.name, ei.name),
'expense_item_id', ei.id, 'justification', bl.justification,
'internal_order', bl.internal_order, 'vsp_id', bl.vsp_id,
'project_id', bl.project_id, 'direction', bl.direction,
'smeta_direction', CASE bl.direction
WHEN 'Support' THEN 'Поддержка'
WHEN 'Development' THEN 'Развитие'
ELSE NULL
END,
'source_form', pg.form_type_code, 'source_id', pg.source_id,
'source_org_unit_id', pg.org_unit_id, 'org_name', pg.org_name,
'smeta_type', pg.smeta_type),
'plan', COALESCE(
(to_jsonb(pl) - 'line_id') || jsonb_build_object(
'plan_year', COALESCE(pl.plan_q1, 0) + COALESCE(pl.plan_q2, 0)
+ COALESCE(pl.plan_q3, 0) + COALESCE(pl.plan_q4, 0)),
e.plan || jsonb_build_object('plan_year', NULL)),
'sequestration', jsonb_build_object(
'DFIP', COALESCE(
(to_jsonb(sd) - 'id' - 'line_id' - 'actor') || jsonb_build_object(
'adj_year', COALESCE(sd.adj_q1, 0) + COALESCE(sd.adj_q2, 0)
+ COALESCE(sd.adj_q3, 0) + COALESCE(sd.adj_q4, 0)),
e.sequestration || jsonb_build_object('adj_year', NULL)),
'SSP_GO', COALESCE(
(to_jsonb(ss) - 'id' - 'line_id' - 'actor') || jsonb_build_object(
'adj_year', COALESCE(ss.adj_q1, 0) + COALESCE(ss.adj_q2, 0)
+ COALESCE(ss.adj_q3, 0) + COALESCE(ss.adj_q4, 0)),
e.sequestration || jsonb_build_object('adj_year', NULL))),
'reserve', COALESCE(
(to_jsonb(rv) - 'line_id') || jsonb_build_object(
'amount_year', COALESCE(rv.amount_q1, 0) + COALESCE(rv.amount_q2, 0)
+ COALESCE(rv.amount_q3, 0) + COALESCE(rv.amount_q4, 0)),
e.reserve || jsonb_build_object('amount_year', NULL)),
'approved', CASE WHEN pl.line_id IS NULL AND sd.id IS NULL
AND ss.id IS NULL AND rv.line_id IS NULL
THEN e.approved
ELSE jsonb_build_object(
'approved_q1', COALESCE(pl.plan_q1, 0) + COALESCE(sd.adj_q1, 0)
+ COALESCE(ss.adj_q1, 0) + COALESCE(rv.amount_q1, 0),
'approved_q2', COALESCE(pl.plan_q2, 0) + COALESCE(sd.adj_q2, 0)
+ COALESCE(ss.adj_q2, 0) + COALESCE(rv.amount_q2, 0),
'approved_q3', COALESCE(pl.plan_q3, 0) + COALESCE(sd.adj_q3, 0)
+ COALESCE(ss.adj_q3, 0) + COALESCE(rv.amount_q3, 0),
'approved_q4', COALESCE(pl.plan_q4, 0) + COALESCE(sd.adj_q4, 0)
+ COALESCE(ss.adj_q4, 0) + COALESCE(rv.amount_q4, 0),
'approved_year', COALESCE(pl.plan_q1, 0) + COALESCE(pl.plan_q2, 0)
+ COALESCE(pl.plan_q3, 0) + COALESCE(pl.plan_q4, 0)
+ COALESCE(sd.adj_q1, 0) + COALESCE(sd.adj_q2, 0)
+ COALESCE(sd.adj_q3, 0) + COALESCE(sd.adj_q4, 0)
+ COALESCE(ss.adj_q1, 0) + COALESCE(ss.adj_q2, 0)
+ COALESCE(ss.adj_q3, 0) + COALESCE(ss.adj_q4, 0)
+ COALESCE(rv.amount_q1, 0) + COALESCE(rv.amount_q2, 0)
+ COALESCE(rv.amount_q3, 0) + COALESCE(rv.amount_q4, 0)) END,
'contract_summary', COALESCE(to_jsonb(cs) - 'line_id', e.contract_summary),
'allocation', COALESCE(to_jsonb(al) - 'line_id', e.allocation),
'collegial', COALESCE(to_jsonb(ca) - 'line_id', e.collegial),
'ckk', COALESCE(to_jsonb(ck) - 'line_id', e.ckk),
'contract_detail', COALESCE(to_jsonb(cd) - 'line_id', e.contract_detail),
'booking', COALESCE(bk.booking, jsonb_build_array(e.booking)),
'q1', COALESCE(to_jsonb(q1) - 'line_id' - 'quarter', e.budget_quarter),
'q2', COALESCE(to_jsonb(q2) - 'line_id' - 'quarter', e.budget_quarter),
'q3', COALESCE(to_jsonb(q3) - 'line_id' - 'quarter', e.budget_quarter),
'q4', COALESCE(to_jsonb(q4) - 'line_id' - 'quarter', e.budget_quarter)
) AS data
FROM page pg
JOIN v3.budget_line bl ON bl.id = pg.line_id
JOIN v3.expense_item ei ON ei.id = bl.expense_item_id
JOIN v3.contract_detail cd ON cd.line_id = bl.id
CROSS JOIN empty_sections e
LEFT JOIN v3.plan pl ON pl.line_id = bl.id
LEFT JOIN v3.sequestration sd ON sd.line_id = bl.id AND sd.actor = 'DFIP'
LEFT JOIN v3.sequestration ss ON ss.line_id = bl.id AND ss.actor = 'SSP_GO'
LEFT JOIN v3.reserve rv ON rv.line_id = bl.id
LEFT JOIN v3.contract_summary cs ON cs.line_id = bl.id
LEFT JOIN v3.allocation al ON al.line_id = bl.id
LEFT JOIN v3.collegial_approval ca ON ca.line_id = bl.id
LEFT JOIN v3.ckk ck ON ck.line_id = bl.id
LEFT JOIN booking_data bk ON bk.line_id = bl.id
LEFT JOIN v3.budget_line_quarter q1 ON q1.line_id = bl.id AND q1.quarter = 1
LEFT JOIN v3.budget_line_quarter q2 ON q2.line_id = bl.id AND q2.quarter = 2
LEFT JOIN v3.budget_line_quarter q3 ON q3.line_id = bl.id AND q3.quarter = 3
LEFT JOIN v3.budget_line_quarter q4 ON q4.line_id = bl.id AND q4.quarter = 4
ORDER BY pg.sort_order;
$function$;
CREATE OR REPLACE FUNCTION v3.v_3form_rf_r(p_year integer, p_org_unit_ids integer[] DEFAULT NULL)
RETURNS TABLE(
section_code character varying,
name character varying,
plan_development_q1 numeric,
plan_development_q2 numeric,
plan_development_q3 numeric,
plan_development_q4 numeric,
plan_development_year numeric,
approved_development_q1 numeric,
approved_development_q2 numeric,
approved_development_q3 numeric,
approved_development_q4 numeric,
approved_development_year numeric,
fact_development_q1 numeric,
fact_development_q2 numeric,
fact_development_q3 numeric,
fact_development_q4 numeric,
fact_development_year numeric,
corrected_development_q2 numeric,
corrected_development_q3 numeric,
corrected_development_q4 numeric
)
LANGUAGE sql
STABLE
SET search_path TO 'v3', 'pg_catalog'
AS $function$
WITH
report_lines AS (
SELECT
l.expense_item_id AS eid,
q.quarter,
q.base_plan,
q.actual_m1,
q.actual_m2,
q.actual_m3,
q.actual_spod,
q.corrected_plan
FROM v3.rf_project_report AS r
JOIN v3.rf_project_year AS rpry ON rpry.id = r.rf_project_year_id
JOIN v3.project AS p ON p.id = r.project_id
JOIN v3.rf_project_report_line AS l ON l.rf_project_report_id = r.id
JOIN v3.rf_project_report_quarter AS q ON q.rf_project_report_line_id = l.id
WHERE rpry.year = p_year
AND (p_org_unit_ids IS NULL OR cardinality(p_org_unit_ids) = 0 OR p.org_unit_id = ANY(p_org_unit_ids))
),
agg AS (
SELECT
eid,
SUM(CASE WHEN quarter = 1 THEN base_plan END) AS bp1,
SUM(CASE WHEN quarter = 2 THEN base_plan END) AS bp2,
SUM(CASE WHEN quarter = 3 THEN base_plan END) AS bp3,
SUM(CASE WHEN quarter = 4 THEN base_plan END) AS bp4,
SUM(CASE WHEN quarter = 1 THEN actual_m1 END) AS m1_1,
SUM(CASE WHEN quarter = 2 THEN actual_m1 END) AS m1_2,
SUM(CASE WHEN quarter = 3 THEN actual_m1 END) AS m1_3,
SUM(CASE WHEN quarter = 4 THEN actual_m1 END) AS m1_4,
SUM(CASE WHEN quarter = 1 THEN actual_m2 END) AS m2_1,
SUM(CASE WHEN quarter = 2 THEN actual_m2 END) AS m2_2,
SUM(CASE WHEN quarter = 3 THEN actual_m2 END) AS m2_3,
SUM(CASE WHEN quarter = 4 THEN actual_m2 END) AS m2_4,
SUM(CASE WHEN quarter = 1 THEN actual_m3 END) AS m3_1,
SUM(CASE WHEN quarter = 2 THEN actual_m3 END) AS m3_2,
SUM(CASE WHEN quarter = 3 THEN actual_m3 END) AS m3_3,
SUM(CASE WHEN quarter = 4 THEN actual_m3 END) AS m3_4,
SUM(CASE WHEN quarter = 4 THEN actual_spod END) AS spod4,
SUM(CASE WHEN quarter = 2 THEN corrected_plan END) AS cp2,
SUM(CASE WHEN quarter = 3 THEN corrected_plan END) AS cp3,
SUM(CASE WHEN quarter = 4 THEN corrected_plan END) AS cp4
FROM report_lines
GROUP BY eid
),
tree_nodes AS (
SELECT t.id, t.item_id, t.name, t.desc_ids
FROM v3.mv_expense_item_tree AS t
JOIN v3.expense_item_form_type AS eft
ON eft.expense_item_id = t.id
AND eft.form_type_code = 'FORM_3'
WHERE t.depth < 3
),
rolled AS (
SELECT
tn.item_id,
tn.name,
SUM(a.bp1) AS bp1, SUM(a.bp2) AS bp2, SUM(a.bp3) AS bp3, SUM(a.bp4) AS bp4,
SUM(a.m1_1) AS m1_1, SUM(a.m1_2) AS m1_2, SUM(a.m1_3) AS m1_3, SUM(a.m1_4) AS m1_4,
SUM(a.m2_1) AS m2_1, SUM(a.m2_2) AS m2_2, SUM(a.m2_3) AS m2_3, SUM(a.m2_4) AS m2_4,
SUM(a.m3_1) AS m3_1, SUM(a.m3_2) AS m3_2, SUM(a.m3_3) AS m3_3, SUM(a.m3_4) AS m3_4,
SUM(a.spod4) AS spod4,
SUM(a.cp2) AS cp2, SUM(a.cp3) AS cp3, SUM(a.cp4) AS cp4
FROM tree_nodes AS tn
LEFT JOIN agg AS a ON a.eid = ANY (tn.desc_ids)
GROUP BY tn.item_id, tn.name
)
SELECT
r.item_id AS section_code,
r.name,
COALESCE(r.bp1, 0) AS plan_development_q1,
COALESCE(r.bp2, 0) AS plan_development_q2,
COALESCE(r.bp3, 0) AS plan_development_q3,
COALESCE(r.bp4, 0) AS plan_development_q4,
COALESCE(r.bp1, 0) + COALESCE(r.bp2, 0) + COALESCE(r.bp3, 0) + COALESCE(r.bp4, 0) AS plan_development_year,
COALESCE(r.bp1, 0) AS approved_development_q1,
COALESCE(r.bp2, 0) AS approved_development_q2,
COALESCE(r.bp3, 0) AS approved_development_q3,
COALESCE(r.bp4, 0) AS approved_development_q4,
COALESCE(r.bp1, 0) + COALESCE(r.bp2, 0) + COALESCE(r.bp3, 0) + COALESCE(r.bp4, 0) AS approved_development_year,
COALESCE(r.m1_1, 0) + COALESCE(r.m2_1, 0) + COALESCE(r.m3_1, 0) AS fact_development_q1,
COALESCE(r.m1_2, 0) + COALESCE(r.m2_2, 0) + COALESCE(r.m3_2, 0) AS fact_development_q2,
COALESCE(r.m1_3, 0) + COALESCE(r.m2_3, 0) + COALESCE(r.m3_3, 0) AS fact_development_q3,
COALESCE(r.m1_4, 0) + COALESCE(r.m2_4, 0) + COALESCE(r.m3_4, 0) + COALESCE(r.spod4, 0) AS fact_development_q4,
COALESCE(r.m1_1, 0) + COALESCE(r.m2_1, 0) + COALESCE(r.m3_1, 0)
+ COALESCE(r.m1_2, 0) + COALESCE(r.m2_2, 0) + COALESCE(r.m3_2, 0)
+ COALESCE(r.m1_3, 0) + COALESCE(r.m2_3, 0) + COALESCE(r.m3_3, 0)
+ COALESCE(r.m1_4, 0) + COALESCE(r.m2_4, 0) + COALESCE(r.m3_4, 0) + COALESCE(r.spod4, 0) AS fact_development_year,
COALESCE(r.cp2, 0) AS corrected_development_q2,
COALESCE(r.cp3, 0) AS corrected_development_q3,
COALESCE(r.cp4, 0) AS corrected_development_q4
FROM rolled AS r;
$function$;

View File

@ -8,8 +8,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src.services.org_unit_service import OrgUnitService
from src.db.models.form_type import FormTypeEnum
from src.db.models.role import UserRoleEnum
from src.services.sheet_service import SheetService
from src.domain.schemas import AddLineSchema, BaseListResponse, BaseSingleResponse, BudgetFormResponse, CellPatch, CellsPatch, DirectionSchemaEnum, FormCreateSchema, SheetFormTypeResponse, SheetResponse
from src.domain.schemas import AddLineSchema, BaseListResponse, BaseSingleResponse, ResponseBase, BudgetFormResponse, CellPatch, CellsPatch, DirectionSchemaEnum, FormCreateSchema, SheetFormTypeResponse, SheetResponse
from src.db.models.app_user import AppUser
from src.services.budget_form_service import BudgetFormService
from src.api.v1.deps import get_current_active_user_with_set_db, require_admin
@ -80,6 +81,7 @@ async def get_forms(
org_units: str | None = None,
form_type: Literal["FORM_1", "FORM_2", "FORM_4"] | None = None,
year: int | None = None,
delete_status: Literal["active", "marked", "all"] | None = None,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(get_current_active_user_with_set_db),
) -> BaseListResponse[BudgetFormResponse]:
@ -95,6 +97,7 @@ async def get_forms(
form_type=form_type,
year=year,
load_org=True,
delete_status=delete_status,
)
return BaseListResponse(
result = [
@ -112,12 +115,15 @@ async def get_form(
) -> BaseSingleResponse[BudgetFormResponse]:
bf_service = BudgetFormService(db)
form = await bf_service.get(
user=current_user,
user=current_user,
budget_form_id=form_id,
load_org=True,
include_to_delete=current_user.role_id == UserRoleEnum.ADMIN,
)
if not form:
raise HTTPException(404, f"Форма {form_id} не найдена")
return BaseSingleResponse(
result = BudgetFormResponse.model_validate(form),
result=BudgetFormResponse.model_validate(form),
)
@ -464,6 +470,7 @@ async def add_form(
form_type_code=form.form_type_code,
year=form.year,
org_unit_ids=org_unit_ids,
user=current_user,
)
db_ms = (time.perf_counter() - t0) * 1000
@ -485,3 +492,36 @@ async def add_form(
result=[BudgetFormResponse.model_validate(new_form) for new_form in new_forms],
count=len(new_forms),
)
@router.post("/{form_id}/mark-delete")
async def mark_form_for_deletion(
form_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseSingleResponse[BudgetFormResponse]:
bf_service = BudgetFormService(db)
form = await bf_service.mark_for_deletion(budget_form_id=form_id, user=current_user)
return BaseSingleResponse(result=BudgetFormResponse.model_validate(form))
@router.post("/{form_id}/restore")
async def restore_form(
form_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> BaseSingleResponse[BudgetFormResponse]:
bf_service = BudgetFormService(db)
form = await bf_service.restore(budget_form_id=form_id, user=current_user)
return BaseSingleResponse(result=BudgetFormResponse.model_validate(form))
@router.delete("/{form_id}/hard")
async def hard_delete_form(
form_id: int,
db: AsyncSession = Depends(get_db),
current_user: AppUser = Depends(require_admin),
) -> ResponseBase:
bf_service = BudgetFormService(db)
await bf_service.hard_delete(budget_form_id=form_id, user=current_user)
return ResponseBase(success=True, message="Задача полностью удалена")

View File

@ -1,7 +1,7 @@
from datetime import datetime
import typing
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.models.form_type import FormTypeEnum
@ -34,6 +34,9 @@ class BudgetForm(Base):
org_unit_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("v3.org_unit.id", name="fk_v3_budget_form_org_unit")
)
to_delete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
deleted_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
form_type: Mapped["FormType"] = relationship(
"FormType", #back_populates="budget_forms"

View File

@ -330,6 +330,9 @@ class BudgetFormResponse(BaseModel):
form_type_code: FormTypeSchemaEnum
year: int
org_unit_id: int
to_delete: bool = False
deleted_by: int | None = None
deleted_at: datetime | None = None
org_unit: OrgUnitSchema | None = None
model_config = ConfigDict(from_attributes=True)

View File

@ -1,4 +1,4 @@
from sqlalchemy import func, select, text
from sqlalchemy import func, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.models.form_type import FormTypeEnum
@ -6,20 +6,20 @@ from src.db.models.budget_form import BudgetForm
from sqlalchemy.orm import joinedload
class BudgetFormRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def get_list(
self,
offset: int | None = None,
self,
offset: int | None = None,
limit: int | None = None,
org_unit: int | list[int] | None = None,
with_count: bool = False,
load_org: bool = False,
form_type: FormTypeEnum | str | None = None,
year: int | None = None,
delete_status: str = "active",
) -> list[BudgetForm] | tuple[int, list[BudgetForm]]:
if with_count:
query = select(func.count().over().label("total_count"), BudgetForm)
@ -38,6 +38,11 @@ class BudgetFormRepository:
if year is not None:
where.append(BudgetForm.year == year)
if delete_status == "marked":
where.append(BudgetForm.to_delete.is_(True))
elif delete_status != "all":
where.append(BudgetForm.to_delete.is_(False))
query = query.where(*where)
if load_org:
query = query.options(
@ -63,19 +68,20 @@ class BudgetFormRepository:
return (await self.db.execute(count_query)).scalar(), []
else:
return (await self.db.execute(query)).scalars().all()
async def get(
self,
self,
budget_form_id: int | None = None,
org_unit: int | list[int] | None = None,
form_type_code: FormTypeEnum | str | None = None,
year: int | None = None,
load_form_type: bool = False,
load_org: bool = False,
include_to_delete: bool = False,
) -> BudgetForm | None:
assert budget_form_id or all((form_type_code, year, org_unit))
query = select(BudgetForm)
where = []
if budget_form_id is not None:
where.append(BudgetForm.id == budget_form_id)
@ -85,16 +91,19 @@ class BudgetFormRepository:
where.append(BudgetForm.org_unit_id == org_unit)
else:
where.append(BudgetForm.org_unit_id.in_(org_unit))
if form_type_code is not None:
if isinstance(form_type_code, FormTypeEnum):
where.append(BudgetForm.form_type_code == form_type_code.value)
else:
where.append(BudgetForm.form_type_code == form_type_code)
if year is not None:
where.append(BudgetForm.year == year)
if not include_to_delete:
where.append(BudgetForm.to_delete.is_(False))
query = query.where(*where)
if load_org:
query = query.options(
@ -106,12 +115,13 @@ class BudgetFormRepository:
joinedload(BudgetForm.form_type)
)
return (await self.db.execute(query)).scalars().first()
async def create(
self,
self,
form_type_code: str,
year: int,
year: int,
org_unit_id: int,
user_id: int | None = None,
) -> BudgetForm | None:
form_id = (
await self.db.execute(
@ -134,6 +144,42 @@ class BudgetFormRepository:
).scalar_one_or_none()
if form_id is None:
return None
if user_id is not None:
await self.db.execute(
update(BudgetForm)
.where(BudgetForm.id == form_id)
.values(created_by=user_id, updated_by=user_id)
)
return await self.get(
budget_form_id=form_id,
)
async def mark_for_deletion(self, budget_form_id: int, user_id: int) -> None:
await self.db.execute(
text(
"""
UPDATE v3.budget_form
SET to_delete = true, deleted_by = :user_id, deleted_at = now(), updated_by = :user_id, updated_at = now()
WHERE id = :form_id
"""
),
{"form_id": budget_form_id, "user_id": user_id},
)
async def restore(self, budget_form_id: int, user_id: int) -> None:
await self.db.execute(
text(
"""
UPDATE v3.budget_form
SET to_delete = false, deleted_by = NULL, deleted_at = NULL, updated_by = :user_id, updated_at = now()
WHERE id = :form_id
"""
),
{"form_id": budget_form_id, "user_id": user_id},
)
async def hard_delete(self, budget_form_id: int) -> None:
await self.db.execute(
text("SELECT v3.hard_delete_budget_form(:form_id)"),
{"form_id": budget_form_id},
)

View File

@ -4,12 +4,15 @@ from typing import Literal
from sqlalchemy.ext.asyncio import AsyncSession
from src.repository.user_repository import UserRepository
from src.core.errors import AccessDeniedException, ValidationException
from src.db.models.app_user import AppUser
from src.db.models.audit_log import AuditLog
from src.db.models.budget_form import BudgetForm
from src.db.models.role import UserRoleEnum
from src.repository.budget_form_repository import BudgetFormRepository
from src.repository.user_repository import UserRepository
DeleteStatus = Literal["active", "marked", "all"]
class BudgetFormService:
@ -28,8 +31,15 @@ class BudgetFormService:
org_unit: list[int] | None = None,
form_type: Literal["FORM_1", "FORM_2", "FORM_4"] | None = None,
year: int | None = None,
delete_status: DeleteStatus | None = None,
) -> list[BudgetForm] | tuple[int, list[BudgetForm]]:
if user.role_id == UserRoleEnum.ADMIN:
if delete_status is not None and delete_status not in {"active", "marked", "all"}:
raise ValidationException("delete_status должен быть active, marked или all")
is_admin = user.role_id == UserRoleEnum.ADMIN
effective_delete_status = (delete_status or "all") if is_admin else "active"
if is_admin:
return await self.bf_repo.get_list(
offset=offset,
limit=limit,
@ -38,6 +48,7 @@ class BudgetFormService:
org_unit=org_unit,
form_type=form_type,
year=year,
delete_status=effective_delete_status,
)
user = await self.user_repo.get(
user_id=user.id,
@ -58,6 +69,7 @@ class BudgetFormService:
load_org=load_org,
form_type=form_type,
year=year,
delete_status=effective_delete_status,
)
async def get(
@ -66,12 +78,15 @@ class BudgetFormService:
user: AppUser,
load_form_type: bool = False,
load_org: bool = False,
include_to_delete: bool = False,
) -> BudgetForm | None:
if user.role_id == UserRoleEnum.ADMIN:
is_admin = user.role_id == UserRoleEnum.ADMIN
if is_admin:
return await self.bf_repo.get(
budget_form_id=budget_form_id,
load_form_type=load_form_type,
load_org=load_org,
include_to_delete=True,
)
user = await self.user_repo.get(
user_id=user.id,
@ -82,30 +97,35 @@ class BudgetFormService:
org_unit=[ou.id for ou in user.org_units],
load_form_type=load_form_type,
load_org=load_org,
include_to_delete=False,
)
async def create(
self,
self,
form_type_code: str,
year: int,
year: int,
org_unit_id: int,
user: AppUser | None = None,
) -> BudgetForm | None:
return await self.bf_repo.create(
form_type_code=form_type_code,
year=year,
org_unit_id=org_unit_id,
user_id=user.id if user else None,
)
async def create_if_not_exists(
self,
self,
form_type_code: str,
year: int,
year: int,
org_unit_id: int,
user: AppUser | None = None,
) -> BudgetForm | None:
existing_bf = await self.bf_repo.get(
form_type_code=form_type_code,
year=year,
org_unit=org_unit_id,
include_to_delete=True,
)
if existing_bf:
return existing_bf
@ -113,13 +133,15 @@ class BudgetFormService:
form_type_code=form_type_code,
year=year,
org_unit_id=org_unit_id,
user_id=user.id if user else None,
)
async def bulk_create(
self,
self,
form_type_code: str,
year: int,
year: int,
org_unit_ids: list[int],
user: AppUser | None = None,
) -> BudgetForm | None:
return await asyncio.gather(
*[
@ -127,16 +149,18 @@ class BudgetFormService:
form_type_code=form_type_code,
year=year,
org_unit_id=org_unit_id,
user=user,
)
for org_unit_id in org_unit_ids
]
]
)
async def bulk_create_if_not_exists(
self,
self,
form_type_code: str,
year: int,
year: int,
org_unit_ids: list[int],
user: AppUser | None = None,
) -> BudgetForm | None:
return await asyncio.gather(
*[
@ -144,7 +168,118 @@ class BudgetFormService:
form_type_code=form_type_code,
year=year,
org_unit_id=org_unit_id,
user=user,
)
for org_unit_id in org_unit_ids
]
]
)
async def mark_for_deletion(self, budget_form_id: int, user: AppUser) -> BudgetForm:
self._ensure_admin(user)
form = await self._get_admin_form(budget_form_id)
if form.to_delete:
return form
await self.bf_repo.mark_for_deletion(budget_form_id=budget_form_id, user_id=user.id)
await self.db.refresh(form)
self._add_audit_log(
event="FORM_MARKED_FOR_DELETION",
user=user,
form=form,
event_data={
"entity_type": "form",
"entity_id": form.id,
"form_id": form.id,
"form_type_code": self._form_type_value(form.form_type_code),
"year": form.year,
"org_unit_id": form.org_unit_id,
},
)
return form
async def restore(self, budget_form_id: int, user: AppUser) -> BudgetForm:
self._ensure_admin(user)
form = await self._get_admin_form(budget_form_id)
if not form.to_delete:
return form
snapshot = self._form_snapshot(form)
await self.bf_repo.restore(budget_form_id=budget_form_id, user_id=user.id)
await self.db.refresh(form)
self._add_audit_log(
event="FORM_RESTORED",
user=user,
form=form,
event_data={
"entity_type": "form",
"entity_id": form.id,
"before": snapshot,
},
)
return form
async def hard_delete(self, budget_form_id: int, user: AppUser) -> None:
self._ensure_admin(user)
form = await self._get_admin_form(budget_form_id)
if not form.to_delete:
raise ValidationException("Задача не помечена на удаление")
if form.deleted_by == user.id:
raise AccessDeniedException("Полностью удалить задачу должен другой администратор")
snapshot = self._form_snapshot(form)
self._add_audit_log(
event="FORM_HARD_DELETED",
user=user,
form=form,
event_data={
"entity_type": "form",
"entity_id": form.id,
"before": snapshot,
},
)
await self.db.flush()
await self.bf_repo.hard_delete(budget_form_id=budget_form_id)
def _ensure_admin(self, user: AppUser) -> None:
if user.role_id != UserRoleEnum.ADMIN:
raise AccessDeniedException("Недостаточно прав")
async def _get_admin_form(self, budget_form_id: int) -> BudgetForm:
form = await self.bf_repo.get(budget_form_id=budget_form_id, include_to_delete=True, load_org=True)
if not form:
raise ValidationException("Форма не найдена")
return form
def _add_audit_log(
self,
event: str,
user: AppUser,
form: BudgetForm,
event_data: dict,
) -> None:
self.db.add(
AuditLog(
user_id=user.id,
org_unit_id=form.org_unit_id,
form_id=form.id,
task_id=form.id,
event=event,
event_type="FORM",
event_data=event_data,
)
)
def _form_type_value(self, form_type_code) -> str:
return getattr(form_type_code, "value", str(form_type_code))
def _form_snapshot(self, form: BudgetForm) -> dict:
return {
"id": form.id,
"form_type_code": self._form_type_value(form.form_type_code),
"year": form.year,
"org_unit_id": form.org_unit_id,
"created_by": form.created_by,
"created_at": form.created_at.isoformat() if form.created_at else None,
"updated_by": form.updated_by,
"updated_at": form.updated_at.isoformat() if form.updated_at else None,
"to_delete": form.to_delete,
"deleted_by": form.deleted_by,
"deleted_at": form.deleted_at.isoformat() if form.deleted_at else None,
}

View File

@ -31,6 +31,15 @@ export const TasksApi = {
responseType: 'blob',
});
},
markForDeletion(taskId) {
return api.post(`/form/${taskId}/mark-delete`).then((r) => r.data);
},
restore(taskId) {
return api.post(`/form/${taskId}/restore`).then((r) => r.data);
},
hardDelete(taskId) {
return api.delete(`/form/${taskId}/hard`).then((r) => r.data);
},
exportTasks(payload, params = {}) {
return api.post(`export/bulk`, payload, {
params,

View File

@ -681,6 +681,10 @@ export const config = {
color_type: yellowColumn,
accessorKey: "data.q4.adj_reserve",
},
"data.q4.adj_comment": {
color_type: yellowColumn,
accessorKey: "data.q4.adj_comment",
},
"data.q4.corrected_plan": {
color_type: yellowColumn,
accessorKey: "data.q4.corrected_plan",
@ -3663,6 +3667,25 @@ export const config = {
},
},
},
{
header: "Комментарий",
accessorKey: "4_kvartal_2026_adj_kommentariy",
columns: [
{
header: "Сумма (без НДС)",
accessorKey: "data.q4.adj_comment",
columnLetter: "GG",
size: 150,
filterFn: "contains",
},
],
muiTableHeadCellProps: {
sx: {
backgroundColor: "#FFFF99",
color: "#000000",
},
},
},
{
header: "Скорректированный план 4 кв. {year:short}",
accessorKey: "4_kvartal_2026_skorr_plan_4_kv_26",

View File

@ -681,6 +681,10 @@ export const config = {
color_type: yellowColumn,
accessorKey: "data.q4.adj_reserve",
},
"data.q4.adj_comment": {
color_type: yellowColumn,
accessorKey: "data.q4.adj_comment",
},
"data.q4.corrected_plan": {
color_type: yellowColumn,
accessorKey: "data.q4.corrected_plan",
@ -3659,6 +3663,25 @@ export const config = {
},
},
},
{
header: "Комментарий",
accessorKey: "4_kvartal_2026_adj_kommentariy",
columns: [
{
header: "Сумма (без НДС)",
accessorKey: "data.q4.adj_comment",
columnLetter: "GG",
size: 150,
filterFn: "contains",
},
],
muiTableHeadCellProps: {
sx: {
backgroundColor: "#FFFF99",
color: "#000000",
},
},
},
{
header: "Скорректированный план 4 кв. {year:short}",
accessorKey: "4_kvartal_2026_skorr_plan_4_kv_26",

View File

@ -682,6 +682,10 @@ export const config = {
color_type: yellowColumn,
accessorKey: "data.q4.adj_reserve",
},
"data.q4.adj_comment": {
color_type: yellowColumn,
accessorKey: "data.q4.adj_comment",
},
"data.q4.corrected_plan": {
color_type: yellowColumn,
accessorKey: "data.q4.corrected_plan",
@ -3659,6 +3663,25 @@ export const config = {
},
},
},
{
header: "Комментарий",
accessorKey: "4_kvartal_2026_adj_kommentariy",
columns: [
{
header: "Сумма (без НДС)",
accessorKey: "data.q4.adj_comment",
columnLetter: "GG",
size: 150,
filterFn: "contains",
},
],
muiTableHeadCellProps: {
sx: {
backgroundColor: "#FFFF99",
color: "#000000",
},
},
},
{
header: "Скорректированный план 4 кв. {year:short}",
accessorKey: "4_kvartal_2026_skorr_plan_4_kv_26",

View File

@ -3571,13 +3571,6 @@ export const config = {
size: 260,
filterFn: "contains",
},
{
header: "Сумма, тыс. руб. (без НДС)",
accessorKey: "data.q4.adj_comment",
columnLetter: "GG",
size: 250,
filterFn: "contains",
},
],
muiTableHeadCellProps: {
sx: {
@ -3688,6 +3681,25 @@ export const config = {
},
},
},
{
header: "Комментарий",
accessorKey: "4_kvartal_2026_adj_kommentariy",
columns: [
{
header: "Сумма, тыс. руб. (без НДС)",
accessorKey: "data.q4.adj_comment",
columnLetter: "GG",
size: 250,
filterFn: "contains",
},
],
muiTableHeadCellProps: {
sx: {
backgroundColor: "#FFFF66",
color: "#000000",
},
},
},
{
header: "Скорректированный план 4 кв. {year:short}",
accessorKey: "4_kvartal_2026_skorr_plan_4_kv_26",

View File

@ -59,6 +59,9 @@ export const TaskCardComponent = ({
onAddForExport,
onExportClick,
isLastOpened = false,
isAdmin = false,
onChanged,
currentUserId = null,
}) => {
const navigate = useNavigate();
const location = useLocation();
@ -67,11 +70,14 @@ export const TaskCardComponent = ({
if (isLastOpened) cardRef.current?.scrollIntoView({ block: 'nearest' });
}, [isLastOpened]);
const { id, title, org_unit, form_type_code, year } = taskForm;
const canHardDelete = taskForm.to_delete && taskForm.deleted_by !== currentUserId;
const [isEditModalOpen, setEditModalOpen] = useState(false);
const [editedTitle, setEditedTitle] = useState('');
const [currentTypeCode, setCurrentTypeCode] = useState(FORM_TYPE_TRANSLATE[form_type_code] || '');
const [isChecked, setIsChecked] = useState(false);
const [confirmAction, setConfirmAction] = useState(null);
const [actionLoading, setActionLoading] = useState(false);
useEffect(() => {
setIsChecked(false);
@ -102,6 +108,52 @@ export const TaskCardComponent = ({
onExportClick?.(id, title);
};
const openConfirmAction = (event, action) => {
event.stopPropagation();
setConfirmAction(action);
};
const closeConfirmAction = () => {
if (!actionLoading) setConfirmAction(null);
};
const runConfirmedAction = async () => {
if (!confirmAction) return;
try {
setActionLoading(true);
if (confirmAction === 'mark') {
await TasksApi.markForDeletion(id);
toast.success('Задача помечена на удаление');
}
if (confirmAction === 'restore') {
await TasksApi.restore(id);
toast.success('Задача возвращена');
}
if (confirmAction === 'hardDelete') {
await TasksApi.hardDelete(id);
toast.success('Задача полностью удалена');
}
setConfirmAction(null);
onChanged?.();
} catch (error) {
toast.error(error?.response?.data?.detail || 'Не удалось выполнить действие');
} finally {
setActionLoading(false);
}
};
const actionTitle = {
mark: 'Удалить задачу',
restore: 'Вернуть задачу',
hardDelete: 'Полностью удалить задачу',
}[confirmAction];
const actionText = {
mark: 'Задача будет скрыта от пользователей. Админы смогут вернуть её или удалить окончательно.',
restore: 'Задача снова станет доступна пользователям.',
hardDelete: 'Будут удалены задача и все связанные данные. Это действие нельзя отменить.',
}[confirmAction];
const updateTask = async (nextTitle) => {
try {
await TasksApi.editTask(id, { title: nextTitle });
@ -147,6 +199,7 @@ export const TaskCardComponent = ({
<TaskCard
ref={cardRef}
$fluidWidth={fluidWidth}
$marked={Boolean(taskForm.to_delete)}
onClick={(event) => {
if (exportMode) {
handleCheckboxClick(event);
@ -159,7 +212,10 @@ export const TaskCardComponent = ({
<Box sx={{ width: '100%' }}>
<CardHeader>
<TaskId>ID: {id}</TaskId>
{isLastOpened && <LastOpenedBadge sx={{ mb: 0 }}>Последняя открытая</LastOpenedBadge>}
<Stack direction='row' gap={0.75} alignItems='center'>
{taskForm.to_delete && <Chip sx={{ background: '#fde8e8', color: '#b42318' }}>На удаление</Chip>}
{isLastOpened && <LastOpenedBadge sx={{ mb: 0 }}>Последняя открытая</LastOpenedBadge>}
</Stack>
</CardHeader>
<TaskTitle>{org_unit?.title || 'Подразделение не указано'}</TaskTitle>
@ -182,10 +238,47 @@ export const TaskCardComponent = ({
</CardBody>
{!isForm && !exportMode && <ExportButton onClick={handleExportTask} />}
{exportMode && <div>{isChecked ? <CheckBoxCheckSvg size='1.625rem' /> : <CheckBoxUncheckSvg size='1.625rem' />}</div>}
<Stack direction='row' gap={0.75} alignItems='center' onClick={(event) => event.stopPropagation()}>
{isAdmin && !isForm && !exportMode && !taskForm.to_delete && (
<Button size='small' variant='text' color='error' onClick={(event) => openConfirmAction(event, 'mark')}>
Удалить
</Button>
)}
{isAdmin && !isForm && !exportMode && taskForm.to_delete && (
<>
<Button size='small' variant='text' onClick={(event) => openConfirmAction(event, 'restore')}>
Вернуть
</Button>
{canHardDelete && (
<Button size='small' variant='text' color='error' onClick={(event) => openConfirmAction(event, 'hardDelete')}>
Удалить
</Button>
)}
</>
)}
{!isForm && !exportMode && !taskForm.to_delete && <ExportButton onClick={handleExportTask} />}
{exportMode && <div>{isChecked ? <CheckBoxCheckSvg size='1.625rem' /> : <CheckBoxUncheckSvg size='1.625rem' />}</div>}
</Stack>
</Stack>
</TaskCard>
<Modal
open={Boolean(confirmAction)}
onClose={closeConfirmAction}
title={actionTitle}
size='small'
actions={
<>
<Button variant='grey' onClick={closeConfirmAction} disabled={actionLoading}>
Отменить
</Button>
<Button variant='contained' color={confirmAction === 'hardDelete' || confirmAction === 'mark' ? 'error' : 'primary'} onClick={runConfirmedAction} disabled={actionLoading}>
Подтвердить
</Button>
</>
}>
{actionText}
</Modal>
</>
);
};

View File

@ -10,12 +10,13 @@ export const TaskCard = styled.div`
justify-content: flex-start;
align-items: flex-start;
box-sizing: border-box;
border: 0.06rem solid var(--Stroke, rgba(0, 0, 0, 0.1));
border: 0.06rem solid ${({ $marked }) => ($marked ? 'rgba(211, 47, 47, 0.35)' : 'var(--Stroke, rgba(0, 0, 0, 0.1))')};
border-radius: 1rem;
box-shadow:
0rem 2rem 4rem -2rem rgba(0, 0, 0, 0.02),
0rem 4rem 6rem -1rem rgba(0, 0, 0, 0.02);
background: var(--White, rgba(255, 255, 255, 1));
background: ${({ $marked }) => ($marked ? '#fff7f7' : 'var(--White, rgba(255, 255, 255, 1))')};
opacity: ${({ $marked }) => ($marked ? 0.72 : 1)};
overflow: hidden;
cursor: pointer;
position: relative;

View File

@ -5,6 +5,9 @@ export const DEFAULT_ACTIONS = {
ACCESS_WINDOW_CHANGE: 'Изменение этапа',
USER_ROLE_CHANGE: 'Изменение роли пользователя',
CREATE_TASK: 'Создание задачи',
FORM_MARKED_FOR_DELETION: 'Задача отмечена на удаление',
FORM_RESTORED: 'Задача возвращена',
FORM_HARD_DELETED: 'Задача полностью удалена',
// UPDATE_TASK: 'Обновление задачи', //сейчас нет события
// DELETE_TASK: 'Удаление задачи',
USER_CREATE: 'Создание пользователя',

View File

@ -58,6 +58,21 @@ export const EVENT_CONFIGS = {
form_type: 'Форма',
ssp_list: 'Создана для СПП/РФ',
},
FORM_MARKED_FOR_DELETION: {
entity_id: 'Задача',
form_id: 'Задача',
form_type_code: 'Форма',
year: 'Год',
org_unit_id: 'ССП/РФ',
},
FORM_RESTORED: {
entity_id: 'Задача',
before: 'Данные до возврата',
},
FORM_HARD_DELETED: {
entity_id: 'Задача',
before: 'Удалённые данные',
},
ROW_CREATE: {
sheet: 'Лист',
expense_item_id: 'Статья расходов',

View File

@ -23,6 +23,12 @@ const METRICS = [
{ key: 'fact', label: 'Факт' },
{ key: 'corrected', label: 'Скорректированный план' },
];
const FORM_3_METRIC_LABELS = {
plan: 'Расходы, планируемые РФ',
approved: 'Утвержденные базовые расходы',
fact: 'Фактические расходы',
corrected: 'Скорректированная смета расходов',
};
const DIRECTIONS = [
{ key: 'support', label: 'Поддержка' },
{ key: 'development', label: 'Развитие' },
@ -34,6 +40,13 @@ const QUARTERS = [
{ key: 'q4', label: 'IV кв.' },
{ key: 'year', label: 'Год' },
];
const FORM_3_QUARTERS = [
{ key: 'q1', label: 'I квартал' },
{ key: 'q2', label: 'II квартал' },
{ key: 'q3', label: 'III квартал' },
{ key: 'q4', label: 'IV квартал' },
{ key: 'year', label: 'ИТОГО Развитие' },
];
const numberFormatter = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 });
const valueAt = (row, path) => path.reduce((value, key) => value?.[key], row.data);
const tableSize = (rem) => rem * 16;
@ -66,14 +79,42 @@ const valueColumn = (metric, direction, period) => ({
muiTableHeadCellProps: { align: 'right' },
});
const createForm3MetricColumns = (metric) => ({
id: metric.key,
header: FORM_3_METRIC_LABELS[metric.key],
columns: FORM_3_QUARTERS.filter(({ key }) =>
valueAt({ data: getShapeForSheet('FORM_3') }, [metric.key, 'development', key]) !== undefined,
).map((period) => ({
...valueColumn(metric, { key: 'development' }, period),
id: metric.key + '.' + period.key,
})),
});
const createColumns = (sheet) => {
let visibleDirections = DIRECTIONS;
if (sheet === 'FORM_2') visibleDirections = DIRECTIONS.filter(({ key }) => key === 'support');
if (['FORM_3', 'FORM_4'].includes(sheet)) visibleDirections = DIRECTIONS.filter(({ key }) => key === 'development');
const shape = getShapeForSheet(sheet);
if (sheet === 'FORM_3') {
return [
{ id: 'section_code', header: '№ п/п', accessorFn: (row) => row.data?.section_code, size: tableSize(5.75) },
{
id: 'name',
header: 'Перечень расходов и затрат',
accessorFn: (row) => row.data?.name,
size: tableSize(20.625),
Cell: ({ cell, row }) => (
<Box sx={{ pl: row.original.depth * 1.125 + 'rem', fontWeight: row.original.depth < 2 ? 600 : 400 }}>
{cell.getValue() || 'Без названия'}
</Box>
),
},
...METRICS.map(createForm3MetricColumns),
];
}
const metricColumns = METRICS.map((metric) => ({
id: metric.key,
header: metric.label,
header: sheet === 'FORM_3' ? FORM_3_METRIC_LABELS[metric.key] : metric.label,
columns: visibleDirections
.map((direction) => ({
id: `${metric.key}.${direction.key}`,

View File

@ -39,6 +39,7 @@ export default function TasksPage() {
const yearFilter = searchParams.get('year') || '';
const formTypeFilter = searchParams.get('form_type') || '';
const orgUnitsFilter = searchParams.get('org_units') || '';
const deleteStatusFilter = searchParams.get('delete_status') || 'all';
const orgUnitIdsFilter = useMemo(
() =>
orgUnitsFilter
@ -185,6 +186,7 @@ export default function TasksPage() {
...(yearFilter && { year: Number(yearFilter) }),
...(formTypeFilter && { form_type: formTypeFilter }),
...(orgUnitIdsFilter.length > 0 && { org_units: orgUnitIdsFilter.join(',') }),
...(user?.role_id === ROLES_NAME_ID.admin && deleteStatusFilter && { delete_status: deleteStatusFilter }),
};
const res = await TasksApi.list(params);
@ -219,7 +221,7 @@ export default function TasksPage() {
if (currentRequest === requestId.current) setLoading(false);
}
},
[yearFilter, formTypeFilter, orgUnitIdsFilter, setSearchParams],
[yearFilter, formTypeFilter, orgUnitIdsFilter, deleteStatusFilter, user?.role_id, setSearchParams],
);
const handlePageChange = (_event, value) => {
@ -266,6 +268,7 @@ export default function TasksPage() {
nextParams.delete('year');
nextParams.delete('form_type');
nextParams.delete('org_units');
nextParams.delete('delete_status');
return nextParams;
});
};
@ -337,6 +340,19 @@ export default function TasksPage() {
sx={{ width: 480, maxWidth: '100%' }}
/>
{user.role_id === ROLES_NAME_ID.admin && (
<Select
size='small'
displayEmpty
value={deleteStatusFilter}
onChange={(event) => handleFilterChange('delete_status', event.target.value)}
sx={{ width: 230 }}>
<MenuItem value='active'>Непомеченные</MenuItem>
<MenuItem value='marked'>Помеченные</MenuItem>
<MenuItem value='all'>Все задачи</MenuItem>
</Select>
)}
<ResetFiltersButton onClick={handleClearFilters} />
</Box>
}