Files
public/rsbu_fns_reporting/models/rsbu_nds_xml.py

358 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Генерация декларации по НДС в 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'):
"""Имя файла обмена: <prefix>_ИФНС_ИФНС_ИНН+КПП_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',
}