# -*- 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', }