"""Генерация декларации по НДС в XML (КНД 1151001, формат 5.12). Комплект из трёх файлов: * корневая декларация (NO_NDS): реквизиты + раздел «НДС» (СумУплНП / СумУпл164) + ссылки на книги (КнигаПокуп / КнигаПрод); * раздел 9 (NO_NDS.9): книга продаж — строки КнПродСтр; * раздел 8 (NO_NDS.8): книга покупок — строки КнПокСтр. Суммы корневой декларации — в целых рублях; суммы книг (разделы 8/9) — с копейками (как в выгрузке 1С). Данные — из rsbu.vat.book.line, реквизиты — из настроек компании (rsbu_reporting_base). Валидация каждого файла по своей XSD 5.12. """ import base64 import uuid from datetime import date from odoo import _, api, models from odoo.exceptions import UserError KND_NDS = '1151001' NDS_FORMAT_VERSION = '5.12' # Расчётная доля НДС в сумме по ставочному режиму (для восстановления базы). _ADVANCE_VAT_FRACTION = {'vat_5': 5.0 / 105.0, 'vat_7': 7.0 / 107.0} NDS_PERIOD_YEAR = '34' # налоговый период (год); квартал — 21..24 PLACE_AT_LOCATION = '214' # по месту нахождения российской организации KBK_NDS_DEFAULT = '18210301000011000110' # Индексы разделов в имени файла и в атрибуте Документ/Индекс. SECTION_INDEX = {'purchases': '0000080', 'sales': '0000090'} class RsbuNdsDeclaration(models.Model): _inherit = 'rsbu.nds.declaration' # ------------------------------------------------------------------ # Имена файлов обмена # ------------------------------------------------------------------ def _nds_filename(self, prefix='NO_NDS'): """Имя файла обмена: _ИФНС_ИФНС_ИНН+КПП_GGGGMMDD_GUID. prefix: NO_NDS (корень) / NO_NDS.8 (покупки) / NO_NDS.9 (продажи). """ self.ensure_one() 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') return '%s_%s_%s_%s%s_%s_%s' % ( prefix, code_no, code_no, inn, kpp, today, uuid.uuid4()) # ------------------------------------------------------------------ # Форматирование # ------------------------------------------------------------------ @staticmethod def _rub(value): """Целые рубли строкой (корневая декларация).""" return str(int(round(value or 0.0))) @staticmethod def _kop(value): """Сумма с копейками строкой (книги — разделы 8/9).""" return '%.2f' % (value or 0.0) def _nds_validate_requisites(self): 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.rsbu_tax_office_code: errors.append(_('код налогового органа')) if not (self.oktmo or company.rsbu_oktmo): errors.append(_('ОКТМО')) if not (company.rsbu_signatory_id or getattr(company, 'chief_id', False)): errors.append(_('подписант')) if errors: raise UserError(_( 'Не заполнены реквизиты для декларации НДС: %s.') % ', '.join(errors)) # ------------------------------------------------------------------ # Раздел 9 (книга продаж) и раздел 8 (книга покупок) # ------------------------------------------------------------------ def _nds_build_book(self, book, filename): """Собрать XML раздела книги (8 покупки / 9 продажи). Возвращает bytes.""" self.ensure_one() from lxml import etree E = etree.Element company = self.company_id BookLine = self.env['rsbu.vat.book.line'].sudo() lines = BookLine.search([ ('company_id', '=', company.id), ('book', '=', book), ('date', '>=', self.date_from), ('date', '<=', self.date_to), ], order='date, id') root = E('Файл', { 'ИдФайл': filename, 'ВерсПрог': 'RuOdoo', 'ВерсФорм': NDS_FORMAT_VERSION, }) doc = E('Документ', { 'Индекс': SECTION_INDEX[book], 'НомКорр': '0', }) root.append(doc) if book == 'sales': total_base = round(sum(lines.mapped('base_amount')), 2) total_vat = round(sum(lines.mapped('vat_amount')), 2) kniga = E('КнигаПрод', { 'СтПродБезНДС5': self._kop(total_base), 'СумНДСВсКПр5': self._kop(total_vat), }) doc.append(kniga) for idx, line in enumerate(lines, start=1): self._nds_sales_row(etree, kniga, idx, line) else: # purchases total_vat = round(sum(lines.mapped('vat_amount')), 2) kniga = E('КнигаПокуп', {'СумНДСВсКПк': self._kop(total_vat)}) doc.append(kniga) for idx, line in enumerate(lines, start=1): self._nds_purchase_row(etree, kniga, idx, line) return etree.tostring( root, xml_declaration=True, encoding='windows-1251', pretty_print=True) def _partner_node(self, etree, tag, partner): """СвПокуп/СвПрод: СведЮЛ (ИНН 10) или СведИП (ИНН 12).""" node = etree.Element(tag) inn = (partner.inn or '').strip() if len(inn) == 12: etree.SubElement(node, 'СведИП', {'ИННФЛ': inn}) elif inn: attrs = {'ИННЮЛ': inn} if partner.kpp: attrs['КПП'] = partner.kpp etree.SubElement(node, 'СведЮЛ', attrs) return node def _nds_sales_row(self, etree, kniga, idx, line): """Строка книги продаж КнПродСтр.""" # База 5% (СтоимПродСФ5): из строки, иначе восстановить из НДС. fraction = _ADVANCE_VAT_FRACTION.get(self.company_id.rsbu_vat_mode, 0.0) base5 = line.base_amount if not base5 and fraction: base5 = round(line.vat_amount * (1.0 / fraction - 1.0), 2) gross = round(base5 + line.vat_amount, 2) row = etree.SubElement(kniga, 'КнПродСтр', { 'НомерПор': str(idx), 'НомСчФПрод': (line.invoice_id.name or str(idx)) if line.invoice_id else str(idx), 'ДатаСчФПрод': line.date.strftime('%d.%m.%Y'), 'СтоимПродСФ': self._kop(gross), 'СтоимПродСФ5': self._kop(base5), 'СумНДССФ5': self._kop(line.vat_amount), }) etree.SubElement(row, 'КодВидОпер').text = '01' if line.partner_id: row.append(self._partner_node(etree, 'СвПокуп', line.partner_id)) def _nds_purchase_row(self, etree, kniga, idx, line): """Строка книги покупок КнПокСтр. Книга покупок УСН+НДС — вычет авансового НДС при отгрузке. В rsbu.vat.book.line покупок заполнен только vat_amount; стоимость с НДС восстанавливаем из НДС по расчётной ставке режима (для 5% — НДС × 105/5). """ fraction = _ADVANCE_VAT_FRACTION.get(self.company_id.rsbu_vat_mode, 0.0) gross = line.base_amount + line.vat_amount if not line.base_amount and fraction: gross = round(line.vat_amount / fraction, 2) row = etree.SubElement(kniga, 'КнПокСтр', { 'НомерПор': str(idx), 'НомСчФПрод': (line.invoice_id.name or str(idx)) if line.invoice_id else str(idx), 'ДатаСчФПрод': line.date.strftime('%d.%m.%Y'), 'СтоимПокупВ': self._kop(gross), 'СумНДСВыч': self._kop(line.vat_amount), }) etree.SubElement(row, 'КодВидОпер').text = '22' # Продавец для книги покупок — сама организация (вычет ав. НДС). seller = self.company_id.partner_id row.append(self._partner_node(etree, 'СвПрод', seller)) # ------------------------------------------------------------------ # Корневая декларация # ------------------------------------------------------------------ def _nds_build_root(self, filename, name_pokup, name_prod): """Собрать корневую декларацию НДС. Возвращает bytes.""" self.ensure_one() from lxml import etree E = etree.Element company = self.company_id partner = company.partner_id oktmo = self.oktmo or company.rsbu_oktmo or '' kbk = self.kbk or company.rsbu_kbk_nds or KBK_NDS_DEFAULT root = E('Файл', { 'ИдФайл': filename, 'ВерсПрог': 'RuOdoo', 'ВерсФорм': NDS_FORMAT_VERSION, 'ПризнНал8-12': '1', 'ПризнНал8': '1', 'ПризнНал81': '0', 'ПризнНал9': '1', 'ПризнНал91': '0', 'ПризнНал10': '0', 'ПризнНал11': '0', 'ПризнНал12': '0', }) quarter = (self.date_to.month - 1) // 3 + 1 doc = E('Документ', { 'КНД': KND_NDS, 'ДатаДок': date.today().strftime('%d.%m.%Y'), 'Период': '2%s' % quarter, # 21/22/23/24 — кварталы 'ОтчетГод': str(self.date_to.year), 'КодНО': company.rsbu_tax_office_code or '', 'НомКорр': '0', 'ПоМесту': PLACE_AT_LOCATION, }) root.append(doc) svnp = E('СвНП') etree.SubElement(svnp, 'НПЮЛ', { 'НаимОрг': company.name or '', 'ИННЮЛ': (partner.inn or '').strip(), 'КПП': (partner.kpp or '').strip(), }) doc.append(svnp) signer = company.rsbu_signatory_id or getattr(company, 'chief_id', False) pr_podp = '2' if company.rsbu_signatory_is_representative else '1' podp = E('Подписант', {'ПрПодп': pr_podp}) if signer: names = (signer.name or '').split() etree.SubElement(podp, 'ФИО', { 'Фамилия': names[0] if names else (signer.name or '-'), 'Имя': names[1] if len(names) > 1 else '-', **({'Отчество': names[2]} if len(names) > 2 else {}), }) doc.append(podp) nds = E('НДС') doc.append(nds) # СумУплНП — к уплате по ОКТМО/КБК. etree.SubElement(nds, 'СумУплНП', { 'ОКТМО': oktmo, 'КБК': kbk, 'СумПУ_173.1': self._rub(self.vat_payable), 'ПризнСЗПК': '2', }) # СумУпл164 — расчёт: начислено (реализация 5% + авансы) − вычет. sumupl = E('СумУпл164', {'НалПУ164': self._rub(self.vat_payable)}) nds.append(sumupl) nalob = E('СумНалОб', { 'НалВосстОбщ': self._rub(self.vat_charged_total)}) sumupl.append(nalob) if self.vat_tax_realization: etree.SubElement(nalob, 'РеалТов5', { 'НалБаза': self._rub(self.vat_base_realization), 'СумНал': self._rub(self.vat_tax_realization), }) if self.vat_tax_advance: etree.SubElement(nalob, 'ОплПредПост', { 'НалБаза': self._rub(self.vat_base_advance), 'СумНал': self._rub(self.vat_tax_advance), }) etree.SubElement(sumupl, 'СумНалВыч', { 'НалИсчПрод': self._rub(self.vat_deduction), 'НалВычОбщ': self._rub(self.vat_deduction), }) # Ссылки на файлы разделов (книги). etree.SubElement(nds, 'КнигаПокуп', {'НаимКнПок': name_pokup}) etree.SubElement(nds, 'КнигаПрод', {'НаимКнПрод': name_prod}) return etree.tostring( root, xml_declaration=True, encoding='windows-1251', pretty_print=True) def _nds_build_all(self): """Собрать все три файла. Возвращает список (filename, xml_bytes).""" self.ensure_one() self._nds_validate_requisites() name_pokup = self._nds_filename('NO_NDS.8') name_prod = self._nds_filename('NO_NDS.9') name_root = self._nds_filename('NO_NDS') xml_pokup = self._nds_build_book('purchases', name_pokup) xml_prod = self._nds_build_book('sales', name_prod) xml_root = self._nds_build_root(name_root, name_pokup, name_prod) return [ (name_root, xml_root), (name_pokup, xml_pokup), (name_prod, xml_prod), ] # ------------------------------------------------------------------ # Валидация против XSD # ------------------------------------------------------------------ @api.model def _nds_xsd_path(self, kind): import os from odoo.modules import get_module_path fname = { 'root': 'NO_NDS_1_003_00_05_12_01.xsd', 'purchases': 'NO_NDS.8_1_003_01_05_12_01.xsd', 'sales': 'NO_NDS.9_1_003_03_05_12_01.xsd', }[kind] return os.path.join( get_module_path('rsbu_fns_reporting'), 'data', 'schemas', fname) def _nds_validate_xml(self, xml_bytes, kind): from lxml import etree schema = etree.XMLSchema(etree.parse(self._nds_xsd_path(kind))) ok = schema.validate(etree.fromstring(xml_bytes)) errors = [e.message for e in schema.error_log] if not ok else [] return ok, errors def action_export_xml(self): """Пересчитать, сформировать 3 XML, провалидировать, вернуть архивом. Свежесть: перед выгрузкой всегда выполняется action_compute. """ self.ensure_one() self.action_compute() files = self._nds_build_all() # Валидация каждого файла по своей XSD. kinds = ['root', 'purchases', 'sales'] for (fname, xml), kind in zip(files, kinds): ok, errors = self._nds_validate_xml(xml, kind) if not ok: raise UserError(_( 'Файл %s не прошёл проверку по схеме ФНС:\n%s') % (fname, '\n'.join(errors[:8]))) # Упаковать три файла в zip-вложение. import io import zipfile buf = io.BytesIO() with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: for fname, xml in files: zf.writestr(fname + '.xml', xml) attachment = self.env['ir.attachment'].create({ 'name': 'Декларация_НДС_%s.zip' % (self.date_to or ''), 'res_model': self._name, 'res_id': self.id, 'type': 'binary', 'datas': base64.b64encode(buf.getvalue()), 'mimetype': 'application/zip', }) return { 'type': 'ir.actions.act_url', 'url': '/web/content/%s?download=true' % attachment.id, 'target': 'self', }