Загрузка данных
-- СНВ: серверная обработка ВСЕХ строк одного уже загруженного XML.
-- Версия 1 для установки и проверки в тестовой базе НПФ 360.
-- Загрузчик a33r8zRX сохраняет XML в ourpension.load_snv до вызова функции.
-- Существующие загрузчик, функции и ручные конструкторы этот скрипт не меняет.
-- Новые объекты: служебная таблица успешных обработок и f_process_snv_file.
-- Модель E57LIUhi / SN0s1Sgg отдельно вызывать не требуется.
--
-- УСТАНОВКА: выполнить весь скрипт. В конце нет вызова обработки данных.
-- Планируемый вызов из операции регламента:
-- SELECT ourpension.f_process_snv_file(:fileId, :username, :user_id);
-- Параметры без значений по умолчанию. Пользователь запуска должен быть
-- отдельным от пользователей ручного прикрепления файлов snv_card.
--
-- УСПЕХ: JSON с success=true. ОШИБКА: исключение SQL (не JSON успеха).
-- Регламент должен считать исключение ошибкой всего файла, а перемещать
-- файл в успешные только ПОСЛЕ фиксации транзакции обработки в БД.
--
-- Повторный вызов того же file_id возвращает сохранённый результат без
-- повторного создания справок. Новый file_id для того же XML здесь НЕ
-- распознаётся как дубликат: это задача учёта файлов в регламенте.
--
-- Дополнительные условия автоматического режима:
-- * ровно одно соответствие исходной строки принятому заявлению;
-- * нет нескольких справок одному клиенту за один год внутри файла:
-- существующая f_merge_year_tax_deduction аннулирует их по клиенту/году;
-- * для каждой строки найден отправленный запрос в журнале ФНС;
-- * отсутствует ручное вложение snv_card у пользователя запуска.
-- Ошибки этих условий останавливают весь файл ДО регистрации.
--
-- Особенность исходного валидатора: проверка с названием «ИНН» фактически
-- проверяет только наличие номера договора. Новое требование совпадения
-- ИНН здесь не вводится. Проверки имени/отчества сохранены как в валидаторе.
-- Сопоставление фамилии/имени и расчёт взносов взяты из модели пользователя.
--
-- Изменения регистрации и журнала откатываются вместе при SQL-ошибке.
-- Значения nextval, как обычно, могут иметь пропуски после отката.
-- Транзакции/перенос сетевых файлов этим скриптом не управляются.
-- Регламенту следует сохранять MESSAGE и DETAIL исключения в журнале ошибок.
--
-- ЛОКАЛЬНАЯ ПРОВЕРКА 26.10.2026: разбор SQL + выполнение в изолированной
-- PostgreSQL/PGlite с тестовыми таблицами, функцией регистрации из присланного
-- кода и тестовыми зависимостями. Пройдены 24 сценария, включая откат при
-- ошибке второй строки, тихом пропуске регистрации и сбое отметки журнала,
-- а также повтор file_id. Это не проверка на базе/в интеграциях заказчика.
BEGIN;
CREATE TABLE ourpension.snv_auto_file_processing (
file_id integer PRIMARY KEY,
batch_id integer NOT NULL,
registered_count integer NOT NULL CHECK (registered_count > 0),
journal_updated_count integer NOT NULL CHECK (journal_updated_count > 0),
version_datetime timestamp without time zone NOT NULL,
processed_at timestamp with time zone NOT NULL DEFAULT clock_timestamp(),
username text NOT NULL,
user_id integer NOT NULL,
result jsonb NOT NULL
);
COMMENT ON TABLE ourpension.snv_auto_file_processing IS
'Успешная автоматическая регистрация СНВ; защита от повтора одного file_id';
CREATE FUNCTION ourpension.f_process_snv_file(
p_file_id integer,
p_username text,
p_user_id integer
)
RETURNS jsonb
LANGUAGE plpgsql
AS $function$
DECLARE
v_source jsonb;
v_mapped jsonb;
v_ready jsonb;
v_errors jsonb := '[]'::jsonb;
v_result jsonb;
v_saved jsonb;
v_row record;
v_bad record;
v_row_errors text[];
v_source_count integer;
v_count integer;
v_batch_id integer;
v_registered_count integer := 0;
v_journal_updated_count integer;
v_version_datetime timestamp without time zone;
BEGIN
IF p_file_id IS NULL OR p_file_id <= 0 THEN
RAISE EXCEPTION 'СНВ: требуется положительный fileId';
END IF;
IF p_username IS NULL OR btrim(p_username) = ''
OR p_user_id IS NULL OR p_user_id <= 0 THEN
RAISE EXCEPTION 'СНВ: требуются username и положительный user_id регламента';
END IF;
-- Два одновременных вызова для одного файла выполняются последовательно.
PERFORM pg_advisory_xact_lock(hashtext('ourpension.snv_auto.file'), p_file_id);
SELECT h.result INTO v_saved
FROM ourpension.snv_auto_file_processing h
WHERE h.file_id = p_file_id;
IF FOUND THEN
RETURN v_saved || jsonb_build_object('already_processed', true);
END IF;
-- Не позволяем функции создания справки забрать чужое ручное вложение.
PERFORM pg_advisory_xact_lock(
hashtext('ourpension.snv_auto.username'), hashtext(p_username)
);
IF EXISTS (
SELECT 1 FROM dev.upload_file f
WHERE f.code = 'snv_card' AND f.username = p_username
) THEN
RAISE EXCEPTION 'СНВ: у пользователя запуска есть ручное вложение snv_card; fileId=%', p_file_id;
END IF;
-- Снимок уже загруженных строк. Во всех дальнейших шагах используется
-- этот же набор, а не пользовательский выбор tools.selection.
PERFORM l.id FROM ourpension.load_snv l
WHERE l.file_id = p_file_id ORDER BY l.id FOR UPDATE;
SELECT jsonb_agg(to_jsonb(l) ORDER BY l.id) INTO v_source
FROM ourpension.load_snv l WHERE l.file_id = p_file_id;
IF v_source IS NULL THEN
RAISE EXCEPTION 'СНВ: файл % не содержит загруженных строк', p_file_id;
END IF;
v_source_count := jsonb_array_length(v_source);
-- Проверки исходного валидатора без зависимости от tools.selection.
FOR v_row IN
SELECT l.* FROM jsonb_populate_recordset(NULL::ourpension.load_snv, v_source) l
LOOP
v_row_errors := ARRAY[]::text[];
IF NOT EXISTS (
SELECT 1 FROM ourpension.contract c
WHERE c.number = v_row.contract_number
) THEN
v_row_errors := array_append(v_row_errors, 'Не найден номер договора');
END IF;
IF NOT EXISTS (
SELECT 1 FROM ourpension.contract c
WHERE c.number = v_row.contract_number AND c.date = v_row.contract_date
) THEN
v_row_errors := array_append(v_row_errors, 'Не совпадает дата договора');
END IF;
IF EXISTS (
SELECT 1 FROM ourpension.contract c
JOIN ourpension.individual i ON i.id = c.individual_id
WHERE c.number = v_row.contract_number
AND i.inn = v_row.person_inn
AND c.service_type <> v_row.service_type
) THEN
v_row_errors := array_append(v_row_errors, 'Не совпадает вид деятельности');
END IF;
IF NOT EXISTS (
SELECT 1 FROM ourpension.contract c
JOIN ourpension.individual i ON i.id = c.individual_id
WHERE c.number = v_row.contract_number
AND i.first_name = v_row.person_first_name
) THEN
v_row_errors := array_append(v_row_errors, 'Не совпадает имя клиента');
END IF;
IF NOT EXISTS (
SELECT 1 FROM ourpension.contract c
JOIN ourpension.individual i ON i.id = c.individual_id
WHERE c.number = v_row.contract_number
AND i.middle_name = v_row.person_middle_name
) THEN
v_row_errors := array_append(v_row_errors, 'Не совпадает отчество клиента');
END IF;
-- Исключаем тихий пропуск создания и ошибки преобразования года.
IF v_row.deduction_sign IS NULL
OR v_row.deduction_sign::text NOT IN ('RECEIVED', 'NOT_RECEIVED') THEN
v_row_errors := array_append(v_row_errors, 'Не определён признак получения вычета');
END IF;
IF v_row.service_type IS NULL
OR v_row.service_type::text NOT IN ('NPO', 'PDS') THEN
v_row_errors := array_append(v_row_errors, 'Не определён вид деятельности НПО/ПДС');
END IF;
IF v_row.year IS NULL OR btrim(v_row.year::text) !~ '^[0-9]{1,4}$' THEN
v_row_errors := array_append(v_row_errors, 'Некорректный год справки');
ELSIF v_row.year::integer < 1 THEN
v_row_errors := array_append(v_row_errors, 'Некорректный год справки');
END IF;
-- Без отправленного запроса проставить обязательную отметку невозможно.
IF NOT EXISTS (
SELECT 1
FROM report.fns_reference_request_journal fns
JOIN dev.model_file_to_verification mfc
ON mfc.id = fns.model_file_to_verification_id
JOIN report.smev_register sr ON sr.id = mfc.register_id
WHERE fns.contract_number = v_row.contract_number
AND sr.number = v_row.request_number::text
AND fns.is_sent_to_fns = true
) THEN
v_row_errors := array_append(v_row_errors, 'Не найден отправленный запрос в журнале ФНС');
END IF;
IF cardinality(v_row_errors) > 0 THEN
v_errors := v_errors || jsonb_build_array(jsonb_build_object(
'load_snv_id', v_row.id, 'errors', to_jsonb(v_row_errors)
));
END IF;
END LOOP;
IF jsonb_array_length(v_errors) > 0 THEN
RAISE EXCEPTION USING
MESSAGE = format('СНВ: проверка файла %s завершилась ошибками; регистрация не начата', p_file_id),
DETAIL = v_errors::text;
END IF;
-- Сопоставление из модели. Номер, дата и вид деятельности должны
-- относиться к одному договору; иначе строка не готова к регистрации.
WITH base AS (
SELECT l.id AS load_snv_id,
ct.id AS application_termination_contract_id,
c.individual_id, l.sum_tax_deduction,
l.year::integer AS year, l.deduction_sign::text AS type,
c.service_type::text AS service_type,
sh.sharer_id_1c::text AS sharer_id_1c
FROM jsonb_populate_recordset(NULL::ourpension.load_snv, v_source) l
JOIN ourpension.contract c
ON c.number = l.contract_number AND c.date = l.contract_date
AND c.service_type = l.service_type
JOIN ourpension.individual i ON i.id = c.individual_id
AND lower(btrim(i.last_name)) = lower(btrim(l.person_last_name))
AND lower(btrim(i.first_name)) = lower(btrim(l.person_first_name))
JOIN ourpension.sharer sh ON sh.contract_id = c.id
LEFT JOIN ourpension.application_termination_contract_data ctd
ON ctd.sharer_id = sh.id
JOIN ourpension.application_termination_contract ct
ON CASE WHEN ctd.sharer_id IS NULL THEN ct.sharer_id = sh.id
ELSE ct.id = ctd.application_termination_contract_id END
AND ct.accepted IS NOT NULL AND ct.rejected IS NULL AND ct.canceled IS NULL
AND ct.termination_type IN ('TRANSFER', 'PAYMENT', 'FULL', 'LIFE_SITUATION')
)
SELECT coalesce(jsonb_agg(to_jsonb(b) ORDER BY b.load_snv_id), '[]'::jsonb)
INTO v_mapped FROM base b;
FOR v_row IN
SELECT l.id FROM jsonb_populate_recordset(NULL::ourpension.load_snv, v_source) l
LOOP
SELECT count(*) INTO v_count
FROM jsonb_to_recordset(v_mapped) b(load_snv_id integer)
WHERE b.load_snv_id = v_row.id;
IF v_count <> 1 THEN
v_errors := v_errors || jsonb_build_array(jsonb_build_object(
'load_snv_id', v_row.id, 'matches', v_count,
'error', 'Требуется ровно одно соответствие клиенту, участнику и принятому заявлению'
));
END IF;
END LOOP;
FOR v_bad IN
SELECT b.individual_id, b.year, jsonb_agg(b.load_snv_id) AS row_ids
FROM jsonb_to_recordset(v_mapped)
b(load_snv_id integer, individual_id integer, year integer)
GROUP BY b.individual_id, b.year HAVING count(*) > 1
LOOP
v_errors := v_errors || jsonb_build_array(jsonb_build_object(
'load_snv_ids', v_bad.row_ids,
'error', 'Несколько справок одного клиента за один год: они аннулируют друг друга'
));
END LOOP;
FOR v_bad IN
SELECT b.load_snv_id FROM jsonb_to_recordset(v_mapped)
b(load_snv_id integer, sharer_id_1c text)
WHERE b.sharer_id_1c IS NULL OR btrim(b.sharer_id_1c) = ''
LOOP
v_errors := v_errors || jsonb_build_array(jsonb_build_object(
'load_snv_id', v_bad.load_snv_id, 'error', 'Не определён счёт 1С для расчёта взносов'
));
END LOOP;
IF jsonb_array_length(v_errors) > 0 THEN
RAISE EXCEPTION USING
MESSAGE = format('СНВ: неполный или неоднозначный набор файла %s; регистрация не начата', p_file_id),
DETAIL = v_errors::text;
END IF;
-- Расчёт из модели SN0s1Sgg: один запрос операций на пару счёт/вид.
WITH base AS (
SELECT * FROM jsonb_to_recordset(v_mapped) b(
load_snv_id integer, application_termination_contract_id integer,
individual_id integer, sum_tax_deduction numeric, year integer,
type text, service_type text, sharer_id_1c text
)
), account_pairs AS (
SELECT DISTINCT b.sharer_id_1c, b.service_type FROM base b
), operations AS (
SELECT p.sharer_id_1c, p.service_type,
date_part('year', t.period_date)::integer AS year, t.amount
FROM account_pairs p
JOIN mgr.ft_dwh_get__pension_account_operation(jsonb_build_object(
'p_sPensionAccountId', p.sharer_id_1c, 'p_sServiceType', p.service_type
)) t ON true
WHERE CASE WHEN p.service_type = 'NPO'
THEN t.operation_type_name IN ('Ч/з банк', 'От работодателя')
WHEN p.service_type = 'PDS'
THEN t.operation_type_name = 'Сберегательные взносы'
ELSE false END
), sum_by_year AS (
SELECT o.sharer_id_1c, o.service_type, o.year, sum(o.amount) AS value
FROM operations o GROUP BY o.sharer_id_1c, o.service_type, o.year
), prepared AS (
SELECT b.*, coalesce(s.value, 0) AS sum_income,
m.value AS max_tax_deduction
FROM base b
LEFT JOIN sum_by_year s ON s.sharer_id_1c = b.sharer_id_1c
AND s.service_type = b.service_type AND s.year = b.year
LEFT JOIN ourpension.max_year_tax_deduction m
ON m.year = b.year AND coalesce(s.value, 0) >= 0
)
SELECT jsonb_agg(to_jsonb(p) ORDER BY p.load_snv_id)
INTO v_ready FROM prepared p;
-- Проверяем также возможное размножение строк справочником лимитов.
FOR v_row IN
SELECT l.id FROM jsonb_populate_recordset(NULL::ourpension.load_snv, v_source) l
LOOP
SELECT count(*) INTO v_count FROM jsonb_to_recordset(v_ready)
b(load_snv_id integer) WHERE b.load_snv_id = v_row.id;
IF v_count <> 1 THEN
RAISE EXCEPTION 'СНВ: для строки % файла % получено % наборов параметров',
v_row.id, p_file_id, v_count;
END IF;
END LOOP;
-- Упорядоченные блокировки сериализуют автоматическую регистрацию
-- пересекающихся клиент/год из разных файлов. Ручной код эти блокировки
-- не использует; совместный запуск с ручным режимом требует отдельной QA.
FOR v_row IN
SELECT b.individual_id, b.year FROM jsonb_to_recordset(v_ready)
b(individual_id integer, year integer)
ORDER BY b.individual_id, b.year
LOOP
PERFORM pg_advisory_xact_lock(hashtext('ourpension.snv_auto.person_year'),
hashtext(v_row.individual_id::text || ':' || v_row.year::text));
END LOOP;
v_batch_id := nextval('ourpension.year_tax_deduction_batch_sequence')::integer;
v_version_datetime := clock_timestamp() AT TIME ZONE 'Europe/Moscow';
FOR v_row IN
SELECT * FROM jsonb_to_recordset(v_ready) b(
load_snv_id integer, application_termination_contract_id integer,
individual_id integer, sum_tax_deduction numeric, year integer,
type text, sum_income numeric, max_tax_deduction numeric
) ORDER BY b.load_snv_id
LOOP
-- p_date NULL воспроизводит отсутствующую row.date в ручной модели.
-- Текущее время создания задаётся явно последним аргументом.
-- Сумма -1, как и в операции qu84AJSQB, означает NULL.
PERFORM ourpension.f_merge_year_tax_deduction(
v_row.application_termination_contract_id::integer, NULL::date,
v_row.individual_id::integer, v_row.max_tax_deduction::integer,
v_row.sum_income::numeric, NULLIF(v_row.sum_tax_deduction, -1)::numeric,
v_row.type::text, v_row.year::integer, p_username::text,
p_user_id::integer, v_batch_id::integer, NULL::integer,
v_version_datetime::timestamp
);
-- Возврат функции всегда 0; проверяем реальный результат записи.
SELECT count(*) INTO v_count FROM ourpension.year_tax_deduction y
WHERE y.batch_id = v_batch_id AND y.individual_id = v_row.individual_id
AND y.year = v_row.year
AND y.application_termination_contract_id = v_row.application_termination_contract_id
AND y.type::text = v_row.type AND y.canceled IS NULL
AND y.accepted IS NOT NULL;
IF v_count <> 1 THEN
RAISE EXCEPTION 'СНВ: не подтверждена регистрация строки % файла %',
v_row.load_snv_id, p_file_id;
END IF;
v_registered_count := v_registered_count + 1;
END LOOP;
-- Исходный UPDATE из TMbTo05q выполняется только после ВСЕХ справок.
UPDATE report.fns_reference_request_journal fns
SET response_received = true
FROM jsonb_populate_recordset(NULL::ourpension.load_snv, v_source) snv,
dev.model_file_to_verification mfc, report.smev_register sr
WHERE fns.model_file_to_verification_id = mfc.id
AND mfc.register_id = sr.id
AND fns.contract_number = snv.contract_number
AND sr.number = snv.request_number::text
AND fns.is_sent_to_fns = true
AND snv.file_id = p_file_id;
GET DIAGNOSTICS v_journal_updated_count = ROW_COUNT;
-- Повторяем контроль сопоставления: нулевой UPDATE не является успехом.
FOR v_row IN
SELECT l.* FROM jsonb_populate_recordset(NULL::ourpension.load_snv, v_source) l
LOOP
IF NOT EXISTS (
SELECT 1 FROM report.fns_reference_request_journal fns
JOIN dev.model_file_to_verification mfc
ON mfc.id = fns.model_file_to_verification_id
JOIN report.smev_register sr ON sr.id = mfc.register_id
WHERE fns.contract_number = v_row.contract_number
AND sr.number = v_row.request_number::text
AND fns.is_sent_to_fns = true AND fns.response_received = true
) THEN
RAISE EXCEPTION 'СНВ: отметка ответа не подтверждена для строки % файла %',
v_row.id, p_file_id;
END IF;
END LOOP;
IF v_registered_count <> v_source_count OR v_journal_updated_count < 1 THEN
RAISE EXCEPTION 'СНВ: неполный результат регистрации файла %', p_file_id;
END IF;
v_result := jsonb_build_object(
'success', true, 'already_processed', false, 'file_id', p_file_id,
'batch_id', v_batch_id, 'source_count', v_source_count,
'registered_count', v_registered_count,
'journal_updated_count', v_journal_updated_count,
'version_datetime', v_version_datetime
);
INSERT INTO ourpension.snv_auto_file_processing(
file_id, batch_id, registered_count, journal_updated_count,
version_datetime, username, user_id, result
) VALUES (
p_file_id, v_batch_id, v_registered_count, v_journal_updated_count,
v_version_datetime, p_username, p_user_id, v_result
);
RETURN v_result;
-- Не перехватываем ошибку как успех: исключение откатывает весь вызов.
END;
$function$;
COMMENT ON FUNCTION ourpension.f_process_snv_file(integer, text, integer) IS
'Все строки загруженного СНВ: проверка, расчёт, регистрация и отметка ответа; SQL-ошибка откатывает вызов';
COMMIT;
-- ПРОВЕРКИ В ТЕСТОВОЙ БАЗЕ ПОСЛЕ УСТАНОВКИ (выполняются отдельно):
-- 1. Валидный файл с несколькими разными клиент/год: все справки в одном batch.
-- 2. Ошибка хотя бы одной строки: нет новых справок, аннулирования и отметок.
-- 3. Нулевой набор, неизвестный договор, несколько заявлений, неверный год,
-- неопределённый тип вычета, нет записи журнала: весь файл отклонён.
-- 4. Сбой на второй регистрации: первая запись и аннулирование откатились.
-- 5. Повтор ТОГО ЖЕ file_id: already_processed=true, batch и количество прежние.
-- 6. Прежняя справка того же клиента/года: аннулируется по действующим правилам.
-- 7. Проверить фактическую сигнатуру mgr.ft_dwh_get__pension_account_operation,
-- типы полей и права отдельного пользователя регламента в целевой базе.
-- 8. После успешного COMMIT проверить перенос файла отдельно: этот SQL
-- не подключает расписание, сетевую папку или обработку результата роботом.