Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC
This commit is contained in:
355
rsbu_fns_reporting/models/rsbu_rsv_xml.py
Normal file
355
rsbu_fns_reporting/models/rsbu_rsv_xml.py
Normal file
@@ -0,0 +1,355 @@
|
||||
# -*- 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',
|
||||
}
|
||||
Reference in New Issue
Block a user