Files
public/rsbu_fns_reporting/models/rsbu_efs1_szvtd_xml.py

346 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Генерация ЕФС-1 подраздел 1.1 (СЗВ-ТД) в XML + XSD-валидация (СФР).
Формат ЕФС-1 2024-01-01 (пространство имён http://пф.рф/ЕФС-1/2024-01-01).
В отличие от форм ФНС (латинские имена, windows-1251, lxml), ЕФС-1 СФР
использует КИРИЛЛИЧЕСКИЕ имена элементов и кириллические URI пространств
имён (http://пф.рф/...). Библиотека lxml с такими URI не работает вовсе
(ни парсинг, ни построение, ни XSD), поэтому:
* дерево строится через стандартный xml.etree.ElementTree (поддерживает
кириллические namespace);
* XSD-валидация выполняется библиотекой xmlschema (чистый Python).
Структура (корень ЭДПФР, ns по умолчанию = ЕФС-1/2024-01-01):
ЭДПФР
├ ЕФС-1
│ ├ Страхователь (ЕФС:РегНомер, ЕФС:Наименование, УТ2:ИНН,
│ │ УТ2:КПП, УТ2:КодПоОКВЭД, ЕФС:ОГРН,
│ │ УТ2:Телефон?, УТ2:АдресЭлПочты?)
│ ├ СЗВ → ЗЛ[1..*] (УТ2:СНИЛС, ИНН?, УТ2:ФИО, УТ2:ДатаРождения,
│ │ СтатусЗЛ, Гражданство, СЗВ-ТД → ТД → Мероприятие[1..*])
│ ├ Руководитель (УТ2:ФИО, УТ2:Должность)
│ └ ДатаЗаполнения
└ СлужебнаяИнформация (АФ5:GUID, АФ5:ДатаВремя, АФ5:ПрограммаПодготовки)
Реквизиты страхователя и руководителя — из настроек компании
(rsbu_reporting_base). Данные ЗЛ — из карточки сотрудника (rsbu_payroll).
"""
import base64
import os
import uuid
from datetime import datetime
from xml.etree import ElementTree as ET
from odoo import _, api, models
from odoo.exceptions import UserError
from odoo.modules import get_module_path
FORMAT_VERSION = '2024-01-01'
# Пространства имён формата ЕФС-1 2024-01-01.
NS_EFS1 = 'http://пф.рф/ЕФС-1/2024-01-01' # по умолчанию (без префикса)
NS_EFS = 'http://пф.рф/ВС/ЕФС/2024-01-01' # ЕФС:
NS_UT2 = 'http://пф.рф/УТ/2017-08-21' # УТ2:
NS_AF5 = 'http://пф.рф/АФ/2018-12-07' # АФ5:
NS_VS2 = 'http://пф.рф/ВС/типы/2017-10-23' # ВС2:
# Рекомендуемые префиксы (как в живых образцах 1С, принятых СФР).
NS_PREFIXES = {
'': NS_EFS1,
'ЕФС': NS_EFS,
'УТ2': NS_UT2,
'АФ5': NS_AF5,
'ВС2': NS_VS2,
}
# Кэш собранной XSD-схемы (сборка пакета схем — дорогая операция).
_XSD_SCHEMA = None
def _q(ns, tag):
"""Квалифицированное имя {namespace}tag для ElementTree."""
return '{%s}%s' % (ns, tag) if ns else tag
class RsbuEfs1Szvtd(models.Model):
_inherit = 'rsbu.efs1.szvtd'
# ------------------------------------------------------------------
# Реквизиты и валидация входных данных
# ------------------------------------------------------------------
def _efs1_validate_requisites(self):
self.ensure_one()
company = self.company_id
partner = company.partner_id
errors = []
if not company.rsbu_sfr_reg_number:
errors.append(_('регистрационный номер в СФР'))
if not (partner.inn or '').strip():
errors.append(_('ИНН организации'))
if not company.rsbu_ogrn:
errors.append(_('ОГРН организации'))
if not company.rsbu_signatory_id:
errors.append(_('подписант (руководитель)'))
if not self.event_ids:
errors.append(_('ни одного кадрового мероприятия'))
for ev in self.event_ids:
emp = ev.employee_id
if not emp.rsbu_snils:
errors.append(_('СНИЛС сотрудника «%s»') % emp.name)
if not emp.birthday:
errors.append(_('дата рождения сотрудника «%s»') % emp.name)
if ev._okz_required() and not ev._effective_okz():
errors.append(
_('код по ОКЗ для мероприятия «%s»') % ev.name)
if errors:
raise UserError(_(
'Не заполнены обязательные реквизиты ЕФС-1 (СЗВ-ТД): %s.')
% ', '.join(errors[:10]))
@staticmethod
def _fio_subelements(parent, full_name):
"""Добавить УТ2:Фамилия/Имя/Отчество из строки ФИО (через пробел)."""
parts = (full_name or '').split()
if parts:
ET.SubElement(parent, _q(NS_UT2, 'Фамилия')).text = parts[0]
if len(parts) > 1:
ET.SubElement(parent, _q(NS_UT2, 'Имя')).text = parts[1]
if len(parts) > 2:
ET.SubElement(parent, _q(NS_UT2, 'Отчество')).text = ' '.join(
parts[2:])
# ------------------------------------------------------------------
# Построение дерева
# ------------------------------------------------------------------
def _efs1_build_strahovatel(self, parent):
"""Блок Страхователь (ЕФС:ТипСтрахователь)."""
self.ensure_one()
company = self.company_id
partner = company.partner_id
s = ET.SubElement(parent, _q(NS_EFS1, 'Страхователь'))
ET.SubElement(s, _q(NS_EFS, 'РегНомер')).text = \
company.rsbu_sfr_reg_number or ''
ET.SubElement(s, _q(NS_EFS, 'Наименование')).text = company.name or ''
ET.SubElement(s, _q(NS_UT2, 'ИНН')).text = (partner.inn or '').strip()
if (partner.kpp or '').strip():
ET.SubElement(s, _q(NS_UT2, 'КПП')).text = partner.kpp.strip()
if company.rsbu_okved:
ET.SubElement(s, _q(NS_UT2, 'КодПоОКВЭД')).text = company.rsbu_okved
# ОГРН (13 знаков) или ОГРНИП (15) — по choice типа Страхователь.
ogrn = (company.rsbu_ogrn or '').strip()
tag = 'ОГРНИП' if len(ogrn) == 15 else 'ОГРН'
ET.SubElement(s, _q(NS_EFS, tag)).text = ogrn
phone = (partner.phone or '').strip()
if phone:
digits = ''.join(ch for ch in phone if ch.isdigit())
if digits:
ET.SubElement(s, _q(NS_UT2, 'Телефон')).text = digits
email = (partner.email or '').strip()
if email:
ET.SubElement(s, _q(NS_UT2, 'АдресЭлПочты')).text = email
return s
def _efs1_build_meropriyatie(self, parent, ev):
"""Элемент Мероприятие (ЕФС:ТипТД) — все дочерние в ns ЕФС."""
m = ET.SubElement(parent, _q(NS_EFS1, 'Мероприятие'))
ET.SubElement(m, _q(NS_EFS, 'UUID')).text = \
ev.event_uuid or str(uuid.uuid4())
ET.SubElement(m, _q(NS_EFS, 'Дата')).text = \
ev.event_date.strftime('%Y-%m-%d')
ET.SubElement(m, _q(NS_EFS, 'Вид')).text = ev.kind
if ev.job_title:
ET.SubElement(m, _q(NS_EFS, 'Должность')).text = ev.job_title
if ev.employment_type:
ET.SubElement(m, _q(NS_EFS, 'ЯвляетсяСовместителем')).text = \
ev.employment_type
if ev.work_kind:
ET.SubElement(m, _q(NS_EFS, 'ВидПР')).text = ev.work_kind
if ev.department_name:
ET.SubElement(m, _q(NS_EFS, 'СтруктурноеПодразделение')).text = \
ev.department_name
if ev.func_code:
ET.SubElement(m, _q(NS_EFS, 'КодВФ')).text = ev.func_code
okz = ev._effective_okz()
if okz:
ET.SubElement(m, _q(NS_EFS, 'КодВФпоОКЗ')).text = okz
# Основание увольнения (только при виде 5 и наличии данных).
if ev.kind == '5' and ev.dismissal_reason_code:
ou = ET.SubElement(m, _q(NS_EFS, 'ОснованиеУвольнения'))
ET.SubElement(ou, _q(NS_EFS, 'КодПричиныУвольнения')).text = \
ev.dismissal_reason_code
if ev.dismissal_document:
ET.SubElement(ou, _q(NS_EFS, 'НормативныйДокумент')).text = \
ev.dismissal_document
if ev.dismissal_article:
ET.SubElement(ou, _q(NS_EFS, 'Статья')).text = \
ev.dismissal_article
if ev.dismissal_part:
ET.SubElement(ou, _q(NS_EFS, 'Часть')).text = ev.dismissal_part
if ev.dismissal_point:
ET.SubElement(ou, _q(NS_EFS, 'Пункт')).text = \
ev.dismissal_point
if ev.dismissal_subpoint:
ET.SubElement(ou, _q(NS_EFS, 'Подпункт')).text = \
ev.dismissal_subpoint
if ev.dismissal_paragraph:
ET.SubElement(ou, _q(NS_EFS, 'Абзац')).text = \
ev.dismissal_paragraph
if ev.dismissal_reason:
ET.SubElement(m, _q(NS_EFS, 'Причина')).text = ev.dismissal_reason
# Основание (приказ) — обязательный блок.
basis = ET.SubElement(m, _q(NS_EFS, 'Основание'))
ET.SubElement(basis, _q(NS_EFS, 'Наименование')).text = \
ev.basis_name or 'Приказ'
basis_date = ev.basis_date or ev.event_date
ET.SubElement(basis, _q(NS_EFS, 'Дата')).text = \
basis_date.strftime('%Y-%m-%d')
if ev.basis_series:
ET.SubElement(basis, _q(NS_EFS, 'Серия')).text = ev.basis_series
ET.SubElement(basis, _q(NS_EFS, 'Номер')).text = \
ev.basis_number or '1'
return m
def _efs1_build_zl(self, parent, emp, events):
"""Блок ЗЛ (одно зарегистрированное лицо) с его мероприятиями."""
zl = ET.SubElement(parent, _q(NS_EFS1, 'ЗЛ'))
snils = (emp.rsbu_snils or '').strip()
ET.SubElement(zl, _q(NS_UT2, 'СНИЛС')).text = snils
if emp.rsbu_inn:
ET.SubElement(zl, _q(NS_EFS1, 'ИНН')).text = emp.rsbu_inn.strip()
fio = ET.SubElement(zl, _q(NS_UT2, 'ФИО'))
self._fio_subelements(fio, emp.name)
if emp.birthday:
ET.SubElement(zl, _q(NS_UT2, 'ДатаРождения')).text = \
emp.birthday.strftime('%Y-%m-%d')
ET.SubElement(zl, _q(NS_EFS1, 'СтатусЗЛ')).text = 'ГРФ'
ET.SubElement(zl, _q(NS_EFS1, 'Гражданство')).text = \
emp.rsbu_citizenship_code or '643'
szvtd = ET.SubElement(zl, _q(NS_EFS1, 'СЗВ-ТД'))
td = ET.SubElement(szvtd, _q(NS_EFS1, 'ТД'))
for ev in events:
self._efs1_build_meropriyatie(td, ev)
return zl
def _efs1_build_rukovoditel(self, parent):
"""Блок Руководитель (УТ2:ТипДолжностноеЛицо)."""
self.ensure_one()
company = self.company_id
signatory = company.rsbu_signatory_id
r = ET.SubElement(parent, _q(NS_EFS1, 'Руководитель'))
fio = ET.SubElement(r, _q(NS_UT2, 'ФИО'))
self._fio_subelements(fio, signatory.partner_id.name
if signatory else '')
ET.SubElement(r, _q(NS_UT2, 'Должность')).text = \
company.rsbu_signatory_title or 'РУКОВОДИТЕЛЬ'
return r
def _efs1_build_xml(self, guid=None):
"""Собрать XML документа ЕФС-1 (СЗВ-ТД). Возвращает bytes (UTF-8)."""
self.ensure_one()
self._efs1_validate_requisites()
if guid is None:
guid = str(uuid.uuid4())
for prefix, ns in NS_PREFIXES.items():
ET.register_namespace(prefix, ns)
root = ET.Element(_q(NS_EFS1, 'ЭДПФР'))
efs1 = ET.SubElement(root, _q(NS_EFS1, 'ЕФС-1'))
self._efs1_build_strahovatel(efs1)
szv = ET.SubElement(efs1, _q(NS_EFS1, 'СЗВ'))
for emp, events in self._events_by_employee():
self._efs1_build_zl(szv, emp, events)
self._efs1_build_rukovoditel(efs1)
ET.SubElement(efs1, _q(NS_EFS1, 'ДатаЗаполнения')).text = \
(self.date_fill or self.date_to).strftime('%Y-%m-%d')
service = ET.SubElement(root, _q(NS_EFS1, 'СлужебнаяИнформация'))
ET.SubElement(service, _q(NS_AF5, 'GUID')).text = guid
ET.SubElement(service, _q(NS_AF5, 'ДатаВремя')).text = \
datetime.now().strftime('%Y-%m-%dT%H:%M:%S')
ET.SubElement(service, _q(NS_AF5, 'ПрограммаПодготовки')).text = \
'RuOdoo'
return ET.tostring(root, encoding='UTF-8', xml_declaration=True)
# ------------------------------------------------------------------
# XSD-валидация через xmlschema
# ------------------------------------------------------------------
@api.model
def _efs1_xsd_path(self):
return os.path.join(
get_module_path('rsbu_fns_reporting'), 'data', 'schemas', 'efs1',
'ВС', 'Входящие', 'ЕФС-1_2024-01-01.xsd')
@api.model
def _efs1_get_schema(self):
"""Собрать (с кэшем) XSD-схему ЕФС-1 через xmlschema."""
global _XSD_SCHEMA
if _XSD_SCHEMA is not None:
return _XSD_SCHEMA
try:
import xmlschema
except ImportError:
raise UserError(_(
'Для проверки ЕФС-1 по схеме СФР требуется библиотека '
'Python «xmlschema». Установите её: pip install xmlschema.'
)) from None
path = self._efs1_xsd_path()
if not os.path.exists(path):
raise UserError(_(
'Не найдена XSD-схема ЕФС-1: %s') % path)
schema = xmlschema.XMLSchema(path, build=False)
schema.build()
_XSD_SCHEMA = schema
return schema
def _efs1_validate_xml(self, xml_bytes):
"""Проверить XML по XSD. Возвращает (ok, [сообщения об ошибках])."""
schema = self._efs1_get_schema()
errors = []
try:
schema.validate(xml_bytes.decode('utf-8'))
except Exception as exc: # xmlschema.XMLSchemaValidationError и пр.
errors = [str(exc)]
return (not errors), errors
# ------------------------------------------------------------------
def action_export_xml(self):
self.ensure_one()
self.action_compute()
guid = str(uuid.uuid4())
xml_bytes = self._efs1_build_xml(guid=guid)
ok, errors = self._efs1_validate_xml(xml_bytes)
if not ok:
raise UserError(_(
'Сформированная форма ЕФС-1 (СЗВ-ТД) не прошла проверку по '
'схеме СФР:\n%s') % '\n'.join(errors[:10]))
filename = self._efs1_xml_filename(guid)
attachment = self.env['ir.attachment'].create({
'name': filename + '.xml',
'res_model': self._name,
'res_id': self.id,
'type': 'binary',
'datas': base64.b64encode(xml_bytes),
'mimetype': 'application/xml',
})
self.event_ids.write({'state': 'reported'})
self.state = 'reported'
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%s?download=true' % attachment.id,
'target': 'self',
}
def _efs1_xml_filename(self, guid):
"""Имя файла выгрузки ЕФС-1 (по образцу 1С: ПФР_<рег>_<...>_ЕФС-1_...)."""
self.ensure_one()
reg = (self.company_id.rsbu_sfr_reg_number or '').replace('-', '')
reg_dash = self.company_id.rsbu_sfr_reg_number or ''
date_str = (self.date_fill or self.date_to).strftime('%Y%m%d')
return 'ПФР_%s_%s_ЕФС-1_%s_%s' % (reg_dash, reg[:6], date_str, guid)