[IMP] analytic, *: Json field for analytic distribution

The `analytic_distribution` field is a Json.
It was stored temporarily as a char.

Search is not available yet, so we do queries by hand when we need to search on keys.

Also added a constraint on account_analytic_distribution_model,
so we don't have models with accounts specific to a company when the model has no company or another company.
It would cause an issue when looking at the models from another company.

X-original-commit: 7064c95aa04e5138bb12ae97acfee04ebb67cc0e
Part-of: odoo/odoo#103097
This commit is contained in:
gawa-odoo
2022-10-17 10:11:09 +02:00
parent 7a366abb40
commit b8dca3b174
36 changed files with 211 additions and 173 deletions
@@ -41,10 +41,16 @@ class AccountAnalyticAccount(models.Model):
def action_view_invoice(self):
self.ensure_one()
query = self.env['account.move.line']._search([('move_id.move_type', 'in', self.env['account.move'].get_sale_types())])
query.order = None
query.add_where('analytic_distribution ? %s', [str(self.id)])
query_string, query_param = query.select('DISTINCT move_id')
self._cr.execute(query_string, query_param)
move_ids = [line.get('move_id') for line in self._cr.dictfetchall()]
result = {
"type": "ir.actions.act_window",
"res_model": "account.move",
"domain": [('line_ids.analytic_distribution_stored_char', '=ilike', f'%"{self.id}":%'), ('move_type', 'in', self.env['account.move'].get_sale_types())],
"domain": [('id', 'in', move_ids)],
"context": {"create": False},
"name": "Customer Invoices",
'view_mode': 'tree,form',
@@ -53,10 +59,16 @@ class AccountAnalyticAccount(models.Model):
def action_view_vendor_bill(self):
self.ensure_one()
query = self.env['account.move.line']._search([('move_id.move_type', 'in', self.env['account.move'].get_purchase_types())])
query.order = None
query.add_where('analytic_distribution ? %s', [str(self.id)])
query_string, query_param = query.select('DISTINCT move_id')
self._cr.execute(query_string, query_param)
move_ids = [line.get('move_id') for line in self._cr.dictfetchall()]
result = {
"type": "ir.actions.act_window",
"res_model": "account.move",
"domain": [('line_ids.analytic_distribution_stored_char', '=ilike', f'%"{self.id}":%'), ('move_type', 'in', self.env['account.move'].get_purchase_types())],
"domain": [('id', 'in', move_ids)],
"context": {"create": False},
"name": "Vendor Bills",
'view_mode': 'tree,form',
+7 -7
View File
@@ -15,7 +15,7 @@ INTEGRITY_HASH_LINE_FIELDS = ('debit', 'credit', 'account_id', 'partner_id')
class AccountMoveLine(models.Model):
_name = "account.move.line"
_inherit = 'analytic.mixin'
_inherit = "analytic.mixin"
_description = "Journal Item"
_order = "date desc, move_name desc, sequence, id"
_check_company_auto = True
@@ -352,6 +352,9 @@ class AccountMoveLine(models.Model):
comodel_name='account.analytic.line', inverse_name='move_line_id',
string='Analytic lines',
)
analytic_distribution = fields.Json(
inverse="_inverse_analytic_distribution",
) # add the inverse function used to trigger the creation/update of the analytic lines accordingly (field originally defined in the analytic mixin)
# === Early Pay fields === #
discount_date = fields.Date(
@@ -1071,10 +1074,10 @@ class AccountMoveLine(models.Model):
line.term_key = False
@api.depends('account_id', 'partner_id', 'product_id')
def _compute_analytic_distribution_stored_char(self):
def _compute_analytic_distribution(self):
for line in self:
if line.display_type == 'product' or not line.move_id.is_invoice(include_receipts=True):
distribution = self.env['account.analytic.distribution.model']._get_distributionjson({
distribution = self.env['account.analytic.distribution.model']._get_distribution({
"product_id": line.product_id.id,
"product_categ_id": line.product_id.categ_id.id,
"partner_id": line.partner_id.id,
@@ -1082,8 +1085,7 @@ class AccountMoveLine(models.Model):
"account_prefix": line.account_id.code,
"company_id": line.company_id.id,
})
line.analytic_distribution_stored_char = distribution or line.analytic_distribution_stored_char
line._compute_analytic_distribution()
line.analytic_distribution = distribution or line.analytic_distribution
# -------------------------------------------------------------------------
# INVERSE METHODS
@@ -1125,7 +1127,6 @@ class AccountMoveLine(models.Model):
@api.onchange('analytic_distribution')
def _inverse_analytic_distribution(self):
""" Unlink and recreate analytic_lines when modifying the distribution."""
super()._inverse_analytic_distribution()
lines_to_modify = self.env['account.move.line'].browse([
line.id for line in self if line.parent_state == "posted"
])
@@ -2365,7 +2366,6 @@ class AccountMoveLine(models.Model):
def _prepare_analytic_lines(self):
self.ensure_one()
analytic_line_vals = []
self._compute_analytic_distribution()
if self.analytic_distribution:
# distribution_on_each_plan corresponds to the proportion that is distributed to each plan to be able to
# give the real amount when we achieve a 100% distribution
@@ -182,8 +182,8 @@ class AccountMoveLine(models.Model):
)
AND (
NOT tax.analytic
OR (base_line.analytic_distribution_stored_char IS NULL AND account_move_line.analytic_distribution_stored_char IS NULL)
OR base_line.analytic_distribution_stored_char = account_move_line.analytic_distribution_stored_char
OR (base_line.analytic_distribution IS NULL AND account_move_line.analytic_distribution IS NULL)
OR base_line.analytic_distribution = account_move_line.analytic_distribution
)
LEFT JOIN affecting_base_tax_ids tax_line_tax_ids ON tax_line_tax_ids.id = account_move_line.id
JOIN affecting_base_tax_ids base_line_tax_ids ON base_line_tax_ids.id = base_line.id
+14 -10
View File
@@ -21,10 +21,12 @@ class TestAccountAnalyticAccount(AccountTestInvoicingCommon):
cls.analytic_account_a = cls.env['account.analytic.account'].create({
'name': 'analytic_account_a',
'plan_id': cls.default_plan.id,
'company_id': False,
})
cls.analytic_account_b = cls.env['account.analytic.account'].create({
'name': 'analytic_account_b',
'plan_id': cls.default_plan.id,
'company_id': False,
})
def create_invoice(self, partner, product):
@@ -123,7 +125,7 @@ class TestAccountAnalyticAccount(AccountTestInvoicingCommon):
# Partner and product match, score 2
invoice = self.create_invoice(self.partner_a, self.product_a)
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {self.analytic_account_b.id: 100})
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {str(self.analytic_account_b.id): 100})
# Match the partner but not the product, score 0
invoice = self.create_invoice(self.partner_a, self.product_b)
@@ -131,7 +133,7 @@ class TestAccountAnalyticAccount(AccountTestInvoicingCommon):
# Product match, score 1
invoice = self.create_invoice(self.partner_b, self.product_a)
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {self.analytic_account_a.id: 100})
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {str(self.analytic_account_a.id): 100})
# No rule match with the product, score 0
invoice = self.create_invoice(self.partner_b, self.product_b)
@@ -141,10 +143,12 @@ class TestAccountAnalyticAccount(AccountTestInvoicingCommon):
"""Test that the distribution is recomputed if and only if it is needed when changing the partner."""
self.env['account.analytic.distribution.model'].create([{
'partner_id': self.partner_a.id,
'analytic_distribution': {self.analytic_account_a.id: 100}
'analytic_distribution': {self.analytic_account_a.id: 100},
'company_id': False,
}, {
'partner_id': self.partner_b.id,
'analytic_distribution': {self.analytic_account_b.id: 100}
'analytic_distribution': {self.analytic_account_b.id: 100},
'company_id': False,
}])
invoice = self.create_invoice(self.env['res.partner'], self.product_a)
@@ -153,27 +157,27 @@ class TestAccountAnalyticAccount(AccountTestInvoicingCommon):
# A model is found, set the new values
invoice.partner_id = self.partner_a
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {self.analytic_account_a.id: 100})
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {str(self.analytic_account_a.id): 100})
# A model is found, set the new values
invoice.partner_id = self.partner_b
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {self.analytic_account_b.id: 100})
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {str(self.analytic_account_b.id): 100})
# No model is found, don't change previously set values
invoice.partner_id = invoice.company_id.partner_id
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {self.analytic_account_b.id: 100})
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {str(self.analytic_account_b.id): 100})
# No model is found, don't change previously set values
invoice.partner_id = False
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {self.analytic_account_b.id: 100})
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {str(self.analytic_account_b.id): 100})
# It manual value is not erased in form view when saving
with Form(invoice) as invoice_form:
invoice_form.partner_id = self.partner_a
with invoice_form.invoice_line_ids.edit(0) as line_form:
self.assertEqual(line_form.analytic_distribution, {self.analytic_account_a.id: 100})
self.assertEqual(line_form.analytic_distribution, {str(self.analytic_account_a.id): 100})
line_form.analytic_distribution = {self.analytic_account_b.id: 100}
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {self.analytic_account_b.id: 100})
self.assertEqual(invoice.invoice_line_ids.analytic_distribution, {str(self.analytic_account_b.id): 100})
def test_mandatory_plan_validation(self):
invoice = self.create_invoice(self.partner_b, self.product_a)
@@ -1173,7 +1173,7 @@ class TestAccountMoveOutInvoiceOnchanges(AccountTestInvoicingCommon):
'code': 'TEST'
})
analytic_distribution = {analytic_account.id: 100}
analytic_distribution = {str(analytic_account.id): 100.00}
move_form = Form(self.invoice)
with move_form.invoice_line_ids.edit(0) as line_form:
@@ -1291,7 +1291,7 @@ class TestAccountMoveOutInvoiceOnchanges(AccountTestInvoicingCommon):
'code': 'TEST1'
})
analytic_distribution = {analytic_account.id: 100}
analytic_distribution = {str(analytic_account.id): 100.00}
self.invoice.write({'invoice_line_ids': [(1, self.invoice.invoice_line_ids.ids[0], {
'analytic_distribution': analytic_distribution,
@@ -78,7 +78,7 @@ class AccountingTestTemplConsistency(TransactionCase):
)
# lines
self.check_fields_consistency('account.reconcile.model.line.template', 'account.reconcile.model.line', exceptions=['chart_template_id'])
self.check_fields_consistency('account.reconcile.model.line', 'account.reconcile.model.line.template', exceptions=['company_id', 'journal_id', 'analytic_distribution_stored_char', 'amount'])
self.check_fields_consistency('account.reconcile.model.line', 'account.reconcile.model.line.template', exceptions=['company_id', 'journal_id', 'analytic_distribution', 'amount'])
def test_account_group_fields(self):
'''Test fields consistency for ('account.group', 'account.group.template')
@@ -70,7 +70,6 @@
<field name="blocked"/>
</group>
<group string="Analytic" groups="analytic.group_analytic_accounting">
<field name="analytic_distribution_stored_char" invisible="1"/>
<field name="analytic_distribution" widget="analytic_distribution" groups="analytic.group_analytic_accounting"
readonly="1"/>
</group>
@@ -187,7 +186,6 @@
<field name="matching_number" readonly="1" optional="show"/>
<field name="amount_residual" sum="Total Residual" string="Residual" readonly="1" optional="hide" attrs="{'invisible':[('is_account_reconcile', '=', False)]}"/>
<field name="amount_residual_currency" sum="Total Residual in Currency" string="Residual in Currency" readonly="1" optional="hide" attrs="{'invisible':['|', ('is_same_currency', '=', True), ('is_account_reconcile', '=', False)]}"/>
<field name="analytic_distribution_stored_char" invisible="1"/>
<field name="analytic_distribution" widget="analytic_distribution"
groups="analytic.group_analytic_accounting"
optional="show"
@@ -885,7 +883,6 @@
options="{'no_create': True}"
domain="[('deprecated', '=', False), ('account_type', 'not in', ('asset_receivable', 'liability_payable')), ('company_id', '=', parent.company_id), ('is_off_balance', '=', False)]"
attrs="{'required': [('display_type', 'not in', ('line_note', 'line_section'))]}"/>
<field name="analytic_distribution_stored_char" invisible="1"/>
<field name="analytic_distribution" widget="analytic_distribution"
groups="analytic.group_analytic_accounting"
optional="show"
@@ -994,7 +991,6 @@
<group>
<field name="account_id" options="{'no_create': True}" domain="[('company_id', '=', company_id)]" context="{'partner_id': partner_id, 'move_type': parent.move_type}"/>
<field name="tax_ids" widget="many2many_tags"/>
<field name="analytic_distribution_stored_char" invisible="1"/>
<field name="analytic_distribution" widget="analytic_distribution" groups="analytic.group_analytic_accounting"/>
</group>
<label for="name" string="Description" attrs="{'invisible': [('display_type', 'in', ('line_note', 'line_section'))]}"/>
@@ -1056,7 +1052,6 @@
domain="['|', ('parent_id', '=', False), ('is_company', '=', True)]"
attrs="{'column_invisible': [('parent.move_type', '!=', 'entry')]}"/>
<field name="name" widget="section_and_note_text" optional="show"/>
<field name="analytic_distribution_stored_char" invisible="1"/>
<field name="analytic_distribution" widget="analytic_distribution"
groups="analytic.group_analytic_accounting"
optional="show"
@@ -1140,7 +1135,6 @@
<field name="account_id" domain="[('company_id', '=', parent.company_id), ('deprecated', '=', False)]"/>
<field name="partner_id" domain="['|', ('parent_id', '=', False), ('is_company', '=', True)]"/>
<field name="name"/>
<field name="analytic_distribution_stored_char" invisible="1"/>
<field name="analytic_distribution" widget="analytic_distribution" groups="analytic.group_analytic_accounting"/>
<field name="amount_currency" groups="base.group_multi_currency"/>
<field name="company_currency_id" invisible="1"/>
@@ -24,7 +24,6 @@
<field name="show_force_tax_included" invisible="1"/>
<field name="force_tax_included"
attrs="{'invisible': [('show_force_tax_included', '=', False)]}" force_save="1"/>
<field name="analytic_distribution_stored_char" invisible="1"/>
<field name="analytic_distribution" widget="analytic_distribution"
groups="analytic.group_analytic_accounting"
options="{'account_field': 'account_id', 'business_domain': 'general'}"/>
@@ -244,7 +243,6 @@
<field name="tax_ids"
widget="many2many_tags"
optional="hide"/>
<field name="analytic_distribution_stored_char" invisible="1"/>
<field name="analytic_distribution" widget="analytic_distribution"
groups="analytic.group_analytic_accounting"
options="{'account_field': 'account_id', 'business_domain': 'general'}"/>
+1
View File
@@ -21,6 +21,7 @@ that have no counterpart in the general financial accounts.
'views/analytic_account_views.xml',
'views/analytic_plan_views.xml',
'views/analytic_distribution_model_views.xml',
'data/analytic_data.xml'
],
'demo': [
'data/analytic_account_demo.xml'
+2 -2
View File
@@ -3,7 +3,7 @@
<data noupdate="1">
<record id="decimal_percentage_analytic" model="decimal.precision">
<field name="name">Percentage Analytic</field>
<field name="digits">10</field>
<field name="digits" eval="2"/>
</record>
</data>
</odoo>
</odoo>
@@ -1,8 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
import json
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class AccountAnalyticDistributionModel(models.Model):
_name = 'account.analytic.distribution.model'
@@ -26,10 +27,27 @@ class AccountAnalyticDistributionModel(models.Model):
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
ondelete='cascade',
help="Select a company for which the analytic distribution will be used (e.g. create new customer invoice or Sales order if we select this company, it will automatically take this as an analytic account)",
)
@api.constrains('company_id')
def _check_company_accounts(self):
query = """
SELECT model.id
FROM account_analytic_distribution_model model
JOIN account_analytic_account account
ON model.analytic_distribution ? CAST(account.id AS VARCHAR)
WHERE account.company_id IS NOT NULL
AND (model.company_id IS NULL
OR model.company_id != account.company_id)
GROUP BY model.id
"""
self.env.cr.execute(query)
if self.env.cr.dictfetchone():
raise UserError(_('You defined a distribution with analytic account(s) belonging to a specific company but a model shared between companies or with a different company'))
@api.model
def _get_distribution(self, vals):
""" Returns the distribution model that has the most fields that corresponds to the vals given
@@ -71,9 +89,3 @@ class AccountAnalyticDistributionModel(models.Model):
return [(fname, 'in', value)]
else:
return [(fname, 'in', [value, False])]
@api.model
def _get_distributionjson(self, vals):
""" Returns the distribution model as a json for the compute_analytic_distribution_stored_char functions"""
distribution = self._get_distribution(vals)
return json.dumps(distribution) if distribution else None
+37 -35
View File
@@ -1,49 +1,51 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields, api
import json
from odoo.tools import float_repr
from odoo.tools.float_utils import float_round
class AnalyticMixin(models.AbstractModel):
_name = 'analytic.mixin'
_description = 'Analytic Mixin'
# We create 2 different fields, with a computed binary field, so we don't have to decode encode each time the json.
# We also format the float values of the stored field, so we can use it as key (for tax detail for ex.)
analytic_distribution_stored_char = fields.Char(
compute="_compute_analytic_distribution_stored_char", store=True, copy=True)
analytic_distribution = fields.Binary(
string="Analytic",
compute="_compute_analytic_distribution",
inverse="_inverse_analytic_distribution",
readonly=False,
analytic_distribution = fields.Json(
'Analytic',
compute="_compute_analytic_distribution", store=True, copy=True, readonly=False,
precompute=True
)
def _compute_analytic_distribution_stored_char(self):
def init(self):
# Add a gin index for json search on the keys, on the models that actually have a table
query = ''' SELECT table_name
FROM information_schema.tables
WHERE table_name=%s '''
self.env.cr.execute(query, [self._table])
if self.env.cr.dictfetchone():
query = f"""
CREATE INDEX IF NOT EXISTS {self._table}_analytic_distribution_index
ON {self._table} USING gin(analytic_distribution);
"""
self.env.cr.execute(query)
def _compute_analytic_distribution(self):
pass
@api.depends('analytic_distribution_stored_char')
def _compute_analytic_distribution(self):
for record in self:
if record.analytic_distribution_stored_char:
distribution_to_return = {}
distribution_json = json.loads(record.analytic_distribution_stored_char)
for account, distribution in distribution_json.items():
distribution_to_return[int(account)] = float(distribution)
# Check if the account exists, can be removed when we have a constraint between account and model
account_ids = self.env['account.analytic.account'].browse(distribution_to_return.keys()).exists().ids
record.analytic_distribution = {account_id: distribution_to_return[account_id] for account_id in account_ids}
@api.onchange('analytic_distribution')
def _inverse_analytic_distribution(self):
def write(self, vals):
""" Format the analytic_distribution float value, so equality on analytic_distribution can be done """
decimal_precision = self.env['decimal.precision'].precision_get('Percentage Analytic')
self.env.remove_to_compute(self._fields['analytic_distribution_stored_char'], self)
for record in self:
if not record.analytic_distribution:
record.analytic_distribution_stored_char = None
else:
distribution_to_return = {}
for account, distribution in record.analytic_distribution.items():
distribution_to_return[account] = float_repr(distribution, decimal_precision)
record.analytic_distribution_stored_char = json.dumps(distribution_to_return)
vals = self._sanitize_values(vals, decimal_precision)
return super().write(vals)
@api.model_create_multi
def create(self, vals_list):
""" Format the analytic_distribution float value, so equality on analytic_distribution can be done """
decimal_precision = self.env['decimal.precision'].precision_get('Percentage Analytic')
vals_list = [self._sanitize_values(vals, decimal_precision) for vals in vals_list]
return super().create(vals_list)
def _sanitize_values(self, vals, decimal_precision):
""" Normalize the float of the distribution """
if 'analytic_distribution' in vals:
vals['analytic_distribution'] = vals.get('analytic_distribution') and {
account_id: float_round(distribution, decimal_precision) for account_id, distribution in vals['analytic_distribution'].items()}
return vals
@@ -79,6 +79,12 @@ export class AnalyticDistribution extends Component {
if (this.editingRecord) {
await this.fetchAllPlans(this.props);
}
const args = {
domain: [["name", "like", "Percentage Analytic"]],
fields: ["digits"],
context: [],
}
this.decimal_precision = await this.orm.call("decimal.precision", "search_read", [], args);
await this.formatData(this.props);
}
@@ -559,7 +565,8 @@ export class AnalyticDistribution extends Component {
}
formatPercentage(value) {
return formatPercentage(value / 100, { digits: [false, 2] });
return formatPercentage(value / 100, { digits: [false, this.decimal_precision[0].digits || 2] });
}
}
AnalyticDistribution.template = "analytic_distribution";
@@ -63,7 +63,7 @@ QUnit.module("Analytic", (hooks) => {
fields: {
label: { string: "Label", type: "char" },
amount: { string: "Amount", type: "float" },
analytic_distribution: { string: "Analytic", type: "char" },
analytic_distribution: { string: "Analytic", type: "json" },
move_id: { string: "Account Move", type: "many2one", relation: "move" },
},
records: [
@@ -81,6 +81,15 @@ QUnit.module("Analytic", (hooks) => {
{ id: 1, display_name: "INV0001", line_ids: [1, 2]},
{ id: 2, display_name: "INV0002", line_ids: [3, 4]},
],
},
"decimal.precision": {
fields: {
name: { string: "Name", type: "char" },
digits: { string: "Digits", type: "int" },
},
records: [
{ id: 1, name: "Percentage Analytic", digits: 2}
]
}
},
};
@@ -114,8 +114,8 @@ class TestAnalyticAccount(TransactionCase):
distribution_json = self.env['account.analytic.distribution.model']._get_distribution({
"partner_id": self.partner_a.id,
})
self.assertEqual(distribution_json, self.distribution_1.analytic_distribution, "Distribution 1 should be given")
self.assertEqual(distribution_json, {str(self.analytic_account_3.id): 100}, "Distribution 1 should be given")
distribution_json = self.env['account.analytic.distribution.model']._get_distribution({
"partner_id": self.partner_b.id,
})
self.assertEqual(distribution_json, self.distribution_2.analytic_distribution, "Distribution 2 should be given")
self.assertEqual(distribution_json, {str(self.analytic_account_2.id): 100}, "Distribution 2 should be given")
@@ -5,7 +5,6 @@
<field name="model">account.analytic.distribution.model</field>
<field name="arch" type="xml">
<tree string="Analytic Distribution Model">
<field name="analytic_distribution_stored_char" invisible="1"/>
<field name="partner_id" optional="show"/>
<field name="partner_category_id" optional="hide"/>
<field name="company_id" groups="base.group_multi_company" optional="show"/>
@@ -21,7 +20,6 @@
<form string="Analytic Distribution Model">
<sheet>
<group>
<field name="analytic_distribution_stored_char" invisible="1"/>
<group string="Simultaneous conditions to meet" colspan="2">
<group>
<field name="partner_id"/>
+3 -4
View File
@@ -307,16 +307,15 @@ class HrExpense(models.Model):
expenses = expenses - exp
@api.depends('product_id', 'account_id')
def _compute_analytic_distribution_stored_char(self):
def _compute_analytic_distribution(self):
for expense in self:
distribution = self.env['account.analytic.distribution.model']._get_distributionjson({
distribution = self.env['account.analytic.distribution.model']._get_distribution({
'product_id': expense.product_id.id,
'product_categ_id': expense.product_id.categ_id.id,
'account_prefix': expense.account_id.code,
'company_id': expense.company_id.id,
})
expense.analytic_distribution_stored_char = distribution or expense.analytic_distribution_stored_char
expense._compute_analytic_distribution()
expense.analytic_distribution = distribution or expense.analytic_distribution
@api.constrains('payment_mode')
def _check_payment_mode(self):
@@ -51,7 +51,7 @@
<span t-field="line.description"/><br/>
<span>
<div t-foreach="line.analytic_distribution" t-as="distribution">
<t t-esc="line.env['account.analytic.account'].browse(distribution)[0].name"/>: <t t-esc="line.analytic_distribution.get(distribution)"/>
<t t-esc="line.env['account.analytic.account'].browse(int(distribution))[0].name"/>: <t t-esc="line.analytic_distribution.get(distribution)"/>
</div>
</span>
</td>
+3 -3
View File
@@ -199,7 +199,7 @@ class TestExpenses(TestExpenseCommon):
'product_id': self.product_b.id,
'currency_id': self.currency_data['currency'].id,
'tax_line_id': False,
'analytic_distribution': {self.analytic_account_2.id: 100},
'analytic_distribution': {str(self.analytic_account_2.id): 100},
},
# Product line (company currency):
{
@@ -210,7 +210,7 @@ class TestExpenses(TestExpenseCommon):
'product_id': self.product_a.id,
'currency_id': self.company_data['currency'].id,
'tax_line_id': False,
'analytic_distribution': {self.analytic_account_1.id: 100},
'analytic_distribution': {str(self.analytic_account_1.id): 100},
},
])
@@ -319,7 +319,7 @@ class TestExpenses(TestExpenseCommon):
'unit_amount': 350.00,
'tax_ids': [(6, 0, [self.tax_purchase_a.id])],
'sheet_id': expense.id,
'analytic_distribution': {self.analytic_account_1.id: 100},
'analytic_distribution': {str(self.analytic_account_1.id): 100},
})
expense.action_submit_sheet()
@@ -56,7 +56,6 @@
<field name="activity_ids" widget="list_activity" optional="show"/>
<field name="accounting_date" optional="hide" groups="account.group_account_invoice,account.group_account_readonly" readonly="0"/>
<field name="reference" optional="hide" groups="account.group_account_readonly"/>
<field name="analytic_distribution_stored_char" invisible="1"/>
<field name="analytic_distribution" widget="analytic_distribution"
optional="show"
groups="analytic.group_analytic_accounting"
@@ -194,7 +193,6 @@
<field name="accounting_date" attrs="{'invisible': ['|', ('accounting_date', '=', False), ('state', 'not in', ['approved', 'done'])]}" />
<field name="account_id" options="{'no_create': True}" domain="[('account_type', 'not in', ('asset_receivable','liability_payable','asset_cash','liability_credit_card')), ('company_id', '=', company_id)]" groups="account.group_account_readonly" attrs="{'readonly': ['|', ('is_editable', '=', False), ('sheet_is_editable', '=', False)]}" context="{'default_company_id': company_id}"/>
<field name="sheet_id" invisible="1"/>
<field name="analytic_distribution_stored_char" invisible="1"/>
<field name="analytic_distribution" widget="analytic_distribution"
groups="analytic.group_analytic_accounting"
options="{'product_field': 'product_id', 'account_field': 'account_id', 'business_domain': 'expense'}"
@@ -753,7 +751,6 @@
<field name="attachment_number" invisible="1"/>
<button name="action_get_attachment_view" type="object" icon="fa-paperclip" aria-label="View Attachments" title="View Attachments" class="float-end pe-0" readonly="True" attrs="{'invisible': [('attachment_number', '=', 0)]}"/>
<field name="reference" optional="hide" readonly="True"/>
<field name="analytic_distribution_stored_char" invisible="1"/>
<field name="analytic_distribution" widget="analytic_distribution"
groups="analytic.group_analytic_accounting"
optional="show"
@@ -20,7 +20,6 @@
<field name="total_amount" force_save="1" attrs="{'readonly': [('product_has_cost', '=', True)]}"/>
<field name="tax_ids" widget="many2many_tags" attrs="{'readonly': [('product_has_tax', '=', False)]}"/>
<field name="amount_tax"/>
<field name="analytic_distribution_stored_char" invisible="1"/>
<field name="analytic_distribution" widget="analytic_distribution"
optional="show"
groups="analytic.group_analytic_accounting"/>
+14 -12
View File
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from collections import Counter
from odoo import api, fields, models, _, _lt
from odoo.osv import expression
@@ -16,11 +17,13 @@ class Project(models.Model):
if not self.analytic_account_id:
self.expenses_count = 0
return
query = self.env['hr.expense']._search([])
query.add_where('hr_expense.analytic_distribution ?| array[%s]', [str(account_id) for account_id in self.analytic_account_id.ids])
query_string, query_param = query.select('analytic_distribution')
self._cr.execute(query_string, query_param)
mapped_data = Counter(account for data in self._cr.dictfetchall() for account in data['analytic_distribution'])
for project in self:
expenses = self.env['hr.expense'].search([
('analytic_distribution_stored_char', '=ilike', f'%"{project.analytic_account_id.id}":%')
])
project.expenses_count = len(expenses)
project.expenses_count = mapped_data.get(project.analytic_account_id.id, 0)
# ----------------------------
# Actions
@@ -64,14 +67,13 @@ class Project(models.Model):
if not self.analytic_account_id:
return {}
can_see_expense = with_action and self.user_has_groups('hr_expense.group_hr_expense_team_approver')
expenses_read_group = self.env['hr.expense'].sudo()._read_group(
[('analytic_distribution_stored_char', '=ilike', f'%"{self.analytic_account_id.id}":%'),
('is_refused', '=', False),
('state', 'in', ['approved', 'done'])],
['untaxed_amount', 'ids:array_agg(id)'],
[],
)
if not expenses_read_group or not expenses_read_group[0]['__count']:
query = self.env['hr.expense']._search([('is_refused', '=', False), ('state', 'in', ['approved', 'done'])])
query.order = None
query.add_where('hr_expense.analytic_distribution ? %s', [str(self.analytic_account_id.id)])
query_string, query_param = query.select('array_agg(id) as ids', 'SUM(untaxed_amount) as untaxed_amount')
self._cr.execute(query_string, query_param)
expenses_read_group = [expense for expense in self._cr.dictfetchall()]
if not expenses_read_group or not expenses_read_group[0].get('ids'):
return {}
expense_data = expenses_read_group[0]
section_id = 'expenses'
+24 -13
View File
@@ -3,6 +3,7 @@
import json
from collections import defaultdict
from odoo import api, fields, models, _, _lt
from odoo.osv import expression
@@ -17,33 +18,40 @@ class Project(models.Model):
if not self.analytic_account_id:
self.purchase_orders_count = 0
return
query = self.env['purchase.order.line']._search([])
query.add_where('purchase_order_line.analytic_distribution ?| array[%s]', [str(account_id) for account_id in self.analytic_account_id.ids])
mapped_data = defaultdict(set)
query_string, query_param = query.select('analytic_distribution', 'order_id')
self._cr.execute(query_string, query_param)
for data in self._cr.dictfetchall():
for analytic_account_id in data['analytic_distribution']:
mapped_data[int(analytic_account_id)].add(data['order_id'])
for project in self:
purchase_orders = self.env['purchase.order'].search([
('order_line.analytic_distribution_stored_char', '=ilike', f'%"{project.analytic_account_id.id}":%')
])
project.purchase_orders_count = len(purchase_orders)
project.purchase_orders_count = len(mapped_data.get(project.analytic_account_id.id, []))
# ----------------------------
# Actions
# ----------------------------
def action_open_project_purchase_orders(self):
purchase_orders = self.env['purchase.order'].search([
('order_line.analytic_distribution_stored_char', '=ilike', f'%"{self.analytic_account_id.id}":%')
])
query = self.env['purchase.order.line']._search([])
query.add_where('purchase_order_line.analytic_distribution ? %s', [str(self.analytic_account_id.id)])
query_string, query_param = query.select('order_id')
self._cr.execute(query_string, query_param)
purchase_order_ids = [pol.get('order_id') for pol in self._cr.dictfetchall()]
action_window = {
'name': _('Purchase Orders'),
'type': 'ir.actions.act_window',
'res_model': 'purchase.order',
'views': [[False, 'tree'], [False, 'form']],
'domain': [('id', 'in', purchase_orders.ids)],
'domain': [('id', 'in', purchase_order_ids)],
'context': {
'project_id': self.id,
}
}
if len(purchase_orders) == 1:
if len(purchase_order_ids) == 1:
action_window['views'] = [[False, 'form']]
action_window['res_id'] = purchase_orders.id
action_window['res_id'] = purchase_order_ids[0]
return action_window
def action_profitability_items(self, section_name, domain=None, res_id=False):
@@ -108,13 +116,16 @@ class Project(models.Model):
def _get_profitability_items(self, with_action=True):
profitability_items = super()._get_profitability_items(with_action)
if self.analytic_account_id:
purchase_order_line_read = self.env['purchase.order.line'].sudo().search_read([
('analytic_distribution_stored_char', '=ilike', f'%"{self.analytic_account_id.id}":%'),
query = self.env['purchase.order.line'].sudo()._search([
('state', 'in', ['purchase', 'done']),
'|',
('qty_invoiced', '>', 0),
'|', ('qty_to_invoice', '>', 0), ('product_uom_qty', '>', 0),
], ['qty_invoiced', 'qty_to_invoice', 'product_uom_qty', 'price_unit'])
])
query.add_where('purchase_order_line.analytic_distribution ? %s', [str(self.analytic_account_id.id)])
query_string, query_param = query.select('"purchase_order_line".id', 'qty_invoiced', 'qty_to_invoice', 'product_uom_qty', 'price_unit')
self._cr.execute(query_string, query_param)
purchase_order_line_read = [pol for pol in self._cr.dictfetchall()]
if purchase_order_line_read:
amount_invoiced = amount_to_invoice = 0.0
purchase_order_line_ids = []
@@ -13,24 +13,21 @@ class Project(models.Model):
if not self.analytic_account_id:
return {}
can_see_expense = with_action and self.user_has_groups('hr_expense.group_hr_expense_team_approver')
expenses_read_group = self.env['hr.expense'].sudo()._read_group(
[
('analytic_distribution_stored_char', '=ilike', f'%"{self.analytic_account_id.id}":%'),
('is_refused', '=', False),
('state', 'in', ['approved', 'done']),
],
['sale_order_id', 'product_id', 'ids:array_agg(id)', 'untaxed_amount'],
['sale_order_id', 'product_id'],
lazy=False,
)
query = self.env['hr.expense']._search([('is_refused', '=', False), ('state', 'in', ['approved', 'done'])])
query.add_where('hr_expense.analytic_distribution ? %s', [str(self.analytic_account_id.id)])
query.order = None
query_string, query_param = query.select('sale_order_id', 'product_id', 'array_agg(id) as ids', 'SUM(untaxed_amount) as untaxed_amount')
query_string = f"{query_string} GROUP BY sale_order_id, product_id"
self._cr.execute(query_string, query_param)
expenses_read_group = [expense for expense in self._cr.dictfetchall()]
if not expenses_read_group:
return {}
expenses_per_so_id = {}
expense_ids = []
amount_billed = 0.0
for res in expenses_read_group:
so_id = res['sale_order_id'] and res['sale_order_id'][0]
product_id = res['product_id'] and res['product_id'][0]
so_id = res['sale_order_id']
product_id = res['product_id']
expenses_per_so_id.setdefault(so_id, {})[product_id] = res['ids']
if can_see_expense:
expense_ids.extend(res['ids'])
+3 -4
View File
@@ -1131,18 +1131,17 @@ class PurchaseOrderLine(models.Model):
return datetime.today() + relativedelta(days=seller.delay if seller else 0)
@api.depends('product_id', 'order_id.partner_id')
def _compute_analytic_distribution_stored_char(self):
def _compute_analytic_distribution(self):
for line in self:
if not line.display_type:
distribution = self.env['account.analytic.distribution.model']._get_distributionjson({
distribution = self.env['account.analytic.distribution.model']._get_distribution({
"product_id": line.product_id.id,
"product_categ_id": line.product_id.categ_id.id,
"partner_id": line.order_id.partner_id.id,
"partner_category_id": line.order_id.partner_id.category_id.ids,
"company_id": line.company_id.id,
})
line.analytic_distribution_stored_char = distribution or line.analytic_distribution_stored_char
line._compute_analytic_distribution()
line.analytic_distribution = distribution or line.analytic_distribution
@api.onchange('product_id')
def onchange_product_id(self):
@@ -327,7 +327,7 @@ class TestPurchaseToInvoice(AccountTestInvoicingCommon):
'analytic_distribution': {analytic_account_default.id: 100},
'product_id': self.product_order.id,
})
analytic_distribution_manual = {analytic_account_manual.id: 100}
analytic_distribution_manual = {str(analytic_account_manual.id): 100}
po_form = Form(self.env['purchase.order'].with_context(tracking_disable=True))
po_form.partner_id = self.partner_a
@@ -373,9 +373,9 @@ class TestPurchaseToInvoice(AccountTestInvoicingCommon):
purchase_order = po_form.save()
purchase_order_line = purchase_order.order_line
self.assertEqual(purchase_order_line.analytic_distribution, {analytic_account_super.id: 100}, "The analytic account should be set to 'Super Account'")
self.assertEqual(purchase_order_line.analytic_distribution, {str(analytic_account_super.id): 100}, "The analytic account should be set to 'Super Account'")
purchase_order_line.write({'product_id': great_product.id})
self.assertEqual(purchase_order_line.analytic_distribution, {analytic_account_great.id: 100}, "The analytic account should be set to 'Great Account'")
self.assertEqual(purchase_order_line.analytic_distribution, {str(analytic_account_great.id): 100}, "The analytic account should be set to 'Great Account'")
po_no_analytic_distribution = self.env['purchase.order'].create({
'partner_id': self.env.ref('base.res_partner_1').id,
-3
View File
@@ -237,7 +237,6 @@
force_save="1" domain="[('purchase_ok', '=', True), '|', ('company_id', '=', False), ('company_id', '=', parent.company_id)]"/>
<field name="name" widget="section_and_note_text"/>
<field name="date_planned" optional="hide" attrs="{'required': [('display_type', '=', False)]}" force_save="1"/>
<field name="analytic_distribution_stored_char" invisible="1"/>
<field name="analytic_distribution" widget="analytic_distribution"
optional="hide"
groups="analytic.group_analytic_accounting"
@@ -285,7 +284,6 @@
</group>
<group>
<field name="date_planned" widget="date" attrs="{'required': [('display_type', '=', False)]}"/>
<field name="analytic_distribution_stored_char" invisible="1"/>
<field name="analytic_distribution" widget="analytic_distribution"
groups="analytic.group_analytic_accounting"
options="{'product_field': 'product_id', 'business_domain': 'purchase_order'}"/>
@@ -738,7 +736,6 @@
domain="[('type_tax_use', '=', 'purchase')]"/>
<field name="date_planned" widget="date" readonly="1"/>
<field name="company_id" groups="base.group_multi_company" options="{'no_create': True}"/>
<field name="analytic_distribution_stored_char" invisible="1"/>
<field name="analytic_distribution" widget="analytic_distribution"
groups="analytic.group_analytic_accounting"
options="{'product_field': 'product_id', 'business_domain': 'purchase_order'}"/>
@@ -161,7 +161,6 @@
<field name="product_uom_category_id" invisible="1"/>
<field name="product_uom_id" string="UoM" groups="uom.group_uom" optional="show" attrs="{'required': [('product_id', '!=', False)]}"/>
<field name="schedule_date" optional="hide"/>
<field name="analytic_distribution_stored_char" invisible="1"/>
<field name="analytic_distribution" widget="analytic_distribution"
optional="hide"
groups="analytic.group_analytic_accounting"
@@ -177,7 +176,6 @@
<field name="product_uom_category_id" invisible="1"/>
<field name="product_uom_id" />
<field name="schedule_date"/>
<field name="analytic_distribution_stored_char" invisible="1"/>
<field name="analytic_distribution" widget="analytic_distribution"
groups="analytic.group_analytic_accounting"
options="{'product_field': 'product_id', 'business_domain': 'purchase_order'}"/>
+4 -5
View File
@@ -876,18 +876,17 @@ class SaleOrderLine(models.Model):
line.untaxed_amount_to_invoice = amount_to_invoice
@api.depends('order_id.partner_id', 'product_id')
def _compute_analytic_distribution_stored_char(self):
def _compute_analytic_distribution(self):
for line in self:
if not line.display_type and line.state == 'draft':
distribution = line.env['account.analytic.distribution.model']._get_distributionjson({
distribution = line.env['account.analytic.distribution.model']._get_distribution({
"product_id": line.product_id.id,
"product_categ_id": line.product_id.categ_id.id,
"partner_id": line.order_id.partner_id.id,
"partner_category_id": line.order_id.partner_id.category_id.ids,
"company_id": line.company_id.id,
})
line.analytic_distribution_stored_char = distribution or line.analytic_distribution_stored_char
line._compute_analytic_distribution()
line.analytic_distribution = distribution or line.analytic_distribution
@api.depends('product_id', 'state', 'qty_invoiced', 'qty_delivered')
def _compute_product_updatable(self):
@@ -997,7 +996,7 @@ class SaleOrderLine(models.Model):
def _get_protected_fields(self):
return [
'product_id', 'name', 'price_unit', 'product_uom', 'product_uom_qty',
'tax_id', 'analytic_distribution_stored_char'
'tax_id', 'analytic_distribution'
]
def _update_line_quantity(self, values):
+2 -2
View File
@@ -402,9 +402,9 @@ class TestSalesTeam(SaleCommon):
'order_id': sale_order.id,
})
self.assertEqual(sol.analytic_distribution, {analytic_account_super.id: 100}, "The analytic distribution should be set to Super Account")
self.assertEqual(sol.analytic_distribution, {str(analytic_account_super.id): 100}, "The analytic distribution should be set to Super Account")
sol.write({'product_id': great_product.id})
self.assertEqual(sol.analytic_distribution, {analytic_account_great.id: 100}, "The analytic distribution should be set to Great Account")
self.assertEqual(sol.analytic_distribution, {str(analytic_account_great.id): 100}, "The analytic distribution should be set to Great Account")
so_no_analytic_account = self.env['sale.order'].create({
'partner_id': self.env.ref('base.res_partner_1').id,
+2 -2
View File
@@ -447,7 +447,7 @@ class TestSaleToInvoice(TestSaleCommon):
down_payment.create_invoices()
aml = self.env['account.move.line'].search([('move_id', 'in', so.invoice_ids.ids)])[0]
self.assertRecordValues(aml, [{'analytic_distribution': {analytic_account_default.id: 100}}])
self.assertRecordValues(aml, [{'analytic_distribution': {str(analytic_account_default.id): 100}}])
def test_invoice_analytic_account_so_not_default(self):
""" Tests whether, when an analytic account rule is set and the so has an analytic account,
@@ -486,7 +486,7 @@ class TestSaleToInvoice(TestSaleCommon):
down_payment.create_invoices()
aml = self.env['account.move.line'].search([('move_id', 'in', so.invoice_ids.ids)])[0]
self.assertRecordValues(aml, [{'analytic_distribution': {analytic_account_default.id: 100, analytic_account_so.id: 100}}])
self.assertRecordValues(aml, [{'analytic_distribution': {str(analytic_account_default.id): 100, str(analytic_account_so.id): 100}}])
def test_invoice_after_product_return_price_not_default(self):
so = self.env['sale.order'].create({
+1 -4
View File
@@ -363,7 +363,6 @@
<div name="lead">
<field name="customer_lead" class="oe_inline"/> days
</div>
<field name="analytic_distribution_stored_char" invisible="1"/>
<field name="analytic_distribution" widget="analytic_distribution"
groups="analytic.group_analytic_accounting"
options="{'product_field': 'product_id', 'business_domain': 'sale_order'}"/>
@@ -440,12 +439,10 @@
domain="[('sale_ok', '=', True), '|', ('company_id', '=', False), ('company_id', '=', parent.company_id)]"
widget="sol_product_many2one"/>
<field name="name" widget="section_and_note_text" optional="show"/>
<field name="analytic_distribution_stored_char" invisible="1"/>
<field name="analytic_distribution" widget="analytic_distribution"
optional="hide"
groups="analytic.group_analytic_accounting"
options="{'product_field': 'product_id'}"
business_domain_compute="'sale', 'business_domain': 'sale_order'}"/>
options="{'product_field': 'product_id', 'business_domain': 'sale_order'}"/>
<field
name="product_uom_qty"
decoration-info="(not display_type and invoice_status == 'to invoice')" decoration-bf="(not display_type and invoice_status == 'to invoice')"
+2 -2
View File
@@ -6,8 +6,8 @@ from odoo import models
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
def _compute_analytic_distribution_stored_char(self):
def _compute_analytic_distribution(self):
# when a project creates an aml, it adds an analytic account to it. the following filter is to save this
# analytic account from being overridden by analytic default rules and lack thereof
project_amls = self.filtered(lambda aml: aml.analytic_distribution and any(aml.sale_line_ids.project_id))
super(AccountMoveLine, self - project_amls)._compute_analytic_distribution_stored_char()
super(AccountMoveLine, self - project_amls)._compute_analytic_distribution()
+16 -13
View File
@@ -152,23 +152,24 @@ class Project(models.Model):
return action
def action_open_project_invoices(self):
invoices = self.env['account.move'].search([
('line_ids.analytic_distribution_stored_char', '=ilike', f'%"{self.analytic_account_id.id}":%'),
('move_type', '=', 'out_invoice')
])
query = self.env['account.move.line']._search([('move_id.move_type', '=', 'out_invoice')])
query.add_where('analytic_distribution ? %s', [str(self.analytic_account_id.id)])
query_string, query_param = query.select('DISTINCT move_id')
self._cr.execute(query_string, query_param)
invoice_ids = [line.get('move_id') for line in self._cr.dictfetchall()]
action = {
'name': _('Invoices'),
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'views': [[False, 'tree'], [False, 'form'], [False, 'kanban']],
'domain': [('id', 'in', invoices.ids)],
'domain': [('id', 'in', invoice_ids)],
'context': {
'create': False,
}
}
if len(invoices) == 1:
if len(invoice_ids) == 1:
action['views'] = [[False, 'form']]
action['res_id'] = invoices.id
action['res_id'] = invoice_ids[0]
return action
# ----------------------------
@@ -446,22 +447,24 @@ class Project(models.Model):
return buttons
def action_open_project_vendor_bills(self):
vendor_bills = self.env['account.move'].search([
('line_ids.analytic_distribution_stored_char', '=ilike', f'%"{self.analytic_account_id.id}":%'),
('move_type', '=', 'in_invoice')])
query = self.env['account.move.line']._search([('move_id.move_type', '=', 'in_invoice')])
query.add_where('analytic_distribution ? %s', [str(self.analytic_account_id.id)])
query_string, query_param = query.select('DISTINCT move_id')
self._cr.execute(query_string, query_param)
vendor_bill_ids = [line.get('move_id') for line in self._cr.dictfetchall()]
action_window = {
'name': _('Vendor Bills'),
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'views': [[False, 'tree'], [False, 'form'], [False, 'kanban']],
'domain': [('id', 'in', vendor_bills.ids)],
'domain': [('id', 'in', vendor_bill_ids)],
'context': {
'create': False,
}
}
if len(vendor_bills) == 1:
if len(vendor_bill_ids) == 1:
action_window['views'] = [[False, 'form']]
action_window['res_id'] = vendor_bills.id
action_window['res_id'] = vendor_bill_ids[0]
return action_window
class ProjectTask(models.Model):
@@ -685,7 +685,7 @@ class TestSaleService(TestCommonSaleTimesheet):
# Check that the resulting invoice line and the project have the same analytic account
invoice_line = self.sale_order.invoice_ids.line_ids.filtered(lambda line: line.product_id == product_add)
self.assertEqual(invoice_line.analytic_distribution, {self.project_global.analytic_account_id.id: 100},
self.assertEqual(invoice_line.analytic_distribution, {str(self.project_global.analytic_account_id.id): 100},
"SOL's analytic distribution should contain the project analytic account")
def test_sale_timesheet_invoice(self):
@@ -301,6 +301,9 @@ class SelectionConverter(models.AbstractModel):
options.update(
selection=dict(type='selection', string=_('Selection'), description=_('By default the widget uses the field information'), required=True)
)
options.update(
selection=dict(type='json', string=_('Json'), description=_('By default the widget uses the field information'), required=True)
)
return options
@api.model