mirror of
https://github.com/bringout/oca-ocb-sale.git
synced 2026-04-26 16:52:03 +02:00
19.0 vanilla
This commit is contained in:
parent
79f83631d5
commit
73afc09215
6267 changed files with 1534193 additions and 1130106 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue