AURORA-1900: единые подписи «тыс. руб.» и НДС на экране и в выгрузке #291
15
api/alembic/versions/0050_form2_ahr_spod_column.py
Normal file
15
api/alembic/versions/0050_form2_ahr_spod_column.py
Normal file
@ -0,0 +1,15 @@
|
||||
from sql_migration import run_sql_file_migration
|
||||
|
||||
|
||||
revision = "0050"
|
||||
down_revision = "0049"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
run_sql_file_migration("0050_form2_ahr_spod_column.sql")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
15
api/alembic/versions/0051_form2_ahr_satellite_new_line.py
Normal file
15
api/alembic/versions/0051_form2_ahr_satellite_new_line.py
Normal 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
|
||||
@ -0,0 +1,15 @@
|
||||
from sql_migration import run_sql_file_migration
|
||||
|
||||
|
||||
revision = "0052"
|
||||
down_revision = "0051"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
run_sql_file_migration("0052_form2_ahr_limit_period_vat_units.sql")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
15
api/alembic/versions/0053_form2_ahr_source_sheet.py
Normal file
15
api/alembic/versions/0053_form2_ahr_source_sheet.py
Normal file
@ -0,0 +1,15 @@
|
||||
from sql_migration import run_sql_file_migration
|
||||
|
||||
|
||||
revision = "0053"
|
||||
down_revision = "0052"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
run_sql_file_migration("0053_form2_ahr_source_sheet.sql")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
1628
api/alembic/versions/sql/0050_form2_ahr_spod_column.sql
Normal file
1628
api/alembic/versions/sql/0050_form2_ahr_spod_column.sql
Normal file
File diff suppressed because it is too large
Load Diff
228
api/alembic/versions/sql/0051_form2_ahr_satellite_new_line.sql
Normal file
228
api/alembic/versions/sql/0051_form2_ahr_satellite_new_line.sql
Normal 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);
|
||||
@ -0,0 +1,914 @@
|
||||
-- AURORA-1897: лист «АХР лимиты» формы 2.
|
||||
-- 1) годовые нормативы (ДМС/оргтехника) делятся на 4 квартала, а не
|
||||
-- дублируются в каждом квартале;
|
||||
-- 2) сумма на листе лимитов и в плане АХР считается от лимита БЕЗ НДС;
|
||||
-- 3) план лимитных строк в АХР переводится в тыс. руб. (как аренда/
|
||||
-- коммуналка/охрана/Смета), а не остаётся в рублях.
|
||||
--
|
||||
-- CREATE OR REPLACE для v3.v_form2_view, v3.sync_form2_ahr_satellite_lines и
|
||||
-- v3.sync_form2_ahr_satellite_line ниже воспроизводят тело функций, актуальное
|
||||
-- на голове миграций этой ветки (0051): сами функции последний раз целиком
|
||||
-- переопределены в 0050_form2_ahr_spod_column.sql (ради колонки СПОД),
|
||||
-- 0051_form2_ahr_satellite_new_line.sql их не трогает (проверено grep —
|
||||
-- он редактирует только v3.add_budget_line). Правится только ветка
|
||||
-- AHR_LIMIT; ветки AHR/CAP/OPER/AHR_RENT/AHR_UTILITY/AHR_SECURITY/OTCH9F/SMETA
|
||||
-- и переносы аренды/коммуналки/охраны копируются из 0050 без изменений.
|
||||
|
||||
|
||||
-- 1. Справочник: period — YEAR / QUARTER / MONTH, по умолчанию QUARTER.
|
||||
ALTER TABLE v3.limit_template
|
||||
ADD COLUMN IF NOT EXISTS period varchar NOT NULL DEFAULT 'QUARTER';
|
||||
|
||||
ALTER TABLE v3.limit_template
|
||||
ADD CONSTRAINT ck_v3_limit_template_period CHECK (period IN ('YEAR','QUARTER','MONTH'));
|
||||
|
||||
-- Backfill по тому же правилу, что раньше зашивал фактор прямо в формулу.
|
||||
UPDATE v3.limit_template SET period = 'YEAR' WHERE unit LIKE '%/год';
|
||||
UPDATE v3.limit_template SET period = 'MONTH' WHERE unit LIKE '%/месяц';
|
||||
|
||||
|
||||
-- 2. Точечные правки справочника (см. разбор в тикете AURORA-1897).
|
||||
DO $do$
|
||||
BEGIN
|
||||
UPDATE v3.limit_template
|
||||
SET unit = '1 чел/год', period = 'YEAR'
|
||||
WHERE id = 24 AND name = '- стандарт РФ';
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'limit_template.id=24 (- стандарт РФ) не найден';
|
||||
END IF;
|
||||
|
||||
UPDATE v3.limit_template
|
||||
SET unit = '1 чел/год', period = 'YEAR'
|
||||
WHERE id = 25 AND name = '- стандарт 1 РФ';
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'limit_template.id=25 (- стандарт 1 РФ) не найден';
|
||||
END IF;
|
||||
|
||||
UPDATE v3.limit_template
|
||||
SET unit = '1 шт/год', period = 'YEAR',
|
||||
limit_with_vat = 6213, limit_without_vat = 5177.5
|
||||
WHERE id = 13 AND name = '- принтеры';
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'limit_template.id=13 (- принтеры) не найден';
|
||||
END IF;
|
||||
|
||||
UPDATE v3.limit_template
|
||||
SET unit = '1 шт/год', period = 'YEAR',
|
||||
limit_with_vat = 6213, limit_without_vat = 5177.5
|
||||
WHERE id = 14 AND name = '- МФУ';
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'limit_template.id=14 (- МФУ) не найден';
|
||||
END IF;
|
||||
|
||||
UPDATE v3.limit_template
|
||||
SET unit = '1 шт/год', period = 'YEAR',
|
||||
limit_with_vat = 6213, limit_without_vat = 5177.5
|
||||
WHERE id = 15 AND name = '- копировальные аппараты';
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'limit_template.id=15 (- копировальные аппараты) не найден';
|
||||
END IF;
|
||||
|
||||
UPDATE v3.limit_template
|
||||
SET unit = '1 чел/квартал'
|
||||
WHERE id = 21 AND name = '- Канцелярские принадлежности';
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'limit_template.id=21 (- Канцелярские принадлежности) не найден';
|
||||
END IF;
|
||||
|
||||
UPDATE v3.limit_template
|
||||
SET limit_without_vat = 375
|
||||
WHERE id = 7 AND name = '- SIM-карта для планшета (легкий фронт)';
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'limit_template.id=7 (SIM-карта для планшета) не найден';
|
||||
END IF;
|
||||
|
||||
UPDATE v3.limit_template
|
||||
SET limit_without_vat = 375
|
||||
WHERE id = 8 AND name = '- SIM-карта для уполномоченного работника';
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'limit_template.id=8 (SIM-карта для уполномоченного работника) не найден';
|
||||
END IF;
|
||||
END;
|
||||
$do$;
|
||||
|
||||
|
||||
-- 3. Новая формула: рубли без НДС, период вместо regexp по названию unit.
|
||||
CREATE OR REPLACE FUNCTION v3._form2_ahr_limit_amount(
|
||||
p_period character varying,
|
||||
p_limit numeric,
|
||||
p_qty numeric
|
||||
)
|
||||
RETURNS numeric
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
AS $function$
|
||||
SELECT COALESCE(p_qty, 0) * COALESCE(p_limit, 0) *
|
||||
CASE p_period
|
||||
WHEN 'YEAR' THEN 0.25
|
||||
WHEN 'MONTH' THEN 3
|
||||
ELSE 1
|
||||
END;
|
||||
$function$;
|
||||
|
||||
|
||||
-- 4а. v3.v_form2_view: ветка AHR_LIMIT — amount от limit_without_vat,
|
||||
-- amount_year, period в data, последняя строка «Итого» (row_type ROOT).
|
||||
CREATE OR REPLACE FUNCTION v3.v_form2_view(
|
||||
p_form_id INT,
|
||||
p_sheet VARCHAR,
|
||||
p_sections TEXT[] DEFAULT NULL
|
||||
)
|
||||
RETURNS TABLE (
|
||||
row_type VARCHAR,
|
||||
depth INT,
|
||||
sort_order BIGINT,
|
||||
data JSONB
|
||||
)
|
||||
AS $function$
|
||||
BEGIN
|
||||
|
||||
|
||||
IF p_sheet IN ('AHR','CAP','OPER') THEN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
j.row_type,
|
||||
j.depth,
|
||||
ROW_NUMBER() OVER (ORDER BY j._sort_path) AS sort_order,
|
||||
jsonb_build_object(
|
||||
'line_id', j.line_id,
|
||||
'header', j.header,
|
||||
'plan', j.plan_data,
|
||||
'seq_dfip', j.seq_dfip_data,
|
||||
'seq_ssp', j.seq_ssp_data,
|
||||
'approved', j.approved_data,
|
||||
'contract', j.contract_data,
|
||||
'booking', j.booking_data,
|
||||
'q1', j.q1_data,
|
||||
'q2', j.q2_data,
|
||||
'q3', j.q3_data,
|
||||
'q4', j.q4_data,
|
||||
'totals', j.totals_data
|
||||
) AS data
|
||||
FROM v3.v_form2_sheet_jsonb(p_form_id, p_sheet, p_sections) j
|
||||
ORDER BY j._sort_path;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
|
||||
IF p_sheet = 'AHR_LIMIT' THEN
|
||||
RETURN QUERY
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT id AS root_id, id AS desc_id FROM v3.limit_template
|
||||
UNION
|
||||
SELECT t.root_id, child.id
|
||||
FROM tree t
|
||||
JOIN v3.limit_template child ON child.parent_id = t.desc_id
|
||||
),
|
||||
leaf_amt AS (
|
||||
|
||||
SELECT lt.id,
|
||||
fl.qty_q1, fl.qty_q2, fl.qty_q3, fl.qty_q4,
|
||||
v3._form2_ahr_limit_amount(lt.period, lt.limit_without_vat, fl.qty_q1) AS amount_q1,
|
||||
v3._form2_ahr_limit_amount(lt.period, lt.limit_without_vat, fl.qty_q2) AS amount_q2,
|
||||
v3._form2_ahr_limit_amount(lt.period, lt.limit_without_vat, fl.qty_q3) AS amount_q3,
|
||||
v3._form2_ahr_limit_amount(lt.period, lt.limit_without_vat, fl.qty_q4) AS amount_q4
|
||||
FROM v3.limit_template lt
|
||||
LEFT JOIN v3.form_limit fl
|
||||
ON fl.template_id = lt.id AND fl.budget_form_id = p_form_id
|
||||
WHERE lt.row_type = 'LEAF'
|
||||
),
|
||||
leaf_amt_year AS (
|
||||
SELECT la.*,
|
||||
(COALESCE(la.amount_q1,0)+COALESCE(la.amount_q2,0)
|
||||
+COALESCE(la.amount_q3,0)+COALESCE(la.amount_q4,0)) AS amount_year
|
||||
FROM leaf_amt la
|
||||
),
|
||||
agg AS (
|
||||
SELECT t.root_id,
|
||||
SUM(la.qty_q1) AS qty_q1, SUM(la.amount_q1) AS amount_q1,
|
||||
SUM(la.qty_q2) AS qty_q2, SUM(la.amount_q2) AS amount_q2,
|
||||
SUM(la.qty_q3) AS qty_q3, SUM(la.amount_q3) AS amount_q3,
|
||||
SUM(la.qty_q4) AS qty_q4, SUM(la.amount_q4) AS amount_q4,
|
||||
SUM(la.amount_year) AS amount_year
|
||||
FROM tree t
|
||||
JOIN leaf_amt_year la ON la.id = t.desc_id
|
||||
GROUP BY t.root_id
|
||||
),
|
||||
rows AS (
|
||||
SELECT
|
||||
|
||||
CASE lt.row_type WHEN 'SECTION' THEN 'ROOT'
|
||||
WHEN 'GROUP' THEN 'GROUP'
|
||||
WHEN 'LEAF' THEN 'INPUT' END::VARCHAR AS row_type,
|
||||
CASE lt.row_type WHEN 'SECTION' THEN 0 WHEN 'GROUP' THEN 1 ELSE 2 END AS depth,
|
||||
lt.sort_order::BIGINT AS sort_order,
|
||||
jsonb_build_object(
|
||||
'id', lt.id,
|
||||
'section_no', lt.section_no,
|
||||
'expense_item_code',lt.expense_item_code,
|
||||
'name', lt.name,
|
||||
'unit', lt.unit,
|
||||
'period', lt.period,
|
||||
'limit_with_vat', lt.limit_with_vat,
|
||||
'limit_without_vat',lt.limit_without_vat,
|
||||
'qty_q1', a.qty_q1, 'amount_q1', a.amount_q1,
|
||||
'qty_q2', a.qty_q2, 'amount_q2', a.amount_q2,
|
||||
'qty_q3', a.qty_q3, 'amount_q3', a.amount_q3,
|
||||
'qty_q4', a.qty_q4, 'amount_q4', a.amount_q4,
|
||||
'amount_year', a.amount_year,
|
||||
'comment', fl.comment
|
||||
) AS data
|
||||
FROM v3.limit_template lt
|
||||
LEFT JOIN agg a ON a.root_id = lt.id
|
||||
LEFT JOIN v3.form_limit fl
|
||||
ON fl.template_id = lt.id AND fl.budget_form_id = p_form_id
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
'ROOT'::VARCHAR AS row_type,
|
||||
0 AS depth,
|
||||
999999::BIGINT AS sort_order,
|
||||
jsonb_build_object(
|
||||
'id', 0,
|
||||
'name', 'Итого',
|
||||
'amount_q1', totals.amount_q1,
|
||||
'amount_q2', totals.amount_q2,
|
||||
'amount_q3', totals.amount_q3,
|
||||
'amount_q4', totals.amount_q4,
|
||||
'amount_year', totals.amount_year
|
||||
) AS data
|
||||
FROM (
|
||||
SELECT
|
||||
SUM(la.amount_q1) AS amount_q1,
|
||||
SUM(la.amount_q2) AS amount_q2,
|
||||
SUM(la.amount_q3) AS amount_q3,
|
||||
SUM(la.amount_q4) AS amount_q4,
|
||||
SUM(la.amount_year) AS amount_year
|
||||
FROM leaf_amt_year la
|
||||
) totals
|
||||
)
|
||||
SELECT rows.row_type, rows.depth, rows.sort_order, rows.data
|
||||
FROM rows
|
||||
ORDER BY rows.sort_order;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
|
||||
IF p_sheet = 'AHR_RENT' THEN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
'INPUT'::VARCHAR,
|
||||
0,
|
||||
ROW_NUMBER() OVER (ORDER BY rd.id) AS sort_order,
|
||||
jsonb_build_object(
|
||||
'id', rd.id,
|
||||
'line_id', rd.line_id,
|
||||
'vsp_id', rd.vsp_id,
|
||||
'address', v.address,
|
||||
'object_type', v.format,
|
||||
'rented_area', v.total_area,
|
||||
'contract_number', rd.contract_number,
|
||||
'contract_end_date',rd.contract_end_date,
|
||||
'plan', jsonb_build_object('q1', rd.plan_q1, 'q2', rd.plan_q2, 'q3', rd.plan_q3, 'q4', rd.plan_q4,
|
||||
'year', COALESCE(rd.plan_q1,0)+COALESCE(rd.plan_q2,0)+COALESCE(rd.plan_q3,0)+COALESCE(rd.plan_q4,0)),
|
||||
'fact_q1', jsonb_build_object('jan', rd.actual_jan, 'feb', rd.actual_feb, 'mar', rd.actual_mar,
|
||||
'total', COALESCE(rd.actual_jan,0)+COALESCE(rd.actual_feb,0)+COALESCE(rd.actual_mar,0)),
|
||||
'fact_q2', jsonb_build_object('apr', rd.actual_apr, 'may', rd.actual_may, 'jun', rd.actual_jun,
|
||||
'total', COALESCE(rd.actual_apr,0)+COALESCE(rd.actual_may,0)+COALESCE(rd.actual_jun,0)),
|
||||
'fact_q3', jsonb_build_object('jul', rd.actual_jul, 'aug', rd.actual_aug, 'sep', rd.actual_sep,
|
||||
'total', COALESCE(rd.actual_jul,0)+COALESCE(rd.actual_aug,0)+COALESCE(rd.actual_sep,0)),
|
||||
'fact_q4', jsonb_build_object('oct', rd.actual_oct, 'nov', rd.actual_nov, 'dec', rd.actual_dec, 'spod', rd.actual_spod,
|
||||
'total', COALESCE(rd.actual_oct,0)+COALESCE(rd.actual_nov,0)+COALESCE(rd.actual_dec,0)+COALESCE(rd.actual_spod,0)),
|
||||
'comment', rd.comment
|
||||
)
|
||||
FROM v3.rent_detail rd
|
||||
JOIN v3.budget_line bl ON bl.id = rd.line_id
|
||||
JOIN v3.vsp v ON v.id = rd.vsp_id
|
||||
WHERE bl.budget_form_id = p_form_id
|
||||
ORDER BY rd.id;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
|
||||
IF p_sheet = 'AHR_UTILITY' THEN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
'INPUT'::VARCHAR,
|
||||
0,
|
||||
ROW_NUMBER() OVER (ORDER BY ud.id) AS sort_order,
|
||||
jsonb_build_object(
|
||||
'id', ud.id,
|
||||
'line_id', ud.line_id,
|
||||
'vsp_id', ud.vsp_id,
|
||||
'address', v.address,
|
||||
'object_type', v.format,
|
||||
'object_area', v.total_area,
|
||||
'contract_number', ud.contract_number,
|
||||
'contract_end_date',ud.contract_end_date,
|
||||
'plan', jsonb_build_object('q1', ud.plan_q1, 'q2', ud.plan_q2, 'q3', ud.plan_q3, 'q4', ud.plan_q4,
|
||||
'year', COALESCE(ud.plan_q1,0)+COALESCE(ud.plan_q2,0)+COALESCE(ud.plan_q3,0)+COALESCE(ud.plan_q4,0)),
|
||||
'fact_q1', jsonb_build_object('jan', ud.actual_jan, 'feb', ud.actual_feb, 'mar', ud.actual_mar,
|
||||
'total', COALESCE(ud.actual_jan,0)+COALESCE(ud.actual_feb,0)+COALESCE(ud.actual_mar,0)),
|
||||
'fact_q2', jsonb_build_object('apr', ud.actual_apr, 'may', ud.actual_may, 'jun', ud.actual_jun,
|
||||
'total', COALESCE(ud.actual_apr,0)+COALESCE(ud.actual_may,0)+COALESCE(ud.actual_jun,0)),
|
||||
'fact_q3', jsonb_build_object('jul', ud.actual_jul, 'aug', ud.actual_aug, 'sep', ud.actual_sep,
|
||||
'total', COALESCE(ud.actual_jul,0)+COALESCE(ud.actual_aug,0)+COALESCE(ud.actual_sep,0)),
|
||||
'fact_q4', jsonb_build_object('oct', ud.actual_oct, 'nov', ud.actual_nov, 'dec', ud.actual_dec, 'spod', ud.actual_spod,
|
||||
'total', COALESCE(ud.actual_oct,0)+COALESCE(ud.actual_nov,0)+COALESCE(ud.actual_dec,0)+COALESCE(ud.actual_spod,0)),
|
||||
'comment', ud.comment
|
||||
)
|
||||
FROM v3.utility_detail ud
|
||||
JOIN v3.budget_line bl ON bl.id = ud.line_id
|
||||
JOIN v3.vsp v ON v.id = ud.vsp_id
|
||||
WHERE bl.budget_form_id = p_form_id
|
||||
ORDER BY ud.id;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
|
||||
IF p_sheet = 'AHR_SECURITY' THEN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
'INPUT'::VARCHAR,
|
||||
0,
|
||||
ROW_NUMBER() OVER (ORDER BY sd.id) AS sort_order,
|
||||
jsonb_build_object(
|
||||
'id', sd.id,
|
||||
'line_id', sd.line_id,
|
||||
'vsp_id', sd.vsp_id,
|
||||
'address', v.address,
|
||||
'object_type', v.format,
|
||||
'object_area', v.total_area,
|
||||
'contract_number', sd.contract_number,
|
||||
'contract_end_date',sd.contract_end_date,
|
||||
'plan', jsonb_build_object('q1', sd.plan_q1, 'q2', sd.plan_q2, 'q3', sd.plan_q3, 'q4', sd.plan_q4,
|
||||
'year', COALESCE(sd.plan_q1,0)+COALESCE(sd.plan_q2,0)+COALESCE(sd.plan_q3,0)+COALESCE(sd.plan_q4,0)),
|
||||
'fact_q1', jsonb_build_object('jan', sd.actual_jan, 'feb', sd.actual_feb, 'mar', sd.actual_mar,
|
||||
'total', COALESCE(sd.actual_jan,0)+COALESCE(sd.actual_feb,0)+COALESCE(sd.actual_mar,0)),
|
||||
'fact_q2', jsonb_build_object('apr', sd.actual_apr, 'may', sd.actual_may, 'jun', sd.actual_jun,
|
||||
'total', COALESCE(sd.actual_apr,0)+COALESCE(sd.actual_may,0)+COALESCE(sd.actual_jun,0)),
|
||||
'fact_q3', jsonb_build_object('jul', sd.actual_jul, 'aug', sd.actual_aug, 'sep', sd.actual_sep,
|
||||
'total', COALESCE(sd.actual_jul,0)+COALESCE(sd.actual_aug,0)+COALESCE(sd.actual_sep,0)),
|
||||
'fact_q4', jsonb_build_object('oct', sd.actual_oct, 'nov', sd.actual_nov, 'dec', sd.actual_dec, 'spod', sd.actual_spod,
|
||||
'total', COALESCE(sd.actual_oct,0)+COALESCE(sd.actual_nov,0)+COALESCE(sd.actual_dec,0)+COALESCE(sd.actual_spod,0)),
|
||||
'comment', sd.comment
|
||||
)
|
||||
FROM v3.security_detail sd
|
||||
JOIN v3.budget_line bl ON bl.id = sd.line_id
|
||||
JOIN v3.vsp v ON v.id = sd.vsp_id
|
||||
WHERE bl.budget_form_id = p_form_id
|
||||
ORDER BY sd.id;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
|
||||
IF p_sheet = 'OTCH9F' THEN
|
||||
RETURN QUERY
|
||||
WITH base AS (
|
||||
SELECT far.id, far.budget_form_id, far.expense_item_id,
|
||||
far.equipment_name, far.month,
|
||||
far.acquired_qty_604, far.acquired_amt_604,
|
||||
far.disposed_qty_604, far.disposed_amt_604,
|
||||
far.acquired_qty_60415, far.acquired_amt_60415,
|
||||
far.transferred_qty_60415, far.transferred_amt_60415,
|
||||
far.go_balance_only_amt,
|
||||
|
||||
FIRST_VALUE(COALESCE(far.opening_qty_604,0))
|
||||
OVER w AS base_qty_604,
|
||||
FIRST_VALUE(COALESCE(far.opening_amt_604,0))
|
||||
OVER w AS base_amt_604,
|
||||
FIRST_VALUE(COALESCE(far.opening_qty_60415,0))
|
||||
OVER w AS base_qty_60415,
|
||||
FIRST_VALUE(COALESCE(far.opening_amt_60415,0))
|
||||
OVER w AS base_amt_60415,
|
||||
|
||||
COALESCE(SUM(COALESCE(far.acquired_qty_604,0) - COALESCE(far.disposed_qty_604,0))
|
||||
OVER w_prev, 0) AS cum_dq_604,
|
||||
COALESCE(SUM(COALESCE(far.acquired_amt_604,0) - COALESCE(far.disposed_amt_604,0))
|
||||
OVER w_prev, 0) AS cum_da_604,
|
||||
COALESCE(SUM(COALESCE(far.acquired_qty_60415,0) - COALESCE(far.transferred_qty_60415,0))
|
||||
OVER w_prev, 0) AS cum_dq_60415,
|
||||
COALESCE(SUM(COALESCE(far.acquired_amt_60415,0) - COALESCE(far.transferred_amt_60415,0))
|
||||
OVER w_prev, 0) AS cum_da_60415
|
||||
FROM v3.fixed_asset_report far
|
||||
WHERE far.budget_form_id = p_form_id
|
||||
WINDOW w AS (PARTITION BY far.budget_form_id, far.expense_item_id, far.equipment_name
|
||||
ORDER BY far.month),
|
||||
w_prev AS (PARTITION BY far.budget_form_id, far.expense_item_id, far.equipment_name
|
||||
ORDER BY far.month
|
||||
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING)
|
||||
),
|
||||
eff AS (
|
||||
SELECT b.*,
|
||||
(b.base_qty_604 + b.cum_dq_604) AS opening_qty_604_eff,
|
||||
(b.base_amt_604 + b.cum_da_604) AS opening_amt_604_eff,
|
||||
(b.base_qty_60415 + b.cum_dq_60415) AS opening_qty_60415_eff,
|
||||
(b.base_amt_60415 + b.cum_da_60415) AS opening_amt_60415_eff
|
||||
FROM base b
|
||||
)
|
||||
SELECT
|
||||
'INPUT'::VARCHAR,
|
||||
0,
|
||||
ROW_NUMBER() OVER (ORDER BY eff.expense_item_id, eff.month) AS sort_order,
|
||||
jsonb_build_object(
|
||||
'id', eff.id,
|
||||
'expense_item_id', ei.id,
|
||||
'item_id', ei.item_id,
|
||||
'section_code', ei.section_code,
|
||||
'equipment_name', eff.equipment_name,
|
||||
'month', eff.month,
|
||||
'b_604', jsonb_build_object(
|
||||
'opening_qty', eff.opening_qty_604_eff,
|
||||
'opening_amt', eff.opening_amt_604_eff,
|
||||
'acquired_qty', eff.acquired_qty_604,
|
||||
'acquired_amt', eff.acquired_amt_604,
|
||||
'disposed_qty', eff.disposed_qty_604,
|
||||
'disposed_amt', eff.disposed_amt_604
|
||||
),
|
||||
'b_60415', jsonb_build_object(
|
||||
'opening_qty', eff.opening_qty_60415_eff,
|
||||
'opening_amt', eff.opening_amt_60415_eff,
|
||||
'acquired_qty', eff.acquired_qty_60415,
|
||||
'acquired_amt', eff.acquired_amt_60415,
|
||||
'transferred_qty',eff.transferred_qty_60415,
|
||||
'transferred_amt',eff.transferred_amt_60415
|
||||
),
|
||||
'totals', jsonb_build_object(
|
||||
|
||||
'acquired_total', COALESCE(eff.acquired_amt_604,0)+COALESCE(eff.acquired_amt_60415,0)
|
||||
+COALESCE(eff.go_balance_only_amt,0)-COALESCE(eff.transferred_amt_60415,0),
|
||||
'go_balance_only',eff.go_balance_only_amt,
|
||||
|
||||
'disposed_total', COALESCE(eff.disposed_amt_604,0),
|
||||
|
||||
'balance_qty', (eff.opening_qty_604_eff + COALESCE(eff.acquired_qty_604,0) - COALESCE(eff.disposed_qty_604,0))
|
||||
+ (eff.opening_qty_60415_eff + COALESCE(eff.acquired_qty_60415,0) - COALESCE(eff.transferred_qty_60415,0)),
|
||||
'balance_amt', (eff.opening_amt_604_eff + COALESCE(eff.acquired_amt_604,0) - COALESCE(eff.disposed_amt_604,0))
|
||||
+ (eff.opening_amt_60415_eff + COALESCE(eff.acquired_amt_60415,0) - COALESCE(eff.transferred_amt_60415,0))
|
||||
)
|
||||
)
|
||||
FROM eff
|
||||
JOIN v3.expense_item ei ON ei.id = eff.expense_item_id
|
||||
ORDER BY eff.expense_item_id, eff.month;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
|
||||
IF p_sheet = 'SMETA' THEN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
sm.row_type,
|
||||
sm.depth,
|
||||
ROW_NUMBER() OVER (ORDER BY sm.section_code) AS sort_order,
|
||||
jsonb_build_object(
|
||||
'section_code', sm.section_code,
|
||||
'name', sm.name,
|
||||
'plan', jsonb_build_object(
|
||||
'support', jsonb_build_object('q1', sm.plan_supp_q1, 'q2', sm.plan_supp_q2, 'q3', sm.plan_supp_q3, 'q4', sm.plan_supp_q4, 'year', sm.plan_supp_year),
|
||||
'development', jsonb_build_object('q1', sm.plan_dev_q1, 'q2', sm.plan_dev_q2, 'q3', sm.plan_dev_q3, 'q4', sm.plan_dev_q4, 'year', sm.plan_dev_year)
|
||||
),
|
||||
'approved', jsonb_build_object(
|
||||
'support', jsonb_build_object('q1', sm.appr_supp_q1, 'q2', sm.appr_supp_q2, 'q3', sm.appr_supp_q3, 'q4', sm.appr_supp_q4, 'year', sm.appr_supp_year),
|
||||
'development', jsonb_build_object('q1', sm.appr_dev_q1, 'q2', sm.appr_dev_q2, 'q3', sm.appr_dev_q3, 'q4', sm.appr_dev_q4, 'year', sm.appr_dev_year)
|
||||
),
|
||||
'fact', jsonb_build_object(
|
||||
'support', jsonb_build_object('q1', sm.fact_supp_q1, 'q2', sm.fact_supp_q2, 'q3', sm.fact_supp_q3, 'q4', sm.fact_supp_q4, 'year', sm.fact_supp_year),
|
||||
'development', jsonb_build_object('q1', sm.fact_dev_q1, 'q2', sm.fact_dev_q2, 'q3', sm.fact_dev_q3, 'q4', sm.fact_dev_q4, 'year', sm.fact_dev_year)
|
||||
),
|
||||
|
||||
'corrected', jsonb_build_object(
|
||||
'support', jsonb_build_object('q2', sm.corr_supp_q2, 'q3', sm.corr_supp_q3, 'q4', sm.corr_supp_q4),
|
||||
'development', jsonb_build_object('q2', sm.corr_dev_q2, 'q3', sm.corr_dev_q3, 'q4', sm.corr_dev_q4)
|
||||
)
|
||||
)
|
||||
FROM v3.v_form2_smeta(p_form_id) sm
|
||||
ORDER BY sm.section_code;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
RAISE EXCEPTION 'Unknown sheet: %', p_sheet
|
||||
USING HINT = 'Use AHR / CAP / OPER / AHR_LIMIT / AHR_RENT / AHR_UTILITY / AHR_SECURITY / OTCH9F / SMETA';
|
||||
|
||||
END;
|
||||
$function$
|
||||
LANGUAGE plpgsql STABLE;
|
||||
|
||||
|
||||
-- 4б. v3.sync_form2_ahr_satellite_lines: лимитная ветка — amount от
|
||||
-- limit_without_vat, деление на 1000 при переносе в план АХР. Ветки
|
||||
-- AHR_RENT/AHR_UTILITY/AHR_SECURITY не трогаются (AURORA-1887/1909).
|
||||
CREATE OR REPLACE FUNCTION v3.sync_form2_ahr_satellite_lines(p_form_id integer)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_form_type varchar;
|
||||
v_line_id integer;
|
||||
v_rec record;
|
||||
BEGIN
|
||||
SELECT bf.form_type_code
|
||||
INTO v_form_type
|
||||
FROM v3.budget_form bf
|
||||
WHERE bf.id = p_form_id;
|
||||
|
||||
IF v_form_type IS DISTINCT FROM 'FORM_2' THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
|
||||
|
||||
FOR v_rec IN
|
||||
WITH limit_amounts AS (
|
||||
SELECT
|
||||
ei.id AS expense_item_id,
|
||||
regexp_replace(lt.name, '^[[:space:]]*-[[:space:]]*', '')::varchar AS line_name,
|
||||
v3._form2_ahr_limit_amount(lt.period, lt.limit_without_vat, fl.qty_q1) / 1000 AS plan_q1,
|
||||
v3._form2_ahr_limit_amount(lt.period, lt.limit_without_vat, fl.qty_q2) / 1000 AS plan_q2,
|
||||
v3._form2_ahr_limit_amount(lt.period, lt.limit_without_vat, fl.qty_q3) / 1000 AS plan_q3,
|
||||
v3._form2_ahr_limit_amount(lt.period, lt.limit_without_vat, fl.qty_q4) / 1000 AS plan_q4
|
||||
FROM v3.limit_template lt
|
||||
LEFT JOIN v3.form_limit fl
|
||||
ON fl.template_id = lt.id
|
||||
AND fl.budget_form_id = p_form_id
|
||||
JOIN v3.expense_item ei
|
||||
ON ei.sheet = 'AHR'
|
||||
AND ei.direction = 'Support'
|
||||
AND (
|
||||
(ei.depth = 3 AND ei.item_id = lt.expense_item_code)
|
||||
OR ei.num_group_id = lt.expense_item_code
|
||||
)
|
||||
JOIN v3.expense_item_form_type eft
|
||||
ON eft.expense_item_id = ei.id
|
||||
AND eft.form_type_code = 'FORM_2'
|
||||
WHERE lt.row_type = 'LEAF'
|
||||
)
|
||||
SELECT * FROM limit_amounts
|
||||
LOOP
|
||||
PERFORM v3._upsert_form2_ahr_limit_sync_values(
|
||||
p_form_id, v_rec.expense_item_id, v_rec.line_name,
|
||||
v_rec.plan_q1, v_rec.plan_q2, v_rec.plan_q3, v_rec.plan_q4
|
||||
);
|
||||
END LOOP;
|
||||
|
||||
|
||||
FOR v_line_id IN
|
||||
SELECT m.target_line_id
|
||||
FROM v3.form2_ahr_satellite_line_source m
|
||||
JOIN v3.budget_line bl ON bl.id = m.target_line_id
|
||||
WHERE m.source_sheet = 'AHR_RENT'
|
||||
AND bl.budget_form_id = p_form_id
|
||||
AND NOT EXISTS (SELECT 1 FROM v3.rent_detail rd WHERE rd.id = m.source_id)
|
||||
LOOP
|
||||
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;
|
||||
END LOOP;
|
||||
|
||||
FOR v_rec IN
|
||||
SELECT
|
||||
rd.id AS source_id,
|
||||
rd.line_id AS existing_line_id,
|
||||
rd.vsp_id,
|
||||
COALESCE(v.format, 'АХР Аренда #' || rd.id)::varchar AS line_name,
|
||||
rd.contract_number::varchar AS internal_order,
|
||||
rd.plan_q1, rd.plan_q2, rd.plan_q3, rd.plan_q4,
|
||||
rd.actual_jan, rd.actual_feb, rd.actual_mar,
|
||||
rd.actual_apr, rd.actual_may, rd.actual_jun,
|
||||
rd.actual_jul, rd.actual_aug, rd.actual_sep,
|
||||
rd.actual_oct, rd.actual_nov, rd.actual_dec, rd.actual_spod
|
||||
FROM v3.rent_detail rd
|
||||
JOIN v3.budget_line bl ON bl.id = rd.line_id
|
||||
LEFT JOIN v3.vsp v ON v.id = rd.vsp_id
|
||||
WHERE bl.budget_form_id = p_form_id
|
||||
ORDER BY rd.id
|
||||
LOOP
|
||||
PERFORM v3._upsert_form2_ahr_detail_sync_values(
|
||||
p_form_id, 314, 'AHR_RENT', v_rec.source_id,
|
||||
v_rec.line_name, v_rec.internal_order, v_rec.vsp_id, v_rec.existing_line_id,
|
||||
v_rec.plan_q1, v_rec.plan_q2, v_rec.plan_q3, v_rec.plan_q4,
|
||||
v_rec.actual_jan, v_rec.actual_feb, v_rec.actual_mar,
|
||||
v_rec.actual_apr, v_rec.actual_may, v_rec.actual_jun,
|
||||
v_rec.actual_jul, v_rec.actual_aug, v_rec.actual_sep,
|
||||
v_rec.actual_oct, v_rec.actual_nov, v_rec.actual_dec, v_rec.actual_spod
|
||||
);
|
||||
|
||||
UPDATE v3.rent_detail rd
|
||||
SET line_id = m.target_line_id
|
||||
FROM v3.form2_ahr_satellite_line_source m
|
||||
WHERE rd.id = v_rec.source_id
|
||||
AND m.source_sheet = 'AHR_RENT'
|
||||
AND m.source_id = rd.id;
|
||||
END LOOP;
|
||||
|
||||
|
||||
FOR v_line_id IN
|
||||
SELECT m.target_line_id
|
||||
FROM v3.form2_ahr_satellite_line_source m
|
||||
JOIN v3.budget_line bl ON bl.id = m.target_line_id
|
||||
WHERE m.source_sheet = 'AHR_UTILITY'
|
||||
AND bl.budget_form_id = p_form_id
|
||||
AND NOT EXISTS (SELECT 1 FROM v3.utility_detail ud WHERE ud.id = m.source_id)
|
||||
LOOP
|
||||
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;
|
||||
END LOOP;
|
||||
|
||||
FOR v_rec IN
|
||||
SELECT
|
||||
ud.id AS source_id,
|
||||
ud.line_id AS existing_line_id,
|
||||
ud.vsp_id,
|
||||
COALESCE(v.format, 'АХР Коммунальные услуги #' || ud.id)::varchar AS line_name,
|
||||
ud.contract_number::varchar AS internal_order,
|
||||
ud.plan_q1, ud.plan_q2, ud.plan_q3, ud.plan_q4,
|
||||
ud.actual_jan, ud.actual_feb, ud.actual_mar,
|
||||
ud.actual_apr, ud.actual_may, ud.actual_jun,
|
||||
ud.actual_jul, ud.actual_aug, ud.actual_sep,
|
||||
ud.actual_oct, ud.actual_nov, ud.actual_dec, ud.actual_spod
|
||||
FROM v3.utility_detail ud
|
||||
JOIN v3.budget_line bl ON bl.id = ud.line_id
|
||||
LEFT JOIN v3.vsp v ON v.id = ud.vsp_id
|
||||
WHERE bl.budget_form_id = p_form_id
|
||||
ORDER BY ud.id
|
||||
LOOP
|
||||
PERFORM v3._upsert_form2_ahr_detail_sync_values(
|
||||
p_form_id, 24, 'AHR_UTILITY', v_rec.source_id,
|
||||
v_rec.line_name, v_rec.internal_order, v_rec.vsp_id, v_rec.existing_line_id,
|
||||
v_rec.plan_q1, v_rec.plan_q2, v_rec.plan_q3, v_rec.plan_q4,
|
||||
v_rec.actual_jan, v_rec.actual_feb, v_rec.actual_mar,
|
||||
v_rec.actual_apr, v_rec.actual_may, v_rec.actual_jun,
|
||||
v_rec.actual_jul, v_rec.actual_aug, v_rec.actual_sep,
|
||||
v_rec.actual_oct, v_rec.actual_nov, v_rec.actual_dec, v_rec.actual_spod
|
||||
);
|
||||
|
||||
UPDATE v3.utility_detail ud
|
||||
SET line_id = m.target_line_id
|
||||
FROM v3.form2_ahr_satellite_line_source m
|
||||
WHERE ud.id = v_rec.source_id
|
||||
AND m.source_sheet = 'AHR_UTILITY'
|
||||
AND m.source_id = ud.id;
|
||||
END LOOP;
|
||||
|
||||
|
||||
FOR v_line_id IN
|
||||
SELECT m.target_line_id
|
||||
FROM v3.form2_ahr_satellite_line_source m
|
||||
JOIN v3.budget_line bl ON bl.id = m.target_line_id
|
||||
WHERE m.source_sheet = 'AHR_SECURITY'
|
||||
AND bl.budget_form_id = p_form_id
|
||||
AND NOT EXISTS (SELECT 1 FROM v3.security_detail sd WHERE sd.id = m.source_id)
|
||||
LOOP
|
||||
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;
|
||||
END LOOP;
|
||||
|
||||
FOR v_rec IN
|
||||
SELECT
|
||||
sd.id AS source_id,
|
||||
sd.line_id AS existing_line_id,
|
||||
sd.vsp_id,
|
||||
COALESCE(v.format, 'АХР Безопасность #' || sd.id)::varchar AS line_name,
|
||||
sd.contract_number::varchar AS internal_order,
|
||||
sd.plan_q1, sd.plan_q2, sd.plan_q3, sd.plan_q4,
|
||||
sd.actual_jan, sd.actual_feb, sd.actual_mar,
|
||||
sd.actual_apr, sd.actual_may, sd.actual_jun,
|
||||
sd.actual_jul, sd.actual_aug, sd.actual_sep,
|
||||
sd.actual_oct, sd.actual_nov, sd.actual_dec, sd.actual_spod
|
||||
FROM v3.security_detail sd
|
||||
JOIN v3.budget_line bl ON bl.id = sd.line_id
|
||||
LEFT JOIN v3.vsp v ON v.id = sd.vsp_id
|
||||
WHERE bl.budget_form_id = p_form_id
|
||||
ORDER BY sd.id
|
||||
LOOP
|
||||
PERFORM v3._upsert_form2_ahr_detail_sync_values(
|
||||
p_form_id, 779, 'AHR_SECURITY', v_rec.source_id,
|
||||
v_rec.line_name, v_rec.internal_order, v_rec.vsp_id, v_rec.existing_line_id,
|
||||
v_rec.plan_q1, v_rec.plan_q2, v_rec.plan_q3, v_rec.plan_q4,
|
||||
v_rec.actual_jan, v_rec.actual_feb, v_rec.actual_mar,
|
||||
v_rec.actual_apr, v_rec.actual_may, v_rec.actual_jun,
|
||||
v_rec.actual_jul, v_rec.actual_aug, v_rec.actual_sep,
|
||||
v_rec.actual_oct, v_rec.actual_nov, v_rec.actual_dec, v_rec.actual_spod
|
||||
);
|
||||
|
||||
UPDATE v3.security_detail sd
|
||||
SET line_id = m.target_line_id
|
||||
FROM v3.form2_ahr_satellite_line_source m
|
||||
WHERE sd.id = v_rec.source_id
|
||||
AND m.source_sheet = 'AHR_SECURITY'
|
||||
AND m.source_id = sd.id;
|
||||
END LOOP;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
|
||||
-- 4в. v3.sync_form2_ahr_satellite_line: тот же набор правок для синка одной строки.
|
||||
CREATE OR REPLACE FUNCTION v3.sync_form2_ahr_satellite_line(p_form_id integer, p_sheet character varying, p_source_id integer)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_rec record;
|
||||
BEGIN
|
||||
IF p_sheet = 'AHR_LIMIT' THEN
|
||||
SELECT
|
||||
ei.id AS expense_item_id,
|
||||
regexp_replace(lt.name, '^[[:space:]]*-[[:space:]]*', '')::varchar AS line_name,
|
||||
v3._form2_ahr_limit_amount(lt.period, lt.limit_without_vat, fl.qty_q1) / 1000 AS plan_q1,
|
||||
v3._form2_ahr_limit_amount(lt.period, lt.limit_without_vat, fl.qty_q2) / 1000 AS plan_q2,
|
||||
v3._form2_ahr_limit_amount(lt.period, lt.limit_without_vat, fl.qty_q3) / 1000 AS plan_q3,
|
||||
v3._form2_ahr_limit_amount(lt.period, lt.limit_without_vat, fl.qty_q4) / 1000 AS plan_q4
|
||||
INTO v_rec
|
||||
FROM v3.limit_template lt
|
||||
LEFT JOIN v3.form_limit fl
|
||||
ON fl.template_id = lt.id
|
||||
AND fl.budget_form_id = p_form_id
|
||||
JOIN v3.expense_item ei
|
||||
ON ei.sheet = 'AHR'
|
||||
AND ei.direction = 'Support'
|
||||
AND (
|
||||
(ei.depth = 3 AND ei.item_id = lt.expense_item_code)
|
||||
OR ei.num_group_id = lt.expense_item_code
|
||||
)
|
||||
JOIN v3.expense_item_form_type eft
|
||||
ON eft.expense_item_id = ei.id
|
||||
AND eft.form_type_code = 'FORM_2'
|
||||
WHERE lt.id = p_source_id
|
||||
AND lt.row_type = 'LEAF';
|
||||
|
||||
IF v_rec.expense_item_id IS NOT NULL THEN
|
||||
PERFORM v3._upsert_form2_ahr_limit_sync_values(
|
||||
p_form_id, v_rec.expense_item_id, v_rec.line_name,
|
||||
v_rec.plan_q1, v_rec.plan_q2, v_rec.plan_q3, v_rec.plan_q4
|
||||
);
|
||||
END IF;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
IF p_sheet = 'AHR_RENT' THEN
|
||||
SELECT
|
||||
rd.id AS source_id,
|
||||
rd.line_id AS existing_line_id,
|
||||
rd.vsp_id,
|
||||
COALESCE(v.format, 'АХР Аренда #' || rd.id)::varchar AS line_name,
|
||||
rd.contract_number::varchar AS internal_order,
|
||||
rd.plan_q1, rd.plan_q2, rd.plan_q3, rd.plan_q4,
|
||||
rd.actual_jan, rd.actual_feb, rd.actual_mar,
|
||||
rd.actual_apr, rd.actual_may, rd.actual_jun,
|
||||
rd.actual_jul, rd.actual_aug, rd.actual_sep,
|
||||
rd.actual_oct, rd.actual_nov, rd.actual_dec, rd.actual_spod
|
||||
INTO v_rec
|
||||
FROM v3.rent_detail rd
|
||||
JOIN v3.budget_line bl ON bl.id = rd.line_id
|
||||
LEFT JOIN v3.vsp v ON v.id = rd.vsp_id
|
||||
WHERE rd.id = p_source_id
|
||||
AND bl.budget_form_id = p_form_id;
|
||||
|
||||
IF v_rec.source_id IS NOT NULL THEN
|
||||
PERFORM v3._upsert_form2_ahr_detail_sync_values(
|
||||
p_form_id, 314, 'AHR_RENT', v_rec.source_id,
|
||||
v_rec.line_name, v_rec.internal_order, v_rec.vsp_id, v_rec.existing_line_id,
|
||||
v_rec.plan_q1, v_rec.plan_q2, v_rec.plan_q3, v_rec.plan_q4,
|
||||
v_rec.actual_jan, v_rec.actual_feb, v_rec.actual_mar,
|
||||
v_rec.actual_apr, v_rec.actual_may, v_rec.actual_jun,
|
||||
v_rec.actual_jul, v_rec.actual_aug, v_rec.actual_sep,
|
||||
v_rec.actual_oct, v_rec.actual_nov, v_rec.actual_dec, v_rec.actual_spod
|
||||
);
|
||||
|
||||
UPDATE v3.rent_detail rd
|
||||
SET line_id = m.target_line_id
|
||||
FROM v3.form2_ahr_satellite_line_source m
|
||||
WHERE rd.id = v_rec.source_id
|
||||
AND m.source_sheet = 'AHR_RENT'
|
||||
AND m.source_id = rd.id;
|
||||
END IF;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
IF p_sheet = 'AHR_UTILITY' THEN
|
||||
SELECT
|
||||
ud.id AS source_id,
|
||||
ud.line_id AS existing_line_id,
|
||||
ud.vsp_id,
|
||||
COALESCE(v.format, 'АХР Коммунальные услуги #' || ud.id)::varchar AS line_name,
|
||||
ud.contract_number::varchar AS internal_order,
|
||||
ud.plan_q1, ud.plan_q2, ud.plan_q3, ud.plan_q4,
|
||||
ud.actual_jan, ud.actual_feb, ud.actual_mar,
|
||||
ud.actual_apr, ud.actual_may, ud.actual_jun,
|
||||
ud.actual_jul, ud.actual_aug, ud.actual_sep,
|
||||
ud.actual_oct, ud.actual_nov, ud.actual_dec, ud.actual_spod
|
||||
INTO v_rec
|
||||
FROM v3.utility_detail ud
|
||||
JOIN v3.budget_line bl ON bl.id = ud.line_id
|
||||
LEFT JOIN v3.vsp v ON v.id = ud.vsp_id
|
||||
WHERE ud.id = p_source_id
|
||||
AND bl.budget_form_id = p_form_id;
|
||||
|
||||
IF v_rec.source_id IS NOT NULL THEN
|
||||
PERFORM v3._upsert_form2_ahr_detail_sync_values(
|
||||
p_form_id, 24, 'AHR_UTILITY', v_rec.source_id,
|
||||
v_rec.line_name, v_rec.internal_order, v_rec.vsp_id, v_rec.existing_line_id,
|
||||
v_rec.plan_q1, v_rec.plan_q2, v_rec.plan_q3, v_rec.plan_q4,
|
||||
v_rec.actual_jan, v_rec.actual_feb, v_rec.actual_mar,
|
||||
v_rec.actual_apr, v_rec.actual_may, v_rec.actual_jun,
|
||||
v_rec.actual_jul, v_rec.actual_aug, v_rec.actual_sep,
|
||||
v_rec.actual_oct, v_rec.actual_nov, v_rec.actual_dec, v_rec.actual_spod
|
||||
);
|
||||
|
||||
UPDATE v3.utility_detail ud
|
||||
SET line_id = m.target_line_id
|
||||
FROM v3.form2_ahr_satellite_line_source m
|
||||
WHERE ud.id = v_rec.source_id
|
||||
AND m.source_sheet = 'AHR_UTILITY'
|
||||
AND m.source_id = ud.id;
|
||||
END IF;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
IF p_sheet = 'AHR_SECURITY' THEN
|
||||
SELECT
|
||||
sd.id AS source_id,
|
||||
sd.line_id AS existing_line_id,
|
||||
sd.vsp_id,
|
||||
COALESCE(v.format, 'АХР Безопасность #' || sd.id)::varchar AS line_name,
|
||||
sd.contract_number::varchar AS internal_order,
|
||||
sd.plan_q1, sd.plan_q2, sd.plan_q3, sd.plan_q4,
|
||||
sd.actual_jan, sd.actual_feb, sd.actual_mar,
|
||||
sd.actual_apr, sd.actual_may, sd.actual_jun,
|
||||
sd.actual_jul, sd.actual_aug, sd.actual_sep,
|
||||
sd.actual_oct, sd.actual_nov, sd.actual_dec, sd.actual_spod
|
||||
INTO v_rec
|
||||
FROM v3.security_detail sd
|
||||
JOIN v3.budget_line bl ON bl.id = sd.line_id
|
||||
LEFT JOIN v3.vsp v ON v.id = sd.vsp_id
|
||||
WHERE sd.id = p_source_id
|
||||
AND bl.budget_form_id = p_form_id;
|
||||
|
||||
IF v_rec.source_id IS NOT NULL THEN
|
||||
PERFORM v3._upsert_form2_ahr_detail_sync_values(
|
||||
p_form_id, 779, 'AHR_SECURITY', v_rec.source_id,
|
||||
v_rec.line_name, v_rec.internal_order, v_rec.vsp_id, v_rec.existing_line_id,
|
||||
v_rec.plan_q1, v_rec.plan_q2, v_rec.plan_q3, v_rec.plan_q4,
|
||||
v_rec.actual_jan, v_rec.actual_feb, v_rec.actual_mar,
|
||||
v_rec.actual_apr, v_rec.actual_may, v_rec.actual_jun,
|
||||
v_rec.actual_jul, v_rec.actual_aug, v_rec.actual_sep,
|
||||
v_rec.actual_oct, v_rec.actual_nov, v_rec.actual_dec, v_rec.actual_spod
|
||||
);
|
||||
|
||||
UPDATE v3.security_detail sd
|
||||
SET line_id = m.target_line_id
|
||||
FROM v3.form2_ahr_satellite_line_source m
|
||||
WHERE sd.id = v_rec.source_id
|
||||
AND m.source_sheet = 'AHR_SECURITY'
|
||||
AND m.source_id = sd.id;
|
||||
END IF;
|
||||
END IF;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
|
||||
-- 5. Старая 4-аргументная сигнатура больше не вызывается — удаляем.
|
||||
DROP FUNCTION v3._form2_ahr_limit_amount(character varying, character varying, numeric, numeric);
|
||||
|
||||
|
||||
-- 6. Строка «Итого» (id=0) должна попадать в path-set, чтобы upd_form_cells
|
||||
-- включал её в diff при любом изменении ячейки на листе AHR_LIMIT.
|
||||
CREATE OR REPLACE FUNCTION v3._limit_template_path_set(p_ids integer[])
|
||||
RETURNS integer[]
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $function$
|
||||
WITH RECURSIVE up AS (
|
||||
SELECT id, parent_id FROM v3.limit_template WHERE id = ANY(p_ids)
|
||||
UNION
|
||||
SELECT p.id, p.parent_id
|
||||
FROM v3.limit_template p
|
||||
JOIN up u ON p.id = u.parent_id
|
||||
),
|
||||
agg AS (
|
||||
SELECT array_agg(DISTINCT id) AS ids FROM up
|
||||
)
|
||||
SELECT CASE
|
||||
WHEN p_ids IS NULL OR array_length(p_ids, 1) IS NULL THEN agg.ids
|
||||
ELSE COALESCE(agg.ids, ARRAY[]::integer[]) || ARRAY[0]
|
||||
END
|
||||
FROM agg;
|
||||
$function$;
|
||||
|
||||
|
||||
-- 7. Пересинхронизировать все существующие формы 2 с новой формулой.
|
||||
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$;
|
||||
825
api/alembic/versions/sql/0053_form2_ahr_source_sheet.sql
Normal file
825
api/alembic/versions/sql/0053_form2_ahr_source_sheet.sql
Normal file
@ -0,0 +1,825 @@
|
||||
-- AURORA-1907: признак источника строки АХР (v3.budget_line.source_sheet) и
|
||||
-- раздельные суммы «в пределах лимита» / «сверх лимита» на листе АХР.
|
||||
--
|
||||
-- Проблема (см. тикет): строка АХР опознаётся как «лимитная» только по
|
||||
-- совпадению name с LEAF limit_template (is_form2_ahr_satellite_line,
|
||||
-- 0031_form2_ahr_satellite_sync.sql:857-902; delete guard,
|
||||
-- 0040_ahr_limit_delete_guard.sql:78-103; get_form2_ahr_satellite_delta,
|
||||
-- 0031:784-855). Ручная строка исполнителя с тем же именем, что и LEAF,
|
||||
-- ошибочно считается лимитной и захватывается синхронизацией лимитов
|
||||
-- (_ensure_form2_ahr_limit_sync_line, 0031:245-273) — тот же класс бага,
|
||||
-- что чинил #265/AURORA-1909 для сателлитов аренды/коммуналки/охраны, теперь
|
||||
-- для лимитов через явный признак источника, а не совпадение имён.
|
||||
--
|
||||
-- CREATE OR REPLACE ниже воспроизводят тела функций, актуальные на голове
|
||||
-- миграций этой ветки (0052): _ensure_form2_ahr_detail_sync_line,
|
||||
-- _ensure_form2_ahr_limit_sync_line, is_form2_ahr_satellite_line,
|
||||
-- get_form2_ahr_satellite_delta — последний раз целиком определены в 0031
|
||||
-- и с тех пор не переопределялись (grep подтверждает: 0050/0051/0052 трогают
|
||||
-- только _upsert_form2_ahr_detail_sync_values, sync_form2_ahr_satellite_lines/
|
||||
-- _line, v_form2_view, add_budget_line, _form2_ahr_limit_amount,
|
||||
-- _limit_template_path_set — не эти пять). del_budget_line последний раз
|
||||
-- целиком определён в 0040_ahr_limit_delete_guard.sql. v_form2_sheet_jsonb
|
||||
-- последний раз целиком определён в 0015_aggregates_fix.sql:2534-2807.
|
||||
|
||||
|
||||
-- 1. Колонка-признак источника строки. NULL — строку добавил пользователь.
|
||||
ALTER TABLE v3.budget_line
|
||||
ADD COLUMN source_sheet varchar NULL;
|
||||
|
||||
ALTER TABLE v3.budget_line
|
||||
ADD CONSTRAINT ck_v3_budget_line_source_sheet
|
||||
CHECK (source_sheet IN ('AHR_LIMIT', 'AHR_RENT', 'AHR_UTILITY', 'AHR_SECURITY'));
|
||||
|
||||
|
||||
-- 2а. Backfill: сателлиты аренды/коммуналки/охраны — однозначно по мэппингу
|
||||
-- v3.form2_ahr_satellite_line_source (source_sheet/source_id -> target_line_id).
|
||||
UPDATE v3.budget_line bl
|
||||
SET source_sheet = m.source_sheet
|
||||
FROM v3.form2_ahr_satellite_line_source m
|
||||
WHERE bl.id = m.target_line_id
|
||||
AND bl.source_sheet IS NULL;
|
||||
|
||||
|
||||
-- 2б. Backfill: лимитные строки. Для каждого сочетания (форма, статья, имя
|
||||
-- LEAF limit_template) строку с минимальным id (именно её обновляет
|
||||
-- _ensure_form2_ahr_limit_sync_line/sync_form2_ahr_satellite_line(s)) помечаем
|
||||
-- 'AHR_LIMIT'. Остальные строки с тем же именем остаются NULL (становятся
|
||||
-- ручными — см. RAISE NOTICE ниже). Ограничено bf.form_type_code='FORM_2':
|
||||
-- тот же expense_item_id может использоваться в FORM_1/FORM_4 АХР, где
|
||||
-- лимитной синхронизации нет вовсе, и совпадение имени там чисто случайное.
|
||||
WITH limit_candidates AS (
|
||||
SELECT
|
||||
bl.id AS line_id,
|
||||
MIN(bl.id) OVER (PARTITION BY bl.budget_form_id, bl.expense_item_id, leaf.leaf_name) AS min_line_id
|
||||
FROM v3.budget_line bl
|
||||
JOIN v3.budget_form bf
|
||||
ON bf.id = bl.budget_form_id
|
||||
AND bf.form_type_code = 'FORM_2'
|
||||
JOIN v3.expense_item ei ON ei.id = bl.expense_item_id
|
||||
JOIN v3.expense_item_form_type eft
|
||||
ON eft.expense_item_id = ei.id
|
||||
AND eft.form_type_code = 'FORM_2'
|
||||
JOIN LATERAL (
|
||||
SELECT regexp_replace(lt.name, '^[[:space:]]*-[[:space:]]*', '') AS leaf_name
|
||||
FROM v3.limit_template lt
|
||||
WHERE lt.row_type = 'LEAF'
|
||||
AND ei.sheet = 'AHR'
|
||||
AND ei.direction = 'Support'
|
||||
AND (
|
||||
(ei.depth = 3 AND ei.item_id = lt.expense_item_code)
|
||||
OR ei.num_group_id = lt.expense_item_code
|
||||
)
|
||||
AND bl.name = regexp_replace(lt.name, '^[[:space:]]*-[[:space:]]*', '')
|
||||
) leaf ON TRUE
|
||||
WHERE bl.source_sheet IS NULL
|
||||
)
|
||||
UPDATE v3.budget_line bl
|
||||
SET source_sheet = 'AHR_LIMIT'
|
||||
FROM limit_candidates lc
|
||||
WHERE bl.id = lc.line_id
|
||||
AND lc.line_id = lc.min_line_id;
|
||||
|
||||
|
||||
-- 2в. Остальные строки, совпавшие по имени с LEAF лимита, но не минимальный
|
||||
-- id по (форма, статья, имя) — остаются ручными (source_sheet NULL).
|
||||
-- До этой миграции они были неотличимы от лимитных по имени; после неё они
|
||||
-- — обычные ручные строки. Выводим список для ручной проверки администратором.
|
||||
DO $do$
|
||||
DECLARE
|
||||
v_rec record;
|
||||
BEGIN
|
||||
FOR v_rec IN
|
||||
SELECT bl.id, bl.budget_form_id, bl.expense_item_id, bl.name
|
||||
FROM v3.budget_line bl
|
||||
JOIN v3.budget_form bf
|
||||
ON bf.id = bl.budget_form_id
|
||||
AND bf.form_type_code = 'FORM_2'
|
||||
JOIN v3.expense_item ei ON ei.id = bl.expense_item_id
|
||||
JOIN v3.expense_item_form_type eft
|
||||
ON eft.expense_item_id = ei.id
|
||||
AND eft.form_type_code = 'FORM_2'
|
||||
JOIN v3.limit_template lt
|
||||
ON lt.row_type = 'LEAF'
|
||||
AND ei.sheet = 'AHR'
|
||||
AND ei.direction = 'Support'
|
||||
AND (
|
||||
(ei.depth = 3 AND ei.item_id = lt.expense_item_code)
|
||||
OR ei.num_group_id = lt.expense_item_code
|
||||
)
|
||||
WHERE bl.source_sheet IS NULL
|
||||
AND bl.name = regexp_replace(lt.name, '^[[:space:]]*-[[:space:]]*', '')
|
||||
GROUP BY bl.id, bl.budget_form_id, bl.expense_item_id, bl.name
|
||||
LOOP
|
||||
RAISE NOTICE 'AURORA-1907 backfill: v3.budget_line id=% (budget_form_id=%, expense_item_id=%, name=%) совпадает по имени с LEAF лимита, но не минимальный id в своей группе — остаётся ручной строкой (source_sheet=NULL)',
|
||||
v_rec.id, v_rec.budget_form_id, v_rec.expense_item_id, v_rec.name;
|
||||
END LOOP;
|
||||
END;
|
||||
$do$;
|
||||
|
||||
|
||||
-- 3. _ensure_form2_ahr_detail_sync_line: ставить source_sheet соответствующего
|
||||
-- листа при создании строки и при привязке к существующей (ветка rebind).
|
||||
-- Тело — актуальное (0031, не менялось в 0050-0052) + source_sheet.
|
||||
CREATE OR REPLACE FUNCTION v3._ensure_form2_ahr_detail_sync_line(
|
||||
p_form_id integer,
|
||||
p_expense_item_id integer,
|
||||
p_source_sheet character varying,
|
||||
p_source_id integer,
|
||||
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_existing_line_id integer DEFAULT NULL::integer
|
||||
)
|
||||
RETURNS integer
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_line_id integer;
|
||||
BEGIN
|
||||
SELECT m.target_line_id
|
||||
INTO v_line_id
|
||||
FROM v3.form2_ahr_satellite_line_source m
|
||||
JOIN v3.budget_line bl ON bl.id = m.target_line_id
|
||||
WHERE m.source_sheet = p_source_sheet
|
||||
AND m.source_id = p_source_id
|
||||
AND bl.budget_form_id = p_form_id;
|
||||
|
||||
IF v_line_id IS NULL THEN
|
||||
SELECT bl.id
|
||||
INTO v_line_id
|
||||
FROM v3.budget_line bl
|
||||
WHERE bl.id = p_existing_line_id
|
||||
AND bl.budget_form_id = p_form_id
|
||||
AND bl.expense_item_id = p_expense_item_id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM v3.form2_ahr_satellite_line_source m
|
||||
WHERE m.target_line_id = bl.id
|
||||
);
|
||||
|
||||
IF v_line_id IS NULL THEN
|
||||
INSERT INTO v3.budget_line (
|
||||
budget_form_id, expense_item_id, name, internal_order, vsp_id, direction, source_sheet
|
||||
)
|
||||
VALUES (
|
||||
p_form_id, p_expense_item_id, p_name, p_internal_order, p_vsp_id, 'Support', p_source_sheet
|
||||
)
|
||||
RETURNING id INTO v_line_id;
|
||||
ELSE
|
||||
UPDATE v3.budget_line
|
||||
SET name = p_name,
|
||||
internal_order = p_internal_order,
|
||||
vsp_id = p_vsp_id,
|
||||
direction = 'Support',
|
||||
source_sheet = p_source_sheet,
|
||||
updated_at = now()
|
||||
WHERE id = v_line_id;
|
||||
END IF;
|
||||
|
||||
INSERT INTO v3.form2_ahr_satellite_line_source (
|
||||
source_sheet, source_id, target_line_id
|
||||
)
|
||||
VALUES (p_source_sheet, p_source_id, v_line_id)
|
||||
ON CONFLICT (source_sheet, source_id) DO UPDATE SET
|
||||
target_line_id = EXCLUDED.target_line_id;
|
||||
ELSE
|
||||
UPDATE v3.budget_line
|
||||
SET expense_item_id = p_expense_item_id,
|
||||
name = p_name,
|
||||
internal_order = p_internal_order,
|
||||
vsp_id = p_vsp_id,
|
||||
direction = 'Support',
|
||||
source_sheet = p_source_sheet,
|
||||
updated_at = now()
|
||||
WHERE id = v_line_id;
|
||||
END IF;
|
||||
|
||||
RETURN v_line_id;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
|
||||
-- 4. _ensure_form2_ahr_limit_sync_line: искать только среди строк с
|
||||
-- source_sheet = 'AHR_LIMIT' (а не по совпадению name) и ставить признак при
|
||||
-- INSERT. Ручная строка с именем LEAF больше не захватывается синком лимитов.
|
||||
-- Имя всё равно участвует в поиске — у одной статьи может быть несколько
|
||||
-- LEAF лимитов (разные строки каталога с одним expense_item_code, см.
|
||||
-- "Мобильная связь"/"- Директор"/"- Заместитель директора"/...), каждая со
|
||||
-- своей строкой АХР; без имени они были бы неотличимы друг от друга.
|
||||
CREATE OR REPLACE FUNCTION v3._ensure_form2_ahr_limit_sync_line(
|
||||
p_form_id integer,
|
||||
p_expense_item_id integer,
|
||||
p_name character varying
|
||||
)
|
||||
RETURNS integer
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
DECLARE
|
||||
v_line_id integer;
|
||||
BEGIN
|
||||
SELECT bl.id
|
||||
INTO v_line_id
|
||||
FROM v3.budget_line bl
|
||||
WHERE bl.budget_form_id = p_form_id
|
||||
AND bl.expense_item_id = p_expense_item_id
|
||||
AND bl.source_sheet = 'AHR_LIMIT'
|
||||
AND bl.name IS NOT DISTINCT FROM p_name
|
||||
ORDER BY bl.id
|
||||
LIMIT 1;
|
||||
|
||||
IF v_line_id IS NULL THEN
|
||||
INSERT INTO v3.budget_line (budget_form_id, expense_item_id, name, direction, source_sheet)
|
||||
VALUES (p_form_id, p_expense_item_id, p_name, 'Support', 'AHR_LIMIT')
|
||||
RETURNING id INTO v_line_id;
|
||||
ELSE
|
||||
UPDATE v3.budget_line
|
||||
SET name = p_name,
|
||||
updated_at = now()
|
||||
WHERE id = v_line_id;
|
||||
END IF;
|
||||
|
||||
RETURN v_line_id;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
|
||||
-- 5. is_form2_ahr_satellite_line: признак источника вместо совпадения имён.
|
||||
CREATE OR REPLACE FUNCTION v3.is_form2_ahr_satellite_line(
|
||||
p_form_id integer,
|
||||
p_line_id integer
|
||||
)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $function$
|
||||
SELECT COALESCE(
|
||||
(SELECT bl.source_sheet IS NOT NULL
|
||||
FROM v3.budget_line bl
|
||||
WHERE bl.id = p_line_id
|
||||
AND bl.budget_form_id = p_form_id),
|
||||
false
|
||||
);
|
||||
$function$;
|
||||
|
||||
|
||||
-- 6. del_budget_line: та же проверка — v_source_sheet IS NOT NULL вместо
|
||||
-- двух EXISTS (мэппинг сателлитов + совпадение имени с LEAF лимита). Тело —
|
||||
-- актуальное (0040, не менялось с тех пор) + замена одной проверки.
|
||||
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[];
|
||||
v_source_sheet VARCHAR;
|
||||
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, bl.source_sheet
|
||||
INTO v_form_id, v_sheet, v_eid, v_source_sheet
|
||||
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 v_source_sheet IS NOT NULL 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$;
|
||||
|
||||
|
||||
-- 7. get_form2_ahr_satellite_delta: лимитные строки ищем по
|
||||
-- source_sheet = 'AHR_LIMIT' (доп. фильтр к совпадению по имени) — манипулируя
|
||||
-- именем, ручная строка больше не попадёт в дельту лимитной строки. Ветка
|
||||
-- сателлитов (аренда/коммуналка/охрана) не трогается — там уже однозначное
|
||||
-- сопоставление по v3.form2_ahr_satellite_line_source (source_id). Тело —
|
||||
-- актуальное (0031, не менялось с тех пор) + один доп. AND.
|
||||
CREATE OR REPLACE FUNCTION v3.get_form2_ahr_satellite_delta(
|
||||
p_form_id integer,
|
||||
p_source_sheet character varying,
|
||||
p_source_row_id integer
|
||||
)
|
||||
RETURNS TABLE(
|
||||
row_type character varying,
|
||||
depth integer,
|
||||
sort_order bigint,
|
||||
data jsonb
|
||||
)
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $function$
|
||||
WITH target_lines AS (
|
||||
SELECT m.target_line_id AS line_id
|
||||
FROM v3.form2_ahr_satellite_line_source m
|
||||
JOIN v3.budget_line bl ON bl.id = m.target_line_id
|
||||
WHERE m.source_sheet = p_source_sheet
|
||||
AND m.source_id = p_source_row_id
|
||||
AND bl.budget_form_id = p_form_id
|
||||
AND p_source_sheet IN ('AHR_RENT', 'AHR_UTILITY', 'AHR_SECURITY')
|
||||
|
||||
UNION
|
||||
|
||||
SELECT bl.id AS line_id
|
||||
FROM v3.limit_template lt
|
||||
JOIN v3.expense_item ei
|
||||
ON ei.sheet = 'AHR'
|
||||
AND ei.direction = 'Support'
|
||||
AND ei.depth = 3
|
||||
AND (
|
||||
ei.item_id = lt.expense_item_code
|
||||
OR ei.num_group_id = lt.expense_item_code
|
||||
)
|
||||
JOIN v3.budget_line bl
|
||||
ON bl.budget_form_id = p_form_id
|
||||
AND bl.expense_item_id = ei.id
|
||||
AND bl.source_sheet = 'AHR_LIMIT'
|
||||
AND bl.name = regexp_replace(
|
||||
lt.name,
|
||||
'^[[:space:]]*-[[:space:]]*',
|
||||
''
|
||||
)
|
||||
WHERE p_source_sheet = 'AHR_LIMIT'
|
||||
AND lt.id = p_source_row_id
|
||||
AND lt.row_type = 'LEAF'
|
||||
),
|
||||
target_path AS (
|
||||
SELECT unnest(
|
||||
v3._expense_item_path_set(
|
||||
COALESCE(array_agg(bl.expense_item_id), ARRAY[]::integer[])
|
||||
)
|
||||
) AS expense_item_id
|
||||
FROM target_lines tl
|
||||
JOIN v3.budget_line bl ON bl.id = tl.line_id
|
||||
)
|
||||
SELECT v.row_type, v.depth, v.sort_order, v.data
|
||||
FROM v3.v_form_view(p_form_id, 'AHR', NULL, 'Support') v
|
||||
WHERE (
|
||||
v.row_type IN ('ROOT', 'GROUP', 'ITEM', 'SUB_ITEM')
|
||||
AND (v.data->'header'->>'expense_item_id')::INT IN (
|
||||
SELECT expense_item_id FROM target_path
|
||||
)
|
||||
)
|
||||
OR (
|
||||
v.row_type = 'INPUT'
|
||||
AND (v.data->>'line_id')::INT IN (
|
||||
SELECT line_id FROM target_lines
|
||||
)
|
||||
)
|
||||
ORDER BY v.sort_order;
|
||||
$function$;
|
||||
|
||||
|
||||
-- 8. Правило «у статьи есть лимит» — вынесено в отдельную функцию, т.к.
|
||||
-- используется и в header.is_over_limit, и в агрегатах plan.limit_year /
|
||||
-- plan.over_limit_year (v_form2_sheet_jsonb ниже).
|
||||
CREATE OR REPLACE FUNCTION v3._form2_expense_item_has_limit(
|
||||
p_expense_item_id integer
|
||||
)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $function$
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM v3.limit_template lt
|
||||
JOIN v3.expense_item ei
|
||||
ON ei.id = p_expense_item_id
|
||||
AND ei.sheet = 'AHR'
|
||||
AND ei.direction = 'Support'
|
||||
JOIN v3.expense_item_form_type eft
|
||||
ON eft.expense_item_id = ei.id
|
||||
AND eft.form_type_code = 'FORM_2'
|
||||
WHERE 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
|
||||
)
|
||||
);
|
||||
$function$;
|
||||
|
||||
|
||||
-- 9. v_form2_sheet_jsonb: header.source / header.is_over_limit и
|
||||
-- plan.limit_year / plan.over_limit_year (только на листе AHR; plan.year —
|
||||
-- сумма всех строк, без изменений). Тело — актуальное (0015, не менялось с
|
||||
-- тех пор) + источник строки и агрегаты лимита/сверхлимита.
|
||||
CREATE OR REPLACE FUNCTION v3.v_form2_sheet_jsonb(p_form_id integer, p_sheet character varying, p_sections text[] DEFAULT NULL::text[])
|
||||
RETURNS TABLE(row_type character varying, depth integer, line_id integer, header jsonb, plan_data jsonb, seq_dfip_data jsonb, seq_ssp_data jsonb, approved_data jsonb, contract_data jsonb, booking_data jsonb, contract_summary jsonb, allocation_data jsonb, collegial_data jsonb, ckk_data jsonb, q1_data jsonb, q2_data jsonb, q3_data jsonb, q4_data jsonb, totals_data jsonb, _sort_path integer[])
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $function$
|
||||
WITH flags AS (
|
||||
SELECT
|
||||
(p_sections IS NULL OR 'plan' = ANY(p_sections)) AS s_plan,
|
||||
(p_sections IS NULL OR 'seq_dfip' = ANY(p_sections)) AS s_seq_d,
|
||||
(p_sections IS NULL OR 'seq_ssp' = ANY(p_sections)) AS s_seq_s,
|
||||
(p_sections IS NULL OR 'approved' = ANY(p_sections)) AS s_appr,
|
||||
(p_sections IS NULL OR 'contract' = ANY(p_sections)) AS s_cd,
|
||||
(p_sections IS NULL OR 'booking' = ANY(p_sections)) AS s_book,
|
||||
(p_sections IS NULL OR 'contract_summary' = ANY(p_sections)) AS s_cs,
|
||||
(p_sections IS NULL OR 'allocation' = ANY(p_sections)) AS s_al,
|
||||
(p_sections IS NULL OR 'collegial' = ANY(p_sections)) AS s_ca,
|
||||
(p_sections IS NULL OR 'ckk' = ANY(p_sections)) AS s_ckk,
|
||||
(p_sections IS NULL OR 'q1' = ANY(p_sections)) AS s_q1,
|
||||
(p_sections IS NULL OR 'q2' = ANY(p_sections)) AS s_q2,
|
||||
(p_sections IS NULL OR 'q3' = ANY(p_sections)) AS s_q3,
|
||||
(p_sections IS NULL OR 'q4' = ANY(p_sections)) AS s_q4,
|
||||
(p_sections IS NULL OR 'totals' = ANY(p_sections)) AS s_tot
|
||||
),
|
||||
sheet_rows AS (
|
||||
SELECT s.*,
|
||||
CASE WHEN s.row_type = 'INPUT'
|
||||
THEN s._sort_path[array_upper(s._sort_path,1)-1]
|
||||
ELSE s._sort_path[array_upper(s._sort_path,1)] END AS eid
|
||||
FROM v3.v_form2_sheet_sections(p_form_id, p_sheet, p_sections) s
|
||||
),
|
||||
limit_tree AS (
|
||||
SELECT
|
||||
t.id AS expense_item_id,
|
||||
COALESCE(SUM(CASE WHEN bl2.source_sheet = 'AHR_LIMIT'
|
||||
THEN COALESCE(p2.plan_q1,0)+COALESCE(p2.plan_q2,0)+COALESCE(p2.plan_q3,0)+COALESCE(p2.plan_q4,0)
|
||||
ELSE 0 END), 0) AS limit_year,
|
||||
COALESCE(SUM(CASE WHEN bl2.source_sheet IS NULL
|
||||
AND v3._form2_expense_item_has_limit(bl2.expense_item_id)
|
||||
THEN COALESCE(p2.plan_q1,0)+COALESCE(p2.plan_q2,0)+COALESCE(p2.plan_q3,0)+COALESCE(p2.plan_q4,0)
|
||||
ELSE 0 END), 0) AS over_limit_year
|
||||
FROM v3.mv_expense_item_tree t
|
||||
JOIN v3.budget_line bl2
|
||||
ON bl2.expense_item_id = ANY(t.desc_ids)
|
||||
AND bl2.budget_form_id = p_form_id
|
||||
AND bl2.direction = 'Support'
|
||||
LEFT JOIN v3.plan p2 ON p2.line_id = bl2.id
|
||||
WHERE p_sheet = 'AHR'
|
||||
AND t.sheet = p_sheet
|
||||
GROUP BY t.id
|
||||
)
|
||||
SELECT
|
||||
s.row_type, s.depth, s.line_id,
|
||||
|
||||
jsonb_build_object(
|
||||
'section_code', s.col_section_code,
|
||||
'item_id', s.col_item_id,
|
||||
'num_group', s.col_num_group_id,
|
||||
'name', s.col_name,
|
||||
'vsp_id', s.col_vsp_id,
|
||||
'vsp_address', s.col_vsp_address,
|
||||
'internal_order', s.col_internal_order,
|
||||
'expense_item_id', s.eid,
|
||||
'source', CASE WHEN s.row_type = 'INPUT'
|
||||
THEN COALESCE(bl.source_sheet, 'MANUAL')
|
||||
ELSE NULL END,
|
||||
'is_over_limit', (s.row_type = 'INPUT'
|
||||
AND bl.source_sheet IS NULL
|
||||
AND v3._form2_expense_item_has_limit(bl.expense_item_id))
|
||||
) AS header,
|
||||
|
||||
CASE WHEN f.s_plan THEN jsonb_build_object(
|
||||
'q1', s.col_plan_q1, 'q2', s.col_plan_q2,
|
||||
'q3', s.col_plan_q3, 'q4', s.col_plan_q4,
|
||||
'year', s.col_plan_year, 'comment', s.col_plan_comment
|
||||
) || CASE WHEN p_sheet = 'AHR' THEN jsonb_build_object(
|
||||
'limit_year', CASE WHEN s.row_type = 'INPUT'
|
||||
THEN CASE WHEN bl.source_sheet = 'AHR_LIMIT'
|
||||
THEN COALESCE(s.col_plan_year, 0) ELSE 0 END
|
||||
ELSE COALESCE(lim.limit_year, 0) END,
|
||||
'over_limit_year', CASE WHEN s.row_type = 'INPUT'
|
||||
THEN CASE WHEN bl.source_sheet IS NULL
|
||||
AND v3._form2_expense_item_has_limit(bl.expense_item_id)
|
||||
THEN COALESCE(s.col_plan_year, 0) ELSE 0 END
|
||||
ELSE COALESCE(lim.over_limit_year, 0) END
|
||||
) ELSE '{}'::jsonb END END AS plan_data,
|
||||
|
||||
CASE WHEN f.s_seq_d THEN jsonb_build_object(
|
||||
'q1', s.col_seq_dfip_q1, 'q2', s.col_seq_dfip_q2,
|
||||
'q3', s.col_seq_dfip_q3, 'q4', s.col_seq_dfip_q4,
|
||||
'year', s.col_seq_dfip_year,
|
||||
'justification', s.col_seq_dfip_just
|
||||
) END AS seq_dfip_data,
|
||||
|
||||
CASE WHEN f.s_seq_s THEN jsonb_build_object(
|
||||
'q1', s.col_seq_ssp_q1, 'q2', s.col_seq_ssp_q2,
|
||||
'q3', s.col_seq_ssp_q3, 'q4', s.col_seq_ssp_q4,
|
||||
'year', s.col_seq_ssp_year,
|
||||
'justification', s.col_seq_ssp_just
|
||||
) END AS seq_ssp_data,
|
||||
|
||||
CASE WHEN f.s_appr THEN jsonb_build_object(
|
||||
'q1', s.col_appr_q1, 'q2', s.col_appr_q2,
|
||||
'q3', s.col_appr_q3, 'q4', s.col_appr_q4,
|
||||
'year', s.col_appr_year
|
||||
) END AS approved_data,
|
||||
|
||||
CASE WHEN f.s_cd THEN jsonb_build_object(
|
||||
'counterparty', s.col_cd_counterparty,
|
||||
'reference', s.col_cd_reference,
|
||||
'addenda', s.col_cd_addenda,
|
||||
'date', TO_CHAR(s.col_cd_date, 'DD.MM.YYYY'),
|
||||
'subject', s.col_cd_subject,
|
||||
'currency', s.col_cd_currency,
|
||||
'ceiling', s.col_cd_ceiling,
|
||||
'q1', s.col_cd_exp_q1,
|
||||
'q2', s.col_cd_exp_q2,
|
||||
'q3', s.col_cd_exp_q3,
|
||||
'q4', s.col_cd_exp_q4,
|
||||
'rf_schedule', s.col_cd_rf_sched,
|
||||
'vat_rate', s.col_cd_vat_rate,
|
||||
'exchange_rate', s.col_cd_exchange_rate,
|
||||
'amount_foreign', s.col_cd_amount_foreign,
|
||||
'deadline', s.col_cd_deadline,
|
||||
'scheme', s.col_cd_scheme,
|
||||
'act', s.col_cd_act,
|
||||
'comment', s.col_cd_comment
|
||||
) END AS contract_data,
|
||||
|
||||
CASE WHEN f.s_book THEN jsonb_build_object(
|
||||
'y2026', jsonb_build_object('q1', s.col_book_q1, 'q2', s.col_book_q2, 'q3', s.col_book_q3, 'q4', s.col_book_q4),
|
||||
'y2027', jsonb_build_object('q1', s.col_book_next_q1, 'q2', s.col_book_next_q2, 'q3', s.col_book_next_q3, 'q4', s.col_book_next_q4)
|
||||
) END AS booking_data,
|
||||
|
||||
CASE WHEN f.s_cs THEN jsonb_build_object(
|
||||
'total', s.col_cs_total,
|
||||
'counterparty', s.col_cs_counterparty,
|
||||
'deadline', s.col_cs_deadline,
|
||||
'comment', s.col_cs_comment,
|
||||
'future_y1', s.col_cs_future_y1,
|
||||
'future_y2', s.col_cs_future_y2,
|
||||
'other_ssp', s.col_cs_other_ssp
|
||||
) END AS contract_summary,
|
||||
|
||||
CASE WHEN f.s_al THEN jsonb_build_object(
|
||||
'contract_ref', s.col_al_contract_ref,
|
||||
'purpose', s.col_al_purpose,
|
||||
'order', s.col_al_order,
|
||||
'property', s.col_al_property
|
||||
) END AS allocation_data,
|
||||
|
||||
CASE WHEN f.s_ca THEN jsonb_build_object(
|
||||
'approved', s.col_ca_approved,
|
||||
'protocol', s.col_ca_protocol,
|
||||
'note', s.col_ca_note
|
||||
) END AS collegial_data,
|
||||
|
||||
CASE WHEN f.s_ckk THEN jsonb_build_object(
|
||||
'ceiling', s.col_ckk_ceiling,
|
||||
'rf_schedule', s.col_ckk_rf_sched,
|
||||
'deadline', s.col_ckk_deadline,
|
||||
'proc_plan', s.col_ckk_proc_plan,
|
||||
'proc_method', s.col_ckk_proc_meth,
|
||||
'comment', s.col_ckk_comment
|
||||
) END AS ckk_data,
|
||||
|
||||
CASE WHEN f.s_q1 THEN jsonb_build_object(
|
||||
'adj_current', s.col_q1_adj_current,
|
||||
'adj_ssp', s.col_q1_adj_ssp,
|
||||
'adj_rf', s.col_q1_adj_rf,
|
||||
'adj_reserve', s.col_q1_adj_reserve,
|
||||
'adj_comment', s.col_q1_adj_comment,
|
||||
'corrected_plan', s.col_q1_corrected_plan,
|
||||
'pay_date', TO_CHAR(s.col_q1_pay_date, 'DD.MM.YYYY'),
|
||||
'pay_amount', s.col_q1_pay_amount,
|
||||
'pay_ho', s.col_q1_pay_ho,
|
||||
'pay_rf', s.col_q1_pay_rf,
|
||||
'pay_comment', s.col_q1_pay_comment,
|
||||
'pay_act', s.col_q1_pay_act,
|
||||
'booking', s.col_q1_booking,
|
||||
'actual_m1', s.col_q1_actual_m1,
|
||||
'actual_m2', s.col_q1_actual_m2,
|
||||
'actual_m3', s.col_q1_actual_m3,
|
||||
'actual_quarter', s.col_q1_actual_quarter,
|
||||
'residual_after_booking', s.col_q1_residual_after_booking,
|
||||
'residual_after_actual', s.col_q1_residual_after_actual,
|
||||
'transfer_q2', s.col_q1_transfer_q2,
|
||||
'transfer_q2_delay_acts', s.col_q1_transfer_q2_delay_acts,
|
||||
'transfer_q2_delay_procurement', s.col_q1_transfer_q2_delay_procurement,
|
||||
'transfer_q2_economy_rf', s.col_q1_transfer_q2_economy_rf,
|
||||
'transfer_next_comment', s.col_q1_transfer_next_comment,
|
||||
'transfer_q3', s.col_q1_transfer_q3,
|
||||
'transfer_q4', s.col_q1_transfer_q4,
|
||||
'transfer_far_comment', s.col_q1_transfer_far_comment,
|
||||
'transfer_econ', s.col_q1_transfer_econ,
|
||||
'total', s.col_q1_total
|
||||
) END AS q1_data,
|
||||
|
||||
CASE WHEN f.s_q2 THEN jsonb_build_object(
|
||||
'target_change', s.col_q2_target_change,
|
||||
'base_correction', s.col_q2_base_correction,
|
||||
'base_correction_comment', s.col_q2_base_correction_comment,
|
||||
'revision_inc', s.col_q2_revision_inc,
|
||||
'revision_seq', s.col_q2_revision_seq,
|
||||
'revision_comment', s.col_q2_revision_comment,
|
||||
'new_plan', s.col_q2_new_plan,
|
||||
'adj_current', s.col_q2_adj_current,
|
||||
'adj_ssp', s.col_q2_adj_ssp,
|
||||
'adj_rf', s.col_q2_adj_rf,
|
||||
'adj_reserve', s.col_q2_adj_reserve,
|
||||
'adj_comment', s.col_q2_adj_comment,
|
||||
'corrected_plan', s.col_q2_corrected_plan,
|
||||
'pay_date', TO_CHAR(s.col_q2_pay_date, 'DD.MM.YYYY'),
|
||||
'pay_amount', s.col_q2_pay_amount,
|
||||
'pay_ho', s.col_q2_pay_ho,
|
||||
'pay_rf', s.col_q2_pay_rf,
|
||||
'pay_comment', s.col_q2_pay_comment,
|
||||
'pay_act', s.col_q2_pay_act,
|
||||
'booking', s.col_q2_booking,
|
||||
'actual_m1', s.col_q2_actual_m1,
|
||||
'actual_m2', s.col_q2_actual_m2,
|
||||
'actual_m3', s.col_q2_actual_m3,
|
||||
'actual_quarter', s.col_q2_actual_quarter,
|
||||
'residual_after_booking', s.col_q2_residual_after_booking,
|
||||
'residual_after_actual', s.col_q2_residual_after_actual,
|
||||
'transfer_q3', s.col_q2_transfer_q3,
|
||||
'transfer_q3_delay_acts', s.col_q2_transfer_q3_delay_acts,
|
||||
'transfer_q3_delay_procurement', s.col_q2_transfer_q3_delay_procurement,
|
||||
'transfer_q3_economy_rf', s.col_q2_transfer_q3_economy_rf,
|
||||
'transfer_next_comment', s.col_q2_transfer_next_comment,
|
||||
'transfer_q4', s.col_q2_transfer_q4,
|
||||
'transfer_far_comment', s.col_q2_transfer_far_comment,
|
||||
'transfer_econ', s.col_q2_transfer_econ,
|
||||
'total', s.col_q2_total
|
||||
) END AS q2_data,
|
||||
|
||||
CASE WHEN f.s_q3 THEN jsonb_build_object(
|
||||
'target_change', s.col_q3_target_change,
|
||||
'base_correction', s.col_q3_base_correction,
|
||||
'base_correction_comment', s.col_q3_base_correction_comment,
|
||||
'revision_inc', s.col_q3_revision_inc,
|
||||
'revision_seq', s.col_q3_revision_seq,
|
||||
'revision_comment', s.col_q3_revision_comment,
|
||||
'new_plan', s.col_q3_new_plan,
|
||||
'adj_current', s.col_q3_adj_current,
|
||||
'adj_ssp', s.col_q3_adj_ssp,
|
||||
'adj_rf', s.col_q3_adj_rf,
|
||||
'adj_reserve', s.col_q3_adj_reserve,
|
||||
'adj_comment', s.col_q3_adj_comment,
|
||||
'corrected_plan', s.col_q3_corrected_plan,
|
||||
'pay_date', TO_CHAR(s.col_q3_pay_date, 'DD.MM.YYYY'),
|
||||
'pay_amount', s.col_q3_pay_amount,
|
||||
'pay_ho', s.col_q3_pay_ho,
|
||||
'pay_rf', s.col_q3_pay_rf,
|
||||
'pay_comment', s.col_q3_pay_comment,
|
||||
'pay_act', s.col_q3_pay_act,
|
||||
'booking', s.col_q3_booking,
|
||||
'actual_m1', s.col_q3_actual_m1,
|
||||
'actual_m2', s.col_q3_actual_m2,
|
||||
'actual_m3', s.col_q3_actual_m3,
|
||||
'actual_quarter', s.col_q3_actual_quarter,
|
||||
'residual_after_booking', s.col_q3_residual_after_booking,
|
||||
'residual_after_actual', s.col_q3_residual_after_actual,
|
||||
'transfer_q4', s.col_q3_transfer_q4,
|
||||
'transfer_q4_delay_acts', s.col_q3_transfer_q4_delay_acts,
|
||||
'transfer_q4_delay_procurement', s.col_q3_transfer_q4_delay_procurement,
|
||||
'transfer_q4_economy_rf', s.col_q3_transfer_q4_economy_rf,
|
||||
'transfer_next_comment', s.col_q3_transfer_next_comment,
|
||||
'transfer_econ', s.col_q3_transfer_econ,
|
||||
'total', s.col_q3_total
|
||||
) END AS q3_data,
|
||||
|
||||
CASE WHEN f.s_q4 THEN jsonb_build_object(
|
||||
'target_change', s.col_q4_target_change,
|
||||
'base_correction', s.col_q4_base_correction,
|
||||
'base_correction_comment', s.col_q4_base_correction_comment,
|
||||
'revision_inc', s.col_q4_revision_inc,
|
||||
'revision_seq', s.col_q4_revision_seq,
|
||||
'revision_comment', s.col_q4_revision_comment,
|
||||
'new_plan', s.col_q4_new_plan,
|
||||
'adj_current', s.col_q4_adj_current,
|
||||
'adj_ssp', s.col_q4_adj_ssp,
|
||||
'adj_rf', s.col_q4_adj_rf,
|
||||
'adj_reserve', s.col_q4_adj_reserve,
|
||||
'adj_comment', s.col_q4_adj_comment,
|
||||
'corrected_plan', s.col_q4_corrected_plan,
|
||||
'pay_date', TO_CHAR(s.col_q4_pay_date, 'DD.MM.YYYY'),
|
||||
'pay_amount', s.col_q4_pay_amount,
|
||||
'pay_ho', s.col_q4_pay_ho,
|
||||
'pay_rf', s.col_q4_pay_rf,
|
||||
'pay_comment', s.col_q4_pay_comment,
|
||||
'pay_act', s.col_q4_pay_act,
|
||||
'booking', s.col_q4_booking,
|
||||
'actual_m1', s.col_q4_actual_m1,
|
||||
'actual_m2', s.col_q4_actual_m2,
|
||||
'actual_m3', s.col_q4_actual_m3,
|
||||
'actual_spod', s.col_q4_actual_spod,
|
||||
'actual_quarter', s.col_q4_actual_quarter,
|
||||
'residual_after_booking', s.col_q4_residual_after_booking,
|
||||
'residual_after_actual', s.col_q4_residual_after_actual,
|
||||
'transfer_econ', s.col_q4_transfer_econ,
|
||||
'total', s.col_q4_total
|
||||
) END AS q4_data,
|
||||
|
||||
CASE WHEN f.s_tot THEN jsonb_build_object(
|
||||
'fact_year', s.col_fact_year,
|
||||
'pay_year', s.col_pay_year,
|
||||
'economy_year', s.col_economy_year
|
||||
) END AS totals_data,
|
||||
|
||||
s._sort_path
|
||||
FROM sheet_rows s
|
||||
CROSS JOIN flags f
|
||||
LEFT JOIN v3.budget_line bl ON bl.id = s.line_id
|
||||
LEFT JOIN limit_tree lim ON lim.expense_item_id = s.eid
|
||||
ORDER BY s._sort_path;
|
||||
$function$
|
||||
;
|
||||
@ -29,6 +29,7 @@ class BudgetLine(Base):
|
||||
updated_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("v3.app_user.id"))
|
||||
updated_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
|
||||
direction: Mapped[str | None] = mapped_column(String)
|
||||
source_sheet: Mapped[str | None] = mapped_column(String)
|
||||
|
||||
# budget_form = relationship("BudgetForm", back_populates="budget_lines")
|
||||
# expense_item = relationship("ExpenseItem", back_populates="budget_lines")
|
||||
|
||||
@ -13,6 +13,10 @@ class LimitTemplate(Base):
|
||||
"row_type IN ('SECTION','GROUP','LEAF')",
|
||||
name="ck_v3_limit_template_row_type",
|
||||
),
|
||||
CheckConstraint(
|
||||
"period IN ('YEAR','QUARTER','MONTH')",
|
||||
name="ck_v3_limit_template_period",
|
||||
),
|
||||
Index("ix_v3_limit_template_parent", "parent_id"),
|
||||
{"schema": "v3"},
|
||||
)
|
||||
@ -25,6 +29,9 @@ class LimitTemplate(Base):
|
||||
expense_item_code: Mapped[str | None] = mapped_column(String)
|
||||
name: Mapped[str] = mapped_column(String)
|
||||
unit: Mapped[str | None] = mapped_column(String)
|
||||
# Период норматива: YEAR — делится на 4 квартала, MONTH — умножается на 3,
|
||||
# QUARTER (по умолчанию) — считается как есть. AURORA-1897.
|
||||
period: Mapped[str] = mapped_column(String, nullable=False, server_default="QUARTER")
|
||||
limit_with_vat: Mapped[float | None] = mapped_column(Numeric)
|
||||
limit_without_vat: Mapped[float | None] = mapped_column(Numeric)
|
||||
|
||||
|
||||
@ -37,6 +37,7 @@ class RentDetail(Base):
|
||||
actual_oct: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_nov: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_dec: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_spod: Mapped[float | None] = mapped_column(Numeric)
|
||||
comment: Mapped[str | None] = mapped_column(String)
|
||||
|
||||
# line = relationship("BudgetLine")
|
||||
|
||||
@ -37,6 +37,7 @@ class SecurityDetail(Base):
|
||||
actual_oct: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_nov: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_dec: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_spod: Mapped[float | None] = mapped_column(Numeric)
|
||||
comment: Mapped[str | None] = mapped_column(String)
|
||||
|
||||
# line = relationship("BudgetLine")
|
||||
|
||||
@ -37,6 +37,7 @@ class UtilityDetail(Base):
|
||||
actual_oct: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_nov: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_dec: Mapped[float | None] = mapped_column(Numeric)
|
||||
actual_spod: Mapped[float | None] = mapped_column(Numeric)
|
||||
comment: Mapped[str | None] = mapped_column(String)
|
||||
|
||||
# line = relationship("BudgetLine")
|
||||
|
||||
@ -66,6 +66,18 @@ FORM1_DEPTH_COLUMNS: dict = {
|
||||
"year": {
|
||||
"name": "Год",
|
||||
},
|
||||
# AURORA-1907: лист АХР формы 2 — суммы «в пределах лимита» /
|
||||
# «сверх лимита» (v3.v_form2_sheet_jsonb считает их только
|
||||
# для p_sheet='AHR'; у СМЕТЫ/CAP/OPER и у FORM_1 этих ключей
|
||||
# в data.plan нет вовсе, ячейки будут пустыми). Отдельного
|
||||
# columns_dict для FORM_2/AHR не завели — он общий с
|
||||
# OPER/CAP/FORM_1 (см. export_service._create_xlsxwriter_sheet_writers).
|
||||
"limit_year": {
|
||||
"name": "В пределах лимита, год",
|
||||
},
|
||||
"over_limit_year": {
|
||||
"name": "Сверх лимита, год",
|
||||
},
|
||||
"comment": {
|
||||
"name": "Комментарий",
|
||||
},
|
||||
@ -3386,7 +3398,12 @@ FORM4_OPER_COLORS: dict = _rename_color_paths(
|
||||
)
|
||||
|
||||
|
||||
SMETA_COLUMNS: list[tuple[str, str]] = [
|
||||
# AURORA-1903: у каждой формы на листе "Смета" свой набор колонок, в порядке
|
||||
# её экрана (RealtimeTable constants/FORM_*/SMETA.js). FORM_1 показывает и
|
||||
# поддержку, и развитие, и три группы ИТОГО (план/утверждено/факт); FORM_2
|
||||
# ("Смета РФ") — только поддержку; FORM_4 ("Проектная деятельность") — только
|
||||
# развитие. Ключи совпадают с SMETA_VALUE_PATHS в export_normalizers.py.
|
||||
SMETA_FORM1_COLUMNS: list[tuple[str, str]] = [
|
||||
("section_code", "Код раздела"),
|
||||
("name", "Наименование"),
|
||||
("plan_support_q1", "План поддержка I квартал, тыс. руб."),
|
||||
@ -3429,6 +3446,59 @@ SMETA_COLUMNS: list[tuple[str, str]] = [
|
||||
("corrected_development_q3", "Корректировка развитие III квартал, тыс. руб."),
|
||||
("corrected_development_q4", "Корректировка развитие IV квартал, тыс. руб."),
|
||||
]
|
||||
SMETA_FORM2_COLUMNS: list[tuple[str, str]] = [
|
||||
("section_code", "Код раздела"),
|
||||
("name", "Наименование"),
|
||||
("plan_support_q1", "План поддержка I квартал"),
|
||||
("plan_support_q2", "План поддержка II квартал"),
|
||||
("plan_support_q3", "План поддержка III квартал"),
|
||||
("plan_support_q4", "План поддержка IV квартал"),
|
||||
("plan_support_year", "План поддержка Год"),
|
||||
("approved_support_q1", "Утверждено поддержка I квартал"),
|
||||
("approved_support_q2", "Утверждено поддержка II квартал"),
|
||||
("approved_support_q3", "Утверждено поддержка III квартал"),
|
||||
("approved_support_q4", "Утверждено поддержка IV квартал"),
|
||||
("approved_support_year", "Утверждено поддержка Год"),
|
||||
("fact_support_q1", "Факт поддержка I квартал"),
|
||||
("fact_support_q2", "Факт поддержка II квартал"),
|
||||
("fact_support_q3", "Факт поддержка III квартал"),
|
||||
("fact_support_q4", "Факт поддержка IV квартал"),
|
||||
("fact_support_year", "Факт поддержка Год"),
|
||||
("corrected_support_q2", "Корректировка поддержка II квартал"),
|
||||
("corrected_support_q3", "Корректировка поддержка III квартал"),
|
||||
("corrected_support_q4", "Корректировка поддержка IV квартал"),
|
||||
]
|
||||
SMETA_FORM4_COLUMNS: list[tuple[str, str]] = [
|
||||
("section_code", "Код раздела"),
|
||||
("name", "Наименование"),
|
||||
("plan_development_q1", "План развитие I квартал"),
|
||||
("plan_development_q2", "План развитие II квартал"),
|
||||
("plan_development_q3", "План развитие III квартал"),
|
||||
("plan_development_q4", "План развитие IV квартал"),
|
||||
("plan_development_year", "План развитие Год"),
|
||||
("approved_development_q1", "Утверждено развитие I квартал"),
|
||||
("approved_development_q2", "Утверждено развитие II квартал"),
|
||||
("approved_development_q3", "Утверждено развитие III квартал"),
|
||||
("approved_development_q4", "Утверждено развитие IV квартал"),
|
||||
("approved_development_year", "Утверждено развитие Год"),
|
||||
("fact_development_q1", "Факт развитие I квартал"),
|
||||
("fact_development_q2", "Факт развитие II квартал"),
|
||||
("fact_development_q3", "Факт развитие III квартал"),
|
||||
("fact_development_q4", "Факт развитие IV квартал"),
|
||||
("fact_development_year", "Факт развитие Год"),
|
||||
("corrected_development_q2", "Корректировка развитие II квартал"),
|
||||
("corrected_development_q3", "Корректировка развитие III квартал"),
|
||||
("corrected_development_q4", "Корректировка развитие IV квартал"),
|
||||
]
|
||||
SMETA_COLUMNS_BY_FORM: dict[str, list[tuple[str, str]]] = {
|
||||
"FORM_1": SMETA_FORM1_COLUMNS,
|
||||
"FORM_2": SMETA_FORM2_COLUMNS,
|
||||
"FORM_4": SMETA_FORM4_COLUMNS,
|
||||
}
|
||||
# Алиас для обратной совместимости: часть кода (неиспользуемый openpyxl-словарь
|
||||
# writer'ов в ExportService.__init__, старый тест test_sheet_column_contracts_are_stable)
|
||||
# по-прежнему ссылается на единый SMETA_COLUMNS — это раскладка FORM_1.
|
||||
SMETA_COLUMNS: list[tuple[str, str]] = SMETA_COLUMNS_BY_FORM["FORM_1"]
|
||||
FORM3_LIMIT_DEPTH_COLUMNS: dict = {
|
||||
"header": {
|
||||
"name": "Данные",
|
||||
|
||||
@ -45,6 +45,7 @@ from src.services.export_service.export_mappings import (
|
||||
FORM3_LIMIT_DEPTH_COLUMNS,
|
||||
PALETTE,
|
||||
SMETA_COLUMNS,
|
||||
SMETA_COLUMNS_BY_FORM,
|
||||
build_summary1_columns,
|
||||
build_summary2_columns,
|
||||
render_column_years,
|
||||
@ -928,7 +929,7 @@ class ExportService:
|
||||
"__default__": default_writer,
|
||||
"SMETA": XlsxTabularSheetWriter(
|
||||
workbook=workbook,
|
||||
columns=SMETA_COLUMNS,
|
||||
columns=SMETA_COLUMNS_BY_FORM.get(form_type_code, SMETA_COLUMNS_BY_FORM["FORM_1"]),
|
||||
row_normalizer=self.smeta_row_normalizer.normalize,
|
||||
value_serializer=self.value_serializer.to_excel_value,
|
||||
),
|
||||
|
||||
@ -78,6 +78,10 @@ class TabularSheetWriter(AbstractWriter):
|
||||
worksheet.append(headers)
|
||||
self._track_row_widths(column_widths, headers)
|
||||
for row in rows:
|
||||
if row and row[0] == "META":
|
||||
# v_form_view всегда отдаёт служебную строку META первой;
|
||||
# на листе "Смета" ей не место (AURORA-1903).
|
||||
continue
|
||||
raw_data = row[3] if len(row) > 3 and isinstance(row[3], dict) else {}
|
||||
normalized = self.row_normalizer(raw_data)
|
||||
values = [self.value_serializer(normalized.get(key)) for key, _ in self.columns]
|
||||
@ -303,6 +307,10 @@ class XlsxTabularSheetWriter(AbstractWriter):
|
||||
|
||||
output_row = 1
|
||||
for row in rows:
|
||||
if row and row[0] == "META":
|
||||
# v_form_view всегда отдаёт служебную строку META первой;
|
||||
# на листе "Смета" ей не место (AURORA-1903).
|
||||
continue
|
||||
raw_data = row[3] if len(row) > 3 and isinstance(row[3], dict) else {}
|
||||
normalized = self.row_normalizer(raw_data)
|
||||
values = [self.value_serializer(normalized.get(key)) for key, _ in self.columns]
|
||||
|
||||
212
api/tests/integration/test_form2_ahr_limit.py
Normal file
212
api/tests/integration/test_form2_ahr_limit.py
Normal file
@ -0,0 +1,212 @@
|
||||
"""AURORA-1897: лист «АХР лимиты» формы 2.
|
||||
|
||||
Три правки одной формулой/миграцией (0052_form2_ahr_limit_period_vat_units):
|
||||
1) годовые нормативы (ДМС id 23/24/25, оргтехника id 13/14/15) делятся на
|
||||
4 квартала (period=YEAR), а не дублируются в каждом квартале;
|
||||
2) сумма на листе лимитов и в плане АХР считается от лимита БЕЗ НДС
|
||||
(limit_without_vat), а не с НДС;
|
||||
3) план лимитных строк в АХР переводится в тыс. руб. (как аренда/
|
||||
коммуналка/охрана/Смета формы 2), а не остаётся в рублях.
|
||||
|
||||
Сессия тестов работает в rollback-транзакции (см. tests/integration/conftest.py
|
||||
client fixture), поэтому изменения не переживают отдельный тест.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
# АХР Аренда синхронизируется в АХР под статьёй с этим expense_item_id
|
||||
# (v3.sync_form2_ahr_satellite_line(s), api/alembic/versions/sql/
|
||||
# 0050_form2_ahr_spod_column.sql) — используется только в регрессионном
|
||||
# тесте ветки аренды (эта миграция её не трогает).
|
||||
AHR_RENT_PARENT_EXPENSE_ITEM = 314
|
||||
|
||||
|
||||
def _auth_headers(tokens: dict) -> dict:
|
||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
|
||||
def _get_form2_id(client, admin_tokens) -> int | None:
|
||||
response = client.get(
|
||||
"/api/v1/form/",
|
||||
params={"form_type": "FORM_2"},
|
||||
headers=_auth_headers(admin_tokens),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
forms = response.json()["result"]
|
||||
return forms[0]["id"] if forms else None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def form2_id(client, admin_tokens):
|
||||
form_id = _get_form2_id(client, admin_tokens)
|
||||
if form_id is None:
|
||||
pytest.skip("В фикстуре нет формы 2 (FORM_2) — тест лимитов АХР пропущен")
|
||||
return form_id
|
||||
|
||||
|
||||
def _get_sheet_rows(client, admin_tokens, form2_id, sheet) -> list[dict]:
|
||||
response = client.get(
|
||||
f"/api/v1/form/{form2_id}/sheet/{sheet}",
|
||||
headers=_auth_headers(admin_tokens),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()["result"]
|
||||
|
||||
|
||||
def _patch_cell(client, admin_tokens, form2_id, sheet, line_id, column, value) -> list[dict]:
|
||||
response = client.patch(
|
||||
f"/api/v1/form/{form2_id}/sheet/{sheet}/cell",
|
||||
json={"line_id": line_id, "column": column, "value": value},
|
||||
headers=_auth_headers(admin_tokens),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()["result"]
|
||||
|
||||
|
||||
def _find_by_id(rows: list[dict], target_id: int) -> dict:
|
||||
# v_form_view может отдавать META-строку ({"editable": [...], ...}) без
|
||||
# "id" — её пропускаем, т.к. (data->>'id')::INT там NULL.
|
||||
for row in rows:
|
||||
data = row.get("data")
|
||||
if isinstance(data, dict) and data.get("id") == target_id:
|
||||
return row
|
||||
raise AssertionError(f"строка с id={target_id} не найдена среди {rows!r}")
|
||||
|
||||
|
||||
def _find_ahr_row_by_name(rows: list[dict], name: str) -> dict:
|
||||
for row in rows:
|
||||
if row.get("row_type") != "INPUT":
|
||||
continue
|
||||
data = row.get("data") or {}
|
||||
if (data.get("header") or {}).get("name") == name:
|
||||
return row
|
||||
raise AssertionError(f"строка АХР с именем {name!r} не найдена среди {rows!r}")
|
||||
|
||||
|
||||
def _find_ahr_row_by_expense_item(rows: list[dict], expense_item_id: int) -> dict:
|
||||
for row in rows:
|
||||
if row.get("row_type") != "INPUT":
|
||||
continue
|
||||
data = row.get("data") or {}
|
||||
if (data.get("header") or {}).get("expense_item_id") == expense_item_id:
|
||||
return row
|
||||
raise AssertionError(
|
||||
f"строка АХР с expense_item_id={expense_item_id} не найдена среди {rows!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_annual_dms_limit_divided_by_four(client, admin_tokens, form2_id):
|
||||
# id=24 «- стандарт РФ»: ДМС, лимит 27858 руб/год без НДС, period=YEAR —
|
||||
# 10 * 27858 / 4 = 69645 руб. в заполненный квартал (а не дублирование
|
||||
# годовой суммы 278580, как было до миграции).
|
||||
rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 24, "qty_q1", 10)
|
||||
row = _find_by_id(rows, 24)
|
||||
assert row["data"]["amount_q1"] == 69645
|
||||
assert row["data"]["amount_year"] == 69645
|
||||
|
||||
ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR")
|
||||
ahr_row = _find_ahr_row_by_name(ahr_rows, "стандарт РФ")
|
||||
assert ahr_row["data"]["plan"]["q1"] == pytest.approx(69.645)
|
||||
|
||||
|
||||
def test_quarterly_limit_uses_amount_without_vat(client, admin_tokens, form2_id):
|
||||
# id=3 «- Директор»: 1 чел/квартал, 2400 руб. с НДС / 2000 без НДС —
|
||||
# сумма должна считаться от лимита БЕЗ НДС.
|
||||
rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 3, "qty_q2", 1)
|
||||
row = _find_by_id(rows, 3)
|
||||
assert row["data"]["amount_q2"] == 2000
|
||||
|
||||
ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR")
|
||||
ahr_row = _find_ahr_row_by_name(ahr_rows, "Директор")
|
||||
assert ahr_row["data"]["plan"]["q2"] == pytest.approx(2.0)
|
||||
|
||||
|
||||
def test_sim_card_without_vat_limit_backfilled_is_not_zero(client, admin_tokens, form2_id):
|
||||
# id=7 «- SIM-карта для планшета (легкий фронт)»: limit_without_vat был
|
||||
# NULL до миграции — она заполнила его 375 (450 / 1.2). Сумма не должна
|
||||
# стать 0 после перехода на расчёт без НДС.
|
||||
rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 7, "qty_q1", 20)
|
||||
row = _find_by_id(rows, 7)
|
||||
assert row["data"]["amount_q1"] == 7500
|
||||
|
||||
ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR")
|
||||
ahr_row = _find_ahr_row_by_name(ahr_rows, "SIM-карта для планшета (легкий фронт)")
|
||||
assert ahr_row["data"]["plan"]["q1"] == pytest.approx(7.5)
|
||||
|
||||
|
||||
def test_printers_annual_limit_without_name_lookup(client, admin_tokens, form2_id):
|
||||
# id=13 «- принтеры»: раньше считалось по захардкоженному имени
|
||||
# (qty * 0.57 * 10900 / 4), теперь — общей формулой через period=YEAR,
|
||||
# limit_without_vat=5177.5 (миграция переписала справочник).
|
||||
rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 13, "qty_q1", 5)
|
||||
row = _find_by_id(rows, 13)
|
||||
assert row["data"]["amount_q1"] == pytest.approx(6471.875)
|
||||
|
||||
ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR")
|
||||
ahr_row = _find_ahr_row_by_name(ahr_rows, "принтеры")
|
||||
assert ahr_row["data"]["plan"]["q1"] == pytest.approx(6.471875)
|
||||
|
||||
|
||||
def test_quarterly_norm_unchanged_other_than_vat_source(client, admin_tokens, form2_id):
|
||||
# id=17 «- счетчики/сортировщики банкнот»: квартальный норматив (period
|
||||
# остаётся QUARTER по умолчанию) считается как раньше — поменялся только
|
||||
# источник суммы (НДС).
|
||||
rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 17, "qty_q3", 2)
|
||||
row = _find_by_id(rows, 17)
|
||||
assert row["data"]["amount_q3"] == 7500
|
||||
|
||||
ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR")
|
||||
ahr_row = _find_ahr_row_by_name(ahr_rows, "счетчики/сортировщики банкнот")
|
||||
assert ahr_row["data"]["plan"]["q3"] == pytest.approx(7.5)
|
||||
|
||||
|
||||
def test_total_row_is_last_root_row_summing_all_leaves(client, admin_tokens, form2_id):
|
||||
patch_rows = _patch_cell(client, admin_tokens, form2_id, "AHR_LIMIT", 24, "qty_q1", 10)
|
||||
# Ответ PATCH должен содержать строку «Итого» (data.id = 0) в diff, т.к.
|
||||
# v3._limit_template_path_set дописывает 0 к path-set любого изменения.
|
||||
total_in_patch = _find_by_id(patch_rows, 0)
|
||||
assert total_in_patch["row_type"] == "ROOT"
|
||||
|
||||
full_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR_LIMIT")
|
||||
leaf_rows = [
|
||||
row
|
||||
for row in full_rows
|
||||
if row.get("row_type") == "INPUT" and (row.get("data") or {}).get("id") is not None
|
||||
]
|
||||
total_row = next(
|
||||
row
|
||||
for row in full_rows
|
||||
if row.get("row_type") == "ROOT" and (row.get("data") or {}).get("id") == 0
|
||||
)
|
||||
|
||||
assert total_row["data"]["name"] == "Итого"
|
||||
expected_amount_q1 = sum((row["data"].get("amount_q1") or 0) for row in leaf_rows)
|
||||
assert total_row["data"]["amount_q1"] == pytest.approx(expected_amount_q1)
|
||||
assert total_row["data"]["amount_q1"] == pytest.approx(69645)
|
||||
# «Итого» должна идти последней строкой листа (sort_order = 999999).
|
||||
assert full_rows[-1] is total_row
|
||||
|
||||
|
||||
def test_rent_plan_still_passed_through_without_division(client, admin_tokens, form2_id):
|
||||
# Регрессия: AURORA-1897 не должна задевать перенос аренды/коммуналки/
|
||||
# охраны — только лимитные строки АХР лимитов.
|
||||
response = client.post(
|
||||
f"/api/v1/form/{form2_id}/sheet/AHR_RENT/line",
|
||||
json={"vsp_id": 1},
|
||||
headers=_auth_headers(admin_tokens),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
rent_rows = response.json()["result"]
|
||||
assert len(rent_rows) == 1
|
||||
rent_line_id = rent_rows[0]["data"]["id"]
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/form/{form2_id}/sheet/AHR_RENT/cell",
|
||||
json={"line_id": rent_line_id, "column": "plan.q1", "value": 100},
|
||||
headers=_auth_headers(admin_tokens),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR")
|
||||
ahr_row = _find_ahr_row_by_expense_item(ahr_rows, AHR_RENT_PARENT_EXPENSE_ITEM)
|
||||
assert ahr_row["data"]["plan"]["q1"] == 100
|
||||
498
api/tests/integration/test_form2_ahr_satellite_integration.py
Normal file
498
api/tests/integration/test_form2_ahr_satellite_integration.py
Normal file
@ -0,0 +1,498 @@
|
||||
"""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}")
|
||||
|
||||
|
||||
def _find_ahr_item_row(rows: list[dict], expense_item_id: int) -> dict:
|
||||
# Статья-"лист" дерева расходов (depth=3, та, на которую вешаются
|
||||
# budget_line) рендерится в v_form2_view как SUB_ITEM, а не ITEM — ITEM/
|
||||
# GROUP/ROOT — только промежуточные узлы выше по дереву. Проверено
|
||||
# эмпирически через v3.v_form2_view на тестовой БД.
|
||||
for row in rows:
|
||||
if row["row_type"] == "SUB_ITEM" and row["data"]["header"].get("expense_item_id") == expense_item_id:
|
||||
return row
|
||||
raise AssertionError(f"SUB_ITEM expense_item_id={expense_item_id} не найден на листе AHR: {[r for r in rows if r['row_type'] != 'INPUT']}")
|
||||
|
||||
|
||||
@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"]
|
||||
|
||||
|
||||
# --- AURORA-1907: признак источника строки (source_sheet) и раздельные суммы
|
||||
# «в пределах лимита» / «сверх лимита» на листе АХР ------------------------
|
||||
#
|
||||
# "Мобильная связь" (expense_item_id=150 в реально накатанном каталоге —
|
||||
# значения id из текста миграций 0003/0013 переопределяются более позднім
|
||||
# re-seed'ом, проверено эмпирически через psql на тестовой БД, а не по тексту
|
||||
# .sql) — статья каталога лимитов с 7 LEAF в limit_template (id=3..9:
|
||||
# "- Директор", "- Заместитель директора", ...), все с
|
||||
# expense_item_code='R061015039', совпадающим с ei.item_id статьи 150.
|
||||
# Это единственная статья в тестовом каталоге, где несколько LEAF лимитов
|
||||
# синкуются в разные строки одной и той же статьи — нужна для регрессионного
|
||||
# теста на совпадение имени (п.4 тикета).
|
||||
MOBILE_EXPENSE_ITEM_ID = 150
|
||||
# "Амортизация: НВНОД" (код R061161019) — статья FORM_2/AHR без лимита в
|
||||
# каталоге (проверено эмпирически: v3._form2_expense_item_has_limit(95) = false).
|
||||
NO_LIMIT_EXPENSE_ITEM_ID = 95
|
||||
|
||||
|
||||
def test_fresh_form_limit_rows_have_source_ahr_limit(client, admin_tokens, auth_headers):
|
||||
"""GET .../sheet/AHR: у лимитных строк (созданных синком на листе лимитов
|
||||
при создании формы) header.source == 'AHR_LIMIT'; у строки сателлита
|
||||
аренды (после POST .../sheet/AHR_RENT/line) — 'AHR_RENT'."""
|
||||
form_id = _create_form2(client, admin_tokens, auth_headers, year=2040)
|
||||
|
||||
ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id)
|
||||
limit_input_rows = [
|
||||
row
|
||||
for row in ahr_rows
|
||||
if row["row_type"] == "INPUT"
|
||||
and row["data"]["header"].get("expense_item_id") == MOBILE_EXPENSE_ITEM_ID
|
||||
]
|
||||
assert limit_input_rows, ahr_rows
|
||||
for row in limit_input_rows:
|
||||
assert row["data"]["header"]["source"] == "AHR_LIMIT", row
|
||||
|
||||
satellite_data = _add_satellite_line(
|
||||
client, admin_tokens, auth_headers, form_id, "AHR_RENT", vsp_id=1
|
||||
)
|
||||
ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id)
|
||||
rent_row = _find_ahr_row_by_line_id(ahr_rows, satellite_data["line_id"])
|
||||
assert rent_row["data"]["header"]["source"] == "AHR_RENT"
|
||||
|
||||
|
||||
def test_manual_line_on_limited_item_is_over_limit(client, admin_tokens, auth_headers):
|
||||
"""Ручная строка на статье с лимитом: header.source='MANUAL',
|
||||
is_over_limit=true; у ITEM этой статьи over_limit_year учитывает только
|
||||
ручную строку, limit_year — только лимитные строки, year — сумму обоих."""
|
||||
form_id = _create_form2(client, admin_tokens, auth_headers, year=2041)
|
||||
|
||||
manual_line_id = _add_manual_ahr_line(
|
||||
client, admin_tokens, auth_headers, form_id, MOBILE_EXPENSE_ITEM_ID
|
||||
)
|
||||
_patch_ahr_cell(
|
||||
client, admin_tokens, auth_headers, form_id, manual_line_id, "plan.q1", 1000
|
||||
)
|
||||
|
||||
ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id)
|
||||
manual_row = _find_ahr_row_by_line_id(ahr_rows, manual_line_id)
|
||||
assert manual_row["data"]["header"]["source"] == "MANUAL"
|
||||
assert manual_row["data"]["header"]["is_over_limit"] is True
|
||||
|
||||
item_row = _find_ahr_item_row(ahr_rows, MOBILE_EXPENSE_ITEM_ID)
|
||||
item_plan = item_row["data"]["plan"]
|
||||
assert item_plan["over_limit_year"] == 1000
|
||||
assert item_plan["year"] == item_plan["limit_year"] + item_plan["over_limit_year"]
|
||||
|
||||
|
||||
def test_manual_line_on_item_without_limit_is_not_over_limit(client, admin_tokens, auth_headers):
|
||||
"""Ручная строка на статье без лимита в каталоге: is_over_limit=false,
|
||||
over_limit_year не растёт (AURORA-1907: «сверх лимита» считается только
|
||||
для статей из листа лимитов)."""
|
||||
form_id = _create_form2(client, admin_tokens, auth_headers, year=2042)
|
||||
|
||||
manual_line_id = _add_manual_ahr_line(
|
||||
client, admin_tokens, auth_headers, form_id, NO_LIMIT_EXPENSE_ITEM_ID
|
||||
)
|
||||
_patch_ahr_cell(
|
||||
client, admin_tokens, auth_headers, form_id, manual_line_id, "plan.q1", 500
|
||||
)
|
||||
|
||||
ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id)
|
||||
manual_row = _find_ahr_row_by_line_id(ahr_rows, manual_line_id)
|
||||
assert manual_row["data"]["header"]["source"] == "MANUAL"
|
||||
assert manual_row["data"]["header"]["is_over_limit"] is False
|
||||
|
||||
item_row = _find_ahr_item_row(ahr_rows, NO_LIMIT_EXPENSE_ITEM_ID)
|
||||
assert item_row["data"]["plan"]["over_limit_year"] == 0
|
||||
|
||||
|
||||
def test_manual_line_named_like_limit_leaf_is_not_captured_by_limit_sync(
|
||||
client, admin_tokens, auth_headers
|
||||
):
|
||||
"""Регрессия на баг, который фиксит AURORA-1907: ручная строка с именем,
|
||||
совпадающим с LEAF лимита после снятия префикса "- " ("- Директор" ->
|
||||
"Директор"), не должна захватываться _ensure_form2_ahr_limit_sync_line
|
||||
при пересинхронизации (правка qty на листе лимитов) — план ручной строки
|
||||
не меняется, и её можно удалить (до фикса она считалась лимитной и
|
||||
удаление блокировалось v3.del_budget_line)."""
|
||||
form_id = _create_form2(client, admin_tokens, auth_headers, year=2043)
|
||||
|
||||
manual_line_id = _add_manual_ahr_line(
|
||||
client, admin_tokens, auth_headers, form_id, MOBILE_EXPENSE_ITEM_ID
|
||||
)
|
||||
_patch_ahr_cell(
|
||||
client, admin_tokens, auth_headers, form_id, manual_line_id, "header.name", "Директор"
|
||||
)
|
||||
_patch_ahr_cell(
|
||||
client, admin_tokens, auth_headers, form_id, manual_line_id, "plan.q1", 321
|
||||
)
|
||||
|
||||
limit_rows_resp = client.get(
|
||||
f"/api/v1/form/{form_id}/sheet/AHR_LIMIT",
|
||||
headers=auth_headers(admin_tokens),
|
||||
)
|
||||
assert limit_rows_resp.status_code == 200, limit_rows_resp.text
|
||||
leaf_row = next(
|
||||
row
|
||||
for row in limit_rows_resp.json()["result"]
|
||||
if row["row_type"] == "INPUT" and row["data"]["name"] == "- Директор"
|
||||
)
|
||||
leaf_id = leaf_row["data"]["id"]
|
||||
|
||||
patch_resp = client.patch(
|
||||
f"/api/v1/form/{form_id}/sheet/AHR_LIMIT/cell",
|
||||
json={"line_id": leaf_id, "column": "qty_q1", "value": 5},
|
||||
headers=auth_headers(admin_tokens),
|
||||
)
|
||||
assert patch_resp.status_code == 200, patch_resp.text
|
||||
|
||||
ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_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"] == 321
|
||||
assert manual_row["data"]["header"]["source"] == "MANUAL"
|
||||
|
||||
delete_resp = client.delete(
|
||||
f"/api/v1/form/{form_id}/sheet/AHR/line/{manual_line_id}",
|
||||
headers=auth_headers(admin_tokens),
|
||||
)
|
||||
assert delete_resp.status_code == 200, delete_resp.text
|
||||
|
||||
|
||||
async def test_backfill_sets_source_sheet_for_legacy_satellite_mapping():
|
||||
"""AURORA-1907: backfill (0053_form2_ahr_source_sheet.sql, шаг 2а) должен
|
||||
проставлять source_sheet строкам, у которых уже есть запись в
|
||||
v3.form2_ahr_satellite_line_source, но source_sheet ещё не заполнен —
|
||||
имитация состояния БД до миграции 0053. Отдельный engine (не общий
|
||||
src.db.base.SessionLocal — его пул может держать asyncpg-соединения,
|
||||
привязанные к event loop'у другого теста; NullPool+dispose исключают это),
|
||||
всё в одной незакоммиченной транзакции — rollback в finally, другие тесты
|
||||
не задевает."""
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from src.core.config import settings
|
||||
|
||||
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
Session = async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||
async with Session() as session:
|
||||
try:
|
||||
form_id = (
|
||||
await session.execute(
|
||||
text(
|
||||
"INSERT INTO v3.budget_form (form_type_code, year, org_unit_id) "
|
||||
"VALUES ('FORM_2', 2099, 1) RETURNING id"
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
line_id = (
|
||||
await session.execute(
|
||||
text(
|
||||
"INSERT INTO v3.budget_line (budget_form_id, expense_item_id, name, direction) "
|
||||
"VALUES (:form_id, 314, 'legacy rent row (AURORA-1907 backfill test)', 'Support') "
|
||||
"RETURNING id"
|
||||
),
|
||||
{"form_id": form_id},
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
await session.execute(
|
||||
text(
|
||||
"INSERT INTO v3.form2_ahr_satellite_line_source "
|
||||
"(source_sheet, source_id, target_line_id) "
|
||||
"VALUES ('AHR_RENT', 9999999, :line_id)"
|
||||
),
|
||||
{"line_id": line_id},
|
||||
)
|
||||
|
||||
before = (
|
||||
await session.execute(
|
||||
text("SELECT source_sheet FROM v3.budget_line WHERE id = :line_id"),
|
||||
{"line_id": line_id},
|
||||
)
|
||||
).scalar_one()
|
||||
assert before is None
|
||||
|
||||
# Тот же UPDATE, что в шаге 2а миграции 0053.
|
||||
await session.execute(
|
||||
text(
|
||||
"UPDATE v3.budget_line bl "
|
||||
"SET source_sheet = m.source_sheet "
|
||||
"FROM v3.form2_ahr_satellite_line_source m "
|
||||
"WHERE bl.id = m.target_line_id AND bl.source_sheet IS NULL"
|
||||
)
|
||||
)
|
||||
|
||||
after = (
|
||||
await session.execute(
|
||||
text("SELECT source_sheet FROM v3.budget_line WHERE id = :line_id"),
|
||||
{"line_id": line_id},
|
||||
)
|
||||
).scalar_one()
|
||||
assert after == "AHR_RENT"
|
||||
finally:
|
||||
await session.rollback()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_backfill_limit_lines_picks_min_id_per_name():
|
||||
"""AURORA-1907: backfill (0053_form2_ahr_source_sheet.sql, шаг 2б/2в) для
|
||||
строк, совпадающих по имени с LEAF лимита "- Директор" (статья 129) —
|
||||
строка с минимальным id помечается 'AHR_LIMIT', остальные с тем же именем
|
||||
остаются ручными (source_sheet=NULL). Отдельный engine (см. комментарий в
|
||||
test_backfill_sets_source_sheet_for_legacy_satellite_mapping выше), rollback
|
||||
в finally."""
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from src.core.config import settings
|
||||
|
||||
engine = create_async_engine(settings.DATABASE_URL, poolclass=NullPool)
|
||||
Session = async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||
async with Session() as session:
|
||||
try:
|
||||
form_id = (
|
||||
await session.execute(
|
||||
text(
|
||||
"INSERT INTO v3.budget_form (form_type_code, year, org_unit_id) "
|
||||
"VALUES ('FORM_2', 2098, 1) RETURNING id"
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
first_id = (
|
||||
await session.execute(
|
||||
text(
|
||||
"INSERT INTO v3.budget_line (budget_form_id, expense_item_id, name, direction) "
|
||||
"VALUES (:form_id, :eid, 'Директор', 'Support') RETURNING id"
|
||||
),
|
||||
{"form_id": form_id, "eid": MOBILE_EXPENSE_ITEM_ID},
|
||||
)
|
||||
).scalar_one()
|
||||
second_id = (
|
||||
await session.execute(
|
||||
text(
|
||||
"INSERT INTO v3.budget_line (budget_form_id, expense_item_id, name, direction) "
|
||||
"VALUES (:form_id, :eid, 'Директор', 'Support') RETURNING id"
|
||||
),
|
||||
{"form_id": form_id, "eid": MOBILE_EXPENSE_ITEM_ID},
|
||||
)
|
||||
).scalar_one()
|
||||
assert first_id < second_id
|
||||
|
||||
# Тот же UPDATE (шаг 2б миграции 0053).
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
WITH limit_candidates AS (
|
||||
SELECT
|
||||
bl.id AS line_id,
|
||||
MIN(bl.id) OVER (
|
||||
PARTITION BY bl.budget_form_id, bl.expense_item_id, leaf.leaf_name
|
||||
) AS min_line_id
|
||||
FROM v3.budget_line bl
|
||||
JOIN v3.budget_form bf
|
||||
ON bf.id = bl.budget_form_id
|
||||
AND bf.form_type_code = 'FORM_2'
|
||||
JOIN v3.expense_item ei ON ei.id = bl.expense_item_id
|
||||
JOIN v3.expense_item_form_type eft
|
||||
ON eft.expense_item_id = ei.id
|
||||
AND eft.form_type_code = 'FORM_2'
|
||||
JOIN LATERAL (
|
||||
SELECT regexp_replace(lt.name, '^[[:space:]]*-[[:space:]]*', '') AS leaf_name
|
||||
FROM v3.limit_template lt
|
||||
WHERE lt.row_type = 'LEAF'
|
||||
AND ei.sheet = 'AHR'
|
||||
AND ei.direction = 'Support'
|
||||
AND (
|
||||
(ei.depth = 3 AND ei.item_id = lt.expense_item_code)
|
||||
OR ei.num_group_id = lt.expense_item_code
|
||||
)
|
||||
AND bl.name = regexp_replace(lt.name, '^[[:space:]]*-[[:space:]]*', '')
|
||||
) leaf ON TRUE
|
||||
WHERE bl.source_sheet IS NULL
|
||||
)
|
||||
UPDATE v3.budget_line bl
|
||||
SET source_sheet = 'AHR_LIMIT'
|
||||
FROM limit_candidates lc
|
||||
WHERE bl.id = lc.line_id
|
||||
AND lc.line_id = lc.min_line_id
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT id, source_sheet FROM v3.budget_line "
|
||||
"WHERE id IN (:first_id, :second_id) ORDER BY id"
|
||||
),
|
||||
{"first_id": first_id, "second_id": second_id},
|
||||
)
|
||||
).all()
|
||||
by_id = {row.id: row.source_sheet for row in rows}
|
||||
assert by_id[first_id] == "AHR_LIMIT"
|
||||
assert by_id[second_id] is None
|
||||
finally:
|
||||
await session.rollback()
|
||||
await engine.dispose()
|
||||
@ -0,0 +1,220 @@
|
||||
"""AURORA-1906: колонка «СПОД» в сателлитах АХР (RENT/UTILITY/SECURITY) формы 2
|
||||
и её перенос в АХР (Support) через v3.sync_form2_ahr_satellite_line(s).
|
||||
|
||||
Сессия тестов работает в rollback-транзакции (см. tests/integration/conftest.py
|
||||
client fixture), поэтому изменения не переживают отдельный тест.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
# parent-строка в АХР для каждого сателлита — budget_line с этим
|
||||
# expense_item_id (v3.add_budget_line / v3.sync_form2_ahr_satellite_line,
|
||||
# api/alembic/versions/sql/0034_ahr_rf_sort_order.sql).
|
||||
SATELLITE_PARENT_EXPENSE_ITEM = {
|
||||
"AHR_RENT": 314,
|
||||
"AHR_UTILITY": 24,
|
||||
"AHR_SECURITY": 779,
|
||||
}
|
||||
|
||||
|
||||
def _auth_headers(tokens: dict) -> dict:
|
||||
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
||||
|
||||
|
||||
def _get_form2_id(client, admin_tokens) -> int | None:
|
||||
response = client.get(
|
||||
"/api/v1/form/",
|
||||
params={"form_type": "FORM_2"},
|
||||
headers=_auth_headers(admin_tokens),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
forms = response.json()["result"]
|
||||
return forms[0]["id"] if forms else None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def form2_id(client, admin_tokens):
|
||||
form_id = _get_form2_id(client, admin_tokens)
|
||||
if form_id is None:
|
||||
pytest.skip("В фикстуре нет формы 2 (FORM_2) — тест СПОД пропущен")
|
||||
return form_id
|
||||
|
||||
|
||||
def _add_satellite_line(client, admin_tokens, form2_id, sheet, vsp_id=1):
|
||||
response = client.post(
|
||||
f"/api/v1/form/{form2_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
|
||||
return rows[0]["data"]["id"]
|
||||
|
||||
|
||||
def _get_sheet_rows(client, admin_tokens, form2_id, sheet) -> list[dict]:
|
||||
response = client.get(
|
||||
f"/api/v1/form/{form2_id}/sheet/{sheet}",
|
||||
headers=_auth_headers(admin_tokens),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return response.json()["result"]
|
||||
|
||||
|
||||
def _find_row(rows: list[dict], predicate) -> dict:
|
||||
# v_form_view prepends a META row ({"editable": [...], "role": ..., ...},
|
||||
# row_type="META", depth=-1) with no "id"/"header" — only INPUT rows carry
|
||||
# satellite/AHR line data.
|
||||
for row in rows:
|
||||
if row.get("row_type") != "INPUT":
|
||||
continue
|
||||
if predicate(row["data"]):
|
||||
return row
|
||||
raise AssertionError("строка не найдена")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sheet", ["AHR_RENT", "AHR_UTILITY", "AHR_SECURITY"])
|
||||
def test_satellite_spod_written_and_included_in_q4_total(
|
||||
client, admin_tokens, form2_id, sheet
|
||||
):
|
||||
line_id = _add_satellite_line(client, admin_tokens, form2_id, sheet)
|
||||
|
||||
rows = _get_sheet_rows(client, admin_tokens, form2_id, sheet)
|
||||
row = _find_row(rows, lambda d: d["id"] == line_id)
|
||||
oct_ = row["data"]["fact_q4"]["oct"] or 0
|
||||
nov_ = row["data"]["fact_q4"]["nov"] or 0
|
||||
dec_ = row["data"]["fact_q4"]["dec"] or 0
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/form/{form2_id}/sheet/{sheet}/cell",
|
||||
json={"line_id": line_id, "column": "fact_q4.spod", "value": 5},
|
||||
headers=_auth_headers(admin_tokens),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
rows = _get_sheet_rows(client, admin_tokens, form2_id, sheet)
|
||||
row = _find_row(rows, lambda d: d["id"] == line_id)
|
||||
assert row["data"]["fact_q4"]["spod"] == 5
|
||||
assert row["data"]["fact_q4"]["total"] == oct_ + nov_ + dec_ + 5
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sheet", ["AHR_RENT", "AHR_UTILITY", "AHR_SECURITY"])
|
||||
def test_satellite_spod_syncs_into_ahr_support_quarter(
|
||||
client, admin_tokens, form2_id, sheet
|
||||
):
|
||||
line_id = _add_satellite_line(client, admin_tokens, form2_id, sheet)
|
||||
|
||||
ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR")
|
||||
ahr_row_before = _find_row(
|
||||
ahr_rows,
|
||||
lambda d: d.get("header", {}).get("expense_item_id")
|
||||
== SATELLITE_PARENT_EXPENSE_ITEM[sheet],
|
||||
)
|
||||
actual_quarter_before = ahr_row_before["data"]["q4"]["actual_quarter"] or 0
|
||||
fact_year_before = ahr_row_before["data"]["totals"]["fact_year"] or 0
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/form/{form2_id}/sheet/{sheet}/cell",
|
||||
json={"line_id": line_id, "column": "fact_q4.spod", "value": 5},
|
||||
headers=_auth_headers(admin_tokens),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
ahr_rows = _get_sheet_rows(client, admin_tokens, form2_id, "AHR")
|
||||
ahr_row_after = _find_row(
|
||||
ahr_rows,
|
||||
lambda d: d.get("header", {}).get("expense_item_id")
|
||||
== SATELLITE_PARENT_EXPENSE_ITEM[sheet],
|
||||
)
|
||||
assert ahr_row_after["data"]["q4"]["actual_spod"] == 5
|
||||
assert ahr_row_after["data"]["q4"]["actual_quarter"] == actual_quarter_before + 5
|
||||
assert ahr_row_after["data"]["totals"]["fact_year"] == fact_year_before + 5
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sheet", ["AHR_RENT", "AHR_UTILITY", "AHR_SECURITY"])
|
||||
def test_satellite_spod_rejects_other_quarters(
|
||||
client, admin_tokens, form2_id, sheet
|
||||
):
|
||||
line_id = _add_satellite_line(client, admin_tokens, form2_id, sheet)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/form/{form2_id}/sheet/{sheet}/cell",
|
||||
json={"line_id": line_id, "column": "fact_q1.spod", "value": 5},
|
||||
headers=_auth_headers(admin_tokens),
|
||||
)
|
||||
assert response.status_code in (400, 422), response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sheet", ["AHR_RENT", "AHR_UTILITY", "AHR_SECURITY"])
|
||||
def test_satellite_spod_rejected_before_phase_includes_spod_column(
|
||||
client, admin_tokens, isp_tokens, form2_id, sheet
|
||||
):
|
||||
"""DFIP-пользователь (isp1) с этапом, где column_keys ещё не включает
|
||||
'fact_q4.spod' (как до data-миграции): запись отклоняется.
|
||||
|
||||
Note: a failed request (403/400) makes the shared test-session's
|
||||
`_get_db` override call `db_ref.rollback()`, which would wipe out any
|
||||
uncommitted setup done earlier in the *same* test (the satellite line,
|
||||
the phase row) — see conftest.py's `client` fixture. So the "before"
|
||||
and "after" migration scenarios must be two separate tests, each
|
||||
ending in exactly one request that is allowed to fail.
|
||||
"""
|
||||
line_id = _add_satellite_line(client, admin_tokens, form2_id, sheet)
|
||||
|
||||
phase_code = f"spod_test_before_{sheet.lower()}"
|
||||
create_body = {
|
||||
"budget_form_id": form2_id,
|
||||
"sheet": sheet,
|
||||
"phase_code": phase_code,
|
||||
"role": "DFIP",
|
||||
"column_keys": ["fact_q4.dec"],
|
||||
"opens_at": "2020-01-01T00:00:00",
|
||||
"closes_at": "2099-01-01T00:00:00",
|
||||
"direction": None,
|
||||
}
|
||||
response = client.post(
|
||||
f"/api/v1/stages/form/{form2_id}",
|
||||
json=create_body,
|
||||
headers=_auth_headers(admin_tokens),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/form/{form2_id}/sheet/{sheet}/cell",
|
||||
json={"line_id": line_id, "column": "fact_q4.spod", "value": 5},
|
||||
headers=_auth_headers(isp_tokens),
|
||||
)
|
||||
assert response.status_code in (400, 403, 409), response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sheet", ["AHR_RENT", "AHR_UTILITY", "AHR_SECURITY"])
|
||||
def test_satellite_spod_allowed_once_phase_includes_spod_column(
|
||||
client, admin_tokens, isp_tokens, form2_id, sheet
|
||||
):
|
||||
"""DFIP-пользователь (isp1) с этапом, где column_keys уже включает
|
||||
'fact_q4.spod' (как после data-миграции из этого PR): запись проходит."""
|
||||
line_id = _add_satellite_line(client, admin_tokens, form2_id, sheet)
|
||||
|
||||
phase_code = f"spod_test_after_{sheet.lower()}"
|
||||
response = client.post(
|
||||
f"/api/v1/stages/form/{form2_id}",
|
||||
json={
|
||||
"budget_form_id": form2_id,
|
||||
"sheet": sheet,
|
||||
"phase_code": phase_code,
|
||||
"role": "DFIP",
|
||||
"column_keys": ["fact_q4.dec", "fact_q4.spod"],
|
||||
"opens_at": "2020-01-01T00:00:00",
|
||||
"closes_at": "2099-01-01T00:00:00",
|
||||
"direction": None,
|
||||
},
|
||||
headers=_auth_headers(admin_tokens),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1/form/{form2_id}/sheet/{sheet}/cell",
|
||||
json={"line_id": line_id, "column": "fact_q4.spod", "value": 5},
|
||||
headers=_auth_headers(isp_tokens),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
@ -9,13 +9,47 @@ from src.services.export_service import ExportService
|
||||
from src.services.export_service.export_mappings import (
|
||||
FORM1_FLAT_COLUMNS,
|
||||
SMETA_COLUMNS,
|
||||
SMETA_COLUMNS_BY_FORM,
|
||||
)
|
||||
from src.services.export_service.export_writers import (
|
||||
DepthSheetWriter,
|
||||
TabularSheetWriter,
|
||||
XlsxDepthSheetWriter,
|
||||
XlsxTabularSheetWriter,
|
||||
)
|
||||
|
||||
# Эталонные порядки колонок листа "Смета" экрана каждой формы (AURORA-1903).
|
||||
SMETA_SCREEN_COLUMNS_BY_FORM = {
|
||||
"FORM_1": [
|
||||
"section_code", "name",
|
||||
"plan_support_q1", "plan_support_q2", "plan_support_q3", "plan_support_q4", "plan_support_year",
|
||||
"plan_development_q1", "plan_development_q2", "plan_development_q3", "plan_development_q4", "plan_development_year",
|
||||
"plan_total_year",
|
||||
"approved_support_q1", "approved_support_q2", "approved_support_q3", "approved_support_q4", "approved_support_year",
|
||||
"approved_development_q1", "approved_development_q2", "approved_development_q3", "approved_development_q4", "approved_development_year",
|
||||
"approved_total_year",
|
||||
"fact_support_q1", "fact_support_q2", "fact_support_q3", "fact_support_q4", "fact_support_year",
|
||||
"fact_development_q1", "fact_development_q2", "fact_development_q3", "fact_development_q4", "fact_development_year",
|
||||
"fact_total_year",
|
||||
"corrected_support_q2", "corrected_support_q3", "corrected_support_q4",
|
||||
"corrected_development_q2", "corrected_development_q3", "corrected_development_q4",
|
||||
],
|
||||
"FORM_2": [
|
||||
"section_code", "name",
|
||||
"plan_support_q1", "plan_support_q2", "plan_support_q3", "plan_support_q4", "plan_support_year",
|
||||
"approved_support_q1", "approved_support_q2", "approved_support_q3", "approved_support_q4", "approved_support_year",
|
||||
"fact_support_q1", "fact_support_q2", "fact_support_q3", "fact_support_q4", "fact_support_year",
|
||||
"corrected_support_q2", "corrected_support_q3", "corrected_support_q4",
|
||||
],
|
||||
"FORM_4": [
|
||||
"section_code", "name",
|
||||
"plan_development_q1", "plan_development_q2", "plan_development_q3", "plan_development_q4", "plan_development_year",
|
||||
"approved_development_q1", "approved_development_q2", "approved_development_q3", "approved_development_q4", "approved_development_year",
|
||||
"fact_development_q1", "fact_development_q2", "fact_development_q3", "fact_development_q4", "fact_development_year",
|
||||
"corrected_development_q2", "corrected_development_q3", "corrected_development_q4",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("backend", ["openpyxl", "xlsxwriter", "xlsxwriter_columns", "xlsxwriter_rows", "xlsxwriter_palette"])
|
||||
@pytest.mark.parametrize("name_key", ["name", "header.name"])
|
||||
@ -87,6 +121,91 @@ def test_sheet_column_contracts_are_stable():
|
||||
assert len(SMETA_COLUMNS) == 41
|
||||
assert FORM1_FLAT_COLUMNS[0] == ("section", "Код раздела")
|
||||
assert SMETA_COLUMNS[0] == ("section_code", "Код раздела")
|
||||
# AURORA-1903: SMETA_COLUMNS остаётся алиасом на FORM_1-раскладку.
|
||||
assert SMETA_COLUMNS is SMETA_COLUMNS_BY_FORM["FORM_1"]
|
||||
assert [key for key, _ in SMETA_COLUMNS_BY_FORM["FORM_2"]] == SMETA_SCREEN_COLUMNS_BY_FORM["FORM_2"]
|
||||
assert [key for key, _ in SMETA_COLUMNS_BY_FORM["FORM_4"]] == SMETA_SCREEN_COLUMNS_BY_FORM["FORM_4"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("form_type_code", ["FORM_1", "FORM_2", "FORM_4"])
|
||||
def test_smeta_writer_columns_match_screen_order_per_form(form_type_code):
|
||||
"""AURORA-1903: лист "Смета" каждой формы выгружается в порядке своего экрана."""
|
||||
service = ExportService(AsyncMock())
|
||||
workbook = XlsxWorkbook(BytesIO())
|
||||
writers = service._create_xlsxwriter_sheet_writers(
|
||||
workbook=workbook,
|
||||
form_type_code=form_type_code,
|
||||
year=2024,
|
||||
)
|
||||
smeta_writer = writers["SMETA"]
|
||||
assert [key for key, _ in smeta_writer.columns] == SMETA_SCREEN_COLUMNS_BY_FORM[form_type_code]
|
||||
workbook.close()
|
||||
|
||||
|
||||
def test_smeta_writer_falls_back_to_form1_layout_for_unknown_form_type():
|
||||
service = ExportService(AsyncMock())
|
||||
workbook = XlsxWorkbook(BytesIO())
|
||||
writers = service._create_xlsxwriter_sheet_writers(
|
||||
workbook=workbook,
|
||||
form_type_code=None,
|
||||
year=2024,
|
||||
)
|
||||
assert [key for key, _ in writers["SMETA"].columns] == SMETA_SCREEN_COLUMNS_BY_FORM["FORM_1"]
|
||||
workbook.close()
|
||||
|
||||
|
||||
class _FakeXlsxWorksheet:
|
||||
"""Фейковый worksheet, фиксирующий ровно то, что было записано."""
|
||||
|
||||
def __init__(self):
|
||||
self.written_rows: dict[int, list] = {}
|
||||
|
||||
def write_row(self, row, col, values):
|
||||
self.written_rows[row] = list(values)
|
||||
|
||||
def set_column(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
def test_xlsx_tabular_sheet_writer_skips_meta_row():
|
||||
writer = XlsxTabularSheetWriter(
|
||||
workbook=object(),
|
||||
columns=[("section_code", "Код раздела"), ("name", "Наименование"), ("plan_support_q1", "План поддержка I квартал")],
|
||||
row_normalizer=lambda row: row,
|
||||
value_serializer=lambda value: value,
|
||||
)
|
||||
worksheet = _FakeXlsxWorksheet()
|
||||
rows = [
|
||||
("META", -1, 0, {"ignored": True}),
|
||||
("ROOT", 0, 1, {"section_code": "1", "name": "x", "plan_support_q1": 10}),
|
||||
]
|
||||
writer.write(worksheet, rows)
|
||||
|
||||
assert set(worksheet.written_rows.keys()) == {0, 1}
|
||||
assert worksheet.written_rows[0] == ["Код раздела", "Наименование", "План поддержка I квартал"]
|
||||
assert worksheet.written_rows[1] == ["1", "x", 10]
|
||||
|
||||
|
||||
def test_tabular_sheet_writer_skips_meta_row_openpyxl():
|
||||
writer = TabularSheetWriter(
|
||||
columns=[("a", "A"), ("b", "B")],
|
||||
row_normalizer=lambda row: row,
|
||||
value_serializer=lambda value: value,
|
||||
)
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
|
||||
rows = [
|
||||
("META", -1, 0, {"a": "should-not-appear", "b": "should-not-appear"}),
|
||||
("ROOT", 0, 1, {"a": 1, "b": 2}),
|
||||
]
|
||||
writer.write(ws, rows)
|
||||
|
||||
assert ws.cell(row=1, column=1).value == "A"
|
||||
assert ws.cell(row=1, column=2).value == "B"
|
||||
assert ws.cell(row=2, column=1).value == 1
|
||||
assert ws.cell(row=2, column=2).value == 2
|
||||
assert ws.cell(row=3, column=1).value is None
|
||||
|
||||
|
||||
def test_export_service_uses_sheet_writer_strategy():
|
||||
|
||||
@ -7,6 +7,10 @@ from pathlib import Path
|
||||
|
||||
|
||||
SQL_PATH = Path(__file__).resolve().parents[2] / "alembic/versions/sql/0049_summary2_related_records.sql"
|
||||
# AURORA-1906 (0050) переопределяет v_svod2_go целиком (CREATE OR REPLACE) и
|
||||
# добавляет туда проверку rent_detail.actual_spod — патч применяется поверх
|
||||
# 0049 ниже (см. setUp), v_svod2_rf/v_svod2_go_export 0050 не трогает.
|
||||
SQL_PATCH_PATH = Path(__file__).resolve().parents[2] / "alembic/versions/sql/0050_form2_ahr_spod_column.sql"
|
||||
TABLES = (
|
||||
"plan", "reserve", "contract_summary", "allocation", "collegial_approval",
|
||||
"ckk", "contract_detail", "sequestration", "booking", "budget_line_quarter",
|
||||
@ -58,18 +62,28 @@ class Summary2RelatedRecordsSQLTest(unittest.TestCase):
|
||||
columns += ", expenses_q1, expenses_q2, expenses_q3, expenses_q4, vat_rate, deadline"
|
||||
self.db.execute(f"CREATE TABLE v3.{table} ({columns})")
|
||||
# Execute the eligibility SELECTs, adapting PostgreSQL casts, arrays and regex syntax.
|
||||
self.queries = []
|
||||
for part in SQL_PATH.read_text().split("eligible_lines AS (")[1:]:
|
||||
query = part.split("), ", 1)[0].replace("::varchar", "")
|
||||
def adapt(query):
|
||||
query = query.replace("::varchar", "")
|
||||
query = re.sub(r"(presence_\w+\.\w+) ~ '\[\^\[:space:\]\]'",
|
||||
r"has_text(\1)", query)
|
||||
query = query.replace("p_year", ":year")
|
||||
query = query.replace("cardinality(p_org_unit_ids)", "1")
|
||||
query = query.replace("ANY(p_org_unit_ids)", ":org_id")
|
||||
query = query.replace("p_org_unit_ids", ":org_id")
|
||||
self.queries.append(query)
|
||||
return query
|
||||
|
||||
self.queries = [
|
||||
adapt(part.split("), ", 1)[0])
|
||||
for part in SQL_PATH.read_text().split("eligible_lines AS (")[1:]
|
||||
]
|
||||
self.assertEqual(len(self.queries), 3)
|
||||
|
||||
# v_svod2_go (index 0) is redefined whole by 0050 (CREATE OR REPLACE),
|
||||
# which is the current source of truth for its eligible_lines CTE.
|
||||
patch_parts = SQL_PATCH_PATH.read_text().split("eligible_lines AS (")[1:]
|
||||
if patch_parts:
|
||||
self.queries[0] = adapt(patch_parts[0].split("), ", 1)[0])
|
||||
|
||||
def rows(self, index, year=2026, org_id=None):
|
||||
return self.db.execute(self.queries[index], {"year": year, "org_id": org_id}).fetchall()
|
||||
|
||||
@ -118,6 +132,11 @@ class Summary2RelatedRecordsSQLTest(unittest.TestCase):
|
||||
self.db.execute("DELETE FROM v3.reserve")
|
||||
|
||||
def test_each_meaningful_plan_quarter_or_rent_field_qualifies(self):
|
||||
# AURORA-1906 (0050) added rent_detail.actual_spod and made only
|
||||
# v_svod2_go (index 0) qualify a line by it — v_svod2_rf/
|
||||
# v_svod2_go_export (index 1/2) were out of that ticket's scope and
|
||||
# still don't check actual_spod (known gap, see PR Rulings).
|
||||
unpatched_spod_indexes = {1, 2}
|
||||
for index in range(3):
|
||||
self.db.execute("UPDATE v3.budget_form SET form_type_code = ?",
|
||||
("FORM_2" if index == 1 else "FORM_1",))
|
||||
@ -127,7 +146,12 @@ class Summary2RelatedRecordsSQLTest(unittest.TestCase):
|
||||
for value in values:
|
||||
with self.subTest(query=index, table=table, field=field, value=value):
|
||||
self.db.execute(f"INSERT INTO v3.{table}(line_id, {field}) VALUES (1, ?)", (value,))
|
||||
self.assertEqual(len(self.rows(index)), 1)
|
||||
expected = (
|
||||
0
|
||||
if table == "rent_detail" and field == "actual_spod" and index in unpatched_spod_indexes
|
||||
else 1
|
||||
)
|
||||
self.assertEqual(len(self.rows(index)), expected)
|
||||
self.db.execute(f"DELETE FROM v3.{table}")
|
||||
|
||||
def test_source_year_org_and_sheet_filters_are_preserved(self):
|
||||
|
||||
@ -65,6 +65,23 @@ const CONTAINER_STYLES = {
|
||||
wordBreak: 'break-word',
|
||||
};
|
||||
|
||||
// AURORA-1907: метка «сверх лимита» в ячейке наименования строки для ручных
|
||||
// строк на статьях, у которых есть лимит (header.is_over_limit) — только чип,
|
||||
// без заливки всей строки/ячейки цветом (см. AURORA-1890 для контекста).
|
||||
const OVER_LIMIT_CHIP_STYLES = {
|
||||
marginLeft: 6,
|
||||
padding: '1px 6px',
|
||||
borderRadius: 10,
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
color: '#ffffff',
|
||||
backgroundColor: '#D32F2F',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const NAME_COLUMN_IDS = new Set(['data.header.name', 'data.name']);
|
||||
|
||||
const getColorBrightness = (hexColor) => {
|
||||
if (!hexColor) return 255;
|
||||
const color = hexColor.replace('#', '');
|
||||
@ -132,7 +149,9 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
|
||||
const isLocked = useCellLock(cellKey);
|
||||
|
||||
const isVspDropdown = column?.columnDef?.editType === 'vsp_dropdown';
|
||||
const nameIndent = column.id === 'data.header.name' || column.id === 'data.name' ? 8 + row.depth * 16 : 8;
|
||||
const isNameColumn = NAME_COLUMN_IDS.has(column.id);
|
||||
const nameIndent = isNameColumn ? 8 + row.depth * 16 : 8;
|
||||
const isOverLimit = isNameColumn && row.original?.data?.header?.is_over_limit === true;
|
||||
|
||||
const { rawValue, displayValue, isNumeric } = useMemo(() => {
|
||||
const value = cell.getValue();
|
||||
@ -171,9 +190,9 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
|
||||
textAlign: isNumeric ? 'right' : 'left',
|
||||
backgroundColor: isLocked ? '#f5f5f5' : backgroundColor,
|
||||
color: textColor,
|
||||
...(isEditable ? EDITABLE_STYLES : NOT_EDITABLE_STYLES),
|
||||
...(isLocked ? LOCKED_STYLES : {}),
|
||||
...(isInvalid ? INVALID_STYLES : {}),
|
||||
...(isEditable ? EDITABLE_STYLES : NOT_EDITABLE_STYLES),
|
||||
}),
|
||||
[backgroundColor, isInvalid, isLocked, textColor, isEditable, isNumeric, nameIndent],
|
||||
);
|
||||
@ -193,6 +212,7 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
|
||||
<div type='button' className='cell' style={cellStyles} onClick={handleClick}>
|
||||
<span style={CONTAINER_STYLES}>{highlightedContent}</span>
|
||||
{isOverLimit && <span style={OVER_LIMIT_CHIP_STYLES}>сверх лимита</span>}
|
||||
{lockBadge}
|
||||
</div>
|
||||
);
|
||||
@ -211,7 +231,8 @@ const Cell = React.memo(CellComponent, (prevProps, nextProps) => {
|
||||
prevProps.isInvalid === nextProps.isInvalid &&
|
||||
prevProps.isEditable === nextProps.isEditable &&
|
||||
prevProps.vspOptions === nextProps.vspOptions &&
|
||||
prevProps.displayFactor === nextProps.displayFactor
|
||||
prevProps.displayFactor === nextProps.displayFactor &&
|
||||
prevProps.row.original?.data?.header?.is_over_limit === nextProps.row.original?.data?.header?.is_over_limit
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -569,6 +569,37 @@ export const config = {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Итого: {year}",
|
||||
accessorKey: "itogo_2024_utverzhdennye",
|
||||
columns: [
|
||||
{
|
||||
header: "",
|
||||
accessorKey: "itogo_2024_utverzhdennye_field",
|
||||
columns: [
|
||||
{
|
||||
header: "",
|
||||
accessorKey: "itogo_2024_utverzhdennye_field_field",
|
||||
columns: [
|
||||
{
|
||||
header: "",
|
||||
accessorKey: "data.approved.total_year",
|
||||
columnLetter: "Z",
|
||||
size: 150,
|
||||
filterFn: "contains",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
muiTableHeadCellProps: {
|
||||
sx: {
|
||||
backgroundColor: "#BFBFBF",
|
||||
color: "#000000",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Фактические расходы за {year} год, {unit}",
|
||||
accessorKey: "fakticheskie_raskhody_za_2024_god",
|
||||
@ -805,6 +836,37 @@ export const config = {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Итого: {year}",
|
||||
accessorKey: "itogo_2024_fakticheskie",
|
||||
columns: [
|
||||
{
|
||||
header: "",
|
||||
accessorKey: "itogo_2024_fakticheskie_field",
|
||||
columns: [
|
||||
{
|
||||
header: "",
|
||||
accessorKey: "itogo_2024_fakticheskie_field_field",
|
||||
columns: [
|
||||
{
|
||||
header: "",
|
||||
accessorKey: "data.fact.total_year",
|
||||
columnLetter: "AL",
|
||||
size: 150,
|
||||
filterFn: "contains",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
muiTableHeadCellProps: {
|
||||
sx: {
|
||||
backgroundColor: "#BFBFBF",
|
||||
color: "#000000",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Скорректированная смета расходов на {year} год, {unit}",
|
||||
accessorKey: "skorrektirovannaya_smeta_raskhodov_na_2024_god",
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import test from 'node:test';
|
||||
|
||||
// SMETA.js is imported elsewhere via an extensionless path ("../columnColors")
|
||||
// that only the project's bundler (Rsbuild) resolves, so plain `node --test`
|
||||
// cannot `import` this module directly (ERR_MODULE_NOT_FOUND). Per AURORA-1903
|
||||
// and its precedent (AURORA-1901's form2SmetaConfig test), we instead match the
|
||||
// file's text for its `accessorKey: "data...."` leaves and assert their order.
|
||||
const sourcePath = fileURLToPath(new URL('./SMETA.js', import.meta.url));
|
||||
const source = readFileSync(sourcePath, 'utf8');
|
||||
|
||||
// AURORA-1903: экран формы 1 должен показывать "Утверждено ИТОГО Год" (Z) и
|
||||
// "Факт ИТОГО Год" (AL) — раньше их теряли, хотя SQL (v_form_view) всегда их
|
||||
// отдавал и выгрузка Excel их писала. Порядок ниже — источник истины для
|
||||
// колонок листа "Смета" формы 1: он литерально совпадает с порядком ключей
|
||||
// SMETA_COLUMNS_BY_FORM["FORM_1"] в api/src/services/export_service/export_mappings.py.
|
||||
const EXPECTED_DATA_ACCESSOR_KEY_ORDER = [
|
||||
'data.section_code',
|
||||
'data.name',
|
||||
'data.plan.support.q1',
|
||||
'data.plan.support.q2',
|
||||
'data.plan.support.q3',
|
||||
'data.plan.support.q4',
|
||||
'data.plan.support.year',
|
||||
'data.plan.development.q1',
|
||||
'data.plan.development.q2',
|
||||
'data.plan.development.q3',
|
||||
'data.plan.development.q4',
|
||||
'data.plan.development.year',
|
||||
'data.plan.total_year',
|
||||
'data.approved.support.q1',
|
||||
'data.approved.support.q2',
|
||||
'data.approved.support.q3',
|
||||
'data.approved.support.q4',
|
||||
'data.approved.support.year',
|
||||
'data.approved.development.q1',
|
||||
'data.approved.development.q2',
|
||||
'data.approved.development.q3',
|
||||
'data.approved.development.q4',
|
||||
'data.approved.development.year',
|
||||
'data.approved.total_year',
|
||||
'data.fact.support.q1',
|
||||
'data.fact.support.q2',
|
||||
'data.fact.support.q3',
|
||||
'data.fact.support.q4',
|
||||
'data.fact.support.year',
|
||||
'data.fact.development.q1',
|
||||
'data.fact.development.q2',
|
||||
'data.fact.development.q3',
|
||||
'data.fact.development.q4',
|
||||
'data.fact.development.year',
|
||||
'data.fact.total_year',
|
||||
'data.corrected.support.q2',
|
||||
'data.corrected.support.q3',
|
||||
'data.corrected.support.q4',
|
||||
'data.corrected.development.q2',
|
||||
'data.corrected.development.q3',
|
||||
'data.corrected.development.q4',
|
||||
];
|
||||
|
||||
function extractDataAccessorKeysInOrder(text) {
|
||||
const re = /accessorKey:\s*"(data\.[^"]+)"/g;
|
||||
const keys = [];
|
||||
let match;
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
keys.push(match[1]);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
test('FORM_1 SMETA screen leaf accessorKeys are in the ticket-mandated order (AURORA-1903)', () => {
|
||||
const keys = extractDataAccessorKeysInOrder(source);
|
||||
assert.deepEqual(keys, EXPECTED_DATA_ACCESSOR_KEY_ORDER);
|
||||
});
|
||||
|
||||
test('FORM_1 SMETA screen has exactly one approved.total_year (Z) and one fact.total_year (AL) column', () => {
|
||||
const keys = extractDataAccessorKeysInOrder(source);
|
||||
assert.equal(keys.filter((key) => key === 'data.approved.total_year').length, 1);
|
||||
assert.equal(keys.filter((key) => key === 'data.fact.total_year').length, 1);
|
||||
assert.match(source, /accessorKey:\s*"data\.approved\.total_year"[\s\S]{0,80}columnLetter:\s*"Z"/);
|
||||
assert.match(source, /accessorKey:\s*"data\.fact\.total_year"[\s\S]{0,80}columnLetter:\s*"AL"/);
|
||||
});
|
||||
@ -49,6 +49,14 @@ export const config = {
|
||||
color_type: greenColumn,
|
||||
accessorKey: "data.plan.year",
|
||||
},
|
||||
"data.plan.limit_year": {
|
||||
color_type: greenColumn,
|
||||
accessorKey: "data.plan.limit_year",
|
||||
},
|
||||
"data.plan.over_limit_year": {
|
||||
color_type: greenColumn,
|
||||
accessorKey: "data.plan.over_limit_year",
|
||||
},
|
||||
"data.plan.comment": {
|
||||
color_type: greenColumn,
|
||||
accessorKey: "data.plan.comment",
|
||||
@ -828,6 +836,38 @@ export const config = {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// AURORA-1907: вычисляемая колонка (суммы плана лимитных
|
||||
// строк source_sheet='AHR_LIMIT' по дереву статей),
|
||||
// нередактируемая — нет случая в v3._apply_form_cell для
|
||||
// plan.limit_year, как и для соседних вычисляемых полей
|
||||
// (например data.q1.actual_quarter).
|
||||
header: "В пределах лимита, год",
|
||||
accessorKey: "data.plan.limit_year",
|
||||
size: 170,
|
||||
filterFn: "contains",
|
||||
muiTableHeadCellProps: {
|
||||
sx: {
|
||||
backgroundColor: "#FFFFFF",
|
||||
color: "#000000",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// AURORA-1907: вычисляемая колонка (план ручных строк на
|
||||
// статьях с лимитом), нередактируемая — см. комментарий у
|
||||
// data.plan.limit_year выше.
|
||||
header: "Сверх лимита, год",
|
||||
accessorKey: "data.plan.over_limit_year",
|
||||
size: 170,
|
||||
filterFn: "contains",
|
||||
muiTableHeadCellProps: {
|
||||
sx: {
|
||||
backgroundColor: "#FFFFFF",
|
||||
color: "#000000",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Комментарий",
|
||||
accessorKey: "data.plan.comment",
|
||||
|
||||
@ -118,9 +118,9 @@ export const config = {
|
||||
color_type: whiteColumn,
|
||||
accessorKey: 'data.fact_q4.dec',
|
||||
},
|
||||
fakticheskie_raskhody_za_iv_kv_2026_goda_spod_field_v_tys_rubley: {
|
||||
'data.fact_q4.spod': {
|
||||
color_type: whiteColumn,
|
||||
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_spod_field_v_tys_rubley',
|
||||
accessorKey: 'data.fact_q4.spod',
|
||||
},
|
||||
'data.fact_q4.total': {
|
||||
color_type: whiteColumn,
|
||||
@ -903,6 +903,32 @@ export const config = {
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
header: 'СПОД',
|
||||
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_spod',
|
||||
columns: [
|
||||
{
|
||||
header: '',
|
||||
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_spod_field',
|
||||
columns: [
|
||||
{
|
||||
header: 'В тыс. рублей',
|
||||
accessorKey: 'data.fact_q4.spod',
|
||||
columnLetter: 'AR',
|
||||
size: 150,
|
||||
filterFn: 'contains',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
muiTableHeadCellProps: {
|
||||
sx: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
color: '#000000',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
header: 'Итог IV кв.',
|
||||
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_itog_iv_kv',
|
||||
|
||||
@ -110,9 +110,9 @@ export const config = {
|
||||
color_type: whiteColumn,
|
||||
accessorKey: 'data.fact_q4.dec',
|
||||
},
|
||||
fakticheskie_raskhody_za_iv_kv_2026_goda_spod_field_v_tys_rubley: {
|
||||
'data.fact_q4.spod': {
|
||||
color_type: whiteColumn,
|
||||
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_spod_field_v_tys_rubley',
|
||||
accessorKey: 'data.fact_q4.spod',
|
||||
},
|
||||
'data.fact_q4.total': {
|
||||
color_type: whiteColumn,
|
||||
@ -857,6 +857,32 @@ export const config = {
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
header: 'СПОД',
|
||||
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_spod',
|
||||
columns: [
|
||||
{
|
||||
header: '',
|
||||
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_spod_field',
|
||||
columns: [
|
||||
{
|
||||
header: 'В тыс. рублей',
|
||||
accessorKey: 'data.fact_q4.spod',
|
||||
columnLetter: 'AP',
|
||||
size: 150,
|
||||
filterFn: 'contains',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
muiTableHeadCellProps: {
|
||||
sx: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
color: '#000000',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
header: 'Итог IV кв.',
|
||||
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_itog_iv_kv',
|
||||
|
||||
@ -110,9 +110,9 @@ export const config = {
|
||||
color_type: whiteColumn,
|
||||
accessorKey: 'data.fact_q4.dec',
|
||||
},
|
||||
fakticheskie_raskhody_za_iv_kv_2026_goda_spod_field_v_tys_rubley: {
|
||||
'data.fact_q4.spod': {
|
||||
color_type: whiteColumn,
|
||||
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_spod_field_v_tys_rubley',
|
||||
accessorKey: 'data.fact_q4.spod',
|
||||
},
|
||||
'data.fact_q4.total': {
|
||||
color_type: whiteColumn,
|
||||
@ -858,6 +858,32 @@ export const config = {
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
header: 'СПОД',
|
||||
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_spod',
|
||||
columns: [
|
||||
{
|
||||
header: '',
|
||||
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_spod_field',
|
||||
columns: [
|
||||
{
|
||||
header: 'В тыс. рублей',
|
||||
accessorKey: 'data.fact_q4.spod',
|
||||
columnLetter: 'AP',
|
||||
size: 150,
|
||||
filterFn: 'contains',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
muiTableHeadCellProps: {
|
||||
sx: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
color: '#000000',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
header: 'Итог IV кв.',
|
||||
accessorKey: 'fakticheskie_raskhody_za_iv_kv_2026_goda_itog_iv_kv',
|
||||
|
||||
@ -3,6 +3,7 @@ import { toast } from 'react-toastify';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { buildCellUpdatedMessage } from '../utils/messageUtils';
|
||||
import { getRowId } from '../utils/rowUtils';
|
||||
import { getWsErrorMessage } from '../utils/wsErrorUtils';
|
||||
|
||||
const RealtimeActionsContext = createContext(null);
|
||||
const RealtimeConnectionContext = createContext(null);
|
||||
@ -106,8 +107,8 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
|
||||
const handleMessage = useCallback((data) => {
|
||||
// Обработка ошибок
|
||||
if (data.error) {
|
||||
console.log(data.error.message);
|
||||
toast.error(`Server error:${data.error.message}` || '');
|
||||
console.log(data.error);
|
||||
toast.error(getWsErrorMessage(data.error));
|
||||
onErrorRef.current?.(data.error);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// AHR_RENT.js / AHR_UTILITY.js / AHR_SECURITY.js импортируют '../columnColors'
|
||||
// без расширения, поэтому node не может загрузить модуль напрямую — читаем
|
||||
// файл как текст и вытаскиваем accessorKey листьев регэкспом (см.
|
||||
// form2SmetaConfig.test.js, AURORA-1901, для образца этого подхода).
|
||||
|
||||
const SHEETS = ['AHR_RENT', 'AHR_UTILITY', 'AHR_SECURITY'];
|
||||
|
||||
function readConfigSource(sheet) {
|
||||
const configPath = fileURLToPath(new URL(`../constants/FORM_2/${sheet}.js`, import.meta.url));
|
||||
return readFileSync(configPath, 'utf8');
|
||||
}
|
||||
|
||||
function extractLeafAccessorKeys(source) {
|
||||
const matches = source.matchAll(/accessorKey:\s*'(data\.[^']+)'/g);
|
||||
return [...matches].map((m) => m[1]);
|
||||
}
|
||||
|
||||
for (const sheet of SHEETS) {
|
||||
test(`${sheet}: колонка СПОД стоит между Декабрём и Итогом IV кв.`, () => {
|
||||
const keys = extractLeafAccessorKeys(readConfigSource(sheet));
|
||||
const decIndex = keys.indexOf('data.fact_q4.dec');
|
||||
const spodIndex = keys.indexOf('data.fact_q4.spod');
|
||||
const totalIndex = keys.indexOf('data.fact_q4.total');
|
||||
|
||||
assert.notEqual(decIndex, -1, 'data.fact_q4.dec должен присутствовать в конфиге');
|
||||
assert.notEqual(spodIndex, -1, 'data.fact_q4.spod должен присутствовать в конфиге');
|
||||
assert.notEqual(totalIndex, -1, 'data.fact_q4.total должен присутствовать в конфиге');
|
||||
assert.deepEqual(
|
||||
[decIndex, spodIndex, totalIndex],
|
||||
[decIndex, decIndex + 1, decIndex + 2],
|
||||
'ожидается порядок ..., data.fact_q4.dec, data.fact_q4.spod, data.fact_q4.total',
|
||||
);
|
||||
});
|
||||
|
||||
test(`${sheet}: колонка СПОД не оставляет висящий ключ в colors`, () => {
|
||||
const source = readConfigSource(sheet);
|
||||
assert.ok(
|
||||
!source.includes('fakticheskie_raskhody_za_iv_kv_2026_goda_spod_field_v_tys_rubley'),
|
||||
'старый висящий ключ цвета СПОД должен быть заменён на data.fact_q4.spod',
|
||||
);
|
||||
});
|
||||
}
|
||||
35
web/src/components/RealtimeTable/utils/wsErrorUtils.js
Normal file
35
web/src/components/RealtimeTable/utils/wsErrorUtils.js
Normal file
@ -0,0 +1,35 @@
|
||||
// Человеческие тексты для технических префиксов ошибок, которые шлёт backend
|
||||
// (api/src/core/CONSTANTS.py: VALIDATION_PREFIXES, PG_403_ERRORS, и org_not_assigned из прав доступа).
|
||||
const KNOWN_PREFIX_MESSAGES = [
|
||||
{ prefix: 'computed_field:', message: 'Это расчётная ячейка, её нельзя изменить' },
|
||||
{ prefix: 'role_not_allowed:', message: 'Нет прав на изменение этой колонки' },
|
||||
{ prefix: 'window_closed:', message: 'Окно ввода для этой колонки закрыто' },
|
||||
{ prefix: 'org_not_assigned', message: 'Вы не назначены на эту форму' },
|
||||
];
|
||||
|
||||
const DEFAULT_ERROR_MESSAGE = 'Неизвестная ошибка сервера';
|
||||
|
||||
// Если текст начинается с известного технического префикса — возвращает человеческую замену, иначе null.
|
||||
const resolveKnownPrefix = (text) => {
|
||||
const normalized = String(text ?? '').trim();
|
||||
if (!normalized) return null;
|
||||
const lower = normalized.toLowerCase();
|
||||
const match = KNOWN_PREFIX_MESSAGES.find(({ prefix }) => lower.startsWith(prefix));
|
||||
return match ? match.message : null;
|
||||
};
|
||||
|
||||
// Приводит ошибку WebSocket (строка или объект {code, field, message}/{detail}) к читаемому тексту для тоста.
|
||||
// Никогда не возвращает undefined/null и не добавляет технический префикс "Server error:".
|
||||
export const getWsErrorMessage = (error) => {
|
||||
if (typeof error === 'string') {
|
||||
return resolveKnownPrefix(error) ?? (error.trim() || DEFAULT_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
if (error && typeof error === 'object') {
|
||||
const raw = error.message || error.detail;
|
||||
if (!raw) return DEFAULT_ERROR_MESSAGE;
|
||||
return resolveKnownPrefix(raw) ?? raw;
|
||||
}
|
||||
|
||||
return DEFAULT_ERROR_MESSAGE;
|
||||
};
|
||||
53
web/src/components/RealtimeTable/utils/wsErrorUtils.test.js
Normal file
53
web/src/components/RealtimeTable/utils/wsErrorUtils.test.js
Normal file
@ -0,0 +1,53 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { getWsErrorMessage } from './wsErrorUtils.js';
|
||||
|
||||
test('строка возвращается как есть', () => {
|
||||
assert.equal(getWsErrorMessage('Ячейка уже редактируется другим пользователем'), 'Ячейка уже редактируется другим пользователем');
|
||||
});
|
||||
|
||||
test('объект с message возвращает message', () => {
|
||||
assert.equal(getWsErrorMessage({ code: 400, field: 'q1.transfer_econ', message: 'Нельзя указать отрицательное значение' }), 'Нельзя указать отрицательное значение');
|
||||
});
|
||||
|
||||
test('объект без message возвращает detail', () => {
|
||||
assert.equal(getWsErrorMessage({ detail: 'Ошибка подключения к базе данных' }), 'Ошибка подключения к базе данных');
|
||||
});
|
||||
|
||||
test('объект без message и без detail возвращает дефолтный текст, а не undefined', () => {
|
||||
const result = getWsErrorMessage({ code: 500 });
|
||||
assert.notEqual(result, undefined);
|
||||
assert.equal(typeof result, 'string');
|
||||
assert.ok(!result.includes('undefined'));
|
||||
});
|
||||
|
||||
test('префикс computed_field: заменяется человеческим текстом', () => {
|
||||
assert.equal(getWsErrorMessage('computed_field: q1.corrected_plan'), 'Это расчётная ячейка, её нельзя изменить');
|
||||
assert.equal(getWsErrorMessage({ message: 'computed_field: q1.corrected_plan' }), 'Это расчётная ячейка, её нельзя изменить');
|
||||
});
|
||||
|
||||
test('префикс role_not_allowed: заменяется человеческим текстом', () => {
|
||||
assert.equal(getWsErrorMessage('role_not_allowed: data.q1.transfer_econ'), 'Нет прав на изменение этой колонки');
|
||||
});
|
||||
|
||||
test('префикс window_closed: заменяется человеческим текстом', () => {
|
||||
assert.equal(getWsErrorMessage('window_closed: data.q1.transfer_econ'), 'Окно ввода для этой колонки закрыто');
|
||||
});
|
||||
|
||||
test('префикс org_not_assigned заменяется человеческим текстом', () => {
|
||||
assert.equal(getWsErrorMessage('org_not_assigned'), 'Вы не назначены на эту форму');
|
||||
assert.equal(getWsErrorMessage({ message: 'org_not_assigned: form_id=123' }), 'Вы не назначены на эту форму');
|
||||
});
|
||||
|
||||
test('неизвестный текст возвращается без технического префикса Server error:', () => {
|
||||
const result = getWsErrorMessage('Database unavailable');
|
||||
assert.equal(result, 'Database unavailable');
|
||||
assert.ok(!result.startsWith('Server error:'));
|
||||
});
|
||||
|
||||
test('пустая строка и отсутствующая ошибка не превращаются в undefined', () => {
|
||||
assert.notEqual(getWsErrorMessage(''), undefined);
|
||||
assert.notEqual(getWsErrorMessage(null), undefined);
|
||||
assert.notEqual(getWsErrorMessage(undefined), undefined);
|
||||
assert.ok(!String(getWsErrorMessage(undefined)).includes('undefined'));
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user