Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC
This commit is contained in:
4
rsbu_journal/models/__init__.py
Normal file
4
rsbu_journal/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from . import rsbu_move
|
||||
from . import rsbu_move_line
|
||||
from . import rsbu_account
|
||||
from . import rsbu_trial_balance
|
||||
23
rsbu_journal/models/rsbu_account.py
Normal file
23
rsbu_journal/models/rsbu_account.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from odoo import models
|
||||
|
||||
|
||||
class RsbuAccount(models.Model):
|
||||
"""Расширение плана счетов: наполнение хука агрегации данными журнала."""
|
||||
_inherit = 'rsbu.account'
|
||||
|
||||
def _read_postings(self, domain=None):
|
||||
"""Прочитать проведённые строки журнала для агрегации.
|
||||
|
||||
Переопределяет заглушку Этапа 1. Возвращает строки rsbu.move.line
|
||||
по поддереву счёта, только проведённые (state='posted'), с учётом
|
||||
дополнительного domain (период/компания) из вызывающего метода.
|
||||
"""
|
||||
self.ensure_one()
|
||||
base_domain = [
|
||||
('account_id', 'in', self._get_subtree_account_ids()),
|
||||
('state', '=', 'posted'),
|
||||
]
|
||||
if domain:
|
||||
# Отфильтровать по period/company — поля есть на строке (related).
|
||||
base_domain += domain
|
||||
return self.env['rsbu.move.line'].search(base_domain)
|
||||
148
rsbu_journal/models/rsbu_move.py
Normal file
148
rsbu_journal/models/rsbu_move.py
Normal file
@@ -0,0 +1,148 @@
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools import float_is_zero
|
||||
|
||||
|
||||
class RsbuMove(models.Model):
|
||||
"""Хозяйственная операция РСБУ (аналог account.move)."""
|
||||
_name = 'rsbu.move'
|
||||
_description = 'РСБУ — Хозяйственная операция'
|
||||
_order = 'date desc, name desc, id desc'
|
||||
_check_company_auto = True
|
||||
_inherit = ['mail.thread']
|
||||
|
||||
name = fields.Char(
|
||||
string='Номер', default='/', copy=False, readonly=True, index=True)
|
||||
date = fields.Date(
|
||||
string='Дата операции', required=True, index=True,
|
||||
default=fields.Date.context_today, tracking=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)
|
||||
state = fields.Selection(
|
||||
selection=[('draft', 'Черновик'), ('posted', 'Проведён')],
|
||||
string='Состояние', default='draft', required=True,
|
||||
copy=False, tracking=True, index=True)
|
||||
line_ids = fields.One2many(
|
||||
'rsbu.move.line', 'move_id', string='Строки проводки',
|
||||
copy=True)
|
||||
ref = fields.Char(string='Ссылка / описание')
|
||||
note = fields.Text(string='Примечание')
|
||||
|
||||
# Ссылка на документ-источник (используется Этапом 3).
|
||||
source_model = fields.Char(string='Модель источника', copy=False)
|
||||
source_res_id = fields.Integer(string='ID источника', copy=False)
|
||||
|
||||
# Контрольные обороты по проводке (для проверки баланса, не «сумма
|
||||
# операции»): при наличии внутренних проводок (напр. НДС на 90.03)
|
||||
# оборот по дебету не равен сумме документа, поэтому «сумму операции»
|
||||
# намеренно не храним и не показываем.
|
||||
total_debit = fields.Monetary(
|
||||
string='Оборот Дт', compute='_compute_totals', store=True,
|
||||
currency_field='currency_id')
|
||||
total_credit = fields.Monetary(
|
||||
string='Оборот Кт', compute='_compute_totals', store=True,
|
||||
currency_field='currency_id')
|
||||
line_count = fields.Integer(
|
||||
string='Кол-во строк', compute='_compute_line_count')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Вычисляемые поля
|
||||
# ------------------------------------------------------------------
|
||||
@api.depends('company_id')
|
||||
def _compute_currency_id(self):
|
||||
for move in self:
|
||||
move.currency_id = move.company_id.currency_id
|
||||
|
||||
@api.depends('line_ids.debit', 'line_ids.credit')
|
||||
def _compute_totals(self):
|
||||
for move in self:
|
||||
move.total_debit = sum(move.line_ids.mapped('debit'))
|
||||
move.total_credit = sum(move.line_ids.mapped('credit'))
|
||||
|
||||
@api.depends('line_ids')
|
||||
def _compute_line_count(self):
|
||||
for move in self:
|
||||
move.line_count = len(move.line_ids)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Проверки при проведении
|
||||
# ------------------------------------------------------------------
|
||||
def _check_has_lines(self):
|
||||
for move in self:
|
||||
if not move.line_ids:
|
||||
raise UserError(_(
|
||||
'Нельзя провести операцию «%s» без строк.')
|
||||
% move.display_name)
|
||||
|
||||
def _check_balanced(self):
|
||||
for move in self:
|
||||
rounding = move.currency_id.rounding or 0.01
|
||||
total_debit = sum(move.line_ids.mapped('debit'))
|
||||
total_credit = sum(move.line_ids.mapped('credit'))
|
||||
if not float_is_zero(total_debit - total_credit,
|
||||
precision_rounding=rounding):
|
||||
raise UserError(_(
|
||||
'Операция «%(name)s» не сбалансирована: '
|
||||
'дебет %(debit)s ≠ кредит %(credit)s.',
|
||||
name=move.display_name,
|
||||
debit=total_debit, credit=total_credit))
|
||||
|
||||
def _check_required_subconto(self):
|
||||
for move in self:
|
||||
for line in move.line_ids:
|
||||
required = line.account_id.get_required_subconto()
|
||||
missing = [dim for dim in required
|
||||
if not line._has_subconto(dim)]
|
||||
if missing:
|
||||
labels = {'partner': 'Контрагент', 'product': 'Номенклатура',
|
||||
'user': 'Пользователь', 'employee': 'Сотрудник'}
|
||||
names = ', '.join(labels.get(m, m) for m in missing)
|
||||
raise UserError(_(
|
||||
'Строка со счётом «%(acc)s» требует обязательные '
|
||||
'субконто: %(subs)s.',
|
||||
acc=line.account_id.display_name, subs=names))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Жизненный цикл
|
||||
# ------------------------------------------------------------------
|
||||
def action_post(self):
|
||||
for move in self:
|
||||
if move.state == 'posted':
|
||||
continue
|
||||
move._check_has_lines()
|
||||
move._check_balanced()
|
||||
move._check_required_subconto()
|
||||
if move.name == '/' or not move.name:
|
||||
move.name = self.env['ir.sequence'].with_company(
|
||||
move.company_id).next_by_code('rsbu.move') or '/'
|
||||
move.state = 'posted'
|
||||
return True
|
||||
|
||||
def action_draft(self):
|
||||
for move in self:
|
||||
move.state = 'draft'
|
||||
return True
|
||||
|
||||
def write(self, vals):
|
||||
# Ограничение редактирования ключевых полей у проведённой операции.
|
||||
protected = {'line_ids', 'date', 'company_id'}
|
||||
if any(f in vals for f in protected):
|
||||
for move in self:
|
||||
if move.state == 'posted':
|
||||
raise UserError(_(
|
||||
'Операция «%s» проведена. Верните её в черновик '
|
||||
'перед изменением строк или даты.')
|
||||
% move.display_name)
|
||||
return super().write(vals)
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_only_draft(self):
|
||||
for move in self:
|
||||
if move.state == 'posted':
|
||||
raise UserError(_(
|
||||
'Нельзя удалить проведённую операцию «%s». '
|
||||
'Сначала верните её в черновик.') % move.display_name)
|
||||
92
rsbu_journal/models/rsbu_move_line.py
Normal file
92
rsbu_journal/models/rsbu_move_line.py
Normal file
@@ -0,0 +1,92 @@
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tools import float_is_zero
|
||||
|
||||
# Соответствие измерений субконто полям строки.
|
||||
SUBCONTO_FIELD = {
|
||||
'partner': 'partner_id',
|
||||
'product': 'product_id',
|
||||
'user': 'user_id',
|
||||
'employee': 'employee_id',
|
||||
'origin': 'origin_move_id',
|
||||
'contract': 'contract_id',
|
||||
}
|
||||
|
||||
|
||||
class RsbuMoveLine(models.Model):
|
||||
"""Строка проводки РСБУ (двойная запись, субконто)."""
|
||||
_name = 'rsbu.move.line'
|
||||
_description = 'РСБУ — Строка проводки'
|
||||
_check_company_auto = True
|
||||
|
||||
move_id = fields.Many2one(
|
||||
'rsbu.move', string='Операция', required=True,
|
||||
ondelete='cascade', index=True)
|
||||
account_id = fields.Many2one(
|
||||
'rsbu.account', string='Счёт', required=True,
|
||||
check_company=True, index=True)
|
||||
name = fields.Char(string='Описание')
|
||||
debit = fields.Monetary(string='Дебет', default=0.0,
|
||||
currency_field='currency_id')
|
||||
credit = fields.Monetary(string='Кредит', default=0.0,
|
||||
currency_field='currency_id')
|
||||
|
||||
# Субконто.
|
||||
partner_id = fields.Many2one('res.partner', string='Контрагент',
|
||||
check_company=True)
|
||||
product_id = fields.Many2one('product.product', string='Номенклатура')
|
||||
user_id = fields.Many2one('res.users', string='Пользователь')
|
||||
employee_id = fields.Many2one('hr.employee', string='Сотрудник',
|
||||
check_company=True)
|
||||
# Субконто «Документ-основание»: проводка-основание расчёта (аванс),
|
||||
# аналог 3-го субконто 1С на 62.02/60.02/76.АВ («Документы расчётов»).
|
||||
# Позволяет вести остаток и зачёт по каждому авансу отдельно.
|
||||
origin_move_id = fields.Many2one(
|
||||
'rsbu.move', string='Документ-основание', index=True,
|
||||
ondelete='restrict')
|
||||
# Субконто «Договор» — аналог 2-го субконто 1С «Договоры» на 60/62/76.
|
||||
# Расчёты и зачёт авансов ведутся в разрезе (контрагент, договор).
|
||||
contract_id = fields.Many2one(
|
||||
'partner.contract.customer', string='Договор', index=True,
|
||||
ondelete='restrict')
|
||||
|
||||
company_id = fields.Many2one(
|
||||
related='move_id.company_id', string='Компания',
|
||||
store=True, index=True)
|
||||
currency_id = fields.Many2one(
|
||||
related='move_id.currency_id', string='Валюта', store=True)
|
||||
date = fields.Date(related='move_id.date', store=True, index=True)
|
||||
state = fields.Selection(
|
||||
related='move_id.state', store=True, index=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Ограничения
|
||||
# ------------------------------------------------------------------
|
||||
@api.constrains('debit', 'credit')
|
||||
def _check_debit_credit(self):
|
||||
for line in self:
|
||||
rounding = line.currency_id.rounding or 0.01
|
||||
debit_zero = float_is_zero(line.debit, precision_rounding=rounding)
|
||||
credit_zero = float_is_zero(line.credit, precision_rounding=rounding)
|
||||
if debit_zero and credit_zero:
|
||||
raise ValidationError(_(
|
||||
'Строка проводки должна иметь ненулевую сумму '
|
||||
'по дебету или кредиту.'))
|
||||
if not debit_zero and not credit_zero:
|
||||
raise ValidationError(_(
|
||||
'Строка проводки не может иметь одновременно дебет '
|
||||
'и кредит. Заполните только одну сторону.'))
|
||||
|
||||
@api.constrains('account_id')
|
||||
def _check_account_not_group(self):
|
||||
for line in self:
|
||||
if line.account_id.is_group:
|
||||
raise ValidationError(_(
|
||||
'Нельзя относить проводку на счёт-группу «%s».')
|
||||
% line.account_id.display_name)
|
||||
|
||||
def _has_subconto(self, dimension):
|
||||
"""Заполнено ли измерение субконто в строке."""
|
||||
self.ensure_one()
|
||||
field_name = SUBCONTO_FIELD.get(dimension)
|
||||
return bool(field_name and self[field_name])
|
||||
73
rsbu_journal/models/rsbu_trial_balance.py
Normal file
73
rsbu_journal/models/rsbu_trial_balance.py
Normal file
@@ -0,0 +1,73 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Оборотно-сальдовая ведомость (ОСВ) по РСБУ-проводкам.
|
||||
|
||||
Строится за период по проведённым строкам rsbu.move.line, сгруппированным
|
||||
по счёту плана счетов. Для каждого счёта показываются шесть показателей:
|
||||
сальдо на начало (Дт/Кт), обороты за период (Дт/Кт), сальдо на конец
|
||||
(Дт/Кт) — как в 1С.
|
||||
|
||||
Особенности:
|
||||
* сальдо активно-пассивных счетов (60/62/76) — РАЗВЁРНУТОЕ (дебиторка и
|
||||
кредиторка одновременно), считается в разрезе субконто через
|
||||
rsbu.account.get_expanded_balance;
|
||||
* иерархия счетов сворачивается снизу вверх: показатели счёта-группы =
|
||||
сумма показателей его потомков-листьев;
|
||||
* начало года для сальдо — календарное (1 января), результатные счета
|
||||
90/91/99 к концу года закрываются регламентом закрытия месяца.
|
||||
|
||||
Строки живут в рамках пользовательской сессии (TransientModel); отчёт на
|
||||
экране (QWeb-HTML) и переход к проводкам (drill-down) строятся поверх них.
|
||||
"""
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class RsbuTrialBalanceLine(models.TransientModel):
|
||||
_name = 'rsbu.trial.balance.line'
|
||||
_description = 'РСБУ — Строка оборотно-сальдовой ведомости'
|
||||
_order = 'code'
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
'rsbu.trial.balance.wizard', string='Отчёт', ondelete='cascade',
|
||||
index=True)
|
||||
account_id = fields.Many2one('rsbu.account', string='Счёт', index=True)
|
||||
code = fields.Char(string='Код счёта', index=True)
|
||||
name = fields.Char(string='Наименование счёта')
|
||||
level = fields.Integer(string='Уровень')
|
||||
is_group = fields.Boolean(string='Счёт-группа')
|
||||
parent_code = fields.Char(string='Код родителя')
|
||||
|
||||
company_id = fields.Many2one('res.company', string='Компания')
|
||||
currency_id = fields.Many2one('res.currency', string='Валюта')
|
||||
|
||||
opening_debit = fields.Monetary(string='Сальдо нач. Дт',
|
||||
currency_field='currency_id')
|
||||
opening_credit = fields.Monetary(string='Сальдо нач. Кт',
|
||||
currency_field='currency_id')
|
||||
turnover_debit = fields.Monetary(string='Оборот Дт',
|
||||
currency_field='currency_id')
|
||||
turnover_credit = fields.Monetary(string='Оборот Кт',
|
||||
currency_field='currency_id')
|
||||
closing_debit = fields.Monetary(string='Сальдо кон. Дт',
|
||||
currency_field='currency_id')
|
||||
closing_credit = fields.Monetary(string='Сальдо кон. Кт',
|
||||
currency_field='currency_id')
|
||||
|
||||
def action_open_entries(self):
|
||||
"""Drill-down: открыть проводки счёта за период отчёта."""
|
||||
self.ensure_one()
|
||||
wiz = self.wizard_id
|
||||
domain = [
|
||||
('account_id', 'child_of', self.account_id.id),
|
||||
('state', '=', 'posted'),
|
||||
('company_id', '=', self.company_id.id),
|
||||
('date', '>=', wiz.date_from),
|
||||
('date', '<=', wiz.date_to),
|
||||
]
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': '%s %s' % (self.code, self.name or ''),
|
||||
'res_model': 'rsbu.move.line',
|
||||
'view_mode': 'list,form',
|
||||
'domain': domain,
|
||||
'context': {'search_default_group_account': 0},
|
||||
}
|
||||
Reference in New Issue
Block a user