Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC
This commit is contained in:
357
rsbu_fns_reporting/models/rsbu_nds_xml.py
Normal file
357
rsbu_fns_reporting/models/rsbu_nds_xml.py
Normal file
@@ -0,0 +1,357 @@
|
||||
"""Генерация декларации по НДС в 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',
|
||||
}
|
||||
Reference in New Issue
Block a user