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,18 @@
from . import rsbu_usn_declaration
from . import rsbu_usn_xml
from . import rsbu_upd_xml
from . import rsbu_upd_docx
from . import rsbu_upd_generation
from . import rsbu_nds_declaration
from . import rsbu_nds_xml
from . import rsbu_rsv_declaration
from . import rsbu_rsv_xml
from . import rsbu_enp_notification
from . import rsbu_enp_xml
from . import rsbu_ndfl_report
from . import rsbu_ndfl_xml
from . import rsbu_pers_info
from . import rsbu_pers_info_xml
from . import rsbu_hr_event
from . import rsbu_efs1_szvtd
from . import rsbu_efs1_szvtd_xml

View File

@@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-
"""Документ ЕФС-1 подраздел 1.1 (СЗВ-ТД) — сведения о трудовой деятельности.
Единая форма сведений ЕФС-1 (СФР), подраздел 1.1 «Сведения о трудовой
(иной) деятельности зарегистрированного лица». Формат 2024-01-01
(пространство имён http://пф.рф/ЕФС-1/2024-01-01). Подаётся в
Социальный фонд России по факту кадровых мероприятий (приём, увольнение,
перевод и т.п.).
Документ группирует кадровые мероприятия (rsbu.hr.event) по
зарегистрированным лицам: один документ → блок СЗВ с несколькими ЗЛ, у
каждого ЗЛ — свои мероприятия. Автозаполнение собирает ещё не поданные
мероприятия за отчётный период. XML-выгрузка и XSD-валидация — в
rsbu_efs1_szvtd_xml (через xml.etree.ElementTree + xmlschema, т.к. имена
элементов и namespace СФР — кириллические).
Реквизиты страхователя (рег. номер СФР, наименование, ИНН/КПП, ОКВЭД,
ОГРН) и руководителя берутся из настроек компании (rsbu_reporting_base).
"""
from odoo import api, fields, models
class RsbuEfs1Szvtd(models.Model):
_name = 'rsbu.efs1.szvtd'
_description = 'РСБУ — ЕФС-1 подраздел 1.1 (СЗВ-ТД)'
_order = 'date_fill desc, id desc'
_check_company_auto = True
name = fields.Char(string='Наименование', compute='_compute_name',
store=True)
company_id = fields.Many2one(
'res.company', string='Компания', required=True, index=True,
default=lambda self: self.env.company)
date_from = fields.Date(
string='Период с', required=True,
default=lambda self: fields.Date.today().replace(day=1),
help='Начало периода отбора кадровых мероприятий для автозаполнения.')
date_to = fields.Date(
string='Период по', required=True,
default=fields.Date.context_today,
help='Конец периода отбора кадровых мероприятий для автозаполнения.')
date_fill = fields.Date(
string='Дата заполнения', required=True,
default=fields.Date.context_today,
help='Дата заполнения сведений (элемент ДатаЗаполнения).')
state = fields.Selection(
selection=[('draft', 'Черновик'), ('computed', 'Сформировано'),
('reported', 'Подано')],
default='draft', string='Состояние', copy=False)
event_ids = fields.One2many(
'rsbu.hr.event', 'efs1_id', string='Кадровые мероприятия')
event_count = fields.Integer(
string='Мероприятий', compute='_compute_counts')
employee_count = fields.Integer(
string='Сотрудников', compute='_compute_counts')
@api.depends('date_from', 'date_to')
def _compute_name(self):
for rec in self:
df = rec.date_from and rec.date_from.strftime('%d.%m.%Y') or '?'
dt = rec.date_to and rec.date_to.strftime('%d.%m.%Y') or '?'
rec.name = 'ЕФС-1 (СЗВ-ТД): %s — %s' % (df, dt)
@api.depends('event_ids', 'event_ids.employee_id')
def _compute_counts(self):
for rec in self:
rec.event_count = len(rec.event_ids)
rec.employee_count = len(rec.event_ids.employee_id)
# ------------------------------------------------------------------
def action_compute(self):
"""Автозаполнить документ ещё не поданными мероприятиями за период."""
Event = self.env['rsbu.hr.event']
for rec in self:
# Отвязать прежний набор (не удаляя сами мероприятия).
rec.event_ids.write({'efs1_id': False})
events = Event.search([
('company_id', '=', rec.company_id.id),
('event_date', '>=', rec.date_from),
('event_date', '<=', rec.date_to),
('state', '=', 'draft'),
('efs1_id', '=', False),
])
events.write({'efs1_id': rec.id})
rec.state = 'computed'
return True
def _events_by_employee(self):
"""Сгруппировать мероприятия документа по сотруднику (ЗЛ).
Возвращает список пар (employee, events) в стабильном порядке
(по ФИО сотрудника), мероприятия внутри — по дате.
"""
self.ensure_one()
by_emp = {}
for ev in self.event_ids:
by_emp.setdefault(ev.employee_id, self.env['rsbu.hr.event'])
by_emp[ev.employee_id] |= ev
pairs = []
for emp in sorted(by_emp, key=lambda e: (e.name or '')):
events = by_emp[emp].sorted(lambda e: (e.event_date, e.id))
pairs.append((emp, events))
return pairs

View File

@@ -0,0 +1,345 @@
# -*- coding: utf-8 -*-
"""Генерация ЕФС-1 подраздел 1.1 (СЗВ-ТД) в XML + XSD-валидация (СФР).
Формат ЕФС-1 2024-01-01 (пространство имён http://пф.рф/ЕФС-1/2024-01-01).
В отличие от форм ФНС (латинские имена, windows-1251, lxml), ЕФС-1 СФР
использует КИРИЛЛИЧЕСКИЕ имена элементов и кириллические URI пространств
имён (http://пф.рф/...). Библиотека lxml с такими URI не работает вовсе
(ни парсинг, ни построение, ни XSD), поэтому:
* дерево строится через стандартный xml.etree.ElementTree (поддерживает
кириллические namespace);
* XSD-валидация выполняется библиотекой xmlschema (чистый Python).
Структура (корень ЭДПФР, ns по умолчанию = ЕФС-1/2024-01-01):
ЭДПФР
├ ЕФС-1
│ ├ Страхователь (ЕФС:РегНомер, ЕФС:Наименование, УТ2:ИНН,
│ │ УТ2:КПП, УТ2:КодПоОКВЭД, ЕФС:ОГРН,
│ │ УТ2:Телефон?, УТ2:АдресЭлПочты?)
│ ├ СЗВ → ЗЛ[1..*] (УТ2:СНИЛС, ИНН?, УТ2:ФИО, УТ2:ДатаРождения,
│ │ СтатусЗЛ, Гражданство, СЗВ-ТД → ТД → Мероприятие[1..*])
│ ├ Руководитель (УТ2:ФИО, УТ2:Должность)
│ └ ДатаЗаполнения
└ СлужебнаяИнформация (АФ5:GUID, АФ5:ДатаВремя, АФ5:ПрограммаПодготовки)
Реквизиты страхователя и руководителя — из настроек компании
(rsbu_reporting_base). Данные ЗЛ — из карточки сотрудника (rsbu_payroll).
"""
import base64
import os
import uuid
from datetime import datetime
from xml.etree import ElementTree as ET
from odoo import _, api, models
from odoo.exceptions import UserError
from odoo.modules import get_module_path
FORMAT_VERSION = '2024-01-01'
# Пространства имён формата ЕФС-1 2024-01-01.
NS_EFS1 = 'http://пф.рф/ЕФС-1/2024-01-01' # по умолчанию (без префикса)
NS_EFS = 'http://пф.рф/ВС/ЕФС/2024-01-01' # ЕФС:
NS_UT2 = 'http://пф.рф/УТ/2017-08-21' # УТ2:
NS_AF5 = 'http://пф.рф/АФ/2018-12-07' # АФ5:
NS_VS2 = 'http://пф.рф/ВС/типы/2017-10-23' # ВС2:
# Рекомендуемые префиксы (как в живых образцах 1С, принятых СФР).
NS_PREFIXES = {
'': NS_EFS1,
'ЕФС': NS_EFS,
'УТ2': NS_UT2,
'АФ5': NS_AF5,
'ВС2': NS_VS2,
}
# Кэш собранной XSD-схемы (сборка пакета схем — дорогая операция).
_XSD_SCHEMA = None
def _q(ns, tag):
"""Квалифицированное имя {namespace}tag для ElementTree."""
return '{%s}%s' % (ns, tag) if ns else tag
class RsbuEfs1Szvtd(models.Model):
_inherit = 'rsbu.efs1.szvtd'
# ------------------------------------------------------------------
# Реквизиты и валидация входных данных
# ------------------------------------------------------------------
def _efs1_validate_requisites(self):
self.ensure_one()
company = self.company_id
partner = company.partner_id
errors = []
if not company.rsbu_sfr_reg_number:
errors.append(_('регистрационный номер в СФР'))
if not (partner.inn or '').strip():
errors.append(_('ИНН организации'))
if not company.rsbu_ogrn:
errors.append(_('ОГРН организации'))
if not company.rsbu_signatory_id:
errors.append(_('подписант (руководитель)'))
if not self.event_ids:
errors.append(_('ни одного кадрового мероприятия'))
for ev in self.event_ids:
emp = ev.employee_id
if not emp.rsbu_snils:
errors.append(_('СНИЛС сотрудника «%s»') % emp.name)
if not emp.birthday:
errors.append(_('дата рождения сотрудника «%s»') % emp.name)
if ev._okz_required() and not ev._effective_okz():
errors.append(
_('код по ОКЗ для мероприятия «%s»') % ev.name)
if errors:
raise UserError(_(
'Не заполнены обязательные реквизиты ЕФС-1 (СЗВ-ТД): %s.')
% ', '.join(errors[:10]))
@staticmethod
def _fio_subelements(parent, full_name):
"""Добавить УТ2:Фамилия/Имя/Отчество из строки ФИО (через пробел)."""
parts = (full_name or '').split()
if parts:
ET.SubElement(parent, _q(NS_UT2, 'Фамилия')).text = parts[0]
if len(parts) > 1:
ET.SubElement(parent, _q(NS_UT2, 'Имя')).text = parts[1]
if len(parts) > 2:
ET.SubElement(parent, _q(NS_UT2, 'Отчество')).text = ' '.join(
parts[2:])
# ------------------------------------------------------------------
# Построение дерева
# ------------------------------------------------------------------
def _efs1_build_strahovatel(self, parent):
"""Блок Страхователь (ЕФС:ТипСтрахователь)."""
self.ensure_one()
company = self.company_id
partner = company.partner_id
s = ET.SubElement(parent, _q(NS_EFS1, 'Страхователь'))
ET.SubElement(s, _q(NS_EFS, 'РегНомер')).text = \
company.rsbu_sfr_reg_number or ''
ET.SubElement(s, _q(NS_EFS, 'Наименование')).text = company.name or ''
ET.SubElement(s, _q(NS_UT2, 'ИНН')).text = (partner.inn or '').strip()
if (partner.kpp or '').strip():
ET.SubElement(s, _q(NS_UT2, 'КПП')).text = partner.kpp.strip()
if company.rsbu_okved:
ET.SubElement(s, _q(NS_UT2, 'КодПоОКВЭД')).text = company.rsbu_okved
# ОГРН (13 знаков) или ОГРНИП (15) — по choice типа Страхователь.
ogrn = (company.rsbu_ogrn or '').strip()
tag = 'ОГРНИП' if len(ogrn) == 15 else 'ОГРН'
ET.SubElement(s, _q(NS_EFS, tag)).text = ogrn
phone = (partner.phone or '').strip()
if phone:
digits = ''.join(ch for ch in phone if ch.isdigit())
if digits:
ET.SubElement(s, _q(NS_UT2, 'Телефон')).text = digits
email = (partner.email or '').strip()
if email:
ET.SubElement(s, _q(NS_UT2, 'АдресЭлПочты')).text = email
return s
def _efs1_build_meropriyatie(self, parent, ev):
"""Элемент Мероприятие (ЕФС:ТипТД) — все дочерние в ns ЕФС."""
m = ET.SubElement(parent, _q(NS_EFS1, 'Мероприятие'))
ET.SubElement(m, _q(NS_EFS, 'UUID')).text = \
ev.event_uuid or str(uuid.uuid4())
ET.SubElement(m, _q(NS_EFS, 'Дата')).text = \
ev.event_date.strftime('%Y-%m-%d')
ET.SubElement(m, _q(NS_EFS, 'Вид')).text = ev.kind
if ev.job_title:
ET.SubElement(m, _q(NS_EFS, 'Должность')).text = ev.job_title
if ev.employment_type:
ET.SubElement(m, _q(NS_EFS, 'ЯвляетсяСовместителем')).text = \
ev.employment_type
if ev.work_kind:
ET.SubElement(m, _q(NS_EFS, 'ВидПР')).text = ev.work_kind
if ev.department_name:
ET.SubElement(m, _q(NS_EFS, 'СтруктурноеПодразделение')).text = \
ev.department_name
if ev.func_code:
ET.SubElement(m, _q(NS_EFS, 'КодВФ')).text = ev.func_code
okz = ev._effective_okz()
if okz:
ET.SubElement(m, _q(NS_EFS, 'КодВФпоОКЗ')).text = okz
# Основание увольнения (только при виде 5 и наличии данных).
if ev.kind == '5' and ev.dismissal_reason_code:
ou = ET.SubElement(m, _q(NS_EFS, 'ОснованиеУвольнения'))
ET.SubElement(ou, _q(NS_EFS, 'КодПричиныУвольнения')).text = \
ev.dismissal_reason_code
if ev.dismissal_document:
ET.SubElement(ou, _q(NS_EFS, 'НормативныйДокумент')).text = \
ev.dismissal_document
if ev.dismissal_article:
ET.SubElement(ou, _q(NS_EFS, 'Статья')).text = \
ev.dismissal_article
if ev.dismissal_part:
ET.SubElement(ou, _q(NS_EFS, 'Часть')).text = ev.dismissal_part
if ev.dismissal_point:
ET.SubElement(ou, _q(NS_EFS, 'Пункт')).text = \
ev.dismissal_point
if ev.dismissal_subpoint:
ET.SubElement(ou, _q(NS_EFS, 'Подпункт')).text = \
ev.dismissal_subpoint
if ev.dismissal_paragraph:
ET.SubElement(ou, _q(NS_EFS, 'Абзац')).text = \
ev.dismissal_paragraph
if ev.dismissal_reason:
ET.SubElement(m, _q(NS_EFS, 'Причина')).text = ev.dismissal_reason
# Основание (приказ) — обязательный блок.
basis = ET.SubElement(m, _q(NS_EFS, 'Основание'))
ET.SubElement(basis, _q(NS_EFS, 'Наименование')).text = \
ev.basis_name or 'Приказ'
basis_date = ev.basis_date or ev.event_date
ET.SubElement(basis, _q(NS_EFS, 'Дата')).text = \
basis_date.strftime('%Y-%m-%d')
if ev.basis_series:
ET.SubElement(basis, _q(NS_EFS, 'Серия')).text = ev.basis_series
ET.SubElement(basis, _q(NS_EFS, 'Номер')).text = \
ev.basis_number or '1'
return m
def _efs1_build_zl(self, parent, emp, events):
"""Блок ЗЛ (одно зарегистрированное лицо) с его мероприятиями."""
zl = ET.SubElement(parent, _q(NS_EFS1, 'ЗЛ'))
snils = (emp.rsbu_snils or '').strip()
ET.SubElement(zl, _q(NS_UT2, 'СНИЛС')).text = snils
if emp.rsbu_inn:
ET.SubElement(zl, _q(NS_EFS1, 'ИНН')).text = emp.rsbu_inn.strip()
fio = ET.SubElement(zl, _q(NS_UT2, 'ФИО'))
self._fio_subelements(fio, emp.name)
if emp.birthday:
ET.SubElement(zl, _q(NS_UT2, 'ДатаРождения')).text = \
emp.birthday.strftime('%Y-%m-%d')
ET.SubElement(zl, _q(NS_EFS1, 'СтатусЗЛ')).text = 'ГРФ'
ET.SubElement(zl, _q(NS_EFS1, 'Гражданство')).text = \
emp.rsbu_citizenship_code or '643'
szvtd = ET.SubElement(zl, _q(NS_EFS1, 'СЗВ-ТД'))
td = ET.SubElement(szvtd, _q(NS_EFS1, 'ТД'))
for ev in events:
self._efs1_build_meropriyatie(td, ev)
return zl
def _efs1_build_rukovoditel(self, parent):
"""Блок Руководитель (УТ2:ТипДолжностноеЛицо)."""
self.ensure_one()
company = self.company_id
signatory = company.rsbu_signatory_id
r = ET.SubElement(parent, _q(NS_EFS1, 'Руководитель'))
fio = ET.SubElement(r, _q(NS_UT2, 'ФИО'))
self._fio_subelements(fio, signatory.partner_id.name
if signatory else '')
ET.SubElement(r, _q(NS_UT2, 'Должность')).text = \
company.rsbu_signatory_title or 'РУКОВОДИТЕЛЬ'
return r
def _efs1_build_xml(self, guid=None):
"""Собрать XML документа ЕФС-1 (СЗВ-ТД). Возвращает bytes (UTF-8)."""
self.ensure_one()
self._efs1_validate_requisites()
if guid is None:
guid = str(uuid.uuid4())
for prefix, ns in NS_PREFIXES.items():
ET.register_namespace(prefix, ns)
root = ET.Element(_q(NS_EFS1, 'ЭДПФР'))
efs1 = ET.SubElement(root, _q(NS_EFS1, 'ЕФС-1'))
self._efs1_build_strahovatel(efs1)
szv = ET.SubElement(efs1, _q(NS_EFS1, 'СЗВ'))
for emp, events in self._events_by_employee():
self._efs1_build_zl(szv, emp, events)
self._efs1_build_rukovoditel(efs1)
ET.SubElement(efs1, _q(NS_EFS1, 'ДатаЗаполнения')).text = \
(self.date_fill or self.date_to).strftime('%Y-%m-%d')
service = ET.SubElement(root, _q(NS_EFS1, 'СлужебнаяИнформация'))
ET.SubElement(service, _q(NS_AF5, 'GUID')).text = guid
ET.SubElement(service, _q(NS_AF5, 'ДатаВремя')).text = \
datetime.now().strftime('%Y-%m-%dT%H:%M:%S')
ET.SubElement(service, _q(NS_AF5, 'ПрограммаПодготовки')).text = \
'RuOdoo'
return ET.tostring(root, encoding='UTF-8', xml_declaration=True)
# ------------------------------------------------------------------
# XSD-валидация через xmlschema
# ------------------------------------------------------------------
@api.model
def _efs1_xsd_path(self):
return os.path.join(
get_module_path('rsbu_fns_reporting'), 'data', 'schemas', 'efs1',
'ВС', 'Входящие', 'ЕФС-1_2024-01-01.xsd')
@api.model
def _efs1_get_schema(self):
"""Собрать (с кэшем) XSD-схему ЕФС-1 через xmlschema."""
global _XSD_SCHEMA
if _XSD_SCHEMA is not None:
return _XSD_SCHEMA
try:
import xmlschema
except ImportError:
raise UserError(_(
'Для проверки ЕФС-1 по схеме СФР требуется библиотека '
'Python «xmlschema». Установите её: pip install xmlschema.'
)) from None
path = self._efs1_xsd_path()
if not os.path.exists(path):
raise UserError(_(
'Не найдена XSD-схема ЕФС-1: %s') % path)
schema = xmlschema.XMLSchema(path, build=False)
schema.build()
_XSD_SCHEMA = schema
return schema
def _efs1_validate_xml(self, xml_bytes):
"""Проверить XML по XSD. Возвращает (ok, [сообщения об ошибках])."""
schema = self._efs1_get_schema()
errors = []
try:
schema.validate(xml_bytes.decode('utf-8'))
except Exception as exc: # xmlschema.XMLSchemaValidationError и пр.
errors = [str(exc)]
return (not errors), errors
# ------------------------------------------------------------------
def action_export_xml(self):
self.ensure_one()
self.action_compute()
guid = str(uuid.uuid4())
xml_bytes = self._efs1_build_xml(guid=guid)
ok, errors = self._efs1_validate_xml(xml_bytes)
if not ok:
raise UserError(_(
'Сформированная форма ЕФС-1 (СЗВ-ТД) не прошла проверку по '
'схеме СФР:\n%s') % '\n'.join(errors[:10]))
filename = self._efs1_xml_filename(guid)
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',
})
self.event_ids.write({'state': 'reported'})
self.state = 'reported'
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%s?download=true' % attachment.id,
'target': 'self',
}
def _efs1_xml_filename(self, guid):
"""Имя файла выгрузки ЕФС-1 (по образцу 1С: ПФР_<рег>_<...>_ЕФС-1_...)."""
self.ensure_one()
reg = (self.company_id.rsbu_sfr_reg_number or '').replace('-', '')
reg_dash = self.company_id.rsbu_sfr_reg_number or ''
date_str = (self.date_fill or self.date_to).strftime('%Y%m%d')
return 'ПФР_%s_%s_ЕФС-1_%s_%s' % (reg_dash, reg[:6], date_str, guid)

View File

@@ -0,0 +1,205 @@
# -*- coding: utf-8 -*-
"""Уведомление об исчисленных суммах налогов, авансов, сборов, взносов.
Форма ФНС КНД 1110355, формат UT_UVISCHSUMNAL версии 5.03 (приказ ФНС
от 16.01.2024 № ЕД-7-8/20@). Подаётся в составе механизма ЕНП в месяцы,
когда налог/взносы исчислены, но срок декларации/расчёта ещё не наступил.
Автозаполнение строк за отчётный месяц из данных учёта:
* страховые взносы — помесячные итоги rsbu.rsv.summary (ОПС+ОМС+ВНиМ);
* НДФЛ — удержанный налог из проведённых расчётных листков (строки NDFL);
* аванс УСН — из закрытия периода (rsbu.period.close) за квартал.
Каждая строка несёт КБК/ОКТМО/период/сумму. Один документ может содержать
несколько строк по разным налогам (НДФЛ + взносы + аванс УСН).
"""
from odoo import api, fields, models
# Код квартала (Период) по номеру квартала.
ENP_PERIOD_CODE = {1: '21', 2: '31', 3: '33', 4: '34'}
class RsbuEnpNotification(models.Model):
_name = 'rsbu.enp.notification'
_description = 'РСБУ — Уведомление об исчисленных суммах (ЕНП)'
_order = 'year desc, month desc, id desc'
_check_company_auto = True
name = fields.Char(string='Наименование', compute='_compute_name',
store=True)
company_id = fields.Many2one(
'res.company', string='Компания', required=True, index=True,
default=lambda self: self.env.company)
year = fields.Integer(string='Год', required=True,
default=lambda self: fields.Date.today().year)
month = fields.Integer(
string='Отчётный месяц', required=True,
default=lambda self: fields.Date.today().month,
help='Календарный месяц, за который исчислены суммы. Определяет '
'код периода (квартал) и номер месяца в квартале.')
date_doc = fields.Date(
string='Дата документа', default=fields.Date.context_today)
state = fields.Selection(
selection=[('draft', 'Черновик'), ('computed', 'Рассчитано')],
default='draft', string='Состояние', copy=False)
currency_id = fields.Many2one(
'res.currency', related='company_id.currency_id')
line_ids = fields.One2many(
'rsbu.enp.notification.line', 'notification_id', string='Строки')
@api.depends('year', 'month')
def _compute_name(self):
names = ['', 'январь', 'февраль', 'март', 'апрель', 'май', 'июнь',
'июль', 'август', 'сентябрь', 'октябрь', 'ноябрь', 'декабрь']
for rec in self:
m = names[rec.month] if rec.month and 1 <= rec.month <= 12 else '?'
rec.name = 'Уведомление ЕНП: %s %s' % (m, rec.year or '?')
def _quarter(self):
self.ensure_one()
return (self.month - 1) // 3 + 1 if self.month else 1
def _month_in_quarter(self):
"""Порядковый номер месяца в квартале (01..03)."""
self.ensure_one()
return '%02d' % ((self.month - 1) % 3 + 1)
def _month_bounds(self):
import calendar
self.ensure_one()
last = calendar.monthrange(self.year, self.month)[1]
return (fields.Date.to_date('%04d-%02d-01' % (self.year, self.month)),
fields.Date.to_date('%04d-%02d-%02d' % (
self.year, self.month, last)))
# ------------------------------------------------------------------
def _insurance_amount(self):
"""Начисленные страховые взносы (ОПС+ОМС+ВНиМ) за отчётный месяц."""
self.ensure_one()
Summary = self.env['rsbu.rsv.summary'].sudo()
recs = Summary.search([
('company_id', '=', self.company_id.id),
('year', '=', self.year),
('month', '=', self.month),
])
return round(sum(recs.mapped('ops')) + sum(recs.mapped('oms'))
+ sum(recs.mapped('vnim')), 2)
def _ndfl_amount(self):
"""Удержанный НДФЛ за отчётный месяц (строки NDFL в листках)."""
self.ensure_one()
date_from, date_to = self._month_bounds()
slips = self.env['hr.payslip'].sudo().search([
('company_id', '=', self.company_id.id),
('state', '=', 'done'),
('date_from', '>=', date_from),
('date_from', '<=', date_to),
])
total = 0.0
for slip in slips:
total += abs(sum(slip.line_ids.filtered(
lambda l: l.code == 'NDFL').mapped('total')))
return round(total, 2)
def _usn_advance_amount(self):
"""Аванс УСН за квартал (если месяц — месяц уплаты аванса).
Авансы УСН уплачиваются по итогам квартала в следующем месяце
(апрель/июль/октябрь). Берём из закрытия периода последнего месяца
соответствующего квартала.
"""
self.ensure_one()
# Уведомление по УСН-авансу подаётся в апреле/июле/октябре за
# предыдущий квартал (1/2/3). За 4 квартал — годовая декларация.
usn_quarter = {4: 1, 7: 2, 10: 3}.get(self.month)
if not usn_quarter:
return 0.0
import calendar
q_last_month = usn_quarter * 3
last = calendar.monthrange(self.year, q_last_month)[1]
close = self.env['rsbu.period.close'].sudo().search([
('company_id', '=', self.company_id.id),
('date_to', '=', fields.Date.to_date('%04d-%02d-%02d' % (
self.year, q_last_month, last))),
], limit=1)
return round(close.usn_tax_amount, 2) if close else 0.0
def action_compute(self):
"""Пересобрать итоги взносов за месяц и автозаполнить строки."""
for rec in self:
df, dt = rec._month_bounds()
self.env['rsbu.rsv.summary'].sudo().recompute_period(
df, dt, rec.company_id)
return self.fill_lines()
def fill_lines(self):
"""Автозаполнить строки из готовых итогов учёта за месяц.
Отделено от action_compute, чтобы заполнять по существующим данным
без принудительного пересбора итогов (тесты, повторный расчёт).
"""
for rec in self:
rec.line_ids.unlink()
company = rec.company_id
oktmo = company.rsbu_oktmo or ''
period = ENP_PERIOD_CODE[rec._quarter()]
mq = rec._month_in_quarter()
Line = self.env['rsbu.enp.notification.line']
vals = []
insurance = rec._insurance_amount()
if insurance > 0:
vals.append({
'notification_id': rec.id, 'kind': 'insurance',
'kbk': company.rsbu_kbk_insurance or '', 'oktmo': oktmo,
'amount': insurance, 'period_code': period,
'month_in_quarter': mq, 'report_year': rec.year,
})
ndfl = rec._ndfl_amount()
if ndfl > 0:
vals.append({
'notification_id': rec.id, 'kind': 'ndfl',
'kbk': company.rsbu_kbk_ndfl or '', 'oktmo': oktmo,
'amount': ndfl, 'period_code': period,
'month_in_quarter': mq, 'report_year': rec.year,
})
usn = rec._usn_advance_amount()
if usn > 0:
# Аванс УСН: период — квартал, за который платится аванс.
usn_q = {4: 1, 7: 2, 10: 3}[rec.month]
vals.append({
'notification_id': rec.id, 'kind': 'usn',
'kbk': company.rsbu_kbk_usn or '', 'oktmo': oktmo,
'amount': usn, 'period_code': ENP_PERIOD_CODE[usn_q],
'month_in_quarter': '03', 'report_year': rec.year,
})
if vals:
Line.create(vals)
rec.state = 'computed'
return True
class RsbuEnpNotificationLine(models.Model):
_name = 'rsbu.enp.notification.line'
_description = 'РСБУ — Строка уведомления ЕНП'
_order = 'notification_id, kind'
notification_id = fields.Many2one(
'rsbu.enp.notification', string='Уведомление', required=True,
ondelete='cascade', index=True)
currency_id = fields.Many2one(
'res.currency', related='notification_id.currency_id')
kind = fields.Selection(
selection=[('insurance', 'Страховые взносы'), ('ndfl', 'НДФЛ'),
('usn', 'Аванс УСН')],
string='Вид платежа', required=True)
kpp_decl = fields.Char(string='КПП (деклар.)')
oktmo = fields.Char(string='ОКТМО', required=True)
kbk = fields.Char(string='КБК', required=True)
amount = fields.Monetary(string='Сумма', currency_field='currency_id')
period_code = fields.Char(string='Период', required=True,
help='Код периода: 21/31/33/34.')
month_in_quarter = fields.Char(
string='Номер месяца/квартала', required=True,
help='Номер месяца в квартале (01-03) либо квартальный код (11-13).')
report_year = fields.Integer(string='Год', required=True)

View File

@@ -0,0 +1,175 @@
# -*- coding: utf-8 -*-
"""Генерация уведомления об исчисленных суммах (ЕНП) в XML.
Формат ФНС UT_UVISCHSUMNAL версии 5.03 (КНД 1110355). Кодировка
windows-1251, кириллические имена элементов. Структура:
Файл(ИдФайл, ВерсПрог, ВерсФорм)
└ Документ(КНД, ДатаДок, КодНО)
├ СвНП → НПЮЛ(ИННЮЛ, КПП)
├ Подписант(ПрПодп) [→ ФИО]
└ УвИсчСумНалог[1..*](КППДекл, ОКТМО, КБК, СумНалогАванс,
Период, НомерМесКварт, Год)
Суммы — decimal с 2 знаками. Реквизиты — из настроек компании.
"""
import base64
from datetime import date
from odoo import _, api, models
from odoo.exceptions import UserError
KND_ENP = '1110355'
FORMAT_VERSION = '5.03'
class RsbuEnpNotification(models.Model):
_inherit = 'rsbu.enp.notification'
@staticmethod
def _sum(value):
return '%.2f' % (value or 0.0)
def _enp_xml_filename(self):
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 'UT_UVISCHSUMNAL_%s_%s_%s%s_%s_%s' % (
code_no, code_no, inn, kpp, today, guid)
def _enp_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.rsbu_tax_office_code:
errors.append(_('код налогового органа'))
if not self.line_ids:
errors.append(_('ни одной строки с исчисленными суммами'))
for line in self.line_ids:
if not line.oktmo:
errors.append(_('ОКТМО в строке «%s»') % line.kind)
if not line.kbk:
errors.append(_('КБК в строке «%s»') % line.kind)
if errors:
raise UserError(_(
'Не заполнены обязательные реквизиты уведомления: %s.')
% ', '.join(errors[:10]))
def _enp_build_xml(self, filename=None):
self.ensure_one()
try:
from lxml import etree
except ImportError:
raise UserError(_('Для выгрузки XML требуется библиотека lxml.')) from None
self._enp_validate_requisites()
if filename is None:
filename = self._enp_xml_filename()
company = self.company_id
partner = company.partner_id
kpp = (partner.kpp or '').strip()
E = etree.Element
root = E('Файл', {
'ИдФайл': filename,
'ВерсПрог': 'RuOdoo',
'ВерсФорм': FORMAT_VERSION,
})
doc = E('Документ', {
'КНД': KND_ENP,
'ДатаДок': (self.date_doc or date.today()).strftime('%d.%m.%Y'),
'КодНО': company.rsbu_tax_office_code or '',
})
root.append(doc)
svnp = E('СвНП')
svnp.append(E('НПЮЛ', {
'ИННЮЛ': (partner.inn or '').strip(),
'КПП': kpp,
}))
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)
# Строки «Данные» — по каждому исчисленному налогу/взносу.
for line in self.line_ids:
attrs = {
'ОКТМО': line.oktmo or '',
'КБК': line.kbk or '',
'СумНалогАванс': self._sum(line.amount),
'Период': line.period_code or '',
'НомерМесКварт': line.month_in_quarter or '',
'Год': str(line.report_year or self.year),
}
# КППДекл обязателен при наличии НПЮЛ (schematron XSD).
attrs['КППДекл'] = line.kpp_decl or kpp
etree.SubElement(doc, 'УвИсчСумНалог', attrs)
return etree.tostring(
root, xml_declaration=True, encoding='windows-1251',
pretty_print=True)
@api.model
def _enp_xsd_path(self):
import os
from odoo.modules import get_module_path
return os.path.join(
get_module_path('rsbu_fns_reporting'), 'data', 'schemas',
'UT_UVISCHSUMNAL_1_263_00_05_03_01.xsd')
def _enp_validate_xml(self, xml_bytes):
from lxml import etree
schema = etree.XMLSchema(etree.parse(self._enp_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._enp_xml_filename()
xml_bytes = self._enp_build_xml(filename=filename)
ok, errors = self._enp_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',
}

View File

@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
"""Связь кадрового мероприятия с документом ЕФС-1 (СЗВ-ТД).
Базовая модель кадрового мероприятия rsbu.hr.event определена в модуле
rsbu_payroll (кадровый блок). Привязка к документу ЕФС-1 (rsbu.efs1.szvtd)
живёт здесь, в модуле отчётности, чтобы кадровый блок не зависел от
модуля регламентной отчётности.
"""
from odoo import fields, models
class RsbuHrEvent(models.Model):
_inherit = 'rsbu.hr.event'
efs1_id = fields.Many2one(
'rsbu.efs1.szvtd', string='Документ ЕФС-1', copy=False, index=True,
ondelete='set null',
help='Документ ЕФС-1 (СЗВ-ТД), в который включено мероприятие.')

View File

@@ -0,0 +1,235 @@
# -*- coding: utf-8 -*-
"""Расчёт сумм НДФЛ налогового агента (6-НДФЛ, КНД 1151100, формат 5.05).
Строится поквартально нарастающим итогом с начала года из помесячных
итогов НДФЛ (rsbu.ndfl.summary).
Разделы:
* Раздел 1 (ОбязНА) — удержанный НДФЛ к перечислению: сумма нарастающим
итогом + разбивка по шести срокам квартала (каждый месяц квартала —
два периода: дни 1–22 и 23–конец);
* Раздел 2 (РасчСумНал) — по каждой ставке: число физлиц, начисленный
доход, база, исчисленный и удержанный налог, с помесячной разбивкой
(3 месяца последнего квартала, логика как в РСВ);
* Приложение №1 (СправДох) — годовые справки о доходах; заложена
структура (строки rsbu.ndfl.report.cert), наполнение — для годового
расчёта.
"""
from odoo import api, fields, models
NDFL_PERIOD_CODE = {1: '21', 2: '31', 3: '33', 4: '34'}
class RsbuNdflReport(models.Model):
_name = 'rsbu.ndfl.report'
_description = 'РСБУ — Расчёт 6-НДФЛ'
_order = 'year desc, quarter desc, id desc'
_check_company_auto = True
name = fields.Char(string='Наименование', compute='_compute_name',
store=True)
company_id = fields.Many2one(
'res.company', string='Компания', required=True, index=True,
default=lambda self: self.env.company)
year = fields.Integer(string='Отчётный год', required=True,
default=lambda self: fields.Date.today().year)
quarter = fields.Selection(
selection=[('1', 'I квартал'), ('2', 'Полугодие'),
('3', '9 месяцев'), ('4', 'Год')],
string='Отчётный период', required=True, default='1')
currency_id = fields.Many2one(
'res.currency', related='company_id.currency_id')
state = fields.Selection(
selection=[('draft', 'Черновик'), ('computed', 'Рассчитан')],
default='draft', string='Состояние', copy=False)
oktmo = fields.Char(string='ОКТМО')
kbk = fields.Char(string='КБК')
# --- Раздел 1 (ОбязНА): удержанный НДФЛ по 6 срокам квартала ---
withheld_total = fields.Monetary(
string='Удержано (нараст. итогом)', currency_field='currency_id')
returned_total = fields.Monetary(
string='Возвращено (нараст. итогом)', currency_field='currency_id')
term1 = fields.Monetary(string='Срок 1', currency_field='currency_id')
term2 = fields.Monetary(string='Срок 2', currency_field='currency_id')
term3 = fields.Monetary(string='Срок 3', currency_field='currency_id')
term4 = fields.Monetary(string='Срок 4', currency_field='currency_id')
term5 = fields.Monetary(string='Срок 5', currency_field='currency_id')
term6 = fields.Monetary(string='Срок 6', currency_field='currency_id')
line_ids = fields.One2many(
'rsbu.ndfl.report.line', 'report_id',
string='Расчёт по ставкам (раздел 2)')
cert_ids = fields.One2many(
'rsbu.ndfl.report.cert', 'report_id',
string='Справки о доходах (приложение №1)')
@api.depends('year', 'quarter')
def _compute_name(self):
labels = dict(self._fields['quarter'].selection)
for rec in self:
rec.name = '6-НДФЛ за %s, %s' % (
labels.get(rec.quarter, ''), rec.year or '?')
def _period_bounds(self):
self.ensure_one()
import calendar
q = int(self.quarter)
last_month = q * 3
last_day = calendar.monthrange(self.year, last_month)[1]
return (fields.Date.to_date('%04d-01-01' % self.year),
fields.Date.to_date('%04d-%02d-%02d' % (
self.year, last_month, last_day)), q)
# ------------------------------------------------------------------
def action_compute(self):
"""Пересобрать итоги НДФЛ за год и рассчитать 6-НДФЛ."""
Summary = self.env['rsbu.ndfl.summary'].sudo()
for rec in self:
date_from, date_to, _q = rec._period_bounds()
Summary.recompute_period(date_from, date_to, rec.company_id)
return self.compute_from_summary()
def compute_from_summary(self):
"""Рассчитать показатели 6-НДФЛ из готовых помесячных итогов."""
Summary = self.env['rsbu.ndfl.summary'].sudo()
for rec in self:
company = rec.company_id
q = int(rec.quarter)
m1, m2, m3 = Summary._quarter_months(q)
ytd = Summary.ytd_records(company, rec.year, q)
# Последние 3 месяца квартала (для раздела 2 и сроков раздела 1).
last3 = ytd.filtered(lambda r: m1 <= r.month <= m3)
kbk = company.rsbu_kbk_ndfl or ''
rec.oktmo = rec.oktmo or company.rsbu_oktmo or ''
rec.kbk = kbk
# --- Раздел 1: удержано нарастающим + по 6 срокам квартала ---
# Срок считается по дате удержания (месяц удержания в квартале).
# Удержано нарастающим итогом с начала года — вся сумма удержаний.
rec.withheld_total = round(sum(ytd.mapped('withheld_tax')), 2)
rec.returned_total = 0.0
terms = {i: 0.0 for i in range(1, 7)}
for r in ytd:
# Срок относится к кварталу удержания; в раздел 1 отчётного
# периода попадают сроки последнего квартала (удержания в
# месяцах m1..m3). Определяем срок по месяцу удержания.
if r.withholding_month and m1 <= r.withholding_month <= m3:
term = r.deadline_term or 0
if 1 <= term <= 6:
terms[term] += r.withheld_tax
rec.term1, rec.term2, rec.term3 = (
round(terms[1], 2), round(terms[2], 2), round(terms[3], 2))
rec.term4, rec.term5, rec.term6 = (
round(terms[4], 2), round(terms[5], 2), round(terms[6], 2))
# --- Раздел 2: по ставкам, помесячно (3 месяца квартала) ---
rec.line_ids.unlink()
Line = self.env['rsbu.ndfl.report.line']
by_rate = {}
for r in ytd:
by_rate.setdefault(r.rate, []).append(r)
line_vals = []
for rate, recs in by_rate.items():
emp_ids = set(x.employee_id.id for x in recs)
def msum(month, field):
return round(sum(x[field] for x in recs
if x.month == month), 2)
def mwithheld_half(month, half):
# Удержано в месяце квартала по полупериоду (1-22/23-).
total = 0.0
for x in recs:
if x.withholding_month == month:
is_first = x.deadline_term and x.deadline_term % 2 == 1
if (half == 1 and is_first) or \
(half == 2 and not is_first):
total += x.withheld_tax
return round(total, 2)
line_vals.append({
'report_id': rec.id,
'rate': rate,
'kbk': kbk,
'persons_count': len(emp_ids),
'accrued': round(sum(x.accrued for x in recs), 2),
'deduction': round(sum(x.deduction for x in recs), 2),
'base': round(sum(x.base for x in recs), 2),
'computed_tax': round(sum(x.computed_tax
for x in recs), 2),
'withheld_tax': round(sum(x.withheld_tax
for x in recs), 2),
'wh_m1_h1': mwithheld_half(m1, 1),
'wh_m1_h2': mwithheld_half(m1, 2),
'wh_m2_h1': mwithheld_half(m2, 1),
'wh_m2_h2': mwithheld_half(m2, 2),
'wh_m3_h1': mwithheld_half(m3, 1),
'wh_m3_h2': mwithheld_half(m3, 2),
})
if line_vals:
Line.create(line_vals)
rec.state = 'computed'
return True
class RsbuNdflReportLine(models.Model):
_name = 'rsbu.ndfl.report.line'
_description = 'РСБУ — Строка расчёта 6-НДФЛ (раздел 2, по ставке)'
_order = 'report_id, rate'
report_id = fields.Many2one(
'rsbu.ndfl.report', string='Расчёт', required=True,
ondelete='cascade', index=True)
currency_id = fields.Many2one(
'res.currency', related='report_id.currency_id')
rate = fields.Integer(string='Ставка, %', required=True)
kbk = fields.Char(string='КБК')
persons_count = fields.Integer(string='Кол-во физлиц')
accrued = fields.Monetary(string='Начислено дохода',
currency_field='currency_id')
deduction = fields.Monetary(string='Вычеты',
currency_field='currency_id')
base = fields.Monetary(string='Налоговая база',
currency_field='currency_id')
computed_tax = fields.Monetary(string='Исчислено',
currency_field='currency_id')
withheld_tax = fields.Monetary(string='Удержано',
currency_field='currency_id')
# Помесячная разбивка удержаний по полупериодам (3 месяца квартала).
wh_m1_h1 = fields.Monetary(string='Уд. 1 мес. (1-22)',
currency_field='currency_id')
wh_m1_h2 = fields.Monetary(string='Уд. 1 мес. (23-)',
currency_field='currency_id')
wh_m2_h1 = fields.Monetary(string='Уд. 2 мес. (1-22)',
currency_field='currency_id')
wh_m2_h2 = fields.Monetary(string='Уд. 2 мес. (23-)',
currency_field='currency_id')
wh_m3_h1 = fields.Monetary(string='Уд. 3 мес. (1-22)',
currency_field='currency_id')
wh_m3_h2 = fields.Monetary(string='Уд. 3 мес. (23-)',
currency_field='currency_id')
class RsbuNdflReportCert(models.Model):
_name = 'rsbu.ndfl.report.cert'
_description = 'РСБУ — Справка о доходах 6-НДФЛ (приложение №1, годовая)'
_order = 'report_id, employee_id'
report_id = fields.Many2one(
'rsbu.ndfl.report', string='Расчёт', required=True,
ondelete='cascade', index=True)
currency_id = fields.Many2one(
'res.currency', related='report_id.currency_id')
employee_id = fields.Many2one('hr.employee', string='Сотрудник')
rate = fields.Integer(string='Ставка, %')
income_total = fields.Monetary(string='Сумма дохода',
currency_field='currency_id')
base = fields.Monetary(string='Налоговая база',
currency_field='currency_id')
computed_tax = fields.Monetary(string='Налог исчислен',
currency_field='currency_id')
withheld_tax = fields.Monetary(string='Налог удержан',
currency_field='currency_id')
not_withheld_tax = fields.Monetary(string='Налог не удержан',
currency_field='currency_id')

View File

@@ -0,0 +1,227 @@
# -*- coding: utf-8 -*-
"""Генерация расчёта 6-НДФЛ в XML.
Формат ФНС NO_NDFL6.2 версии 5.05 (КНД 1151100). Кодировка windows-1251,
кириллические имена элементов. Структура:
Файл(ИдФайл, ВерсПрог, ВерсФорм)
└ Документ(КНД, ДатаДок, Период, ОтчетГод, КодНО, НомКорр, ПоМесту)
├ СвНП(ОКТМО, Тлф) → НПЮЛ(НаимОрг, ИННЮЛ, КПП)
├ Подписант(ПрПодп) [→ ФИО]
└ НДФЛ6.2
├ ОбязНА(КБК, СумНалУд, СумНалВоз)
│ └ СведСумНалУд(СумНал1Срок..СумНал6Срок)
└ РасчСумНал(Ставка, КБК, КолФЛ, СумНачислНач, НалБаза,
СумНалИсч, СумНалУдерж + помесячно, ...)
Налог — целые рубли (integer); доход/база/вычеты — decimal с 2 знаками.
"""
import base64
from datetime import date
from odoo import _, api, models
from odoo.exceptions import UserError
from .rsbu_ndfl_report import NDFL_PERIOD_CODE
KND_NDFL = '1151100'
FORMAT_VERSION = '5.05'
PLACE_AT_LOCATION = '214'
class RsbuNdflReport(models.Model):
_inherit = 'rsbu.ndfl.report'
@staticmethod
def _int(value):
"""Налоговый показатель — целые рубли (строкой)."""
return str(int(round(value or 0.0)))
@staticmethod
def _dec(value):
"""Доход/база/вычет — decimal с 2 знаками."""
return '%.2f' % (value or 0.0)
def _ndfl_xml_filename(self):
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_NDFL6.2_%s_%s_%s%s_%s_%s' % (
code_no, code_no, inn, kpp, today, guid)
def _ndfl_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.rsbu_tax_office_code:
errors.append(_('код налогового органа'))
if not (self.oktmo or company.rsbu_oktmo):
errors.append(_('ОКТМО'))
if not self.line_ids:
errors.append(_('ни одной строки расчёта по ставке'))
if errors:
raise UserError(_(
'Не заполнены обязательные реквизиты 6-НДФЛ: %s.')
% ', '.join(errors[:10]))
def _ndfl_build_xml(self, filename=None):
self.ensure_one()
try:
from lxml import etree
except ImportError:
raise UserError(_('Для выгрузки XML требуется библиотека lxml.')) from None
self._ndfl_validate_requisites()
if filename is None:
filename = self._ndfl_xml_filename()
company = self.company_id
partner = company.partner_id
oktmo = self.oktmo or company.rsbu_oktmo or ''
kbk = self.kbk or company.rsbu_kbk_ndfl or ''
E = etree.Element
root = E('Файл', {
'ИдФайл': filename, 'ВерсПрог': 'RuOdoo',
'ВерсФорм': FORMAT_VERSION,
})
doc = E('Документ', {
'КНД': KND_NDFL,
'ДатаДок': date.today().strftime('%d.%m.%Y'),
'Период': NDFL_PERIOD_CODE[int(self.quarter)],
'ОтчетГод': str(self.year),
'КодНО': company.rsbu_tax_office_code or '',
'НомКорр': '0',
'ПоМесту': PLACE_AT_LOCATION,
})
root.append(doc)
svnp = E('СвНП', {'ОКТМО': oktmo})
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)
ndfl = E('НДФЛ6.2')
doc.append(ndfl)
# Раздел 1: ОбязНА + СведСумНалУд (6 сроков).
obyaz = E('ОбязНА', {
'КБК': kbk,
'СумНалУд': self._int(self.withheld_total),
'СумНалВоз': self._int(self.returned_total),
})
etree.SubElement(obyaz, 'СведСумНалУд', {
'СумНал1Срок': self._int(self.term1),
'СумНал2Срок': self._int(self.term2),
'СумНал3Срок': self._int(self.term3),
'СумНал4Срок': self._int(self.term4),
'СумНал5Срок': self._int(self.term5),
'СумНал6Срок': self._int(self.term6),
})
ndfl.append(obyaz)
# Раздел 2: РасчСумНал по каждой ставке.
for line in self.line_ids:
etree.SubElement(ndfl, 'РасчСумНал', {
'Ставка': str(line.rate),
'КБК': line.kbk or kbk,
'КолФЛ': str(line.persons_count),
'КолКвал': '0',
'СумНачислНач': self._dec(line.accrued),
'СумНачислКвал': self._dec(0.0),
'СумВыч': self._dec(line.deduction),
'НалБаза': self._dec(line.base),
'СумНалИсч': self._int(line.computed_tax),
'СумНалИсчКвал': self._int(0.0),
'СумФикс': self._int(0.0),
'СумНалПриб': self._int(0.0),
'СумНалИнГос': self._int(0.0),
'СумНалУдерж': self._int(line.withheld_tax),
'СумНалУдерж1Мес': self._int(line.wh_m1_h1),
'СумНалУдерж23_1Мес': self._int(line.wh_m1_h2),
'СумНалУдерж2Мес': self._int(line.wh_m2_h1),
'СумНалУдерж23_2Мес': self._int(line.wh_m2_h2),
'СумНалУдерж3Мес': self._int(line.wh_m3_h1),
'СумНалУдерж23_3Мес': self._int(line.wh_m3_h2),
'СумНалНеУдерж': self._int(0.0),
'СумНалИзлУдерж': self._int(0.0),
'СумНалВозвр': self._int(0.0),
'СумНалВозвр1Мес': self._int(0.0),
'СумНалВозвр23_1Мес': self._int(0.0),
'СумНалВозвр2Мес': self._int(0.0),
'СумНалВозвр23_2Мес': self._int(0.0),
'СумНалВозвр3Мес': self._int(0.0),
'СумНалВозвр23_3Мес': self._int(0.0),
})
return etree.tostring(
root, xml_declaration=True, encoding='windows-1251',
pretty_print=True)
@api.model
def _ndfl_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_NDFL6.2_1_231_00_05_05_01.xsd')
def _ndfl_validate_xml(self, xml_bytes):
from lxml import etree
schema = etree.XMLSchema(etree.parse(self._ndfl_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):
self.ensure_one()
self.action_compute()
filename = self._ndfl_xml_filename()
xml_bytes = self._ndfl_build_xml(filename=filename)
ok, errors = self._ndfl_validate_xml(xml_bytes)
if not ok:
raise UserError(_(
'Сформированный 6-НДФЛ не прошёл проверку по схеме ФНС:\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',
}

View File

@@ -0,0 +1,145 @@
"""Декларация по НДС (КНД 1151001, УСН + спецставка НДS 5%/7%).
Комплект из трёх XML: корневая декларация (раздел «НДС» — СумУплНП/СумУпл164)
и разделы 8 (книга покупок) и 9 (книга продаж). Расчёт — из готовых
регистров НДС: книги (rsbu.vat.book.line) и итоги (rsbu.vat.summary).
Начисленный НДС раскладывается на реализацию (РеалТов5) и авансы
(ОплПредПост): авансовый НДС определяется по наличию в проводке-источнике
начисления строки счёта авансового НДС (76.АВ). Вычет — книга покупок
(вычет авансового НДС при отгрузке).
Постоянная модель (не транзиент): сохраняется, переоткрывается, хранит
рассчитанные показатели. XML — в rsbu_nds_xml. Свежесть: выгрузка XML
всегда выполняет пересчёт (см. rsbu_nds_xml.action_export_xml).
"""
from odoo import api, fields, models
# Расчётная доля НДС в авансе по ставочному режиму (для базы авансов).
_ADVANCE_VAT_FRACTION = {'vat_5': 5.0 / 105.0, 'vat_7': 7.0 / 107.0}
# Код счёта авансового НДС — признак авансового начисления в проводке.
VAT_ADVANCE_CODE = '76.АВ'
class RsbuNdsDeclaration(models.Model):
_name = 'rsbu.nds.declaration'
_description = 'РСБУ — Декларация НДС'
_order = 'date_to desc, id desc'
_check_company_auto = True
name = fields.Char(string='Наименование', compute='_compute_name',
store=True)
company_id = fields.Many2one(
'res.company', string='Компания', required=True, index=True,
default=lambda self: self.env.company)
date_from = fields.Date(string='Период с', required=True)
date_to = fields.Date(string='Период по', required=True)
currency_id = fields.Many2one(
'res.currency', related='company_id.currency_id')
state = fields.Selection(
selection=[('draft', 'Черновик'), ('computed', 'Рассчитана')],
default='draft', string='Состояние', copy=False)
# --- Начисление (раздел НДС / СумУпл164) ---
vat_base_realization = fields.Monetary(
string='База реализации (РеалТов5)', currency_field='currency_id')
vat_tax_realization = fields.Monetary(
string='НДС с реализации', currency_field='currency_id')
vat_base_advance = fields.Monetary(
string='База авансов (ОплПредПост)', currency_field='currency_id')
vat_tax_advance = fields.Monetary(
string='НДС с авансов', currency_field='currency_id')
vat_charged_total = fields.Monetary(
string='НДС начислен всего (НалВосстОбщ)',
currency_field='currency_id')
# --- Вычет и итог ---
vat_deduction = fields.Monetary(
string='Вычет (СумНалВыч)', currency_field='currency_id')
vat_payable = fields.Monetary(
string='НДС к уплате (СумПУ_173.1)', currency_field='currency_id')
oktmo = fields.Char(string='ОКТМО')
kbk = fields.Char(string='КБК')
@api.depends('date_from', 'date_to')
def _compute_name(self):
for rec in self:
rec.name = 'Декларация НДС %s — %s' % (
rec.date_from or '?', rec.date_to or '?')
# ------------------------------------------------------------------
def _vat_advance_account(self):
"""Счёт авансового НДС (76.АВ) — признак авансового начисления."""
self.ensure_one()
return self.env['rsbu.account'].sudo().search([
('code', '=', VAT_ADVANCE_CODE),
('company_id', '=', self.company_id.id)], limit=1)
def _split_sales_vat(self):
"""Разложить начисленный НДС книги продаж на реализацию и авансы.
Авансовое начисление — проводка-источник строки книги содержит
строку счёта 76.АВ (Дт). Возвращает (нДС_реализации, НДС_авансов).
"""
self.ensure_one()
BookLine = self.env['rsbu.vat.book.line'].sudo()
adv_acc = self._vat_advance_account()
sales = BookLine.search([
('company_id', '=', self.company_id.id), ('book', '=', 'sales'),
('date', '>=', self.date_from), ('date', '<=', self.date_to),
])
real_vat = 0.0
adv_vat = 0.0
for line in sales:
move = line.move_id
is_advance = adv_acc and any(
l.account_id == adv_acc and l.debit > 0
for l in move.line_ids)
if is_advance:
adv_vat += line.vat_amount
else:
real_vat += line.vat_amount
return round(real_vat, 2), round(adv_vat, 2)
def action_compute(self):
"""Пересобрать книги/итоги НДС и рассчитать показатели декларации.
Идемпотентно. Начислено — из книги продаж (разделено на реализацию
и авансы), вычет — из книги покупок. Реквизиты — из настроек.
"""
BookLine = self.env['rsbu.vat.book.line'].sudo()
Summary = self.env['rsbu.vat.summary'].sudo()
for rec in self:
company = rec.company_id
BookLine.recompute_period(rec.date_from, rec.date_to, company)
Summary.recompute_period(rec.date_from, rec.date_to, company)
real_vat, adv_vat = rec._split_sales_vat()
# База по расчётной ставке: реализация = НДС/долю − НДС... но для
# спецставки 5% база реализации = НДС × (1/доля − 1) = НДС × 20.
fraction = _ADVANCE_VAT_FRACTION.get(company.rsbu_vat_mode, 0.0)
inv = (1.0 / fraction - 1.0) if fraction else 0.0
real_base = round(real_vat * inv, 2)
# База авансов (ОплПредПост/НалБаза) — сумма аванса С НДС по
# расчётной ставке (в 1С: НалБаза = НДС / доля = НДС × 105/5),
# в отличие от реализации, где база — без НДС.
adv_base = round(adv_vat / fraction, 2) if fraction else 0.0
deduction = round(sum(BookLine.search([
('company_id', '=', company.id), ('book', '=', 'purchases'),
('date', '>=', rec.date_from), ('date', '<=', rec.date_to),
]).mapped('vat_amount')), 2)
charged = round(real_vat + adv_vat, 2)
rec.vat_base_realization = real_base
rec.vat_tax_realization = real_vat
rec.vat_base_advance = adv_base
rec.vat_tax_advance = adv_vat
rec.vat_charged_total = charged
rec.vat_deduction = deduction
rec.vat_payable = round(charged - deduction, 2)
if not rec.oktmo:
rec.oktmo = company.rsbu_oktmo or ''
if not rec.kbk:
rec.kbk = company.rsbu_kbk_nds or ''
rec.state = 'computed'
return True

View File

@@ -0,0 +1,357 @@
"""Генерация декларации по НДС в XML (КНД 1151001, формат 5.12).
Комплект из трёх файлов:
* корневая декларация (NO_NDS): реквизиты + раздел «НДС» (СумУплНП /
СумУпл164) + ссылки на книги (КнигаПокуп / КнигаПрод);
* раздел 9 (NO_NDS.9): книга продаж — строки КнПродСтр;
* раздел 8 (NO_NDS.8): книга покупок — строки КнПокСтр.
Суммы корневой декларации — в целых рублях; суммы книг (разделы 8/9) — с
копейками (как в выгрузке 1С). Данные — из rsbu.vat.book.line, реквизиты —
из настроек компании (rsbu_reporting_base). Валидация каждого файла по
своей XSD 5.12.
"""
import base64
import uuid
from datetime import date
from odoo import _, api, models
from odoo.exceptions import UserError
KND_NDS = '1151001'
NDS_FORMAT_VERSION = '5.12'
# Расчётная доля НДС в сумме по ставочному режиму (для восстановления базы).
_ADVANCE_VAT_FRACTION = {'vat_5': 5.0 / 105.0, 'vat_7': 7.0 / 107.0}
NDS_PERIOD_YEAR = '34' # налоговый период (год); квартал — 21..24
PLACE_AT_LOCATION = '214' # по месту нахождения российской организации
KBK_NDS_DEFAULT = '18210301000011000110'
# Индексы разделов в имени файла и в атрибуте Документ/Индекс.
SECTION_INDEX = {'purchases': '0000080', 'sales': '0000090'}
class RsbuNdsDeclaration(models.Model):
_inherit = 'rsbu.nds.declaration'
# ------------------------------------------------------------------
# Имена файлов обмена
# ------------------------------------------------------------------
def _nds_filename(self, prefix='NO_NDS'):
"""Имя файла обмена: <prefix>_ИФНС_ИФНС_ИНН+КПП_GGGGMMDD_GUID.
prefix: NO_NDS (корень) / NO_NDS.8 (покупки) / NO_NDS.9 (продажи).
"""
self.ensure_one()
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')
return '%s_%s_%s_%s%s_%s_%s' % (
prefix, code_no, code_no, inn, kpp, today, uuid.uuid4())
# ------------------------------------------------------------------
# Форматирование
# ------------------------------------------------------------------
@staticmethod
def _rub(value):
"""Целые рубли строкой (корневая декларация)."""
return str(int(round(value or 0.0)))
@staticmethod
def _kop(value):
"""Сумма с копейками строкой (книги — разделы 8/9)."""
return '%.2f' % (value or 0.0)
def _nds_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.rsbu_tax_office_code:
errors.append(_('код налогового органа'))
if not (self.oktmo or company.rsbu_oktmo):
errors.append(_('ОКТМО'))
if not (company.rsbu_signatory_id or getattr(company, 'chief_id',
False)):
errors.append(_('подписант'))
if errors:
raise UserError(_(
'Не заполнены реквизиты для декларации НДС: %s.')
% ', '.join(errors))
# ------------------------------------------------------------------
# Раздел 9 (книга продаж) и раздел 8 (книга покупок)
# ------------------------------------------------------------------
def _nds_build_book(self, book, filename):
"""Собрать XML раздела книги (8 покупки / 9 продажи). Возвращает bytes."""
self.ensure_one()
from lxml import etree
E = etree.Element
company = self.company_id
BookLine = self.env['rsbu.vat.book.line'].sudo()
lines = BookLine.search([
('company_id', '=', company.id), ('book', '=', book),
('date', '>=', self.date_from), ('date', '<=', self.date_to),
], order='date, id')
root = E('Файл', {
'ИдФайл': filename,
'ВерсПрог': 'RuOdoo',
'ВерсФорм': NDS_FORMAT_VERSION,
})
doc = E('Документ', {
'Индекс': SECTION_INDEX[book],
'НомКорр': '0',
})
root.append(doc)
if book == 'sales':
total_base = round(sum(lines.mapped('base_amount')), 2)
total_vat = round(sum(lines.mapped('vat_amount')), 2)
kniga = E('КнигаПрод', {
'СтПродБезНДС5': self._kop(total_base),
'СумНДСВсКПр5': self._kop(total_vat),
})
doc.append(kniga)
for idx, line in enumerate(lines, start=1):
self._nds_sales_row(etree, kniga, idx, line)
else: # purchases
total_vat = round(sum(lines.mapped('vat_amount')), 2)
kniga = E('КнигаПокуп', {'СумНДСВсКПк': self._kop(total_vat)})
doc.append(kniga)
for idx, line in enumerate(lines, start=1):
self._nds_purchase_row(etree, kniga, idx, line)
return etree.tostring(
root, xml_declaration=True, encoding='windows-1251',
pretty_print=True)
def _partner_node(self, etree, tag, partner):
"""СвПокуп/СвПрод: СведЮЛ (ИНН 10) или СведИП (ИНН 12)."""
node = etree.Element(tag)
inn = (partner.inn or '').strip()
if len(inn) == 12:
etree.SubElement(node, 'СведИП', {'ИННФЛ': inn})
elif inn:
attrs = {'ИННЮЛ': inn}
if partner.kpp:
attrs['КПП'] = partner.kpp
etree.SubElement(node, 'СведЮЛ', attrs)
return node
def _nds_sales_row(self, etree, kniga, idx, line):
"""Строка книги продаж КнПродСтр."""
# База 5% (СтоимПродСФ5): из строки, иначе восстановить из НДС.
fraction = _ADVANCE_VAT_FRACTION.get(self.company_id.rsbu_vat_mode, 0.0)
base5 = line.base_amount
if not base5 and fraction:
base5 = round(line.vat_amount * (1.0 / fraction - 1.0), 2)
gross = round(base5 + line.vat_amount, 2)
row = etree.SubElement(kniga, 'КнПродСтр', {
'НомерПор': str(idx),
'НомСчФПрод': (line.invoice_id.name or str(idx))
if line.invoice_id else str(idx),
'ДатаСчФПрод': line.date.strftime('%d.%m.%Y'),
'СтоимПродСФ': self._kop(gross),
'СтоимПродСФ5': self._kop(base5),
'СумНДССФ5': self._kop(line.vat_amount),
})
etree.SubElement(row, 'КодВидОпер').text = '01'
if line.partner_id:
row.append(self._partner_node(etree, 'СвПокуп', line.partner_id))
def _nds_purchase_row(self, etree, kniga, idx, line):
"""Строка книги покупок КнПокСтр.
Книга покупок УСН+НДС — вычет авансового НДС при отгрузке. В
rsbu.vat.book.line покупок заполнен только vat_amount; стоимость с
НДС восстанавливаем из НДС по расчётной ставке режима (для 5% —
НДС × 105/5).
"""
fraction = _ADVANCE_VAT_FRACTION.get(self.company_id.rsbu_vat_mode, 0.0)
gross = line.base_amount + line.vat_amount
if not line.base_amount and fraction:
gross = round(line.vat_amount / fraction, 2)
row = etree.SubElement(kniga, 'КнПокСтр', {
'НомерПор': str(idx),
'НомСчФПрод': (line.invoice_id.name or str(idx))
if line.invoice_id else str(idx),
'ДатаСчФПрод': line.date.strftime('%d.%m.%Y'),
'СтоимПокупВ': self._kop(gross),
'СумНДСВыч': self._kop(line.vat_amount),
})
etree.SubElement(row, 'КодВидОпер').text = '22'
# Продавец для книги покупок — сама организация (вычет ав. НДС).
seller = self.company_id.partner_id
row.append(self._partner_node(etree, 'СвПрод', seller))
# ------------------------------------------------------------------
# Корневая декларация
# ------------------------------------------------------------------
def _nds_build_root(self, filename, name_pokup, name_prod):
"""Собрать корневую декларацию НДС. Возвращает bytes."""
self.ensure_one()
from lxml import etree
E = etree.Element
company = self.company_id
partner = company.partner_id
oktmo = self.oktmo or company.rsbu_oktmo or ''
kbk = self.kbk or company.rsbu_kbk_nds or KBK_NDS_DEFAULT
root = E('Файл', {
'ИдФайл': filename,
'ВерсПрог': 'RuOdoo',
'ВерсФорм': NDS_FORMAT_VERSION,
'ПризнНал8-12': '1',
'ПризнНал8': '1', 'ПризнНал81': '0',
'ПризнНал9': '1', 'ПризнНал91': '0',
'ПризнНал10': '0', 'ПризнНал11': '0', 'ПризнНал12': '0',
})
quarter = (self.date_to.month - 1) // 3 + 1
doc = E('Документ', {
'КНД': KND_NDS,
'ДатаДок': date.today().strftime('%d.%m.%Y'),
'Период': '2%s' % quarter, # 21/22/23/24 — кварталы
'ОтчетГод': str(self.date_to.year),
'КодНО': company.rsbu_tax_office_code or '',
'НомКорр': '0',
'ПоМесту': PLACE_AT_LOCATION,
})
root.append(doc)
svnp = E('СвНП')
etree.SubElement(svnp, 'НПЮЛ', {
'НаимОрг': company.name or '',
'ИННЮЛ': (partner.inn or '').strip(),
'КПП': (partner.kpp or '').strip(),
})
doc.append(svnp)
signer = company.rsbu_signatory_id or getattr(company, 'chief_id',
False)
pr_podp = '2' if company.rsbu_signatory_is_representative else '1'
podp = E('Подписант', {'ПрПодп': pr_podp})
if signer:
names = (signer.name or '').split()
etree.SubElement(podp, 'ФИО', {
'Фамилия': names[0] if names else (signer.name or '-'),
'Имя': names[1] if len(names) > 1 else '-',
**({'Отчество': names[2]} if len(names) > 2 else {}),
})
doc.append(podp)
nds = E('НДС')
doc.append(nds)
# СумУплНП — к уплате по ОКТМО/КБК.
etree.SubElement(nds, 'СумУплНП', {
'ОКТМО': oktmo, 'КБК': kbk,
'СумПУ_173.1': self._rub(self.vat_payable),
'ПризнСЗПК': '2',
})
# СумУпл164 — расчёт: начислено (реализация 5% + авансы) − вычет.
sumupl = E('СумУпл164', {'НалПУ164': self._rub(self.vat_payable)})
nds.append(sumupl)
nalob = E('СумНалОб', {
'НалВосстОбщ': self._rub(self.vat_charged_total)})
sumupl.append(nalob)
if self.vat_tax_realization:
etree.SubElement(nalob, 'РеалТов5', {
'НалБаза': self._rub(self.vat_base_realization),
'СумНал': self._rub(self.vat_tax_realization),
})
if self.vat_tax_advance:
etree.SubElement(nalob, 'ОплПредПост', {
'НалБаза': self._rub(self.vat_base_advance),
'СумНал': self._rub(self.vat_tax_advance),
})
etree.SubElement(sumupl, 'СумНалВыч', {
'НалИсчПрод': self._rub(self.vat_deduction),
'НалВычОбщ': self._rub(self.vat_deduction),
})
# Ссылки на файлы разделов (книги).
etree.SubElement(nds, 'КнигаПокуп', {'НаимКнПок': name_pokup})
etree.SubElement(nds, 'КнигаПрод', {'НаимКнПрод': name_prod})
return etree.tostring(
root, xml_declaration=True, encoding='windows-1251',
pretty_print=True)
def _nds_build_all(self):
"""Собрать все три файла. Возвращает список (filename, xml_bytes)."""
self.ensure_one()
self._nds_validate_requisites()
name_pokup = self._nds_filename('NO_NDS.8')
name_prod = self._nds_filename('NO_NDS.9')
name_root = self._nds_filename('NO_NDS')
xml_pokup = self._nds_build_book('purchases', name_pokup)
xml_prod = self._nds_build_book('sales', name_prod)
xml_root = self._nds_build_root(name_root, name_pokup, name_prod)
return [
(name_root, xml_root),
(name_pokup, xml_pokup),
(name_prod, xml_prod),
]
# ------------------------------------------------------------------
# Валидация против XSD
# ------------------------------------------------------------------
@api.model
def _nds_xsd_path(self, kind):
import os
from odoo.modules import get_module_path
fname = {
'root': 'NO_NDS_1_003_00_05_12_01.xsd',
'purchases': 'NO_NDS.8_1_003_01_05_12_01.xsd',
'sales': 'NO_NDS.9_1_003_03_05_12_01.xsd',
}[kind]
return os.path.join(
get_module_path('rsbu_fns_reporting'), 'data', 'schemas', fname)
def _nds_validate_xml(self, xml_bytes, kind):
from lxml import etree
schema = etree.XMLSchema(etree.parse(self._nds_xsd_path(kind)))
ok = schema.validate(etree.fromstring(xml_bytes))
errors = [e.message for e in schema.error_log] if not ok else []
return ok, errors
def action_export_xml(self):
"""Пересчитать, сформировать 3 XML, провалидировать, вернуть архивом.
Свежесть: перед выгрузкой всегда выполняется action_compute.
"""
self.ensure_one()
self.action_compute()
files = self._nds_build_all()
# Валидация каждого файла по своей XSD.
kinds = ['root', 'purchases', 'sales']
for (fname, xml), kind in zip(files, kinds):
ok, errors = self._nds_validate_xml(xml, kind)
if not ok:
raise UserError(_(
'Файл %s не прошёл проверку по схеме ФНС:\n%s')
% (fname, '\n'.join(errors[:8])))
# Упаковать три файла в zip-вложение.
import io
import zipfile
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
for fname, xml in files:
zf.writestr(fname + '.xml', xml)
attachment = self.env['ir.attachment'].create({
'name': 'Декларация_НДС_%s.zip' % (self.date_to or ''),
'res_model': self._name,
'res_id': self.id,
'type': 'binary',
'datas': base64.b64encode(buf.getvalue()),
'mimetype': 'application/zip',
})
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%s?download=true' % attachment.id,
'target': 'self',
}

View File

@@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
"""Персонифицированные сведения о физических лицах (ПСВ, КНД 1151162).
Форма ФНС NO_PERSSVFL версии 5.01. Подаётся ЕЖЕМЕСЯЧНО в налоговый орган:
по каждому застрахованному лицу — ФИО, СНИЛС, ИНН и сумма выплат за месяц.
Автозаполнение строк из помесячных итогов выплат rsbu.rsv.summary
(поле payments) за отчётный месяц.
"""
from odoo import api, fields, models
class RsbuPersInfo(models.Model):
_name = 'rsbu.pers.info'
_description = 'РСБУ — Персонифицированные сведения (ПСВ)'
_order = 'year desc, month desc, id desc'
_check_company_auto = True
name = fields.Char(string='Наименование', compute='_compute_name',
store=True)
company_id = fields.Many2one(
'res.company', string='Компания', required=True, index=True,
default=lambda self: self.env.company)
year = fields.Integer(string='Год', required=True,
default=lambda self: fields.Date.today().year)
month = fields.Integer(
string='Отчётный месяц', required=True,
default=lambda self: fields.Date.today().month,
help='Календарный месяц, за который подаются сведения.')
date_doc = fields.Date(
string='Дата документа', default=fields.Date.context_today)
currency_id = fields.Many2one(
'res.currency', related='company_id.currency_id')
state = fields.Selection(
selection=[('draft', 'Черновик'), ('computed', 'Рассчитано')],
default='draft', string='Состояние', copy=False)
line_ids = fields.One2many(
'rsbu.pers.info.line', 'report_id', string='Сведения по физлицам')
@api.depends('year', 'month')
def _compute_name(self):
names = ['', 'январь', 'февраль', 'март', 'апрель', 'май', 'июнь',
'июль', 'август', 'сентябрь', 'октябрь', 'ноябрь', 'декабрь']
for rec in self:
m = names[rec.month] if rec.month and 1 <= rec.month <= 12 else '?'
rec.name = 'Перс. сведения: %s %s' % (m, rec.year or '?')
def _month_bounds(self):
import calendar
self.ensure_one()
last = calendar.monthrange(self.year, self.month)[1]
return (fields.Date.to_date('%04d-%02d-01' % (self.year, self.month)),
fields.Date.to_date('%04d-%02d-%02d' % (
self.year, self.month, last)))
# ------------------------------------------------------------------
def action_compute(self):
"""Пересобрать итоги выплат за месяц и автозаполнить строки."""
Summary = self.env['rsbu.rsv.summary'].sudo()
for rec in self:
df, dt = rec._month_bounds()
Summary.recompute_period(df, dt, rec.company_id)
return self.fill_lines()
def fill_lines(self):
"""Автозаполнить строки по сотрудникам из итогов выплат за месяц.
Отделено от action_compute, чтобы заполнять по существующим данным
без принудительного пересбора (тесты, повторный расчёт).
"""
Summary = self.env['rsbu.rsv.summary'].sudo()
Line = self.env['rsbu.pers.info.line']
for rec in self:
rec.line_ids.unlink()
recs = Summary.search([
('company_id', '=', rec.company_id.id),
('year', '=', rec.year),
('month', '=', rec.month),
])
# Суммируем выплаты по сотруднику (на случай нескольких записей).
by_emp = {}
for r in recs:
by_emp.setdefault(r.employee_id, 0.0)
by_emp[r.employee_id] += r.payments
vals = []
for emp, pay in by_emp.items():
vals.append({
'report_id': rec.id,
'employee_id': emp.id,
'amount': round(pay, 2),
})
if vals:
Line.create(vals)
rec.state = 'computed'
return True
class RsbuPersInfoLine(models.Model):
_name = 'rsbu.pers.info.line'
_description = 'РСБУ — Строка ПСВ (физлицо)'
_order = 'report_id, employee_id'
report_id = fields.Many2one(
'rsbu.pers.info', string='Сведения', required=True,
ondelete='cascade', index=True)
currency_id = fields.Many2one(
'res.currency', related='report_id.currency_id')
employee_id = fields.Many2one('hr.employee', string='Сотрудник',
required=True)
snils = fields.Char(string='СНИЛС', related='employee_id.rsbu_snils')
inn = fields.Char(string='ИНН', related='employee_id.rsbu_inn')
amount = fields.Monetary(string='Сумма выплат',
currency_field='currency_id')
cancelled = fields.Boolean(
string='Аннулирующие сведения',
help='Признак аннулирования ранее поданных сведений (ПрАннулир).')

View File

@@ -0,0 +1,194 @@
# -*- coding: utf-8 -*-
"""Генерация персонифицированных сведений (ПСВ) в XML.
Формат ФНС NO_PERSSVFL версии 5.01 (КНД 1151162). Кодировка windows-1251,
кириллические имена элементов. Структура:
Файл(ИдФайл, ВерсПрог, ВерсФорм)
└ Документ(КНД, ДатаДок, НомКорр, Период, ОтчетГод, КодНО, ПоМесту)
├ СвНП(Тлф) → НПЮЛ(НаимОрг, ИННЮЛ, КПП)
├ Подписант(ПрПодп) [→ ФИО]
└ ПерсСвФЛ[1..*](ПрАннулир?, ИННФЛ?, СНИЛС, СумВыпл) → ФИО
Период — код календарного месяца (01..12). Сумма выплат — decimal с 2
знаками. СНИЛС нормализуется к формату NNN-NNN-NNN-NN.
"""
import base64
from datetime import date
from odoo import _, api, models
from odoo.exceptions import UserError
KND_PSV = '1151162'
FORMAT_VERSION = '5.01'
PLACE_AT_LOCATION = '214'
class RsbuPersInfo(models.Model):
_inherit = 'rsbu.pers.info'
@staticmethod
def _sum(value):
return '%.2f' % (value or 0.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 _psv_xml_filename(self):
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_PERSSVFL_%s_%s_%s%s_%s_%s' % (
code_no, code_no, inn, kpp, today, guid)
def _psv_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.rsbu_tax_office_code:
errors.append(_('код налогового органа'))
if not self.line_ids:
errors.append(_('ни одного физлица'))
for line in self.line_ids:
if not line.employee_id.rsbu_snils:
errors.append(_('СНИЛС сотрудника «%s»')
% line.employee_id.name)
if errors:
raise UserError(_(
'Не заполнены обязательные реквизиты ПСВ: %s.')
% ', '.join(errors[:10]))
def _psv_build_xml(self, filename=None):
self.ensure_one()
try:
from lxml import etree
except ImportError:
raise UserError(_('Для выгрузки XML требуется библиотека lxml.')) from None
self._psv_validate_requisites()
if filename is None:
filename = self._psv_xml_filename()
company = self.company_id
partner = company.partner_id
E = etree.Element
root = E('Файл', {
'ИдФайл': filename, 'ВерсПрог': 'RuOdoo',
'ВерсФорм': FORMAT_VERSION,
})
doc = E('Документ', {
'КНД': KND_PSV,
'ДатаДок': (self.date_doc or date.today()).strftime('%d.%m.%Y'),
'НомКорр': '0',
'Период': '%02d' % self.month,
'ОтчетГод': str(self.year),
'КодНО': company.rsbu_tax_office_code or '',
'ПоМесту': PLACE_AT_LOCATION,
})
root.append(doc)
svnp = E('СвНП')
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)
# Сведения по каждому физлицу.
for line in self.line_ids:
emp = line.employee_id
attrs = {
'СНИЛС': self._snils(emp.rsbu_snils),
'СумВыпл': self._sum(line.amount),
}
if emp.rsbu_inn:
attrs['ИННФЛ'] = emp.rsbu_inn.strip()
if line.cancelled:
attrs['ПрАннулир'] = '1'
pers = etree.SubElement(doc, 'ПерсСвФЛ', attrs)
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(pers, 'ФИО', fio)
return etree.tostring(
root, xml_declaration=True, encoding='windows-1251',
pretty_print=True)
@api.model
def _psv_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_PERSSVFL_1_297_00_05_01_02.xsd')
def _psv_validate_xml(self, xml_bytes):
from lxml import etree
schema = etree.XMLSchema(etree.parse(self._psv_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):
self.ensure_one()
self.action_compute()
filename = self._psv_xml_filename()
xml_bytes = self._psv_build_xml(filename=filename)
ok, errors = self._psv_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',
}

View File

@@ -0,0 +1,245 @@
# -*- coding: utf-8 -*-
"""Расчёт по страховым взносам (РСВ, КНД 1151111, формат NO_RASCHSV 5.08).
Строится поквартально нарастающим итогом с начала года из помесячных
итогов взносов (rsbu.rsv.summary). Профиль: УСН + субъект МСП, единый
пониженный тариф (п.2.5 ст.427 НК).
Разделы (первый релиз):
* Раздел 1 (ОбязПлатСВ) — сводные суммы взносов к уплате по единому
тарифу за расчётный период и помесячно (3 месяца квартала);
* подраздел 1 (РасчСВ_ОПСОМС) — расчёт по единому тарифу: число
застрахованных, база (в пределах/сверх предельной базы ОПС),
исчисленные взносы (нарастающим итогом + 3 месяца);
* Приложение 3.1 (ПравТариф13.2.427) — расчёт соответствия условиям
применения единого пониженного тарифа МСП (доходы — реквизиты);
* Раздел 3 (ПерсСвСтрахЛиц) — персонифицированные сведения по каждому
сотруднику (выплаты/база/взносы по месяцам квартала).
Расчёт и хранение — здесь; XML-выгрузка (5.08, windows-1251) — в
rsbu_rsv_xml. Реквизиты — из настроек компании (rsbu_reporting_base,
rsbu_payroll, l10n_ru).
"""
from odoo import api, fields, models
# Код отчётного периода РСВ по номеру квартала (нарастающим итогом).
RSV_PERIOD_CODE = {1: '21', 2: '31', 3: '33', 4: '34'}
class RsbuRsvDeclaration(models.Model):
_name = 'rsbu.rsv.declaration'
_description = 'РСБУ — Расчёт по страховым взносам (РСВ)'
_order = 'year desc, quarter desc, id desc'
_check_company_auto = True
name = fields.Char(string='Наименование', compute='_compute_name',
store=True)
company_id = fields.Many2one(
'res.company', string='Компания', required=True, index=True,
default=lambda self: self.env.company)
year = fields.Integer(string='Отчётный год', required=True,
default=lambda self: fields.Date.today().year)
quarter = fields.Selection(
selection=[('1', 'I квартал'), ('2', 'Полугодие'),
('3', '9 месяцев'), ('4', 'Год')],
string='Отчётный период', required=True, default='1',
help='Расчётный (отчётный) период нарастающим итогом.')
currency_id = fields.Many2one(
'res.currency', related='company_id.currency_id')
state = fields.Selection(
selection=[('draft', 'Черновик'), ('computed', 'Рассчитан')],
default='draft', string='Состояние', copy=False)
oktmo = fields.Char(string='ОКТМО')
avg_headcount = fields.Integer(
string='Среднесписочная численность',
help='Среднесписочная численность работников (атрибут СрЧисл).')
# --- Раздел 1 / подраздел 1: расчёт по единому тарифу (нараст. итогом) ---
insured_count = fields.Integer(string='Кол-во застрахованных лиц')
charged_count = fields.Integer(
string='Кол-во лиц с начисленными взносами')
payments_total = fields.Monetary(string='Выплаты (всего)',
currency_field='currency_id')
non_taxable_total = fields.Monetary(string='Необлагаемые выплаты',
currency_field='currency_id')
base_total = fields.Monetary(string='База для исчисления',
currency_field='currency_id')
base_within_total = fields.Monetary(
string='База не превышающая предел (ОПС)',
currency_field='currency_id')
base_over_total = fields.Monetary(
string='База сверх предела (ОПС)', currency_field='currency_id')
charged_total = fields.Monetary(string='Исчислено взносов (всего)',
currency_field='currency_id')
charged_within_total = fields.Monetary(
string='Исчислено с базы в пределах', currency_field='currency_id')
charged_over_total = fields.Monetary(
string='Исчислено с базы сверх', currency_field='currency_id')
oms_total = fields.Monetary(string='Взносы ОМС',
currency_field='currency_id')
vnim_total = fields.Monetary(string='Взносы ВНиМ',
currency_field='currency_id')
# Помесячная разбивка последних 3 месяцев квартала (к уплате, единый тариф).
contrib_month1 = fields.Monetary(string='Взносы 1-й мес.',
currency_field='currency_id')
contrib_month2 = fields.Monetary(string='Взносы 2-й мес.',
currency_field='currency_id')
contrib_month3 = fields.Monetary(string='Взносы 3-й мес.',
currency_field='currency_id')
contrib_period = fields.Monetary(string='Взносы за период (единый тариф)',
currency_field='currency_id')
# --- Приложение 3.1 (МСП, п.13.2 ст.427): доходы для проверки условий ---
msp_income_total = fields.Monetary(
string='Доходы всего (ст.26.2)', currency_field='currency_id',
help='Доходы для проверки соответствия условиям МСП (заполняются '
'по данным УСН/бухучёта). Приложение 3.1 РСВ.')
msp_income_main = fields.Monetary(
string='Доходы по основному виду деятельности',
currency_field='currency_id')
pers_line_ids = fields.One2many(
'rsbu.rsv.pers.line', 'declaration_id',
string='Персонифицированные сведения (раздел 3)')
@api.depends('year', 'quarter')
def _compute_name(self):
labels = dict(self._fields['quarter'].selection)
for rec in self:
rec.name = 'РСВ за %s, %s' % (
labels.get(rec.quarter, ''), rec.year or '?')
def _period_bounds(self):
"""Границы периода: (year_start, quarter_last_day, q1..q_last)."""
self.ensure_one()
q = int(self.quarter)
last_month = q * 3
import calendar
last_day = calendar.monthrange(self.year, last_month)[1]
return (
fields.Date.to_date('%04d-01-01' % self.year),
fields.Date.to_date('%04d-%02d-%02d' % (
self.year, last_month, last_day)),
q,
)
# ------------------------------------------------------------------
def action_compute(self):
"""Пересобрать итоги взносов за год и рассчитать РСВ."""
Summary = self.env['rsbu.rsv.summary'].sudo()
for rec in self:
date_from, date_to, _q = rec._period_bounds()
Summary.recompute_period(date_from, date_to, rec.company_id)
return self.compute_from_summary()
def compute_from_summary(self):
"""Рассчитать показатели РСВ из готовых помесячных итогов."""
Summary = self.env['rsbu.rsv.summary'].sudo()
for rec in self:
company = rec.company_id
q = int(rec.quarter)
m1, m2, m3 = Summary._quarter_months(q)
# Нарастающим итогом с начала года по конец квартала.
ytd = Summary.ytd_records(company, rec.year, q)
# Последние 3 месяца квартала (для помесячной разбивки).
last3 = Summary.records_for(company, rec.year, m1, m3)
rec.payments_total = round(sum(ytd.mapped('payments')), 2)
rec.non_taxable_total = round(sum(ytd.mapped('non_taxable')), 2)
rec.base_total = round(sum(ytd.mapped('base')), 2)
rec.base_within_total = round(
sum(ytd.mapped('base_within_limit')), 2)
rec.base_over_total = round(
sum(ytd.mapped('base_over_limit')), 2)
rec.charged_total = round(sum(ytd.mapped('ops')), 2)
rec.charged_within_total = round(sum(ytd.mapped('ops_within')), 2)
rec.charged_over_total = round(sum(ytd.mapped('ops_over')), 2)
rec.oms_total = round(sum(ytd.mapped('oms')), 2)
rec.vnim_total = round(sum(ytd.mapped('vnim')), 2)
# Число застрахованных / с начислениями (нарастающим итогом).
rec.insured_count = len(ytd.mapped('employee_id'))
rec.charged_count = len(ytd.filtered(
lambda r: r.base > 0).mapped('employee_id'))
# Помесячно за последние 3 месяца — взносы единого тарифа
# (ОПС+ОМС+ВНиМ) к уплате.
def month_contrib(month):
recs = last3.filtered(lambda r: r.month == month)
return round(sum(recs.mapped('ops'))
+ sum(recs.mapped('oms'))
+ sum(recs.mapped('vnim')), 2)
rec.contrib_month1 = month_contrib(m1)
rec.contrib_month2 = month_contrib(m2)
rec.contrib_month3 = month_contrib(m3)
rec.contrib_period = round(
rec.charged_total + rec.oms_total + rec.vnim_total, 2)
if not rec.oktmo:
rec.oktmo = company.rsbu_oktmo or ''
# --- Раздел 3: строки по сотрудникам за 3 месяца квартала ---
rec.pers_line_ids.unlink()
PersLine = self.env['rsbu.rsv.pers.line']
by_emp = {}
for r in last3:
by_emp.setdefault(r.employee_id, []).append(r)
pers_vals = []
for emp, recs in by_emp.items():
def m_sum(month, field):
mr = [x for x in recs if x.month == month]
return round(sum(x[field] for x in mr), 2)
pers_vals.append({
'declaration_id': rec.id,
'employee_id': emp.id,
'category': recs[0].insured_category or 'НР',
'pay_m1': m_sum(m1, 'payments'),
'pay_m2': m_sum(m2, 'payments'),
'pay_m3': m_sum(m3, 'payments'),
'base_m1': m_sum(m1, 'base'),
'base_m2': m_sum(m2, 'base'),
'base_m3': m_sum(m3, 'base'),
'ops_m1': m_sum(m1, 'ops'),
'ops_m2': m_sum(m2, 'ops'),
'ops_m3': m_sum(m3, 'ops'),
})
if pers_vals:
PersLine.create(pers_vals)
rec.state = 'computed'
return True
class RsbuRsvPersLine(models.Model):
_name = 'rsbu.rsv.pers.line'
_description = 'РСБУ — Перс. сведения РСВ (раздел 3)'
_order = 'declaration_id, employee_id'
declaration_id = fields.Many2one(
'rsbu.rsv.declaration', string='Расчёт', required=True,
ondelete='cascade', index=True)
employee_id = fields.Many2one('hr.employee', string='Сотрудник',
required=True)
currency_id = fields.Many2one(
'res.currency', related='declaration_id.currency_id')
category = fields.Char(string='Код категории')
pay_m1 = fields.Monetary(string='Выплаты 1-й мес.',
currency_field='currency_id')
pay_m2 = fields.Monetary(string='Выплаты 2-й мес.',
currency_field='currency_id')
pay_m3 = fields.Monetary(string='Выплаты 3-й мес.',
currency_field='currency_id')
base_m1 = fields.Monetary(string='База 1-й мес.',
currency_field='currency_id')
base_m2 = fields.Monetary(string='База 2-й мес.',
currency_field='currency_id')
base_m3 = fields.Monetary(string='База 3-й мес.',
currency_field='currency_id')
ops_m1 = fields.Monetary(string='ОПС 1-й мес.',
currency_field='currency_id')
ops_m2 = fields.Monetary(string='ОПС 2-й мес.',
currency_field='currency_id')
ops_m3 = fields.Monetary(string='ОПС 3-й мес.',
currency_field='currency_id')

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

View File

@@ -0,0 +1,294 @@
# -*- coding: utf-8 -*-
"""Данные для печатной формы УПД (DOCX) из документа РСБУ-слоя.
Печатный УПД (docx_report) строится из документа «Счёт-фактура» РСБУ-слоя
(rsbu.invoice, отгрузочный), как и XML-выгрузка (Вариант B):
* счёт-фактурная часть (номер/дата, ставка, суммы/итоги) — из rsbu.invoice;
* товарные позиции — из связанного инвойса-источника (_source_invoice);
* стороны и подписант — из res.company / res.partner.
Логика пересчёта НДС по строке совпадает с rsbu_upd_xml (НДС = база ×
ставка РСБУ). Методы публичные, т.к. вызываются из docxtpl-шаблона как
``docs.upd_docx_*()`` (Jinja не обращается к «приватным» _методам).
"""
from odoo import models
# Переиспользуем справочники ставок из XML-генератора.
from .rsbu_upd_xml import VAT_RATE_LABEL, VAT_RATE_PCT
class RsbuRealizationUpdDocx(models.Model):
_inherit = 'rsbu.realization'
# ------------------------------------------------------------------
@staticmethod
def _upd_fio(user_or_partner):
"""ФИО и должность подписанта/ответственного лица."""
partner = getattr(user_or_partner, 'partner_id', user_or_partner)
name = (getattr(user_or_partner, 'name', '') or
getattr(partner, 'name', '') or '')
function = getattr(partner, 'function', '') or ''
return {'name': name, 'function': function}
def upd_vat_label(self):
"""Текстовая метка ставки НДС (напр. «5%», «без НДС»).
Ставка берётся из подчинённого СФ при наличии, иначе «без НДС».
"""
self.ensure_one()
rate = self.invoice_id.vat_rate if self.invoice_id else 'vat_exempt'
return VAT_RATE_LABEL.get(rate, 'без НДС')
def upd_docx_status(self):
"""Статус УПД: «1» — счёт-фактура + передаточный документ;
«2» — только передаточный документ (без НДС).
Структурный драйвер — наличие подчинённого отгрузочного СФ
(self.invoice_id), тот же, что в XML (Функция)."""
self.ensure_one()
return '1' if self.invoice_id else '2'
@staticmethod
def _bank_requisites(partner):
"""Банковские реквизиты стороны (Р/с, Банк, БИК, К/с) одной строкой."""
bank = False
if partner.bank_ids:
bank = partner.bank_ids[0]
elif partner.parent_id and partner.parent_id.bank_ids:
bank = partner.parent_id.bank_ids[0]
if not bank:
return ''
parts = []
if bank.acc_number:
parts.append('Р/с %s' % bank.acc_number)
name = getattr(bank, 'bank_name', '') or (
bank.bank_id.name if bank.bank_id else '')
if name:
parts.append(name)
bic = getattr(bank, 'bank_bic', '') or (
bank.bank_id.bic if bank.bank_id else '')
if bic:
parts.append('БИК %s' % bic)
corr = getattr(bank, 'bank_corr_acc', '') or ''
if corr:
parts.append('к/с %s' % corr)
return ', '.join(parts)
def upd_docx_lines(self):
"""Товарные позиции УПД (16 колонок счёта-фактуры, Пост. № 1137).
А(код), 1(№), 1а(наимен), 1б(код вида), 2(ОКЕИ), 2а(ед.изм.),
3(кол-во), 4(цена), 5(без НДС), 6(акциз), 7(ставка), 8(НДС),
9(с НДС), 10/10а(страна), 11(ДТ).
Суммы берутся ФАКТИЧЕСКИЕ из строки инвойса-источника: база =
price_subtotal, НДС = price_total − price_subtotal (корректно и для
ставки «в том числе», где налог выделяется из цены, а не начисляется
сверху), стоимость с НДС = price_total. Если у строки нет налогов,
но в rsbu.invoice задана ставка — НДС пересчитывается по ставке
(fallback для источников без налогов Odoo).
"""
self.ensure_one()
source = self._source_invoice()
sf_vat_rate = self.invoice_id.vat_rate if self.invoice_id \
else 'vat_exempt'
vat_pct = VAT_RATE_PCT.get(sf_vat_rate, 0.0)
rate_label = self.upd_vat_label()
lines = source.invoice_line_ids.filtered(
lambda l: l.display_type in (False, 'product')) if source \
else self.env['account.move.line']
rows = []
for idx, line in enumerate(lines, start=1):
subtotal = line.price_subtotal or 0.0
if line.tax_ids:
# Фактический НДС строки (учитывает «в т.ч.»/«сверху»).
line_vat = round((line.price_total or 0.0) - subtotal, 2)
total = round(line.price_total or 0.0, 2)
else:
# Нет налогов в источнике — пересчёт по ставке РСБУ.
line_vat = round(subtotal * vat_pct, 2)
total = round(subtotal + line_vat, 2)
qty = line.quantity or 0.0
uom = line.product_uom_id
product = line.product_id
rows.append({
'num': idx,
'code': product.default_code or '', # А
'name': line.name or (product.name or 'Товар'), # 1а
'type_code': '', # 1б (прослеж.)
'okei': getattr(uom, 'okei', '') or '', # 2
'uom': uom.name or '', # 2а
'qty': qty, # 3
'price': round(subtotal / qty, 2) if qty else 0.0, # 4
'subtotal': subtotal, # 5
'excise': 'Без акциза', # 6
'vat_label': rate_label if line_vat else 'без НДС', # 7
'vat': line_vat, # 8
'vat_cell': ('%.2f' % line_vat) if line_vat else 'без НДС',
'total': total, # 9
'country_code': '', # 10
'country': '', # 10а
'customs_decl': '', # 11
})
return rows
def upd_docx_totals(self):
"""Итоги УПД: без НДС, НДС, с НДС (по фактическим суммам строк)."""
self.ensure_one()
rows = self.upd_docx_lines()
wo_vat = round(sum(r['subtotal'] for r in rows), 2)
vat = round(sum(r['vat'] for r in rows), 2)
with_vat = round(sum(r['total'] for r in rows), 2)
return {'wo_vat': wo_vat, 'vat': vat, 'with_vat': with_vat}
@staticmethod
def _clean_address(partner):
"""Почтовый адрес без наименования организации (для строк 2а/6а).
contact_address начинается с названия партнёра — убираем первую
строку с именем и собираем адрес из полей.
"""
parts = []
if partner.zip:
parts.append(partner.zip)
if getattr(partner, 'state_id', False) and partner.state_id:
parts.append(partner.state_id.name)
if partner.city:
parts.append(partner.city)
if partner.street:
parts.append(partner.street)
if partner.street2:
parts.append(partner.street2)
if parts:
return ', '.join(parts)
# Фолбэк: contact_address без первой строки (имени).
addr = (partner.contact_address or '').split('\n')
tail = [a.strip() for a in addr[1:] if a.strip()]
return ', '.join(tail)
def _party_dict(self, partner, name=None):
return {
'name': name or partner.name or '',
'inn': (partner.inn or '').strip(),
'kpp': (getattr(partner, 'kpp', '') or '').strip(),
'okpo': getattr(partner, 'okpo', '') or '',
'address': self._clean_address(partner),
'bank': self._bank_requisites(partner),
}
def upd_docx_seller(self):
"""Реквизиты продавца (из организации) с банковскими реквизитами."""
self.ensure_one()
return self._party_dict(self.company_id.partner_id,
self.company_id.name)
def upd_docx_buyer(self):
"""Реквизиты покупателя (из контрагента) с банковскими реквизитами."""
self.ensure_one()
return self._party_dict(self.partner_id)
def upd_docx_shipper(self):
"""Грузоотправитель (3): задан явно или «он же» (= продавец)."""
self.ensure_one()
if self.upd_shipper_id and self.upd_shipper_id != \
self.company_id.partner_id:
p = self.upd_shipper_id
return '%s, %s' % (p.name or '', self._clean_address(p))
return 'он же'
def upd_docx_consignee(self):
"""Грузополучатель (4): задан явно или покупатель."""
self.ensure_one()
p = self.upd_consignee_id or self.partner_id
return '%s, %s' % (p.name or '', self._clean_address(p))
def upd_docx_basis(self):
"""Основание передачи (10): договор/документ-основание."""
self.ensure_one()
return self.upd_basis or (self.name or '')
def upd_docx_shipment_doc(self):
"""Документ об отгрузке (5а)."""
self.ensure_one()
return 'Универсальный передаточный документ № %s от %s' % (
self.name or '', self.date.strftime('%d.%m.%Y') if self.date
else '')
def upd_docx_receiver(self):
"""Лицо со стороны покупателя, принявшее груз (17/20)."""
self.ensure_one()
if not self.upd_received_by_id:
return {'name': '', 'function': ''}
data = self._upd_fio(self.upd_received_by_id)
if not data['function']:
data['function'] = 'Директор'
return data
def upd_docx_seller_subject(self):
"""Наименование экономического субъекта — продавца (16)."""
self.ensure_one()
p = self.company_id.partner_id
return '%s, ИНН/КПП %s/%s' % (
self.company_id.name or '', (p.inn or '').strip(),
(getattr(p, 'kpp', '') or '').strip())
def upd_docx_buyer_subject(self):
"""Наименование экономического субъекта — покупателя (21)."""
self.ensure_one()
p = self.partner_id
kpp = (getattr(p, 'kpp', '') or '').strip()
return '%s%s' % (p.name or '',
', ИНН/КПП %s/%s' % ((p.inn or '').strip(), kpp)
if p.inn else '')
def upd_docx_signer(self):
"""Подписант (руководитель организации-продавца)."""
self.ensure_one()
signer = self.company_id.rsbu_signatory_id or getattr(
self.company_id, 'chief_id', False)
if not signer:
return {'name': '', 'function': 'Руководитель'}
data = self._upd_fio(signer)
if not data['function']:
data['function'] = (
self.company_id.rsbu_signatory_title
if hasattr(self.company_id, 'rsbu_signatory_title')
else 'Руководитель') or 'Руководитель'
return data
def upd_docx_transferer(self):
"""Лицо, передавшее товар (кладовщик) — для «Товар передал»."""
self.ensure_one()
if not self.upd_transferred_by_id:
return {'name': '', 'function': ''}
data = self._upd_fio(self.upd_transferred_by_id)
if not data['function']:
data['function'] = 'Кладовщик'
return data
# ------------------------------------------------------------------
def _upd_docx_report(self, with_stamp=True):
xmlid = ('rsbu_fns_reporting.docx_template_rsbu_upd' if with_stamp
else 'rsbu_fns_reporting.docx_template_rsbu_updn')
template = self.env.ref(xmlid, raise_if_not_found=False)
return template.report_id if template else False
def action_print_upd_docx(self):
"""Печать УПД (DOCX, с печатями) из отгрузочного СФ."""
self.ensure_one()
from odoo.exceptions import UserError
from odoo import _
report = self._upd_docx_report(with_stamp=True)
if not report:
raise UserError(_('Не найден DOCX-шаблон УПД.'))
return report.report_action(self)
def action_print_updn_docx(self):
"""Печать УПД без печатей (DOCX) из отгрузочного СФ."""
self.ensure_one()
from odoo.exceptions import UserError
from odoo import _
report = self._upd_docx_report(with_stamp=False)
if not report:
raise UserError(_('Не найден DOCX-шаблон УПД.'))
return report.report_action(self)

View File

@@ -0,0 +1,132 @@
"""Ручное формирование документа реализации (и УПД) из инвойса Odoo.
Пользователь оформляет и проводит инвойс (account.move, out_invoice) —
РСБУ-слой формирует проводку реализации с зачётом авансов (резолвер
shipment_upd). Документ реализации (rsbu.realization), из которого затем
выгружается/печатается УПД, создаётся НЕ автоматически, а по кнопке
«Создать документ реализации» на форме инвойса.
Документ реализации создаётся для ЛЮБОЙ отгрузки — облагаемой НДС и
«Без НДС». При наличии НДС хотя бы в одной строке он порождает подчинённый
отгрузочный счёт-фактуру (rsbu.invoice, тип «shipment»).
Жизненный цикл закрывающего документа отражает поле closing_document на
инвойсе:
not_done — закрывающий документ не сформирован;
issued — документ реализации оформлен, ждём подпись клиента;
signed — клиент подписал (проставляется вручную).
При создании документа реализации статус переходит в «issued». При
удалении/отмене реализации — возвращается в «not_done» (хук на
rsbu.realization). В «signed» статус переводит только человек. Исходная
РСБУ-проводка инвойса при формировании УПД не меняется: реализация и СФ —
документы-надстройки, собственных проводок отгрузки не создают (их уже
сформировал резолвер при проведении).
"""
from odoo import _, fields, models
from odoo.exceptions import UserError
class AccountMove(models.Model):
_inherit = 'account.move'
closing_document = fields.Selection(
selection=[
('not_done', 'Не сделан'),
('issued', 'Оформлен'),
('signed', 'Подписан'),
],
string='Закрывающий документ', default='not_done', copy=False,
tracking=True,
help='Состояние закрывающего документа (УПД) по реализации: '
'«Не сделан» — документ реализации не сформирован; '
'«Оформлен» — создан, ждём подпись клиента; «Подписан» — '
'клиент подписал (ставится вручную).')
# Связанный документ реализации РСБУ (источник УПД).
rsbu_realization_id = fields.Many2one(
'rsbu.realization', string='Документ реализации (УПД)', copy=False,
compute='_compute_rsbu_realization_id', store=False,
help='Документ реализации РСБУ-слоя, сформированный по этому инвойсу '
'для выгрузки/печати УПД.')
def _compute_rsbu_realization_id(self):
Realization = self.env['rsbu.realization'].sudo()
for move in self:
move.rsbu_realization_id = Realization.search([
('source_model', '=', 'account.move'),
('source_res_id', '=', move.id),
('state', '!=', 'cancelled'),
('company_id', '=', move.company_id.id),
], limit=1)
# ------------------------------------------------------------------
def _upd_can_create(self):
"""Можно ли сформировать документ реализации по этому инвойсу.
Документ реализации формируется по любой проведённой реализации
(out_invoice, с НДС и без), если он ещё не создан.
"""
self.ensure_one()
return bool(
self.move_type == 'out_invoice'
and self.state == 'posted'
and not self.rsbu_realization_id
)
def action_create_realization(self):
"""Сформировать документ реализации (и УПД) по инвойсу.
Всегда создаётся rsbu.realization; при наличии НДS хотя бы в одной
строке — дополнительно порождается подчинённый отгрузочный СФ.
"""
self.ensure_one()
if self.move_type != 'out_invoice':
raise UserError(_(
'Документ реализации формируется только по счёту покупателю '
'(реализация).'))
if self.state != 'posted':
raise UserError(_(
'Проведите инвойс перед формированием документа реализации.'))
if self.rsbu_realization_id:
raise UserError(_(
'По этому инвойсу уже сформирован документ реализации: %s.')
% self.rsbu_realization_id.name)
Realization = self.env['rsbu.realization'].sudo()
realization = Realization.create({
'partner_id': self.partner_id.id,
'company_id': self.company_id.id,
'date': self.invoice_date or self.date or fields.Date.today(),
'source_model': 'account.move',
'source_res_id': self.id,
'amount_untaxed': abs(self.amount_untaxed),
'amount_tax': abs(self.amount_tax),
'state': 'confirmed',
})
if realization._has_vat(self):
realization._create_shipment_invoice()
self.closing_document = 'issued'
# Связь rsbu_realization_id — non-stored computed; сбрасываем кэш,
# чтобы кнопки/статус сразу отразили созданную реализацию.
self.invalidate_recordset(['rsbu_realization_id'])
return self._upd_open_realization(realization)
def action_open_realization(self):
"""Открыть сформированный документ реализации (УПД)."""
self.ensure_one()
realization = self.rsbu_realization_id
if not realization:
raise UserError(_(
'По этому инвойсу документ реализации ещё не сформирован.'))
return self._upd_open_realization(realization)
def _upd_open_realization(self, realization):
return {
'type': 'ir.actions.act_window',
'name': _('Документ реализации (УПД)'),
'res_model': 'rsbu.realization',
'view_mode': 'form',
'res_id': realization.id,
'target': 'current',
}

View File

@@ -0,0 +1,424 @@
"""Генерация УПД (универсального передаточного документа) в XML.
Формат ФНС ON_NSCHFDOPPR (файл продавца, КНД 1115131, версия 5.03).
Источник — документ РСБУ-слоя «Счёт-фактура» (rsbu.invoice, отгрузочный).
Разделение источников (Вариант B):
* счёт-фактурная часть (номер/дата СФ, суммы, ставка и сумма НДС, итоги)
— из РСБУ-слоя (rsbu.invoice);
* товарные позиции (наименование, количество, ОКЕИ, цена, ГТИН/код) —
из связанного инвойса-источника (account.move, _source_invoice);
* реквизиты сторон (ИНН/КПП/ОКПО/адрес), подписант — из res.company /
res.partner (переиспользуются штатные поля l10n_ru).
Заменяет устаревший l10n_ru_upd_xml (формат 5.01 из account.move).
Кодировка windows-1251, кириллические имена элементов, валидация по XSD.
"""
import base64
from datetime import date, datetime
from odoo import _, api, models
from odoo.exceptions import UserError
KND_UPD = '1115131'
UPD_FORMAT_VERSION = '5.03'
# Отображение ставки НДС rsbu.invoice.vat_rate → значение НалСт (enum XSD).
VAT_RATE_LABEL = {
'vat_exempt': 'без НДС',
'vat_5': '5%',
'vat_7': '7%',
'vat_10': '10%',
'vat_20': '20%',
}
# Доля ставки НДС для расчёта суммы налога (НДС = база × доля).
VAT_RATE_PCT = {
'vat_exempt': 0.0,
'vat_5': 0.05,
'vat_7': 0.07,
'vat_10': 0.10,
'vat_20': 0.20,
}
class RsbuRealizationUpd(models.Model):
_inherit = 'rsbu.realization'
# ------------------------------------------------------------------
# Реквизиты СФ (из подчинённого СФ при наличии, иначе из реализации)
# ------------------------------------------------------------------
def _upd_schf_number(self):
"""Номер для СвСчФакт: из отгрузочного СФ, иначе из реализации."""
self.ensure_one()
return (self.invoice_id.name if self.invoice_id else self.name) or ''
def _upd_schf_date(self):
"""Дата для СвСчФакт: из отгрузочного СФ, иначе из реализации."""
self.ensure_one()
if self.invoice_id and self.invoice_id.date:
return self.invoice_id.date
return self.date or date.today()
def _upd_vat_rate_label(self):
"""Метка ставки НДС (НалСт) — из СФ при наличии, иначе «без НДС»."""
self.ensure_one()
rate = self.invoice_id.vat_rate if self.invoice_id else 'vat_exempt'
return VAT_RATE_LABEL.get(rate, 'без НДС')
# ------------------------------------------------------------------
# Реквизиты и вспомогательные значения
# ------------------------------------------------------------------
def _upd_filename(self):
"""Имя файла обмена УПД по Порядку (п.4):
ON_NSCHFDOPPR_A_О_GGGGMMDD_N1_N2_N3_N4_N5_N6_N7
где A — получатель «УИОЭДО+УИПол», О — отправитель «УИОЭДО+УИОтпр»;
УИОЭДО — код оператора ЭДО (3 симв, «000» без оператора); УИПол/
УИОтпр — идентификаторы участников в ЭДО (или GUID вне ЭДО); N1 —
36-символьный GUID (совпадает с ИдФайл); N2–N6 — флаги прослежива-
емости/маркировки (0); N7 — двузначный код (00).
"""
self.ensure_one()
import uuid
company = self.company_id
operator = (company.rsbu_edo_operator_code or '000').strip()
# Отправитель (продавец) — идентификатор нашей организации в ЭДО.
sender = (company.rsbu_edo_participant_id or '').strip() \
or str(uuid.uuid4())
# Получатель (покупатель) — идентификатор контрагента в ЭДО.
receiver = (self.partner_id.rsbu_edo_participant_id or '').strip() \
or str(uuid.uuid4())
a = '%s%s' % (operator, receiver) # А — получатель
o = '%s%s' % (operator, sender) # О — отправитель
today = date.today().strftime('%Y%m%d')
guid = str(uuid.uuid4()) # N1
# N2..N7 — признаки прослеживаемости/маркировки/спецрежимов (по
# умолчанию отсутствуют).
flags = '0_0_0_0_0_00'
return 'ON_NSCHFDOPPR_%s_%s_%s_%s_%s' % (a, o, today, guid, flags)
def _upd_validate(self):
"""Проверить наличие данных, обязательных для УПД."""
self.ensure_one()
errors = []
company = self.company_id
partner = company.partner_id
if not partner.inn:
errors.append(_('ИНН организации-продавца'))
if not self.partner_id:
errors.append(_('контрагент-покупатель'))
elif not self.partner_id.inn:
errors.append(_('ИНН покупателя'))
source = self._source_invoice()
if not source:
errors.append(_('инвойс-источник с товарными позициями'))
elif not source.invoice_line_ids:
errors.append(_('строки инвойса-источника'))
else:
for line in source.invoice_line_ids.filtered(
lambda l: l.display_type in (False, 'product')):
uom = line.product_uom_id
if uom and not getattr(uom, 'okei', False):
errors.append(
_('код ОКЕИ для единицы «%s»') % uom.name)
break
if not company.rsbu_signatory_id and not getattr(
company, 'chief_id', False):
errors.append(_('подписант (руководитель) организации'))
if errors:
raise UserError(_(
'Не удалось сформировать УПД. Не хватает данных:\n%s')
% '\n'.join('— %s' % e for e in errors))
@staticmethod
def _amt(value):
"""Денежная сумма УПД: строка с 2 знаками (СтТовБезНДС/СтТовУчНал)."""
return '%.2f' % (value or 0.0)
@staticmethod
def _qty(value):
"""Количество/цена: до 11 знаков дробной части, без хвостовых нулей."""
s = ('%.11f' % (value or 0.0)).rstrip('0').rstrip('.')
return s or '0'
def _upd_party_node(self, etree, tag, partner, okpo=True):
"""Узел стороны (СвПрод/СвПокуп/ГрузОтпр/ГрузПолуч) по res.partner.
ЮЛ (ИНН 10) → СвЮЛУч; ИП (ИНН 12) → СвИП. Адрес — упрощённый АдрИнф
(код страны 643, текстом). Реквизиты штатные (l10n_ru).
"""
attrs = {}
if okpo and getattr(partner, 'okpo', False):
attrs['ОКПО'] = partner.okpo
node = etree.Element(tag, attrs)
idsv = etree.SubElement(node, 'ИдСв')
inn = (partner.inn or '').strip()
if len(inn) == 12:
names = (partner.name or '').replace('ИП', '').split()
svip = etree.SubElement(idsv, 'СвИП', {
'ИННФЛ': inn, 'ИныеСвед': partner.name or ''})
etree.SubElement(svip, 'ФИО', {
'Фамилия': names[0] if names else partner.name or '',
'Имя': names[1] if len(names) > 1 else '-',
**({'Отчество': names[2]} if len(names) > 2 else {}),
})
else:
etree.SubElement(idsv, 'СвЮЛУч', {
'НаимОрг': partner.name or '',
'ИННЮЛ': inn,
**({'КПП': partner.kpp} if getattr(partner, 'kpp', False)
else {}),
})
# Адрес текстом (АдрИнф) — упрощённый, достаточно по XSD.
addr = etree.SubElement(node, 'Адрес')
parts = [partner.city or '', partner.street or '']
etree.SubElement(addr, 'АдрИнф', {
'КодСтр': '643',
'НаимСтран': 'Российская Федерация',
'АдрТекст': ', '.join(p for p in parts if p) or 'н/д',
})
return node
# ------------------------------------------------------------------
# Сборка XML УПД
# ------------------------------------------------------------------
def _upd_build_xml(self, filename=None):
"""Собрать XML УПД (формат 5.03) через lxml. Возвращает bytes."""
self.ensure_one()
try:
from lxml import etree
except ImportError:
raise UserError(_('Для выгрузки УПД требуется библиотека lxml.')) from None
self._upd_validate()
if filename is None:
filename = self._upd_filename()
company = self.company_id
seller = company.partner_id
buyer = self.partner_id
source = self._source_invoice()
now = datetime.now()
E = etree.Element
root = E('Файл', {
'ИдФайл': filename,
'ВерсФорм': UPD_FORMAT_VERSION,
'ВерсПрог': 'RuOdoo',
})
has_sf = bool(self.invoice_id)
if has_sf:
naim_dok_opr = ('Счет-фактура и документ об отгрузке товаров '
'(выполнении работ), передаче имущественных прав '
'(документ об оказании услуг)')
else:
naim_dok_opr = ('Документ об отгрузке товаров (выполнении работ), '
'передаче имущественных прав (документ об '
'оказании услуг)')
doc = E('Документ', {
'КНД': KND_UPD,
'Функция': 'СЧФДОП' if has_sf else 'ДОП',
'ПоФактХЖ': 'Документ об отгрузке товаров (выполнении работ), '
'передаче имущественных прав (документ об оказании '
'услуг)',
'НаимДокОпр': naim_dok_opr,
'ДатаИнфПр': now.strftime('%d.%m.%Y'),
'ВремИнфПр': now.strftime('%H.%M.%S'),
'НаимЭконСубСост': company.name or '',
})
root.append(doc)
# СвСчФакт: номер и дата — из подчинённого СФ при наличии, иначе из
# документа реализации (ветка «2»/ДОП, 03 §6.3). XSD требует СвСчФакт
# с НомерДок/ДатаДок независимо от Функции.
schf = E('СвСчФакт', {
'НомерДок': self._upd_schf_number(),
'ДатаДок': self._upd_schf_date().strftime('%d.%m.%Y'),
})
doc.append(schf)
# Продавец / покупатель.
schf.append(self._upd_party_node(etree, 'СвПрод', seller))
# Грузоотправитель (если задан и отличается от продавца) / «он же».
shipper = self.upd_shipper_id
gruzot = E('ГрузОт')
if shipper and shipper != seller:
gruzot.append(self._upd_party_node(etree, 'ГрузОтпр', shipper))
else:
etree.SubElement(gruzot, 'ОнЖе').text = 'он же'
doc_has_shipment = any(
(l.product_id.type != 'service')
for l in source.invoice_line_ids) if source else False
if doc_has_shipment:
schf.append(gruzot)
consignee = self.upd_consignee_id or buyer
schf.append(self._upd_party_node(
etree, 'ГрузПолуч', consignee))
schf.append(self._upd_party_node(etree, 'СвПокуп', buyer))
# Денежный измеритель — рубли.
etree.SubElement(schf, 'ДенИзм', {
'КодОКВ': '643', 'НаимОКВ': 'Российский рубль'})
# ТаблСчФакт: позиции из инвойса-источника, финансы из РСБУ/строки.
tabl = E('ТаблСчФакт')
doc.append(tabl)
sf_vat_rate = self.invoice_id.vat_rate if self.invoice_id \
else 'vat_exempt'
rate_label = VAT_RATE_LABEL.get(sf_vat_rate, 'без НДС')
# Метка ставки — из РСБУ (vat_rate). Суммы НДС берутся ФАКТИЧЕСКИЕ
# из строки инвойса-источника (price_total − price_subtotal): это
# корректно и для ставки «в том числе» (расчётная 5/105), где налог
# выделяется из цены, а не начисляется сверху. Fallback (нет налогов
# в источнике) — пересчёт по ставке РСБУ.
vat_pct = VAT_RATE_PCT.get(sf_vat_rate, 0.0)
total_wo_vat = 0.0
total_vat = 0.0
total_with_vat = 0.0
lines = source.invoice_line_ids.filtered(
lambda l: l.display_type in (False, 'product')) if source \
else self.env['account.move.line']
for idx, line in enumerate(lines, start=1):
# База (без НДС) — стоимость позиции из инвойса-источника.
subtotal = line.price_subtotal
if line.tax_ids:
line_vat = round((line.price_total or 0.0) - subtotal, 2)
total = round(line.price_total or 0.0, 2)
else:
line_vat = round(subtotal * vat_pct, 2)
total = round(subtotal + line_vat, 2)
total_wo_vat += subtotal
total_vat += line_vat
total_with_vat += total
uom = line.product_uom_id
sved = E('СведТов', {
'НомСтр': str(idx),
'НаимТов': line.name or (line.product_id.name or 'Товар'),
'НаимЕдИзм': uom.name or '',
'КолТов': self._qty(line.quantity),
'ЦенаТов': self._qty(
subtotal / line.quantity if line.quantity else 0.0),
'СтТовБезНДС': self._amt(subtotal),
'НалСт': rate_label,
'СтТовУчНал': self._amt(total),
})
if uom and getattr(uom, 'okei', False):
sved.set('ОКЕИ_Тов', uom.okei)
# Доп. сведения товара (код/ГТИН) — из product.
product = line.product_id
dop_attrs = {}
if product.default_code:
dop_attrs['КодТов'] = product.default_code
if product.barcode:
dop_attrs['ГТИН'] = ('0' + product.barcode)[-14:]
if dop_attrs:
etree.SubElement(sved, 'ДопСведТов', dop_attrs)
# Сумма НДС по строке.
sumnal = etree.SubElement(sved, 'СумНал')
if line_vat:
etree.SubElement(sumnal, 'СумНал').text = self._amt(line_vat)
else:
etree.SubElement(sumnal, 'БезНДС').text = 'без НДС'
tabl.append(sved)
vsego = E('ВсегоОпл', {
'СтТовБезНДСВсего': self._amt(total_wo_vat),
'СтТовУчНалВсего': self._amt(total_with_vat),
})
sumnal_all = etree.SubElement(vsego, 'СумНалВсего')
if round(total_vat, 2):
etree.SubElement(sumnal_all, 'СумНал').text = self._amt(total_vat)
else:
etree.SubElement(sumnal_all, 'БезНДС').text = 'без НДС'
tabl.append(vsego)
# СвПродПер: передача, ответственное лицо (кладовщик), транспорт.
svprodper = E('СвПродПер')
doc.append(svprodper)
svper = E('СвПер', {
'СодОпер': self.upd_operation_content or 'Товары/услуги переданы',
'ДатаПер': (self.date or date.today()).strftime('%d.%m.%Y'),
})
svprodper.append(svper)
# Основание передачи.
etree.SubElement(svper, 'ОснПер', {
'РеквНаимДок': 'Договор',
'РеквНомерДок': self.upd_basis or (self.name or ''),
'РеквДатаДок': (self.date or date.today()).strftime('%d.%m.%Y'),
})
transferer = self.upd_transferred_by_id
if transferer:
svlic = etree.SubElement(svper, 'СвЛицПер')
names = (transferer.name or '').split()
rab = etree.SubElement(svlic, 'РабОргПрод', {
'Должность': (transferer.partner_id.function or 'Кладовщик'),
})
etree.SubElement(rab, 'ФИО', {
'Фамилия': names[0] if names else (transferer.name or '-'),
'Имя': names[1] if len(names) > 1 else '-',
**({'Отчество': names[2]} if len(names) > 2 else {}),
})
if self.upd_transport:
etree.SubElement(svper, 'Тран', {'СвТран': self.upd_transport})
# Подписант (руководитель организации-продавца).
signer = company.rsbu_signatory_id or getattr(company, 'chief_id',
False)
podp = E('Подписант', {
'СпосПодтПолном': '1',
**({'Должн': signer.partner_id.function}
if signer and signer.partner_id.function else {}),
})
if signer:
names = (signer.name or '').split()
etree.SubElement(podp, 'ФИО', {
'Фамилия': names[0] if names else (signer.name or '-'),
'Имя': names[1] if len(names) > 1 else '-',
**({'Отчество': names[2]} if len(names) > 2 else {}),
})
doc.append(podp)
return etree.tostring(
root, xml_declaration=True, encoding='windows-1251',
pretty_print=True)
# ------------------------------------------------------------------
# Валидация против XSD и выгрузка
# ------------------------------------------------------------------
@api.model
def _upd_xsd_path(self):
import os
from odoo.modules import get_module_path
return os.path.join(
get_module_path('rsbu_fns_reporting'), 'data', 'schemas',
'ON_NSCHFDOPPR_1_997_01_05_03_02.xsd')
def _upd_validate_xml(self, xml_bytes):
"""Валидация УПД против XSD. Возвращает (ok, errors)."""
from lxml import etree
schema = etree.XMLSchema(etree.parse(self._upd_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_upd_xml(self):
"""Сформировать УПД в XML, провалидировать и вернуть вложением."""
self.ensure_one()
filename = self._upd_filename()
xml_bytes = self._upd_build_xml(filename=filename)
ok, errors = self._upd_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',
}

View File

@@ -0,0 +1,181 @@
"""Декларация по УСН (объект «Доходы», КНД 1152017).
Расчёт разделов 2.1.1 и 1.1 нарастающим итогом по отчётным периодам
(I квартал, полугодие, 9 месяцев, год) из итогов КУДиР (rsbu.kudir.summary).
Формулы — по Порядку заполнения (разделы III и V): исчисленный налог,
уменьшение на страховые взносы (для работодателя не более 50%), авансовые
платежи и налог к доплате/уменьшению за год.
XML-выгрузка — в rsbu_usn_xml (Этап 10-совместимый генератор). Здесь —
расчёт и хранение показателей для сверки и последующей выгрузки.
"""
from odoo import api, fields, models
class RsbuUsnDeclaration(models.Model):
_name = 'rsbu.usn.declaration'
_description = 'РСБУ — Декларация УСН (Доходы)'
_order = 'year desc, id desc'
_check_company_auto = True
name = fields.Char(string='Наименование', compute='_compute_name',
store=True)
company_id = fields.Many2one(
'res.company', string='Компания', required=True, index=True,
default=lambda self: self.env.company)
year = fields.Integer(string='Отчётный год', required=True,
default=lambda self: fields.Date.today().year)
taxpayer_sign = fields.Selection(
selection=[('1', 'Производит выплаты физлицам'),
('2', 'ИП без выплат физлицам')],
string='Признак налогоплательщика', default='1', required=True,
help='Стр. 102. Признак «1» — уменьшение налога на взносы не более '
'50%; «2» (ИП без работников) — до 100%.')
currency_id = fields.Many2one(
'res.currency', related='company_id.currency_id')
state = fields.Selection(
selection=[('draft', 'Черновик'), ('computed', 'Рассчитана')],
default='draft', string='Состояние', copy=False)
# --- Раздел 2.1.1: доход, ставка, исчислено, вычет (нарастающим итогом) ---
# Доход (стр. 110-113).
income_q1 = fields.Monetary(string='Доход за I кв. (110)',
currency_field='currency_id')
income_h1 = fields.Monetary(string='Доход за полугодие (111)',
currency_field='currency_id')
income_9m = fields.Monetary(string='Доход за 9 мес. (112)',
currency_field='currency_id')
income_year = fields.Monetary(string='Доход за год (113)',
currency_field='currency_id')
# Ставка (стр. 120-123), %.
rate = fields.Float(string='Ставка УСН, % (120-123)', default=6.0)
# Исчислено (стр. 130-133).
tax_q1 = fields.Monetary(string='Исчислено за I кв. (130)',
currency_field='currency_id')
tax_h1 = fields.Monetary(string='Исчислено за полугодие (131)',
currency_field='currency_id')
tax_9m = fields.Monetary(string='Исчислено за 9 мес. (132)',
currency_field='currency_id')
tax_year = fields.Monetary(string='Исчислено за год (133)',
currency_field='currency_id')
# Вычет: взносы/пособия (стр. 140-143), ≤ 50% исчисленного (признак 1).
ded_q1 = fields.Monetary(string='Вычет за I кв. (140)',
currency_field='currency_id')
ded_h1 = fields.Monetary(string='Вычет за полугодие (141)',
currency_field='currency_id')
ded_9m = fields.Monetary(string='Вычет за 9 мес. (142)',
currency_field='currency_id')
ded_year = fields.Monetary(string='Вычет за год (143)',
currency_field='currency_id')
# --- Раздел 1.1: авансы к уплате/уменьшению по срокам ---
adv_q1 = fields.Monetary(string='Аванс к уплате I кв. (020)',
currency_field='currency_id')
adv_h1 = fields.Monetary(string='Аванс к уплате полугодие (040)',
currency_field='currency_id')
adv_h1_reduce = fields.Monetary(string='Аванс к уменьшению полугодие (050)',
currency_field='currency_id')
adv_9m = fields.Monetary(string='Аванс к уплате 9 мес. (070)',
currency_field='currency_id')
adv_9m_reduce = fields.Monetary(string='Аванс к уменьшению 9 мес. (080)',
currency_field='currency_id')
tax_due_year = fields.Monetary(string='Налог к доплате за год (100)',
currency_field='currency_id')
tax_reduce_year = fields.Monetary(string='Налог к уменьшению за год (110)',
currency_field='currency_id')
oktmo = fields.Char(string='ОКТМО (010)')
@api.depends('year', 'company_id')
def _compute_name(self):
for rec in self:
rec.name = 'Декларация УСН за %s' % (rec.year or '?')
# ------------------------------------------------------------------
def _reduction_limited(self, insurance, tax):
"""Вычет взносов, ограниченный признаком налогоплательщика.
Признак «1» (работодатель): не более 50% исчисленного налога.
Признак «2» (ИП без работников): до 100% (в пределах налога).
В обоих случаях не больше самого налога и не больше фактических
взносов.
"""
if tax <= 0:
return 0.0
cap = tax * 0.5 if self.taxpayer_sign == '1' else tax
return round(min(insurance, cap), 2)
def action_compute(self):
"""Пересобрать итоги КУДиР за год и рассчитать декларацию."""
Summary = self.env['rsbu.kudir.summary'].sudo()
for rec in self:
date_from = fields.Date.to_date('%s-01-01' % rec.year)
date_to = fields.Date.to_date('%s-12-31' % rec.year)
Summary.recompute_period(date_from, date_to, rec.company_id)
return self.compute_from_summary()
def compute_from_summary(self):
"""Рассчитать показатели декларации из готовых итогов КУДиР.
Отделено от action_compute, чтобы считать по существующим записям
rsbu.kudir.summary без принудительного пересбора (для тестов и
повторного расчёта).
"""
Summary = self.env['rsbu.kudir.summary'].sudo()
for rec in self:
company = rec.company_id
year = rec.year
rate = (rec.rate or 0.0) / 100.0
# Доход нарастающим итогом по отчётным периодам.
inc = {
q: Summary.ytd_totals(company, year, q)['income']
for q in (1, 2, 3, 4)
}
# Взносы/пособия (раздел IV) нарастающим итогом.
ins = {
q: Summary.ytd_totals(company, year, q)['insurance']
for q in (1, 2, 3, 4)
}
# Исчислено (стр. 130-133) = доход × ставка.
t = {q: round(inc[q] * rate, 2) for q in (1, 2, 3, 4)}
# Вычет (стр. 140-143), ограничение по признаку НП.
d = {q: rec._reduction_limited(ins[q], t[q]) for q in (1, 2, 3, 4)}
rec.income_q1, rec.income_h1, rec.income_9m, rec.income_year = (
inc[1], inc[2], inc[3], inc[4])
rec.tax_q1, rec.tax_h1, rec.tax_9m, rec.tax_year = (
t[1], t[2], t[3], t[4])
rec.ded_q1, rec.ded_h1, rec.ded_9m, rec.ded_year = (
d[1], d[2], d[3], d[4])
# --- Раздел 1.1 (авансы по срокам, формулы Порядка п.16-25) ---
# Аванс за I кв. (020) = (130 − 140), если ≥ 0.
a020 = max(0.0, round(t[1] - d[1], 2))
# Полугодие: (131 − 141) − 020.
base_h1 = round(t[2] - d[2], 2)
diff_h1 = round(base_h1 - a020, 2)
a040 = max(0.0, diff_h1) # к уплате (040)
a050 = max(0.0, -diff_h1) # к уменьшению (050)
# 9 мес.: (132 − 142) − (020 + 040 − 050).
base_9m = round(t[3] - d[3], 2)
prev_9m = round(a020 + a040 - a050, 2)
diff_9m = round(base_9m - prev_9m, 2)
a070 = max(0.0, diff_9m) # к уплате (070)
a080 = max(0.0, -diff_9m) # к уменьшению (080)
# Год: (133 − 143) − (020 + 040 − 050 + 070 − 080).
base_year = round(t[4] - d[4], 2)
prev_year = round(a020 + a040 - a050 + a070 - a080, 2)
diff_year = round(base_year - prev_year, 2)
a100 = max(0.0, diff_year) # к доплате (100)
a110 = max(0.0, -diff_year) # к уменьшению (110)
rec.adv_q1 = a020
rec.adv_h1 = a040
rec.adv_h1_reduce = a050
rec.adv_9m = a070
rec.adv_9m_reduce = a080
rec.tax_due_year = a100
rec.tax_reduce_year = a110
if not rec.oktmo:
rec.oktmo = company.rsbu_oktmo or ''
rec.state = 'computed'
return True

View File

@@ -0,0 +1,260 @@
"""Генерация электронной декларации УСН в XML (формат ФНС, КНД 1152017).
Формат NO_USN_1_030_00_05_09_01 (версия 5.09). Кодировка windows-1251,
кириллические имена элементов/атрибутов. Структура:
Файл(ИдФайл, ВерсПрог, ВерсФорм)
└ Документ(КНД, ДатаДок, Период, ОтчетГод, КодНО, НомКорр, ПоМесту)
├ СвНП → НПЮЛ(НаимОрг, ИННЮЛ, КПП)
├ Подписант(ПрПодп) [→ ФИО]
└ УСН
└ СумНалПУ_НП(ПризНП, ОКТМО, АвПУКв, ОКТМО_Пг, АвПУУменПг,
ОКТМО_9м, АвПУУмен9м, ОКТМО_Пер, НалПУУменПер)
└ РасчНал1(Признак)
├ Доход(СумЗаКв, СумЗаПг, СумЗа9м, СумЗаНалПер)
├ Ставка(СтавкаКв, СтавкаПг, Ставка9м, СтавкаНалПер)
├ Исчисл(СумЗаКв …)
└ УменНал(СумЗаКв …)
Суммы денежных показателей — целые рубли (в XSD integer). Ставки —
десятичные (напр. 6.0). Реквизиты — из настроек компании (rsbu_reporting_base
и l10n_ru). Версии формата — параметрами, TODO(сверка).
"""
import base64
from datetime import date
from odoo import _, api, models
from odoo.exceptions import UserError
# Версия формата и КНД (данными, TODO сверка при обновлении формата ФНС).
KND_USN = '1152017'
FORMAT_VERSION = '5.09'
DECL_PERIOD_YEAR = '34' # налоговый период «год» (код по прил. к Порядку)
PLACE_AT_LOCATION = '210' # по месту нахождения российской организации
OBJECT_INCOME = '1' # объект налогообложения: доходы
class RsbuUsnDeclaration(models.Model):
_inherit = 'rsbu.usn.declaration'
def _usn_xml_filename(self):
"""Имя файла обмена: R_Т_A_K_О_GGGGMMDD_N (Порядок, п.3).
R_Т = NO_USN; A и K — код налогового органа (совпадают, если нет
промежуточных НО); О — ИНН+КПП организации (19 знаков); GGGGMMDD —
дата формирования; N — уникальный идентификатор (UUID, ≤36 знаков).
Идентификатор файла (атрибут ИдФайл) обязан совпадать с этим именем,
поэтому имя вычисляется один раз и переиспользуется (см.
_usn_build_xml, action_export_xml).
"""
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_USN_%s_%s_%s%s_%s_%s' % (
code_no, code_no, inn, kpp, today, guid)
def _usn_validate_requisites(self):
"""Проверить наличие обязательных реквизитов до генерации XML."""
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(_('ОКТМО'))
if errors:
raise UserError(_(
'Не заполнены обязательные реквизиты для декларации УСН: '
'%s. Укажите их в настройках компании (РСБУ — Отчётность).')
% ', '.join(errors))
@staticmethod
def _rub(value):
"""Денежный показатель в целых рублях (строкой), как требует XSD."""
return str(int(round(value or 0.0)))
@staticmethod
def _rate(value):
"""Ставка налога строкой (десятичная, напр. «6.0»)."""
return ('%.1f' % (value or 0.0))
def _usn_build_xml(self, filename=None):
"""Собрать дерево XML декларации УСН через lxml. Возвращает bytes.
`filename` (без расширения) записывается в атрибут ИдФайл и обязан
совпадать с именем файла вложения (Порядок, п.3). Если не передан —
генерируется здесь; тогда переиспользуйте _usn_build_named.
"""
self.ensure_one()
try:
from lxml import etree
except ImportError:
raise UserError(_('Для выгрузки XML требуется библиотека lxml.')) from None
self._usn_validate_requisites()
company = self.company_id
partner = company.partner_id
oktmo = self.oktmo or company.rsbu_oktmo or ''
if filename is None:
filename = self._usn_xml_filename()
E = etree.Element
root = E('Файл', {
'ИдФайл': filename,
'ВерсПрог': 'RuOdoo',
'ВерсФорм': FORMAT_VERSION,
})
doc = E('Документ', {
'КНД': KND_USN,
'ДатаДок': date.today().strftime('%d.%m.%Y'),
'Период': DECL_PERIOD_YEAR,
'ОтчетГод': str(self.year),
'КодНО': company.rsbu_tax_office_code or '',
'НомКорр': '0',
'ПоМесту': PLACE_AT_LOCATION,
})
root.append(doc)
# Сведения о налогоплательщике (организация).
svnp = E('СвНП')
E_np = E('НПЮЛ', {
'НаимОрг': company.name or '',
'ИННЮЛ': (partner.inn or '').strip(),
'КПП': (partner.kpp or '').strip(),
})
svnp.append(E_np)
doc.append(svnp)
# Подписант (руководитель — «1» или представитель — «2»).
pr_podp = '2' if company.rsbu_signatory_is_representative else '1'
signer = E('Подписант', {'ПрПодп': pr_podp})
signatory = company.rsbu_signatory_id
if signatory:
name_parts = (signatory.name or '').split()
fio_attrs = {}
if len(name_parts) >= 1:
fio_attrs['Фамилия'] = name_parts[0]
if len(name_parts) >= 2:
fio_attrs['Имя'] = name_parts[1]
if len(name_parts) >= 3:
fio_attrs['Отчество'] = name_parts[2]
if fio_attrs:
signer.append(E('ФИО', fio_attrs))
doc.append(signer)
# Раздел данных УСН (ОбНал=1 — объект «доходы»).
usn = E('УСН', {'ОбНал': OBJECT_INCOME})
doc.append(usn)
# СумНалПУ_НП — раздел 1.1 (авансы к уплате/уменьшению) + контейнер 2.1.1.
# Отрицательные «к уменьшению» кодируются в атрибутах *Умен* (по XSD
# один атрибут на срок несёт либо к уплате, либо к уменьшению).
sum_np_attrs = {
'ОКТМО': oktmo,
'АвПУКв': self._rub(self.adv_q1),
'ОКТМО_Пг': oktmo,
'АвПУУменПг': self._rub(self.adv_h1 or -self.adv_h1_reduce),
'ОКТМО_9м': oktmo,
'АвПУУмен9м': self._rub(self.adv_9m or -self.adv_9m_reduce),
'ОКТМО_Пер': oktmo,
'НалПУУменПер': self._rub(
self.tax_due_year or -self.tax_reduce_year),
}
sum_np = E('СумНалПУ_НП', sum_np_attrs)
usn.append(sum_np)
# РасчНал1 — раздел 2.1.1 (расчёт налога «Доходы»).
rasch = E('РасчНал1', {'ПризНП': self.taxpayer_sign})
rasch.append(E('Доход', {
'СумЗаКв': self._rub(self.income_q1),
'СумЗаПг': self._rub(self.income_h1),
'СумЗа9м': self._rub(self.income_9m),
'СумЗаНалПер': self._rub(self.income_year),
}))
rasch.append(E('Ставка', {
'СтавкаКв': self._rate(self.rate),
'СтавкаПг': self._rate(self.rate),
'Ставка9м': self._rate(self.rate),
'СтавкаНалПер': self._rate(self.rate),
}))
rasch.append(E('Исчисл', {
'СумЗаКв': self._rub(self.tax_q1),
'СумЗаПг': self._rub(self.tax_h1),
'СумЗа9м': self._rub(self.tax_9m),
'СумЗаНалПер': self._rub(self.tax_year),
}))
rasch.append(E('УменНал', {
'СумЗаКв': self._rub(self.ded_q1),
'СумЗаПг': self._rub(self.ded_h1),
'СумЗа9м': self._rub(self.ded_9m),
'СумЗаНалПер': self._rub(self.ded_year),
}))
sum_np.append(rasch)
return etree.tostring(
root, xml_declaration=True, encoding='windows-1251',
pretty_print=True)
@api.model
def _usn_xsd_path(self):
"""Путь к XSD-схеме формата УСН внутри модуля."""
import os
from odoo.modules import get_module_path
module_path = get_module_path('rsbu_fns_reporting')
return os.path.join(
module_path, 'data', 'schemas',
'NO_USN_1_030_00_05_09_01.xsd')
def _usn_validate_xml(self, xml_bytes):
"""Провалидировать XML против XSD-схемы ФНС. Возвращает (ok, errors)."""
from lxml import etree
path = self._usn_xsd_path()
schema = etree.XMLSchema(etree.parse(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-файл декларации и вернуть как вложение.
Перед выгрузкой ВСЕГДА выполняется пересчёт (action_compute):
итоги КУДиР могли измениться после проведения новых документов, а
выгружать устаревшие суммы недопустимо. Пересчёт идемпотентен.
"""
self.ensure_one()
self.action_compute()
# Имя файла вычисляется один раз: атрибут ИдФайл обязан совпадать с
# именем файла обмена (Порядок, п.3).
filename = self._usn_xml_filename()
xml_bytes = self._usn_build_xml(filename=filename)
ok, errors = self._usn_validate_xml(xml_bytes)
if not ok:
raise UserError(_(
'Сформированный XML не прошёл проверку по схеме ФНС:\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',
}