Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC
This commit is contained in:
59
rsbu_kudir/README.rst
Normal file
59
rsbu_kudir/README.rst
Normal file
@@ -0,0 +1,59 @@
|
||||
=============================================
|
||||
РСБУ — КУДиР (Книга учёта доходов и расходов)
|
||||
=============================================
|
||||
|
||||
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
|
||||
:target: https://odoo-community.org/page/development-status
|
||||
:alt: Beta
|
||||
.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png
|
||||
:target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html
|
||||
:alt: License: LGPL-3
|
||||
|
||||
|badge1| |badge2|
|
||||
|
||||
Книга учёта доходов и расходов для организаций и ИП на УСН. Доходы и
|
||||
расходы признаются кассовым методом — по фактическому движению денег,
|
||||
на основании проведённых проводок журнала РСБУ.
|
||||
|
||||
**Содержание**
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
|
||||
Возможности
|
||||
===========
|
||||
|
||||
* Выбор объекта налогообложения: «Доходы» или «Доходы минус расходы»
|
||||
* Раздел I — доходы и расходы
|
||||
* Раздел IV — уплаченные страховые взносы
|
||||
* Пересчёт книги за выбранный период
|
||||
|
||||
Настройка
|
||||
=========
|
||||
|
||||
#. Перейдите в *Настройки* и укажите объект налогообложения УСН
|
||||
(«Доходы» или «Доходы минус расходы»).
|
||||
#. В шаблонах операций задайте, как каждая операция влияет на книгу
|
||||
(доход, расход, возврат клиенту, возврат поставщику или не участвует).
|
||||
|
||||
Использование
|
||||
=============
|
||||
|
||||
#. Проводите документы обычным образом — записи КУДиР формируются
|
||||
автоматически по мере поступления и расходования денег.
|
||||
#. Откройте *РСБУ > КУДиР*, чтобы просмотреть разделы I и IV.
|
||||
#. При необходимости пересчитайте книгу за нужный период через
|
||||
соответствующий мастер.
|
||||
|
||||
Авторы
|
||||
======
|
||||
|
||||
* MK.Lab, RuOdoo
|
||||
|
||||
Модуль сопровождается MK.Lab, RuOdoo.
|
||||
|
||||
.. image:: https://ruodoo.ru/logo.png
|
||||
:alt: MK.Lab, RuOdoo
|
||||
:target: https://ruodoo.ru
|
||||
|
||||
Модуль является частью РСБУ-слоя проекта РуОду.
|
||||
2
rsbu_kudir/__init__.py
Normal file
2
rsbu_kudir/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import wizards
|
||||
46
rsbu_kudir/__manifest__.py
Normal file
46
rsbu_kudir/__manifest__.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'РСБУ — КУДиР (кассовый слой)',
|
||||
'version': '19.0.2026.09.17',
|
||||
'summary': 'Книга учёта доходов и расходов на УСН (разделы I и IV)',
|
||||
'description': """
|
||||
РСБУ — КУДиР (Книга учёта доходов и расходов)
|
||||
=============================================
|
||||
|
||||
Книга учёта доходов и расходов для организаций и ИП на УСН. Доходы и
|
||||
расходы признаются кассовым методом — по фактическому движению денег,
|
||||
на основании проведённых проводок журнала РСБУ.
|
||||
|
||||
Возможности:
|
||||
------------
|
||||
* Выбор объекта налогообложения в настройках: «Доходы» или
|
||||
«Доходы минус расходы».
|
||||
* Раздел I — доходы и расходы.
|
||||
* Раздел IV — уплаченные страховые взносы.
|
||||
* Пересчёт книги за выбранный период.
|
||||
|
||||
Как пользоваться:
|
||||
-----------------
|
||||
1. В настройках укажите объект налогообложения УСН.
|
||||
2. В шаблонах операций задайте, как каждая операция влияет на книгу
|
||||
(доход, расход, возврат и т. п.).
|
||||
3. Проводите документы обычным образом — записи КУДиР формируются
|
||||
автоматически. При необходимости пересчитайте книгу за период.
|
||||
""",
|
||||
'author': 'MK.Lab, RuOdoo',
|
||||
'website': 'https://ruodoo.ru',
|
||||
'category': 'Accounting/Localizations',
|
||||
'depends': ['rsbu_mapping'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'wizards/rsbu_kudir_recompute_wizard_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/rsbu_operation_template_views.xml',
|
||||
'views/rsbu_kudir_views.xml',
|
||||
'views/rsbu_menus.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'license': 'LGPL-3',
|
||||
'maintainers': ['mklab', 'ruodoo'],
|
||||
}
|
||||
6
rsbu_kudir/models/__init__.py
Normal file
6
rsbu_kudir/models/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from . import res_company
|
||||
from . import res_config_settings
|
||||
from . import rsbu_operation_template
|
||||
from . import rsbu_move
|
||||
from . import rsbu_kudir_line
|
||||
from . import rsbu_kudir_summary
|
||||
26
rsbu_kudir/models/res_company.py
Normal file
26
rsbu_kudir/models/res_company.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
rsbu_usn_mode = fields.Selection(
|
||||
selection=[
|
||||
('usn_income', 'УСН «Доходы»'),
|
||||
('usn_income_expense', 'УСН «Доходы минус расходы»'),
|
||||
],
|
||||
string='Режим УСН', default='usn_income')
|
||||
rsbu_usn_rate = fields.Float(
|
||||
string='Ставка УСН, %', default=6.0,
|
||||
help='Ставка налога при УСН (6% для «Доходы», 15% для «Доходы минус '
|
||||
'расходы»; может быть снижена региональным законом).')
|
||||
rsbu_usn_reduction_limit = fields.Float(
|
||||
string='Лимит уменьшения на взносы, %', default=50.0,
|
||||
help='Предел уменьшения налога УСН «Доходы» на страховые взносы и '
|
||||
'пособия: 50% для организаций и ИП с работниками, 100% для ИП '
|
||||
'без работников.')
|
||||
rsbu_accounting_start = fields.Date(
|
||||
string='Дата начала учёта РСБУ',
|
||||
help='Дата, с которой ведётся учёт в РСБУ-слое. Месяц этой даты — '
|
||||
'первый доступный для закрытия периода; более ранние месяцы '
|
||||
'закрывать не требуется и нельзя.')
|
||||
12
rsbu_kudir/models/res_config_settings.py
Normal file
12
rsbu_kudir/models/res_config_settings.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
rsbu_usn_mode = fields.Selection(
|
||||
related='company_id.rsbu_usn_mode', readonly=False,
|
||||
string='Режим УСН')
|
||||
rsbu_accounting_start = fields.Date(
|
||||
related='company_id.rsbu_accounting_start', readonly=False,
|
||||
string='Дата начала учёта РСБУ')
|
||||
171
rsbu_kudir/models/rsbu_kudir_line.py
Normal file
171
rsbu_kudir/models/rsbu_kudir_line.py
Normal file
@@ -0,0 +1,171 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
# Коды денежных счетов (по плану 94н): касса и расчётные счета.
|
||||
MONEY_ACCOUNT_CODES = ('50', '51', '52', '55', '57')
|
||||
# Код счёта расчётов по страховым взносам (для раздела IV).
|
||||
INSURANCE_ACCOUNT_CODE = '69'
|
||||
|
||||
|
||||
class RsbuKudirLine(models.Model):
|
||||
"""Запись КУДиР (кассовый слой поверх журнала)."""
|
||||
_name = 'rsbu.kudir.line'
|
||||
_description = 'РСБУ — Запись КУДиР'
|
||||
_order = 'date, id'
|
||||
_check_company_auto = True
|
||||
|
||||
date = fields.Date(string='Дата', required=True, index=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Компания', required=True, index=True,
|
||||
default=lambda self: self.env.company)
|
||||
section = fields.Selection(
|
||||
selection=[
|
||||
('section_1', 'Раздел I. Доходы и расходы'),
|
||||
('section_4', 'Раздел IV. Страховые взносы'),
|
||||
],
|
||||
string='Раздел', required=True, index=True)
|
||||
move_id = fields.Many2one('rsbu.move', string='Операция-источник',
|
||||
ondelete='cascade')
|
||||
move_line_id = fields.Many2one('rsbu.move.line', string='Строка журнала',
|
||||
ondelete='cascade')
|
||||
document_ref = fields.Char(string='Первичный документ')
|
||||
narration = fields.Char(string='Содержание операции')
|
||||
income_amount = fields.Monetary(string='Доходы',
|
||||
currency_field='currency_id')
|
||||
expense_amount = fields.Monetary(string='Расходы',
|
||||
currency_field='currency_id')
|
||||
is_refund = fields.Boolean(string='Возврат')
|
||||
currency_id = fields.Many2one('res.currency', string='Валюта',
|
||||
compute='_compute_currency_id', store=True)
|
||||
period_key = fields.Char(string='Период', compute='_compute_period_key',
|
||||
store=True, index=True)
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_currency_id(self):
|
||||
for line in self:
|
||||
line.currency_id = line.company_id.currency_id
|
||||
|
||||
@api.depends('date')
|
||||
def _compute_period_key(self):
|
||||
for line in self:
|
||||
if line.date:
|
||||
quarter = (line.date.month - 1) // 3 + 1
|
||||
line.period_key = '%s-Q%s' % (line.date.year, quarter)
|
||||
else:
|
||||
line.period_key = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Пересчёт
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _is_money_account(self, account):
|
||||
code = (account.code or '').split('.')[0]
|
||||
return code in MONEY_ACCOUNT_CODES
|
||||
|
||||
@api.model
|
||||
def _is_insurance_account(self, account):
|
||||
code = (account.code or '').split('.')[0]
|
||||
return code == INSURANCE_ACCOUNT_CODE
|
||||
|
||||
@api.model
|
||||
def recompute_period(self, date_from, date_to, company):
|
||||
"""Пересобрать записи КУДиР за период из проведённых проводок.
|
||||
|
||||
Идемпотентно: сначала удаляет существующие записи периода,
|
||||
затем создаёт заново.
|
||||
|
||||
TODO(perf): при больших объёмах вынести в cron пакетного
|
||||
пересчёта. Сейчас крон не реализуется (см. спецификацию Э5 R6.4).
|
||||
"""
|
||||
# 1) Удалить существующие записи за период.
|
||||
self.search([
|
||||
('company_id', '=', company.id),
|
||||
('date', '>=', date_from),
|
||||
('date', '<=', date_to),
|
||||
]).unlink()
|
||||
|
||||
usn_mode = company.rsbu_usn_mode
|
||||
# Отбираем проводки, участвующие в УСН (доход/расход/возврат), а также
|
||||
# проводки с корректировкой дохода при отгрузке (переходящие сделки —
|
||||
# см. kudir_income_adjust), даже если сама проводка не признаётся
|
||||
# доходом по кассе (usn_recognition='none').
|
||||
moves = self.env['rsbu.move'].search([
|
||||
('company_id', '=', company.id),
|
||||
('state', '=', 'posted'),
|
||||
('date', '>=', date_from),
|
||||
('date', '<=', date_to),
|
||||
'|',
|
||||
'&', ('usn_recognition', 'not in', ('none', False)),
|
||||
(1, '=', 1),
|
||||
('kudir_income_adjust', '!=', 0.0),
|
||||
])
|
||||
created = self.browse()
|
||||
for move in moves:
|
||||
created |= self._build_lines_for_move(move, usn_mode)
|
||||
return created
|
||||
|
||||
@api.model
|
||||
def _build_lines_for_move(self, move, usn_mode):
|
||||
vals_list = []
|
||||
recognition = move.usn_recognition
|
||||
# Корректировка дохода при отгрузке в счёт входящего аванса
|
||||
# (переходящая сделка УСН→УСН+НДС): отдельная строка раздела I на
|
||||
# сумму kudir_income_adjust (обычно отрицательная), не связанная с
|
||||
# движением денег. Первичный документ/содержание — от проводки
|
||||
# реализации (документ отгрузки).
|
||||
adjust = getattr(move, 'kudir_income_adjust', 0.0) or 0.0
|
||||
if adjust:
|
||||
vals_list.append(self._line_vals(
|
||||
move, move.line_ids[:1], 'section_1',
|
||||
income_amount=round(adjust, 2)))
|
||||
for line in move.line_ids:
|
||||
account = line.account_id
|
||||
# Раздел IV: уплата страховых взносов (дебет счёта 69).
|
||||
if self._is_insurance_account(account) and line.debit:
|
||||
vals_list.append(self._line_vals(
|
||||
move, line, 'section_4',
|
||||
expense_amount=line.debit))
|
||||
continue
|
||||
# Раздел I: движение денег.
|
||||
if not self._is_money_account(account):
|
||||
continue
|
||||
amount = line.debit or line.credit
|
||||
if recognition == 'income' and line.debit:
|
||||
# Доход УСН = поступление минус предъявленный НДС (на УСН+НДС
|
||||
# НДС в доход не включается). НДС берётся из документа-
|
||||
# источника (move.kudir_income_vat), для необлагаемых = 0.
|
||||
vat = getattr(move, 'kudir_income_vat', 0.0) or 0.0
|
||||
income = round(amount - vat, 2)
|
||||
vals_list.append(self._line_vals(
|
||||
move, line, 'section_1', income_amount=income))
|
||||
elif recognition == 'refund_customer' and line.credit:
|
||||
vals_list.append(self._line_vals(
|
||||
move, line, 'section_1', income_amount=-amount,
|
||||
is_refund=True))
|
||||
elif recognition == 'expense' and line.credit \
|
||||
and usn_mode == 'usn_income_expense':
|
||||
vals_list.append(self._line_vals(
|
||||
move, line, 'section_1', expense_amount=amount))
|
||||
elif recognition == 'refund_supplier' and line.debit \
|
||||
and usn_mode == 'usn_income_expense':
|
||||
vals_list.append(self._line_vals(
|
||||
move, line, 'section_1', expense_amount=-amount,
|
||||
is_refund=True))
|
||||
if not vals_list:
|
||||
return self.browse()
|
||||
return self.create(vals_list)
|
||||
|
||||
@api.model
|
||||
def _line_vals(self, move, line, section, income_amount=0.0,
|
||||
expense_amount=0.0, is_refund=False):
|
||||
return {
|
||||
'date': move.date,
|
||||
'company_id': move.company_id.id,
|
||||
'section': section,
|
||||
'move_id': move.id,
|
||||
'move_line_id': line.id,
|
||||
'document_ref': move.name,
|
||||
'narration': move.ref or line.name or '',
|
||||
'income_amount': income_amount,
|
||||
'expense_amount': expense_amount,
|
||||
'is_refund': is_refund,
|
||||
}
|
||||
95
rsbu_kudir/models/rsbu_kudir_summary.py
Normal file
95
rsbu_kudir/models/rsbu_kudir_summary.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Регистр промежуточных итогов КУДиР по кварталам (для декларации УСН).
|
||||
|
||||
Аналог rsbu.vat.summary: агрегирует записи rsbu.kudir.line за квартал —
|
||||
доходы и расходы раздела I и уплаченные взносы/пособия раздела IV. Служит
|
||||
источником для декларации УСН (нарастающим итогом по отчётным периодам).
|
||||
"""
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class RsbuKudirSummary(models.Model):
|
||||
_name = 'rsbu.kudir.summary'
|
||||
_description = 'РСБУ — Итоги КУДиР за период'
|
||||
_order = 'period_key desc'
|
||||
_check_company_auto = True
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Компания', required=True, index=True,
|
||||
default=lambda self: self.env.company)
|
||||
period_key = fields.Char(string='Квартал', required=True, index=True)
|
||||
income = fields.Monetary(string='Доходы (раздел I)',
|
||||
currency_field='currency_id')
|
||||
expense = fields.Monetary(string='Расходы (раздел I)',
|
||||
currency_field='currency_id')
|
||||
insurance = fields.Monetary(string='Взносы/пособия (раздел IV)',
|
||||
currency_field='currency_id')
|
||||
currency_id = fields.Many2one('res.currency', string='Валюта',
|
||||
compute='_compute_currency_id', store=True)
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_currency_id(self):
|
||||
for rec in self:
|
||||
rec.currency_id = rec.company_id.currency_id
|
||||
|
||||
@api.model
|
||||
def recompute_period(self, date_from, date_to, company):
|
||||
"""Пересчитать итоги КУДиР по кварталам периода из записей книги.
|
||||
|
||||
Идемпотентно: сначала пересобирает сами записи КУДиР за период
|
||||
(rsbu.kudir.line.recompute_period), затем агрегирует по кварталам.
|
||||
"""
|
||||
Line = self.env['rsbu.kudir.line']
|
||||
Line.recompute_period(date_from, date_to, company)
|
||||
lines = Line.search([
|
||||
('company_id', '=', company.id),
|
||||
('date', '>=', date_from),
|
||||
('date', '<=', date_to),
|
||||
])
|
||||
by_period = {}
|
||||
for ln in lines:
|
||||
data = by_period.setdefault(
|
||||
ln.period_key,
|
||||
{'income': 0.0, 'expense': 0.0, 'insurance': 0.0})
|
||||
if ln.section == 'section_1':
|
||||
data['income'] += ln.income_amount
|
||||
data['expense'] += ln.expense_amount
|
||||
elif ln.section == 'section_4':
|
||||
data['insurance'] += ln.expense_amount
|
||||
|
||||
result = self.browse()
|
||||
for period_key, data in by_period.items():
|
||||
existing = self.search([
|
||||
('company_id', '=', company.id),
|
||||
('period_key', '=', period_key)], limit=1)
|
||||
vals = {
|
||||
'company_id': company.id,
|
||||
'period_key': period_key,
|
||||
'income': data['income'],
|
||||
'expense': data['expense'],
|
||||
'insurance': data['insurance'],
|
||||
}
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
result |= existing
|
||||
else:
|
||||
result |= self.create(vals)
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def ytd_totals(self, company, year, up_to_quarter):
|
||||
"""Итоги нарастающим итогом с начала года по квартал включительно.
|
||||
|
||||
Возвращает dict {income, expense, insurance} — сумма по кварталам
|
||||
year-Q1 … year-Q{up_to_quarter}. Для декларации УСН (отчётные
|
||||
периоды нарастающим итогом).
|
||||
"""
|
||||
keys = ['%s-Q%s' % (year, q) for q in range(1, up_to_quarter + 1)]
|
||||
recs = self.search([
|
||||
('company_id', '=', company.id),
|
||||
('period_key', 'in', keys),
|
||||
])
|
||||
return {
|
||||
'income': round(sum(recs.mapped('income')), 2),
|
||||
'expense': round(sum(recs.mapped('expense')), 2),
|
||||
'insurance': round(sum(recs.mapped('insurance')), 2),
|
||||
}
|
||||
174
rsbu_kudir/models/rsbu_move.py
Normal file
174
rsbu_kudir/models/rsbu_move.py
Normal file
@@ -0,0 +1,174 @@
|
||||
from odoo import fields, models
|
||||
|
||||
# Расчётная доля НДС в сумме по режиму НДС компании (аналог ставки 5/105).
|
||||
_ADVANCE_VAT_FRACTION = {'vat_5': 5.0 / 105.0, 'vat_7': 7.0 / 107.0}
|
||||
|
||||
|
||||
class RsbuMove(models.Model):
|
||||
"""Расширение операции: перенос правила признания УСН (для КУДиР)."""
|
||||
_inherit = 'rsbu.move'
|
||||
|
||||
usn_recognition = fields.Selection(
|
||||
selection=[
|
||||
('income', 'Доход'),
|
||||
('expense', 'Расход'),
|
||||
('refund_customer', 'Возврат клиенту'),
|
||||
('refund_supplier', 'Возврат поставщику'),
|
||||
('none', 'Не участвует в УСН'),
|
||||
],
|
||||
string='Признание в УСН', default='none', copy=False)
|
||||
is_refund = fields.Boolean(string='Это возврат', copy=False)
|
||||
kudir_income_vat = fields.Monetary(
|
||||
string='НДС, исключаемый из дохода УСН',
|
||||
currency_field='currency_id', default=0.0, copy=False,
|
||||
help='Сумма предъявленного НДС по поступлению, которая исключается '
|
||||
'из дохода КУДиР (доход = поступление − этот НДС). На УСН+НДС '
|
||||
'заполняется из налога документа; для необлагаемых операций 0.')
|
||||
kudir_income_adjust = fields.Monetary(
|
||||
string='Корректировка дохода УСН при отгрузке',
|
||||
currency_field='currency_id', default=0.0, copy=False,
|
||||
help='Отрицательная корректировка дохода КУДиР в момент отгрузки в '
|
||||
'счёт ранее полученного аванса, по которому НДС не был исключён '
|
||||
'при поступлении (переходящая сделка: аванс получен до перехода '
|
||||
'на НДС, отгрузка облагается по ставке). Равна −(зачтённый аванс '
|
||||
'× расчётная ставка НДС). См. п.1 ст.248, п.1 ст.346.15 НК РФ.')
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
def _rsbu_is_calibration(self):
|
||||
"""Документ создан калибровкой (несёт ключ 1С). Для таких проводок
|
||||
поля КУДиР-НДС размечает калибровочный слой по ОСВ-эталону, а не
|
||||
онлайн-логика проведения."""
|
||||
self.ensure_one()
|
||||
return bool(getattr(self, 'calibration_ref_key', False))
|
||||
|
||||
def _rsbu_get_move_values(self):
|
||||
vals = super()._rsbu_get_move_values()
|
||||
template = self.rsbu_template_id or self._rsbu_detect_template()
|
||||
if template:
|
||||
vals['usn_recognition'] = template.usn_recognition
|
||||
vals['is_refund'] = template.is_refund
|
||||
# Корректировка дохода КУДиР при отгрузке в счёт аванса, по
|
||||
# которому НДС не был исключён при поступлении (переходящая
|
||||
# сделка УСН→УСН+НДС). Только для облагаемой отгрузки.
|
||||
# Калибровочные отгрузки размечаются mark_kudir_shipment_adjust
|
||||
# (по ОСВ-эталону входящих авансов) — онлайн-расчёт пропускаем.
|
||||
if template.resolver_key == 'shipment_upd' and self.amount_tax \
|
||||
and not self._rsbu_is_calibration():
|
||||
adjust = self._rsbu_kudir_shipment_adjust(template)
|
||||
if adjust:
|
||||
vals['kudir_income_adjust'] = adjust
|
||||
return vals
|
||||
|
||||
def _rsbu_kudir_shipment_adjust(self, template):
|
||||
"""Отрицательная корректировка дохода КУДиР при отгрузке.
|
||||
|
||||
Проходит зачитываемые отгрузкой авансы (FIFO по основанию, как в
|
||||
резолвере shipment_upd). Для авансов, по которым НДС не был исключён
|
||||
при поступлении (origin.kudir_income_vat == 0), начисляет исключение
|
||||
сейчас: −(зачтённая сумма × расчётная доля НДС). Возвращает <= 0.
|
||||
"""
|
||||
self.ensure_one()
|
||||
fraction = _ADVANCE_VAT_FRACTION.get(self.company_id.rsbu_vat_mode, 0.0)
|
||||
if not fraction:
|
||||
return 0.0
|
||||
advance = template.sudo()._account_or_none('advance_received')
|
||||
partner = self.partner_id
|
||||
if not advance or not partner:
|
||||
return 0.0
|
||||
subconto = {'partner': partner.id}
|
||||
contract = getattr(self, 'mt_contract_id', False)
|
||||
if contract:
|
||||
subconto['contract'] = contract.id
|
||||
origins = advance.get_origin_balances(
|
||||
date_to=self.date, company_id=self.company_id.id,
|
||||
subconto=subconto)
|
||||
remaining = abs(self.amount_total)
|
||||
corr_base = 0.0
|
||||
for data in origins:
|
||||
if remaining <= 0:
|
||||
break
|
||||
available = data['balance'] if data['side'] == 'credit' else 0.0
|
||||
if available <= 0:
|
||||
continue
|
||||
take = round(min(available, remaining), 2)
|
||||
if take <= 0:
|
||||
continue
|
||||
origin_move = data['origin']
|
||||
# НДС по этому авансу при поступлении не исключался → исключаем
|
||||
# соответствующую долю сейчас, при отгрузке.
|
||||
if not (origin_move.kudir_income_vat or 0.0):
|
||||
corr_base += take
|
||||
remaining -= take
|
||||
if corr_base <= 0:
|
||||
return 0.0
|
||||
return -round(corr_base * fraction, 2)
|
||||
|
||||
|
||||
class AccountPayment(models.Model):
|
||||
_inherit = 'account.payment'
|
||||
|
||||
def _rsbu_is_calibration(self):
|
||||
"""См. AccountMove._rsbu_is_calibration."""
|
||||
self.ensure_one()
|
||||
return bool(getattr(self, 'calibration_ref_key', False))
|
||||
|
||||
def _rsbu_get_move_values(self):
|
||||
vals = super()._rsbu_get_move_values()
|
||||
template = self.rsbu_template_id or self._rsbu_detect_template()
|
||||
if template:
|
||||
vals['usn_recognition'] = template.usn_recognition
|
||||
vals['is_refund'] = template.is_refund
|
||||
# НДС, исключаемый из дохода КУДиР (доход = поступление − НДС).
|
||||
# Только для доходных поступлений от покупателя (кассовый метод
|
||||
# УСН, п.1 ст.248/346.15 НК: доход без предъявленного НДС).
|
||||
# Калибровочные проводки размечаются отдельно (mark_kudir_vat по
|
||||
# ОСВ-эталону) — онлайн-расчёт для них пропускаем.
|
||||
if template.usn_recognition == 'income' \
|
||||
and not self._rsbu_is_calibration():
|
||||
vals['kudir_income_vat'] = self._rsbu_kudir_income_vat()
|
||||
return vals
|
||||
|
||||
def _rsbu_kudir_income_vat(self):
|
||||
"""НДС, исключаемый из дохода КУДиР по клиентскому поступлению.
|
||||
|
||||
Приоритет — явно указанный НДС платежа (rsbu_vat_amount: банковская
|
||||
выписка/оператор). Иначе, если поступление относится к облагаемой
|
||||
НДС реализации (инвойс того же контрагента и договора несёт налог —
|
||||
«инвойс источник правды»), исключаем расчётную долю НДС из суммы.
|
||||
Для необлагаемых операций (лицензии и т.п.) — 0.
|
||||
"""
|
||||
self.ensure_one()
|
||||
vat = abs(self.rsbu_vat_amount or 0.0)
|
||||
if vat:
|
||||
return round(vat, 2)
|
||||
fraction = _ADVANCE_VAT_FRACTION.get(self.company_id.rsbu_vat_mode, 0.0)
|
||||
if fraction and self._rsbu_payment_is_taxable():
|
||||
return round(abs(self.amount) * fraction, 2)
|
||||
return 0.0
|
||||
|
||||
def _rsbu_payment_is_taxable(self):
|
||||
"""Облагается ли НДС реализация, к которой относится поступление.
|
||||
|
||||
Инвойс — источник правды об облагаемости: ищем out_invoice того же
|
||||
контрагента и договора (mt_contract_id) с ненулевым налогом. Договор
|
||||
в смешанной сделке (лицензия без НДС + услуги с НДС) разграничивает
|
||||
облагаемость по конкретной реализации.
|
||||
"""
|
||||
self.ensure_one()
|
||||
partner = self.partner_id
|
||||
if not partner:
|
||||
return False
|
||||
Move = self.env['account.move'].sudo()
|
||||
domain = [
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('company_id', '=', self.company_id.id),
|
||||
('partner_id', '=', partner.id),
|
||||
('amount_tax', '>', 0),
|
||||
]
|
||||
contract = getattr(self, 'mt_contract_id', False)
|
||||
if contract:
|
||||
domain.append(('mt_contract_id', '=', contract.id))
|
||||
return bool(Move.search(domain, limit=1))
|
||||
17
rsbu_kudir/models/rsbu_operation_template.py
Normal file
17
rsbu_kudir/models/rsbu_operation_template.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class RsbuOperationTemplate(models.Model):
|
||||
"""Расширение шаблона: правило признания в УСН (для КУДиР)."""
|
||||
_inherit = 'rsbu.operation.template'
|
||||
|
||||
usn_recognition = fields.Selection(
|
||||
selection=[
|
||||
('income', 'Доход'),
|
||||
('expense', 'Расход'),
|
||||
('refund_customer', 'Возврат клиенту'),
|
||||
('refund_supplier', 'Возврат поставщику'),
|
||||
('none', 'Не участвует в УСН'),
|
||||
],
|
||||
string='Признание в УСН', default='none', required=True)
|
||||
is_refund = fields.Boolean(string='Это возврат')
|
||||
6
rsbu_kudir/security/ir.model.access.csv
Normal file
6
rsbu_kudir/security/ir.model.access.csv
Normal file
@@ -0,0 +1,6 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_rsbu_kudir_line_user,rsbu.kudir.line user,model_rsbu_kudir_line,rsbu_chart_of_accounts.group_rsbu_account_user,1,0,0,0
|
||||
access_rsbu_kudir_line_mgr,rsbu.kudir.line manager,model_rsbu_kudir_line,rsbu_journal.group_rsbu_journal_manager,1,1,1,1
|
||||
access_rsbu_kudir_recompute_wizard,rsbu.kudir.recompute.wizard,model_rsbu_kudir_recompute_wizard,rsbu_journal.group_rsbu_journal_user,1,1,1,1
|
||||
access_rsbu_kudir_summary_user,rsbu.kudir.summary user,model_rsbu_kudir_summary,rsbu_chart_of_accounts.group_rsbu_account_user,1,0,0,0
|
||||
access_rsbu_kudir_summary_mgr,rsbu.kudir.summary manager,model_rsbu_kudir_summary,rsbu_journal.group_rsbu_journal_manager,1,1,1,1
|
||||
|
BIN
rsbu_kudir/static/description/icon.png
Normal file
BIN
rsbu_kudir/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
1
rsbu_kudir/tests/__init__.py
Normal file
1
rsbu_kudir/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_kudir
|
||||
116
rsbu_kudir/tests/test_kudir.py
Normal file
116
rsbu_kudir/tests/test_kudir.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestRsbuKudir(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.company = cls.env.company
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'Kudir Клиент'})
|
||||
cls.journal_bank = cls.env['account.journal'].search(
|
||||
[('type', '=', 'bank'), ('company_id', '=', cls.company.id)],
|
||||
limit=1)
|
||||
cls.Kudir = cls.env['rsbu.kudir.line']
|
||||
|
||||
def _acc(self, code):
|
||||
return self.env['rsbu.account'].search([
|
||||
('code', '=', code),
|
||||
('company_id', '=', self.env.company.id)], limit=1)
|
||||
|
||||
def _advance_template(self, recognition='income'):
|
||||
return self.env['rsbu.operation.template'].create({
|
||||
'code': 'ADV_IN_K',
|
||||
'name': 'Аванс',
|
||||
'resolver_key': 'advance_received',
|
||||
'usn_recognition': recognition,
|
||||
'role_ids': [
|
||||
(0, 0, {'role': 'bank', 'account_id': self._acc('51').id}),
|
||||
(0, 0, {'role': 'advance_received',
|
||||
'account_id': self._acc('62.АВ').id}),
|
||||
(0, 0, {'role': 'vat_advance',
|
||||
'account_id': self._acc('76.АВ').id}),
|
||||
(0, 0, {'role': 'vat_output',
|
||||
'account_id': self._acc('68.НДС').id}),
|
||||
],
|
||||
})
|
||||
|
||||
def _matrix(self, template):
|
||||
return self.env['rsbu.operation.matrix'].create({
|
||||
'name': 'Входящий → аванс',
|
||||
'doc_kind': 'payment_inbound',
|
||||
'template_id': template.id,
|
||||
})
|
||||
|
||||
def _post_advance(self, amount=1050.0, vat=50.0):
|
||||
pay = self.env['account.payment'].create({
|
||||
'payment_type': 'inbound',
|
||||
'partner_type': 'customer',
|
||||
'partner_id': self.partner.id,
|
||||
'amount': amount,
|
||||
'rsbu_vat_amount': vat,
|
||||
'journal_id': self.journal_bank.id,
|
||||
})
|
||||
pay.action_post()
|
||||
return pay
|
||||
|
||||
def test_recognition_carried_to_move(self):
|
||||
tmpl = self._advance_template('income')
|
||||
self._matrix(tmpl)
|
||||
pay = self._post_advance()
|
||||
self.assertEqual(pay.rsbu_move_id.usn_recognition, 'income')
|
||||
|
||||
def test_kudir_income_recognized(self):
|
||||
tmpl = self._advance_template('income')
|
||||
self._matrix(tmpl)
|
||||
pay = self._post_advance(amount=1050.0, vat=50.0)
|
||||
move = pay.rsbu_move_id
|
||||
# НДС платежа (rsbu_vat_amount) исключается из дохода КУДиР:
|
||||
# на УСН+НДС доход отражается без предъявленного налога.
|
||||
self.assertEqual(move.kudir_income_vat, 50.0)
|
||||
self.Kudir.recompute_period(move.date, move.date, self.company)
|
||||
lines = self.Kudir.search([
|
||||
('company_id', '=', self.company.id),
|
||||
('section', '=', 'section_1')])
|
||||
self.assertTrue(lines)
|
||||
# Доход = поступление минус предъявленный НДС (1050 − 50 = 1000).
|
||||
total_income = sum(lines.mapped('income_amount'))
|
||||
self.assertEqual(total_income, 1000.0)
|
||||
|
||||
def test_kudir_recompute_idempotent(self):
|
||||
tmpl = self._advance_template('income')
|
||||
self._matrix(tmpl)
|
||||
pay = self._post_advance()
|
||||
move = pay.rsbu_move_id
|
||||
self.Kudir.recompute_period(move.date, move.date, self.company)
|
||||
first = self.Kudir.search_count([('company_id', '=', self.company.id)])
|
||||
self.Kudir.recompute_period(move.date, move.date, self.company)
|
||||
second = self.Kudir.search_count([('company_id', '=', self.company.id)])
|
||||
self.assertEqual(first, second)
|
||||
|
||||
def test_none_recognition_not_in_kudir(self):
|
||||
tmpl = self._advance_template('none')
|
||||
self._matrix(tmpl)
|
||||
pay = self._post_advance()
|
||||
move = pay.rsbu_move_id
|
||||
self.Kudir.recompute_period(move.date, move.date, self.company)
|
||||
lines = self.Kudir.search([('move_id', '=', move.id)])
|
||||
self.assertFalse(lines)
|
||||
|
||||
def test_kudir_income_no_vat_full_amount(self):
|
||||
"""Необлагаемое поступление (нет НДС, нет облагаемой реализации)
|
||||
признаётся доходом в полной сумме — НДС не исключается."""
|
||||
tmpl = self._advance_template('income')
|
||||
self._matrix(tmpl)
|
||||
pay = self._post_advance(amount=1000.0, vat=0.0)
|
||||
move = pay.rsbu_move_id
|
||||
# НДС платежа не задан и облагаемой реализации у партнёра нет —
|
||||
# исключать нечего.
|
||||
self.assertEqual(move.kudir_income_vat, 0.0)
|
||||
self.Kudir.recompute_period(move.date, move.date, self.company)
|
||||
lines = self.Kudir.search([
|
||||
('company_id', '=', self.company.id),
|
||||
('section', '=', 'section_1'),
|
||||
('move_id', '=', move.id)])
|
||||
self.assertEqual(sum(lines.mapped('income_amount')), 1000.0)
|
||||
28
rsbu_kudir/views/res_config_settings_views.xml
Normal file
28
rsbu_kudir/views/res_config_settings_views.xml
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="res_config_settings_view_form_rsbu" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.rsbu</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="priority" eval="30"/>
|
||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form" position="inside">
|
||||
<app string="РСБУ" name="rsbu_kudir"
|
||||
img="/rsbu_kudir/static/description/icon.png">
|
||||
<block title="Учёт по УСН" name="rsbu_usn_setting_container">
|
||||
<setting id="rsbu_usn_mode_setting"
|
||||
string="Режим УСН"
|
||||
help="Объект налогообложения для КУДиР">
|
||||
<field name="rsbu_usn_mode"/>
|
||||
</setting>
|
||||
<setting id="rsbu_accounting_start_setting"
|
||||
string="Дата начала учёта"
|
||||
help="Месяц этой даты — первый доступный для закрытия периода">
|
||||
<field name="rsbu_accounting_start"/>
|
||||
</setting>
|
||||
</block>
|
||||
</app>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
47
rsbu_kudir/views/rsbu_kudir_views.xml
Normal file
47
rsbu_kudir/views/rsbu_kudir_views.xml
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_rsbu_kudir_line_list" model="ir.ui.view">
|
||||
<field name="name">rsbu.kudir.line.list</field>
|
||||
<field name="model">rsbu.kudir.line</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="КУДиР">
|
||||
<field name="date"/>
|
||||
<field name="section"/>
|
||||
<field name="document_ref"/>
|
||||
<field name="narration"/>
|
||||
<field name="income_amount" sum="Итого доходы"/>
|
||||
<field name="expense_amount" sum="Итого расходы"/>
|
||||
<field name="is_refund"/>
|
||||
<field name="period_key"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_rsbu_kudir_line_search" model="ir.ui.view">
|
||||
<field name="name">rsbu.kudir.line.search</field>
|
||||
<field name="model">rsbu.kudir.line</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Поиск по КУДиР">
|
||||
<field name="document_ref"/>
|
||||
<field name="narration"/>
|
||||
<filter name="section_1" string="Раздел I"
|
||||
domain="[('section', '=', 'section_1')]"/>
|
||||
<filter name="section_4" string="Раздел IV"
|
||||
domain="[('section', '=', 'section_4')]"/>
|
||||
<separator/>
|
||||
<filter name="group_section" string="Раздел"
|
||||
context="{'group_by': 'section'}"/>
|
||||
<filter name="group_period" string="Период"
|
||||
context="{'group_by': 'period_key'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_rsbu_kudir_line" model="ir.actions.act_window">
|
||||
<field name="name">КУДиР</field>
|
||||
<field name="res_model">rsbu.kudir.line</field>
|
||||
<field name="view_mode">list</field>
|
||||
<field name="search_view_id" ref="view_rsbu_kudir_line_search"/>
|
||||
</record>
|
||||
</odoo>
|
||||
14
rsbu_kudir/views/rsbu_menus.xml
Normal file
14
rsbu_kudir/views/rsbu_menus.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- КУДиР — отдельный пункт верхнего уровня. -->
|
||||
<menuitem id="menu_rsbu_kudir" name="КУДиР"
|
||||
parent="rsbu_chart_of_accounts.menu_rsbu_root"
|
||||
action="action_rsbu_kudir_line" sequence="40"
|
||||
groups="rsbu_chart_of_accounts.group_rsbu_account_user"/>
|
||||
|
||||
<!-- Пересчёт КУДиР — в «Регламентные операции». -->
|
||||
<menuitem id="menu_rsbu_kudir_recompute" name="Пересчёт КУДиР"
|
||||
parent="rsbu_chart_of_accounts.menu_rsbu_regular_ops"
|
||||
action="action_rsbu_kudir_recompute_wizard" sequence="20"
|
||||
groups="rsbu_journal.group_rsbu_journal_user"/>
|
||||
</odoo>
|
||||
15
rsbu_kudir/views/rsbu_operation_template_views.xml
Normal file
15
rsbu_kudir/views/rsbu_operation_template_views.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_rsbu_operation_template_form_kudir" model="ir.ui.view">
|
||||
<field name="name">rsbu.operation.template.form.kudir</field>
|
||||
<field name="model">rsbu.operation.template</field>
|
||||
<field name="inherit_id"
|
||||
ref="rsbu_mapping.view_rsbu_operation_template_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="resolver_key" position="after">
|
||||
<field name="usn_recognition"/>
|
||||
<field name="is_refund"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
1
rsbu_kudir/wizards/__init__.py
Normal file
1
rsbu_kudir/wizards/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import rsbu_kudir_recompute_wizard
|
||||
29
rsbu_kudir/wizards/rsbu_kudir_recompute_wizard.py
Normal file
29
rsbu_kudir/wizards/rsbu_kudir_recompute_wizard.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from odoo import fields, models, _
|
||||
|
||||
|
||||
class RsbuKudirRecomputeWizard(models.TransientModel):
|
||||
"""Визард пересчёта КУДиР за период."""
|
||||
_name = 'rsbu.kudir.recompute.wizard'
|
||||
_description = 'РСБУ — Пересчёт КУДиР'
|
||||
|
||||
date_from = fields.Date(string='Дата с', required=True)
|
||||
date_to = fields.Date(string='Дата по', required=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Компания', required=True,
|
||||
default=lambda self: self.env.company)
|
||||
|
||||
def action_recompute(self):
|
||||
self.ensure_one()
|
||||
self.env['rsbu.kudir.line'].recompute_period(
|
||||
self.date_from, self.date_to, self.company_id)
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('КУДиР'),
|
||||
'res_model': 'rsbu.kudir.line',
|
||||
'view_mode': 'list',
|
||||
'domain': [
|
||||
('company_id', '=', self.company_id.id),
|
||||
('date', '>=', self.date_from),
|
||||
('date', '<=', self.date_to),
|
||||
],
|
||||
}
|
||||
30
rsbu_kudir/wizards/rsbu_kudir_recompute_wizard_views.xml
Normal file
30
rsbu_kudir/wizards/rsbu_kudir_recompute_wizard_views.xml
Normal file
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_rsbu_kudir_recompute_wizard_form" model="ir.ui.view">
|
||||
<field name="name">rsbu.kudir.recompute.wizard.form</field>
|
||||
<field name="model">rsbu.kudir.recompute.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Пересчёт КУДиР">
|
||||
<group>
|
||||
<field name="date_from"/>
|
||||
<field name="date_to"/>
|
||||
<field name="company_id"
|
||||
groups="base.group_multi_company"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_recompute" type="object"
|
||||
string="Пересчитать" class="btn-primary"/>
|
||||
<button string="Отмена" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_rsbu_kudir_recompute_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Пересчёт КУДиР</field>
|
||||
<field name="res_model">rsbu.kudir.recompute.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user