261 lines
12 KiB
Python
261 lines
12 KiB
Python
"""Генерация электронной декларации УСН в 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',
|
||
}
|