Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC
This commit is contained in:
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