19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:12 +01:00
parent 79f83631d5
commit 73afc09215
6267 changed files with 1534193 additions and 1130106 deletions

View file

@ -1,8 +1,9 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import hr_expense
from . import hr_expense_sheet
from . import hr_expense_split
from . import sale_order
from . import product_template
from . import account_move
from . import account_move_line
from . import hr_expense
from . import hr_expense_split
from . import product_template
from . import sale_order
from . import sale_order_line

View file

@ -1,65 +1,22 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
def _sale_can_be_reinvoice(self):
""" determine if the generated analytic line should be reinvoiced or not.
For Expense flow, if the product has a 'reinvoice policy' and a Sales Order is set on the expense, then we will reinvoice the AAL
"""
self.ensure_one()
if self.expense_id: # expense flow is different from vendor bill reinvoice flow
return self.expense_id.product_id.expense_policy in ['sales_price', 'cost'] and self.expense_id.sale_order_id
return super(AccountMoveLine, self)._sale_can_be_reinvoice()
def _sale_determine_order(self):
""" For move lines created from expense, we override the normal behavior.
Note: if no SO but an AA is given on the expense, we will determine anyway the SO from the AA, using the same
mecanism as in Vendor Bills.
"""
mapping_from_invoice = super(AccountMoveLine, self)._sale_determine_order()
mapping_from_expense = {}
for move_line in self.filtered(lambda move_line: move_line.expense_id):
mapping_from_expense[move_line.id] = move_line.expense_id.sale_order_id or None
mapping_from_invoice.update(mapping_from_expense)
return mapping_from_invoice
def _sale_prepare_sale_line_values(self, order, price):
# Add expense quantity to sales order line and update the sales order price because it will be charged to the customer in the end.
self.ensure_one()
res = super()._sale_prepare_sale_line_values(order, price)
if self.expense_id:
res.update({'product_uom_qty': self.expense_id.quantity})
return res
def _sale_create_reinvoice_sale_line(self):
expensed_lines = self.filtered('expense_id')
res = super(AccountMoveLine, self - expensed_lines)._sale_create_reinvoice_sale_line()
res.update(super(AccountMoveLine, expensed_lines.with_context({'force_split_lines': True}))._sale_create_reinvoice_sale_line())
return res
from odoo import models
class AccountMove(models.Model):
_inherit = 'account.move'
expense_sheet_id = fields.One2many(
comodel_name='hr.expense.sheet',
inverse_name='account_move_id',
string='Expense Sheet',
readonly=True
)
def _reverse_moves(self, default_values_list=None, cancel=False):
self.expense_sheet_id._sale_expense_reset_sol_quantities()
res = super()._reverse_moves(default_values_list, cancel)
return res
# EXTENDS sale
self.expense_ids._sale_expense_reset_sol_quantities()
return super()._reverse_moves(default_values_list, cancel)
def button_draft(self):
res = super().button_draft()
self.expense_sheet_id._sale_expense_reset_sol_quantities()
return res
# EXTENDS sale
self.expense_ids._sale_expense_reset_sol_quantities()
return super().button_draft()
def unlink(self):
# EXTENDS sale
self.expense_ids._sale_expense_reset_sol_quantities()
return super().unlink()

View file

@ -0,0 +1,55 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, Command
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
def _sale_can_be_reinvoice(self):
""" determine if the generated analytic line should be reinvoiced or not.
For Expense flow, if the product has a 'reinvoice policy' and a Sales Order is set on the expense, then we will reinvoice the AAL
"""
self.ensure_one()
if self.expense_id: # expense flow is different from vendor bill reinvoice flow
return (
self.expense_id.product_id.expense_policy in {'sales_price', 'cost'}
and self.expense_id.sale_order_id
and self.display_type == 'product'
)
return super()._sale_can_be_reinvoice()
def _get_so_mapping_from_expense(self):
mapping_from_expense = {}
for move_line in self.filtered(lambda move_line: move_line.expense_id):
mapping_from_expense[move_line.id] = move_line.expense_id.sale_order_id or None
return mapping_from_expense
def _sale_determine_order(self):
# EXTENDS sale
# For move lines created from expense, we override the normal behavior.
mapping_from_invoice = super()._sale_determine_order()
mapping_from_invoice.update(self._get_so_mapping_from_expense())
return mapping_from_invoice
def _sale_prepare_sale_line_values(self, order, price):
# EXTENDS sale
# Add expense quantity to sales order line and update the sales order price because it will be charged to the customer in the end.
res = super()._sale_prepare_sale_line_values(order, price)
if self.expense_id:
res.update({
'name': self.name,
'expense_ids': [Command.set(self.expense_id.ids)],
'product_uom_qty': self.expense_id.quantity,
'analytic_distribution': self.analytic_distribution,
})
return res
def _sale_create_reinvoice_sale_line(self):
# EXTENDS sale
# We force each reinvoiced expense to be on their own sale order line,
# else we cannot properly edit the quantities if the user manually override anything
expensed_lines = self.filtered('expense_id')
res = super(AccountMoveLine, self - expensed_lines)._sale_create_reinvoice_sale_line()
res.update(super(AccountMoveLine, expensed_lines.with_context({'force_split_lines': True}))._sale_create_reinvoice_sale_line())
return res

View file

@ -1,18 +1,31 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo import Command, api, fields, models
class Expense(models.Model):
class HrExpense(models.Model):
_inherit = "hr.expense"
sale_order_id = fields.Many2one('sale.order', compute='_compute_sale_order_id', store=True, string='Customer to Reinvoice', readonly=False, tracking=True,
states={'done': [('readonly', True)], 'refused': [('readonly', True)]},
sale_order_id = fields.Many2one(
'sale.order',
string='Customer to Reinvoice',
compute='_compute_sale_order_id',
store=True,
readonly=False,
index='btree_not_null',
tracking=True,
# NOTE: only confirmed SO can be selected, but this domain in activated throught the name search with the `sale_expense_all_order`
# context key. So, this domain is not the one applied.
domain="[('state', '=', 'sale'), ('company_id', '=', company_id)]",
domain="[('state', '=', 'sale')]",
check_company=True,
help="If the category has an expense policy, it will be reinvoiced on this sales order")
sale_order_line_id = fields.Many2one(
comodel_name='sale.order.line',
compute='_compute_sale_order_id',
store=True,
readonly=True,
index='btree_not_null',
)
can_be_reinvoiced = fields.Boolean("Can be reinvoiced", compute='_compute_can_be_reinvoiced')
@api.depends('product_id.expense_policy')
@ -24,12 +37,7 @@ class Expense(models.Model):
def _compute_sale_order_id(self):
for expense in self.filtered(lambda e: not e.can_be_reinvoiced):
expense.sale_order_id = False
def _compute_analytic_distribution(self):
super(Expense, self)._compute_analytic_distribution()
for expense in self.filtered('sale_order_id'):
if expense.sale_order_id.sudo().analytic_account_id:
expense.analytic_distribution = {expense.sale_order_id.sudo().analytic_account_id.id: 100} # `sudo` required for normal employee without sale access rights
expense.sale_order_line_id = False
@api.onchange('sale_order_id')
def _onchange_sale_order_id(self):
@ -37,22 +45,47 @@ class Expense(models.Model):
to_reset.invalidate_recordset(['analytic_distribution'])
self.env.add_to_compute(self._fields['analytic_distribution'], to_reset)
def _sale_expense_reset_sol_quantities(self):
"""
Resets the quantity of a SOL created by a reinvoiced expense to 0 when the expense or its move is reset to an unfinished state
Note: Resetting the qty_delivered will raise if the product is a storable product and sale_stock is installed,
but it's fine as it doesn't make much sense to have a stored product in an expense.
"""
self.check_access('write')
# If we can edit the expense, we may not be able to edit the sol without sudoing.
self.sudo().sale_order_line_id.write({
'qty_delivered': 0.0,
'product_uom_qty': 0.0,
'expense_ids': [Command.clear()],
})
def _get_split_values(self):
vals = super(Expense, self)._get_split_values()
# EXTENDS hr_expense
vals = super()._get_split_values()
for split_value in vals:
split_value['sale_order_id'] = self.sale_order_id.id
return vals
def action_move_create(self):
""" When posting expense, if the AA is given, we will track cost in that
If a SO is set, this means we want to reinvoice the expense. But to do so, we
need the analytic entries to be generated, so a AA is required to reinvoice. So,
we ensure the AA if a SO is given.
"""
for expense in self.filtered(lambda expense: expense.sale_order_id and not expense.analytic_distribution):
if not expense.sale_order_id.analytic_account_id:
expense.sale_order_id._create_analytic_account()
expense.write({
'analytic_distribution': {expense.sale_order_id.analytic_account_id.id: 100}
})
return super(Expense, self).action_move_create()
def action_post(self):
# EXTENDS hr_expense
# When posting expense, we need the analytic entries to be generated, because reinvoicing uses analytic accounts.
# We then ensure the proper analytic acocunt is given in the distribution and if not,
# we create an account and set the distribution to it.
for expense in self:
if expense.sale_order_id and not expense.analytic_distribution:
analytic_account = self.env['account.analytic.account'].create(expense.sale_order_id._prepare_analytic_account_data())
expense.analytic_distribution = {analytic_account.id: 100}
return super().action_post()
def action_open_sale_order(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'views': [(self.env.ref("sale.view_order_form").id, 'form')],
'view_mode': 'form',
'target': 'current',
'name': self.sale_order_id.display_name,
'res_id': self.sale_order_id.id,
}

View file

@ -1,111 +0,0 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import Counter
from psycopg2.extras import execute_values
from odoo import fields, models, _
class HrExpenseSheet(models.Model):
_inherit = "hr.expense.sheet"
sale_order_count = fields.Integer(compute='_compute_sale_order_count')
def _compute_sale_order_count(self):
for sheet in self:
sheet.sale_order_count = len(sheet.expense_line_ids.sale_order_id)
def _get_sale_order_lines(self):
"""
This method is used to try to find the sale order lines created by expense sheets.
It is used to reset the quantities of the sale order lines when the expense sheet is reset.
It uses several shared fields to try to find the sale order lines:
- order_id
- product_id
- product_uom_qty
- sale order line's price_unit (computed from the product_id, then rounded to the currency's rounding)
- name
"""
# Get the product account move lines created by an expense
expensed_amls = self.account_move_id.line_ids.filtered(lambda aml: aml.expense_id.sale_order_id and aml.balance >= 0 and not aml.tax_line_id)
if not expensed_amls:
return self.env['sale.order.line']
# Get the sale orders linked to the related expenses
aml_to_so_map = expensed_amls._sale_determine_order()
self.env['sale.order.line'].flush_model(['order_id', 'product_id', 'product_uom_qty', 'price_unit', 'name'])
self.env['res.company'].flush_model(['currency_id'])
self.env['res.currency'].flush_model(['rounding'])
query = """
WITH aml(key_id, key_count, order_id, product_id, product_uom_qty, price_unit, name) AS (VALUES %s)
SELECT ARRAY_AGG(sol.id ORDER BY sol.id), aml.key_count
FROM aml,
sale_order_line AS sol
JOIN res_company AS company ON sol.company_id = company.id
JOIN res_currency AS company_currency ON company.currency_id = company_currency.id
LEFT JOIN res_currency AS currency ON sol.currency_id = currency.id
WHERE sol.is_expense = TRUE
AND sol.order_id = aml.order_id
AND sol.product_id = aml.product_id
AND sol.product_uom_qty = aml.product_uom_qty
AND sol.name = aml.name
AND ROUND(sol.price_unit::numeric, COALESCE(currency.rounding, company_currency.rounding)::int)
= ROUND(aml.price_unit::numeric, COALESCE(currency.rounding, company_currency.rounding)::int)
GROUP BY aml.key_id, aml.key_count
"""
# Get the keys used to fetch the corresponding sale order lines, and the number of times they are used
# We need the occurrences count to filter out the sale order lines so that we keep exactly one per expense
expense_keys_counter = Counter(expensed_amls.mapped(lambda aml: (
aml.expense_id.sale_order_id.id,
aml.product_id.id,
aml.quantity,
aml.currency_id.round(aml._sale_get_invoice_price(aml_to_so_map[aml.id])),
aml.name,
)))
expensed_amls_keys_and_count = tuple(
(key_id, key_count, *key) for key_id, (key, key_count) in enumerate(expense_keys_counter.items())
)
execute_values(
self.env.cr._obj,
query,
expensed_amls_keys_and_count,
)
# Filters out the sale order lines so that we only keep one per expense
sol_ids = []
for all_sol_ids_per_key, expense_count_per_key in self.env.cr.fetchall():
sol_ids += all_sol_ids_per_key[:expense_count_per_key]
return self.env['sale.order.line'].browse(sol_ids)
def _sale_expense_reset_sol_quantities(self):
sale_order_lines = self._get_sale_order_lines()
sale_order_lines.write({'qty_delivered': 0.0, 'product_uom_qty': 0.0})
def reset_expense_sheets(self):
super().reset_expense_sheets()
self._sale_expense_reset_sol_quantities()
return True
def action_open_sale_orders(self):
self.ensure_one()
if self.sale_order_count == 1:
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'views': [(self.env.ref("sale.view_order_form").id, 'form')],
'view_mode': 'form',
'target': 'current',
'name': self.expense_line_ids.sale_order_id.display_name,
'res_id': self.expense_line_ids.sale_order_id.id,
}
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'views': [(self.env.ref('sale.view_order_tree').id, 'list'), (self.env.ref("sale.view_order_form").id, 'form')],
'view_mode': 'list,form',
'target': 'current',
'name': _('Reinvoiced Sales Orders'),
'domain': [('id', 'in', self.expense_line_ids.sale_order_id.ids)],
}

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, api
@ -7,13 +6,6 @@ from odoo import fields, models, api
class HrExpenseSplit(models.TransientModel):
_inherit = "hr.expense.split"
def default_get(self, fields):
result = super(HrExpenseSplit, self).default_get(fields)
if 'expense_id' in result:
expense = self.env['hr.expense'].browse(result['expense_id'])
result['sale_order_id'] = expense.sale_order_id
return result
sale_order_id = fields.Many2one('sale.order', string="Customer to Reinvoice", compute='_compute_sale_order_id', readonly=False, store=True, domain="[('state', '=', 'sale'), ('company_id', '=', company_id)]")
can_be_reinvoiced = fields.Boolean("Can be reinvoiced", compute='_compute_can_be_reinvoiced')

View file

@ -31,11 +31,8 @@ class ProductTemplate(models.Model):
@api.depends('can_be_expensed')
def _compute_visible_expense_policy(self):
expense_products = self.filtered(lambda p: p.can_be_expensed)
for product_template in self - expense_products:
product_template.visible_expense_policy = False
super(ProductTemplate, expense_products)._compute_visible_expense_policy()
visibility = self.user_has_groups('hr_expense.group_hr_expense_user')
super(ProductTemplate, self - expense_products)._compute_visible_expense_policy()
visibility = self.env.user.has_group('hr_expense.group_hr_expense_user')
for product_template in expense_products:
if not product_template.visible_expense_policy:
product_template.visible_expense_policy = visibility

View file

@ -1,28 +1,38 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo import SUPERUSER_ID
from odoo.osv import expression
from odoo.fields import Domain
class SaleOrder(models.Model):
_inherit = 'sale.order'
expense_ids = fields.One2many('hr.expense', 'sale_order_id', string='Expenses', domain=[('state', '=', 'done')], readonly=True, copy=False)
expense_ids = fields.One2many(
comodel_name='hr.expense',
inverse_name='sale_order_id',
string='Expenses',
domain=[('state', 'in', ('posted', 'in_payment', 'paid'))],
readonly=True,
)
expense_count = fields.Integer("# of Expenses", compute='_compute_expense_count', compute_sudo=True)
@api.model
def _name_search(self, name='', args=None, operator='ilike', limit=100, name_get_uid=None):
""" For expense, we want to show all sales order but only their name_get (no ir.rule applied), this is the only way to do it. """
if self._context.get('sale_expense_all_order') and self.user_has_groups('sales_team.group_sale_salesman') and not self.user_has_groups('sales_team.group_sale_salesman_all_leads'):
domain = expression.AND([args or [], ['&', ('state', '=', 'sale'), ('company_id', 'in', self.env.companies.ids)]])
return super(SaleOrder, self.sudo())._name_search(name=name, args=domain, operator=operator, limit=limit, name_get_uid=SUPERUSER_ID)
return super(SaleOrder, self)._name_search(name=name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid)
def _search_display_name(self, operator, value):
""" For expense, we want to show all sales order but only their display_name (no ir.rule applied), this is the only way to do it. """
if (
self.env.context.get('sale_expense_all_order')
and self.env.user.has_group('sales_team.group_sale_salesman')
and not self.env.user.has_group('sales_team.group_sale_salesman_all_leads')
):
if operator in Domain.NEGATIVE_OPERATORS:
return NotImplemented
domain = super()._search_display_name(operator, value)
company_domain = Domain('state', '=', 'sale') & ('company_id', 'in', self.env.companies.ids)
query = self.sudo()._search(domain & company_domain)
return Domain('id', 'in', query)
return super()._search_display_name(operator, value)
@api.depends('expense_ids')
def _compute_expense_count(self):
expense_data = self.env['hr.expense']._read_group([('sale_order_id', 'in', self.ids)], ['sale_order_id'], ['sale_order_id'])
mapped_data = dict([(item['sale_order_id'][0], item['sale_order_id_count']) for item in expense_data])
for sale_order in self:
sale_order.expense_count = mapped_data.get(sale_order.id, 0)
sale_order.expense_count = len(sale_order.order_line.expense_ids)

View file

@ -0,0 +1,14 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
expense_ids = fields.One2many(
comodel_name='hr.expense',
inverse_name='sale_order_line_id',
string='Expenses',
readonly=True,
)