358 lines
16 KiB
Python
358 lines
16 KiB
Python
"""Генерация декларации по НДС в 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',
|
||
}
|