264 lines
13 KiB
Python
264 lines
13 KiB
Python
from odoo import _, api, fields, models
|
||
from odoo.exceptions import UserError
|
||
|
||
# Процент ставки налога строки → значение vat_rate счёта-фактуры.
|
||
_PCT_TO_VAT_RATE = [
|
||
(20.0, 'vat_20'),
|
||
(10.0, 'vat_10'),
|
||
(7.0, 'vat_7'),
|
||
(5.0, 'vat_5'),
|
||
]
|
||
|
||
|
||
class RsbuRealization(models.Model):
|
||
"""РСБУ — Документ реализации.
|
||
|
||
Первичный документ отгрузки (эволюция отгрузочного счёта-фактуры).
|
||
Создаётся для ЛЮБОЙ отгрузки — облагаемой НДС и «Без НДС». Несёт
|
||
передаточную часть УПД (upd_*) и является источником печати и XML УПД.
|
||
При наличии НДС порождает подчинённый отгрузочный счёт-фактуру
|
||
(rsbu.invoice, invoice_id).
|
||
"""
|
||
_name = 'rsbu.realization'
|
||
_description = 'РСБУ — Документ реализации'
|
||
_order = 'date desc, name desc, id desc'
|
||
_check_company_auto = True
|
||
|
||
name = fields.Char(string='Номер документа реализации', default='/',
|
||
copy=False, index=True)
|
||
date = fields.Date(string='Дата реализации', required=True, index=True,
|
||
default=fields.Date.context_today)
|
||
partner_id = fields.Many2one('res.partner', string='Контрагент',
|
||
required=True)
|
||
company_id = fields.Many2one(
|
||
'res.company', string='Компания', required=True, index=True,
|
||
default=lambda self: self.env.company)
|
||
currency_id = fields.Many2one(
|
||
'res.currency', string='Валюта', compute='_compute_currency_id',
|
||
store=True)
|
||
|
||
# Связь с источником — инвойс Odoo (как у отгрузочного СФ).
|
||
source_model = fields.Char(string='Модель источника', copy=False)
|
||
source_res_id = fields.Integer(string='ID источника', copy=False)
|
||
|
||
state = fields.Selection(
|
||
selection=[('draft', 'Черновик'), ('confirmed', 'Подтверждён'),
|
||
('cancelled', 'Отменён')],
|
||
string='Состояние', default='draft', copy=False)
|
||
|
||
# --- Передаточная часть УПД (перенос с rsbu.invoice) ---
|
||
upd_shipper_id = fields.Many2one(
|
||
'res.partner', string='Грузоотправитель',
|
||
help='Грузоотправитель для УПД (ГрузОт/ГрузОтпр). Если не задан — '
|
||
'совпадает с продавцом («он же»).')
|
||
upd_consignee_id = fields.Many2one(
|
||
'res.partner', string='Грузополучатель',
|
||
help='Грузополучатель для УПД (ГрузПолуч). Если не задан — '
|
||
'совпадает с покупателем.')
|
||
upd_transferred_by_id = fields.Many2one(
|
||
'res.users', string='Передал (кладовщик)',
|
||
help='Лицо, ответственное за передачу товаров/услуг со стороны '
|
||
'продавца (СвПродПер/СвЛицПер/РабОргПрод в УПД).')
|
||
upd_received_by_id = fields.Many2one(
|
||
'res.users', string='Принял груз',
|
||
help='Лицо, принявшее груз со стороны покупателя. Отражается в '
|
||
'документе о приёмке (файл покупателя ON_NSCHFDOPPOK).')
|
||
upd_transport = fields.Char(
|
||
string='Данные о транспортировке',
|
||
help='Сведения о транспортировке и грузе (Тран/СвТран в УПД).')
|
||
upd_basis = fields.Char(
|
||
string='Основание передачи',
|
||
help='Основание (договор, накладная и т.п.) — ОснПер в УПД. Если '
|
||
'не задано — берётся договор/номер документа-источника.')
|
||
upd_operation_content = fields.Char(
|
||
string='Содержание операции', default='Товары/услуги переданы',
|
||
help='Содержание операции передачи (СвПер/СодОпер в УПД).')
|
||
|
||
# Связь 1 — 0..1 с отгрузочным СФ (порождённым при наличии НДС).
|
||
invoice_id = fields.Many2one(
|
||
'rsbu.invoice', string='Счёт-фактура (при НДС)', copy=False,
|
||
ondelete='set null')
|
||
|
||
# Налоговая сводка строк (питает суммовые итоги УПД).
|
||
amount_untaxed = fields.Monetary(string='База (без НДС)',
|
||
currency_field='currency_id')
|
||
amount_tax = fields.Monetary(string='НДС всего',
|
||
currency_field='currency_id')
|
||
|
||
upd_function = fields.Selection(
|
||
selection=[('1', 'СЧФДОП'), ('2', 'ДОП')],
|
||
string='Функция УПД', compute='_compute_upd_function')
|
||
|
||
@api.depends('company_id')
|
||
def _compute_currency_id(self):
|
||
for rec in self:
|
||
rec.currency_id = rec.company_id.currency_id
|
||
|
||
@api.depends('invoice_id')
|
||
def _compute_upd_function(self):
|
||
for rec in self:
|
||
rec.upd_function = '1' if rec.invoice_id else '2'
|
||
|
||
@api.model_create_multi
|
||
def create(self, vals_list):
|
||
for vals in vals_list:
|
||
if vals.get('name', '/') == '/':
|
||
seq = self.env['ir.sequence'].with_company(
|
||
vals.get('company_id') or self.env.company.id)
|
||
vals['name'] = seq.next_by_code('rsbu.realization') or '/'
|
||
return super().create(vals_list)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Связь с инвойсом-источником (для товарных позиций УПД, Вариант B)
|
||
# ------------------------------------------------------------------
|
||
def _source_invoice(self):
|
||
"""Вернуть документ-источник (account.move), если задан.
|
||
|
||
Товарные позиции УПД (наименование/кол-во/ОКЕИ/ГТИН) берутся из
|
||
инвойса Odoo, а финансовые показатели — из РСБУ-слоя (Вариант B).
|
||
"""
|
||
self.ensure_one()
|
||
if self.source_model == 'account.move' and self.source_res_id:
|
||
move = self.env['account.move'].browse(self.source_res_id)
|
||
return move if move.exists() else self.env['account.move']
|
||
return self.env['account.move']
|
||
|
||
# ------------------------------------------------------------------
|
||
# Наличие НДС, выбор ставки, порождение отгрузочного СФ
|
||
# ------------------------------------------------------------------
|
||
@api.model
|
||
def _has_vat(self, move):
|
||
"""True ⇔ в документе есть ФАКТИЧЕСКИЙ выходной НДС.
|
||
|
||
Единственный источник истины — вычисленная compute_all сумма налога
|
||
по строкам источника. Устойчиво к price_include (спецставки УСН
|
||
«в том числе» 5/7% опознаются как облагаемые). НЕ использовать
|
||
price_total != price_subtotal и НЕ кредит vat_output как детектор.
|
||
"""
|
||
for line in move.invoice_line_ids.filtered(
|
||
lambda l: l.display_type in (False, 'product')):
|
||
if not line.tax_ids:
|
||
continue
|
||
res = line.tax_ids.compute_all(
|
||
line.price_unit, quantity=line.quantity,
|
||
currency=line.currency_id, product=line.product_id,
|
||
partner=line.partner_id)
|
||
if any(t.get('amount') for t in res['taxes']):
|
||
return True
|
||
return False
|
||
|
||
@api.model
|
||
def _upd_vat_rate(self, move):
|
||
"""Ставка vat_rate СФ по ФАКТИЧЕСКИМ налогам строк источника.
|
||
|
||
Отображает процент налога на селектор rsbu.invoice.vat_rate. При
|
||
смешанных ставках берётся максимальная ненулевая (консервативно для
|
||
отчётности, Q7). Нет ненулевых налогов → vat_exempt.
|
||
"""
|
||
percents = []
|
||
for line in move.invoice_line_ids.filtered(
|
||
lambda l: l.display_type in (False, 'product')):
|
||
for tax in line.tax_ids:
|
||
if tax.amount:
|
||
percents.append(tax.amount)
|
||
if not percents:
|
||
return 'vat_exempt'
|
||
top = max(percents)
|
||
for pct, rate in _PCT_TO_VAT_RATE:
|
||
if abs(top - pct) < 0.01:
|
||
return rate
|
||
return 'vat_exempt'
|
||
|
||
def _upd_advance_offset(self):
|
||
"""Данные зачёта аванса из РСБУ-проводки реализации.
|
||
|
||
Возвращает (origin_move, offset_amount): проводка-аванс, зачтённая
|
||
этой отгрузкой (первое основание), и общая сумма зачёта. Проводку
|
||
читаем через источник — у rsbu.realization нет rsbu_move_id (Q6).
|
||
"""
|
||
self.ensure_one()
|
||
move = self._source_invoice().rsbu_move_id
|
||
if not move:
|
||
return self.env['rsbu.move'], 0.0
|
||
advance_acc = self.env['rsbu.invoice'].sudo(
|
||
)._role_account_for_company('advance_received', self.company_id)
|
||
if not advance_acc:
|
||
return self.env['rsbu.move'], 0.0
|
||
offset_lines = move.line_ids.filtered(
|
||
lambda l: l.account_id == advance_acc and l.debit > 0
|
||
and l.origin_move_id)
|
||
origin = offset_lines[:1].origin_move_id
|
||
total = sum(offset_lines.mapped('debit'))
|
||
return origin, total
|
||
|
||
def _upd_vat_amount(self):
|
||
"""Сумма НДС реализации из РСБУ-проводки (Кт счёта vat_output).
|
||
|
||
Фолбэк — amount_tax реализации, если проводки нет. vat_output —
|
||
ИСТОЧНИК СУММЫ, не детектор наличия НДС (детектор — _has_vat).
|
||
"""
|
||
self.ensure_one()
|
||
move = self._source_invoice().rsbu_move_id
|
||
vat_accounts = self.env['rsbu.vat.book.line'].sudo(
|
||
)._vat_output_accounts(self.company_id)
|
||
if move and vat_accounts:
|
||
credit = sum(move.line_ids.filtered(
|
||
lambda l: l.account_id in vat_accounts and l.credit > 0
|
||
).mapped('credit'))
|
||
if credit:
|
||
return credit
|
||
return abs(self.amount_tax)
|
||
|
||
def _create_shipment_invoice(self):
|
||
"""Создать подчинённый отгрузочный СФ (rsbu.invoice, shipment).
|
||
|
||
Вызывается при наличии НДС (_has_vat). Инвариант INV-1: у реализации
|
||
не более одного активного отгрузочного СФ.
|
||
"""
|
||
self.ensure_one()
|
||
if self.invoice_id and self.invoice_id.state != 'cancelled':
|
||
raise UserError(_(
|
||
'По этому документу реализации уже выписан счёт-фактура: '
|
||
'%s.') % self.invoice_id.name)
|
||
move = self._source_invoice()
|
||
origin_move, _offset = self._upd_advance_offset()
|
||
vals = {
|
||
'invoice_type': 'shipment',
|
||
'partner_id': self.partner_id.id,
|
||
'company_id': self.company_id.id,
|
||
'date': self.date or fields.Date.context_today(self),
|
||
'vat_rate': self._upd_vat_rate(move) if move else 'vat_exempt',
|
||
'base_amount': abs(self.amount_untaxed),
|
||
'vat_amount': self._upd_vat_amount(),
|
||
'source_model': 'account.move',
|
||
'source_res_id': self.source_res_id,
|
||
'realization_id': self.id,
|
||
}
|
||
if origin_move:
|
||
vals['origin_move_id'] = origin_move.id
|
||
invoice = self.env['rsbu.invoice'].sudo().create(vals)
|
||
invoice.action_confirm()
|
||
self.invoice_id = invoice
|
||
return invoice
|
||
|
||
# ------------------------------------------------------------------
|
||
# Сброс статуса закрывающего документа у инвойса-источника
|
||
# ------------------------------------------------------------------
|
||
def _upd_reset_source_move(self):
|
||
"""При отмене/удалении реализации вернуть closing_document источника
|
||
в «Не сделан»."""
|
||
Move = self.env['account.move'].sudo()
|
||
for rec in self.filtered(lambda r: r.source_model == 'account.move'
|
||
and r.source_res_id):
|
||
move = Move.browse(rec.source_res_id)
|
||
if move.exists() and move.closing_document != 'not_done':
|
||
move.closing_document = 'not_done'
|
||
|
||
def action_cancel(self):
|
||
self._upd_reset_source_move()
|
||
return self.write({'state': 'cancelled'})
|
||
|
||
def unlink(self):
|
||
self._upd_reset_source_move()
|
||
return super().unlink()
|