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