Files
public/ruodoo_rsbu_act/models/ruodoo_rsbu_act_xml.py

259 lines
11 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.
# -*- coding: utf-8 -*-
"""Электронный акт сверки в XML — формат обмена ФНС.
Приказ ФНС России от 13.05.2022 № ЕД-7-26/405@, КНД 1110332, версия 5.01,
схема ON_AKTSVEROTP (файл отправителя). Кодировка windows-1251,
кириллические имена элементов (латинских namespace нет — используется lxml,
как для прочих форм ФНС). Структура:
Файл(ИдФайл, ВерсФорм=5.01, ВерсПрог)
└ Документ(КНД, ДатаИнфОтпр, ВрИнфОтпр, ДатаНачПер, ДатаОкПер, НомерАкт)
├ СвДокКрАкт(КодОКВ) → СвОтпр / СвПол (УчастникТип)
├ ТаблАкт(сальдо нач/оборот/кон Деб/Кр)
│ └ ДогСв(договор + сальдо) → ДокСв(документ) → СвОпер(операция)
└ Подписант(ФИО, Должн)
"""
import base64
import os
import uuid
from datetime import date, datetime
from odoo import _, api, models
from odoo.exceptions import UserError
from odoo.modules import get_module_path
KND_ACT = '1110332'
ACT_FORMAT_VERSION = '5.01'
class RuodooRsbuActXml(models.Model):
_inherit = 'ruodoo.rsbu.act'
# ------------------------------------------------------------------
@staticmethod
def _d(value):
"""Дата в формате ДД.ММ.ГГГГ (как требует XSD, ДатаТип)."""
return value.strftime('%d.%m.%Y') if value else ''
@staticmethod
def _amt(value):
"""Сумма: decimal с 2 знаками (totalDigits 15, fractionDigits 2)."""
return '%.2f' % round(value or 0.0, 2)
def _act_filename(self):
self.ensure_one()
company = self.company_id
operator = (company.rsbu_edo_operator_code or '000').strip()
sender = (company.rsbu_edo_participant_id or '').strip() \
or str(uuid.uuid4())
receiver = (self.partner_id.rsbu_edo_participant_id or '').strip() \
or str(uuid.uuid4())
today = date.today().strftime('%Y%m%d')
guid = str(uuid.uuid4())
return 'ON_AKTSVEROTP_%s%s_%s%s_%s_%s' % (
operator, receiver, operator, sender, today, guid)
def _party_node(self, etree, tag, partner, company_name=None):
"""Узел участника (СвОтпр/СвПол) по res.partner.
ЮЛ (ИНН 10) → СвЮЛУч (НаимОрг, ИННЮЛ, КПП, ОГРН — все обязательны);
ИП (ИНН 12) → СвИП (ФИО, ИННФЛ, ОГРНИП); иначе → СвФЛ.
"""
attrs = {}
if getattr(partner, 'okpo', False):
attrs['ОКПО'] = partner.okpo
node = etree.Element(tag, attrs)
idsv = etree.SubElement(node, 'ИдСв')
inn = (partner.inn or '').strip()
name = company_name or partner.name or ''
if len(inn) == 12:
svip = etree.SubElement(idsv, 'СвИП', {
'ИННФЛ': inn,
'ОГРНИП': (getattr(partner, 'ogrnip', '') or
getattr(partner, 'ogrn', '') or '').strip(),
})
self._fio_node(etree, svip, partner.name or name)
elif len(inn) == 10:
etree.SubElement(idsv, 'СвЮЛУч', {
'НаимОрг': name,
'ИННЮЛ': inn,
'КПП': (getattr(partner, 'kpp', '') or '').strip(),
'ОГРН': (getattr(partner, 'ogrn', '') or '').strip(),
})
else:
svfl = etree.SubElement(idsv, 'СвФЛ', {'ИННФЛ': inn})
self._fio_node(etree, svfl, partner.name or name)
# Контакт — обязательный элемент (атрибуты опциональны).
contact = {}
if getattr(partner, 'phone', False):
contact['Тлф'] = partner.phone
if getattr(partner, 'email', False):
contact['ЭлПочта'] = partner.email
etree.SubElement(node, 'Контакт', contact)
return node
@staticmethod
def _fio_node(etree, parent, full_name):
parts = (full_name or '').split()
attrs = {
'Фамилия': parts[0] if parts else (full_name or '-'),
'Имя': parts[1] if len(parts) > 1 else '-',
}
if len(parts) > 2:
attrs['Отчество'] = ' '.join(parts[2:])
return etree.SubElement(parent, 'ФИО', attrs)
# ------------------------------------------------------------------
def _act_build_xml(self, filename=None):
"""Собрать XML акта сверки (ON_AKTSVEROTP). Возвращает bytes."""
self.ensure_one()
try:
from lxml import etree
except ImportError:
raise UserError(_('Для выгрузки акта требуется библиотека lxml.')) \
from None
self._check_requisites()
if not self.line_ids:
raise UserError(_(
'Электронный акт сверки требует хотя бы одной операции за '
'период. Нажмите «Рассчитать».'))
if filename is None:
filename = self._act_filename()
company = self.company_id
seller = company.partner_id
buyer = self.partner_id
now = datetime.now()
E = etree.Element
root = E('Файл', {
'ИдФайл': filename,
'ВерсФорм': ACT_FORMAT_VERSION,
'ВерсПрог': 'RuOdoo',
})
doc = E('Документ', {
'КНД': KND_ACT,
'ДатаИнфОтпр': self._d(date.today()),
'ВрИнфОтпр': now.strftime('%H:%M:%S'),
'ДатаНачПер': self._d(self.date_from),
'ДатаОкПер': self._d(self.date_to),
'НомерАкт': self.name or '/',
})
root.append(doc)
# Стороны.
svdok = E('СвДокКрАкт', {'КодОКВ': '643'})
svdok.append(self._party_node(etree, 'СвОтпр', seller,
company_name=company.name))
svdok.append(self._party_node(etree, 'СвПол', buyer))
doc.append(svdok)
# Таблица акта: один договорный блок (все операции периода),
# сальдо/обороты на уровне ТаблАкт и ДогСв совпадают.
totals = {
'СальдоНачДеб': self._amt(self.opening_debit),
'СальдоНачКр': self._amt(self.opening_credit),
'ОборотДеб': self._amt(self.turnover_debit),
'ОборотКр': self._amt(self.turnover_credit),
'СальдоКонДеб': self._amt(self.closing_debit),
'СальдоКонКр': self._amt(self.closing_credit),
}
tabl = E('ТаблАкт', totals)
doc.append(tabl)
dogsv_attrs = dict(totals)
# Договор — из первой операции с договором либо «Основной договор».
contract = self.line_ids.mapped('contract_id')[:1]
dogsv_attrs['ОписТипДог'] = 'Договор'
if contract:
dogsv_attrs['НомДог'] = contract.display_name[:50]
dogsv = etree.SubElement(tabl, 'ДогСв', dogsv_attrs)
# Документы и операции. Группируем операции по документу (проводке).
by_doc = {}
order = []
for line in self.line_ids:
key = line.move_id.id or 0
if key not in by_doc:
by_doc[key] = []
order.append(key)
by_doc[key].append(line)
for key in order:
group = by_doc[key]
first = group[0]
doksv = etree.SubElement(dogsv, 'ДокСв', {
'НаимДок': (first.doc_name or 'Операция')[:100],
'НомДок': (first.doc_number or '-')[:50],
'ДатаДок': self._d(first.doc_date or first.date),
})
for idx, line in enumerate(group, start=1):
oper = {
'НомСтр': str(idx),
'ДатаОпер': self._d(line.date),
'НаимОпер': (line.name or first.doc_name
or 'Операция')[:500],
}
if line.debit:
oper['СумДебет'] = self._amt(line.debit)
if line.credit:
oper['СумКредит'] = self._amt(line.credit)
etree.SubElement(doksv, 'СвОпер', oper)
# Подписант.
signer = company.rsbu_signatory_id or getattr(
company, 'chief_id', False)
podp = E('Подписант', {'СтатПодп': '1'})
if signer and signer.partner_id.function:
podp.set('Должн', signer.partner_id.function)
elif getattr(company, 'rsbu_signatory_title', False):
podp.set('Должн', company.rsbu_signatory_title)
self._fio_node(etree, podp,
signer.partner_id.name if signer else (seller.name or ''))
doc.append(podp)
return etree.tostring(
root, xml_declaration=True, encoding='windows-1251',
pretty_print=True)
# ------------------------------------------------------------------
@api.model
def _act_xsd_path(self):
return os.path.join(
get_module_path('ruodoo_rsbu_act'), 'data', 'schemas',
'ON_AKTSVEROTP_1_972_01_05_01_01.xsd')
def _act_validate_xml(self, xml_bytes):
"""Проверить XML по XSD. Возвращает (ok, [ошибки])."""
from lxml import etree
schema = etree.XMLSchema(etree.parse(self._act_xsd_path()))
doc = etree.fromstring(xml_bytes)
ok = schema.validate(doc)
errors = [e.message for e in schema.error_log] if not ok else []
return ok, errors
def action_export_xml(self):
"""Сформировать акт сверки в XML, провалидировать, вернуть вложением."""
self.ensure_one()
if self.state == 'draft':
self.action_compute()
filename = self._act_filename()
xml_bytes = self._act_build_xml(filename=filename)
ok, errors = self._act_validate_xml(xml_bytes)
if not ok:
raise UserError(_(
'Сформированный акт сверки не прошёл проверку по схеме '
'ФНС:\n%s') % '\n'.join(errors[:10]))
attachment = self.env['ir.attachment'].create({
'name': filename + '.xml',
'res_model': self._name,
'res_id': self.id,
'type': 'binary',
'datas': base64.b64encode(xml_bytes),
'mimetype': 'application/xml',
})
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%s?download=true' % attachment.id,
'target': 'self',
}