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