# -*- 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)