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,194 @@
# -*- coding: utf-8 -*-
"""Генерация персонифицированных сведений (ПСВ) в XML.
Формат ФНС NO_PERSSVFL версии 5.01 (КНД 1151162). Кодировка windows-1251,
кириллические имена элементов. Структура:
Файл(ИдФайл, ВерсПрог, ВерсФорм)
└ Документ(КНД, ДатаДок, НомКорр, Период, ОтчетГод, КодНО, ПоМесту)
├ СвНП(Тлф) → НПЮЛ(НаимОрг, ИННЮЛ, КПП)
├ Подписант(ПрПодп) [→ ФИО]
└ ПерсСвФЛ[1..*](ПрАннулир?, ИННФЛ?, СНИЛС, СумВыпл) → ФИО
Период — код календарного месяца (01..12). Сумма выплат — decimal с 2
знаками. СНИЛС нормализуется к формату NNN-NNN-NNN-NN.
"""
import base64
from datetime import date
from odoo import _, api, models
from odoo.exceptions import UserError
KND_PSV = '1151162'
FORMAT_VERSION = '5.01'
PLACE_AT_LOCATION = '214'
class RsbuPersInfo(models.Model):
_inherit = 'rsbu.pers.info'
@staticmethod
def _sum(value):
return '%.2f' % (value or 0.0)
@staticmethod
def _snils(raw):
"""Нормализовать СНИЛС к формату NNN-NNN-NNN-NN (только цифры)."""
digits = ''.join(ch for ch in (raw or '') if ch.isdigit())
if len(digits) != 11:
return raw or ''
return '%s-%s-%s-%s' % (digits[0:3], digits[3:6], digits[6:9],
digits[9:11])
def _psv_xml_filename(self):
self.ensure_one()
import uuid
company = self.company_id
code_no = company.rsbu_tax_office_code or '0000'
inn = (company.partner_id.inn or '').strip()
kpp = (company.partner_id.kpp or '').strip()
today = date.today().strftime('%Y%m%d')
guid = str(uuid.uuid4())
return 'NO_PERSSVFL_%s_%s_%s%s_%s_%s' % (
code_no, code_no, inn, kpp, today, guid)
def _psv_validate_requisites(self):
self.ensure_one()
company = self.company_id
partner = company.partner_id
errors = []
if not partner.inn:
errors.append(_('ИНН организации'))
if not partner.kpp:
errors.append(_('КПП организации'))
if not company.rsbu_tax_office_code:
errors.append(_('код налогового органа'))
if not self.line_ids:
errors.append(_('ни одного физлица'))
for line in self.line_ids:
if not line.employee_id.rsbu_snils:
errors.append(_('СНИЛС сотрудника «%s»')
% line.employee_id.name)
if errors:
raise UserError(_(
'Не заполнены обязательные реквизиты ПСВ: %s.')
% ', '.join(errors[:10]))
def _psv_build_xml(self, filename=None):
self.ensure_one()
try:
from lxml import etree
except ImportError:
raise UserError(_('Для выгрузки XML требуется библиотека lxml.')) from None
self._psv_validate_requisites()
if filename is None:
filename = self._psv_xml_filename()
company = self.company_id
partner = company.partner_id
E = etree.Element
root = E('Файл', {
'ИдФайл': filename, 'ВерсПрог': 'RuOdoo',
'ВерсФорм': FORMAT_VERSION,
})
doc = E('Документ', {
'КНД': KND_PSV,
'ДатаДок': (self.date_doc or date.today()).strftime('%d.%m.%Y'),
'НомКорр': '0',
'Период': '%02d' % self.month,
'ОтчетГод': str(self.year),
'КодНО': company.rsbu_tax_office_code or '',
'ПоМесту': PLACE_AT_LOCATION,
})
root.append(doc)
svnp = E('СвНП')
svnp.append(E('НПЮЛ', {
'НаимОрг': company.name or '',
'ИННЮЛ': (partner.inn or '').strip(),
'КПП': (partner.kpp or '').strip(),
}))
doc.append(svnp)
pr_podp = '2' if company.rsbu_signatory_is_representative else '1'
signer = E('Подписант', {'ПрПодп': pr_podp})
signatory = company.rsbu_signatory_id
if signatory:
parts = (signatory.name or '').split()
fio = {}
if parts:
fio['Фамилия'] = parts[0]
if len(parts) > 1:
fio['Имя'] = parts[1]
if len(parts) > 2:
fio['Отчество'] = parts[2]
if fio:
signer.append(E('ФИО', fio))
doc.append(signer)
# Сведения по каждому физлицу.
for line in self.line_ids:
emp = line.employee_id
attrs = {
'СНИЛС': self._snils(emp.rsbu_snils),
'СумВыпл': self._sum(line.amount),
}
if emp.rsbu_inn:
attrs['ИННФЛ'] = emp.rsbu_inn.strip()
if line.cancelled:
attrs['ПрАннулир'] = '1'
pers = etree.SubElement(doc, 'ПерсСвФЛ', attrs)
parts = (emp.name or '').split()
fio = {}
if parts:
fio['Фамилия'] = parts[0]
if len(parts) > 1:
fio['Имя'] = parts[1]
if len(parts) > 2:
fio['Отчество'] = parts[2]
etree.SubElement(pers, 'ФИО', fio)
return etree.tostring(
root, xml_declaration=True, encoding='windows-1251',
pretty_print=True)
@api.model
def _psv_xsd_path(self):
import os
from odoo.modules import get_module_path
return os.path.join(
get_module_path('rsbu_fns_reporting'), 'data', 'schemas',
'NO_PERSSVFL_1_297_00_05_01_02.xsd')
def _psv_validate_xml(self, xml_bytes):
from lxml import etree
schema = etree.XMLSchema(etree.parse(self._psv_xsd_path()))
doc = etree.fromstring(xml_bytes)
ok = schema.validate(doc)
errors = [e.message for e in schema.error_log] if not ok else []
return ok, errors
def action_export_xml(self):
self.ensure_one()
self.action_compute()
filename = self._psv_xml_filename()
xml_bytes = self._psv_build_xml(filename=filename)
ok, errors = self._psv_validate_xml(xml_bytes)
if not ok:
raise UserError(_(
'Сформированные перс. сведения не прошли проверку по схеме '
'ФНС:\n%s') % '\n'.join(errors[:10]))
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',
})
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%s?download=true' % attachment.id,
'target': 'self',
}