fix(form2): добавление записи на сателлите АХР не захватывает ручную строку

v3.add_budget_line (ветка сателлитов AHR_RENT/AHR_UTILITY/AHR_SECURITY)
искала любую существующую строку статьи без ORDER BY и без исключения
ручных/уже привязанных строк, и переиспользовала её как заготовку для
новой записи. Если у статьи ещё не было строк в
v3.form2_ahr_satellite_line_source (типично — первая запись при наличии
ручной строки той же статьи), sync переименовывал и перезаписывал план/факт
чужой ручной строки, блокируя её для правки.

Теперь для каждой новой записи сателлита всегда создаётся отдельная
пустая строка-заготовка — sync забирает её как строку именно этой записи.
Заодно удалён мёртвый код (_ensure_form2_ahr_sync_line,
_upsert_form2_ahr_sync_values), не имевший вызывающих мест.

AURORA-1907 (лимитная часть) и AURORA-1887 (статьи 314/24/779 в коде) —
отдельные задачи, не затронуты.

Closes #265.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-31 15:07:26 +04:00
parent a5ffd476a6
commit 91cbe99c32
3 changed files with 406 additions and 0 deletions

View File

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

View File

@ -0,0 +1,228 @@
-- AURORA-1909: добавление записи на сателлите АХР (аренда/коммуналка/охрана)
-- не должно захватывать существующую (в т.ч. ручную) строку статьи 314/24/779.
--
-- v3.add_budget_line, ветка p_sheet IN ('AHR_RENT','AHR_UTILITY','AHR_SECURITY'):
-- раньше искало ЛЮБУЮ существующую строку статьи (без ORDER BY, без исключения
-- ручных и уже привязанных строк) и переиспользовало её как v_parent_line.
-- Если строка не была зарегистрирована в v3.form2_ahr_satellite_line_source,
-- sync (_ensure_form2_ahr_detail_sync_line, 0031) переименовывал и перезаписывал
-- её план/факт значениями новой записи — в т.ч. ручные строки исполнителя.
-- Теперь всегда создаётся новая пустая строка-заготовка; sync заберёт её себе
-- как строку именно этой записи (её ещё нет в form2_ahr_satellite_line_source).
--
-- Лимитная часть (_ensure_form2_ahr_limit_sync_line) здесь не трогается —
-- это AURORA-1907. Статьи 314/24/779 не трогаются — это AURORA-1887.
CREATE OR REPLACE FUNCTION v3.add_budget_line(p_form_id integer, p_expense_item_id integer DEFAULT NULL::integer, p_sheet character varying DEFAULT NULL::character varying, p_item_id character varying DEFAULT NULL::character varying, p_section_code character varying DEFAULT NULL::character varying, p_direction character varying DEFAULT NULL::character varying, p_name character varying DEFAULT NULL::character varying, p_internal_order character varying DEFAULT NULL::character varying, p_vsp_id integer DEFAULT NULL::integer, p_project_id integer DEFAULT NULL::integer, p_justification character varying DEFAULT NULL::character varying, p_contract_number character varying DEFAULT NULL::character varying, p_contract_end_date date DEFAULT NULL::date)
RETURNS TABLE(row_type character varying, depth integer, sort_order bigint, data jsonb)
LANGUAGE plpgsql
AS $function$
DECLARE
v_form_type VARCHAR;
v_eid INT;
v_sheet VARCHAR;
v_auto_dir VARCHAR;
v_final_dir VARCHAR;
v_new_id INT;
v_cnt INT;
v_sat_table TEXT;
v_sat_eid INT;
v_parent_line INT;
v_decision JSONB;
v_msg TEXT;
v_user_id INT;
v_line_name VARCHAR;
BEGIN
SELECT form_type_code INTO v_form_type FROM v3.budget_form WHERE id = p_form_id;
IF v_form_type IS NULL THEN
RAISE EXCEPTION 'budget_form id=% не существует', p_form_id;
END IF;
IF v_form_type = 'FORM_3' THEN
RAISE EXCEPTION 'FORM_3 не использует budget_line — см. add_form3_line (TBD)';
END IF;
v_user_id := v3.current_user_id();
v_decision := v3.can_edit(p_form_id, p_sheet, p_direction, NULL, v_user_id);
IF NOT (v_decision->>'ok')::boolean THEN
IF v_decision->>'code' = 'role_not_allowed' THEN
RAISE EXCEPTION 'role_not_allowed: Невозможно вставить строку';
ELSIF v_decision->>'code' = 'org_not_assigned' THEN
RAISE EXCEPTION 'org_not_assigned: Невозможно вставить строку';
ELSIF v_decision->>'code' = 'window_closed' THEN
IF v_decision ? 'opens_at' THEN
v_msg := format('window_closed: opens %s', v_decision->>'opens_at');
ELSIF v_decision->>'closes_at' IS NOT NULL THEN
v_msg := format('window_closed: closed %s', v_decision->>'closes_at');
ELSE
v_msg := 'window_closed: no phase';
END IF;
RAISE EXCEPTION '%', v_msg;
ELSE
RAISE EXCEPTION '%', v_decision->>'code';
END IF;
END IF;
IF p_sheet IN ('AHR_RENT','AHR_UTILITY','AHR_SECURITY') THEN
v_sat_table := CASE p_sheet
WHEN 'AHR_RENT' THEN 'rent_detail'
WHEN 'AHR_UTILITY' THEN 'utility_detail'
WHEN 'AHR_SECURITY' THEN 'security_detail'
END;
v_sat_eid := CASE p_sheet
WHEN 'AHR_RENT' THEN 314
WHEN 'AHR_UTILITY' THEN 24
WHEN 'AHR_SECURITY' THEN 779
END;
IF p_vsp_id IS NULL THEN
RAISE EXCEPTION 'p_vsp_id обязателен для сателлитного листа %', p_sheet;
END IF;
IF NOT EXISTS (SELECT 1 FROM v3.vsp WHERE id = p_vsp_id) THEN
RAISE EXCEPTION 'vsp id=% не существует', p_vsp_id;
END IF;
-- AURORA-1909: раньше здесь искалась и переиспользовалась любая
-- существующая строка статьи (в т.ч. ручная — см. комментарий в начале
-- файла). Каждая новая запись сателлита получает свою строку-заготовку;
-- sync_form2_ahr_satellite_line(s) заберёт её как строку именно этой
-- записи, т.к. её ещё нет в v3.form2_ahr_satellite_line_source.
INSERT INTO v3.budget_line (budget_form_id, expense_item_id, name, direction)
VALUES (
p_form_id, v_sat_eid, NULL,
CASE v_form_type WHEN 'FORM_2' THEN 'Support' WHEN 'FORM_4' THEN 'Development' ELSE NULL END
) RETURNING id INTO v_parent_line;
EXECUTE format(
'INSERT INTO v3.%I (line_id, vsp_id, contract_number, contract_end_date) '
|| 'VALUES ($1, $2, $3, $4) RETURNING id',
v_sat_table
) USING v_parent_line, p_vsp_id, p_contract_number, p_contract_end_date
INTO v_new_id;
PERFORM v3.sync_form2_ahr_satellite_line(p_form_id, p_sheet, v_new_id);
RETURN QUERY
SELECT v.row_type, v.depth, v.sort_order, v.data
FROM v3.v_form_view(p_form_id, p_sheet, NULL, NULL) v
WHERE v.row_type = 'INPUT' AND (v.data->>'id')::INT = v_new_id;
RETURN;
END IF;
IF p_expense_item_id IS NOT NULL THEN
SELECT ei.id, ei.sheet INTO v_eid, v_sheet
FROM v3.expense_item ei WHERE ei.id = p_expense_item_id;
IF v_eid IS NULL THEN
RAISE EXCEPTION 'expense_item id=% не существует', p_expense_item_id;
END IF;
IF NOT EXISTS (
SELECT 1 FROM v3.expense_item_form_type
WHERE expense_item_id = v_eid AND form_type_code = v_form_type
) THEN
RAISE EXCEPTION 'expense_item id=% не привязан к form_type %', v_eid, v_form_type;
END IF;
ELSE
IF p_sheet IS NULL OR p_item_id IS NULL THEN
RAISE EXCEPTION 'нужен либо p_expense_item_id, либо (p_sheet + p_item_id)';
END IF;
SELECT count(*) INTO v_cnt
FROM v3.expense_item ei
JOIN v3.expense_item_form_type eift ON eift.expense_item_id = ei.id
WHERE ei.item_id = p_item_id
AND ei.sheet = p_sheet
AND eift.form_type_code = v_form_type
AND (p_section_code IS NULL OR ei.section_code = p_section_code)
AND (p_direction IS NULL OR ei.direction IS NULL OR ei.direction = p_direction);
IF v_cnt = 0 THEN
RAISE EXCEPTION 'expense_item не найден: sheet=%, item_id=%, section=%, direction=%, form=%',
p_sheet, p_item_id, p_section_code, p_direction, v_form_type;
END IF;
IF v_cnt > 1 THEN
RAISE EXCEPTION 'expense_item не уникален (% совпадений): sheet=%, item_id=%, section=%, direction=%. Уточните p_section_code или передайте p_expense_item_id напрямую',
v_cnt, p_sheet, p_item_id, p_section_code, p_direction;
END IF;
SELECT ei.id, ei.sheet INTO v_eid, v_sheet
FROM v3.expense_item ei
JOIN v3.expense_item_form_type eift ON eift.expense_item_id = ei.id
WHERE ei.item_id = p_item_id
AND ei.sheet = p_sheet
AND eift.form_type_code = v_form_type
AND (p_section_code IS NULL OR ei.section_code = p_section_code)
AND (p_direction IS NULL OR ei.direction IS NULL OR ei.direction = p_direction);
END IF;
v_auto_dir := CASE
WHEN v_form_type = 'FORM_2' THEN 'Support'
WHEN v_form_type = 'FORM_4' THEN 'Development'
WHEN v_form_type = 'FORM_1' AND v_sheet = 'OPER' THEN NULL
ELSE NULL
END;
IF v_form_type = 'FORM_1' AND v_sheet IN ('AHR','CAP') AND p_direction IS NULL THEN
RAISE EXCEPTION 'p_direction обязателен для FORM_1 / sheet=% (Support|Development)', v_sheet;
END IF;
IF v_form_type IN ('FORM_2','FORM_4') AND p_direction IS NOT NULL AND p_direction <> v_auto_dir THEN
RAISE EXCEPTION 'p_direction=% несовместим с form_type=% (фиксирован=%)',
p_direction, v_form_type, v_auto_dir;
END IF;
IF v_form_type = 'FORM_1' AND v_sheet = 'OPER' AND p_direction IS NOT NULL THEN
RAISE EXCEPTION 'p_direction должен быть NULL для FORM_1 / sheet=OPER (получено: %)', p_direction;
END IF;
v_final_dir := COALESCE(p_direction, v_auto_dir);
IF p_vsp_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM v3.vsp WHERE id = p_vsp_id) THEN
RAISE EXCEPTION 'vsp id=% не существует', p_vsp_id;
END IF;
IF p_project_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM v3.form4_project WHERE id = p_project_id) THEN
RAISE EXCEPTION 'form4_project id=% не существует', p_project_id;
END IF;
v_line_name := CASE WHEN v_form_type = 'FORM_4' THEN NULL ELSE '' END;
INSERT INTO v3.budget_line (
budget_form_id, expense_item_id, name, internal_order,
vsp_id, project_id, direction, justification
) VALUES (
p_form_id, v_eid, v_line_name, p_internal_order,
p_vsp_id, p_project_id, v_final_dir, p_justification
) RETURNING id INTO v_new_id;
PERFORM v3.log_event(
'ROW_CREATE', 'ROW',
jsonb_build_object(
'sheet', v_sheet,
'line_id', v_new_id,
'expense_item_id', v_eid,
'direction', v_final_dir,
'name', v_line_name,
'vsp_id', p_vsp_id,
'project_id', p_project_id
),
p_form_id
);
DECLARE v_ei_path INT[];
BEGIN
v_ei_path := v3._expense_item_path_set(ARRAY[v_eid]);
RETURN QUERY
SELECT v.row_type, v.depth, v.sort_order, v.data
FROM v3.v_form_view(p_form_id, v_sheet, NULL, v_final_dir) v
WHERE (v.row_type IN ('ROOT','GROUP','ITEM','SUB_ITEM')
AND (v.data->'header'->>'expense_item_id')::INT = ANY(v_ei_path))
OR (v.row_type = 'INPUT' AND (v.data->>'line_id')::INT = v_new_id)
ORDER BY v.sort_order;
END;
END;
$function$;
-- AURORA-1909: мёртвый код (0031) — _ensure_form2_ahr_sync_line вызывается
-- только из _upsert_form2_ahr_sync_values, а саму _upsert_form2_ahr_sync_values
-- нигде не вызывают (актуальный путь — ...detail_sync_line/...detail_sync_values).
DROP FUNCTION IF EXISTS v3._upsert_form2_ahr_sync_values(INT, INT, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC);
DROP FUNCTION IF EXISTS v3._ensure_form2_ahr_sync_line(INT, INT);

View File

@ -0,0 +1,163 @@
"""AURORA-1909: добавление записи на сателлите АХР (аренда/коммуналка/охрана)
не должно захватывать существующую — в т.ч. ручную — строку статьи 314/24/779.
До фикса v3.add_budget_line (api/alembic/versions/sql/0051_form2_ahr_satellite_new_line.sql)
искала ЛЮБУЮ существующую строку статьи на листе-источнике (без ORDER BY,
без исключения ручных и уже привязанных строк) и переиспользовала её как
"заготовку" для новой записи сателлита. Если у статьи ещё не было ни одной
строки в v3.form2_ahr_satellite_line_source (типично — первая запись сателлита
при наличии ручной строки той же статьи), sync (_ensure_form2_ahr_detail_sync_line,
0031) переименовывал и перезаписывал план/факт чужой строки — в т.ч. ручной,
созданной исполнителем на листе AHR.
Сессия тестов работает в rollback-транзакции (см. tests/integration/conftest.py
client fixture), поэтому изменения не переживают отдельный тест — на каждый
тест создаётся собственная форма FORM_2 через POST /api/v1/form/.
"""
import pytest
# статья листа-источника (AHR) для каждого сателлита — зашита в v3.add_budget_line
# (AURORA-1887, здесь не трогается).
SATELLITE_PARENT_EXPENSE_ITEM = {
"AHR_RENT": 314,
"AHR_UTILITY": 24,
"AHR_SECURITY": 779,
}
def _create_form2(client, admin_tokens, auth_headers, year: int) -> int:
response = client.post(
"/api/v1/form/",
json={"year": year, "form_type_code": "FORM_2", "org_unit_ids": [1]},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200, response.text
return response.json()["result"][0]["id"]
def _add_manual_ahr_line(client, admin_tokens, auth_headers, form_id, expense_item_id) -> int:
response = client.post(
f"/api/v1/form/{form_id}/sheet/AHR/line",
json={"expense_item_id": expense_item_id},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200, response.text
input_rows = [r for r in response.json()["result"] if r["row_type"] == "INPUT"]
assert len(input_rows) == 1, input_rows
return input_rows[0]["data"]["line_id"]
def _patch_ahr_cell(client, admin_tokens, auth_headers, form_id, line_id, column, value):
response = client.patch(
f"/api/v1/form/{form_id}/sheet/AHR/cell",
json={"line_id": line_id, "column": column, "value": value},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200, response.text
def _add_satellite_line(client, admin_tokens, auth_headers, form_id, sheet, vsp_id) -> dict:
response = client.post(
f"/api/v1/form/{form_id}/sheet/{sheet}/line",
json={"vsp_id": vsp_id},
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200, response.text
rows = response.json()["result"]
assert len(rows) == 1, rows
return rows[0]["data"]
def _get_ahr_rows(client, admin_tokens, auth_headers, form_id) -> list[dict]:
response = client.get(
f"/api/v1/form/{form_id}/sheet/AHR",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200, response.text
return response.json()["result"]
def _find_ahr_row_by_line_id(rows: list[dict], line_id: int) -> dict:
for row in rows:
if row["row_type"] == "INPUT" and row["data"].get("line_id") == line_id:
return row
raise AssertionError(f"line_id={line_id} не найден на листе AHR: {rows}")
@pytest.mark.parametrize("sheet", sorted(SATELLITE_PARENT_EXPENSE_ITEM))
def test_satellite_line_add_does_not_capture_existing_manual_line(
client, admin_tokens, auth_headers, sheet
):
"""Основной сценарий бага: у статьи уже есть ручная строка (и больше ни
одной), затем на лист-сателлит добавляется запись. Ручная строка не
должна быть переименована/перезаписана/привязана — запись должна
получить свою собственную строку АХР."""
expense_item_id = SATELLITE_PARENT_EXPENSE_ITEM[sheet]
form_id = _create_form2(client, admin_tokens, auth_headers, year=2030)
manual_line_id = _add_manual_ahr_line(
client, admin_tokens, auth_headers, form_id, expense_item_id
)
_patch_ahr_cell(
client, admin_tokens, auth_headers, form_id, manual_line_id, "plan.q1", 500
)
_patch_ahr_cell(
client, admin_tokens, auth_headers, form_id, manual_line_id, "header.name", "моя строка"
)
satellite_data = _add_satellite_line(
client, admin_tokens, auth_headers, form_id, sheet, vsp_id=2
)
satellite_line_id = satellite_data["line_id"]
# запись сателлита получила свою строку, а не строку ручной записи
assert satellite_line_id != manual_line_id
ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id)
# ручная строка не тронута: тот же line_id, то же имя, тот же план
manual_row = _find_ahr_row_by_line_id(ahr_rows, manual_line_id)
assert manual_row["data"]["header"]["name"] == "моя строка"
assert manual_row["data"]["plan"]["q1"] == 500
# у записи сателлита — отдельная строка на листе AHR (строка синка)
satellite_row = _find_ahr_row_by_line_id(ahr_rows, satellite_line_id)
assert satellite_row["data"]["line_id"] == satellite_line_id
@pytest.mark.parametrize("sheet", sorted(SATELLITE_PARENT_EXPENSE_ITEM))
def test_manual_line_stays_deletable_after_satellite_add(
client, admin_tokens, auth_headers, sheet
):
"""Ручная строка остаётся удаляемой (не заблокирована
is_form2_ahr_satellite_line, т.к. в form2_ahr_satellite_line_source её нет)."""
expense_item_id = SATELLITE_PARENT_EXPENSE_ITEM[sheet]
form_id = _create_form2(client, admin_tokens, auth_headers, year=2031)
manual_line_id = _add_manual_ahr_line(
client, admin_tokens, auth_headers, form_id, expense_item_id
)
_add_satellite_line(client, admin_tokens, auth_headers, form_id, sheet, vsp_id=2)
response = client.delete(
f"/api/v1/form/{form_id}/sheet/AHR/line/{manual_line_id}",
headers=auth_headers(admin_tokens),
)
assert response.status_code == 200, response.text
@pytest.mark.parametrize("sheet", sorted(SATELLITE_PARENT_EXPENSE_ITEM))
def test_two_satellite_records_on_empty_item_get_two_lines(
client, admin_tokens, auth_headers, sheet
):
"""Две записи подряд на пустой статье (без ручной строки) — уже до
AURORA-1887 (сводная строка источника) должны получать по своей строке
АХР каждая; ни одна из них не должна быть ручной (на статье ручных
строк нет вовсе)."""
form_id = _create_form2(client, admin_tokens, auth_headers, year=2032)
first = _add_satellite_line(client, admin_tokens, auth_headers, form_id, sheet, vsp_id=1)
second = _add_satellite_line(client, admin_tokens, auth_headers, form_id, sheet, vsp_id=2)
assert first["line_id"] != second["line_id"]