Files
public/rsbu_fns_reporting/models/rsbu_rsv_xml.py

356 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Генерация РСВ (расчёт по страховым взносам) в XML.
Формат ФНС NO_RASCHSV, версия 5.08, КНД 1151111. Кодировка windows-1251,
кириллические имена элементов/атрибутов. Профиль: УСН + МСП, единый
пониженный тариф. Заполняются: титул, Раздел 1 (ОбязПлатСВ) с подразделом
единого тарифа (РасчСВ_ОПСОМС), Приложение 3.1 (ПравТариф13.2.427) и
Раздел 3 (ПерсСвСтрахЛиц) по каждому сотруднику.
Суммы — decimal с 2 знаками; количества — целые. Валидация против XSD.
"""
import base64
from datetime import date
from odoo import _, api, models
from odoo.exceptions import UserError
KND_RSV = '1151111'
FORMAT_VERSION = '5.08'
PLACE_AT_LOCATION = '214' # по месту нахождения российской организации (РСВ)
class RsbuRsvDeclaration(models.Model):
_inherit = 'rsbu.rsv.declaration'
# ------------------------------------------------------------------
@staticmethod
def _sum(value):
"""Денежная сумма РСВ: decimal с 2 знаками (строкой)."""
return '%.2f' % (value or 0.0)
@staticmethod
def _int(value):
return str(int(value or 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 _rsv_xml_filename(self):
"""Имя файла обмена: NO_RASCHSV_{КодНО}_{КодНО}_{ИНН}{КПП}_{дата}_{GUID}."""
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_RASCHSV_%s_%s_%s%s_%s_%s' % (
code_no, code_no, inn, kpp, today, guid)
def _rsv_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.name:
errors.append(_('наименование организации'))
if not company.rsbu_tax_office_code:
errors.append(_('код налогового органа'))
if not (self.oktmo or company.rsbu_oktmo):
errors.append(_('ОКТМО'))
# Персональные данные сотрудников раздела 3.
for line in self.pers_line_ids:
emp = line.employee_id
if not emp.rsbu_snils:
errors.append(_('СНИЛС сотрудника «%s»') % emp.name)
if not emp.birthday:
errors.append(_('дата рождения «%s»') % emp.name)
if not emp.rsbu_sex:
errors.append(_('пол «%s»') % emp.name)
if not emp.rsbu_id_doc_number:
errors.append(_('документ (ДУЛ) «%s»') % emp.name)
if errors:
raise UserError(_(
'Не заполнены обязательные реквизиты для РСВ: %s. Укажите '
'их в настройках компании и карточках сотрудников.')
% ', '.join(errors[:15]))
def _rsv_build_xml(self, filename=None):
"""Собрать XML РСВ (формат 5.08). Возвращает bytes."""
self.ensure_one()
try:
from lxml import etree
except ImportError:
raise UserError(_('Для выгрузки XML требуется библиотека lxml.')) from None
self._rsv_validate_requisites()
if filename is None:
filename = self._rsv_xml_filename()
company = self.company_id
partner = company.partner_id
oktmo = self.oktmo or company.rsbu_oktmo or ''
from .rsbu_rsv_declaration import RSV_PERIOD_CODE
period_code = RSV_PERIOD_CODE[int(self.quarter)]
E = etree.Element
root = E('Файл', {
'ИдФайл': filename,
'ВерсПрог': 'RuOdoo',
'ВерсФорм': FORMAT_VERSION,
})
doc = E('Документ', {
'КНД': KND_RSV,
'ДатаДок': date.today().strftime('%d.%m.%Y'),
'НомКорр': '0',
'Период': period_code,
'ОтчетГод': str(self.year),
'КодНО': company.rsbu_tax_office_code or '',
'ПоМесту': PLACE_AT_LOCATION,
})
root.append(doc)
# СвНП + НПЮЛ.
svnp = E('СвНП', {'СрЧисл': self._int(self.avg_headcount)})
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)
# РасчетСВ.
raschet = E('РасчетСВ')
doc.append(raschet)
self._rsv_build_section1(etree, raschet, oktmo, company)
self._rsv_build_pers(etree, raschet, company)
return etree.tostring(
root, xml_declaration=True, encoding='windows-1251',
pretty_print=True)
def _rsv_build_section1(self, etree, raschet, oktmo, company):
"""Раздел 1: ОбязПлатСВ (уплата) + РасчСВ_ОПСОМС + Приложение 3.1."""
E = etree.Element
obyaz = E('ОбязПлатСВ', {'ТипПлат': '1', 'ОКТМО': oktmo})
raschet.append(obyaz)
# УплПерОПС — взносы к уплате по единому тарифу (период + 3 месяца).
etree.SubElement(obyaz, 'УплПерОПС', {
'КБК': company.rsbu_kbk_insurance or '',
'СумСВУплПер': self._sum(self.contrib_period),
'СумСВУпл1М': self._sum(self.contrib_month1),
'СумСВУпл2М': self._sum(self.contrib_month2),
'СумСВУпл3М': self._sum(self.contrib_month3),
})
# РасчСВ_ОПС_ОМС (расчёт по единому тарифу).
rasch = E('РасчСВ_ОПС_ОМС', {
'ТарифПлат': company.rsbu_payer_tariff_code or '01'})
obyaz.append(rasch)
opsoms = E('РасчСВ_ОПСОМС')
rasch.append(opsoms)
# Помесячные значения последних 3 месяцев по показателям.
Summary = self.env['rsbu.rsv.summary'].sudo()
m1, m2, m3 = Summary._quarter_months(int(self.quarter))
last3 = Summary.records_for(self.company_id, self.year, m1, m3)
def qty_node(tag, total, per_month):
etree.SubElement(opsoms, tag, {
'КолВсегоПер': self._int(total),
'Кол1Посл3М': self._int(per_month[0]),
'Кол2Посл3М': self._int(per_month[1]),
'Кол3Посл3М': self._int(per_month[2]),
})
def sum_node(tag, total, per_month):
etree.SubElement(opsoms, tag, {
'СумВсегоПер': self._sum(total),
'Сум1Посл3М': self._sum(per_month[0]),
'Сум2Посл3М': self._sum(per_month[1]),
'Сум3Посл3М': self._sum(per_month[2]),
})
def count_emp(month, cond=lambda r: True):
recs = last3.filtered(lambda r: r.month == month and cond(r))
return len(recs.mapped('employee_id'))
def msum(month, field):
recs = last3.filtered(lambda r: r.month == month)
return round(sum(recs.mapped(field)), 2)
insured = self.insured_count
charged = self.charged_count
# Количества (застрахованные / с начислениями / в пределах / сверх).
qty_node('КолСтрахЛицВс', insured,
[count_emp(m) for m in (m1, m2, m3)])
qty_node('КолЛицНачСВВс', charged,
[count_emp(m, lambda r: r.base > 0) for m in (m1, m2, m3)])
qty_node('НеПревБазОПС',
len(last3.filtered(lambda r: r.base_within_limit > 0)
.mapped('employee_id')),
[count_emp(m, lambda r: r.base_within_limit > 0)
for m in (m1, m2, m3)])
qty_node('ПревБазОПС',
len(last3.filtered(lambda r: r.base_over_limit > 0)
.mapped('employee_id')),
[count_emp(m, lambda r: r.base_over_limit > 0)
for m in (m1, m2, m3)])
# Суммы.
sum_node('ВыплНачислФЛ', self.payments_total,
[msum(m, 'payments') for m in (m1, m2, m3)])
sum_node('НеОбложенСВ', self.non_taxable_total,
[msum(m, 'non_taxable') for m in (m1, m2, m3)])
sum_node('РасхПринВыч', 0.0, [0.0, 0.0, 0.0])
sum_node('БазНачислСВ', self.base_total,
[msum(m, 'base') for m in (m1, m2, m3)])
sum_node('БазНеПревышОПС', self.base_within_total,
[msum(m, 'base_within_limit') for m in (m1, m2, m3)])
sum_node('БазПревышОПС', self.base_over_total,
[msum(m, 'base_over_limit') for m in (m1, m2, m3)])
sum_node('НачислСВ', self.charged_total,
[msum(m, 'ops') for m in (m1, m2, m3)])
sum_node('НачислСВНеПрев', self.charged_within_total,
[msum(m, 'ops_within') for m in (m1, m2, m3)])
sum_node('НачислСВПрев', self.charged_over_total,
[msum(m, 'ops_over') for m in (m1, m2, m3)])
# Приложение 3.1 (МСП, п.13.2 ст.427) — при льготном тарифе.
# КодПлат: признак соответствия условиям ('1' — соблюдены).
# Доходы — целые рубли (integer); доля — процент (decimal 2 знака).
if company.rsbu_msp_enabled:
if self.msp_income_total:
share = round(self.msp_income_main
/ self.msp_income_total * 100.0, 2)
else:
share = 0.0
etree.SubElement(obyaz, 'ПравТариф13.2.427', {
'КодПлат': '1',
'Дох26.2Пер': self._int(self.msp_income_total),
'ДохОснПер': self._int(self.msp_income_main),
'ДолДох13.2.427Пер': '%.2f' % share,
})
def _rsv_build_pers(self, etree, raschet, company):
"""Раздел 3: персонифицированные сведения по сотрудникам."""
E = etree.Element
Summary = self.env['rsbu.rsv.summary'].sudo()
m1, m2, m3 = Summary._quarter_months(int(self.quarter))
months = [m1, m2, m3]
for line in self.pers_line_ids:
emp = line.employee_id
pers = E('ПерсСвСтрахЛиц')
raschet.append(pers)
danfl = E('ДанФЛПолуч', {
'СНИЛС': self._snils(emp.rsbu_snils),
'ДатаРожд': emp.birthday.strftime('%d.%m.%Y')
if emp.birthday else '',
'Гражд': emp.rsbu_citizenship_code or '643',
'Пол': emp.rsbu_sex or '1',
'КодВидДок': emp.rsbu_id_doc_code or '21',
'СерНомДок': emp.rsbu_id_doc_number or '',
})
if emp.rsbu_inn:
danfl.set('ИННФЛ', emp.rsbu_inn.strip())
pers.append(danfl)
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(danfl, 'ФИО', fio)
# Выплаты по ОПС по месяцам (СвВыплСВОПС → СвВыпл → СвВыплМК).
pays = [line.pay_m1, line.pay_m2, line.pay_m3]
bases = [line.base_m1, line.base_m2, line.base_m3]
opses = [line.ops_m1, line.ops_m2, line.ops_m3]
if any(pays):
svvypl_ops = E('СвВыплСВОПС')
pers.append(svvypl_ops)
svvypl = E('СвВыпл')
svvypl_ops.append(svvypl)
for idx, month in enumerate(months):
if not pays[idx] and not bases[idx]:
continue
# «Месяц» в разделе 3 — порядковый номер месяца в
# квартале (1/2/3), а не календарный.
etree.SubElement(svvypl, 'СвВыплМК', {
'Месяц': str(idx + 1),
'КодКатЛиц': line.category or 'НР',
'СумВыпл': self._sum(pays[idx]),
'ВыплОПС': self._sum(bases[idx]),
'НачислСВ': self._sum(opses[idx]),
})
# ------------------------------------------------------------------
@api.model
def _rsv_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_RASCHSV_1_162_00_05_08_02.xsd')
def _rsv_validate_xml(self, xml_bytes):
from lxml import etree
schema = etree.XMLSchema(etree.parse(self._rsv_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):
"""Сформировать РСВ в XML, провалидировать и вернуть вложением."""
self.ensure_one()
self.action_compute()
filename = self._rsv_xml_filename()
xml_bytes = self._rsv_build_xml(filename=filename)
ok, errors = self._rsv_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',
}