2025-01-06 10:57:38 +07:00
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
import json
from collections import defaultdict
from odoo import api , fields , models
from odoo . osv import expression
from odoo . tools import Query , SQL
from odoo . tools . misc import unquote
from odoo . tools . translate import _
class ProjectProject ( models . Model ) :
_inherit = ' project.project '
def _domain_sale_line_id ( self ) :
domain = expression . AND ( [
self . env [ ' sale.order.line ' ] . _sellable_lines_domain ( ) ,
self . env [ ' sale.order.line ' ] . _domain_sale_line_service ( ) ,
[
( ' order_partner_id ' , ' =? ' , unquote ( " partner_id " ) ) ,
] ,
] )
return domain
allow_billable = fields . Boolean ( " Billable " )
sale_line_id = fields . Many2one (
' sale.order.line ' , ' Sales Order Item ' , copy = False ,
compute = " _compute_sale_line_id " , store = True , readonly = False , index = ' btree_not_null ' ,
domain = lambda self : str ( self . _domain_sale_line_id ( ) ) ,
help = " Sales order item that will be selected by default on the tasks and timesheets of this project, "
" except if the employee set on the timesheets is explicitely linked to another sales order item on the project. \n "
" It can be modified on each task and timesheet entry individually if necessary. " )
sale_order_id = fields . Many2one ( related = ' sale_line_id.order_id ' , export_string_translation = False )
has_any_so_to_invoice = fields . Boolean ( ' Has SO to Invoice ' , compute = ' _compute_has_any_so_to_invoice ' , export_string_translation = False )
sale_order_line_count = fields . Integer ( compute = ' _compute_sale_order_count ' , groups = ' sales_team.group_sale_salesman ' , export_string_translation = False )
sale_order_count = fields . Integer ( compute = ' _compute_sale_order_count ' , groups = ' sales_team.group_sale_salesman ' , export_string_translation = False )
has_any_so_with_nothing_to_invoice = fields . Boolean ( ' Has a SO with an invoice status of No ' , compute = ' _compute_has_any_so_with_nothing_to_invoice ' , export_string_translation = False )
invoice_count = fields . Integer ( compute = ' _compute_invoice_count ' , groups = ' account.group_account_readonly ' , export_string_translation = False )
vendor_bill_count = fields . Integer ( related = ' account_id.vendor_bill_count ' , groups = ' account.group_account_readonly ' , export_string_translation = False )
partner_id = fields . Many2one ( compute = " _compute_partner_id " , store = True , readonly = False )
display_sales_stat_buttons = fields . Boolean ( compute = ' _compute_display_sales_stat_buttons ' , export_string_translation = False )
sale_order_state = fields . Selection ( related = ' sale_order_id.state ' , export_string_translation = False )
2025-03-04 12:23:19 +07:00
reinvoiced_sale_order_id = fields . Many2one ( ' sale.order ' , string = ' Sales Order ' , groups = ' sales_team.group_sale_salesman ' , copy = False , domain = " [( ' partner_id ' , ' = ' , partner_id)] " ,
2025-01-06 10:57:38 +07:00
help = " Products added to stock pickings, whose operation type is configured to generate analytic costs, will be re-invoiced in this sales order if they are set up for it. " ,
)
@api.model
def _map_tasks_default_values ( self , project ) :
defaults = super ( ) . _map_tasks_default_values ( project )
defaults [ ' sale_line_id ' ] = False
return defaults
@api.depends ( ' allow_billable ' , ' partner_id.company_id ' )
def _compute_partner_id ( self ) :
for project in self :
# Ensures that the partner_id and its project do not have different companies set
if not project . allow_billable or ( project . company_id and project . partner_id . company_id and project . company_id != project . partner_id . company_id ) :
project . partner_id = False
@api.depends ( ' partner_id ' )
def _compute_sale_line_id ( self ) :
self . filtered (
lambda p :
p . sale_line_id and (
not p . partner_id or p . sale_line_id . order_partner_id . commercial_partner_id != p . partner_id . commercial_partner_id
)
) . update ( { ' sale_line_id ' : False } )
def _get_projects_for_invoice_status ( self , invoice_status ) :
""" Returns a recordset of project.project that has any Sale Order which invoice_status is the same as the
provided invoice_status .
: param invoice_status : The invoice status .
"""
result = self . env . execute_query ( SQL ( """
SELECT id
FROM project_project pp
WHERE pp . active = true
AND ( EXISTS ( SELECT 1
FROM sale_order so
JOIN project_task pt ON pt . sale_order_id = so . id
WHERE pt . project_id = pp . id
AND pt . active = true
AND so . invoice_status = % ( invoice_status ) s )
OR EXISTS ( SELECT 1
FROM sale_order so
JOIN sale_order_line sol ON sol . order_id = so . id
WHERE sol . id = pp . sale_line_id
AND so . invoice_status = % ( invoice_status ) s ) )
AND id in % ( ids ) s """ , ids=tuple(self.ids), invoice_status=invoice_status))
return self . env [ ' project.project ' ] . browse ( id_ for id_ , in result )
@api.depends ( ' sale_order_id.invoice_status ' , ' tasks.sale_order_id.invoice_status ' )
def _compute_has_any_so_to_invoice ( self ) :
""" Has any Sale Order whose invoice_status is set as To Invoice """
if not self . ids :
self . has_any_so_to_invoice = False
return
project_to_invoice = self . _get_projects_for_invoice_status ( ' to invoice ' )
project_to_invoice . has_any_so_to_invoice = True
( self - project_to_invoice ) . has_any_so_to_invoice = False
@api.depends ( ' sale_order_id ' , ' task_ids.sale_order_id ' )
def _compute_sale_order_count ( self ) :
sale_order_items_per_project_id = self . _fetch_sale_order_items_per_project_id ( { ' project.task ' : [ ( ' is_closed ' , ' = ' , False ) ] } )
for project in self :
sale_order_lines = sale_order_items_per_project_id . get ( project . id , self . env [ ' sale.order.line ' ] )
project . sale_order_line_count = len ( sale_order_lines )
# Use sudo to avoid AccessErrors when the SOLs belong to different companies.
project . sale_order_count = len ( sale_order_lines . sudo ( ) . order_id )
def _compute_invoice_count ( self ) :
data = self . env [ ' account.move.line ' ] . _read_group (
[ ( ' move_id.move_type ' , ' in ' , [ ' out_invoice ' , ' out_refund ' ] ) , ( ' analytic_distribution ' , ' in ' , self . account_id . ids ) ] ,
groupby = [ ' analytic_distribution ' ] ,
aggregates = [ ' __count ' ] ,
)
data = { int ( account_id ) : move_count for account_id , move_count in data }
for project in self :
project . invoice_count = data . get ( project . account_id . id , 0 )
@api.depends ( ' allow_billable ' , ' partner_id ' )
def _compute_display_sales_stat_buttons ( self ) :
for project in self :
project . display_sales_stat_buttons = project . allow_billable and project . partner_id
def action_customer_preview ( self ) :
self . ensure_one ( )
return {
' type ' : ' ir.actions.act_url ' ,
' target ' : ' self ' ,
' url ' : self . get_portal_url ( ) ,
}
@api.onchange ( ' reinvoiced_sale_order_id ' )
def _onchange_reinvoiced_sale_order_id ( self ) :
if not self . sale_line_id and self . reinvoiced_sale_order_id . order_line :
self . sale_line_id = self . reinvoiced_sale_order_id . order_line [ 0 ]
@api.onchange ( ' sale_line_id ' )
def _onchange_sale_line_id ( self ) :
if not self . reinvoiced_sale_order_id and self . sale_line_id :
self . reinvoiced_sale_order_id = self . sale_line_id . order_id
def _ensure_sale_order_linked ( self , sol_ids ) :
""" Orders created from project/task are supposed to be confirmed to match the typical flow from sales, but since
we allow SO creation from the project / task itself we want to confirm newly created SOs immediately after creation .
However this would leads to SOs being confirmed without a single product , so we ' d rather do it on record save.
"""
quotations = self . env [ ' sale.order.line ' ] . sudo ( ) . _read_group (
domain = [ ( ' state ' , ' = ' , ' draft ' ) , ( ' id ' , ' in ' , sol_ids ) ] ,
aggregates = [ ' order_id:recordset ' ] ,
) [ 0 ] [ 0 ]
if quotations :
quotations . action_confirm ( )
@api.model_create_multi
def create ( self , vals_list ) :
projects = super ( ) . create ( vals_list )
sol_ids = {
vals [ ' sale_line_id ' ]
for vals in vals_list
if vals . get ( ' sale_line_id ' )
}
if sol_ids :
projects . _ensure_sale_order_linked ( list ( sol_ids ) )
return projects
def write ( self , vals ) :
project = super ( ) . write ( vals )
if sol_id := vals . get ( ' sale_line_id ' ) :
self . _ensure_sale_order_linked ( [ sol_id ] )
return project
def action_view_sols ( self ) :
self . ensure_one ( )
all_sale_order_lines = self . _fetch_sale_order_items ( { ' project.task ' : [ ( ' is_closed ' , ' = ' , False ) ] } )
action_window = {
' type ' : ' ir.actions.act_window ' ,
' res_model ' : ' sale.order.line ' ,
' name ' : _ ( " %(name)s ' s Sales Order Items " , name = self . name ) ,
' context ' : {
' show_sale ' : True ,
' link_to_project ' : self . id ,
' form_view_ref ' : ' sale_project.sale_order_line_view_form_editable ' , # Necessary for some logic in the form view
' action_view_sols ' : True ,
' default_partner_id ' : self . partner_id . id ,
' default_company_id ' : self . company_id . id ,
' default_order_id ' : self . sale_order_id . id ,
} ,
' views ' : [ ( self . env . ref ( ' sale_project.sale_order_line_view_form_editable ' ) . id , ' form ' ) ] ,
}
if len ( all_sale_order_lines ) < = 1 :
action_window [ ' res_id ' ] = all_sale_order_lines . id
else :
action_window . update ( {
' domain ' : [ ( ' id ' , ' in ' , all_sale_order_lines . ids ) ] ,
' views ' : [
( self . env . ref ( ' sale_project.view_order_line_tree_with_create ' ) . id , ' list ' ) ,
( self . env . ref ( ' sale_project.sale_order_line_view_form_editable ' ) . id , ' form ' ) ,
] ,
} )
return action_window
def action_view_sos ( self ) :
self . ensure_one ( )
all_sale_orders = self . _fetch_sale_order_items ( { ' project.task ' : [ ( ' is_closed ' , ' = ' , False ) ] } ) . sudo ( ) . order_id
embedded_action_context = self . env . context . get ( ' from_embedded_action ' , False )
action_window = {
" type " : " ir.actions.act_window " ,
" res_model " : " sale.order " ,
' name ' : _ ( " %(name)s ' s Sales Orders " , name = self . name ) ,
" context " : {
" create " : self . env . context . get ( ' create_for_project_id ' , embedded_action_context ) ,
" show_sale " : True ,
' default_partner_id ' : self . partner_id . id ,
' default_project_id ' : self . id ,
" create_for_project_id " : self . id if not embedded_action_context else False ,
" from_embedded_action " : embedded_action_context
} ,
' help ' : " <p class= ' o_view_nocontent_smiling_face ' > %s </p><p> %s <br/> %s </p> " %
( _ ( " Create a new quotation, the first step of a new sale! " ) ,
_ ( " Once the quotation is confirmed by the customer, it becomes a sales order. " ) ,
_ ( " You will be able to create an invoice and collect the payment. " ) )
}
if len ( all_sale_orders ) < = 1 and not embedded_action_context :
action_window . update ( {
" res_id " : all_sale_orders . id ,
" views " : [ [ False , " form " ] ] ,
} )
else :
action_window . update ( {
" domain " : [ ( ' id ' , ' in ' , all_sale_orders . ids ) ] ,
" views " : [ [ False , " list " ] , [ False , " kanban " ] , [ False , " calendar " ] , [ False , " pivot " ] ,
[ False , " graph " ] , [ False , " activity " ] , [ False , " form " ] ] ,
} )
return action_window
def action_get_list_view ( self ) :
action = super ( ) . action_get_list_view ( )
if self . allow_billable :
action [ ' views ' ] = [ ( self . env . ref ( ' sale_project.project_milestone_view_tree ' ) . id , ' list ' ) , ( False , ' form ' ) ]
return action
def action_profitability_items ( self , section_name , domain = None , res_id = False ) :
if section_name in [ ' service_revenues ' , ' materials ' ] :
view_types = [ ' list ' , ' kanban ' , ' form ' ]
action = {
' name ' : _ ( ' Sales Order Items ' ) ,
' type ' : ' ir.actions.act_window ' ,
' res_model ' : ' sale.order.line ' ,
' context ' : { ' create ' : False , ' edit ' : False } ,
}
if res_id :
action [ ' res_id ' ] = res_id
view_types = [ ' form ' ]
else :
action [ ' domain ' ] = domain
action [ ' views ' ] = [ ( False , v ) for v in view_types ]
return action
if section_name in [ ' other_invoice_revenues ' , ' downpayments ' ] :
action = self . env [ " ir.actions.actions " ] . _for_xml_id ( " account.action_move_out_invoice_type " )
action [ ' domain ' ] = domain if domain else [ ]
action [ ' context ' ] = {
* * ast . literal_eval ( action [ ' context ' ] ) ,
' default_partner_id ' : self . partner_id . id ,
' project_id ' : self . id ,
}
if res_id :
action [ ' views ' ] = [ ( False , ' form ' ) ]
action [ ' view_mode ' ] = ' form '
action [ ' res_id ' ] = res_id
return action
return super ( ) . action_profitability_items ( section_name , domain , res_id )
@api.depends ( ' sale_order_id.invoice_status ' , ' tasks.sale_order_id.invoice_status ' )
def _compute_has_any_so_with_nothing_to_invoice ( self ) :
""" Has any Sale Order whose invoice_status is set as No """
if not self . ids :
self . has_any_so_with_nothing_to_invoice = False
return
project_nothing_to_invoice = self . _get_projects_for_invoice_status ( ' no ' )
project_nothing_to_invoice . has_any_so_with_nothing_to_invoice = True
( self - project_nothing_to_invoice ) . has_any_so_with_nothing_to_invoice = False
def action_create_invoice ( self ) :
action = self . env [ " ir.actions.actions " ] . _for_xml_id ( " sale.action_view_sale_advance_payment_inv " )
so_ids = ( self . sale_order_id | self . task_ids . sale_order_id ) . filtered ( lambda so : so . invoice_status in [ ' to invoice ' , ' no ' ] ) . ids
action [ ' context ' ] = {
' active_id ' : so_ids [ 0 ] if len ( so_ids ) == 1 else False ,
' active_ids ' : so_ids
}
if not self . has_any_so_to_invoice :
action [ ' context ' ] [ ' default_advance_payment_method ' ] = ' percentage '
return action
def action_open_project_invoices ( self ) :
move_lines = self . env [ ' account.move.line ' ] . search_fetch (
[
( ' move_id.move_type ' , ' in ' , [ ' out_invoice ' , ' out_refund ' ] ) ,
( ' analytic_distribution ' , ' in ' , self . account_id . ids ) ,
] ,
[ ' move_id ' ] ,
)
invoice_ids = move_lines . move_id . ids
action = {
' name ' : _ ( ' Invoices ' ) ,
' type ' : ' ir.actions.act_window ' ,
' res_model ' : ' account.move ' ,
' views ' : [ [ False , ' list ' ] , [ False , ' form ' ] , [ False , ' kanban ' ] ] ,
' domain ' : [ ( ' id ' , ' in ' , invoice_ids ) ] ,
' context ' : {
' default_move_type ' : ' out_invoice ' ,
' default_partner_id ' : self . partner_id . id ,
' project_id ' : self . id
} ,
' help ' : " <p class= ' o_view_nocontent_smiling_face ' > %s </p><p> %s </p> " %
( _ ( " Create a customer invoice " ) ,
_ ( " Create invoices, register payments and keep track of the discussions with your customers. " ) )
}
if len ( invoice_ids ) == 1 and not self . env . context . get ( ' from_embedded_action ' , False ) :
action [ ' views ' ] = [ [ False , ' form ' ] ]
action [ ' res_id ' ] = invoice_ids [ 0 ]
return action
# ----------------------------
# Project Updates
# ----------------------------
def _fetch_sale_order_items_per_project_id ( self , domain_per_model = None ) :
if not self :
return { }
if len ( self ) == 1 :
return { self . id : self . _fetch_sale_order_items ( domain_per_model ) }
sql = self . _get_sale_order_items_query ( domain_per_model ) . select ( ' id ' , ' ARRAY_AGG(DISTINCT sale_line_id) AS sale_line_ids ' )
sql = SQL ( " %s GROUP BY id " , sql )
return {
id_ : self . env [ ' sale.order.line ' ] . browse ( sale_line_ids )
for id_ , sale_line_ids in self . env . execute_query ( sql )
}
def _fetch_sale_order_items ( self , domain_per_model = None , limit = None , offset = None ) :
return self . env [ ' sale.order.line ' ] . browse ( self . _fetch_sale_order_item_ids ( domain_per_model , limit , offset ) )
def _fetch_sale_order_item_ids ( self , domain_per_model = None , limit = None , offset = None ) :
if not self or not self . filtered ( ' allow_billable ' ) :
return [ ]
query = self . _get_sale_order_items_query ( domain_per_model )
query . limit = limit
query . offset = offset
return [ id_ for id_ , in self . env . execute_query ( query . select ( ' DISTINCT sale_line_id ' ) ) ]
def _get_sale_orders ( self ) :
return self . _get_sale_order_items ( ) . order_id
def _get_sale_order_items ( self ) :
return self . _fetch_sale_order_items ( )
def _get_sale_order_items_query ( self , domain_per_model = None ) :
if domain_per_model is None :
domain_per_model = { }
billable_project_domain = [ ( ' allow_billable ' , ' = ' , True ) ]
project_domain = [ ( ' id ' , ' in ' , self . ids ) , ( ' sale_line_id ' , ' != ' , False ) ]
if ' project.project ' in domain_per_model :
project_domain = expression . AND ( [
domain_per_model [ ' project.project ' ] ,
project_domain ,
billable_project_domain ,
] )
project_query = self . env [ ' project.project ' ] . _where_calc ( project_domain )
self . _apply_ir_rules ( project_query , ' read ' )
2025-03-04 12:23:19 +07:00
project_sql = project_query . select ( f ' { self . _table } .id ' , f ' { self . _table } .sale_line_id ' )
2025-01-06 10:57:38 +07:00
Task = self . env [ ' project.task ' ]
task_domain = [ ( ' project_id ' , ' in ' , self . ids ) , ( ' sale_line_id ' , ' != ' , False ) ]
if Task . _name in domain_per_model :
task_domain = expression . AND ( [
domain_per_model [ Task . _name ] ,
task_domain ,
] )
task_query = Task . _where_calc ( task_domain )
Task . _apply_ir_rules ( task_query , ' read ' )
task_sql = task_query . select ( f ' { Task . _table } .project_id AS id ' , f ' { Task . _table } .sale_line_id ' )
ProjectMilestone = self . env [ ' project.milestone ' ]
milestone_domain = [ ( ' project_id ' , ' in ' , self . ids ) , ( ' allow_billable ' , ' = ' , True ) , ( ' sale_line_id ' , ' != ' , False ) ]
if ProjectMilestone . _name in domain_per_model :
milestone_domain = expression . AND ( [
domain_per_model [ ProjectMilestone . _name ] ,
milestone_domain ,
billable_project_domain ,
] )
milestone_query = ProjectMilestone . _where_calc ( milestone_domain )
ProjectMilestone . _apply_ir_rules ( milestone_query )
milestone_sql = milestone_query . select (
f ' { ProjectMilestone . _table } .project_id AS id ' ,
f ' { ProjectMilestone . _table } .sale_line_id ' ,
)
SaleOrderLine = self . env [ ' sale.order.line ' ]
sale_order_line_domain = [
' & ' ,
( ' display_type ' , ' = ' , False ) ,
( ' order_id ' , ' any ' , [ ' | ' ,
( ' id ' , ' in ' , self . reinvoiced_sale_order_id . ids ) ,
( ' project_id ' , ' in ' , self . ids ) ,
] ) ,
]
sale_order_line_query = SaleOrderLine . _where_calc ( sale_order_line_domain )
sale_order_line_sql = sale_order_line_query . select (
f ' { SaleOrderLine . _table } .project_id AS id ' ,
f ' { SaleOrderLine . _table } .id AS sale_line_id ' ,
)
return Query ( self . env , ' project_sale_order_item ' , SQL ( ' ( %s ) ' , SQL ( ' UNION ' ) . join ( [
project_sql , task_sql , milestone_sql , sale_order_line_sql ,
] ) ) )
def get_panel_data ( self ) :
panel_data = super ( ) . get_panel_data ( )
foldable_sections = self . _get_foldable_section ( )
if self . _show_profitability ( ) and ' revenues ' in panel_data [ ' profitability_items ' ] :
for section in panel_data [ ' profitability_items ' ] [ ' revenues ' ] [ ' data ' ] :
if section [ ' id ' ] in foldable_sections :
section [ ' isSectionFoldable ' ] = True
return {
* * panel_data ,
' show_sale_items ' : self . allow_billable ,
}
def _get_foldable_section ( self ) :
return [ ' materials ' , ' service_revenues ' ]
def get_sale_items_data ( self , offset = 0 , limit = None , with_action = True , section_id = None ) :
if not self . env . user . has_group ( ' project.group_project_user ' ) :
return { }
all_sols = self . env [ ' sale.order.line ' ] . sudo ( ) . search (
self . _get_domain_from_section_id ( section_id ) ,
offset = offset ,
limit = limit + 1 ,
)
display_load_more = False
if len ( all_sols ) > limit :
all_sols = all_sols - all_sols [ limit ]
display_load_more = True
# filter to only get the action for the SOLs that the user can read
action_per_sol = all_sols . sudo ( False ) . _filtered_access ( ' read ' ) . _get_action_per_item ( ) if with_action else { }
def get_action ( sol_id ) :
""" Return the action vals to call it in frontend if the user can access to the SO related """
action , res_id = action_per_sol . get ( sol_id , ( None , None ) )
return { ' action ' : { ' name ' : action , ' resId ' : res_id , ' buttonContext ' : json . dumps ( { ' active_id ' : sol_id , ' default_project_id ' : self . id } ) } } if action else { }
return {
' sol_items ' : [ {
* * sol_read ,
* * get_action ( sol_read [ ' id ' ] ) ,
} for sol_read in all_sols . with_context ( with_price_unit = True ) . _read_format ( [ ' name ' , ' product_uom_qty ' , ' qty_delivered ' , ' qty_invoiced ' , ' product_uom ' , ' product_id ' ] ) ] ,
' displayLoadMore ' : display_load_more ,
}
def _get_sale_items_domain ( self , additional_domain = None ) :
sale_items = self . sudo ( ) . _get_sale_order_items ( )
domain = [
( ' order_id ' , ' in ' , sale_items . sudo ( ) . order_id . ids ) ,
( ' is_downpayment ' , ' = ' , False ) ,
( ' state ' , ' = ' , ' sale ' ) ,
( ' display_type ' , ' = ' , False ) ,
' | ' ,
( ' project_id ' , ' in ' , [ * self . ids , False ] ) ,
( ' id ' , ' in ' , sale_items . ids ) ,
]
if additional_domain :
domain = expression . AND ( [ domain , additional_domain ] )
return domain
def _get_domain_from_section_id ( self , section_id ) :
# When the sale_timesheet module is not installed, all service products are grouped under the 'service revenues' section.
return self . _get_sale_items_domain ( [ ( ' product_type ' , ' != ' if section_id == ' materials ' else ' = ' , ' service ' ) ] )
def _show_profitability ( self ) :
self . ensure_one ( )
return self . allow_billable and super ( ) . _show_profitability ( )
def _show_profitability_helper ( self ) :
return True
def _get_profitability_labels ( self ) :
return {
* * super ( ) . _get_profitability_labels ( ) ,
' service_revenues ' : self . env . _ ( ' Other Services ' ) ,
' materials ' : self . env . _ ( ' Materials ' ) ,
' other_invoice_revenues ' : self . env . _ ( ' Customer Invoices ' ) ,
' downpayments ' : self . env . _ ( ' Down Payments ' ) ,
}
def _get_profitability_sequence_per_invoice_type ( self ) :
return {
* * super ( ) . _get_profitability_sequence_per_invoice_type ( ) ,
' service_revenues ' : 6 ,
' materials ' : 7 ,
' other_invoice_revenues ' : 9 ,
' downpayments ' : 20 ,
}
def _get_service_policy_to_invoice_type ( self ) :
return {
' ordered_prepaid ' : ' service_revenues ' ,
' delivered_milestones ' : ' service_revenues ' ,
' delivered_manual ' : ' service_revenues ' ,
}
def _get_profitability_sale_order_items_domain ( self , domain = None ) :
if domain is None :
domain = [ ]
return expression . AND ( [
[
' | ' , ( ' product_id ' , ' != ' , False ) , ( ' is_downpayment ' , ' = ' , True ) ,
( ' is_expense ' , ' = ' , False ) ,
( ' state ' , ' = ' , ' sale ' ) ,
' | ' , ( ' qty_to_invoice ' , ' > ' , 0 ) , ( ' qty_invoiced ' , ' > ' , 0 ) ,
] ,
domain ,
] )
def _get_revenues_items_from_sol ( self , domain = None , with_action = True ) :
sale_line_read_group = self . env [ ' sale.order.line ' ] . sudo ( ) . _read_group (
self . _get_profitability_sale_order_items_domain ( domain ) ,
[ ' currency_id ' , ' product_id ' , ' is_downpayment ' ] ,
[ ' id:array_agg ' , ' untaxed_amount_to_invoice:sum ' , ' untaxed_amount_invoiced:sum ' ] ,
)
display_sol_action = with_action and len ( self ) == 1 and self . env . user . has_group ( ' sales_team.group_sale_salesman ' )
revenues_dict = { }
total_to_invoice = total_invoiced = 0.0
data = [ ]
sequence_per_invoice_type = self . _get_profitability_sequence_per_invoice_type ( )
if sale_line_read_group :
# Get conversion rate from currencies of the sale order lines to currency of project
convert_company = self . company_id or self . env . company
sols_per_product = defaultdict ( lambda : [ 0.0 , 0.0 , [ ] ] )
downpayment_amount_invoiced = 0
downpayment_sol_ids = [ ]
for currency , product , is_downpayment , sol_ids , untaxed_amount_to_invoice , untaxed_amount_invoiced in sale_line_read_group :
if is_downpayment :
downpayment_amount_invoiced + = currency . _convert ( untaxed_amount_invoiced , convert_company . currency_id , convert_company , round = False )
downpayment_sol_ids + = sol_ids
else :
sols_per_product [ product . id ] [ 0 ] + = currency . _convert ( untaxed_amount_to_invoice , convert_company . currency_id , convert_company )
sols_per_product [ product . id ] [ 1 ] + = currency . _convert ( untaxed_amount_invoiced , convert_company . currency_id , convert_company )
sols_per_product [ product . id ] [ 2 ] + = sol_ids
if downpayment_amount_invoiced :
downpayments_data = {
' id ' : ' downpayments ' ,
' sequence ' : sequence_per_invoice_type [ ' downpayments ' ] ,
' invoiced ' : downpayment_amount_invoiced ,
' to_invoice ' : - downpayment_amount_invoiced ,
}
if with_action and (
self . env . user . has_group ( ' sales_team.group_sale_salesman_all_leads, ' )
or self . env . user . has_group ( ' account.group_account_invoice, ' )
or self . env . user . has_group ( ' account.group_account_readonly ' )
) :
invoices = self . env [ ' account.move ' ] . search ( [ ( ' line_ids.sale_line_ids ' , ' in ' , downpayment_sol_ids ) ] )
args = [ ' downpayments ' , [ ( ' id ' , ' in ' , invoices . ids ) ] ]
if len ( invoices ) == 1 :
args . append ( invoices . id )
downpayments_data [ ' action ' ] = {
' name ' : ' action_profitability_items ' ,
' type ' : ' object ' ,
' args ' : json . dumps ( args ) ,
}
data + = [ downpayments_data ]
total_invoiced + = downpayment_amount_invoiced
total_to_invoice - = downpayment_amount_invoiced
product_read_group = self . env [ ' product.product ' ] . sudo ( ) . _read_group (
[ ( ' id ' , ' in ' , list ( sols_per_product ) ) ] ,
[ ' invoice_policy ' , ' service_type ' , ' type ' ] ,
[ ' id:array_agg ' ] ,
)
service_policy_to_invoice_type = self . _get_service_policy_to_invoice_type ( )
general_to_service_map = self . env [ ' product.template ' ] . _get_general_to_service_map ( )
for invoice_policy , service_type , type_ , product_ids in product_read_group :
service_policy = None
if type_ == ' service ' :
service_policy = general_to_service_map . get (
( invoice_policy , service_type ) ,
' ordered_prepaid ' )
for product_id , ( amount_to_invoice , amount_invoiced , sol_ids ) in sols_per_product . items ( ) :
if product_id in product_ids :
invoice_type = service_policy_to_invoice_type . get ( service_policy , ' materials ' )
revenue = revenues_dict . setdefault ( invoice_type , { ' invoiced ' : 0.0 , ' to_invoice ' : 0.0 } )
revenue [ ' to_invoice ' ] + = amount_to_invoice
total_to_invoice + = amount_to_invoice
revenue [ ' invoiced ' ] + = amount_invoiced
total_invoiced + = amount_invoiced
if display_sol_action and invoice_type in [ ' service_revenues ' , ' materials ' ] :
revenue . setdefault ( ' record_ids ' , [ ] ) . extend ( sol_ids )
if display_sol_action :
section_name = ' materials '
materials = revenues_dict . get ( section_name , { } )
sale_order_items = self . env [ ' sale.order.line ' ] \
. browse ( materials . pop ( ' record_ids ' , [ ] ) ) \
. _filtered_access ( ' read ' )
if sale_order_items :
args = [ section_name , [ ( ' id ' , ' in ' , sale_order_items . ids ) ] ]
if len ( sale_order_items ) == 1 :
args . append ( sale_order_items . id )
action_params = {
' name ' : ' action_profitability_items ' ,
' type ' : ' object ' ,
' args ' : json . dumps ( args ) ,
}
if len ( sale_order_items ) == 1 :
action_params [ ' res_id ' ] = sale_order_items . id
materials [ ' action ' ] = action_params
sequence_per_invoice_type = self . _get_profitability_sequence_per_invoice_type ( )
data + = [ {
' id ' : invoice_type ,
' sequence ' : sequence_per_invoice_type [ invoice_type ] ,
* * vals ,
} for invoice_type , vals in revenues_dict . items ( ) ]
return {
' data ' : data ,
' total ' : { ' to_invoice ' : total_to_invoice , ' invoiced ' : total_invoiced } ,
}
def _get_revenues_items_from_invoices_domain ( self , domain = None ) :
if domain is None :
domain = [ ]
included_invoice_line_ids = self . _get_already_included_profitability_invoice_line_ids ( )
return expression . AND ( [
domain ,
[ ( ' move_id.move_type ' , ' in ' , self . env [ ' account.move ' ] . get_sale_types ( ) ) ,
( ' parent_state ' , ' in ' , [ ' draft ' , ' posted ' ] ) ,
( ' price_subtotal ' , ' != ' , 0 ) ,
( ' is_downpayment ' , ' = ' , False ) ,
( ' id ' , ' not in ' , included_invoice_line_ids ) ] ,
] )
def _get_revenues_items_from_invoices ( self , excluded_move_line_ids = None , with_action = True ) :
"""
Get all revenues items from invoices , and put them into their own
" other_invoice_revenues " section .
If the final total is 0 for either to_invoice or invoiced ( ex : invoice - > credit note ) ,
we don ' t output a new section
: param excluded_move_line_ids a list of ' account.move.line ' to ignore
when fetching the move lines , for example a list of invoices that were
generated from a sales order
"""
if excluded_move_line_ids is None :
excluded_move_line_ids = [ ]
invoices_move_lines = self . env [ ' account.move.line ' ] . sudo ( ) . search_fetch (
expression . AND ( [
self . _get_revenues_items_from_invoices_domain ( [ ( ' id ' , ' not in ' , excluded_move_line_ids ) ] ) ,
[ ( ' analytic_distribution ' , ' in ' , self . account_id . ids ) ]
] ) ,
[ ' price_subtotal ' , ' parent_state ' , ' currency_id ' , ' analytic_distribution ' , ' move_type ' , ' move_id ' ]
)
# TODO: invoices_move_lines.with_context(prefetch_fields=False).move_id.move_type ??
if invoices_move_lines :
amount_invoiced = amount_to_invoice = 0.0
for move_line in invoices_move_lines :
currency = move_line . currency_id
price_subtotal = currency . _convert ( move_line . price_subtotal , self . currency_id , self . company_id )
# an analytic account can appear several time in an analytic distribution with different repartition percentage
analytic_contribution = sum (
percentage for ids , percentage in move_line . analytic_distribution . items ( )
if str ( self . account_id . id ) in ids . split ( ' , ' )
) / 100.
if move_line . parent_state == ' draft ' :
if move_line . move_type == ' out_invoice ' :
amount_to_invoice + = price_subtotal * analytic_contribution
else : # move_line.move_type == 'out_refund'
amount_to_invoice - = price_subtotal * analytic_contribution
else : # move_line.parent_state == 'posted'
if move_line . move_type == ' out_invoice ' :
amount_invoiced + = price_subtotal * analytic_contribution
else : # moves_read['move_type'] == 'out_refund'
amount_invoiced - = price_subtotal * analytic_contribution
# don't display the section if the final values are both 0 (invoice -> credit note)
if amount_invoiced != 0 or amount_to_invoice != 0 :
section_id = ' other_invoice_revenues '
invoices_revenues = {
' id ' : section_id ,
' sequence ' : self . _get_profitability_sequence_per_invoice_type ( ) [ section_id ] ,
' invoiced ' : amount_invoiced ,
' to_invoice ' : amount_to_invoice ,
}
if with_action and (
self . env . user . has_group ( ' sales_team.group_sale_salesman_all_leads ' )
or self . env . user . has_group ( ' account.group_account_invoice ' )
or self . env . user . has_group ( ' account.group_account_readonly ' )
) :
invoices_revenues [ ' action ' ] = self . _get_action_for_profitability_section ( invoices_move_lines . move_id . ids , section_id )
return {
' data ' : [ invoices_revenues ] ,
' total ' : {
' invoiced ' : amount_invoiced ,
' to_invoice ' : amount_to_invoice ,
} ,
}
return { ' data ' : [ ] , ' total ' : { ' invoiced ' : 0.0 , ' to_invoice ' : 0.0 } }
def _add_invoice_items ( self , domain , profitability_items , with_action = True ) :
sale_lines = self . env [ ' sale.order.line ' ] . sudo ( ) . _read_group (
self . _get_profitability_sale_order_items_domain ( domain ) ,
[ ] ,
[ ' id:recordset ' ] ,
) [ 0 ] [ 0 ]
revenue_items_from_invoices = self . _get_revenues_items_from_invoices (
excluded_move_line_ids = sale_lines . invoice_lines . ids ,
with_action = with_action
)
profitability_items [ ' revenues ' ] [ ' data ' ] + = revenue_items_from_invoices [ ' data ' ]
profitability_items [ ' revenues ' ] [ ' total ' ] [ ' to_invoice ' ] + = revenue_items_from_invoices [ ' total ' ] [ ' to_invoice ' ]
profitability_items [ ' revenues ' ] [ ' total ' ] [ ' invoiced ' ] + = revenue_items_from_invoices [ ' total ' ] [ ' invoiced ' ]
def _get_profitability_items ( self , with_action = True ) :
profitability_items = super ( ) . _get_profitability_items ( with_action )
sale_items = self . sudo ( ) . _get_sale_order_items ( )
domain = [
( ' order_id ' , ' in ' , sale_items . order_id . ids ) ,
' | ' ,
' | ' ,
( ' project_id ' , ' in ' , self . ids ) ,
( ' project_id ' , ' = ' , False ) ,
( ' id ' , ' in ' , sale_items . ids ) ,
]
revenue_items_from_sol = self . _get_revenues_items_from_sol (
domain ,
with_action ,
)
profitability_items [ ' revenues ' ] [ ' data ' ] + = revenue_items_from_sol [ ' data ' ]
profitability_items [ ' revenues ' ] [ ' total ' ] [ ' to_invoice ' ] + = revenue_items_from_sol [ ' total ' ] [ ' to_invoice ' ]
profitability_items [ ' revenues ' ] [ ' total ' ] [ ' invoiced ' ] + = revenue_items_from_sol [ ' total ' ] [ ' invoiced ' ]
self . _add_invoice_items ( domain , profitability_items , with_action = with_action )
self . _add_purchase_items ( profitability_items , with_action = with_action )
return profitability_items
def _get_stat_buttons ( self ) :
buttons = super ( ) . _get_stat_buttons ( )
if self . env . user . has_group ( ' sales_team.group_sale_salesman_all_leads ' ) :
self_sudo = self . sudo ( )
buttons . append ( {
' icon ' : ' dollar ' ,
' text ' : self . env . _ ( ' Sales Orders ' ) ,
' number ' : self_sudo . sale_order_count ,
' action_type ' : ' object ' ,
' action ' : ' action_view_sos ' ,
' additional_context ' : json . dumps ( {
' create_for_project_id ' : self . id ,
} ) ,
' show ' : self_sudo . display_sales_stat_buttons and self_sudo . sale_order_count > 0 ,
' sequence ' : 27 ,
} )
if self . env . user . has_group ( ' sales_team.group_sale_salesman_all_leads ' ) :
buttons . append ( {
' icon ' : ' dollar ' ,
' text ' : self . env . _ ( ' Sales Order Items ' ) ,
' number ' : self . sale_order_line_count ,
' action_type ' : ' object ' ,
' action ' : ' action_view_sols ' ,
' show ' : self . display_sales_stat_buttons ,
' sequence ' : 28 ,
} )
if self . env . user . has_group ( ' account.group_account_readonly ' ) :
self_sudo = self . sudo ( )
buttons . append ( {
' icon ' : ' pencil-square-o ' ,
' text ' : self . env . _ ( ' Invoices ' ) ,
' number ' : self_sudo . invoice_count ,
' action_type ' : ' object ' ,
' action ' : ' action_open_project_invoices ' ,
' show ' : bool ( self . account_id ) and self_sudo . invoice_count > 0 ,
' sequence ' : 30 ,
} )
if self . env . user . has_group ( ' account.group_account_readonly ' ) :
self_sudo = self . sudo ( )
buttons . append ( {
' icon ' : ' pencil-square-o ' ,
' text ' : self . env . _ ( ' Vendor Bills ' ) ,
' number ' : self_sudo . vendor_bill_count ,
' action_type ' : ' object ' ,
' action ' : ' action_open_project_vendor_bills ' ,
' show ' : self_sudo . vendor_bill_count > 0 ,
' sequence ' : 38 ,
} )
return buttons
# ---------------------------------------------------
# Actions
# ---------------------------------------------------
def _get_hide_partner ( self ) :
return not self . allow_billable
def _get_projects_to_make_billable_domain ( self ) :
return expression . AND ( [
super ( ) . _get_projects_to_make_billable_domain ( ) ,
[ ( ' allow_billable ' , ' = ' , False ) ] ,
] )
def action_view_tasks ( self ) :
if self . env . context . get ( ' generate_milestone ' ) :
line_id = self . env . context . get ( ' default_sale_line_id ' )
default_line = self . env [ ' sale.order.line ' ] . browse ( line_id )
milestone = self . env [ ' project.milestone ' ] . create ( {
' name ' : default_line . name ,
' project_id ' : self . id ,
' sale_line_id ' : line_id ,
' quantity_percentage ' : 1 ,
} )
if default_line . product_id . service_tracking == ' task_in_project ' :
default_line . task_id . milestone_id = milestone . id
action = super ( ) . action_view_tasks ( )
action [ ' context ' ] [ ' hide_partner ' ] = self . _get_hide_partner ( )
return action
def action_open_project_vendor_bills ( self ) :
move_lines = self . env [ ' account.move.line ' ] . search_fetch (
[
( ' move_id.move_type ' , ' in ' , [ ' in_invoice ' , ' in_refund ' ] ) ,
( ' analytic_distribution ' , ' in ' , self . account_id . ids ) ,
] ,
[ ' move_id ' ] ,
)
vendor_bill_ids = move_lines . move_id . ids
action_window = {
' name ' : _ ( ' Vendor Bills ' ) ,
' type ' : ' ir.actions.act_window ' ,
' res_model ' : ' account.move ' ,
' views ' : [ [ False , ' list ' ] , [ False , ' form ' ] , [ False , ' kanban ' ] ] ,
' domain ' : [ ( ' id ' , ' in ' , vendor_bill_ids ) ] ,
' context ' : {
' default_move_type ' : ' in_invoice ' ,
' project_id ' : self . id ,
} ,
' help ' : " <p class= ' o_view_nocontent_smiling_face ' > %s </p><p> %s </p> " % (
_ ( " Create a vendor bill " ) ,
_ ( " Create invoices, register payments and keep track of the discussions with your vendors. " ) ,
) ,
}
if not self . env . context . get ( ' from_embedded_action ' ) and len ( vendor_bill_ids ) == 1 :
action_window [ ' views ' ] = [ [ False , ' form ' ] ]
action_window [ ' res_id ' ] = vendor_bill_ids [ 0 ]
return action_window