Начальное наполнение
This commit is contained in:
parent
558efd57f4
commit
255a01aeb4
5
__init__.py
Normal file
5
__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
from . import wizard
|
77
__manifest__.py
Normal file
77
__manifest__.py
Normal file
@ -0,0 +1,77 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
|
||||
{
|
||||
'name': 'Expenses',
|
||||
'version': '2.0',
|
||||
'category': 'Human Resources/Expenses',
|
||||
'sequence': 70,
|
||||
'summary': 'Submit, validate and reinvoice employee expenses',
|
||||
'description': """
|
||||
Manage expenses by Employees
|
||||
============================
|
||||
|
||||
This application allows you to manage your employees' daily expenses. It gives you access to your employees’ fee notes and give you the right to complete and validate or refuse the notes. After validation it creates an invoice for the employee.
|
||||
Employee can encode their own expenses and the validation flow puts it automatically in the accounting after validation by managers.
|
||||
|
||||
|
||||
The whole flow is implemented as:
|
||||
---------------------------------
|
||||
* Draft expense
|
||||
* Submitted by the employee to his manager
|
||||
* Approved by his manager
|
||||
* Validation by the accountant and accounting entries creation
|
||||
|
||||
This module also uses analytic accounting and is compatible with the invoice on timesheet module so that you are able to automatically re-invoice your customers' expenses if your work by project.
|
||||
""",
|
||||
'website': 'https://www.odoo.com/app/expenses',
|
||||
'depends': ['account', 'web_tour', 'hr'],
|
||||
'data': [
|
||||
'security/hr_expense_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'data/digest_data.xml',
|
||||
'data/mail_activity_type_data.xml',
|
||||
'data/mail_alias_data.xml',
|
||||
'data/mail_message_subtype_data.xml',
|
||||
'data/mail_templates.xml',
|
||||
'data/hr_expense_sequence.xml',
|
||||
'data/hr_expense_data.xml',
|
||||
'wizard/hr_expense_refuse_reason_views.xml',
|
||||
'wizard/hr_expense_approve_duplicate_views.xml',
|
||||
'wizard/hr_expense_split_wizard_views.xml',
|
||||
'views/hr_expense_views.xml',
|
||||
'views/mail_activity_views.xml',
|
||||
'security/ir_rule.xml',
|
||||
'report/hr_expense_report.xml',
|
||||
'views/account_move_views.xml',
|
||||
'views/account_payment_views.xml',
|
||||
'views/hr_department_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/account_journal_dashboard.xml',
|
||||
],
|
||||
'demo': ['data/hr_expense_demo.xml'],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'hr_expense/static/src/components/*.js',
|
||||
'hr_expense/static/src/components/*.xml',
|
||||
'hr_expense/static/src/mixins/*.js',
|
||||
'hr_expense/static/src/views/*.js',
|
||||
'hr_expense/static/src/views/*.xml',
|
||||
'hr_expense/static/src/scss/hr_expense.scss',
|
||||
'hr_expense/static/src/js/tours/*.js',
|
||||
],
|
||||
'web.assets_tests': [
|
||||
'hr_expense/static/tests/tours/expense_upload_tours.js',
|
||||
],
|
||||
'web.report_assets_common': [
|
||||
'hr_expense/static/src/scss/hr_expense.scss',
|
||||
],
|
||||
'web.qunit_suite_tests': [
|
||||
'hr_expense/static/tests/**/*.js',
|
||||
],
|
||||
},
|
||||
'license': 'LGPL-3',
|
||||
}
|
17
data/digest_data.xml
Normal file
17
data/digest_data.xml
Normal file
@ -0,0 +1,17 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<odoo>
|
||||
<data>
|
||||
<record id="digest_tip_hr_expense_0" model="digest.tip">
|
||||
<field name="name">Tip: Snap pictures of your receipts with the remote app</field>
|
||||
<field name="sequence">1100</field>
|
||||
<field name="group_id" ref="base.group_user" />
|
||||
<field name="tip_description" type="html">
|
||||
<div>
|
||||
<p class="tip_title">Tip: Snap pictures of your receipts with the remote app</p>
|
||||
<p class="tip_content">Do not keep your expense tickets in your pockets any longer. Just snap a picture of your receipt and let Odoo digitalizes it for you. The OCR and Artificial Intelligence will fill the data automatically.</p>
|
||||
<img src="https://download.odoocdn.com/digests/hr_expense/static/src/img/expense.png" width="540" class="illustration_border" />
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
82
data/hr_expense_data.xml
Normal file
82
data/hr_expense_data.xml
Normal file
@ -0,0 +1,82 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- sgv note - icons will be replaced. Design task is ongoing -->
|
||||
<record id="expense_product_meal" model="product.product">
|
||||
<field name="name">Meals</field>
|
||||
<field name="description">Restaurants, business lunches, etc.</field>
|
||||
<field name="standard_price">0.0</field>
|
||||
<field name="type">service</field>
|
||||
<field name="uom_id" ref="uom.product_uom_unit"/>
|
||||
<field name="uom_po_id" ref="uom.product_uom_unit"/>
|
||||
<field name="default_code">FOOD</field>
|
||||
<field name="can_be_expensed" eval="True"/>
|
||||
<field name="sale_ok" eval="False"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
<field name="image_1920" type="base64" file="hr_expense/static/img/food.svg"/>
|
||||
</record>
|
||||
<record id="expense_product_travel_accommodation" model="product.product">
|
||||
<field name="name">Travel & Accommodation</field>
|
||||
<field name="description">Hotel, plane ticket, taxi, etc.</field>
|
||||
<field name="standard_price">0.0</field>
|
||||
<field name="type">service</field>
|
||||
<field name="uom_id" ref="uom.product_uom_unit"/>
|
||||
<field name="uom_po_id" ref="uom.product_uom_unit"/>
|
||||
<field name="default_code">TRANS & ACC</field>
|
||||
<field name="can_be_expensed" eval="True"/>
|
||||
<field name="sale_ok" eval="False"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
<field name="image_1920" type="base64" file="hr_expense/static/img/transport.svg"/>
|
||||
</record>
|
||||
<record id="expense_product_mileage" model="product.product">
|
||||
<field name="name">Mileage</field>
|
||||
<field name="standard_price">1.0</field>
|
||||
<field name="type">service</field>
|
||||
<field name="uom_id" ref="uom.product_uom_km"/>
|
||||
<field name="uom_po_id" ref="uom.product_uom_km"/>
|
||||
<field name="default_code">MIL</field>
|
||||
<field name="can_be_expensed" eval="True"/>
|
||||
<field name="sale_ok" eval="False"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
<field name="image_1920" type="base64" file="hr_expense/static/img/mileage.svg"/>
|
||||
</record>
|
||||
<record id="expense_product_gift" model="product.product">
|
||||
<field name="name">Gifts</field>
|
||||
<field name="description">Gifts to customers or vendors</field>
|
||||
<field name="standard_price">0.0</field>
|
||||
<field name="type">service</field>
|
||||
<field name="uom_id" ref="uom.product_uom_km"/>
|
||||
<field name="uom_po_id" ref="uom.product_uom_km"/>
|
||||
<field name="default_code">GIFT</field>
|
||||
<field name="can_be_expensed" eval="True"/>
|
||||
<field name="sale_ok" eval="False"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
<field name="image_1920" type="base64" file="hr_expense/static/img/gift.svg"/>
|
||||
</record>
|
||||
<record id="expense_product_communication" model="product.product">
|
||||
<field name="name">Communication</field>
|
||||
<field name="description">Phone bills, postage, etc.</field>
|
||||
<field name="standard_price">0.0</field>
|
||||
<field name="type">service</field>
|
||||
<field name="uom_id" ref="uom.product_uom_km"/>
|
||||
<field name="uom_po_id" ref="uom.product_uom_km"/>
|
||||
<field name="default_code">COMM</field>
|
||||
<field name="can_be_expensed" eval="True"/>
|
||||
<field name="sale_ok" eval="False"/>
|
||||
<field name="purchase_ok" eval="False"/>
|
||||
<field name="image_1920" type="base64" file="hr_expense/static/img/communication.svg"/>
|
||||
</record>
|
||||
<record id="product_product_no_cost" model="product.product">
|
||||
<field name="name">Others</field>
|
||||
<field name="standard_price">0.0</field>
|
||||
<field name="type">service</field>
|
||||
<field name="default_code">EXP_GEN</field>
|
||||
<field name="categ_id" ref="product.cat_expense"/>
|
||||
<field name="can_be_expensed" eval="True"/>
|
||||
<field name="image_1920" type="base64" file="hr_expense/static/img/other.svg"/>
|
||||
</record>
|
||||
</data>
|
||||
<data>
|
||||
<function model="ir.config_parameter" name="set_param" eval="('hr_expense.use_mailgateway', True)"/>
|
||||
</data>
|
||||
</odoo>
|
221
data/hr_expense_demo.xml
Normal file
221
data/hr_expense_demo.xml
Normal file
@ -0,0 +1,221 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="base.user_demo" model="res.users">
|
||||
<field name="groups_id" eval="[(3, ref('hr_expense.group_hr_expense_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="hr.employee_mit" model="hr.employee">
|
||||
<field name="private_email">douglas.fletcher51@example.com</field>
|
||||
<field name="private_phone">(132)-553-7242</field>
|
||||
</record>
|
||||
|
||||
<record id="hr_expense_account_journal" model="account.journal">
|
||||
<field name="name">Expense</field>
|
||||
<field name="code">EXP</field>
|
||||
<field name="type">purchase</field>
|
||||
<!-- avoid being selected as default journal -->
|
||||
<field name="sequence">99</field>
|
||||
<field name="alias_name">purchase_expense</field>
|
||||
</record>
|
||||
|
||||
<record id="hr.employee_fme" model="hr.employee">
|
||||
<field name="private_street">Chaussée de Namur, 40</field>
|
||||
<field name="private_zip">1367</field>
|
||||
<field name="private_city">Grand-Rosière-Hottomont</field>
|
||||
<field name="private_country_id" ref="base.be"/>
|
||||
</record>
|
||||
|
||||
<record id="hr.employee_al" model="hr.employee">
|
||||
<field name="private_street">Chaussée de Namur, 40</field>
|
||||
<field name="private_zip">1367</field>
|
||||
<field name="private_city">Grand-Rosière-Hottomont</field>
|
||||
<field name="private_country_id" ref="base.be"/>
|
||||
</record>
|
||||
|
||||
<!-- ++++++++++++++ Expense sheet for Admin ++++++++++++++-->
|
||||
<record id="screen_expense" model="hr.expense">
|
||||
<field name="name">Screen</field>
|
||||
<field name="employee_id" ref="hr.employee_admin"/>
|
||||
<field name="analytic_distribution" eval="{ref('analytic.analytic_our_super_product'): 100}"/>
|
||||
<field name="product_id" ref="product_product_no_cost"/>
|
||||
<field eval="289.0" name="total_amount_currency"/>
|
||||
<field name="date" eval="time.strftime('%Y')+'-04-03'"/>
|
||||
</record>
|
||||
|
||||
<record id="laptop_expense" model="hr.expense">
|
||||
<field name="name">Laptop</field>
|
||||
<field name="employee_id" ref="hr.employee_admin"/>
|
||||
<field name="analytic_distribution" eval="{ref('analytic.analytic_our_super_product'): 100}"/>
|
||||
<field name="product_id" ref="product_product_no_cost"/>
|
||||
<field eval="889.0" name="total_amount_currency"/>
|
||||
<field name="date" eval="time.strftime('%Y')+'-04-03'"/>
|
||||
</record>
|
||||
|
||||
<record id="travel_admin_by_car_expense" model="hr.expense">
|
||||
<field name="name">Travel by car</field>
|
||||
<field name="employee_id" ref="hr.employee_admin"/>
|
||||
<field name="product_id" ref="expense_product_mileage"/>
|
||||
<field name="product_uom_id" ref="uom.product_uom_km"/>
|
||||
<field eval="108.84" name="quantity"/>
|
||||
</record>
|
||||
|
||||
<record id="breakfast_admin_expense" model="hr.expense">
|
||||
<field name="name">BreakFast</field>
|
||||
<field name="employee_id" ref="hr.employee_admin"/>
|
||||
<field name="product_id" ref="expense_product_meal"/>
|
||||
<field eval="20" name="total_amount_currency"/>
|
||||
</record>
|
||||
|
||||
<record id="travel_ny_sheet" model="hr.expense.sheet">
|
||||
<field name="name">Commercial Travel at New York</field>
|
||||
<field name="employee_id" ref="hr.employee_admin"/>
|
||||
<field name="state">approve</field>
|
||||
</record>
|
||||
|
||||
<record id="travel_by_air_expense" model="hr.expense">
|
||||
<field name="name">Travel by Air</field>
|
||||
<field name="employee_id" ref="hr.employee_admin"/>
|
||||
<field name="analytic_distribution" eval="{ref('analytic.analytic_our_super_product'): 100}"/>
|
||||
<field name="product_id" ref="expense_product_travel_accommodation"/>
|
||||
<field eval="700.0" name="total_amount_currency"/>
|
||||
<field name="product_uom_id" ref="uom.product_uom_unit"/>
|
||||
<field name="date" eval="time.strftime('%Y-%m')+'-12'"/>
|
||||
<field name="sheet_id" ref="travel_ny_sheet"/>
|
||||
</record>
|
||||
|
||||
<record id="hotel_bill_expense" model="hr.expense">
|
||||
<field name="name">Hotel Expenses</field>
|
||||
<field name="employee_id" ref="hr.employee_admin"/>
|
||||
<field name="analytic_distribution" eval="{ref('analytic.analytic_nebula'): 100}"/>
|
||||
<field name="product_id" ref="expense_product_travel_accommodation"/>
|
||||
<field eval="2000.0" name="total_amount_currency"/>
|
||||
<field name="product_uom_id" ref="uom.product_uom_unit"/>
|
||||
<field name="date" eval="time.strftime('%Y-%m')+'-17'"/>
|
||||
<field name="sheet_id" ref="travel_ny_sheet"/>
|
||||
</record>
|
||||
|
||||
<record id="lunch_customer_bill_expense" model="hr.expense">
|
||||
<field name="name">Lunch with Customer</field>
|
||||
<field name="employee_id" ref="hr.employee_admin"/>
|
||||
<field name="analytic_distribution" eval="{ref('analytic.analytic_nebula'): 100}"/>
|
||||
<field name="product_id" ref="expense_product_meal"/>
|
||||
<field eval="152.8" name="total_amount_currency"/>
|
||||
<field name="date" eval="time.strftime('%Y-%m')+'-13'"/>
|
||||
<field name="sheet_id" ref="travel_ny_sheet"/>
|
||||
</record>
|
||||
|
||||
<record id="lunch_bill_expense" model="hr.expense">
|
||||
<field name="name">Lunch</field>
|
||||
<field name="employee_id" ref="hr.employee_admin"/>
|
||||
<field name="analytic_distribution" eval="{ref('analytic.analytic_nebula'): 100}"/>
|
||||
<field name="product_id" ref="expense_product_meal"/>
|
||||
<field name="date" eval="time.strftime('%Y-%m')+'-15'"/>
|
||||
<field eval="56.8" name="total_amount_currency"/>
|
||||
<field name="sheet_id" ref="travel_ny_sheet"/>
|
||||
</record>
|
||||
|
||||
<!-- ++++++++++++++ Expense sheet for Demo ++++++++++++++-->
|
||||
<record id="customer_meeting_sheet" model="hr.expense.sheet">
|
||||
<field name="name">Customer meeting</field>
|
||||
<field name="employee_id" ref="hr.employee_qdp"/>
|
||||
<field name="state">submit</field>
|
||||
</record>
|
||||
|
||||
<record id="travel_demo_by_car_expense" model="hr.expense">
|
||||
<field name="name">Travel by Car</field>
|
||||
<field name="employee_id" ref="hr.employee_qdp"/>
|
||||
<field name="analytic_distribution" eval="{ref('analytic.analytic_our_super_product'): 100}"/>
|
||||
<field name="product_id" ref="expense_product_mileage"/>
|
||||
<field name="product_uom_id" ref="uom.product_uom_km"/>
|
||||
<field name="date" eval="time.strftime('%Y')+'-01-15'"/>
|
||||
<field eval="120.85" name="quantity"/>
|
||||
<field name="sheet_id" ref="customer_meeting_sheet"/>
|
||||
</record>
|
||||
|
||||
<record id="lunch_demo_customer_bill_expense" model="hr.expense">
|
||||
<field name="name">Lunch with Customer</field>
|
||||
<field name="employee_id" ref="hr.employee_qdp"/>
|
||||
<field name="analytic_distribution" eval="{ref('analytic.analytic_nebula'): 100}"/>
|
||||
<field name="product_id" ref="product_product_no_cost"/>
|
||||
<field name="date" eval="time.strftime('%Y')+'-01-15'"/>
|
||||
<field eval="152.8" name="total_amount_currency"/>
|
||||
<field name="sheet_id" ref="customer_meeting_sheet"/>
|
||||
</record>
|
||||
|
||||
<!-- ++++++++++++++ Expense sheet for Keith Byrd ++++++++++++++-->
|
||||
<record id="team_building_sheet" model="hr.expense.sheet">
|
||||
<field name="name">Team Building</field>
|
||||
<field name="employee_id" ref="hr.employee_fme"/>
|
||||
<field name="state">submit</field>
|
||||
</record>
|
||||
|
||||
<record id="pizzas_bill_expense" model="hr.expense">
|
||||
<field name="name">Pizzas</field>
|
||||
<field name="employee_id" ref="hr.employee_fme"/>
|
||||
<field name="analytic_distribution" eval="{ref('analytic.analytic_nebula'): 100}"/>
|
||||
<field name="product_id" ref="expense_product_meal"/>
|
||||
<field name="date" eval="time.strftime('%Y-%m')+'-05'"/>
|
||||
<field eval="154" name="total_amount_currency"/>
|
||||
<field name="sheet_id" ref="team_building_sheet"/>
|
||||
</record>
|
||||
|
||||
<record id="drinks_bill_expense" model="hr.expense">
|
||||
<field name="name">Drinks</field>
|
||||
<field name="employee_id" ref="hr.employee_fme"/>
|
||||
<field name="analytic_distribution" eval="{ref('analytic.analytic_nebula'): 100}"/>
|
||||
<field name="product_id" ref="expense_product_meal"/>
|
||||
<field name="date" eval="time.strftime('%Y-%m')+'-05'"/>
|
||||
<field eval="42.5" name="total_amount_currency"/>
|
||||
<field name="sheet_id" ref="team_building_sheet"/>
|
||||
</record>
|
||||
|
||||
<record id="paintball_bill_expense" model="hr.expense">
|
||||
<field name="name">Paintball</field>
|
||||
<field name="employee_id" ref="hr.employee_fme"/>
|
||||
<field name="analytic_distribution" eval="{ref('analytic.analytic_nebula'): 100}"/>
|
||||
<field name="product_id" ref="product_product_no_cost"/>
|
||||
<field name="date" eval="time.strftime('%Y-%m')+'-05'"/>
|
||||
<field eval="25" name="total_amount_currency"/>
|
||||
<field name="sheet_id" ref="team_building_sheet"/>
|
||||
</record>
|
||||
|
||||
<!-- ++++++++++++++ Expense sheet for Ronnie Hart ++++++++++++++-->
|
||||
<record id="office_furniture_sheet" model="hr.expense.sheet">
|
||||
<field name="name">Office furniture</field>
|
||||
<field name="employee_id" ref="hr.employee_al"/>
|
||||
<field name="state">submit</field>
|
||||
</record>
|
||||
|
||||
<record id="chair_bill_expense" model="hr.expense">
|
||||
<field name="name">Chairs</field>
|
||||
<field name="employee_id" ref="hr.employee_al"/>
|
||||
<field name="analytic_distribution" eval="{ref('analytic.analytic_nebula'): 100}"/>
|
||||
<field name="product_id" ref="product_product_no_cost"/>
|
||||
<field name="date" eval="time.strftime('%Y')+'-06-02'"/>
|
||||
<field eval="55.75" name="total_amount_currency"/>
|
||||
<field name="sheet_id" ref="office_furniture_sheet"/>
|
||||
</record>
|
||||
|
||||
<record id="lamp_bill_expense" model="hr.expense">
|
||||
<field name="name">Lamp</field>
|
||||
<field name="employee_id" ref="hr.employee_al"/>
|
||||
<field name="analytic_distribution" eval="{ref('analytic.analytic_nebula'): 100}"/>
|
||||
<field name="product_id" ref="product_product_no_cost"/>
|
||||
<field name="date" eval="time.strftime('%Y')+'-06-02'"/>
|
||||
<field eval="28.99" name="total_amount_currency"/>
|
||||
<field name="sheet_id" ref="office_furniture_sheet"/>
|
||||
</record>
|
||||
|
||||
<!-- ++++++++++++++ Expense for Randall Lewis ++++++++++++++-->
|
||||
<record id="afterwork_bill_expense" model="hr.expense">
|
||||
<field name="name">Car tyres</field>
|
||||
<field name="employee_id" ref="hr.employee_stw"/>
|
||||
<field name="analytic_distribution" eval="{ref('analytic.analytic_nebula'): 100}"/>
|
||||
<field name="product_id" ref="product_product_no_cost"/>
|
||||
<field name="date" eval="time.strftime('%Y')+'-03-15'"/>
|
||||
<field eval="450.58" name="total_amount_currency"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
13
data/hr_expense_sequence.xml
Normal file
13
data/hr_expense_sequence.xml
Normal file
@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<record id="seq_hr_expense_invoice" model="ir.sequence">
|
||||
<field name="name">Expense invoice</field>
|
||||
<field name="code">hr.expense.invoice</field>
|
||||
<field name="prefix">EXP/</field>
|
||||
<field name="padding">3</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
10
data/mail_activity_type_data.xml
Normal file
10
data/mail_activity_type_data.xml
Normal file
@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="mail_act_expense_approval" model="mail.activity.type">
|
||||
<field name="name">Expense Approval</field>
|
||||
<field name="icon">fa-dollar</field>
|
||||
<field name="res_model">hr.expense.sheet</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
11
data/mail_alias_data.xml
Normal file
11
data/mail_alias_data.xml
Normal file
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- default alias for expenses -->
|
||||
<record id="mail_alias_expense" model="mail.alias">
|
||||
<field name="alias_name">expense</field>
|
||||
<field name="alias_model_id" ref="model_hr_expense"/>
|
||||
<field name="alias_contact">employees</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
36
data/mail_message_subtype_data.xml
Normal file
36
data/mail_message_subtype_data.xml
Normal file
@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Expense-related subtypes for messaging / Chatter -->
|
||||
<record id="mt_expense_approved" model="mail.message.subtype">
|
||||
<field name="name">Approved</field>
|
||||
<field name="res_model">hr.expense.sheet</field>
|
||||
<field name="default" eval="True"/>
|
||||
<field name="description">Expense report approved</field>
|
||||
</record>
|
||||
<record id="mt_expense_refused" model="mail.message.subtype">
|
||||
<field name="name">Refused</field>
|
||||
<field name="res_model">hr.expense.sheet</field>
|
||||
<field name="default" eval="True"/>
|
||||
<field name="description">Expense report refused</field>
|
||||
</record>
|
||||
<record id="mt_expense_paid" model="mail.message.subtype">
|
||||
<field name="name">Paid</field>
|
||||
<field name="res_model">hr.expense.sheet</field>
|
||||
<field name="description">Expense report paid</field>
|
||||
<field name="default" eval="True"/>
|
||||
</record>
|
||||
<record id="mt_expense_reset" model="mail.message.subtype">
|
||||
<field name="name">Draft</field>
|
||||
<field name="res_model">hr.expense.sheet</field>
|
||||
<field name="default" eval="True"/>
|
||||
<field name="description">Expense report reset to Draft</field>
|
||||
</record>
|
||||
<record id="mt_expense_entry_delete" model="mail.message.subtype">
|
||||
<field name="name">Journal Entry Deleted</field>
|
||||
<field name="res_model">hr.expense.sheet</field>
|
||||
<field name="default" eval="True"/>
|
||||
<field name="description">Journal entry deleted</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
57
data/mail_templates.xml
Normal file
57
data/mail_templates.xml
Normal file
@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<template id="hr_expense_template_refuse_reason">
|
||||
<p>Your Expense Report <t t-out="name"/> has been refused</p>
|
||||
<ul class="o_timeline_tracking_value_list">
|
||||
<li>Reason: <t t-out="reason"/></li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<template id="hr_expense_template_register">
|
||||
<p>Dear <t t-out="expense.employee_id.name"/>,</p>
|
||||
<p>
|
||||
Your expense has been successfully registered.
|
||||
<t t-if="expense.employee_id.user_id">
|
||||
You can now submit it to the manager from the following link.
|
||||
</t>
|
||||
</p>
|
||||
<p t-if="expense.product_id">
|
||||
Category: <t t-out="expense.product_id.name"/>
|
||||
</p>
|
||||
<div t-else="">
|
||||
<p>Category: not found</p>
|
||||
<p>The first word of the email subject did not correspond to any category code. You'll have to set the category manually on the expense.</p>
|
||||
</div>
|
||||
<p>
|
||||
Price: <t t-out="expense.price_unit"/><t t-out="expense.currency_id.symbol"/>
|
||||
</p>
|
||||
<p t-if="expense.employee_id.user_id">
|
||||
<br/>
|
||||
<a t-att-href="'/web#id=%s&model=hr.expense&view_type=form' % (expense.id)" style="background-color: #9E588B; margin-top: 10px; padding: 10px; text-decoration: none; color: #fff; border-radius: 5px; font-size: 16px;">View Expense</a>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template id="hr_expense_template_register_no_user">
|
||||
<div style="background:#F0F0F0;color:#515166;padding:10px 0px;font-family:Arial,Helvetica,sans-serif;font-size:14px;">
|
||||
<table style="width:600px;margin:5px auto;" t-if="not expense.employee_id.company_id.uses_default_logo">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><a href="/"><img t-attf-src="/logo.png?company={{ expense.employee_id.company_id.id }}" style="vertical-align:baseline;max-width:100px;max-height:50px;" /></a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table style="width:600px;margin:0px auto;background:white;border:1px solid #e1e1e1;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="padding:15px 20px 10px 20px;">
|
||||
<t t-call="hr_expense.hr_expense_template_register"/>
|
||||
<p style="color:#9E588B;">Powered by <a target="_blank" href="https://www.odoo.com">Odoo</a>.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</data>
|
||||
</odoo>
|
2285
i18n/af.po
Normal file
2285
i18n/af.po
Normal file
File diff suppressed because it is too large
Load Diff
2281
i18n/am.po
Normal file
2281
i18n/am.po
Normal file
File diff suppressed because it is too large
Load Diff
2537
i18n/ar.po
Normal file
2537
i18n/ar.po
Normal file
File diff suppressed because it is too large
Load Diff
2290
i18n/az.po
Normal file
2290
i18n/az.po
Normal file
File diff suppressed because it is too large
Load Diff
2498
i18n/bg.po
Normal file
2498
i18n/bg.po
Normal file
File diff suppressed because it is too large
Load Diff
2286
i18n/bs.po
Normal file
2286
i18n/bs.po
Normal file
File diff suppressed because it is too large
Load Diff
2571
i18n/ca.po
Normal file
2571
i18n/ca.po
Normal file
File diff suppressed because it is too large
Load Diff
2529
i18n/cs.po
Normal file
2529
i18n/cs.po
Normal file
File diff suppressed because it is too large
Load Diff
2507
i18n/da.po
Normal file
2507
i18n/da.po
Normal file
File diff suppressed because it is too large
Load Diff
2594
i18n/de.po
Normal file
2594
i18n/de.po
Normal file
File diff suppressed because it is too large
Load Diff
2293
i18n/el.po
Normal file
2293
i18n/el.po
Normal file
File diff suppressed because it is too large
Load Diff
2283
i18n/en_AU.po
Normal file
2283
i18n/en_AU.po
Normal file
File diff suppressed because it is too large
Load Diff
2284
i18n/en_GB.po
Normal file
2284
i18n/en_GB.po
Normal file
File diff suppressed because it is too large
Load Diff
2568
i18n/es.po
Normal file
2568
i18n/es.po
Normal file
File diff suppressed because it is too large
Load Diff
2570
i18n/es_419.po
Normal file
2570
i18n/es_419.po
Normal file
File diff suppressed because it is too large
Load Diff
2284
i18n/es_BO.po
Normal file
2284
i18n/es_BO.po
Normal file
File diff suppressed because it is too large
Load Diff
2284
i18n/es_CL.po
Normal file
2284
i18n/es_CL.po
Normal file
File diff suppressed because it is too large
Load Diff
2284
i18n/es_CO.po
Normal file
2284
i18n/es_CO.po
Normal file
File diff suppressed because it is too large
Load Diff
2284
i18n/es_CR.po
Normal file
2284
i18n/es_CR.po
Normal file
File diff suppressed because it is too large
Load Diff
2284
i18n/es_DO.po
Normal file
2284
i18n/es_DO.po
Normal file
File diff suppressed because it is too large
Load Diff
2284
i18n/es_EC.po
Normal file
2284
i18n/es_EC.po
Normal file
File diff suppressed because it is too large
Load Diff
2284
i18n/es_PE.po
Normal file
2284
i18n/es_PE.po
Normal file
File diff suppressed because it is too large
Load Diff
2284
i18n/es_PY.po
Normal file
2284
i18n/es_PY.po
Normal file
File diff suppressed because it is too large
Load Diff
2284
i18n/es_VE.po
Normal file
2284
i18n/es_VE.po
Normal file
File diff suppressed because it is too large
Load Diff
2565
i18n/et.po
Normal file
2565
i18n/et.po
Normal file
File diff suppressed because it is too large
Load Diff
2284
i18n/eu.po
Normal file
2284
i18n/eu.po
Normal file
File diff suppressed because it is too large
Load Diff
2517
i18n/fa.po
Normal file
2517
i18n/fa.po
Normal file
File diff suppressed because it is too large
Load Diff
2547
i18n/fi.po
Normal file
2547
i18n/fi.po
Normal file
File diff suppressed because it is too large
Load Diff
2284
i18n/fo.po
Normal file
2284
i18n/fo.po
Normal file
File diff suppressed because it is too large
Load Diff
2591
i18n/fr.po
Normal file
2591
i18n/fr.po
Normal file
File diff suppressed because it is too large
Load Diff
2283
i18n/fr_BE.po
Normal file
2283
i18n/fr_BE.po
Normal file
File diff suppressed because it is too large
Load Diff
2284
i18n/fr_CA.po
Normal file
2284
i18n/fr_CA.po
Normal file
File diff suppressed because it is too large
Load Diff
2284
i18n/gl.po
Normal file
2284
i18n/gl.po
Normal file
File diff suppressed because it is too large
Load Diff
2289
i18n/gu.po
Normal file
2289
i18n/gu.po
Normal file
File diff suppressed because it is too large
Load Diff
2510
i18n/he.po
Normal file
2510
i18n/he.po
Normal file
File diff suppressed because it is too large
Load Diff
2283
i18n/hi.po
Normal file
2283
i18n/hi.po
Normal file
File diff suppressed because it is too large
Load Diff
2304
i18n/hr.po
Normal file
2304
i18n/hr.po
Normal file
File diff suppressed because it is too large
Load Diff
2474
i18n/hr_expense.pot
Normal file
2474
i18n/hr_expense.pot
Normal file
File diff suppressed because it is too large
Load Diff
2497
i18n/hu.po
Normal file
2497
i18n/hu.po
Normal file
File diff suppressed because it is too large
Load Diff
2577
i18n/id.po
Normal file
2577
i18n/id.po
Normal file
File diff suppressed because it is too large
Load Diff
2286
i18n/is.po
Normal file
2286
i18n/is.po
Normal file
File diff suppressed because it is too large
Load Diff
2569
i18n/it.po
Normal file
2569
i18n/it.po
Normal file
File diff suppressed because it is too large
Load Diff
2499
i18n/ja.po
Normal file
2499
i18n/ja.po
Normal file
File diff suppressed because it is too large
Load Diff
2284
i18n/ka.po
Normal file
2284
i18n/ka.po
Normal file
File diff suppressed because it is too large
Load Diff
2284
i18n/kab.po
Normal file
2284
i18n/kab.po
Normal file
File diff suppressed because it is too large
Load Diff
2290
i18n/km.po
Normal file
2290
i18n/km.po
Normal file
File diff suppressed because it is too large
Load Diff
2505
i18n/ko.po
Normal file
2505
i18n/ko.po
Normal file
File diff suppressed because it is too large
Load Diff
2285
i18n/lb.po
Normal file
2285
i18n/lb.po
Normal file
File diff suppressed because it is too large
Load Diff
2284
i18n/lo.po
Normal file
2284
i18n/lo.po
Normal file
File diff suppressed because it is too large
Load Diff
2512
i18n/lt.po
Normal file
2512
i18n/lt.po
Normal file
File diff suppressed because it is too large
Load Diff
2527
i18n/lv.po
Normal file
2527
i18n/lv.po
Normal file
File diff suppressed because it is too large
Load Diff
2284
i18n/mk.po
Normal file
2284
i18n/mk.po
Normal file
File diff suppressed because it is too large
Load Diff
2306
i18n/mn.po
Normal file
2306
i18n/mn.po
Normal file
File diff suppressed because it is too large
Load Diff
2295
i18n/nb.po
Normal file
2295
i18n/nb.po
Normal file
File diff suppressed because it is too large
Load Diff
2281
i18n/ne.po
Normal file
2281
i18n/ne.po
Normal file
File diff suppressed because it is too large
Load Diff
2583
i18n/nl.po
Normal file
2583
i18n/nl.po
Normal file
File diff suppressed because it is too large
Load Diff
2544
i18n/pl.po
Normal file
2544
i18n/pl.po
Normal file
File diff suppressed because it is too large
Load Diff
2491
i18n/pt.po
Normal file
2491
i18n/pt.po
Normal file
File diff suppressed because it is too large
Load Diff
2568
i18n/pt_BR.po
Normal file
2568
i18n/pt_BR.po
Normal file
File diff suppressed because it is too large
Load Diff
2295
i18n/ro.po
Normal file
2295
i18n/ro.po
Normal file
File diff suppressed because it is too large
Load Diff
2583
i18n/ru.po
Normal file
2583
i18n/ru.po
Normal file
File diff suppressed because it is too large
Load Diff
2501
i18n/sk.po
Normal file
2501
i18n/sk.po
Normal file
File diff suppressed because it is too large
Load Diff
2495
i18n/sl.po
Normal file
2495
i18n/sl.po
Normal file
File diff suppressed because it is too large
Load Diff
2284
i18n/sq.po
Normal file
2284
i18n/sq.po
Normal file
File diff suppressed because it is too large
Load Diff
2533
i18n/sr.po
Normal file
2533
i18n/sr.po
Normal file
File diff suppressed because it is too large
Load Diff
2287
i18n/sr@latin.po
Normal file
2287
i18n/sr@latin.po
Normal file
File diff suppressed because it is too large
Load Diff
2512
i18n/sv.po
Normal file
2512
i18n/sv.po
Normal file
File diff suppressed because it is too large
Load Diff
2285
i18n/ta.po
Normal file
2285
i18n/ta.po
Normal file
File diff suppressed because it is too large
Load Diff
2283
i18n/te.po
Normal file
2283
i18n/te.po
Normal file
File diff suppressed because it is too large
Load Diff
2534
i18n/th.po
Normal file
2534
i18n/th.po
Normal file
File diff suppressed because it is too large
Load Diff
2549
i18n/tr.po
Normal file
2549
i18n/tr.po
Normal file
File diff suppressed because it is too large
Load Diff
2557
i18n/uk.po
Normal file
2557
i18n/uk.po
Normal file
File diff suppressed because it is too large
Load Diff
2567
i18n/vi.po
Normal file
2567
i18n/vi.po
Normal file
File diff suppressed because it is too large
Load Diff
2500
i18n/zh_CN.po
Normal file
2500
i18n/zh_CN.po
Normal file
File diff suppressed because it is too large
Load Diff
2497
i18n/zh_TW.po
Normal file
2497
i18n/zh_TW.po
Normal file
File diff suppressed because it is too large
Load Diff
18
models/__init__.py
Normal file
18
models/__init__.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import hr_employee
|
||||
from . import account_move
|
||||
from . import account_move_line
|
||||
from . import account_payment
|
||||
from . import account_tax
|
||||
from . import hr_department
|
||||
from . import hr_expense
|
||||
from . import hr_expense_sheet
|
||||
from . import ir_attachment
|
||||
from . import product_product
|
||||
from . import product_template
|
||||
from . import res_config_settings
|
||||
from . import account_journal_dashboard
|
||||
from . import res_company
|
||||
from . import analytic
|
||||
from . import ir_actions_report
|
51
models/account_journal_dashboard.py
Normal file
51
models/account_journal_dashboard.py
Normal file
@ -0,0 +1,51 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
from odoo.addons.account.models.account_journal_dashboard import group_by_journal
|
||||
|
||||
|
||||
class AccountJournal(models.Model):
|
||||
_inherit = "account.journal"
|
||||
|
||||
def _prepare_expense_sheet_data_domain(self):
|
||||
return [
|
||||
('state', '=', 'post'),
|
||||
('journal_id', 'in', self.ids),
|
||||
]
|
||||
|
||||
def _get_expense_to_pay_query(self):
|
||||
return self.env['hr.expense.sheet']._where_calc(self._prepare_expense_sheet_data_domain())
|
||||
|
||||
def _fill_sale_purchase_dashboard_data(self, dashboard_data):
|
||||
super(AccountJournal, self)._fill_sale_purchase_dashboard_data(dashboard_data)
|
||||
sale_purchase_journals = self.filtered(lambda journal: journal.type in ('sale', 'purchase'))
|
||||
if not sale_purchase_journals:
|
||||
return
|
||||
field_list = [
|
||||
"hr_expense_sheet.journal_id",
|
||||
"hr_expense_sheet.total_amount AS amount_total",
|
||||
"hr_expense_sheet.currency_id AS currency",
|
||||
]
|
||||
query, params = sale_purchase_journals._get_expense_to_pay_query().select(*field_list)
|
||||
self.env.cr.execute(query, params)
|
||||
query_results_to_pay = group_by_journal(self.env.cr.dictfetchall())
|
||||
for journal in sale_purchase_journals:
|
||||
currency = journal.currency_id or journal.company_id.currency_id
|
||||
(number_expenses_to_pay, sum_expenses_to_pay) = self._count_results_and_sum_amounts(query_results_to_pay[journal.id], currency)
|
||||
dashboard_data[journal.id].update({
|
||||
'number_expenses_to_pay': number_expenses_to_pay,
|
||||
'sum_expenses_to_pay': currency.format(sum_expenses_to_pay),
|
||||
})
|
||||
|
||||
def open_expenses_action(self):
|
||||
action = self.env['ir.actions.act_window']._for_xml_id('hr_expense.action_hr_expense_sheet_all_all')
|
||||
action['context'] = {
|
||||
'search_default_approved': 1,
|
||||
'search_default_to_post': 1,
|
||||
'search_default_journal_id': self.id,
|
||||
'default_journal_id': self.id,
|
||||
}
|
||||
action['view_mode'] = 'tree,form'
|
||||
action['views'] = [(k,v) for k,v in action['views'] if v in ['tree', 'form']]
|
||||
action['domain'] = self._prepare_expense_sheet_data_domain()
|
||||
return action
|
65
models/account_move.py
Normal file
65
models/account_move.py
Normal file
@ -0,0 +1,65 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.api import ondelete
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools.misc import frozendict
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = "account.move"
|
||||
|
||||
expense_sheet_id = fields.Many2one(comodel_name='hr.expense.sheet', ondelete='set null', copy=False, index='btree_not_null')
|
||||
|
||||
def action_open_expense_report(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': self.expense_sheet_id.name,
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_mode': 'form',
|
||||
'views': [(False, 'form')],
|
||||
'res_model': 'hr.expense.sheet',
|
||||
'res_id': self.expense_sheet_id.id
|
||||
}
|
||||
|
||||
# Expenses can be written on journal other than purchase, hence don't include them in the constraint check
|
||||
def _check_journal_move_type(self):
|
||||
return super(AccountMove, self.filtered(lambda x: not x.expense_sheet_id))._check_journal_move_type()
|
||||
|
||||
def _creation_message(self):
|
||||
if self.expense_sheet_id:
|
||||
return _("Expense entry created from: %s", self.expense_sheet_id._get_html_link())
|
||||
return super()._creation_message()
|
||||
|
||||
@api.depends('expense_sheet_id')
|
||||
def _compute_needed_terms(self):
|
||||
# EXTENDS account
|
||||
# We want to set the account destination based on the 'payment_mode'.
|
||||
super()._compute_needed_terms()
|
||||
for move in self:
|
||||
if move.expense_sheet_id and move.expense_sheet_id.payment_mode == 'company_account':
|
||||
term_lines = move.line_ids.filtered(lambda l: l.display_type != 'payment_term')
|
||||
move.needed_terms = {
|
||||
frozendict(
|
||||
{
|
||||
"move_id": move.id,
|
||||
"date_maturity": move.expense_sheet_id.accounting_date or fields.Date.context_today(move.expense_sheet_id),
|
||||
}
|
||||
): {
|
||||
"balance": -sum(term_lines.mapped("balance")),
|
||||
"amount_currency": -sum(term_lines.mapped("amount_currency")),
|
||||
"name": "",
|
||||
"account_id": move.expense_sheet_id._get_expense_account_destination(),
|
||||
}
|
||||
}
|
||||
|
||||
def _reverse_moves(self, default_values_list=None, cancel=False):
|
||||
own_expense_moves = self.filtered(lambda move: move.expense_sheet_id.payment_mode == 'own_account')
|
||||
own_expense_moves.write({'expense_sheet_id': False, 'ref': False})
|
||||
# else, when restarting the expense flow we get duplicate issue on vendor.bill
|
||||
return super()._reverse_moves(default_values_list=default_values_list, cancel=cancel)
|
||||
|
||||
@ondelete(at_uninstall=True)
|
||||
def _must_delete_all_expense_entries(self):
|
||||
if self.expense_sheet_id and self.expense_sheet_id.account_move_ids - self: # If not all the payments are to be deleted
|
||||
raise UserError(_("You cannot delete only some entries linked to an expense report. All entries must be deleted at the same time."))
|
51
models/account_move_line.py
Normal file
51
models/account_move_line.py
Normal file
@ -0,0 +1,51 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.tools.misc import frozendict
|
||||
|
||||
|
||||
class AccountMoveLine(models.Model):
|
||||
_inherit = "account.move.line"
|
||||
|
||||
expense_id = fields.Many2one('hr.expense', string='Expense', copy=True) # copy=True, else we don't know price is tax incl.
|
||||
|
||||
@api.constrains('account_id', 'display_type')
|
||||
def _check_payable_receivable(self):
|
||||
super(AccountMoveLine, self.filtered(lambda line: line.move_id.expense_sheet_id.payment_mode != 'company_account'))._check_payable_receivable()
|
||||
|
||||
def _get_attachment_domains(self):
|
||||
attachment_domains = super(AccountMoveLine, self)._get_attachment_domains()
|
||||
if self.expense_id:
|
||||
attachment_domains.append([('res_model', '=', 'hr.expense'), ('res_id', '=', self.expense_id.id)])
|
||||
return attachment_domains
|
||||
|
||||
def _compute_tax_key(self):
|
||||
super()._compute_tax_key()
|
||||
for line in self:
|
||||
if line.expense_id:
|
||||
line.tax_key = frozendict(**line.tax_key, expense_id=line.expense_id.id)
|
||||
|
||||
def _compute_all_tax(self):
|
||||
expense_lines = self.filtered('expense_id')
|
||||
super(AccountMoveLine, expense_lines.with_context(force_price_include=True))._compute_all_tax()
|
||||
super(AccountMoveLine, self - expense_lines)._compute_all_tax()
|
||||
for line in expense_lines:
|
||||
for key in list(line.compute_all_tax.keys()):
|
||||
new_key = frozendict(**key, expense_id=line.expense_id.id)
|
||||
line.compute_all_tax[new_key] = line.compute_all_tax.pop(key)
|
||||
|
||||
def _compute_totals(self):
|
||||
expenses = self.filtered('expense_id')
|
||||
super(AccountMoveLine, expenses.with_context(force_price_include=True))._compute_totals()
|
||||
super(AccountMoveLine, self - expenses)._compute_totals()
|
||||
|
||||
def _convert_to_tax_base_line_dict(self):
|
||||
result = super()._convert_to_tax_base_line_dict()
|
||||
if self.expense_id:
|
||||
result.setdefault('extra_context', {})
|
||||
result['extra_context']['force_price_include'] = True
|
||||
return result
|
||||
|
||||
def _get_extra_query_base_tax_line_mapping(self):
|
||||
return ' AND (base_line.expense_id IS NULL OR account_move_line.expense_id = base_line.expense_id)'
|
49
models/account_payment.py
Normal file
49
models/account_payment.py
Normal file
@ -0,0 +1,49 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, _
|
||||
from odoo.api import ondelete
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class AccountPayment(models.Model):
|
||||
_inherit = "account.payment"
|
||||
|
||||
def action_open_expense_report(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': self.expense_sheet_id.name,
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_type': 'form',
|
||||
'view_mode': 'form',
|
||||
'views': [(False, 'form')],
|
||||
'res_model': 'hr.expense.sheet',
|
||||
'res_id': self.expense_sheet_id.id
|
||||
}
|
||||
|
||||
def _synchronize_from_moves(self, changed_fields):
|
||||
# EXTENDS account
|
||||
if self.expense_sheet_id:
|
||||
# Constraints bypass when entry is linked to an expense.
|
||||
# Context is not enough, as we want to be able to delete
|
||||
# and update those entries later on.
|
||||
return
|
||||
super()._synchronize_from_moves(changed_fields)
|
||||
|
||||
def _synchronize_to_moves(self, changed_fields):
|
||||
# EXTENDS account
|
||||
trigger_fields = set(self._get_trigger_fields_to_synchronize()) | {'ref', 'expense_sheet_id', 'payment_method_line_id'}
|
||||
if self.expense_sheet_id and any(field_name in trigger_fields for field_name in changed_fields):
|
||||
raise UserError(_("You cannot do this modification since the payment is linked to an expense report."))
|
||||
return super()._synchronize_to_moves(changed_fields)
|
||||
|
||||
def _creation_message(self):
|
||||
# EXTENDS mail
|
||||
self.ensure_one()
|
||||
if self.move_id.expense_sheet_id:
|
||||
return _("Payment created for: %s", self.move_id.expense_sheet_id._get_html_link())
|
||||
return super()._creation_message()
|
||||
|
||||
@ondelete(at_uninstall=True)
|
||||
def _must_delete_all_expense_payments(self):
|
||||
if self.expense_sheet_id and self.expense_sheet_id.account_move_ids.payment_ids - self: # If not all the payments are to be deleted
|
||||
raise UserError(_("You cannot delete only some payments linked to an expense report. All payments must be deleted at the same time."))
|
30
models/account_tax.py
Normal file
30
models/account_tax.py
Normal file
@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class AccountTax(models.Model):
|
||||
_inherit = "account.tax"
|
||||
|
||||
def _hook_compute_is_used(self, taxes_to_compute):
|
||||
# OVERRIDE in order to fetch taxes used in expenses
|
||||
|
||||
used_taxes = super()._hook_compute_is_used(taxes_to_compute)
|
||||
taxes_to_compute -= used_taxes
|
||||
|
||||
if taxes_to_compute:
|
||||
self.env['hr.expense'].flush_model(['tax_ids'])
|
||||
self.env.cr.execute("""
|
||||
SELECT id
|
||||
FROM account_tax
|
||||
WHERE EXISTS(
|
||||
SELECT 1
|
||||
FROM expense_tax AS exp
|
||||
WHERE tax_id IN %s
|
||||
AND account_tax.id = exp.tax_id
|
||||
)
|
||||
""", [tuple(taxes_to_compute)])
|
||||
|
||||
used_taxes.update([tax[0] for tax in self.env.cr.fetchall()])
|
||||
|
||||
return used_taxes
|
38
models/analytic.py
Normal file
38
models/analytic.py
Normal file
@ -0,0 +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.exceptions import UserError
|
||||
|
||||
|
||||
class AccountAnalyticApplicability(models.Model):
|
||||
_inherit = 'account.analytic.applicability'
|
||||
_description = "Analytic Plan's Applicabilities"
|
||||
|
||||
business_domain = fields.Selection(
|
||||
selection_add=[
|
||||
('expense', 'Expense'),
|
||||
],
|
||||
ondelete={'expense': 'cascade'},
|
||||
)
|
||||
|
||||
@api.depends('business_domain')
|
||||
def _compute_display_account_prefix(self):
|
||||
super()._compute_display_account_prefix()
|
||||
for applicability in self.filtered(lambda rec: rec.business_domain == 'expense'):
|
||||
applicability.display_account_prefix = True
|
||||
|
||||
|
||||
class AccountAnalyticAccount(models.Model):
|
||||
_inherit = 'account.analytic.account'
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_account_in_analytic_distribution(self):
|
||||
self.env.cr.execute("""
|
||||
SELECT id FROM hr_expense
|
||||
WHERE analytic_distribution::jsonb ?| array[%s]
|
||||
LIMIT 1
|
||||
""", ([str(id) for id in self.ids],))
|
||||
expense_ids = self.env.cr.fetchall()
|
||||
if expense_ids:
|
||||
raise UserError(_("You cannot delete an analytic account that is used in an expense."))
|
16
models/hr_department.py
Normal file
16
models/hr_department.py
Normal file
@ -0,0 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class HrDepartment(models.Model):
|
||||
_inherit = 'hr.department'
|
||||
|
||||
def _compute_expense_sheets_to_approve(self):
|
||||
expense_sheet_data = self.env['hr.expense.sheet']._read_group([('department_id', 'in', self.ids), ('state', '=', 'submit')], ['department_id'], ['__count'])
|
||||
result = {department.id: count for department, count in expense_sheet_data}
|
||||
for department in self:
|
||||
department.expense_sheets_to_approve_count = result.get(department.id, 0)
|
||||
|
||||
expense_sheets_to_approve_count = fields.Integer(compute='_compute_expense_sheets_to_approve', string='Expenses Reports to Approve')
|
81
models/hr_employee.py
Normal file
81
models/hr_employee.py
Normal file
@ -0,0 +1,81 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models, api
|
||||
|
||||
|
||||
class Employee(models.Model):
|
||||
_inherit = 'hr.employee'
|
||||
|
||||
def _group_hr_expense_user_domain(self):
|
||||
# We return the domain only if the group exists for the following reason:
|
||||
# When a group is created (at module installation), the `res.users` form view is
|
||||
# automatically modified to add application accesses. When modifying the view, it
|
||||
# reads the related field `expense_manager_id` of `res.users` and retrieve its domain.
|
||||
# This is a problem because the `group_hr_expense_user` record has already been created but
|
||||
# not its associated `ir.model.data` which makes `self.env.ref(...)` fail.
|
||||
group = self.env.ref('hr_expense.group_hr_expense_team_approver', raise_if_not_found=False)
|
||||
return [('groups_id', 'in', group.ids)] if group else []
|
||||
|
||||
expense_manager_id = fields.Many2one(
|
||||
comodel_name='res.users',
|
||||
string='Expense',
|
||||
compute='_compute_expense_manager', store=True, readonly=False,
|
||||
domain=_group_hr_expense_user_domain,
|
||||
help='Select the user responsible for approving "Expenses" of this employee.\n'
|
||||
'If empty, the approval is done by an Administrator or Approver (determined in settings/users).',
|
||||
)
|
||||
|
||||
filter_for_expense = fields.Boolean(store=False, search='_search_filter_for_expense')
|
||||
|
||||
@api.depends('parent_id')
|
||||
def _compute_expense_manager(self):
|
||||
for employee in self:
|
||||
previous_manager = employee._origin.parent_id.user_id
|
||||
manager = employee.parent_id.user_id
|
||||
if manager and manager.has_group('hr_expense.group_hr_expense_user') \
|
||||
and (employee.expense_manager_id == previous_manager or not employee.expense_manager_id):
|
||||
employee.expense_manager_id = manager
|
||||
elif not employee.expense_manager_id:
|
||||
employee.expense_manager_id = False
|
||||
|
||||
def _get_user_m2o_to_empty_on_archived_employees(self):
|
||||
return super()._get_user_m2o_to_empty_on_archived_employees() + ['expense_manager_id']
|
||||
|
||||
def _search_filter_for_expense(self, operator, value):
|
||||
assert operator == '='
|
||||
assert value
|
||||
|
||||
res = [('id', '=', 0)] # Nothing accepted by domain, by default
|
||||
if self.user_has_groups('hr_expense.group_hr_expense_user') or self.user_has_groups('account.group_account_user'):
|
||||
res = ['|', ('company_id', '=', False), ('company_id', 'child_of', self.env.company.root_id.id)] # Then, domain accepts everything
|
||||
elif self.user_has_groups('hr_expense.group_hr_expense_team_approver') and self.env.user.employee_ids:
|
||||
user = self.env.user
|
||||
employee = self.env.user.employee_id
|
||||
res = [
|
||||
'|', '|', '|',
|
||||
('department_id.manager_id', '=', employee.id),
|
||||
('parent_id', '=', employee.id),
|
||||
('id', '=', employee.id),
|
||||
('expense_manager_id', '=', user.id),
|
||||
'|', ('company_id', '=', False), ('company_id', '=', employee.company_id.id),
|
||||
]
|
||||
elif self.env.user.employee_id:
|
||||
employee = self.env.user.employee_id
|
||||
res = [('id', '=', employee.id), '|', ('company_id', '=', False), ('company_id', '=', employee.company_id.id)]
|
||||
return res
|
||||
|
||||
|
||||
class EmployeePublic(models.Model):
|
||||
_inherit = 'hr.employee.public'
|
||||
|
||||
expense_manager_id = fields.Many2one('res.users', readonly=True)
|
||||
|
||||
|
||||
class User(models.Model):
|
||||
_inherit = ['res.users']
|
||||
|
||||
expense_manager_id = fields.Many2one(related='employee_id.expense_manager_id', readonly=False)
|
||||
|
||||
@property
|
||||
def SELF_READABLE_FIELDS(self):
|
||||
return super().SELF_READABLE_FIELDS + ['expense_manager_id']
|
1049
models/hr_expense.py
Normal file
1049
models/hr_expense.py
Normal file
File diff suppressed because it is too large
Load Diff
758
models/hr_expense_sheet.py
Normal file
758
models/hr_expense_sheet.py
Normal file
@ -0,0 +1,758 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, Command, models, _
|
||||
from odoo.exceptions import UserError, ValidationError, RedirectWarning
|
||||
from odoo.tools.misc import clean_context
|
||||
|
||||
|
||||
class HrExpenseSheet(models.Model):
|
||||
"""
|
||||
Here are the rights associated with the expense flow
|
||||
|
||||
Action Group Restriction
|
||||
=================================================================================
|
||||
Submit Employee Only his own
|
||||
Officer If he is expense manager of the employee, manager of the employee
|
||||
or the employee is in the department managed by the officer
|
||||
Manager Always
|
||||
Approve Officer Not his own and he is expense manager of the employee, manager of the employee
|
||||
or the employee is in the department managed by the officer
|
||||
Manager Always
|
||||
Post Anybody State = approve and journal_id defined
|
||||
Done Anybody State = approve and journal_id defined
|
||||
Cancel Officer Not his own and he is expense manager of the employee, manager of the employee
|
||||
or the employee is in the department managed by the officer
|
||||
Manager Always
|
||||
=================================================================================
|
||||
"""
|
||||
_name = "hr.expense.sheet"
|
||||
_inherit = ['mail.thread.main.attachment', 'mail.activity.mixin']
|
||||
_description = "Expense Report"
|
||||
_order = "accounting_date desc, id desc"
|
||||
_check_company_auto = True
|
||||
|
||||
@api.model
|
||||
def _default_employee_id(self):
|
||||
return self.env.user.employee_id
|
||||
|
||||
@api.model
|
||||
def _default_journal_id(self):
|
||||
"""
|
||||
The journal is determining the company of the accounting entries generated from expense.
|
||||
We need to force journal company and expense sheet company to be the same.
|
||||
"""
|
||||
company_journal_id = self.env.company.expense_journal_id
|
||||
if company_journal_id:
|
||||
return company_journal_id.id
|
||||
default_company_id = self.default_get(['company_id'])['company_id']
|
||||
journal = self.env['account.journal'].search([
|
||||
*self.env['account.journal']._check_company_domain(default_company_id),
|
||||
('type', '=', 'purchase'),
|
||||
], limit=1)
|
||||
return journal.id
|
||||
|
||||
name = fields.Char(string="Expense Report Summary", required=True, tracking=True)
|
||||
expense_line_ids = fields.One2many(
|
||||
comodel_name='hr.expense', inverse_name='sheet_id',
|
||||
string="Expense Lines",
|
||||
copy=False,
|
||||
)
|
||||
nb_expense = fields.Integer(compute='_compute_nb_expense', string="Number of Expenses")
|
||||
state = fields.Selection(
|
||||
selection=[
|
||||
('draft', 'To Submit'),
|
||||
('submit', 'Submitted'),
|
||||
('approve', 'Approved'),
|
||||
('post', 'Posted'),
|
||||
('done', 'Done'),
|
||||
('cancel', 'Refused')
|
||||
],
|
||||
string="Status",
|
||||
compute='_compute_state', store=True, readonly=True,
|
||||
index=True,
|
||||
required=True,
|
||||
default='draft',
|
||||
tracking=True,
|
||||
copy=False,
|
||||
)
|
||||
approval_state = fields.Selection(
|
||||
selection=[
|
||||
('submit', 'Submitted'),
|
||||
('approve', 'Approved'),
|
||||
('cancel', 'Refused'),
|
||||
],
|
||||
copy=False,
|
||||
)
|
||||
approval_date = fields.Datetime(string="Approval Date", readonly=True)
|
||||
company_id = fields.Many2one(
|
||||
comodel_name='res.company',
|
||||
string="Company",
|
||||
required=True,
|
||||
readonly=True,
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
employee_id = fields.Many2one(
|
||||
comodel_name='hr.employee',
|
||||
string="Employee",
|
||||
required=True,
|
||||
readonly=True,
|
||||
default=_default_employee_id,
|
||||
domain=[('filter_for_expense', '=', True)],
|
||||
check_company=True,
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
department_id = fields.Many2one(
|
||||
comodel_name='hr.department',
|
||||
related='employee_id.department_id',
|
||||
string="Department",
|
||||
store=True,
|
||||
copy=False,
|
||||
)
|
||||
user_id = fields.Many2one(
|
||||
comodel_name='res.users',
|
||||
string="Manager",
|
||||
compute='_compute_from_employee_id', store=True, readonly=True,
|
||||
domain=lambda self: [('groups_id', 'in', self.env.ref('hr_expense.group_hr_expense_team_approver').id)],
|
||||
copy=False,
|
||||
tracking=True,
|
||||
)
|
||||
product_ids = fields.Many2many(
|
||||
comodel_name='product.product',
|
||||
string="Categories",
|
||||
compute='_compute_product_ids',
|
||||
search='_search_product_ids',
|
||||
check_company=True,
|
||||
)
|
||||
|
||||
# === Amount fields === #
|
||||
total_amount = fields.Monetary(
|
||||
string="Total",
|
||||
currency_field='company_currency_id',
|
||||
compute='_compute_amount', store=True, readonly=True,
|
||||
tracking=True,
|
||||
)
|
||||
untaxed_amount = fields.Monetary(
|
||||
string="Untaxed Amount",
|
||||
currency_field='company_currency_id',
|
||||
compute='_compute_amount', store=True, readonly=True,
|
||||
)
|
||||
total_tax_amount = fields.Monetary(
|
||||
string="Taxes",
|
||||
currency_field='company_currency_id',
|
||||
compute='_compute_amount', store=True, readonly=True,
|
||||
)
|
||||
amount_residual = fields.Monetary(
|
||||
string="Amount Due",
|
||||
currency_field='company_currency_id',
|
||||
compute='_compute_from_account_move_ids', store=True, readonly=True,
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
comodel_name='res.currency',
|
||||
string="Currency",
|
||||
compute='_compute_currency_id', store=True, readonly=True,
|
||||
)
|
||||
company_currency_id = fields.Many2one(
|
||||
comodel_name='res.currency',
|
||||
related='company_id.currency_id',
|
||||
string="Report Company Currency"
|
||||
)
|
||||
is_multiple_currency = fields.Boolean(
|
||||
string="Handle lines with different currencies",
|
||||
compute='_compute_is_multiple_currency',
|
||||
)
|
||||
|
||||
# === Account fields === #
|
||||
payment_state = fields.Selection(
|
||||
selection=lambda self: self.env["account.move"]._fields["payment_state"]._description_selection(self.env),
|
||||
string="Payment Status",
|
||||
compute='_compute_from_account_move_ids', store=True, readonly=True,
|
||||
copy=False,
|
||||
tracking=True,
|
||||
)
|
||||
payment_mode = fields.Selection(
|
||||
related='expense_line_ids.payment_mode',
|
||||
string="Paid By",
|
||||
tracking=True,
|
||||
readonly=True,
|
||||
)
|
||||
employee_journal_id = fields.Many2one(
|
||||
comodel_name='account.journal',
|
||||
string="Journal",
|
||||
default=_default_journal_id,
|
||||
check_company=True,
|
||||
domain=[('type', '=', 'purchase')],
|
||||
help="The journal used when the expense is paid by employee.",
|
||||
)
|
||||
selectable_payment_method_line_ids = fields.Many2many(
|
||||
comodel_name='account.payment.method.line',
|
||||
compute='_compute_selectable_payment_method_line_ids',
|
||||
)
|
||||
payment_method_line_id = fields.Many2one(
|
||||
comodel_name='account.payment.method.line',
|
||||
string="Payment Method",
|
||||
compute='_compute_payment_method_line_id', store=True, readonly=False,
|
||||
domain="[('id', 'in', selectable_payment_method_line_ids)]",
|
||||
help="The payment method used when the expense is paid by the company.",
|
||||
)
|
||||
attachment_ids = fields.One2many(
|
||||
comodel_name='ir.attachment',
|
||||
inverse_name='res_id',
|
||||
domain="[('res_model', '=', 'hr.expense.sheet')]",
|
||||
string='Attachments of expenses',
|
||||
)
|
||||
message_main_attachment_id = fields.Many2one(compute='_compute_main_attachment', store=True)
|
||||
accounting_date = fields.Date(string="Accounting Date", compute='_compute_accounting_date', store=True)
|
||||
account_move_ids = fields.One2many(
|
||||
string="Journal Entries",
|
||||
comodel_name='account.move', inverse_name='expense_sheet_id', readonly=True,
|
||||
)
|
||||
nb_account_move = fields.Integer(string="Number of Journal Entries", compute='_compute_nb_account_move')
|
||||
journal_id = fields.Many2one(
|
||||
comodel_name='account.journal',
|
||||
string="Expense Journal",
|
||||
compute='_compute_journal_id', store=True,
|
||||
check_company=True,
|
||||
)
|
||||
|
||||
# === Security fields === #
|
||||
can_reset = fields.Boolean(string='Can Reset', compute='_compute_can_reset')
|
||||
can_approve = fields.Boolean(string='Can Approve', compute='_compute_can_approve')
|
||||
cannot_approve_reason = fields.Char(string='Cannot Approve Reason', compute='_compute_can_approve')
|
||||
is_editable = fields.Boolean(string="Expense Lines Are Editable By Current User", compute='_compute_is_editable')
|
||||
|
||||
_sql_constraints = [(
|
||||
'journal_id_required_posted',
|
||||
"CHECK((state IN ('post', 'done') AND journal_id IS NOT NULL) OR (state NOT IN ('post', 'done')))",
|
||||
'The journal must be set on posted expense'
|
||||
)]
|
||||
|
||||
@api.depends('expense_line_ids.total_amount', 'expense_line_ids.tax_amount')
|
||||
def _compute_amount(self):
|
||||
for sheet in self:
|
||||
sheet.total_amount = sum(sheet.expense_line_ids.mapped('total_amount'))
|
||||
sheet.total_tax_amount = sum(sheet.expense_line_ids.mapped('tax_amount'))
|
||||
sheet.untaxed_amount = sheet.total_amount - sheet.total_tax_amount
|
||||
|
||||
@api.depends('account_move_ids.payment_state', 'account_move_ids.amount_residual')
|
||||
def _compute_from_account_move_ids(self):
|
||||
for sheet in self:
|
||||
if sheet.payment_mode == 'company_account':
|
||||
if sheet.account_move_ids:
|
||||
# when the sheet is paid by the company, the state/amount of the related account_move_ids are not relevant
|
||||
# unless all moves have been reversed
|
||||
sheet.amount_residual = 0.
|
||||
if sheet.account_move_ids - sheet.account_move_ids.filtered('reversal_move_id'):
|
||||
sheet.payment_state = 'paid'
|
||||
else:
|
||||
sheet.payment_state = 'reversed'
|
||||
else:
|
||||
sheet.amount_residual = sum(sheet.account_move_ids.mapped('amount_residual'))
|
||||
payment_states = set(sheet.account_move_ids.mapped('payment_state'))
|
||||
if len(payment_states) <= 1: # If only 1 move or only one state
|
||||
sheet.payment_state = payment_states.pop() if payment_states else 'not_paid'
|
||||
elif 'partial' in payment_states or 'paid' in payment_states: # else if any are (partially) paid
|
||||
sheet.payment_state = 'partial'
|
||||
else:
|
||||
sheet.payment_state = 'not_paid'
|
||||
else:
|
||||
# Only one move is created when the expenses are paid by the employee
|
||||
if sheet.account_move_ids:
|
||||
sheet.amount_residual = sum(sheet.account_move_ids.mapped('amount_residual'))
|
||||
sheet.payment_state = sheet.account_move_ids[:1].payment_state
|
||||
else:
|
||||
sheet.amount_residual = 0.0
|
||||
sheet.payment_state = 'not_paid'
|
||||
|
||||
@api.depends('selectable_payment_method_line_ids')
|
||||
def _compute_payment_method_line_id(self):
|
||||
for sheet in self:
|
||||
sheet.payment_method_line_id = sheet.selectable_payment_method_line_ids[:1]
|
||||
|
||||
@api.depends('employee_journal_id', 'payment_method_line_id')
|
||||
def _compute_journal_id(self):
|
||||
for sheet in self:
|
||||
if sheet.payment_mode == 'company_account':
|
||||
sheet.journal_id = sheet.payment_method_line_id.journal_id
|
||||
else:
|
||||
sheet.journal_id = sheet.employee_journal_id
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_selectable_payment_method_line_ids(self):
|
||||
for sheet in self:
|
||||
allowed_method_line_ids = sheet.company_id.company_expense_allowed_payment_method_line_ids
|
||||
if allowed_method_line_ids:
|
||||
sheet.selectable_payment_method_line_ids = allowed_method_line_ids
|
||||
else:
|
||||
sheet.selectable_payment_method_line_ids = self.env['account.payment.method.line'].search([
|
||||
('payment_type', '=', 'outbound'),
|
||||
('company_id', '=', sheet.company_id.id)
|
||||
])
|
||||
|
||||
@api.depends('account_move_ids', 'payment_state', 'approval_state')
|
||||
def _compute_state(self):
|
||||
for sheet in self:
|
||||
if sheet.payment_state != 'not_paid':
|
||||
sheet.state = 'done'
|
||||
elif sheet.account_move_ids:
|
||||
sheet.state = 'post'
|
||||
elif sheet.approval_state:
|
||||
sheet.state = sheet.approval_state
|
||||
else:
|
||||
sheet.state = 'draft'
|
||||
|
||||
@api.depends('expense_line_ids.attachment_ids')
|
||||
def _compute_main_attachment(self):
|
||||
for sheet in self:
|
||||
attachments = sheet.attachment_ids
|
||||
if not sheet.message_main_attachment_id or sheet.message_main_attachment_id not in attachments:
|
||||
expenses = sheet.expense_line_ids
|
||||
expenses_mma_checksums = expenses.message_main_attachment_id.mapped('checksum')
|
||||
sheet.message_main_attachment_id = attachments.filtered(
|
||||
lambda att: att.checksum in expenses_mma_checksums
|
||||
)[:1] or attachments[:1]
|
||||
|
||||
@api.depends('expense_line_ids.currency_id', 'company_currency_id')
|
||||
def _compute_currency_id(self):
|
||||
for sheet in self:
|
||||
if not sheet.expense_line_ids or sheet.is_multiple_currency or sheet.payment_mode == 'own_account':
|
||||
sheet.currency_id = sheet.company_currency_id
|
||||
else:
|
||||
sheet.currency_id = sheet.expense_line_ids[:1].currency_id
|
||||
|
||||
@api.depends('expense_line_ids.currency_id')
|
||||
def _compute_is_multiple_currency(self):
|
||||
for sheet in self:
|
||||
sheet.is_multiple_currency = any(sheet.expense_line_ids.mapped('is_multiple_currency')) \
|
||||
or len(sheet.expense_line_ids.mapped('currency_id')) > 1
|
||||
|
||||
@api.depends('employee_id')
|
||||
def _compute_can_reset(self):
|
||||
is_expense_user = self.user_has_groups('hr_expense.group_hr_expense_team_approver')
|
||||
for sheet in self:
|
||||
sheet.can_reset = is_expense_user if is_expense_user else sheet.employee_id.user_id == self.env.user
|
||||
|
||||
@api.depends_context('uid')
|
||||
@api.depends('employee_id')
|
||||
def _compute_can_approve(self):
|
||||
is_team_approver = self.user_has_groups('hr_expense.group_hr_expense_team_approver')
|
||||
is_approver = self.user_has_groups('hr_expense.group_hr_expense_user')
|
||||
is_hr_admin = self.user_has_groups('hr_expense.group_hr_expense_manager')
|
||||
|
||||
for sheet in self:
|
||||
reason = False
|
||||
if not is_team_approver:
|
||||
reason = _("%s: Your are not a Manager or HR Officer", sheet.name)
|
||||
|
||||
elif not is_hr_admin:
|
||||
sheet_employee = sheet.employee_id
|
||||
current_managers = sheet_employee.expense_manager_id \
|
||||
| sheet_employee.parent_id.user_id \
|
||||
| sheet_employee.department_id.manager_id.user_id \
|
||||
| sheet.user_id
|
||||
|
||||
if sheet_employee.user_id == self.env.user:
|
||||
reason = _("%s: It is your own expense", sheet.name)
|
||||
|
||||
elif self.env.user not in current_managers and not is_approver and sheet_employee.expense_manager_id.id != self.env.user.id:
|
||||
reason = _("%s: It is not from your department", sheet.name)
|
||||
|
||||
sheet.can_approve = not reason
|
||||
sheet.cannot_approve_reason = reason
|
||||
|
||||
@api.depends('expense_line_ids')
|
||||
def _compute_nb_expense(self):
|
||||
for sheet in self:
|
||||
sheet.nb_expense = len(sheet.expense_line_ids)
|
||||
|
||||
@api.depends('account_move_ids')
|
||||
def _compute_nb_account_move(self):
|
||||
for sheet in self:
|
||||
sheet.nb_account_move = len(sheet.account_move_ids)
|
||||
|
||||
@api.depends('account_move_ids.date')
|
||||
def _compute_accounting_date(self):
|
||||
for sheet in self.filtered('account_move_ids'):
|
||||
sheet.accounting_date = sheet.account_move_ids[:1].date
|
||||
|
||||
@api.depends('employee_id', 'employee_id.department_id')
|
||||
def _compute_from_employee_id(self):
|
||||
for sheet in self:
|
||||
sheet.department_id = sheet.employee_id.department_id
|
||||
sheet.user_id = sheet.employee_id.expense_manager_id or sheet.employee_id.parent_id.user_id
|
||||
|
||||
@api.depends_context('uid')
|
||||
@api.depends('employee_id', 'user_id', 'state')
|
||||
def _compute_is_editable(self):
|
||||
is_hr_admin = self.user_has_groups('hr_expense.group_hr_expense_manager')
|
||||
is_approver = self.user_has_groups('hr_expense.group_hr_expense_user')
|
||||
for sheet in self:
|
||||
if sheet.state not in {'draft', 'submit', 'approve'}:
|
||||
# Not editable
|
||||
sheet.is_editable = False
|
||||
continue
|
||||
|
||||
if is_hr_admin:
|
||||
# Administrator-level users are not restricted
|
||||
sheet.is_editable = True
|
||||
continue
|
||||
|
||||
employee = sheet.employee_id
|
||||
|
||||
is_own_sheet = employee.user_id == self.env.user
|
||||
if is_own_sheet and sheet.state == 'draft':
|
||||
# Anyone can edit their own draft sheet
|
||||
sheet.is_editable = True
|
||||
continue
|
||||
|
||||
managers = employee.expense_manager_id | employee.parent_id.user_id | employee.department_id.manager_id.user_id
|
||||
if is_approver:
|
||||
managers |= self.env.user
|
||||
if not is_own_sheet and self.env.user in managers:
|
||||
# If Approver-level or designated manager, can edit other people sheet
|
||||
sheet.is_editable = True
|
||||
continue
|
||||
sheet.is_editable = False
|
||||
|
||||
@api.constrains('expense_line_ids')
|
||||
def _check_payment_mode(self):
|
||||
for sheet in self:
|
||||
expense_lines = sheet.mapped('expense_line_ids')
|
||||
if expense_lines and any(expense.payment_mode != expense_lines[:1].payment_mode for expense in expense_lines):
|
||||
raise ValidationError(_("All expenses in an expense report must have the same \"paid by\" criteria."))
|
||||
|
||||
@api.depends('expense_line_ids')
|
||||
def _compute_product_ids(self):
|
||||
for sheet in self:
|
||||
sheet.product_ids = sheet.expense_line_ids.mapped('product_id')
|
||||
|
||||
@api.constrains('expense_line_ids', 'employee_id')
|
||||
def _check_employee(self):
|
||||
for sheet in self:
|
||||
if sheet.expense_line_ids.employee_id - sheet.employee_id:
|
||||
raise ValidationError(_('You cannot add expenses of another employee.'))
|
||||
|
||||
@api.constrains('expense_line_ids', 'company_id')
|
||||
def _check_expense_lines_company(self):
|
||||
for sheet in self:
|
||||
if sheet.expense_line_ids.company_id - sheet.company_id:
|
||||
raise ValidationError(_('An expense report must contain only lines from the same company.'))
|
||||
|
||||
@api.model
|
||||
def _search_product_ids(self, operator, value):
|
||||
if operator == 'in' and not isinstance(value, list):
|
||||
value = [value]
|
||||
return [('expense_line_ids.product_id', operator, value)]
|
||||
|
||||
# ----------------------------------------
|
||||
# ORM Overrides
|
||||
# ----------------------------------------
|
||||
|
||||
def _read_format(self, fnames, load='_classic_read'):
|
||||
# setting the context in the field on the view is not enough
|
||||
self = self.with_context(show_payment_journal_id=True)
|
||||
return super()._read_format(fnames, load)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
context = clean_context(self.env.context)
|
||||
context.update({
|
||||
'mail_create_nosubscribe': True,
|
||||
'mail_auto_subscribe_no_notify': True,
|
||||
})
|
||||
sheets = super(HrExpenseSheet, self.with_context(context)).create(vals_list)
|
||||
sheets.activity_update()
|
||||
return sheets
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_posted_or_paid(self):
|
||||
for expense in self:
|
||||
if expense.state in {'post', 'done'}:
|
||||
raise UserError(_('You cannot delete a posted or paid expense.'))
|
||||
|
||||
# --------------------------------------------
|
||||
# Mail Thread
|
||||
# --------------------------------------------
|
||||
|
||||
def _track_subtype(self, init_values):
|
||||
self.ensure_one()
|
||||
if 'state' in init_values and self.state == 'draft':
|
||||
return self.env.ref('hr_expense.mt_expense_reset')
|
||||
if 'state' in init_values and self.state == 'approve':
|
||||
if init_values['state'] in {'post', 'done'}:
|
||||
return self.env.ref('hr_expense.mt_expense_entry_delete')
|
||||
return self.env.ref('hr_expense.mt_expense_approved')
|
||||
if 'state' in init_values and self.state == 'cancel':
|
||||
return self.env.ref('hr_expense.mt_expense_refused')
|
||||
if 'state' in init_values and self.state == 'done':
|
||||
return self.env.ref('hr_expense.mt_expense_paid')
|
||||
return super()._track_subtype(init_values)
|
||||
|
||||
def _message_auto_subscribe_followers(self, updated_values, subtype_ids):
|
||||
res = super()._message_auto_subscribe_followers(updated_values, subtype_ids)
|
||||
if updated_values.get('employee_id'):
|
||||
employee_user = self.env['hr.employee'].browse(updated_values['employee_id']).user_id
|
||||
if employee_user:
|
||||
res.append((employee_user.partner_id.id, subtype_ids, False))
|
||||
return res
|
||||
|
||||
def activity_update(self):
|
||||
reports_requiring_feedback = self.env['hr.expense.sheet']
|
||||
reports_activity_unlink = self.env['hr.expense.sheet']
|
||||
for expense_report in self:
|
||||
if expense_report.state == 'submit':
|
||||
expense_report.activity_schedule(
|
||||
'hr_expense.mail_act_expense_approval',
|
||||
user_id=expense_report.sudo()._get_responsible_for_approval().id or self.env.user.id)
|
||||
elif expense_report.state == 'approve':
|
||||
reports_requiring_feedback |= expense_report
|
||||
elif expense_report.state in {'draft', 'cancel'}:
|
||||
reports_activity_unlink |= expense_report
|
||||
if reports_requiring_feedback:
|
||||
reports_requiring_feedback.activity_feedback(['hr_expense.mail_act_expense_approval'])
|
||||
if reports_activity_unlink:
|
||||
reports_activity_unlink.activity_unlink(['hr_expense.mail_act_expense_approval'])
|
||||
|
||||
# --------------------------------------------
|
||||
# Actions
|
||||
# --------------------------------------------
|
||||
|
||||
def action_submit_sheet(self):
|
||||
self._do_submit()
|
||||
|
||||
def action_approve_expense_sheets(self):
|
||||
self._check_can_approve()
|
||||
self._validate_analytic_distribution()
|
||||
duplicates = self.expense_line_ids.duplicate_expense_ids.filtered(lambda exp: exp.state in {'approved', 'done'})
|
||||
if duplicates:
|
||||
action = self.env["ir.actions.act_window"]._for_xml_id('hr_expense.hr_expense_approve_duplicate_action')
|
||||
action['context'] = {'default_sheet_ids': self.ids, 'default_expense_ids': duplicates.ids}
|
||||
return action
|
||||
self._do_approve()
|
||||
|
||||
def action_refuse_expense_sheets(self):
|
||||
self._check_can_refuse()
|
||||
return self.env["ir.actions.act_window"]._for_xml_id('hr_expense.hr_expense_refuse_wizard_action')
|
||||
|
||||
def action_reset_approval_expense_sheets(self):
|
||||
self._check_can_reset_approval()
|
||||
self._do_reset_approval()
|
||||
|
||||
def action_sheet_move_create(self):
|
||||
self._check_can_create_move()
|
||||
self._do_create_moves()
|
||||
|
||||
def action_reset_expense_sheets(self):
|
||||
self._do_reverse_moves()
|
||||
self._do_reset_approval()
|
||||
|
||||
def action_register_payment(self):
|
||||
''' Open the account.payment.register wizard to pay the selected journal entries.
|
||||
There can be more than one bank_account_id in the expense sheet when registering payment for multiple expenses.
|
||||
The default_partner_bank_id is set only if there is one available, if more than one the field is left empty.
|
||||
:return: An action opening the account.payment.register wizard.
|
||||
'''
|
||||
return self.account_move_ids.with_context(default_partner_bank_id=(
|
||||
self.employee_id.sudo().bank_account_id.id if len(self.employee_id.sudo().bank_account_id.ids) <= 1 else None
|
||||
)).action_register_payment()
|
||||
|
||||
def action_open_expense_view(self):
|
||||
self.ensure_one()
|
||||
if self.nb_expense == 1:
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_mode': 'form',
|
||||
'res_model': 'hr.expense',
|
||||
'res_id': self.expense_line_ids.id,
|
||||
}
|
||||
return {
|
||||
'name': _('Expenses'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_mode': 'list,form',
|
||||
'views': [[False, "list"], [False, "form"]],
|
||||
'res_model': 'hr.expense',
|
||||
'domain': [('id', 'in', self.expense_line_ids.ids)],
|
||||
}
|
||||
|
||||
def action_open_account_moves(self):
|
||||
self.ensure_one()
|
||||
if self.payment_mode == 'own_account':
|
||||
res_model = 'account.move'
|
||||
record_ids = self.account_move_ids
|
||||
else:
|
||||
res_model = 'account.payment'
|
||||
record_ids = self.account_move_ids.mapped('payment_id')
|
||||
|
||||
action = {'type': 'ir.actions.act_window', 'res_model': res_model}
|
||||
if len(self.account_move_ids) == 1:
|
||||
action.update({
|
||||
'name': record_ids.name,
|
||||
'view_mode': 'form',
|
||||
'res_id': record_ids.id,
|
||||
'views': [(False, 'form')],
|
||||
})
|
||||
else:
|
||||
action.update({
|
||||
'name': _("Journal entries"),
|
||||
'view_mode': 'list',
|
||||
'domain': [('id', 'in', record_ids.ids)],
|
||||
'views': [(False, 'list'), (False, 'form')],
|
||||
})
|
||||
return action
|
||||
|
||||
# --------------------------------------------
|
||||
# Business
|
||||
# --------------------------------------------
|
||||
|
||||
def set_to_paid(self):
|
||||
# hook used in other modules to bypass payment registration
|
||||
self.write({'state': 'done'})
|
||||
|
||||
def set_to_posted(self):
|
||||
# hook used in other modules to bypass move creation
|
||||
self.write({'state': 'post'})
|
||||
|
||||
def _check_can_approve(self):
|
||||
if not all(self.mapped('can_approve')):
|
||||
reasons = _("You cannot approve:\n %s", "\n".join(self.mapped('cannot_approve_reason')))
|
||||
raise UserError(reasons)
|
||||
|
||||
def _check_can_refuse(self):
|
||||
if not all(self.mapped('can_approve')):
|
||||
reasons = _("You cannot refuse:\n %s", "\n".join(self.mapped('cannot_approve_reason')))
|
||||
raise UserError(reasons)
|
||||
|
||||
def _check_can_reset_approval(self):
|
||||
if not all(self.mapped('can_reset')):
|
||||
raise UserError(_("Only HR Officers or the concerned employee can reset to draft."))
|
||||
|
||||
def _check_can_create_move(self):
|
||||
if any(sheet.state != 'approve' for sheet in self):
|
||||
raise UserError(_("You can only generate accounting entry for approved expense(s)."))
|
||||
|
||||
if any(not sheet.journal_id for sheet in self):
|
||||
raise UserError(_("Specify expense journal to generate accounting entries."))
|
||||
|
||||
missing_email_employees = self.filtered(lambda sheet: not sheet.employee_id.work_email).employee_id
|
||||
if missing_email_employees:
|
||||
action = self.env['ir.actions.actions']._for_xml_id('hr.open_view_employee_tree')
|
||||
action['domain'] = [('id', 'in', missing_email_employees.ids)]
|
||||
raise RedirectWarning(_("The work email of some employees is missing. Please add it on the employee form"), action, _("Show missing work email employees"))
|
||||
|
||||
def _do_submit(self):
|
||||
self.write({'approval_state': 'submit'})
|
||||
self.sudo().activity_update()
|
||||
|
||||
def _do_approve(self):
|
||||
for sheet in self.filtered(lambda s: s.state in {'submit', 'draft'}):
|
||||
sheet.write({
|
||||
'approval_state': 'approve',
|
||||
'user_id': sheet.user_id.id or self.env.user.id,
|
||||
'approval_date': fields.Date.context_today(sheet),
|
||||
})
|
||||
self.activity_update()
|
||||
|
||||
def _do_reset_approval(self):
|
||||
self.sudo().write({'approval_state': False})
|
||||
self.activity_update()
|
||||
|
||||
def _do_refuse(self, reason):
|
||||
self.write({'state': 'cancel'})
|
||||
subtype_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_comment')
|
||||
for sheet in self:
|
||||
sheet.message_post_with_source(
|
||||
'hr_expense.hr_expense_template_refuse_reason',
|
||||
subtype_id=subtype_id,
|
||||
render_values={'reason': reason, 'name': sheet.name},
|
||||
)
|
||||
self.activity_update()
|
||||
|
||||
def _do_create_moves(self):
|
||||
self = self.with_context(clean_context(self.env.context)) # remove default_*
|
||||
skip_context = {
|
||||
'skip_invoice_sync': True,
|
||||
'skip_invoice_line_sync': True,
|
||||
'skip_account_move_synchronization': True,
|
||||
}
|
||||
own_account_sheets = self.filtered(lambda sheet: sheet.payment_mode == 'own_account')
|
||||
company_account_sheets = self - own_account_sheets
|
||||
|
||||
moves = self.env['account.move'].create([sheet._prepare_bills_vals() for sheet in own_account_sheets])
|
||||
# Set the main attachment on the moves directly to avoid recomputing the
|
||||
# `register_as_main_attachment` on the moves which triggers the OCR again
|
||||
for move in moves:
|
||||
move.message_main_attachment_id = move.attachment_ids[0] if move.attachment_ids else None
|
||||
payments = self.env['account.payment'].with_context(**skip_context).create([
|
||||
expense._prepare_payments_vals() for expense in company_account_sheets.expense_line_ids
|
||||
])
|
||||
moves |= payments.move_id
|
||||
moves.action_post()
|
||||
self.activity_update()
|
||||
|
||||
return moves
|
||||
|
||||
def _do_reverse_moves(self):
|
||||
self = self.with_context(clean_context(self.env.context))
|
||||
moves = self.account_move_ids
|
||||
draft_moves = moves.filtered(lambda m: m.state == 'draft')
|
||||
non_draft_moves = moves - draft_moves
|
||||
non_draft_moves._reverse_moves(
|
||||
default_values_list=[{'invoice_date': fields.Date.context_today(move), 'ref': False} for move in non_draft_moves],
|
||||
cancel=True
|
||||
)
|
||||
draft_moves.unlink()
|
||||
|
||||
def _prepare_bills_vals(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
**self._prepare_move_vals(),
|
||||
'invoice_date': self.accounting_date or fields.Date.context_today(self),
|
||||
'journal_id': self.journal_id.id,
|
||||
'ref': self.name,
|
||||
'move_type': 'in_invoice',
|
||||
'partner_id': self.employee_id.sudo().work_contact_id.id,
|
||||
'currency_id': self.currency_id.id,
|
||||
'line_ids': [Command.create(expense._prepare_move_lines_vals()) for expense in self.expense_line_ids],
|
||||
'attachment_ids': [
|
||||
Command.create(attachment.copy_data({'res_model': 'account.move', 'res_id': False, 'raw': attachment.raw})[0])
|
||||
for attachment in self.expense_line_ids.message_main_attachment_id
|
||||
],
|
||||
}
|
||||
|
||||
def _prepare_move_vals(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
# force the name to the default value, to avoid an eventual 'default_name' in the context
|
||||
# to set it to '' which cause no number to be given to the account.move when posted.
|
||||
'name': '/',
|
||||
'date': self.accounting_date or max(self.expense_line_ids.mapped('date')) or fields.Date.context_today(self),
|
||||
'expense_sheet_id': self.id,
|
||||
}
|
||||
|
||||
def _validate_analytic_distribution(self):
|
||||
for line in self.expense_line_ids:
|
||||
line._validate_distribution(account=line.account_id.id, business_domain='expense', company_id=line.company_id.id)
|
||||
|
||||
def _get_responsible_for_approval(self):
|
||||
if self.user_id:
|
||||
return self.user_id
|
||||
if self.employee_id.parent_id.user_id:
|
||||
return self.employee_id.parent_id.user_id
|
||||
if self.employee_id.department_id.manager_id.user_id:
|
||||
return self.employee_id.department_id.manager_id.user_id
|
||||
return self.env['res.users']
|
||||
|
||||
def _get_expense_account_destination(self):
|
||||
self.ensure_one()
|
||||
if self.payment_mode == 'company_account':
|
||||
journal = self.payment_method_line_id.journal_id
|
||||
account_dest = (
|
||||
journal.outbound_payment_method_line_ids[:1].payment_account_id
|
||||
or journal.company_id.account_journal_payment_credit_account_id
|
||||
)
|
||||
else:
|
||||
if not self.employee_id.sudo().work_contact_id:
|
||||
raise UserError(_("No work contact found for the employee %s, please configure one.", self.employee_id.name))
|
||||
partner = self.employee_id.sudo().work_contact_id.with_company(self.company_id)
|
||||
account_dest = partner.property_account_payable_id or partner.parent_id.property_account_payable_id
|
||||
return account_dest.id
|
47
models/ir_actions_report.py
Normal file
47
models/ir_actions_report.py
Normal file
@ -0,0 +1,47 @@
|
||||
import io
|
||||
from odoo import models
|
||||
from odoo.tools import pdf
|
||||
from odoo.tools.pdf import OdooPdfFileReader, OdooPdfFileWriter
|
||||
|
||||
|
||||
class IrActionsReport(models.Model):
|
||||
_inherit = 'ir.actions.report'
|
||||
|
||||
def _render_qweb_pdf_prepare_streams(self, report_ref, data, res_ids=None):
|
||||
# OVERRIDE
|
||||
res = super()._render_qweb_pdf_prepare_streams(report_ref, data, res_ids)
|
||||
if not res_ids:
|
||||
return res
|
||||
report = self._get_report(report_ref)
|
||||
if report.report_name == 'hr_expense.report_expense_sheet':
|
||||
expense_sheets = self.env['hr.expense.sheet'].browse(res_ids)
|
||||
for expense_sheet in expense_sheets:
|
||||
# Will contains the expense report
|
||||
stream_list = []
|
||||
stream = res[expense_sheet.id]['stream']
|
||||
stream_list.append(stream)
|
||||
attachments = self.env['ir.attachment'].search([('res_id', 'in', expense_sheet.expense_line_ids.ids), ('res_model', '=', 'hr.expense')])
|
||||
expense_report = OdooPdfFileReader(stream, strict=False)
|
||||
output_pdf = OdooPdfFileWriter()
|
||||
output_pdf.appendPagesFromReader(expense_report)
|
||||
for attachment in attachments:
|
||||
if attachment.mimetype == 'application/pdf':
|
||||
attachment_stream = pdf.to_pdf_stream(attachment)
|
||||
else:
|
||||
# In case the attachment is not a pdf we will create a new PDF from the template "report_expense_sheet_img"
|
||||
# And then append to the stream. By doing so, the attachment is put on a new page with the name of the expense
|
||||
# associated to the attachment
|
||||
data['attachment'] = attachment
|
||||
attachment_prep_stream = self._render_qweb_pdf_prepare_streams('hr_expense.report_expense_sheet_img', data, res_ids=res_ids)
|
||||
attachment_stream = attachment_prep_stream[expense_sheet.id]['stream']
|
||||
attachment_reader = OdooPdfFileReader(attachment_stream, strict=False)
|
||||
output_pdf.appendPagesFromReader(attachment_reader)
|
||||
stream_list.append(attachment_stream)
|
||||
|
||||
new_pdf_stream = io.BytesIO()
|
||||
output_pdf.write(new_pdf_stream)
|
||||
res[expense_sheet.id]['stream'] = new_pdf_stream
|
||||
|
||||
for stream in stream_list:
|
||||
stream.close()
|
||||
return res
|
39
models/ir_attachment.py
Normal file
39
models/ir_attachment.py
Normal file
@ -0,0 +1,39 @@
|
||||
from odoo import models, api
|
||||
|
||||
|
||||
class IrAttachment(models.Model):
|
||||
_inherit = 'ir.attachment'
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
attachments = super().create(vals_list)
|
||||
if self.env.context.get('sync_attachment', True):
|
||||
expenses_attachments = attachments.filtered(lambda att: att.res_model == 'hr.expense')
|
||||
if expenses_attachments:
|
||||
expenses = self.env['hr.expense'].browse(expenses_attachments.mapped('res_id'))
|
||||
for expense in expenses.filtered('sheet_id'):
|
||||
checksums = set(expense.sheet_id.attachment_ids.mapped('checksum'))
|
||||
for attachment in expense.attachment_ids.filtered(lambda att: att.checksum not in checksums):
|
||||
attachment.copy({
|
||||
'res_model': 'hr.expense.sheet',
|
||||
'res_id': expense.sheet_id.id,
|
||||
})
|
||||
return attachments
|
||||
|
||||
def unlink(self):
|
||||
if self.env.context.get('sync_attachment', True):
|
||||
attachments_to_unlink = self.env['ir.attachment']
|
||||
expenses_attachments = self.filtered(lambda att: att.res_model == 'hr.expense')
|
||||
if expenses_attachments:
|
||||
expenses = self.env['hr.expense'].browse(expenses_attachments.mapped('res_id'))
|
||||
for expense in expenses.exists().filtered('sheet_id'):
|
||||
checksums = set(expense.attachment_ids.mapped('checksum'))
|
||||
attachments_to_unlink += expense.sheet_id.attachment_ids.filtered(lambda att: att.checksum in checksums)
|
||||
sheets_attachments = self.filtered(lambda att: att.res_model == 'hr.expense.sheet')
|
||||
if sheets_attachments:
|
||||
sheets = self.env['hr.expense.sheet'].browse(sheets_attachments.mapped('res_id'))
|
||||
for sheet in sheets.exists():
|
||||
checksums = set((sheet.attachment_ids & sheets_attachments).mapped('checksum'))
|
||||
attachments_to_unlink += sheet.expense_line_ids.attachment_ids.filtered(lambda att: att.checksum in checksums)
|
||||
super(IrAttachment, attachments_to_unlink).unlink()
|
||||
return super().unlink()
|
26
models/product_product.py
Normal file
26
models/product_product.py
Normal file
@ -0,0 +1,26 @@
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class ProductProduct(models.Model):
|
||||
_inherit = "product.product"
|
||||
|
||||
standard_price_update_warning = fields.Char(compute="_compute_standard_price_update_warning")
|
||||
|
||||
@api.onchange('standard_price')
|
||||
def _compute_standard_price_update_warning(self):
|
||||
undone_expenses = self.env['hr.expense']._read_group(
|
||||
domain=[('state', 'in', ['draft', 'reported', 'approved']), ('product_id', 'in', self.ids)],
|
||||
groupby=['price_unit'],
|
||||
)
|
||||
# The following list is composed of all the unit_amounts of expenses that use this product and should NOT trigger a warning.
|
||||
# Those are the amounts of any undone expense using this product and 0.0 which is the default unit_amount.
|
||||
unit_amounts_no_warning = [self.env.company.currency_id.round(row[0]) for row in undone_expenses]
|
||||
for product in self:
|
||||
product.standard_price_update_warning = False
|
||||
if undone_expenses:
|
||||
rounded_price = self.env.company.currency_id.round(product.standard_price)
|
||||
if rounded_price and (len(unit_amounts_no_warning) > 1 or (len(unit_amounts_no_warning) == 1 and rounded_price not in unit_amounts_no_warning)):
|
||||
product.standard_price_update_warning = _(
|
||||
"There are unposted expenses linked to this category. Updating the category cost will change expense amounts. "
|
||||
"Make sure it is what you want to do."
|
||||
)
|
35
models/product_template.py
Normal file
35
models/product_template.py
Normal file
@ -0,0 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.tools.sql import column_exists, create_column
|
||||
|
||||
|
||||
class ProductTemplate(models.Model):
|
||||
_inherit = "product.template"
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
result = super(ProductTemplate, self).default_get(fields)
|
||||
if self.env.context.get('default_can_be_expensed'):
|
||||
result['supplier_taxes_id'] = False
|
||||
return result
|
||||
|
||||
can_be_expensed = fields.Boolean(string="Can be Expensed", compute='_compute_can_be_expensed',
|
||||
store=True, readonly=False, help="Specify whether the product can be selected in an expense.")
|
||||
|
||||
def _auto_init(self):
|
||||
if not column_exists(self.env.cr, "product_template", "can_be_expensed"):
|
||||
create_column(self.env.cr, "product_template", "can_be_expensed", "boolean")
|
||||
self.env.cr.execute(
|
||||
"""
|
||||
UPDATE product_template
|
||||
SET can_be_expensed = false
|
||||
WHERE type NOT IN ('consu', 'service')
|
||||
"""
|
||||
)
|
||||
return super()._auto_init()
|
||||
|
||||
@api.depends('type')
|
||||
def _compute_can_be_expensed(self):
|
||||
self.filtered(lambda p: p.type not in ['consu', 'service']).update({'can_be_expensed': False})
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user