195 lines
7.4 KiB
Python
195 lines
7.4 KiB
Python
# -*- 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',
|
||
}
|