account-closing/account_cutoff_base/models/account_cutoff.py

547 lines
19 KiB
Python
Raw Normal View History

2021-04-28 14:52:58 +00:00
# Copyright 2013-2021 Akretion (http://www.akretion.com/)
# @author: Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from collections import defaultdict
from dateutil.relativedelta import relativedelta
from flectra import _, api, fields, models
from flectra.exceptions import UserError
from flectra.tools import date_utils, float_is_zero
class AccountCutoff(models.Model):
_name = "account.cutoff"
_rec_name = "cutoff_date"
_order = "cutoff_date desc"
_inherit = ["mail.thread", "mail.activity.mixin"]
_check_company_auto = True
_description = "Account Cut-off"
@api.depends("line_ids", "line_ids.cutoff_amount")
def _compute_total_cutoff(self):
rg_res = self.env["account.cutoff.line"].read_group(
[("parent_id", "in", self.ids)],
["parent_id", "cutoff_amount"],
["parent_id"],
)
mapped_data = {x["parent_id"][0]: x["cutoff_amount"] for x in rg_res}
for cutoff in self:
cutoff.total_cutoff_amount = mapped_data.get(cutoff.id, 0)
@property
def cutoff_type_label_map(self):
return {
"accrued_expense": _("Accrued Expense"),
"accrued_revenue": _("Accrued Revenue"),
"prepaid_revenue": _("Prepaid Revenue"),
"prepaid_expense": _("Prepaid Expense"),
}
@api.model
def _default_move_label(self):
cutoff_type = self.env.context.get("default_cutoff_type")
label = self.cutoff_type_label_map.get(cutoff_type, "")
return label
@api.model
def _default_cutoff_date(self):
today = fields.Date.context_today(self)
company = self.env.company
date_from, date_to = date_utils.get_fiscal_year(
today,
day=company.fiscalyear_last_day,
month=int(company.fiscalyear_last_month),
)
if date_from:
return date_from - relativedelta(days=1)
else:
return False
def _selection_cutoff_type(self):
# generate cutoff types from mapping
return list(self.cutoff_type_label_map.items())
@api.model
def _default_cutoff_account_id(self):
cutoff_type = self.env.context.get("default_cutoff_type")
company = self.env.company
if cutoff_type == "accrued_expense":
account_id = company.default_accrued_expense_account_id.id or False
elif cutoff_type == "accrued_revenue":
account_id = company.default_accrued_revenue_account_id.id or False
elif cutoff_type == "prepaid_revenue":
account_id = company.default_prepaid_revenue_account_id.id or False
elif cutoff_type == "prepaid_expense":
account_id = company.default_prepaid_expense_account_id.id or False
else:
account_id = False
return account_id
cutoff_date = fields.Date(
string="Cut-off Date",
readonly=True,
states={"draft": [("readonly", False)]},
copy=False,
tracking=True,
default=lambda self: self._default_cutoff_date(),
)
cutoff_type = fields.Selection(
selection="_selection_cutoff_type",
string="Type",
required=True,
readonly=True,
states={"draft": [("readonly", False)]},
)
2023-05-19 08:19:13 +00:00
source_move_state = fields.Selection(
[("posted", "Posted Entries"), ("draft_posted", "Draft and Posted Entries")],
string="Source Entries",
required=True,
default="posted",
)
2021-04-28 14:52:58 +00:00
move_id = fields.Many2one(
"account.move",
string="Cut-off Journal Entry",
readonly=True,
copy=False,
check_company=True,
)
move_label = fields.Char(
string="Label of the Cut-off Journal Entry",
readonly=True,
states={"draft": [("readonly", False)]},
default=lambda self: self._default_move_label(),
help="This label will be written in the 'Name' field of the "
"Cut-off Account Move Lines and in the 'Reference' field of "
"the Cut-off Account Move.",
)
move_partner = fields.Boolean(
string="Partner on Move Line",
default=lambda self: self.env.company.default_cutoff_move_partner,
)
cutoff_account_id = fields.Many2one(
comodel_name="account.account",
string="Cut-off Account",
domain="[('deprecated', '=', False), ('company_id', '=', company_id)]",
readonly=True,
states={"draft": [("readonly", False)]},
default=lambda self: self._default_cutoff_account_id(),
check_company=True,
tracking=True,
)
cutoff_journal_id = fields.Many2one(
comodel_name="account.journal",
string="Cut-off Account Journal",
default=lambda self: self.env.company.default_cutoff_journal_id,
readonly=True,
states={"draft": [("readonly", False)]},
domain="[('company_id', '=', company_id)]",
check_company=True,
tracking=True,
)
total_cutoff_amount = fields.Monetary(
compute="_compute_total_cutoff",
string="Total Cut-off Amount",
currency_field="company_currency_id",
readonly=True,
tracking=True,
)
company_id = fields.Many2one(
"res.company",
string="Company",
required=True,
readonly=True,
states={"draft": [("readonly", False)]},
default=lambda self: self.env.company,
)
company_currency_id = fields.Many2one(
2023-05-19 08:19:13 +00:00
related="company_id.currency_id",
string="Company Currency",
store=True,
2021-04-28 14:52:58 +00:00
)
line_ids = fields.One2many(
comodel_name="account.cutoff.line",
inverse_name="parent_id",
string="Cut-off Lines",
readonly=True,
states={"draft": [("readonly", False)]},
)
state = fields.Selection(
selection=[("draft", "Draft"), ("done", "Done")],
index=True,
readonly=True,
tracking=True,
default="draft",
copy=False,
help="State of the cutoff. When the Journal Entry is created, "
"the state is set to 'Done' and the fields become read-only.",
)
_sql_constraints = [
(
"date_type_company_uniq",
"unique(cutoff_date, company_id, cutoff_type)",
_("A cutoff of the same type already exists with this cut-off date !"),
)
]
def back2draft(self):
self.ensure_one()
if self.move_id:
self.move_id.unlink()
self.write({"state": "draft"})
def _get_merge_keys(self):
"""Return merge criteria for provision lines
The returned list must contain valid field names
for account.move.line. Provision lines with the
same values for these fields will be merged.
The list must at least contain account_id.
"""
return ["partner_id", "account_id", "analytic_account_id"]
def _prepare_move(self, to_provision):
self.ensure_one()
movelines_to_create = []
amount_total = 0
move_label = self.move_label
merge_keys = self._get_merge_keys()
for merge_values, amount in to_provision.items():
amount = self.company_currency_id.round(amount)
vals = {
"debit": amount < 0 and amount * -1 or 0,
"credit": amount >= 0 and amount or 0,
}
for k, v in zip(merge_keys, merge_values):
vals[k] = v
movelines_to_create.append((0, 0, vals))
amount_total += amount
# add counter-part
counterpart_amount = self.company_currency_id.round(amount_total * -1)
movelines_to_create.append(
(
0,
0,
{
"account_id": self.cutoff_account_id.id,
"debit": counterpart_amount < 0 and counterpart_amount * -1 or 0,
"credit": counterpart_amount >= 0 and counterpart_amount or 0,
"analytic_account_id": False,
},
)
)
res = {
"company_id": self.company_id.id,
"journal_id": self.cutoff_journal_id.id,
"date": self.cutoff_date,
"ref": move_label,
"line_ids": movelines_to_create,
}
return res
def _prepare_provision_line(self, cutoff_line):
"""Convert a cutoff line to elements of a move line.
The returned dictionary must at least contain 'account_id'
and 'amount' (< 0 means debit).
If you override this, the added fields must also be
added in an override of _get_merge_keys.
"""
partner_id = cutoff_line.partner_id.id or False
return {
"partner_id": self.move_partner and partner_id or False,
"account_id": cutoff_line.cutoff_account_id.id,
"analytic_account_id": cutoff_line.analytic_account_id.id,
"amount": cutoff_line.cutoff_amount,
}
def _prepare_provision_tax_line(self, cutoff_tax_line):
"""Convert a cutoff tax line to elements of a move line.
See _prepare_provision_line for more info.
"""
return {
"partner_id": False,
"account_id": cutoff_tax_line.cutoff_account_id.id,
"analytic_account_id": cutoff_tax_line.analytic_account_id.id,
"amount": cutoff_tax_line.cutoff_amount,
}
def _merge_provision_lines(self, provision_lines):
"""Merge provision line.
Returns a dictionary {key, amount} where key is
a tuple containing the values of the properties in _get_merge_keys()
"""
to_provision = defaultdict(float)
merge_keys = self._get_merge_keys()
for provision_line in provision_lines:
key = tuple([provision_line.get(key) for key in merge_keys])
to_provision[key] += provision_line["amount"]
return to_provision
def create_move(self):
self.ensure_one()
move_obj = self.env["account.move"]
if self.move_id:
raise UserError(
_(
"The Cut-off Journal Entry already exists. You should "
"delete it before running this function."
)
)
if not self.line_ids:
raise UserError(
_(
"There are no lines on this Cut-off, so we can't create "
"a Journal Entry."
)
)
provision_lines = []
for line in self.line_ids:
provision_lines.append(self._prepare_provision_line(line))
for tax_line in line.tax_line_ids:
provision_lines.append(self._prepare_provision_tax_line(tax_line))
to_provision = self._merge_provision_lines(provision_lines)
vals = self._prepare_move(to_provision)
move = move_obj.create(vals)
2023-05-19 08:19:13 +00:00
if self.company_id.post_cutoff_move:
move._post(soft=False)
2021-04-28 14:52:58 +00:00
self.write({"move_id": move.id, "state": "done"})
self.message_post(body=_("Journal entry generated"))
2022-02-06 03:11:54 +00:00
xmlid = "account.action_move_journal_line"
action = self.env["ir.actions.act_window"]._for_xml_id(xmlid)
2021-04-28 14:52:58 +00:00
action.update(
{
"view_mode": "form,tree",
"res_id": move.id,
"view_id": False,
"views": False,
}
)
return action
def get_lines(self):
"""This method is designed to be inherited in other modules"""
self.ensure_one()
# Delete existing lines
self.line_ids.unlink()
self.message_post(body=_("Cut-off lines re-generated"))
return True
def unlink(self):
for rec in self:
if rec.state != "draft":
raise UserError(
_(
"You cannot delete cutoff records that are not "
"in draft state."
)
)
return super().unlink()
def button_line_tree(self):
2022-02-06 03:11:54 +00:00
xmlid = "account_cutoff_base.account_cutoff_line_action"
action = self.env["ir.actions.act_window"]._for_xml_id(xmlid)
2021-04-28 14:52:58 +00:00
action.update(
{
"domain": [("parent_id", "=", self.id)],
"views": False,
}
)
return action
def _get_mapping_dict(self):
"""return a dict with:
key = ID of account,
value = ID of cutoff_account"""
self.ensure_one()
mappings = self.env["account.cutoff.mapping"].search(
[
("company_id", "=", self.company_id.id),
("cutoff_type", "in", ("all", self.cutoff_type)),
]
)
mapping = {}
for item in mappings:
mapping[item.account_id.id] = item.cutoff_account_id.id
return mapping
def _prepare_tax_lines(self, tax_compute_all_res, currency):
res = []
ato = self.env["account.tax"]
company_currency = self.company_id.currency_id
cur_rprec = company_currency.rounding
for tax_line in tax_compute_all_res["taxes"]:
tax = ato.browse(tax_line["id"])
if float_is_zero(tax_line["amount"], precision_rounding=cur_rprec):
continue
if self.cutoff_type == "accrued_expense":
tax_accrual_account_id = tax.account_accrued_expense_id.id
tax_account_field_label = _("Accrued Expense Tax Account")
elif self.cutoff_type == "accrued_revenue":
tax_accrual_account_id = tax.account_accrued_revenue_id.id
tax_account_field_label = _("Accrued Revenue Tax Account")
if not tax_accrual_account_id:
raise UserError(
_("Missing '%s' on tax '%s'.")
% (tax_account_field_label, tax.display_name)
)
tax_amount = currency.round(tax_line["amount"])
tax_accrual_amount = currency._convert(
tax_amount, company_currency, self.company_id, self.cutoff_date
)
res.append(
(
0,
0,
{
"tax_id": tax_line["id"],
"base": tax_line["base"], # in currency
"amount": tax_amount, # in currency
"sequence": tax_line["sequence"],
"cutoff_account_id": tax_accrual_account_id,
"cutoff_amount": tax_accrual_amount, # in company currency
},
)
)
return res
class AccountCutoffLine(models.Model):
_name = "account.cutoff.line"
_description = "Account Cut-off Line"
parent_id = fields.Many2one("account.cutoff", string="Cut-off", ondelete="cascade")
name = fields.Char("Description")
company_currency_id = fields.Many2one(
related="parent_id.company_currency_id",
string="Company Currency",
2023-05-19 08:19:13 +00:00
store=True,
2021-04-28 14:52:58 +00:00
)
partner_id = fields.Many2one("res.partner", string="Partner", readonly=True)
quantity = fields.Float(
string="Quantity", digits="Product Unit of Measure", readonly=True
)
price_unit = fields.Float(
string="Unit Price w/o Tax",
digits="Product Price",
readonly=True,
help="Price per unit (discount included) without taxes in the default "
"unit of measure of the product in the currency of the 'Currency' field.",
)
price_origin = fields.Char(readonly=True)
origin_move_line_id = fields.Many2one(
"account.move.line", string="Origin Journal Item", readonly=True
) # Old name: move_line_id
origin_move_id = fields.Many2one(
related="origin_move_line_id.move_id", string="Origin Journal Entry"
) # old name: move_id
origin_move_date = fields.Date(
related="origin_move_line_id.move_id.date", string="Origin Journal Entry Date"
) # old name: move_date
account_id = fields.Many2one(
"account.account",
"Account",
required=True,
readonly=True,
)
cutoff_account_id = fields.Many2one(
"account.account",
string="Cut-off Account",
required=True,
readonly=True,
)
cutoff_account_code = fields.Char(
2023-05-19 08:19:13 +00:00
related="cutoff_account_id.code",
string="Cut-off Account Code",
store=True,
2021-04-28 14:52:58 +00:00
)
analytic_account_id = fields.Many2one(
"account.analytic.account",
string="Analytic Account",
readonly=True,
)
currency_id = fields.Many2one(
"res.currency",
string="Amount Currency",
readonly=True,
help="Currency of the 'Amount' field.",
)
amount = fields.Monetary(
currency_field="currency_id",
readonly=True,
help="Amount that is used as base to compute the Cut-off Amount. "
"This Amount is in the 'Amount Currency', which may be different "
"from the 'Company Currency'.",
)
cutoff_amount = fields.Monetary(
string="Cut-off Amount",
currency_field="company_currency_id",
readonly=True,
help="Cut-off Amount without taxes in the Company Currency.",
)
tax_line_ids = fields.One2many(
"account.cutoff.tax.line",
"parent_id",
string="Cut-off Tax Lines",
readonly=True,
)
notes = fields.Text()
class AccountCutoffTaxLine(models.Model):
_name = "account.cutoff.tax.line"
_description = "Account Cut-off Tax Line"
parent_id = fields.Many2one(
"account.cutoff.line",
string="Account Cut-off Line",
ondelete="cascade",
required=True,
)
tax_id = fields.Many2one("account.tax", string="Tax", required=True)
cutoff_account_id = fields.Many2one(
"account.account",
string="Cut-off Account",
required=True,
readonly=True,
)
analytic_account_id = fields.Many2one(
"account.analytic.account",
string="Analytic Account",
readonly=True,
)
base = fields.Monetary(
currency_field="currency_id",
readonly=True,
help="Base Amount in the currency of the PO.",
)
amount = fields.Monetary(
string="Tax Amount",
currency_field="currency_id",
readonly=True,
help="Tax Amount in the currency of the PO.",
)
sequence = fields.Integer(readonly=True)
cutoff_amount = fields.Monetary(
string="Cut-off Tax Amount",
currency_field="company_currency_id",
readonly=True,
help="Tax Cut-off Amount in the company currency.",
)
currency_id = fields.Many2one(
2023-05-19 08:19:13 +00:00
related="parent_id.currency_id", string="Currency", store=True
2021-04-28 14:52:58 +00:00
)
company_currency_id = fields.Many2one(
related="parent_id.company_currency_id",
string="Company Currency",
2023-05-19 08:19:13 +00:00
store=True,
2021-04-28 14:52:58 +00:00
)