"""Генерация УПД (универсального передаточного документа) в 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', }