Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC

This commit is contained in:
CI Publish Bot
2026-11-01 22:54:30 +00:00
commit 26250aa580
2492 changed files with 664277 additions and 0 deletions

View File

@@ -0,0 +1,345 @@
# -*- 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)