Files
public/rsbu_fns_reporting/models/rsbu_upd_xml.py

425 lines
21 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.
"""Генерация УПД (универсального передаточного документа) в XML.
Формат ФНС ON_NSCHFDOPPR (файл продавца, КНД 1115131, версия 5.03).
Источник — документ РСБУ-слоя «Счёт-фактура» (rsbu.invoice, отгрузочный).
Разделение источников (Вариант B):
* счёт-фактурная часть (номер/дата СФ, суммы, ставка и сумма НДС, итоги)
— из РСБУ-слоя (rsbu.invoice);
* товарные позиции (наименование, количество, ОКЕИ, цена, ГТИН/код) —
из связанного инвойса-источника (account.move, _source_invoice);
* реквизиты сторон (ИНН/КПП/ОКПО/адрес), подписант — из res.company /
res.partner (переиспользуются штатные поля l10n_ru).
Заменяет устаревший l10n_ru_upd_xml (формат 5.01 из account.move).
Кодировка windows-1251, кириллические имена элементов, валидация по XSD.
"""
import base64
from datetime import date, datetime
from odoo import _, api, models
from odoo.exceptions import UserError
KND_UPD = '1115131'
UPD_FORMAT_VERSION = '5.03'
# Отображение ставки НДС rsbu.invoice.vat_rate → значение НалСт (enum XSD).
VAT_RATE_LABEL = {
'vat_exempt': 'без НДС',
'vat_5': '5%',
'vat_7': '7%',
'vat_10': '10%',
'vat_20': '20%',
}
# Доля ставки НДС для расчёта суммы налога (НДС = база × доля).
VAT_RATE_PCT = {
'vat_exempt': 0.0,
'vat_5': 0.05,
'vat_7': 0.07,
'vat_10': 0.10,
'vat_20': 0.20,
}
class RsbuRealizationUpd(models.Model):
_inherit = 'rsbu.realization'
# ------------------------------------------------------------------
# Реквизиты СФ (из подчинённого СФ при наличии, иначе из реализации)
# ------------------------------------------------------------------
def _upd_schf_number(self):
"""Номер для СвСчФакт: из отгрузочного СФ, иначе из реализации."""
self.ensure_one()
return (self.invoice_id.name if self.invoice_id else self.name) or ''
def _upd_schf_date(self):
"""Дата для СвСчФакт: из отгрузочного СФ, иначе из реализации."""
self.ensure_one()
if self.invoice_id and self.invoice_id.date:
return self.invoice_id.date
return self.date or date.today()
def _upd_vat_rate_label(self):
"""Метка ставки НДС (НалСт) — из СФ при наличии, иначе «без НДС»."""
self.ensure_one()
rate = self.invoice_id.vat_rate if self.invoice_id else 'vat_exempt'
return VAT_RATE_LABEL.get(rate, 'без НДС')
# ------------------------------------------------------------------
# Реквизиты и вспомогательные значения
# ------------------------------------------------------------------
def _upd_filename(self):
"""Имя файла обмена УПД по Порядку (п.4):
ON_NSCHFDOPPR_A_О_GGGGMMDD_N1_N2_N3_N4_N5_N6_N7
где A — получатель «УИОЭДО+УИПол», О — отправитель «УИОЭДО+УИОтпр»;
УИОЭДО — код оператора ЭДО (3 симв, «000» без оператора); УИПол/
УИОтпр — идентификаторы участников в ЭДО (или GUID вне ЭДО); N1 —
36-символьный GUID (совпадает с ИдФайл); N2–N6 — флаги прослежива-
емости/маркировки (0); N7 — двузначный код (00).
"""
self.ensure_one()
import uuid
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())
a = '%s%s' % (operator, receiver) # А — получатель
o = '%s%s' % (operator, sender) # О — отправитель
today = date.today().strftime('%Y%m%d')
guid = str(uuid.uuid4()) # N1
# N2..N7 — признаки прослеживаемости/маркировки/спецрежимов (по
# умолчанию отсутствуют).
flags = '0_0_0_0_0_00'
return 'ON_NSCHFDOPPR_%s_%s_%s_%s_%s' % (a, o, today, guid, flags)
def _upd_validate(self):
"""Проверить наличие данных, обязательных для УПД."""
self.ensure_one()
errors = []
company = self.company_id
partner = company.partner_id
if not partner.inn:
errors.append(_('ИНН организации-продавца'))
if not self.partner_id:
errors.append(_('контрагент-покупатель'))
elif not self.partner_id.inn:
errors.append(_('ИНН покупателя'))
source = self._source_invoice()
if not source:
errors.append(_('инвойс-источник с товарными позициями'))
elif not source.invoice_line_ids:
errors.append(_('строки инвойса-источника'))
else:
for line in source.invoice_line_ids.filtered(
lambda l: l.display_type in (False, 'product')):
uom = line.product_uom_id
if uom and not getattr(uom, 'okei', False):
errors.append(
_('код ОКЕИ для единицы «%s»') % uom.name)
break
if not company.rsbu_signatory_id and not getattr(
company, 'chief_id', False):
errors.append(_('подписант (руководитель) организации'))
if errors:
raise UserError(_(
'Не удалось сформировать УПД. Не хватает данных:\n%s')
% '\n'.join('— %s' % e for e in errors))
@staticmethod
def _amt(value):
"""Денежная сумма УПД: строка с 2 знаками (СтТовБезНДС/СтТовУчНал)."""
return '%.2f' % (value or 0.0)
@staticmethod
def _qty(value):
"""Количество/цена: до 11 знаков дробной части, без хвостовых нулей."""
s = ('%.11f' % (value or 0.0)).rstrip('0').rstrip('.')
return s or '0'
def _upd_party_node(self, etree, tag, partner, okpo=True):
"""Узел стороны (СвПрод/СвПокуп/ГрузОтпр/ГрузПолуч) по res.partner.
ЮЛ (ИНН 10) → СвЮЛУч; ИП (ИНН 12) → СвИП. Адрес — упрощённый АдрИнф
(код страны 643, текстом). Реквизиты штатные (l10n_ru).
"""
attrs = {}
if okpo and getattr(partner, 'okpo', False):
attrs['ОКПО'] = partner.okpo
node = etree.Element(tag, attrs)
idsv = etree.SubElement(node, 'ИдСв')
inn = (partner.inn or '').strip()
if len(inn) == 12:
names = (partner.name or '').replace('ИП', '').split()
svip = etree.SubElement(idsv, 'СвИП', {
'ИННФЛ': inn, 'ИныеСвед': partner.name or ''})
etree.SubElement(svip, 'ФИО', {
'Фамилия': names[0] if names else partner.name or '',
'Имя': names[1] if len(names) > 1 else '-',
**({'Отчество': names[2]} if len(names) > 2 else {}),
})
else:
etree.SubElement(idsv, 'СвЮЛУч', {
'НаимОрг': partner.name or '',
'ИННЮЛ': inn,
**({'КПП': partner.kpp} if getattr(partner, 'kpp', False)
else {}),
})
# Адрес текстом (АдрИнф) — упрощённый, достаточно по XSD.
addr = etree.SubElement(node, 'Адрес')
parts = [partner.city or '', partner.street or '']
etree.SubElement(addr, 'АдрИнф', {
'КодСтр': '643',
'НаимСтран': 'Российская Федерация',
'АдрТекст': ', '.join(p for p in parts if p) or 'н/д',
})
return node
# ------------------------------------------------------------------
# Сборка XML УПД
# ------------------------------------------------------------------
def _upd_build_xml(self, filename=None):
"""Собрать XML УПД (формат 5.03) через lxml. Возвращает bytes."""
self.ensure_one()
try:
from lxml import etree
except ImportError:
raise UserError(_('Для выгрузки УПД требуется библиотека lxml.')) from None
self._upd_validate()
if filename is None:
filename = self._upd_filename()
company = self.company_id
seller = company.partner_id
buyer = self.partner_id
source = self._source_invoice()
now = datetime.now()
E = etree.Element
root = E('Файл', {
'ИдФайл': filename,
'ВерсФорм': UPD_FORMAT_VERSION,
'ВерсПрог': 'RuOdoo',
})
has_sf = bool(self.invoice_id)
if has_sf:
naim_dok_opr = ('Счет-фактура и документ об отгрузке товаров '
'(выполнении работ), передаче имущественных прав '
'(документ об оказании услуг)')
else:
naim_dok_opr = ('Документ об отгрузке товаров (выполнении работ), '
'передаче имущественных прав (документ об '
'оказании услуг)')
doc = E('Документ', {
'КНД': KND_UPD,
'Функция': 'СЧФДОП' if has_sf else 'ДОП',
'ПоФактХЖ': 'Документ об отгрузке товаров (выполнении работ), '
'передаче имущественных прав (документ об оказании '
'услуг)',
'НаимДокОпр': naim_dok_opr,
'ДатаИнфПр': now.strftime('%d.%m.%Y'),
'ВремИнфПр': now.strftime('%H.%M.%S'),
'НаимЭконСубСост': company.name or '',
})
root.append(doc)
# СвСчФакт: номер и дата — из подчинённого СФ при наличии, иначе из
# документа реализации (ветка «2»/ДОП, 03 §6.3). XSD требует СвСчФакт
# с НомерДок/ДатаДок независимо от Функции.
schf = E('СвСчФакт', {
'НомерДок': self._upd_schf_number(),
'ДатаДок': self._upd_schf_date().strftime('%d.%m.%Y'),
})
doc.append(schf)
# Продавец / покупатель.
schf.append(self._upd_party_node(etree, 'СвПрод', seller))
# Грузоотправитель (если задан и отличается от продавца) / «он же».
shipper = self.upd_shipper_id
gruzot = E('ГрузОт')
if shipper and shipper != seller:
gruzot.append(self._upd_party_node(etree, 'ГрузОтпр', shipper))
else:
etree.SubElement(gruzot, 'ОнЖе').text = 'он же'
doc_has_shipment = any(
(l.product_id.type != 'service')
for l in source.invoice_line_ids) if source else False
if doc_has_shipment:
schf.append(gruzot)
consignee = self.upd_consignee_id or buyer
schf.append(self._upd_party_node(
etree, 'ГрузПолуч', consignee))
schf.append(self._upd_party_node(etree, 'СвПокуп', buyer))
# Денежный измеритель — рубли.
etree.SubElement(schf, 'ДенИзм', {
'КодОКВ': '643', 'НаимОКВ': 'Российский рубль'})
# ТаблСчФакт: позиции из инвойса-источника, финансы из РСБУ/строки.
tabl = E('ТаблСчФакт')
doc.append(tabl)
sf_vat_rate = self.invoice_id.vat_rate if self.invoice_id \
else 'vat_exempt'
rate_label = VAT_RATE_LABEL.get(sf_vat_rate, 'без НДС')
# Метка ставки — из РСБУ (vat_rate). Суммы НДС берутся ФАКТИЧЕСКИЕ
# из строки инвойса-источника (price_total − price_subtotal): это
# корректно и для ставки «в том числе» (расчётная 5/105), где налог
# выделяется из цены, а не начисляется сверху. Fallback (нет налогов
# в источнике) — пересчёт по ставке РСБУ.
vat_pct = VAT_RATE_PCT.get(sf_vat_rate, 0.0)
total_wo_vat = 0.0
total_vat = 0.0
total_with_vat = 0.0
lines = source.invoice_line_ids.filtered(
lambda l: l.display_type in (False, 'product')) if source \
else self.env['account.move.line']
for idx, line in enumerate(lines, start=1):
# База (без НДС) — стоимость позиции из инвойса-источника.
subtotal = line.price_subtotal
if line.tax_ids:
line_vat = round((line.price_total or 0.0) - subtotal, 2)
total = round(line.price_total or 0.0, 2)
else:
line_vat = round(subtotal * vat_pct, 2)
total = round(subtotal + line_vat, 2)
total_wo_vat += subtotal
total_vat += line_vat
total_with_vat += total
uom = line.product_uom_id
sved = E('СведТов', {
'НомСтр': str(idx),
'НаимТов': line.name or (line.product_id.name or 'Товар'),
'НаимЕдИзм': uom.name or '',
'КолТов': self._qty(line.quantity),
'ЦенаТов': self._qty(
subtotal / line.quantity if line.quantity else 0.0),
'СтТовБезНДС': self._amt(subtotal),
'НалСт': rate_label,
'СтТовУчНал': self._amt(total),
})
if uom and getattr(uom, 'okei', False):
sved.set('ОКЕИ_Тов', uom.okei)
# Доп. сведения товара (код/ГТИН) — из product.
product = line.product_id
dop_attrs = {}
if product.default_code:
dop_attrs['КодТов'] = product.default_code
if product.barcode:
dop_attrs['ГТИН'] = ('0' + product.barcode)[-14:]
if dop_attrs:
etree.SubElement(sved, 'ДопСведТов', dop_attrs)
# Сумма НДС по строке.
sumnal = etree.SubElement(sved, 'СумНал')
if line_vat:
etree.SubElement(sumnal, 'СумНал').text = self._amt(line_vat)
else:
etree.SubElement(sumnal, 'БезНДС').text = 'без НДС'
tabl.append(sved)
vsego = E('ВсегоОпл', {
'СтТовБезНДСВсего': self._amt(total_wo_vat),
'СтТовУчНалВсего': self._amt(total_with_vat),
})
sumnal_all = etree.SubElement(vsego, 'СумНалВсего')
if round(total_vat, 2):
etree.SubElement(sumnal_all, 'СумНал').text = self._amt(total_vat)
else:
etree.SubElement(sumnal_all, 'БезНДС').text = 'без НДС'
tabl.append(vsego)
# СвПродПер: передача, ответственное лицо (кладовщик), транспорт.
svprodper = E('СвПродПер')
doc.append(svprodper)
svper = E('СвПер', {
'СодОпер': self.upd_operation_content or 'Товары/услуги переданы',
'ДатаПер': (self.date or date.today()).strftime('%d.%m.%Y'),
})
svprodper.append(svper)
# Основание передачи.
etree.SubElement(svper, 'ОснПер', {
'РеквНаимДок': 'Договор',
'РеквНомерДок': self.upd_basis or (self.name or ''),
'РеквДатаДок': (self.date or date.today()).strftime('%d.%m.%Y'),
})
transferer = self.upd_transferred_by_id
if transferer:
svlic = etree.SubElement(svper, 'СвЛицПер')
names = (transferer.name or '').split()
rab = etree.SubElement(svlic, 'РабОргПрод', {
'Должность': (transferer.partner_id.function or 'Кладовщик'),
})
etree.SubElement(rab, 'ФИО', {
'Фамилия': names[0] if names else (transferer.name or '-'),
'Имя': names[1] if len(names) > 1 else '-',
**({'Отчество': names[2]} if len(names) > 2 else {}),
})
if self.upd_transport:
etree.SubElement(svper, 'Тран', {'СвТран': self.upd_transport})
# Подписант (руководитель организации-продавца).
signer = company.rsbu_signatory_id or getattr(company, 'chief_id',
False)
podp = E('Подписант', {
'СпосПодтПолном': '1',
**({'Должн': signer.partner_id.function}
if signer and signer.partner_id.function else {}),
})
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)
return etree.tostring(
root, xml_declaration=True, encoding='windows-1251',
pretty_print=True)
# ------------------------------------------------------------------
# Валидация против XSD и выгрузка
# ------------------------------------------------------------------
@api.model
def _upd_xsd_path(self):
import os
from odoo.modules import get_module_path
return os.path.join(
get_module_path('rsbu_fns_reporting'), 'data', 'schemas',
'ON_NSCHFDOPPR_1_997_01_05_03_02.xsd')
def _upd_validate_xml(self, xml_bytes):
"""Валидация УПД против XSD. Возвращает (ok, errors)."""
from lxml import etree
schema = etree.XMLSchema(etree.parse(self._upd_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_upd_xml(self):
"""Сформировать УПД в XML, провалидировать и вернуть вложением."""
self.ensure_one()
filename = self._upd_filename()
xml_bytes = self._upd_build_xml(filename=filename)
ok, errors = self._upd_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',
}