Files
public/rsbu_fns_reporting/models/rsbu_usn_xml.py

261 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Генерация электронной декларации УСН в 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',
}