Initial commit: Sale packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:49 +02:00
commit 14e3d26998
6469 changed files with 2479670 additions and 0 deletions

View file

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models

View file

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Project - Sale - Expense',
'version': '1.0',
'description': 'Adds a full traceability of reinvoice expenses on the profitability report.',
'license': 'LGPL-3',
'category': 'Hidden',
'depends': ['sale_project', 'sale_expense', 'project_hr_expense'],
'auto_install': True,
}

View file

@ -0,0 +1,21 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * project_sale_expense
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-02-06 13:32+0000\n"
"PO-Revision-Date: 2024-02-06 13:32+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: project_sale_expense
#: model:ir.model,name:project_sale_expense.model_project_project
msgid "Project"
msgstr "Projekat"

View file

@ -0,0 +1,21 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * project_sale_expense
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-02-06 13:32+0000\n"
"PO-Revision-Date: 2024-02-06 13:32+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: project_sale_expense
#: model:ir.model,name:project_sale_expense.model_project_project
msgid "Project"
msgstr ""

View file

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import project_project

View file

@ -0,0 +1,107 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from collections import defaultdict
from odoo import fields, models
class Project(models.Model):
_inherit = 'project.project'
def _get_expenses_profitability_items(self, with_action=True):
if not self.analytic_account_id:
return {}
can_see_expense = with_action and self.user_has_groups('hr_expense.group_hr_expense_team_approver')
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', 'currency_id', 'array_agg(id) as ids', 'SUM(untaxed_amount) as untaxed_amount')
query_string = f"{query_string} GROUP BY sale_order_id, product_id, currency_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
dict_amount_per_currency = defaultdict(lambda: 0.0)
for res in expenses_read_group:
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'])
dict_amount_per_currency[res['currency_id']] += res['untaxed_amount']
date = fields.Date.context_today(self)
for currency_id in dict_amount_per_currency:
if currency_id == self.company_id.currency_id.id:
amount_billed += dict_amount_per_currency[currency_id]
continue
currency = self.env['res.currency'].browse(currency_id)
amount_billed += currency._convert(dict_amount_per_currency[currency_id], self.company_id.currency_id, self.company_id, date)
sol_read_group = self.env['sale.order.line'].sudo()._read_group(
[
('order_id', 'in', list(expenses_per_so_id.keys())),
('is_expense', '=', True),
('state', 'in', ['sale', 'done']),
],
['order_id', 'product_id', 'untaxed_amount_to_invoice', 'untaxed_amount_invoiced'],
['order_id', 'product_id'],
lazy=False)
total_amount_expense_invoiced = total_amount_expense_to_invoice = 0.0
reinvoice_expense_ids = []
for res in sol_read_group:
expense_data_per_product_id = expenses_per_so_id[res['order_id'][0]]
product_id = res['product_id'][0]
if product_id in expense_data_per_product_id:
total_amount_expense_to_invoice += res['untaxed_amount_to_invoice']
total_amount_expense_invoiced += res['untaxed_amount_invoiced']
reinvoice_expense_ids += expense_data_per_product_id[product_id]
section_id = 'expenses'
sequence = self._get_profitability_sequence_per_invoice_type()[section_id]
expense_data = {
'costs': {
'id': section_id,
'sequence': sequence,
'billed': -amount_billed,
'to_bill': 0.0,
},
}
if reinvoice_expense_ids:
expense_data['revenues'] = {
'id': section_id,
'sequence': sequence,
'invoiced': total_amount_expense_invoiced,
'to_invoice': total_amount_expense_to_invoice,
}
if can_see_expense:
def get_action(res_ids):
args = [section_id, [('id', 'in', res_ids)]]
if len(res_ids) == 1:
args.append(res_ids[0])
return {'name': 'action_profitability_items', 'type': 'object', 'args': json.dumps(args)}
if reinvoice_expense_ids:
expense_data['revenues']['action'] = get_action(reinvoice_expense_ids)
if expense_ids:
expense_data['costs']['action'] = get_action(expense_ids)
return expense_data
def _get_already_included_profitability_invoice_line_ids(self):
move_line_ids = super()._get_already_included_profitability_invoice_line_ids()
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')
query_string = f"{query_string} GROUP BY sale_order_id"
self._cr.execute(query_string, query_param)
expenses_read_group = list(self._cr.dictfetchall())
if not expenses_read_group:
return move_line_ids
for res in expenses_read_group:
sale_order = self.env['sale.order'].browse(res['sale_order_id'])
move_line_ids.extend(sale_order.invoice_ids.mapped('invoice_line_ids').ids)
return move_line_ids

View file

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_project_profitability

View file

@ -0,0 +1,231 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import tagged
from odoo.addons.project_hr_expense.tests.test_project_profitability import TestProjectHrExpenseProfitabilityCommon
from odoo.addons.sale.tests.common import TestSaleCommon
from odoo.addons.sale_project.tests.test_project_profitability import TestProjectProfitabilityCommon
@tagged('-at_install', 'post_install')
class TestProjectSaleExpenseProfitability(TestProjectProfitabilityCommon, TestProjectHrExpenseProfitabilityCommon, TestSaleCommon):
def test_project_profitability(self):
expense = self.env['hr.expense'].create({
'name': 'expense',
'product_id': self.company_data['product_order_sales_price'].id,
'unit_amount': self.company_data['product_order_sales_price'].list_price,
'employee_id': self.expense_employee.id,
'analytic_distribution': {self.project.analytic_account_id.id: 100},
'sale_order_id': self.sale_order.id,
})
# See method definition in `project_hr_expense.tests.test_project_profitability`
expense_sheet = self.check_project_profitability_before_creating_and_approving_expense_sheet(
expense,
self.project,
self.project_profitability_items_empty)
expense_profitability = self.project._get_expenses_profitability_items(False)
sequence_per_invoice_type = self.project._get_profitability_sequence_per_invoice_type()
self.assertIn('expenses', sequence_per_invoice_type)
expense_sequence = sequence_per_invoice_type['expenses']
self.assertDictEqual(
expense_profitability.get('revenues', {}),
{},
)
self.assertDictEqual(
expense_profitability['costs'],
{'id': 'expenses', 'sequence': expense_sequence, 'billed': -280.0, 'to_bill': 0.0},
)
expense_sheet.action_sheet_move_create()
self.assertRecordValues(self.sale_order.order_line, [
# Original SO line:
{
'product_id': self.product_delivery_service.id,
'qty_delivered': 0.0,
'product_uom_qty': 10,
'is_expense': False,
},
{
'product_id': self.company_data['product_order_sales_price'].id,
'qty_delivered': 1.0,
'product_uom_qty': 1.0,
'is_expense': True,
},
])
expense_sol = self.sale_order.order_line.filtered(lambda sol: sol.product_id == self.company_data['product_order_sales_price'])
expense_profitability = self.project._get_expenses_profitability_items(False)
self.assertDictEqual(
expense_profitability.get('revenues', {}),
{'id': 'expenses', 'sequence': expense_sequence, 'invoiced': 0.0, 'to_invoice': expense_sol.untaxed_amount_to_invoice},
)
self.assertDictEqual(
expense_profitability['costs'],
{'id': 'expenses', 'sequence': expense_sequence, 'billed': -280.0, 'to_bill': 0.0},
)
self.assertDictEqual(
self.project._get_profitability_items(False),
{
'revenues': {
'data': [expense_profitability['revenues']],
'total': {k: v for k, v in expense_profitability['revenues'].items() if k in ['to_invoice', 'invoiced']},
},
'costs': {
'data': [expense_profitability['costs']],
'total': {k: v for k, v in expense_profitability['costs'].items() if k in ['to_bill', 'billed']},
},
}
)
invoice = self.env['sale.advance.payment.inv'] \
.with_context({
'active_model': 'sale.order',
'active_id': self.sale_order.id,
}).create({
'advance_payment_method': 'delivered',
})._create_invoices(self.sale_order)
invoice.action_post()
expense_profitability = self.project._get_expenses_profitability_items(False)
self.assertDictEqual(
expense_profitability.get('revenues', {}),
{'id': 'expenses', 'sequence': expense_sequence, 'invoiced': expense_sol.untaxed_amount_invoiced, 'to_invoice': 0.0},
)
credit_note = invoice._reverse_moves()
credit_note.action_post()
expense_profitability = self.project._get_expenses_profitability_items(False)
self.assertDictEqual(
expense_profitability.get('revenues', {}),
{'id': 'expenses', 'sequence': expense_sequence, 'invoiced': 0.0, 'to_invoice': expense_sol.untaxed_amount_to_invoice},
)
self.sale_order._action_cancel()
expense_profitability = self.project._get_expenses_profitability_items(False)
self.assertDictEqual(
expense_profitability.get('revenues', {}),
{},
)
self.assertDictEqual(
expense_profitability['costs'],
{'id': 'expenses', 'sequence': expense_sequence, 'billed': -280.0, 'to_bill': 0.0},
)
expense_sheet.refuse_sheet('Test Cancel Expense')
expense_profitability = self.project._get_expenses_profitability_items(False)
self.assertDictEqual(
expense_profitability.get('revenues', {}),
{},
)
self.assertDictEqual(
expense_profitability.get('costs', {}),
{},
)
def test_project_profitability_2(self):
"""
Test Case:
==========
- Create an expense for a project.
- post it's entry moves
- create an invoice for the sale order linked to the expense
- post the invoice
- the project profitability should not include the Customer invoice
linked to the expense in the revenues, as the Expenses will be there.
"""
product_new_project_task = self.env['product.product'].create({
'name': "Service, create task in new project",
'standard_price': 30,
'list_price': 90,
'type': 'service',
'default_code': 'SERV-ORDERED2',
'service_tracking': 'task_in_project',
})
sale_order = self.env['sale.order'].with_context(tracking_disable=True).create({
'partner_id': self.partner.id,
'partner_invoice_id': self.partner.id,
'partner_shipping_id': self.partner.id,
})
self.env['sale.order.line'].create({
'product_id': product_new_project_task.id,
'product_uom_qty': 1,
'order_id': sale_order.id,
})
sale_order.action_confirm()
project = sale_order.order_line.project_id
expense = self.env['hr.expense'].create({
'name': 'expense',
'product_id': self.company_data['product_order_cost'].id,
'unit_amount': self.company_data['product_order_cost'].list_price,
'employee_id': self.expense_employee.id,
'analytic_distribution': {project.analytic_account_id.id: 100},
'sale_order_id': sale_order.id,
})
expense_sheet_vals_list = expense._get_default_expense_sheet_values()
expense_sheet = self.env['hr.expense.sheet'].create(expense_sheet_vals_list)
expense_sheet.action_submit_sheet()
expense_sheet.approve_expense_sheets()
expense_sheet.action_sheet_move_create()
invoice = sale_order._create_invoices()
invoice.action_post()
sale_items = project.sudo()._get_sale_order_items()
domain = [
('order_id', 'in', sale_items.order_id.ids),
'|',
'|',
('project_id', 'in', project.ids),
('project_id', '=', False),
('id', 'in', sale_items.ids),
]
revenue_items_from_sol = project._get_revenues_items_from_sol(domain, False)
expense_profitability = project._get_expenses_profitability_items(False)
project_profitability = project._get_profitability_items(False)
# invoice linked to the expense should not be included in the revenues
self.assertDictEqual(
project_profitability.get('revenues', {}),
{
'data': [expense_profitability['revenues'], revenue_items_from_sol['data'][0]],
'total': {'invoiced': expense_profitability['revenues']['invoiced'] + revenue_items_from_sol['total']['invoiced'], 'to_invoice': expense_profitability['revenues']['to_invoice'] + revenue_items_from_sol['total']['to_invoice']},
},
)
def test_project_profitability_multi_currency(self):
currency_rate = 0.5
other_currency = self.env['res.currency'].create({
'name': 'TEST',
'symbol': 'T',
'rate_ids': [(0, 0, {
'name': '2020-01-01',
'rate': currency_rate,
})],
})
amount_in_other_currency = 100
expense = self.env['hr.expense'].create({
'name': 'Expense in another currency',
'product_id': self.company_data['product_order_sales_price'].id,
'total_amount': amount_in_other_currency,
'employee_id': self.expense_employee.id,
'analytic_distribution': {self.project.analytic_account_id.id: 100},
'sale_order_id': self.sale_order.id,
'currency_id': other_currency.id,
})
self.check_project_profitability_before_creating_and_approving_expense_sheet(expense, self.project, self.project_profitability_items_empty)
expense_profitability = self.project._get_expenses_profitability_items(False)
self.assertTrue(expense_profitability['costs']['billed'] == - amount_in_other_currency / currency_rate)