mirror of
https://github.com/bringout/oca-ocb-accounting.git
synced 2026-04-26 06:01:58 +02:00
360 lines
15 KiB
Python
360 lines
15 KiB
Python
from collections import defaultdict
|
|
from dateutil.relativedelta import relativedelta
|
|
|
|
from odoo import Command, _, api, fields, models
|
|
from odoo.fields import Domain
|
|
from odoo.exceptions import UserError
|
|
|
|
|
|
class ResCompany(models.Model):
|
|
_inherit = "res.company"
|
|
|
|
account_stock_journal_id = fields.Many2one('account.journal', string='Stock Journal', check_company=True)
|
|
|
|
account_stock_valuation_id = fields.Many2one('account.account', string='Stock Valuation Account', check_company=True)
|
|
|
|
account_production_wip_account_id = fields.Many2one('account.account', string='Production WIP Account', check_company=True)
|
|
account_production_wip_overhead_account_id = fields.Many2one('account.account', string='Production WIP Overhead Account', check_company=True)
|
|
|
|
inventory_period = fields.Selection(
|
|
string='Inventory Period',
|
|
selection=[
|
|
('manual', 'Manual'),
|
|
('daily', 'Daily'),
|
|
('monthly', 'Monthly'),
|
|
],
|
|
default='manual',
|
|
required=True)
|
|
|
|
inventory_valuation = fields.Selection(
|
|
string='Valuation',
|
|
selection=[
|
|
('periodic', 'Periodic (at closing)'),
|
|
('real_time', 'Perpetual (at invoicing)'),
|
|
],
|
|
default='periodic',
|
|
)
|
|
|
|
cost_method = fields.Selection(
|
|
string="Cost Method",
|
|
selection=[
|
|
('standard', "Standard Price"),
|
|
('fifo', "First In First Out (FIFO)"),
|
|
('average', "Average Cost (AVCO)"),
|
|
],
|
|
default='standard',
|
|
required=True,
|
|
)
|
|
|
|
def action_close_stock_valuation(self, at_date=None, auto_post=False):
|
|
self.ensure_one()
|
|
if at_date and isinstance(at_date, str):
|
|
at_date = fields.Date.from_string(at_date)
|
|
last_closing_date = self._get_last_closing_date()
|
|
if at_date and last_closing_date and at_date < fields.Date.to_date(last_closing_date):
|
|
raise UserError(self.env._('It exists closing entries after the selected date. Cancel them before generate an entry prior to them'))
|
|
aml_vals_list = self._action_close_stock_valuation(at_date=at_date)
|
|
|
|
if not aml_vals_list:
|
|
# No account moves to create, so nothing to display.
|
|
raise UserError(_("Everything is correctly closed"))
|
|
if not self.account_stock_journal_id:
|
|
raise UserError(self.env._("Please set the Journal for Inventory Valuation in the settings."))
|
|
if not self.account_stock_valuation_id:
|
|
raise UserError(self.env._("Please set the Valuation Account for Inventory Valuation in the settings."))
|
|
|
|
moves_vals = {
|
|
'journal_id': self.account_stock_journal_id.id,
|
|
'date': at_date or fields.Date.today(),
|
|
'ref': _('Stock Closing'),
|
|
'line_ids': [Command.create(aml_vals) for aml_vals in aml_vals_list],
|
|
}
|
|
account_move = self.env['account.move'].create(moves_vals)
|
|
self._save_closing_id(account_move.id)
|
|
if auto_post:
|
|
account_move._post()
|
|
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _("Journal Items"),
|
|
'res_model': 'account.move',
|
|
'res_id': account_move.id,
|
|
'views': [(False, 'form')],
|
|
}
|
|
|
|
def stock_value(self, accounts_by_product=None, at_date=None):
|
|
self.ensure_one()
|
|
value_by_account: dict = defaultdict(float)
|
|
if not accounts_by_product:
|
|
accounts_by_product = self._get_accounts_by_product()
|
|
for product, accounts in accounts_by_product.items():
|
|
account = accounts['valuation']
|
|
product_value = product.with_context(to_date=at_date).total_value
|
|
value_by_account[account] += product_value
|
|
return value_by_account
|
|
|
|
def stock_accounting_value(self, accounts_by_product=None, at_date=None):
|
|
self.ensure_one()
|
|
if not accounts_by_product:
|
|
accounts_by_product = self._get_accounts_by_product()
|
|
account_data = defaultdict(float)
|
|
stock_valuation_accounts_ids = set()
|
|
for dummy, accounts in accounts_by_product.items():
|
|
stock_valuation_accounts_ids.add(accounts['valuation'].id)
|
|
stock_valuation_accounts = self.env['account.account'].browse(stock_valuation_accounts_ids)
|
|
domain = Domain([
|
|
('account_id', 'in', stock_valuation_accounts.ids),
|
|
('company_id', '=', self.id),
|
|
('parent_state', '=', 'posted'),
|
|
])
|
|
if at_date:
|
|
domain = domain & Domain([('date', '<=', at_date)])
|
|
amls_group = self.env['account.move.line']._read_group(domain, ['account_id'], ['balance:sum'])
|
|
for account, balance in amls_group:
|
|
account_data[account] += balance
|
|
return account_data
|
|
|
|
def _action_close_stock_valuation(self, at_date=None):
|
|
aml_vals_list = []
|
|
accounts_by_product = self._get_accounts_by_product()
|
|
|
|
vals_list = self._get_location_valuation_vals(at_date)
|
|
if vals_list:
|
|
# Needed directly since it will impact the accounting stock valuation.
|
|
aml_vals_list += vals_list
|
|
|
|
vals_list = self._get_stock_valuation_account_vals(accounts_by_product, at_date, aml_vals_list)
|
|
if vals_list:
|
|
aml_vals_list += vals_list
|
|
|
|
vals_list = self._get_continental_realtime_variation_vals(accounts_by_product, at_date, aml_vals_list)
|
|
if vals_list:
|
|
aml_vals_list += vals_list
|
|
return aml_vals_list
|
|
|
|
@api.model
|
|
def _cron_post_stock_valuation(self):
|
|
domain = Domain([('inventory_period', '=', 'daily'), ('inventory_valuation', '!=', 'real_time')])
|
|
if fields.Date.today() == fields.Date.today() + relativedelta(day=31):
|
|
domain = domain & Domain([('inventory_period', '=', 'monthly')])
|
|
companies = self.env['res.company'].search(domain)
|
|
for company in companies:
|
|
company.action_close_stock_valuation(auto_post=True)
|
|
|
|
def _get_accounts_by_product(self, products=None):
|
|
if not products:
|
|
products = self.env['product.product'].with_company(self).search([('is_storable', '=', True)])
|
|
|
|
accounts_by_product = {}
|
|
for product in products:
|
|
accounts = product._get_product_accounts()
|
|
accounts_by_product[product] = {
|
|
'valuation': accounts['stock_valuation'],
|
|
'variation': accounts['stock_variation'],
|
|
'expense': accounts['expense'],
|
|
}
|
|
return accounts_by_product
|
|
|
|
@api.model
|
|
def _get_extra_balance(self, vals_list=None):
|
|
extra_balance = defaultdict(float)
|
|
if not vals_list:
|
|
return extra_balance
|
|
for vals in vals_list:
|
|
extra_balance[vals['account_id']] += (vals['debit'] - vals['credit'])
|
|
return extra_balance
|
|
|
|
def _get_location_valuation_vals(self, at_date=None, location_domain=False):
|
|
location_domain = Domain.AND([
|
|
location_domain or [],
|
|
[('valuation_account_id', '!=', False)],
|
|
[('company_id', '=', self.id)],
|
|
])
|
|
amls_vals_list = []
|
|
valued_location = self.env['stock.location'].search(location_domain)
|
|
last_closing_date = self._get_last_closing_date()
|
|
moves_base_domain = Domain([
|
|
('product_id.is_storable', '=', True),
|
|
('product_id.valuation', '=', 'periodic')
|
|
])
|
|
if last_closing_date:
|
|
moves_base_domain &= Domain([('date', '>', last_closing_date)])
|
|
if at_date:
|
|
moves_base_domain &= Domain([('date', '<=', at_date)])
|
|
moves_in_domain = Domain([
|
|
('is_out', '=', True),
|
|
('company_id', '=', self.id),
|
|
('location_dest_id', 'in', valued_location.ids),
|
|
]) & moves_base_domain
|
|
moves_in_by_location = self.env['stock.move']._read_group(
|
|
moves_in_domain,
|
|
['location_dest_id', 'product_category_id'],
|
|
['value:sum'],
|
|
)
|
|
moves_out_domain = Domain([
|
|
('is_in', '=', True),
|
|
('company_id', '=', self.id),
|
|
('location_id', 'in', valued_location.ids),
|
|
]) & moves_base_domain
|
|
moves_out_by_location = self.env['stock.move']._read_group(
|
|
moves_out_domain,
|
|
['location_id', 'product_category_id'],
|
|
['value:sum'],
|
|
)
|
|
account_balance = defaultdict(float)
|
|
for location, category, value in moves_in_by_location:
|
|
stock_valuation_acc = category.property_stock_valuation_account_id or self.account_stock_valuation_id
|
|
account_balance[location.valuation_account_id, stock_valuation_acc] += value
|
|
|
|
for location, category, value in moves_out_by_location:
|
|
stock_valuation_acc = category.property_stock_valuation_account_id or self.account_stock_valuation_id
|
|
account_balance[location.valuation_account_id, stock_valuation_acc] -= value
|
|
|
|
for (location_account, stock_account), balance in account_balance.items():
|
|
if balance == 0:
|
|
continue
|
|
amls_vals = self._prepare_inventory_aml_vals(
|
|
location_account,
|
|
stock_account,
|
|
balance,
|
|
_('Closing: Location Reclassification - [%(account)s]', account=location_account.display_name),
|
|
)
|
|
amls_vals_list += amls_vals
|
|
return amls_vals_list
|
|
|
|
def _get_stock_valuation_account_vals(self, accounts_by_product, at_date=None, extra_aml_vals_list=None):
|
|
amls_vals_list = []
|
|
if not accounts_by_product:
|
|
return amls_vals_list
|
|
|
|
extra_balance = self._get_extra_balance(extra_aml_vals_list)
|
|
|
|
if 'inventory_data' in self.env.context:
|
|
inventory_data = self.env.context.get('inventory_data')
|
|
else:
|
|
inventory_data = self.stock_value(accounts_by_product, at_date)
|
|
accounting_data = self.stock_accounting_value(accounts_by_product, at_date)
|
|
|
|
accounts = inventory_data.keys() | accounting_data.keys()
|
|
for account in accounts:
|
|
account_variation = account.account_stock_variation_id
|
|
if not account_variation:
|
|
account_variation = self.expense_account_id
|
|
if not account_variation:
|
|
continue
|
|
balance = inventory_data.get(account, 0) - accounting_data.get(account, 0)
|
|
balance -= extra_balance.get(account.id, 0)
|
|
|
|
if self.currency_id.is_zero(balance):
|
|
continue
|
|
|
|
amls_vals = self._prepare_inventory_aml_vals(
|
|
account,
|
|
account_variation,
|
|
balance,
|
|
_('Closing: Stock Variation Global for company [%(company)s]', company=self.display_name),
|
|
)
|
|
amls_vals_list += amls_vals
|
|
|
|
return amls_vals_list
|
|
|
|
def _get_continental_realtime_variation_vals(self, accounts_by_product, at_date=None, extra_aml_vals_list=None):
|
|
""" In continental perpetual the inventory variation is never posted.
|
|
This method compute the variation for a period and post it.
|
|
"""
|
|
extra_balance = self._get_extra_balance(extra_aml_vals_list)
|
|
|
|
fiscal_year_date_from = self.compute_fiscalyear_dates(fields.Date.today())['date_from']
|
|
|
|
amls_vals_list = []
|
|
accounting_data_today = self.stock_accounting_value(accounts_by_product)
|
|
accounting_data_last_period = self.stock_accounting_value(accounts_by_product, at_date=fiscal_year_date_from)
|
|
|
|
accounts = accounting_data_today.keys() | accounting_data_last_period.keys()
|
|
|
|
for account in accounts:
|
|
variation_acc = account.account_stock_variation_id
|
|
expense_acc = account.account_stock_expense_id
|
|
|
|
if not variation_acc or not expense_acc:
|
|
continue
|
|
|
|
balance_today = accounting_data_today.get(account, 0) - extra_balance[account]
|
|
balance_last_period = accounting_data_last_period.get(account, 0)
|
|
balance_over_period = balance_today - balance_last_period
|
|
|
|
current_balance_domain = Domain([
|
|
('account_id', '=', variation_acc.id),
|
|
('company_id', '=', self.id),
|
|
('parent_state', '=', 'posted'),
|
|
])
|
|
if at_date:
|
|
current_balance_domain &= Domain([('date', '<=', at_date)])
|
|
existing_balance = sum(self.env['account.move.line'].search(current_balance_domain).mapped('balance'))
|
|
balance_over_period += existing_balance
|
|
|
|
if self.currency_id.is_zero(balance_over_period):
|
|
continue
|
|
|
|
amls_vals = self._prepare_inventory_aml_vals(
|
|
expense_acc,
|
|
variation_acc,
|
|
balance_over_period,
|
|
_('Closing: Stock Variation Over Period'),
|
|
)
|
|
amls_vals_list += amls_vals
|
|
|
|
return amls_vals_list
|
|
|
|
def _prepare_inventory_aml_vals(self, debit_acc, credit_acc, balance, ref, product_id=False):
|
|
if balance < 0:
|
|
temp = credit_acc
|
|
credit_acc = debit_acc
|
|
debit_acc = temp
|
|
balance = abs(balance)
|
|
return [{
|
|
'account_id': credit_acc.id,
|
|
'name': ref,
|
|
'debit': 0,
|
|
'credit': balance,
|
|
'product_id': product_id,
|
|
}, {
|
|
'account_id': debit_acc.id,
|
|
'name': ref,
|
|
'debit': balance,
|
|
'credit': 0,
|
|
'product_id': product_id,
|
|
}]
|
|
|
|
def _get_last_closing_date(self):
|
|
self.ensure_one()
|
|
key = f'{self.id}.stock_valuation_closing_ids'
|
|
closing_ids = self.env['ir.config_parameter'].sudo().get_param(key)
|
|
closing_ids = closing_ids.split(',') if closing_ids else []
|
|
closing = self.env['account.move']
|
|
while not closing and closing_ids:
|
|
closing_id = closing_ids.pop(-1)
|
|
closing_id = int(closing_id)
|
|
closing = self.env['account.move'].browse(closing_id).exists().filtered(lambda am: am.state == 'posted')
|
|
if not closing:
|
|
return False
|
|
am_state_field = self.env['ir.model.fields'].search([('model', '=', 'account.move'), ('name', '=', 'state')], limit=1)
|
|
state_tracking = closing.message_ids.sudo().tracking_value_ids.filtered(lambda t: t.field_id == am_state_field).sorted('id')
|
|
return state_tracking[-1:].create_date or fields.Datetime.to_datetime(closing.date)
|
|
|
|
def _save_closing_id(self, move_id):
|
|
self.ensure_one()
|
|
key = f'{self.id}.stock_valuation_closing_ids'
|
|
closing_ids = self.env['ir.config_parameter'].sudo().get_param(key)
|
|
ids = closing_ids.split(',') if closing_ids else []
|
|
ids.append(str(move_id))
|
|
if len(ids) > 10:
|
|
ids = ids[1:]
|
|
self.env['ir.config_parameter'].sudo().set_param(key, ','.join(ids))
|
|
|
|
def _set_category_defaults(self):
|
|
for company in self:
|
|
self.env['ir.default'].set('product.category', 'property_valuation', company.inventory_valuation, company_id=company.id)
|
|
self.env['ir.default'].set('product.category', 'property_cost_method', company.cost_method, company_id=company.id)
|
|
self.env['ir.default'].set('product.category', 'property_stock_journal', company.account_stock_journal_id.id, company_id=company.id)
|
|
self.env['ir.default'].set('product.category', 'property_stock_valuation_account_id', company.account_stock_valuation_id.id, company_id=company.id)
|