Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC

This commit is contained in:
CI Publish Bot
2026-11-01 22:54:30 +00:00
commit 26250aa580
2492 changed files with 664277 additions and 0 deletions

View File

@@ -0,0 +1,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',
}