346 lines
17 KiB
Python
346 lines
17 KiB
Python
# -*- 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)
|