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