Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC

This commit is contained in:
CI Publish Bot
2026-11-01 22:54:30 +00:00
commit 26250aa580
2492 changed files with 664277 additions and 0 deletions

View 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',
}