Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC
This commit is contained in:
18
rsbu_fns_reporting/models/__init__.py
Normal file
18
rsbu_fns_reporting/models/__init__.py
Normal 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
|
||||
103
rsbu_fns_reporting/models/rsbu_efs1_szvtd.py
Normal file
103
rsbu_fns_reporting/models/rsbu_efs1_szvtd.py
Normal 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
|
||||
345
rsbu_fns_reporting/models/rsbu_efs1_szvtd_xml.py
Normal file
345
rsbu_fns_reporting/models/rsbu_efs1_szvtd_xml.py
Normal 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)
|
||||
205
rsbu_fns_reporting/models/rsbu_enp_notification.py
Normal file
205
rsbu_fns_reporting/models/rsbu_enp_notification.py
Normal 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)
|
||||
175
rsbu_fns_reporting/models/rsbu_enp_xml.py
Normal file
175
rsbu_fns_reporting/models/rsbu_enp_xml.py
Normal 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',
|
||||
}
|
||||
18
rsbu_fns_reporting/models/rsbu_hr_event.py
Normal file
18
rsbu_fns_reporting/models/rsbu_hr_event.py
Normal 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 (СЗВ-ТД), в который включено мероприятие.')
|
||||
235
rsbu_fns_reporting/models/rsbu_ndfl_report.py
Normal file
235
rsbu_fns_reporting/models/rsbu_ndfl_report.py
Normal 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')
|
||||
227
rsbu_fns_reporting/models/rsbu_ndfl_xml.py
Normal file
227
rsbu_fns_reporting/models/rsbu_ndfl_xml.py
Normal 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',
|
||||
}
|
||||
145
rsbu_fns_reporting/models/rsbu_nds_declaration.py
Normal file
145
rsbu_fns_reporting/models/rsbu_nds_declaration.py
Normal 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
|
||||
357
rsbu_fns_reporting/models/rsbu_nds_xml.py
Normal file
357
rsbu_fns_reporting/models/rsbu_nds_xml.py
Normal 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',
|
||||
}
|
||||
116
rsbu_fns_reporting/models/rsbu_pers_info.py
Normal file
116
rsbu_fns_reporting/models/rsbu_pers_info.py
Normal 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='Признак аннулирования ранее поданных сведений (ПрАннулир).')
|
||||
194
rsbu_fns_reporting/models/rsbu_pers_info_xml.py
Normal file
194
rsbu_fns_reporting/models/rsbu_pers_info_xml.py
Normal 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',
|
||||
}
|
||||
245
rsbu_fns_reporting/models/rsbu_rsv_declaration.py
Normal file
245
rsbu_fns_reporting/models/rsbu_rsv_declaration.py
Normal 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')
|
||||
355
rsbu_fns_reporting/models/rsbu_rsv_xml.py
Normal file
355
rsbu_fns_reporting/models/rsbu_rsv_xml.py
Normal file
@@ -0,0 +1,355 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Генерация РСВ (расчёт по страховым взносам) в XML.
|
||||
|
||||
Формат ФНС NO_RASCHSV, версия 5.08, КНД 1151111. Кодировка windows-1251,
|
||||
кириллические имена элементов/атрибутов. Профиль: УСН + МСП, единый
|
||||
пониженный тариф. Заполняются: титул, Раздел 1 (ОбязПлатСВ) с подразделом
|
||||
единого тарифа (РасчСВ_ОПСОМС), Приложение 3.1 (ПравТариф13.2.427) и
|
||||
Раздел 3 (ПерсСвСтрахЛиц) по каждому сотруднику.
|
||||
|
||||
Суммы — decimal с 2 знаками; количества — целые. Валидация против XSD.
|
||||
"""
|
||||
import base64
|
||||
from datetime import date
|
||||
|
||||
from odoo import _, api, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
KND_RSV = '1151111'
|
||||
FORMAT_VERSION = '5.08'
|
||||
PLACE_AT_LOCATION = '214' # по месту нахождения российской организации (РСВ)
|
||||
|
||||
|
||||
class RsbuRsvDeclaration(models.Model):
|
||||
_inherit = 'rsbu.rsv.declaration'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _sum(value):
|
||||
"""Денежная сумма РСВ: decimal с 2 знаками (строкой)."""
|
||||
return '%.2f' % (value or 0.0)
|
||||
|
||||
@staticmethod
|
||||
def _int(value):
|
||||
return str(int(value or 0))
|
||||
|
||||
@staticmethod
|
||||
def _snils(raw):
|
||||
"""Нормализовать СНИЛС к формату NNN-NNN-NNN-NN (только цифры)."""
|
||||
digits = ''.join(ch for ch in (raw or '') if ch.isdigit())
|
||||
if len(digits) != 11:
|
||||
return raw or ''
|
||||
return '%s-%s-%s-%s' % (digits[0:3], digits[3:6], digits[6:9],
|
||||
digits[9:11])
|
||||
|
||||
def _rsv_xml_filename(self):
|
||||
"""Имя файла обмена: NO_RASCHSV_{КодНО}_{КодНО}_{ИНН}{КПП}_{дата}_{GUID}."""
|
||||
self.ensure_one()
|
||||
import uuid
|
||||
company = self.company_id
|
||||
code_no = company.rsbu_tax_office_code or '0000'
|
||||
inn = (company.partner_id.inn or '').strip()
|
||||
kpp = (company.partner_id.kpp or '').strip()
|
||||
today = date.today().strftime('%Y%m%d')
|
||||
guid = str(uuid.uuid4())
|
||||
return 'NO_RASCHSV_%s_%s_%s%s_%s_%s' % (
|
||||
code_no, code_no, inn, kpp, today, guid)
|
||||
|
||||
def _rsv_validate_requisites(self):
|
||||
self.ensure_one()
|
||||
company = self.company_id
|
||||
partner = company.partner_id
|
||||
errors = []
|
||||
if not partner.inn:
|
||||
errors.append(_('ИНН организации'))
|
||||
if not partner.kpp:
|
||||
errors.append(_('КПП организации'))
|
||||
if not company.name:
|
||||
errors.append(_('наименование организации'))
|
||||
if not company.rsbu_tax_office_code:
|
||||
errors.append(_('код налогового органа'))
|
||||
if not (self.oktmo or company.rsbu_oktmo):
|
||||
errors.append(_('ОКТМО'))
|
||||
# Персональные данные сотрудников раздела 3.
|
||||
for line in self.pers_line_ids:
|
||||
emp = line.employee_id
|
||||
if not emp.rsbu_snils:
|
||||
errors.append(_('СНИЛС сотрудника «%s»') % emp.name)
|
||||
if not emp.birthday:
|
||||
errors.append(_('дата рождения «%s»') % emp.name)
|
||||
if not emp.rsbu_sex:
|
||||
errors.append(_('пол «%s»') % emp.name)
|
||||
if not emp.rsbu_id_doc_number:
|
||||
errors.append(_('документ (ДУЛ) «%s»') % emp.name)
|
||||
if errors:
|
||||
raise UserError(_(
|
||||
'Не заполнены обязательные реквизиты для РСВ: %s. Укажите '
|
||||
'их в настройках компании и карточках сотрудников.')
|
||||
% ', '.join(errors[:15]))
|
||||
|
||||
def _rsv_build_xml(self, filename=None):
|
||||
"""Собрать XML РСВ (формат 5.08). Возвращает bytes."""
|
||||
self.ensure_one()
|
||||
try:
|
||||
from lxml import etree
|
||||
except ImportError:
|
||||
raise UserError(_('Для выгрузки XML требуется библиотека lxml.')) from None
|
||||
self._rsv_validate_requisites()
|
||||
if filename is None:
|
||||
filename = self._rsv_xml_filename()
|
||||
|
||||
company = self.company_id
|
||||
partner = company.partner_id
|
||||
oktmo = self.oktmo or company.rsbu_oktmo or ''
|
||||
from .rsbu_rsv_declaration import RSV_PERIOD_CODE
|
||||
period_code = RSV_PERIOD_CODE[int(self.quarter)]
|
||||
|
||||
E = etree.Element
|
||||
root = E('Файл', {
|
||||
'ИдФайл': filename,
|
||||
'ВерсПрог': 'RuOdoo',
|
||||
'ВерсФорм': FORMAT_VERSION,
|
||||
})
|
||||
doc = E('Документ', {
|
||||
'КНД': KND_RSV,
|
||||
'ДатаДок': date.today().strftime('%d.%m.%Y'),
|
||||
'НомКорр': '0',
|
||||
'Период': period_code,
|
||||
'ОтчетГод': str(self.year),
|
||||
'КодНО': company.rsbu_tax_office_code or '',
|
||||
'ПоМесту': PLACE_AT_LOCATION,
|
||||
})
|
||||
root.append(doc)
|
||||
|
||||
# СвНП + НПЮЛ.
|
||||
svnp = E('СвНП', {'СрЧисл': self._int(self.avg_headcount)})
|
||||
svnp.append(E('НПЮЛ', {
|
||||
'НаимОрг': company.name or '',
|
||||
'ИННЮЛ': (partner.inn or '').strip(),
|
||||
'КПП': (partner.kpp or '').strip(),
|
||||
}))
|
||||
doc.append(svnp)
|
||||
|
||||
# Подписант.
|
||||
pr_podp = '2' if company.rsbu_signatory_is_representative else '1'
|
||||
signer = E('Подписант', {'ПрПодп': pr_podp})
|
||||
signatory = company.rsbu_signatory_id
|
||||
if signatory:
|
||||
parts = (signatory.name or '').split()
|
||||
fio = {}
|
||||
if parts:
|
||||
fio['Фамилия'] = parts[0]
|
||||
if len(parts) > 1:
|
||||
fio['Имя'] = parts[1]
|
||||
if len(parts) > 2:
|
||||
fio['Отчество'] = parts[2]
|
||||
if fio:
|
||||
signer.append(E('ФИО', fio))
|
||||
doc.append(signer)
|
||||
|
||||
# РасчетСВ.
|
||||
raschet = E('РасчетСВ')
|
||||
doc.append(raschet)
|
||||
self._rsv_build_section1(etree, raschet, oktmo, company)
|
||||
self._rsv_build_pers(etree, raschet, company)
|
||||
|
||||
return etree.tostring(
|
||||
root, xml_declaration=True, encoding='windows-1251',
|
||||
pretty_print=True)
|
||||
|
||||
def _rsv_build_section1(self, etree, raschet, oktmo, company):
|
||||
"""Раздел 1: ОбязПлатСВ (уплата) + РасчСВ_ОПСОМС + Приложение 3.1."""
|
||||
E = etree.Element
|
||||
obyaz = E('ОбязПлатСВ', {'ТипПлат': '1', 'ОКТМО': oktmo})
|
||||
raschet.append(obyaz)
|
||||
# УплПерОПС — взносы к уплате по единому тарифу (период + 3 месяца).
|
||||
etree.SubElement(obyaz, 'УплПерОПС', {
|
||||
'КБК': company.rsbu_kbk_insurance or '',
|
||||
'СумСВУплПер': self._sum(self.contrib_period),
|
||||
'СумСВУпл1М': self._sum(self.contrib_month1),
|
||||
'СумСВУпл2М': self._sum(self.contrib_month2),
|
||||
'СумСВУпл3М': self._sum(self.contrib_month3),
|
||||
})
|
||||
# РасчСВ_ОПС_ОМС (расчёт по единому тарифу).
|
||||
rasch = E('РасчСВ_ОПС_ОМС', {
|
||||
'ТарифПлат': company.rsbu_payer_tariff_code or '01'})
|
||||
obyaz.append(rasch)
|
||||
opsoms = E('РасчСВ_ОПСОМС')
|
||||
rasch.append(opsoms)
|
||||
|
||||
# Помесячные значения последних 3 месяцев по показателям.
|
||||
Summary = self.env['rsbu.rsv.summary'].sudo()
|
||||
m1, m2, m3 = Summary._quarter_months(int(self.quarter))
|
||||
last3 = Summary.records_for(self.company_id, self.year, m1, m3)
|
||||
|
||||
def qty_node(tag, total, per_month):
|
||||
etree.SubElement(opsoms, tag, {
|
||||
'КолВсегоПер': self._int(total),
|
||||
'Кол1Посл3М': self._int(per_month[0]),
|
||||
'Кол2Посл3М': self._int(per_month[1]),
|
||||
'Кол3Посл3М': self._int(per_month[2]),
|
||||
})
|
||||
|
||||
def sum_node(tag, total, per_month):
|
||||
etree.SubElement(opsoms, tag, {
|
||||
'СумВсегоПер': self._sum(total),
|
||||
'Сум1Посл3М': self._sum(per_month[0]),
|
||||
'Сум2Посл3М': self._sum(per_month[1]),
|
||||
'Сум3Посл3М': self._sum(per_month[2]),
|
||||
})
|
||||
|
||||
def count_emp(month, cond=lambda r: True):
|
||||
recs = last3.filtered(lambda r: r.month == month and cond(r))
|
||||
return len(recs.mapped('employee_id'))
|
||||
|
||||
def msum(month, field):
|
||||
recs = last3.filtered(lambda r: r.month == month)
|
||||
return round(sum(recs.mapped(field)), 2)
|
||||
|
||||
insured = self.insured_count
|
||||
charged = self.charged_count
|
||||
# Количества (застрахованные / с начислениями / в пределах / сверх).
|
||||
qty_node('КолСтрахЛицВс', insured,
|
||||
[count_emp(m) for m in (m1, m2, m3)])
|
||||
qty_node('КолЛицНачСВВс', charged,
|
||||
[count_emp(m, lambda r: r.base > 0) for m in (m1, m2, m3)])
|
||||
qty_node('НеПревБазОПС',
|
||||
len(last3.filtered(lambda r: r.base_within_limit > 0)
|
||||
.mapped('employee_id')),
|
||||
[count_emp(m, lambda r: r.base_within_limit > 0)
|
||||
for m in (m1, m2, m3)])
|
||||
qty_node('ПревБазОПС',
|
||||
len(last3.filtered(lambda r: r.base_over_limit > 0)
|
||||
.mapped('employee_id')),
|
||||
[count_emp(m, lambda r: r.base_over_limit > 0)
|
||||
for m in (m1, m2, m3)])
|
||||
# Суммы.
|
||||
sum_node('ВыплНачислФЛ', self.payments_total,
|
||||
[msum(m, 'payments') for m in (m1, m2, m3)])
|
||||
sum_node('НеОбложенСВ', self.non_taxable_total,
|
||||
[msum(m, 'non_taxable') for m in (m1, m2, m3)])
|
||||
sum_node('РасхПринВыч', 0.0, [0.0, 0.0, 0.0])
|
||||
sum_node('БазНачислСВ', self.base_total,
|
||||
[msum(m, 'base') for m in (m1, m2, m3)])
|
||||
sum_node('БазНеПревышОПС', self.base_within_total,
|
||||
[msum(m, 'base_within_limit') for m in (m1, m2, m3)])
|
||||
sum_node('БазПревышОПС', self.base_over_total,
|
||||
[msum(m, 'base_over_limit') for m in (m1, m2, m3)])
|
||||
sum_node('НачислСВ', self.charged_total,
|
||||
[msum(m, 'ops') for m in (m1, m2, m3)])
|
||||
sum_node('НачислСВНеПрев', self.charged_within_total,
|
||||
[msum(m, 'ops_within') for m in (m1, m2, m3)])
|
||||
sum_node('НачислСВПрев', self.charged_over_total,
|
||||
[msum(m, 'ops_over') for m in (m1, m2, m3)])
|
||||
|
||||
# Приложение 3.1 (МСП, п.13.2 ст.427) — при льготном тарифе.
|
||||
# КодПлат: признак соответствия условиям ('1' — соблюдены).
|
||||
# Доходы — целые рубли (integer); доля — процент (decimal 2 знака).
|
||||
if company.rsbu_msp_enabled:
|
||||
if self.msp_income_total:
|
||||
share = round(self.msp_income_main
|
||||
/ self.msp_income_total * 100.0, 2)
|
||||
else:
|
||||
share = 0.0
|
||||
etree.SubElement(obyaz, 'ПравТариф13.2.427', {
|
||||
'КодПлат': '1',
|
||||
'Дох26.2Пер': self._int(self.msp_income_total),
|
||||
'ДохОснПер': self._int(self.msp_income_main),
|
||||
'ДолДох13.2.427Пер': '%.2f' % share,
|
||||
})
|
||||
|
||||
def _rsv_build_pers(self, etree, raschet, company):
|
||||
"""Раздел 3: персонифицированные сведения по сотрудникам."""
|
||||
E = etree.Element
|
||||
Summary = self.env['rsbu.rsv.summary'].sudo()
|
||||
m1, m2, m3 = Summary._quarter_months(int(self.quarter))
|
||||
months = [m1, m2, m3]
|
||||
for line in self.pers_line_ids:
|
||||
emp = line.employee_id
|
||||
pers = E('ПерсСвСтрахЛиц')
|
||||
raschet.append(pers)
|
||||
danfl = E('ДанФЛПолуч', {
|
||||
'СНИЛС': self._snils(emp.rsbu_snils),
|
||||
'ДатаРожд': emp.birthday.strftime('%d.%m.%Y')
|
||||
if emp.birthday else '',
|
||||
'Гражд': emp.rsbu_citizenship_code or '643',
|
||||
'Пол': emp.rsbu_sex or '1',
|
||||
'КодВидДок': emp.rsbu_id_doc_code or '21',
|
||||
'СерНомДок': emp.rsbu_id_doc_number or '',
|
||||
})
|
||||
if emp.rsbu_inn:
|
||||
danfl.set('ИННФЛ', emp.rsbu_inn.strip())
|
||||
pers.append(danfl)
|
||||
parts = (emp.name or '').split()
|
||||
fio = {}
|
||||
if parts:
|
||||
fio['Фамилия'] = parts[0]
|
||||
if len(parts) > 1:
|
||||
fio['Имя'] = parts[1]
|
||||
if len(parts) > 2:
|
||||
fio['Отчество'] = parts[2]
|
||||
etree.SubElement(danfl, 'ФИО', fio)
|
||||
|
||||
# Выплаты по ОПС по месяцам (СвВыплСВОПС → СвВыпл → СвВыплМК).
|
||||
pays = [line.pay_m1, line.pay_m2, line.pay_m3]
|
||||
bases = [line.base_m1, line.base_m2, line.base_m3]
|
||||
opses = [line.ops_m1, line.ops_m2, line.ops_m3]
|
||||
if any(pays):
|
||||
svvypl_ops = E('СвВыплСВОПС')
|
||||
pers.append(svvypl_ops)
|
||||
svvypl = E('СвВыпл')
|
||||
svvypl_ops.append(svvypl)
|
||||
for idx, month in enumerate(months):
|
||||
if not pays[idx] and not bases[idx]:
|
||||
continue
|
||||
# «Месяц» в разделе 3 — порядковый номер месяца в
|
||||
# квартале (1/2/3), а не календарный.
|
||||
etree.SubElement(svvypl, 'СвВыплМК', {
|
||||
'Месяц': str(idx + 1),
|
||||
'КодКатЛиц': line.category or 'НР',
|
||||
'СумВыпл': self._sum(pays[idx]),
|
||||
'ВыплОПС': self._sum(bases[idx]),
|
||||
'НачислСВ': self._sum(opses[idx]),
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _rsv_xsd_path(self):
|
||||
import os
|
||||
from odoo.modules import get_module_path
|
||||
return os.path.join(
|
||||
get_module_path('rsbu_fns_reporting'), 'data', 'schemas',
|
||||
'NO_RASCHSV_1_162_00_05_08_02.xsd')
|
||||
|
||||
def _rsv_validate_xml(self, xml_bytes):
|
||||
from lxml import etree
|
||||
schema = etree.XMLSchema(etree.parse(self._rsv_xsd_path()))
|
||||
doc = etree.fromstring(xml_bytes)
|
||||
ok = schema.validate(doc)
|
||||
errors = [e.message for e in schema.error_log] if not ok else []
|
||||
return ok, errors
|
||||
|
||||
def action_export_xml(self):
|
||||
"""Сформировать РСВ в XML, провалидировать и вернуть вложением."""
|
||||
self.ensure_one()
|
||||
self.action_compute()
|
||||
filename = self._rsv_xml_filename()
|
||||
xml_bytes = self._rsv_build_xml(filename=filename)
|
||||
ok, errors = self._rsv_validate_xml(xml_bytes)
|
||||
if not ok:
|
||||
raise UserError(_(
|
||||
'Сформированный РСВ не прошёл проверку по схеме ФНС:\n%s')
|
||||
% '\n'.join(errors[:10]))
|
||||
attachment = self.env['ir.attachment'].create({
|
||||
'name': filename + '.xml',
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(xml_bytes),
|
||||
'mimetype': 'application/xml',
|
||||
})
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '/web/content/%s?download=true' % attachment.id,
|
||||
'target': 'self',
|
||||
}
|
||||
294
rsbu_fns_reporting/models/rsbu_upd_docx.py
Normal file
294
rsbu_fns_reporting/models/rsbu_upd_docx.py
Normal 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)
|
||||
132
rsbu_fns_reporting/models/rsbu_upd_generation.py
Normal file
132
rsbu_fns_reporting/models/rsbu_upd_generation.py
Normal 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',
|
||||
}
|
||||
424
rsbu_fns_reporting/models/rsbu_upd_xml.py
Normal file
424
rsbu_fns_reporting/models/rsbu_upd_xml.py
Normal 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',
|
||||
}
|
||||
181
rsbu_fns_reporting/models/rsbu_usn_declaration.py
Normal file
181
rsbu_fns_reporting/models/rsbu_usn_declaration.py
Normal 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
|
||||
260
rsbu_fns_reporting/models/rsbu_usn_xml.py
Normal file
260
rsbu_fns_reporting/models/rsbu_usn_xml.py
Normal 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',
|
||||
}
|
||||
Reference in New Issue
Block a user