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