diff --git a/api/alembic/versions/0007_form_constraints.py b/api/alembic/versions/0007_form_constraints.py new file mode 100644 index 0000000..9b9bbc6 --- /dev/null +++ b/api/alembic/versions/0007_form_constraints.py @@ -0,0 +1,180 @@ +from alembic import op +import sqlalchemy as sa + + +revision = "0007" +down_revision = "0006" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(""" + CREATE OR REPLACE FUNCTION v3.v_form1_smeta(p_year integer, p_org_unit_id integer) + RETURNS TABLE(row_type character varying, depth integer, section_code character varying, name character varying, supp_plan_q1 numeric, supp_plan_q2 numeric, supp_plan_q3 numeric, supp_plan_q4 numeric, supp_plan_year numeric, dev_plan_q1 numeric, dev_plan_q2 numeric, dev_plan_q3 numeric, dev_plan_q4 numeric, dev_plan_year numeric, total_plan_year numeric, supp_appr_q1 numeric, supp_appr_q2 numeric, supp_appr_q3 numeric, supp_appr_q4 numeric, supp_appr_year numeric, dev_appr_q1 numeric, dev_appr_q2 numeric, dev_appr_q3 numeric, dev_appr_q4 numeric, dev_appr_year numeric, total_appr_year numeric, supp_act_q1 numeric, supp_act_q2 numeric, supp_act_q3 numeric, supp_act_q4 numeric, supp_act_year numeric, dev_act_q1 numeric, dev_act_q2 numeric, dev_act_q3 numeric, dev_act_q4 numeric, dev_act_year numeric, total_act_year numeric, supp_corr_q2 numeric, supp_corr_q3 numeric, supp_corr_q4 numeric, dev_corr_q2 numeric, dev_corr_q3 numeric, dev_corr_q4 numeric) + LANGUAGE plpgsql + STABLE + SET search_path TO 'v3', 'pg_catalog' + AS $function$ + #variable_conflict use_column + DECLARE + v_ahr INT; v_cap INT; v_oper INT; + v_sections TEXT[] := ARRAY['plan','approved','q1','q2','q3','q4']; + BEGIN + -- Одна budget_form на лист содержит ОБА направления (budget_line.direction). + -- Support/Development различаются параметром p_direction, а не отдельной формой. + WITH ranked AS ( + SELECT bf.id AS fid, ei.sheet AS sh + FROM budget_form bf + JOIN budget_line bl ON bl.budget_form_id = bf.id + JOIN expense_item ei ON ei.id = bl.expense_item_id + WHERE bf.year = p_year AND bf.org_unit_id = p_org_unit_id AND bf.form_type_code = 'FORM_1' + GROUP BY bf.id, ei.sheet + ) + SELECT + MAX(CASE WHEN sh='AHR' THEN fid END), + MAX(CASE WHEN sh='CAP' THEN fid END), + MAX(CASE WHEN sh='OPER' THEN fid END) + INTO v_ahr, v_cap, v_oper + FROM ranked; + + RETURN QUERY + WITH + ahr_s AS ( + SELECT col_C_section AS sc, col_F_name AS nm, depth, row_type, + col_H_plan_q1 AS pq1, col_I_plan_q2 AS pq2, col_J_plan_q3 AS pq3, col_K_plan_q4 AS pq4, col_L_plan_year AS pyr, + col_AM_approved_q1 AS aq1, col_AN_approved_q2 AS aq2, col_AO_approved_q3 AS aq3, col_AP_approved_q4 AS aq4, col_AQ_approved_year AS ayr, + col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3, + COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4, + col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4 + FROM v_form1_sheet_sections(v_ahr, 'AHR', 'Support', v_sections) + WHERE row_type IN ('ROOT','GROUP','ITEM') + ), + ahr_d AS ( + SELECT col_C_section AS sc, col_F_name AS nm, depth, row_type, + col_H_plan_q1 AS pq1, col_I_plan_q2 AS pq2, col_J_plan_q3 AS pq3, col_K_plan_q4 AS pq4, col_L_plan_year AS pyr, + col_AM_approved_q1 AS aq1, col_AN_approved_q2 AS aq2, col_AO_approved_q3 AS aq3, col_AP_approved_q4 AS aq4, col_AQ_approved_year AS ayr, + col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3, + COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4, + col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4 + FROM v_form1_sheet_sections(v_ahr, 'AHR', 'Development', v_sections) + WHERE row_type IN ('ROOT','GROUP','ITEM') + ), + cap_s AS ( + SELECT col_C_section AS sc, col_F_name AS nm, depth, row_type, + col_H_plan_q1 AS pq1, col_I_plan_q2 AS pq2, col_J_plan_q3 AS pq3, col_K_plan_q4 AS pq4, col_L_plan_year AS pyr, + col_AM_approved_q1 AS aq1, col_AN_approved_q2 AS aq2, col_AO_approved_q3 AS aq3, col_AP_approved_q4 AS aq4, col_AQ_approved_year AS ayr, + col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3, + COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4, + col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4 + FROM v_form1_sheet_sections(v_cap, 'CAP', 'Support', v_sections) + WHERE row_type IN ('ROOT','GROUP','ITEM') + ), + cap_d AS ( + SELECT col_C_section AS sc, col_F_name AS nm, depth, row_type, + col_H_plan_q1 AS pq1, col_I_plan_q2 AS pq2, col_J_plan_q3 AS pq3, col_K_plan_q4 AS pq4, col_L_plan_year AS pyr, + col_AM_approved_q1 AS aq1, col_AN_approved_q2 AS aq2, col_AO_approved_q3 AS aq3, col_AP_approved_q4 AS aq4, col_AQ_approved_year AS ayr, + col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3, + COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4, + col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4 + FROM v_form1_sheet_sections(v_cap, 'CAP', 'Development', v_sections) + WHERE row_type IN ('ROOT','GROUP','ITEM') + ), + oper_s AS ( + SELECT col_C_section AS sc, col_F_name AS nm, depth, row_type, + col_H_plan_q1 AS pq1, col_I_plan_q2 AS pq2, col_J_plan_q3 AS pq3, col_K_plan_q4 AS pq4, col_L_plan_year AS pyr, + col_AM_approved_q1 AS aq1, col_AN_approved_q2 AS aq2, col_AO_approved_q3 AS aq3, col_AP_approved_q4 AS aq4, col_AQ_approved_year AS ayr, + col_CS_q1_actual_quarter AS fq1, col_EB_q2_actual_quarter AS fq2, col_FJ_q3_actual_quarter AS fq3, + COALESCE(col_GQ_q4_actual_quarter,0) + COALESCE(col_GR_q4_actual_spod,0) AS fq4, + col_DH_q2_new_plan AS np2, col_EP_q3_new_plan AS np3, col_FW_q4_new_plan AS np4 + FROM v_form1_sheet_sections(v_oper, 'OPER', 'Support', v_sections) + WHERE row_type IN ('ROOT','GROUP','ITEM') + ), + merged AS ( + SELECT COALESCE(s.row_type, d.row_type) AS row_type, + COALESCE(s.depth, d.depth) AS depth, + COALESCE(s.sc, d.sc) AS sc, + COALESCE(s.nm, d.nm) AS nm, + s.pq1, s.pq2, s.pq3, s.pq4, s.pyr, + d.pq1 AS d_pq1, d.pq2 AS d_pq2, d.pq3 AS d_pq3, d.pq4 AS d_pq4, d.pyr AS d_pyr, + s.aq1, s.aq2, s.aq3, s.aq4, s.ayr, + d.aq1 AS d_aq1, d.aq2 AS d_aq2, d.aq3 AS d_aq3, d.aq4 AS d_aq4, d.ayr AS d_ayr, + s.fq1, s.fq2, s.fq3, s.fq4, + d.fq1 AS d_fq1, d.fq2 AS d_fq2, d.fq3 AS d_fq3, d.fq4 AS d_fq4, + s.np2, s.np3, s.np4, + d.np2 AS d_np2, d.np3 AS d_np3, d.np4 AS d_np4 + FROM ahr_s s FULL OUTER JOIN ahr_d d ON s.sc = d.sc + UNION ALL + SELECT COALESCE(s.row_type, d.row_type), COALESCE(s.depth, d.depth), + COALESCE(s.sc, d.sc), COALESCE(s.nm, d.nm), + s.pq1, s.pq2, s.pq3, s.pq4, s.pyr, + d.pq1, d.pq2, d.pq3, d.pq4, d.pyr, + s.aq1, s.aq2, s.aq3, s.aq4, s.ayr, + d.aq1, d.aq2, d.aq3, d.aq4, d.ayr, + s.fq1, s.fq2, s.fq3, s.fq4, + d.fq1, d.fq2, d.fq3, d.fq4, + s.np2, s.np3, s.np4, + d.np2, d.np3, d.np4 + FROM cap_s s FULL OUTER JOIN cap_d d ON s.sc = d.sc + UNION ALL + SELECT s.row_type, s.depth, s.sc, s.nm, + s.pq1, s.pq2, s.pq3, s.pq4, s.pyr, + NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, + s.aq1, s.aq2, s.aq3, s.aq4, s.ayr, + NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, + s.fq1, s.fq2, s.fq3, s.fq4, + NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC, + s.np2, s.np3, s.np4, + NULL::NUMERIC, NULL::NUMERIC, NULL::NUMERIC + FROM oper_s s + ) + SELECT + m.row_type, m.depth, m.sc, m.nm, + m.pq1, m.pq2, m.pq3, m.pq4, m.pyr, + m.d_pq1, m.d_pq2, m.d_pq3, m.d_pq4, m.d_pyr, + COALESCE(m.pyr,0) + COALESCE(m.d_pyr,0) AS total_plan_year, + m.aq1, m.aq2, m.aq3, m.aq4, m.ayr, + m.d_aq1, m.d_aq2, m.d_aq3, m.d_aq4, m.d_ayr, + COALESCE(m.ayr,0) + COALESCE(m.d_ayr,0) AS total_appr_year, + m.fq1, m.fq2, m.fq3, m.fq4, COALESCE(m.fq1,0)+COALESCE(m.fq2,0)+COALESCE(m.fq3,0)+COALESCE(m.fq4,0), + m.d_fq1, m.d_fq2, m.d_fq3, m.d_fq4, COALESCE(m.d_fq1,0)+COALESCE(m.d_fq2,0)+COALESCE(m.d_fq3,0)+COALESCE(m.d_fq4,0), + COALESCE(m.fq1,0)+COALESCE(m.fq2,0)+COALESCE(m.fq3,0)+COALESCE(m.fq4,0) + + COALESCE(m.d_fq1,0)+COALESCE(m.d_fq2,0)+COALESCE(m.d_fq3,0)+COALESCE(m.d_fq4,0), + m.np2, m.np3, m.np4, + m.d_np2, m.d_np3, m.d_np4 + FROM merged m + ORDER BY m.sc; + END; + $function$ + ; + """) + for t_name in ( + "reserve", "contract_detail", "ckk", "allocation", "sequestration", "contract_summary", + "collegial_approval", "rent_detail", "utility_detail", "plan", "budget_line_quarter" + ): + op.execute(f""" + delete from v3.{t_name} where line_id in ( + select id from v3.budget_line where budget_form_id not in (select min(fid) from ( + SELECT bf.id AS fid, bf.year as yr, bf.form_type_code as ftc, bf.org_unit_id as oui + FROM v3.budget_form bf + ) ranked + group by yr, ftc, oui)); + """) + op.execute(""" + delete from v3.budget_line where budget_form_id not in (select min(fid) from ( + SELECT bf.id AS fid, bf.year as yr, bf.form_type_code as ftc, bf.org_unit_id as oui + FROM v3.budget_form bf + ) ranked + group by yr, ftc, oui); + """) + op.execute(""" + delete from v3.budget_form where id not in (select min(fid) from ( + SELECT bf.id AS fid, bf.year as yr, bf.form_type_code as ftc, bf.org_unit_id as oui + FROM v3.budget_form bf + ) ranked + group by yr, ftc, oui); + """) + op.execute("ALTER TABLE v3.budget_form ADD CONSTRAINT uq_ftype_org_year UNIQUE (form_type_code, org_unit_id, year)") + +def downgrade() -> None: + pass diff --git a/api/src/api/v1/forms.py b/api/src/api/v1/forms.py index 32d6625..440cc17 100644 --- a/api/src/api/v1/forms.py +++ b/api/src/api/v1/forms.py @@ -441,7 +441,7 @@ async def add_form( ) raise HTTPException(404, f"ССП {not_found_ids} не найдены") - new_forms = await bf_service.bulk_create( + new_forms = await bf_service.bulk_create_if_not_exists( form_type_code=form.form_type_code, year=form.year, org_unit_ids=org_unit_ids, diff --git a/api/src/repository/budget_form_repository.py b/api/src/repository/budget_form_repository.py index dce6dc1..06fddd0 100644 --- a/api/src/repository/budget_form_repository.py +++ b/api/src/repository/budget_form_repository.py @@ -1,6 +1,7 @@ from sqlalchemy import func, select, text from sqlalchemy.ext.asyncio import AsyncSession +from src.db.models.form_type import FormTypeEnum from src.db.models.budget_form import BudgetForm from sqlalchemy.orm import joinedload @@ -58,19 +59,34 @@ class BudgetFormRepository: async def get( self, - budget_form_id: int, + 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, ) -> BudgetForm | None: + assert budget_form_id or all((form_type_code, year, org_unit)) query = select(BudgetForm) - where = [BudgetForm.id == budget_form_id] + where = [] + if budget_form_id is not None: + where.append(BudgetForm.id == budget_form_id) + if org_unit is not None: if isinstance(org_unit, int): 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) query = query.where(*where) if load_org: diff --git a/api/src/services/budget_form_service.py b/api/src/services/budget_form_service.py index a266033..2289ecc 100644 --- a/api/src/services/budget_form_service.py +++ b/api/src/services/budget_form_service.py @@ -79,6 +79,25 @@ class BudgetFormService: year=year, org_unit_id=org_unit_id, ) + + async def create_if_not_exists( + self, + form_type_code: str, + year: int, + org_unit_id: int, + ) -> BudgetForm | None: + existing_bf = await self.bf_repo.get( + form_type_code=form_type_code, + year=year, + org_unit=org_unit_id, + ) + if existing_bf: + return existing_bf + return await self.bf_repo.create( + form_type_code=form_type_code, + year=year, + org_unit_id=org_unit_id, + ) async def bulk_create( self, @@ -96,3 +115,20 @@ class BudgetFormService: for org_unit_id in org_unit_ids ] ) + + async def bulk_create_if_not_exists( + self, + form_type_code: str, + year: int, + org_unit_ids: list[int], + ) -> BudgetForm | None: + return await asyncio.gather( + *[ + self.create_if_not_exists( + form_type_code=form_type_code, + year=year, + org_unit_id=org_unit_id, + ) + for org_unit_id in org_unit_ids + ] + ) diff --git a/api/tests/integration/test_forms_api_smoke.py b/api/tests/integration/test_forms_api_smoke.py index d3aed8e..0e72371 100644 --- a/api/tests/integration/test_forms_api_smoke.py +++ b/api/tests/integration/test_forms_api_smoke.py @@ -161,6 +161,25 @@ def test_forms_add_form_success(client, admin_tokens, auth_headers): assert response.status_code == 200 +def test_forms_add_form_existing_unit(client, admin_tokens, auth_headers): + response = client.post( + "/api/v1/form/", + json={"year": 2026, "form_type_code": "FORM_2", "org_unit_ids": [1, 2]}, + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200 + payload = response.json() + + assert "result" in payload + assert isinstance(payload["result"], list) + assert len(payload["result"]) == 2 + assert "year" in payload["result"][0] + assert payload["result"][0]["year"] == 2026 + + assert "id" in payload["result"][1] + assert payload["result"][1]["id"] == 4 + + def test_forms_add_form_org_unit_not_found(client, admin_tokens, auth_headers): response = client.post( "/api/v1/form/",