259 lines
11 KiB
Python
259 lines
11 KiB
Python
# -*- 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',
|
||
}
|