mirror of
https://gitlab.com/hibou-io/hibou-odoo/suite.git
synced 2025-01-20 12:37:31 +02:00
Merge branch '13.0' into mig/13.0/hr_payroll_timesheet
This commit is contained in:
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -13,3 +13,6 @@
|
|||||||
[submodule "external/camptocamp-cloud-platform"]
|
[submodule "external/camptocamp-cloud-platform"]
|
||||||
path = external/camptocamp-cloud-platform
|
path = external/camptocamp-cloud-platform
|
||||||
url = https://github.com/hibou-io/camptocamp-cloud-platform
|
url = https://github.com/hibou-io/camptocamp-cloud-platform
|
||||||
|
[submodule "external/hibou-shipbox"]
|
||||||
|
path = external/hibou-shipbox
|
||||||
|
url = https://gitlab.com/hibou-io/hibou-odoo/shipbox.git
|
||||||
|
|||||||
67
attachment_minio/migrations/13.0.0.0.1/post-migration.py
Normal file
67
attachment_minio/migrations/13.0.0.0.1/post-migration.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Copyright 2020 Hibou Corp.
|
||||||
|
# Copyright 2016-2019 Camptocamp SA
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from contextlib import closing
|
||||||
|
|
||||||
|
import odoo
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
if not version:
|
||||||
|
return
|
||||||
|
cr.execute("""
|
||||||
|
SELECT value FROM ir_config_parameter
|
||||||
|
WHERE key = 'ir_attachment.location'
|
||||||
|
""")
|
||||||
|
row = cr.fetchone()
|
||||||
|
|
||||||
|
if row[0] == 's3':
|
||||||
|
uid = odoo.SUPERUSER_ID
|
||||||
|
registry = odoo.modules.registry.Registry(cr.dbname)
|
||||||
|
new_cr = registry.cursor()
|
||||||
|
with closing(new_cr):
|
||||||
|
with odoo.api.Environment.manage():
|
||||||
|
env = odoo.api.Environment(new_cr, uid, {})
|
||||||
|
store_local = env['ir.attachment'].search(
|
||||||
|
[('store_fname', '=like', 's3://%'),
|
||||||
|
'|', ('res_model', '=', 'ir.ui.view'),
|
||||||
|
('res_field', 'in', ['image_small',
|
||||||
|
'image_medium',
|
||||||
|
'web_icon_data',
|
||||||
|
# image.mixin sizes
|
||||||
|
# image_128 is essentially image_medium
|
||||||
|
'image_128',
|
||||||
|
# depending on use case, these may need migrated/moved
|
||||||
|
# 'image_256',
|
||||||
|
# 'image_512',
|
||||||
|
])
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
_logger.info(
|
||||||
|
'Moving %d attachments from S3 to DB for fast access',
|
||||||
|
len(store_local)
|
||||||
|
)
|
||||||
|
for attachment_id in store_local.ids:
|
||||||
|
# force re-storing the document, will move
|
||||||
|
# it from the object storage to the database
|
||||||
|
|
||||||
|
# This is a trick to avoid having the 'datas' function
|
||||||
|
# fields computed for every attachment on each
|
||||||
|
# iteration of the loop. The former issue being that
|
||||||
|
# it reads the content of the file of ALL the
|
||||||
|
# attachments on each loop.
|
||||||
|
try:
|
||||||
|
env.clear()
|
||||||
|
attachment = env['ir.attachment'].browse(attachment_id)
|
||||||
|
_logger.info('Moving attachment %s (id: %s)',
|
||||||
|
attachment.name, attachment.id)
|
||||||
|
attachment.write({'datas': attachment.datas})
|
||||||
|
new_cr.commit()
|
||||||
|
except:
|
||||||
|
new_cr.rollback()
|
||||||
2
external/camptocamp-cloud-platform
vendored
2
external/camptocamp-cloud-platform
vendored
Submodule external/camptocamp-cloud-platform updated: 298050ba52...af8cee48fb
1
external/hibou-shipbox
vendored
Submodule
1
external/hibou-shipbox
vendored
Submodule
Submodule external/hibou-shipbox added at e0a0f3e9c6
3
hibou_professional/__init__.py
Normal file
3
hibou_professional/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import models
|
||||||
24
hibou_professional/__manifest__.py
Normal file
24
hibou_professional/__manifest__.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||||
|
|
||||||
|
{
|
||||||
|
'name': 'Hibou Professional',
|
||||||
|
'author': 'Hibou Corp. <hello@hibou.io>',
|
||||||
|
'category': 'Tools',
|
||||||
|
'depends': ['mail'],
|
||||||
|
'version': '13.0.1.0.0',
|
||||||
|
'description': """
|
||||||
|
Hibou Professional Support and Billing
|
||||||
|
======================================
|
||||||
|
|
||||||
|
""",
|
||||||
|
'website': 'https://hibou.io/',
|
||||||
|
'data': [
|
||||||
|
'views/webclient_templates.xml',
|
||||||
|
],
|
||||||
|
'qweb': [
|
||||||
|
'static/src/xml/templates.xml',
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
'auto_install': True,
|
||||||
|
'license': 'OPL-1',
|
||||||
|
}
|
||||||
3
hibou_professional/models/__init__.py
Normal file
3
hibou_professional/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import update
|
||||||
208
hibou_professional/models/update.py
Normal file
208
hibou_professional/models/update.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from odoo import api, fields, models, release
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
class PublisherWarrantyContract(models.AbstractModel):
|
||||||
|
_inherit = 'publisher_warranty.contract'
|
||||||
|
|
||||||
|
CONFIG_HIBOU_URL = 'https://api.hibou.io/hibouapi/v1/professional'
|
||||||
|
CONFIG_HIBOU_MESSAGE_URL = 'https://api.hibou.io/hibouapi/v1/professional/message'
|
||||||
|
CONFIG_HIBOU_QUOTE_URL = 'https://api.hibou.io/hibouapi/v1/professional/quote'
|
||||||
|
DAYS_ENDING_SOON = 7
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def hibou_professional_status(self):
|
||||||
|
get_param = self.env['ir.config_parameter'].sudo().get_param
|
||||||
|
expiration_date = get_param('database.hibou_professional_expiration_date')
|
||||||
|
expiration_reason = get_param('database.hibou_professional_expiration_reason')
|
||||||
|
dbuuid = get_param('database.uuid')
|
||||||
|
expiring = False
|
||||||
|
expired = False
|
||||||
|
if expiration_date:
|
||||||
|
expiration_date_date = fields.Date.from_string(expiration_date)
|
||||||
|
today = fields.Date.today()
|
||||||
|
if expiration_date_date < today:
|
||||||
|
if expiration_reason == 'trial':
|
||||||
|
expired = 'Your trial of Hibou Professional has ended.'
|
||||||
|
else:
|
||||||
|
expired = 'Your Hibou Professional subscription has ended.'
|
||||||
|
elif expiration_date_date < (today + datetime.timedelta(days=self.DAYS_ENDING_SOON)):
|
||||||
|
if expiration_reason == 'trial':
|
||||||
|
expiring = 'Your trial of Hibou Professional is ending soon.'
|
||||||
|
else:
|
||||||
|
expiring = 'Your Hibou Professional subscription is ending soon.'
|
||||||
|
|
||||||
|
is_admin = self.env.user.has_group('base.group_erp_manager')
|
||||||
|
allow_admin_message = get_param('database.hibou_allow_admin_message')
|
||||||
|
allow_message = get_param('database.hibou_allow_message')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'expiration_date': get_param('database.hibou_professional_expiration_date'),
|
||||||
|
'expiration_reason': get_param('database.hibou_professional_expiration_reason'),
|
||||||
|
'expiring': expiring,
|
||||||
|
'expired': expired,
|
||||||
|
'professional_code': get_param('database.hibou_professional_code'),
|
||||||
|
'dbuuid': dbuuid,
|
||||||
|
'is_admin': is_admin,
|
||||||
|
'allow_admin_message': allow_admin_message,
|
||||||
|
'allow_message': allow_message,
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def hibou_professional_update_message_preferences(self, allow_admin_message, allow_message):
|
||||||
|
if self.env.user.has_group('base.group_erp_manager'):
|
||||||
|
set_param = self.env['ir.config_parameter'].sudo().set_param
|
||||||
|
set_param('database.hibou_allow_admin_message', allow_admin_message and '1')
|
||||||
|
set_param('database.hibou_allow_message', allow_message and '1')
|
||||||
|
return self.hibou_professional_status()
|
||||||
|
|
||||||
|
def _check_message_allow(self):
|
||||||
|
get_param = self.env['ir.config_parameter'].sudo().get_param
|
||||||
|
allow_message = get_param('database.hibou_allow_message')
|
||||||
|
if not allow_message:
|
||||||
|
allow_message = get_param('database.hibou_allow_admin_message') and self.env.user.has_group(
|
||||||
|
'base.group_erp_manager')
|
||||||
|
if not allow_message:
|
||||||
|
raise UserError('You are not allowed to send messages at this time.')
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def hibou_professional_quote(self):
|
||||||
|
get_param = self.env['ir.config_parameter'].sudo().get_param
|
||||||
|
try:
|
||||||
|
self._hibou_install()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
dbuuid = get_param('database.uuid')
|
||||||
|
dbtoken = get_param('database.hibou_token')
|
||||||
|
if dbuuid and dbtoken:
|
||||||
|
return {'url': self.CONFIG_HIBOU_QUOTE_URL + '/%s/%s' % (dbuuid, dbtoken)}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def hibou_professional_send_message(self, type, priority, subject, body, user_url, res_id):
|
||||||
|
self._check_message_allow()
|
||||||
|
get_param = self.env['ir.config_parameter'].sudo().get_param
|
||||||
|
dbuuid = get_param('database.uuid')
|
||||||
|
dbtoken = get_param('database.hibou_token')
|
||||||
|
user_name = self.env.user.name
|
||||||
|
user_email = self.env.user.email or self.env.user.login
|
||||||
|
company_name = self.env.user.company_id.name
|
||||||
|
data = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'method': 'call',
|
||||||
|
'params': {
|
||||||
|
'dbuuid': dbuuid,
|
||||||
|
'user_name': user_name,
|
||||||
|
'user_email': user_email,
|
||||||
|
'user_url': user_url,
|
||||||
|
'company_name': company_name,
|
||||||
|
'type': type,
|
||||||
|
'priority': priority,
|
||||||
|
'subject': subject,
|
||||||
|
'body': body,
|
||||||
|
'res_id': res_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if dbtoken:
|
||||||
|
data['params']['dbtoken'] = dbtoken
|
||||||
|
try:
|
||||||
|
r = requests.post(self.CONFIG_HIBOU_MESSAGE_URL + '/new', json=data, timeout=30)
|
||||||
|
r.raise_for_status()
|
||||||
|
wrapper = r.json()
|
||||||
|
return wrapper.get('result', {})
|
||||||
|
except:
|
||||||
|
return {'error': 'Error sending message.'}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def hibou_professional_get_messages(self):
|
||||||
|
self._check_message_allow()
|
||||||
|
get_param = self.env['ir.config_parameter'].sudo().get_param
|
||||||
|
dbuuid = get_param('database.uuid')
|
||||||
|
dbtoken = get_param('database.hibou_token')
|
||||||
|
try:
|
||||||
|
r = requests.get(self.CONFIG_HIBOU_MESSAGE_URL + '/get/%s/%s' % (dbuuid, dbtoken), timeout=30)
|
||||||
|
r.raise_for_status()
|
||||||
|
# not jsonrpc
|
||||||
|
return r.json()
|
||||||
|
except:
|
||||||
|
return {'error': 'Error retrieving messages, maybe the token is wrong.'}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def hibou_professional_update(self, professional_code):
|
||||||
|
set_param = self.env['ir.config_parameter'].sudo().set_param
|
||||||
|
set_param('database.hibou_professional_code', professional_code)
|
||||||
|
try:
|
||||||
|
self._hibou_install()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return self.hibou_professional_status()
|
||||||
|
|
||||||
|
def _get_hibou_modules(self):
|
||||||
|
domain = [('state', 'in', ['installed', 'to upgrade', 'to remove']), ('author', 'ilike', 'hibou')]
|
||||||
|
module_list = self.env['ir.module.module'].sudo().search_read(domain, ['name'])
|
||||||
|
return {module['name']: 1 for module in module_list}
|
||||||
|
|
||||||
|
def _get_hibou_message(self):
|
||||||
|
IrParamSudo = self.env['ir.config_parameter'].sudo()
|
||||||
|
|
||||||
|
dbuuid = IrParamSudo.get_param('database.uuid')
|
||||||
|
dbtoken = IrParamSudo.get_param('database.hibou_token')
|
||||||
|
db_create_date = IrParamSudo.get_param('database.create_date')
|
||||||
|
user = self.env.user.sudo()
|
||||||
|
professional_code = IrParamSudo.get_param('database.hibou_professional_code')
|
||||||
|
|
||||||
|
module_dictionary = self._get_hibou_modules()
|
||||||
|
modules = []
|
||||||
|
for module, qty in module_dictionary.items():
|
||||||
|
modules.append(module if qty == 1 else '%s,%s' % (module, qty))
|
||||||
|
|
||||||
|
web_base_url = IrParamSudo.get_param('web.base.url')
|
||||||
|
msg = {
|
||||||
|
"dbuuid": dbuuid,
|
||||||
|
"dbname": self._cr.dbname,
|
||||||
|
"db_create_date": db_create_date,
|
||||||
|
"version": release.version,
|
||||||
|
"language": user.lang,
|
||||||
|
"web_base_url": web_base_url,
|
||||||
|
"modules": '\n'.join(modules),
|
||||||
|
"professional_code": professional_code,
|
||||||
|
}
|
||||||
|
if dbtoken:
|
||||||
|
msg['dbtoken'] = dbtoken
|
||||||
|
msg.update({'company_' + key: value for key, value in user.company_id.read(["name", "email", "phone"])[0].items() if key != 'id'})
|
||||||
|
return msg
|
||||||
|
|
||||||
|
def _process_hibou_message(self, result):
|
||||||
|
if result.get('professional_info'):
|
||||||
|
set_param = self.env['ir.config_parameter'].sudo().set_param
|
||||||
|
set_param('database.hibou_professional_expiration_date', result['professional_info'].get('expiration_date'))
|
||||||
|
set_param('database.hibou_professional_expiration_reason', result['professional_info'].get('expiration_reason', 'trial'))
|
||||||
|
if result['professional_info'].get('professional_code'):
|
||||||
|
set_param('database.hibou_professional_code', result['professional_info'].get('professional_code'))
|
||||||
|
if result['professional_info'].get('dbtoken'):
|
||||||
|
set_param('database.hibou_token', result['professional_info'].get('dbtoken'))
|
||||||
|
|
||||||
|
def _hibou_install(self):
|
||||||
|
data = self._get_hibou_message()
|
||||||
|
data = {
|
||||||
|
'jsonrpc': '2.0',
|
||||||
|
'method': 'call',
|
||||||
|
'params': data,
|
||||||
|
}
|
||||||
|
r = requests.post(self.CONFIG_HIBOU_URL, json=data, timeout=30)
|
||||||
|
r.raise_for_status()
|
||||||
|
wrapper = r.json()
|
||||||
|
self._process_hibou_message(wrapper.get('result', {}))
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_sys_logs(self):
|
||||||
|
try:
|
||||||
|
self._hibou_install()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return super(PublisherWarrantyContract, self)._get_sys_logs()
|
||||||
21
hibou_professional/static/src/css/web.css
Normal file
21
hibou_professional/static/src/css/web.css
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
.hibou_professional_systray .o_mail_systray_dropdown {
|
||||||
|
max-height: 580px !important;
|
||||||
|
}
|
||||||
|
.hibou_professional_systray .o_mail_systray_dropdown_items {
|
||||||
|
max-height: 578px !important;
|
||||||
|
}
|
||||||
|
.hibou_professional_systray .subscription_form button {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hibou_professional_systray .hibou-icon-small {
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hibou_professional_systray .hibou_professional_help {
|
||||||
|
color: #0a6fa2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hibou_professional_systray .o_preview_title span {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
BIN
hibou_professional/static/src/img/hibou_icon_small.png
Normal file
BIN
hibou_professional/static/src/img/hibou_icon_small.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
275
hibou_professional/static/src/js/core.js
Normal file
275
hibou_professional/static/src/js/core.js
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
odoo.define('hibou_professional.core', function (require) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var Widget = require('web.Widget');
|
||||||
|
var SystrayMenu = require('web.SystrayMenu');
|
||||||
|
|
||||||
|
var HibouProfessionalSystrayWidget = Widget.extend({
|
||||||
|
template: 'HibouProfessionalSystrayWidget',
|
||||||
|
|
||||||
|
start: function() {
|
||||||
|
var self = this;
|
||||||
|
self.expiration_date = false;
|
||||||
|
self.expiration_reason = false;
|
||||||
|
self.professional_code = false;
|
||||||
|
this.types = [['lead', 'Sales'], ['ticket', 'Support']];
|
||||||
|
this.message_subjects = {'lead': [], 'ticket': [], 'task': []};
|
||||||
|
self.expiring = false;
|
||||||
|
self.expired = false;
|
||||||
|
self.dbuuid = false;
|
||||||
|
self.quote_url = false;
|
||||||
|
self.is_admin = false;
|
||||||
|
self.allow_admin_message = false;
|
||||||
|
self.allow_message = false;
|
||||||
|
this._rpc({
|
||||||
|
model: 'publisher_warranty.contract',
|
||||||
|
method: 'hibou_professional_status',
|
||||||
|
}).then(function (result) {
|
||||||
|
self.handleStatusUpdate(result);
|
||||||
|
});
|
||||||
|
return this._super();
|
||||||
|
},
|
||||||
|
|
||||||
|
get_subjects: function(type) {
|
||||||
|
if (this.message_subjects && this.message_subjects[type]) {
|
||||||
|
return this.message_subjects[type]
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
|
||||||
|
set_error: function(error) {
|
||||||
|
this.$('.hibou_professional_error').text(error);
|
||||||
|
},
|
||||||
|
|
||||||
|
update_message_type: function(el) {
|
||||||
|
var selected_type = this.$('select.hibou_message_type').val();
|
||||||
|
if (selected_type && this.$('.hibou_subject_selection_option.' + selected_type).length > 0) {
|
||||||
|
this.$('#hibou_subject_selection').show();
|
||||||
|
this.$('.hibou_subject_selection_option').hide().attr('disabled', true);
|
||||||
|
this.$('.hibou_subject_selection_option.' + selected_type).show().attr('disabled', false);
|
||||||
|
var selected_subject = this.$('.hibou_subject_selection_option.' + selected_type)[0];
|
||||||
|
this.$('select.hibou_subject_selection').val(selected_subject.value);
|
||||||
|
} else if (selected_type) {
|
||||||
|
this.$('select.hibou_subject_selection').val('0');
|
||||||
|
this.$('#hibou_subject_selection').hide();
|
||||||
|
} else {
|
||||||
|
this.$('#hibou_subject_selection').hide();
|
||||||
|
this.$('#hibou_message_priority').hide();
|
||||||
|
this.$('#hibou_message_subject').hide();
|
||||||
|
}
|
||||||
|
this.update_subject_selection();
|
||||||
|
},
|
||||||
|
|
||||||
|
update_subject_selection: function(el) {
|
||||||
|
var selected_subject = this.$('select.hibou_subject_selection').val();
|
||||||
|
if (selected_subject == '0') {
|
||||||
|
this.$('#hibou_message_priority').show();
|
||||||
|
this.$('#hibou_message_subject').show();
|
||||||
|
} else {
|
||||||
|
this.$('#hibou_message_priority').hide();
|
||||||
|
this.$('#hibou_message_subject').hide();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
update_message_subjects: function(subjects_by_type) {
|
||||||
|
// TODO actually update instead of overriding...
|
||||||
|
this.message_subjects = subjects_by_type;
|
||||||
|
this.renderElement();
|
||||||
|
},
|
||||||
|
|
||||||
|
button_update_subscription: function() {
|
||||||
|
var self = this;
|
||||||
|
var professional_code = self.$('input.hibou_professional_code').val();
|
||||||
|
if (!professional_code) {
|
||||||
|
alert('Please enter a subscription code first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.$('.update_subscription').prop('disabled', 'disabled');
|
||||||
|
self._rpc({
|
||||||
|
model: 'publisher_warranty.contract',
|
||||||
|
method: 'hibou_professional_update',
|
||||||
|
args: [professional_code],
|
||||||
|
}).then(function (result) {
|
||||||
|
self.$('.update_subscription').prop('disabled', false);
|
||||||
|
self.handleStatusUpdate(result);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
button_update_message_preferences: function() {
|
||||||
|
var self = this;
|
||||||
|
var allow_admin_message = self.$('input.hibou_allow_admin_message').prop('checked');
|
||||||
|
var allow_message = self.$('input.hibou_allow_message').prop('checked');
|
||||||
|
self.$('.update_message_preferences').prop('disabled', 'disabled');
|
||||||
|
self._rpc({
|
||||||
|
model: 'publisher_warranty.contract',
|
||||||
|
method: 'hibou_professional_update_message_preferences',
|
||||||
|
args: [allow_admin_message, allow_message],
|
||||||
|
}).then(function (result) {
|
||||||
|
self.$('.update_message_preferences').prop('disabled', false);
|
||||||
|
self.handleStatusUpdate(result);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
button_quote: function() {
|
||||||
|
var self = this;
|
||||||
|
var message_p = self.$('.button-quote-link p');
|
||||||
|
message_p.text('Retrieving URL...');
|
||||||
|
self._rpc({
|
||||||
|
model: 'publisher_warranty.contract',
|
||||||
|
method: 'hibou_professional_quote',
|
||||||
|
}).then(function (result) {
|
||||||
|
if (result && result['url']) {
|
||||||
|
self.quote_url = result.url
|
||||||
|
self.$('.button-quote-link').attr('href', self.quote_url);
|
||||||
|
message_p.text('Quote URL ready. Click again!');
|
||||||
|
} else {
|
||||||
|
message_p.text('Error with quote url. Maybe the database token is incorrect.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
button_send_message: function() {
|
||||||
|
var self = this;
|
||||||
|
var message_type = self.$('select.hibou_message_type').val();
|
||||||
|
var message_priority = self.$('select.hibou_message_priority').val();
|
||||||
|
var message_subject = self.$('input.hibou_message_subject').val();
|
||||||
|
var message_subject_id = self.$('select.hibou_subject_selection').val();
|
||||||
|
var current_url = window.location.href;
|
||||||
|
if (message_subject_id == '0' && (!message_subject || message_subject.length < 3)) {
|
||||||
|
alert('Please enter a longer subject.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var message_body = self.$('textarea.hibou_message_body').val();
|
||||||
|
self.$('.hibou_send_message').prop('disabled', 'disabled');
|
||||||
|
self._rpc({
|
||||||
|
model: 'publisher_warranty.contract',
|
||||||
|
method: 'hibou_professional_send_message',
|
||||||
|
args: [message_type, message_priority, message_subject, message_body, current_url, message_subject_id],
|
||||||
|
}).then(function (result) {
|
||||||
|
// TODO result will have a subject to add to the subjects and re-render.
|
||||||
|
self.$('.hibou_send_message').prop('disabled', false);
|
||||||
|
var message_response = self.$('.hibou_message_response');
|
||||||
|
var access_link = self.$('.hibou_message_response a');
|
||||||
|
var message_form = self.$('.hibou_message_form');
|
||||||
|
if (!result) {
|
||||||
|
access_link.text('An error has occured.')
|
||||||
|
} else {
|
||||||
|
if (result.error) {
|
||||||
|
access_link.text(result.error);
|
||||||
|
} else {
|
||||||
|
access_link.text(result.message || 'Your message has been received.')
|
||||||
|
}
|
||||||
|
if (result.access_url) {
|
||||||
|
access_link.attr('href', result.access_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message_response.show();
|
||||||
|
message_form.hide();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
button_get_messages: function() {
|
||||||
|
var self = this;
|
||||||
|
var $button = this.$('.hibou_get_messages');
|
||||||
|
$button.prop('disabled', 'disabled');
|
||||||
|
self._rpc({
|
||||||
|
model: 'publisher_warranty.contract',
|
||||||
|
method: 'hibou_professional_get_messages',
|
||||||
|
args: [],
|
||||||
|
}).then(function (result) {
|
||||||
|
$button.prop('disabled', false);
|
||||||
|
if (result['message_subjects']) {
|
||||||
|
self.update_message_subjects(result.message_subjects);
|
||||||
|
setTimeout(function () {
|
||||||
|
self.$('.dropdown-toggle').click();
|
||||||
|
}, 100);
|
||||||
|
} else if (result['error']) {
|
||||||
|
self.set_error(result['error']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
renderElement: function() {
|
||||||
|
var self = this;
|
||||||
|
this._super();
|
||||||
|
|
||||||
|
this.update_message_type();
|
||||||
|
this.update_subject_selection();
|
||||||
|
|
||||||
|
this.$('select.hibou_message_type').on('change', function(el) {
|
||||||
|
self.update_message_type(el);
|
||||||
|
});
|
||||||
|
this.$('select.hibou_subject_selection').on('change', function(el) {
|
||||||
|
self.update_subject_selection(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Subscription Button
|
||||||
|
this.$('.update_subscription').on('click', function(e){
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
self.button_update_subscription();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.$('.hibou_get_messages').on('click', function(e){
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
self.button_get_messages();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retrieve quote URL
|
||||||
|
this.$('.button-quote-link').on('click', function(e){
|
||||||
|
if (self.quote_url) {
|
||||||
|
return; // allow default url click event
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
self.button_quote();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Message Preferences Button
|
||||||
|
this.$('.update_message_preferences').on('click', function(e){
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
self.button_update_message_preferences();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send Message Button
|
||||||
|
this.$('.hibou_send_message').on('click', function(e){
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
self.button_send_message();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Kill the default click event
|
||||||
|
this.$('.hibou_message_form_container').on('click', function (e) {
|
||||||
|
//e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleStatusUpdate: function(status) {
|
||||||
|
this.expiration_date = status.expiration_date;
|
||||||
|
this.expiration_reason = status.expiration_reason;
|
||||||
|
this.professional_code = status.professional_code;
|
||||||
|
this.types = [['lead', 'Sales'], ['ticket', 'Support']];
|
||||||
|
if (this.professional_code) {
|
||||||
|
this.types.push(['task', 'Project Manager/Developer'])
|
||||||
|
}
|
||||||
|
this.expiring = status.expiring;
|
||||||
|
this.expired = status.expired;
|
||||||
|
this.dbuuid = status.dbuuid;
|
||||||
|
this.is_admin = status.is_admin;
|
||||||
|
this.allow_admin_message = status.allow_admin_message;
|
||||||
|
this.allow_message = status.allow_message;
|
||||||
|
this.renderElement();
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
SystrayMenu.Items.push(HibouProfessionalSystrayWidget);
|
||||||
|
|
||||||
|
return {
|
||||||
|
HibouProfessionalSystrayWidget: HibouProfessionalSystrayWidget,
|
||||||
|
};
|
||||||
|
|
||||||
|
});
|
||||||
136
hibou_professional/static/src/xml/templates.xml
Normal file
136
hibou_professional/static/src/xml/templates.xml
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates id="template" xml:space="preserve">
|
||||||
|
<t t-name="HibouProfessionalSystrayWidget">
|
||||||
|
<li class="hibou_professional_systray o_mail_systray_item">
|
||||||
|
<a class="dropdown-toggle o-no-caret" data-toggle="dropdown" aria-expanded="false" data-flip="false" data-display="static" href="#">
|
||||||
|
<img t-if="! (widget.expiring || widget.expired)" class="hibou-icon-small" width="16px" height="16px" src="/hibou_professional/static/src/img/hibou_icon_small.png" alt="Hibou Icon"/>
|
||||||
|
<i class="fa fa-exclamation-triangle" t-if="widget.expiring || widget.expired"/>
|
||||||
|
<span class="expiration_message" t-if="widget.expiring" t-esc="widget.expiring"/>
|
||||||
|
<span class="expiration_message" t-if="widget.expired" t-esc="widget.expired"/>
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-menu dropdown-menu-right o_mail_systray_dropdown" role="menu">
|
||||||
|
<div class="o_mail_systray_dropdown_items">
|
||||||
|
<a href="https://hibou.io/help?utm_source=db&utm_medium=help" target="_blank">
|
||||||
|
<div class="o_mail_preview">
|
||||||
|
<div class="o_preview_info">
|
||||||
|
<div class="o_preview_title">
|
||||||
|
<span class="o_preview_name hibou_professional_help">
|
||||||
|
Hibou Professional Help
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p>We're here to help!<br/>Click here to review Hibou's help resources or to contact us today.</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-danger hibou_professional_error"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div t-if="widget.allow_message || (widget.allow_admin_message && widget.is_admin)" class="o_mail_preview hibou_message_form_container">
|
||||||
|
<div class="o_preview_info">
|
||||||
|
<div class="o_preview_title">
|
||||||
|
<span class="o_preview_name hibou_professional_help">Talk to Hibou!</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<br/>
|
||||||
|
<p class="get_messages">
|
||||||
|
<button class="hibou_get_messages btn btn-secondary btn-sm">Retrieve Recent Subjects</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="hibou_message_form">
|
||||||
|
<t t-set="subject_types" t-value="widget.types"/>
|
||||||
|
<p>
|
||||||
|
<label for="hibou_message_type">Who do you want to talk to?</label>
|
||||||
|
<select class="hibou_message_type form-control" name="hibou_message_type">
|
||||||
|
<t t-foreach="subject_types" t-as="type">
|
||||||
|
<option t-attf-value="#{type[0]}" t-esc="type[1]"/>
|
||||||
|
</t>
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
<p id="hibou_subject_selection">
|
||||||
|
<label for="hibou_subject_selection">Update Existing</label>
|
||||||
|
<select class="hibou_subject_selection form-control" name="hibou_subject_selection">
|
||||||
|
<t t-foreach="subject_types" t-as="type">
|
||||||
|
<t t-foreach="widget.get_subjects(type[0])" t-as="subject">
|
||||||
|
<option t-attf-value="#{subject[0]}" t-attf-class="hibou_subject_selection_option #{type[0]}" t-esc="subject[1]"/>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
<option value="0">New</option>
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
<p id="hibou_message_priority">
|
||||||
|
<label for="hibou_message_priority">Priority</label>
|
||||||
|
<select class="hibou_message_priority form-control" name="hibou_message_priority">
|
||||||
|
<option value="0">Low priority</option>
|
||||||
|
<option value="1">Medium priority</option>
|
||||||
|
<option value="2">High priority</option>
|
||||||
|
<option value="3">Urgent</option>
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
<p id="hibou_message_subject">
|
||||||
|
<label for="hibou_message_subject">Subject</label>
|
||||||
|
<input type="text" class="hibou_message_subject form-control" name="hibou_message_subject"/>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<p>You can use <a href="https://www.markdownguide.org/cheat-sheet/" target="_blank">Markdown</a> for formatting.</p>
|
||||||
|
<textarea rows="5" class="hibou_message_body form-control" name="hibou_message_body"/>
|
||||||
|
</p>
|
||||||
|
<button type="submit" class="hibou_send_message btn btn-primary">Send</button>
|
||||||
|
</div>
|
||||||
|
<div class="hibou_message_response" style="display: none;">
|
||||||
|
<a target="_blank">Click to view your message</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<t t-if="widget.expiration_reason == 'trial' || (! widget.expiration_reason) || widget.expired || widget.expiring">
|
||||||
|
<a class="button-quote-link" target="_blank">
|
||||||
|
<div class="o_mail_preview">
|
||||||
|
<div class="o_preview_info">
|
||||||
|
<div class="o_preview_title">
|
||||||
|
<span class="o_preview_name hibou_professional_help">
|
||||||
|
See pricing and get a Quote
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p>Click here to review Hibou's pricing and start a new Professional Subscription.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div class="o_mail_preview subscription_form">
|
||||||
|
<div class="o_preview_info">
|
||||||
|
<div class="o_preview_title">
|
||||||
|
<p>
|
||||||
|
<span t-if="widget.expiration_reason == 'trial'">You are on a trial of Hibou Professional.<br/></span>
|
||||||
|
If you have a subscription code, please enter it here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="text" name="hibou_professional_code form-control" class="hibou_professional_code"/>
|
||||||
|
<button type="submit" class="update_subscription btn btn-primary">Update Subscription</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<div t-if="widget.is_admin" class="o_mail_preview message_preferences_form">
|
||||||
|
<div class="o_preview_info">
|
||||||
|
<div class="o_preview_title">
|
||||||
|
<p>
|
||||||
|
You can send messages (tickets, project tasks, etc.) directly to Hibou using this dropdown.<br/><br/>Select which users can send messages.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
<input type="checkbox" t-att-checked="widget.allow_admin_message=='1' or None" name="hibou_allow_admin_message" class="hibou_allow_admin_message"/> Admin Users (like yourself)
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<input type="checkbox" t-att-checked="widget.allow_message=='1' or None" name="hibou_allow_message" class="hibou_allow_message"/> All Internal Users
|
||||||
|
</p>
|
||||||
|
<button type="submit" class="update_message_preferences btn btn-secondary">Update Message Preferences</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
11
hibou_professional/views/webclient_templates.xml
Normal file
11
hibou_professional/views/webclient_templates.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<template id="assets_backend" name="Hibou Professional" inherit_id="web.assets_backend" priority='15'>
|
||||||
|
<xpath expr="//script[last()]" position="after">
|
||||||
|
<script type="text/javascript" src="/hibou_professional/static/src/js/core.js"></script>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//link[last()]" position="after">
|
||||||
|
<link rel="stylesheet" type="text/css" href="/hibou_professional/static/src/css/web.css"/>
|
||||||
|
</xpath>
|
||||||
|
</template>
|
||||||
|
</odoo>
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
'website': 'https://hibou.io/',
|
'website': 'https://hibou.io/',
|
||||||
'depends': [
|
'depends': [
|
||||||
# 'account_invoice_margin', # optional
|
# 'account_invoice_margin', # optional
|
||||||
|
'hibou_professional',
|
||||||
'hr_contract',
|
'hr_contract',
|
||||||
],
|
],
|
||||||
'data': [
|
'data': [
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ class AccountMove(models.Model):
|
|||||||
'context': {'search_default_source_move_id': self[0].id}
|
'context': {'search_default_source_move_id': self[0].id}
|
||||||
}
|
}
|
||||||
|
|
||||||
def action_post(self):
|
def post(self):
|
||||||
res = super(AccountMove, self).action_post()
|
res = super(AccountMove, self).post()
|
||||||
invoices = self.filtered(lambda m: m.is_invoice())
|
invoices = self.filtered(lambda m: m.is_invoice())
|
||||||
if invoices:
|
if invoices:
|
||||||
self.env['hr.commission'].invoice_validated(invoices)
|
self.env['hr.commission'].invoice_validated(invoices)
|
||||||
|
|||||||
@@ -2,8 +2,13 @@
|
|||||||
|
|
||||||
from odoo.tests import common
|
from odoo.tests import common
|
||||||
|
|
||||||
|
# TODO Tests won't pass without `sale`
|
||||||
|
# Tests should be refactored to not build sale orders
|
||||||
|
# to invoice, just create invoices directly.
|
||||||
|
|
||||||
|
|
||||||
class TestCommission(common.TransactionCase):
|
class TestCommission(common.TransactionCase):
|
||||||
|
# TODO refactor tests to not require sale.order
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|||||||
@@ -118,15 +118,15 @@
|
|||||||
|
|
||||||
<menuitem
|
<menuitem
|
||||||
action="action_hr_commission"
|
action="action_hr_commission"
|
||||||
id="menu_action_sales_commission_form"
|
id="menu_action_account_commission_root"
|
||||||
parent="sale.sale_menu_root"
|
parent="account.menu_finance_receivables"
|
||||||
sequence="5"
|
sequence="5"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<menuitem
|
<menuitem
|
||||||
action="action_hr_commission"
|
action="action_hr_commission"
|
||||||
id="menu_action_sales_commission_form2"
|
id="menu_action_account_commission_form"
|
||||||
parent="menu_action_sales_commission_form"
|
parent="menu_action_account_commission_root"
|
||||||
sequence="5"
|
sequence="5"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -223,8 +223,8 @@ action = records.action_mark_paid()
|
|||||||
|
|
||||||
<menuitem
|
<menuitem
|
||||||
action="action_hr_commission_payment"
|
action="action_hr_commission_payment"
|
||||||
id="menu_action_sales_commission_payment_form"
|
id="menu_action_account_commission_payment_form"
|
||||||
parent="menu_action_sales_commission_form"
|
parent="menu_action_account_commission_root"
|
||||||
sequence="10"
|
sequence="10"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
1
hr_payroll_batch_error_skip/__init__.py
Executable file
1
hr_payroll_batch_error_skip/__init__.py
Executable file
@@ -0,0 +1 @@
|
|||||||
|
from . import wizard
|
||||||
17
hr_payroll_batch_error_skip/__manifest__.py
Executable file
17
hr_payroll_batch_error_skip/__manifest__.py
Executable file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
'name': 'Payroll Batch Work Entry Errork SKIP',
|
||||||
|
'description': 'This module bypasses a blocking error on payroll batch runs. '
|
||||||
|
'If your business does not depend on the stock functionality '
|
||||||
|
'(e.g. you use Timesheet and salary but not the stock work schedule '
|
||||||
|
'calculations), this will alleviate your blocking issues.',
|
||||||
|
'version': '13.0.1.0.0',
|
||||||
|
'website': 'https://hibou.io/',
|
||||||
|
'author': 'Hibou Corp. <hello@hibou.io>',
|
||||||
|
'license': 'AGPL-3',
|
||||||
|
'category': 'Human Resources',
|
||||||
|
'data': [
|
||||||
|
],
|
||||||
|
'depends': [
|
||||||
|
'hr_payroll',
|
||||||
|
],
|
||||||
|
}
|
||||||
1
hr_payroll_batch_error_skip/wizard/__init__.py
Normal file
1
hr_payroll_batch_error_skip/wizard/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import hr_payroll_payslips_by_employees
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import logging
|
||||||
|
from odoo import models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class HrPayslipEmployees(models.TransientModel):
|
||||||
|
_inherit = 'hr.payslip.employees'
|
||||||
|
|
||||||
|
def _check_undefined_slots(self, work_entries, payslip_run):
|
||||||
|
try:
|
||||||
|
super()._check_undefined_slots(work_entries, payslip_run)
|
||||||
|
except UserError as e:
|
||||||
|
_logger.info('Caught user error when checking for undefined slots: ' + str(e))
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
'name': 'Payroll Overtime',
|
'name': 'Payroll Overtime',
|
||||||
'description': 'Provide mechanisms to calculate overtime.',
|
'description': 'Provide mechanisms to calculate overtime.',
|
||||||
'version': '13.0.1.0.0',
|
'version': '13.0.1.0.1',
|
||||||
'website': 'https://hibou.io/',
|
'website': 'https://hibou.io/',
|
||||||
'author': 'Hibou Corp. <hello@hibou.io>',
|
'author': 'Hibou Corp. <hello@hibou.io>',
|
||||||
'license': 'AGPL-3',
|
'license': 'AGPL-3',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from odoo import models
|
from odoo import models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
class HRPayslip(models.Model):
|
class HRPayslip(models.Model):
|
||||||
@@ -30,77 +31,66 @@ class HRPayslip(models.Model):
|
|||||||
day_hours = defaultdict(float)
|
day_hours = defaultdict(float)
|
||||||
week_hours = defaultdict(float)
|
week_hours = defaultdict(float)
|
||||||
iso_days = set()
|
iso_days = set()
|
||||||
|
try:
|
||||||
for iso_date, entries in work_data:
|
for iso_date, entries in work_data:
|
||||||
iso_date = _adjust_week(iso_date)
|
iso_date = _adjust_week(iso_date)
|
||||||
week = iso_date[1]
|
|
||||||
for work_type, hours, _ in entries:
|
for work_type, hours, _ in entries:
|
||||||
|
self._aggregate_overtime_add_work_type_hours(work_type, hours, iso_date, result, iso_days, day_hours, week_hours)
|
||||||
|
except RecursionError:
|
||||||
|
raise UserError('RecursionError raised. Ensure you have not overtime loops, you should have an '
|
||||||
|
'end work type that does not have any "overtime" version, and would be considered '
|
||||||
|
'the "highest overtime" work type and rate.')
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _aggregate_overtime_add_work_type_hours(self, work_type, hours, iso_date, working_aggregation, iso_days, day_hours, week_hours):
|
||||||
|
"""
|
||||||
|
:param work_type: work type of hours being added
|
||||||
|
:param hours: hours being added
|
||||||
|
:param iso_date: date hours were worked
|
||||||
|
:param working_aggregation: dict of work type hours as they are processed
|
||||||
|
:param iso_days: set of iso days already seen
|
||||||
|
:param day_hours: hours worked on iso dates already processed
|
||||||
|
:param week_hours: hours worked on iso week already processed
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
week = iso_date[1]
|
||||||
if work_type.overtime_work_type_id and work_type.overtime_type_id:
|
if work_type.overtime_work_type_id and work_type.overtime_type_id:
|
||||||
ot_h_w = work_type.overtime_type_id.hours_per_week
|
ot_h_w = work_type.overtime_type_id.hours_per_week
|
||||||
ot_h_d = work_type.overtime_type_id.hours_per_day
|
ot_h_d = work_type.overtime_type_id.hours_per_day
|
||||||
|
|
||||||
|
regular_hours = hours
|
||||||
|
# adjust the hours based on overtime conditions
|
||||||
if ot_h_d and (day_hours[iso_date] + hours) > ot_h_d:
|
if ot_h_d and (day_hours[iso_date] + hours) > ot_h_d:
|
||||||
if day_hours[iso_date] >= ot_h_d:
|
# daily overtime in effect
|
||||||
# no time is regular time
|
remaining_hours = max(ot_h_d - day_hours[iso_date], 0.0)
|
||||||
if iso_date not in iso_days:
|
regular_hours = min(remaining_hours, hours)
|
||||||
iso_days.add(iso_date)
|
|
||||||
result[work_type.overtime_work_type_id][0] += 1.0
|
|
||||||
result[work_type.overtime_work_type_id][1] += hours
|
|
||||||
result[work_type.overtime_work_type_id][2] = work_type.overtime_type_id.multiplier
|
|
||||||
else:
|
|
||||||
remaining_regular_hours = ot_h_d - day_hours[iso_date]
|
|
||||||
if remaining_regular_hours - hours < 0.0:
|
|
||||||
# some time is regular time
|
|
||||||
regular_hours = remaining_regular_hours
|
|
||||||
overtime_hours = hours - remaining_regular_hours
|
|
||||||
if iso_date not in iso_days:
|
|
||||||
iso_days.add(iso_date)
|
|
||||||
result[work_type][0] += 1.0
|
|
||||||
result[work_type][1] += regular_hours
|
|
||||||
result[work_type.overtime_work_type_id][1] += overtime_hours
|
|
||||||
result[work_type.overtime_work_type_id][2] = work_type.overtime_type_id.multiplier
|
|
||||||
else:
|
|
||||||
# all time is regular time
|
|
||||||
if iso_date not in iso_days:
|
|
||||||
iso_days.add(iso_date)
|
|
||||||
result[work_type][0] += 1.0
|
|
||||||
result[work_type][1] += hours
|
|
||||||
elif ot_h_w:
|
elif ot_h_w:
|
||||||
if week_hours[week] > ot_h_w:
|
# not daily, but weekly limits....
|
||||||
# no time is regular time
|
remaining_hours = max(ot_h_w - week_hours[week], 0.0)
|
||||||
|
regular_hours = min(remaining_hours, hours)
|
||||||
|
ot_hours = hours - regular_hours
|
||||||
|
if regular_hours:
|
||||||
if iso_date not in iso_days:
|
if iso_date not in iso_days:
|
||||||
iso_days.add(iso_date)
|
iso_days.add(iso_date)
|
||||||
result[work_type.overtime_work_type_id][0] += 1.0
|
working_aggregation[work_type][0] += 1.0
|
||||||
result[work_type.overtime_work_type_id][1] += hours
|
working_aggregation[work_type][1] += regular_hours
|
||||||
result[work_type.overtime_work_type_id][2] = work_type.overtime_type_id.multiplier
|
day_hours[iso_date] += regular_hours
|
||||||
|
week_hours[week] += regular_hours
|
||||||
|
if ot_hours:
|
||||||
|
# we need to save this because it won't be set once it reenter, we won't know what the original
|
||||||
|
# overtime multiplier was
|
||||||
|
working_aggregation[work_type.overtime_work_type_id][2] = work_type.overtime_type_id.multiplier
|
||||||
|
if work_type == work_type.overtime_work_type_id:
|
||||||
|
# trivial infinite recursion
|
||||||
|
raise UserError('Work type %s (id %s) must not have itself as its next overtime type.' % (work_type.name, work_type.id))
|
||||||
|
self._aggregate_overtime_add_work_type_hours(work_type.overtime_work_type_id, ot_hours, iso_date,
|
||||||
|
working_aggregation, iso_days, day_hours, week_hours)
|
||||||
else:
|
else:
|
||||||
remaining_regular_hours = ot_h_w - week_hours[week]
|
# No overtime, just needs added to set
|
||||||
if remaining_regular_hours - hours < 0.0:
|
|
||||||
# some time is regular time
|
|
||||||
regular_hours = remaining_regular_hours
|
|
||||||
overtime_hours = hours - remaining_regular_hours
|
|
||||||
if iso_date not in iso_days:
|
if iso_date not in iso_days:
|
||||||
iso_days.add(iso_date)
|
iso_days.add(iso_date)
|
||||||
result[work_type][0] += 1.0
|
working_aggregation[work_type][0] += 1.0
|
||||||
result[work_type][1] += regular_hours
|
working_aggregation[work_type][1] += hours
|
||||||
result[work_type.overtime_work_type_id][1] += overtime_hours
|
|
||||||
result[work_type.overtime_work_type_id][2] = work_type.overtime_type_id.multiplier
|
|
||||||
else:
|
|
||||||
# all time is regular time
|
|
||||||
if iso_date not in iso_days:
|
|
||||||
iso_days.add(iso_date)
|
|
||||||
result[work_type][0] += 1.0
|
|
||||||
result[work_type][1] += hours
|
|
||||||
else:
|
|
||||||
# all time is regular time
|
|
||||||
if iso_date not in iso_days:
|
|
||||||
iso_days.add(iso_date)
|
|
||||||
result[work_type][0] += 1.0
|
|
||||||
result[work_type][1] += hours
|
|
||||||
else:
|
|
||||||
if iso_date not in iso_days:
|
|
||||||
iso_days.add(iso_date)
|
|
||||||
result[work_type][0] += 1.0
|
|
||||||
result[work_type][1] += hours
|
|
||||||
# Always
|
|
||||||
day_hours[iso_date] += hours
|
day_hours[iso_date] += hours
|
||||||
week_hours[week] += hours
|
week_hours[week] += hours
|
||||||
return result
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from odoo.tests import common
|
from odoo.tests import common
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
class TestOvertime(common.TransactionCase):
|
class TestOvertime(common.TransactionCase):
|
||||||
@@ -269,3 +270,93 @@ class TestOvertime(common.TransactionCase):
|
|||||||
def test_11_overtime_aggregation_daily_week_start(self):
|
def test_11_overtime_aggregation_daily_week_start(self):
|
||||||
self.employee.resource_calendar_id.day_week_start = '7'
|
self.employee.resource_calendar_id.day_week_start = '7'
|
||||||
self.test_10_overtime_aggregation_daily()
|
self.test_10_overtime_aggregation_daily()
|
||||||
|
|
||||||
|
def test_12_recursive_daily(self):
|
||||||
|
# recursive will use a second overtime
|
||||||
|
self.work_type_overtime2 = self.env['hr.work.entry.type'].create({
|
||||||
|
'name': 'Test Overtime 2',
|
||||||
|
'code': 'TEST_OT2'
|
||||||
|
})
|
||||||
|
self.overtime_rules2 = self.env['hr.work.entry.overtime.type'].create({
|
||||||
|
'name': 'Test2',
|
||||||
|
'hours_per_week': 999.0,
|
||||||
|
'hours_per_day': 12.0,
|
||||||
|
'multiplier': 2.0,
|
||||||
|
})
|
||||||
|
self.overtime_rules.hours_per_day = 8.0
|
||||||
|
self.overtime_rules.multiplier_per_day = 1.5
|
||||||
|
self.work_type_overtime.overtime_type_id = self.overtime_rules2
|
||||||
|
self.work_type_overtime.overtime_work_type_id = self.work_type_overtime2
|
||||||
|
|
||||||
|
work_data = [
|
||||||
|
((2020, 24, 1), [
|
||||||
|
# regular day
|
||||||
|
(self.work_type, 4.0, None),
|
||||||
|
(self.work_type, 4.0, None),
|
||||||
|
]),
|
||||||
|
((2020, 24, 2), [
|
||||||
|
# 2hr overtime
|
||||||
|
(self.work_type, 4.0, None),
|
||||||
|
(self.work_type, 6.0, None),
|
||||||
|
]),
|
||||||
|
((2020, 24, 3), [
|
||||||
|
# 4hr overtime
|
||||||
|
(self.work_type, 6.0, None),
|
||||||
|
(self.work_type, 6.0, None),
|
||||||
|
]),
|
||||||
|
((2020, 24, 4), [
|
||||||
|
# 4hr overtime
|
||||||
|
# 2hr overtime2
|
||||||
|
(self.work_type, 6.0, None),
|
||||||
|
(self.work_type, 8.0, None),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
result_data = self.payslip.aggregate_overtime(work_data)
|
||||||
|
self.assertTrue(self.work_type in result_data)
|
||||||
|
self.assertEqual(result_data[self.work_type][0], 4)
|
||||||
|
self.assertEqual(result_data[self.work_type][1], 32.0)
|
||||||
|
self.assertTrue(self.work_type_overtime in result_data)
|
||||||
|
self.assertEqual(result_data[self.work_type_overtime][0], 0)
|
||||||
|
self.assertEqual(result_data[self.work_type_overtime][1], 10.0)
|
||||||
|
self.assertTrue(self.work_type_overtime2 in result_data)
|
||||||
|
self.assertEqual(result_data[self.work_type_overtime2][0], 0)
|
||||||
|
self.assertEqual(result_data[self.work_type_overtime2][1], 2.0)
|
||||||
|
|
||||||
|
def test_13_recursive_infinite_trivial(self):
|
||||||
|
# recursive should will use a second overtime, but not this time!
|
||||||
|
self.overtime_rules.hours_per_day = 8.0
|
||||||
|
self.overtime_rules.multiplier_per_day = 1.5
|
||||||
|
self.work_type.overtime_type_id = self.overtime_rules
|
||||||
|
# overtime goes to itself
|
||||||
|
self.work_type.overtime_work_type_id = self.work_type
|
||||||
|
|
||||||
|
work_data = [
|
||||||
|
((2020, 24, 2), [
|
||||||
|
# 2hr overtime
|
||||||
|
(self.work_type, 4.0, None),
|
||||||
|
(self.work_type, 6.0, None),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
result_data = self.payslip.aggregate_overtime(work_data)
|
||||||
|
|
||||||
|
def test_14_recursive_infinite_loop(self):
|
||||||
|
# recursive will use a second overtime, but not this time!
|
||||||
|
self.overtime_rules.hours_per_day = 8.0
|
||||||
|
self.overtime_rules.multiplier_per_day = 1.5
|
||||||
|
self.work_type_overtime.overtime_type_id = self.overtime_rules
|
||||||
|
# overtime goes back to worktype
|
||||||
|
self.work_type_overtime.overtime_work_type_id = self.work_type
|
||||||
|
|
||||||
|
work_data = [
|
||||||
|
((2020, 24, 2), [
|
||||||
|
# 2hr overtime
|
||||||
|
(self.work_type, 4.0, None),
|
||||||
|
(self.work_type, 6.0, None),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
result_data = self.payslip.aggregate_overtime(work_data)
|
||||||
|
|||||||
@@ -9,8 +9,12 @@
|
|||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<xpath expr="//group[@name='main_group']" position="after">
|
<xpath expr="//group[@name='main_group']" position="after">
|
||||||
<group name="overtime_group">
|
<group name="overtime_group">
|
||||||
<field name="overtime_work_type_id" attrs="{'required': [('overtime_type_id', '!=', False)]}"/>
|
<field name="overtime_work_type_id"
|
||||||
<field name="overtime_type_id" attrs="{'required': [('overtime_work_type_id', '!=', False)]}"/>
|
domain="[('id', '!=', id)]"
|
||||||
|
attrs="{'required': [('overtime_type_id', '!=', False)]}" />
|
||||||
|
<field name="overtime_type_id"
|
||||||
|
domain="[('id', '!=', id)]"
|
||||||
|
attrs="{'required': [('overtime_work_type_id', '!=', False)]}" />
|
||||||
</group>
|
</group>
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
|
|||||||
1
logging_json
Symbolic link
1
logging_json
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
./external/camptocamp-cloud-platform/logging_json
|
||||||
@@ -16,6 +16,7 @@ Create preventative maintenance requests based on usage.
|
|||||||
'website': 'https://hibou.io/',
|
'website': 'https://hibou.io/',
|
||||||
'depends': [
|
'depends': [
|
||||||
'hr_maintenance',
|
'hr_maintenance',
|
||||||
|
'product',
|
||||||
],
|
],
|
||||||
'data': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
|
"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
|
||||||
"manage_maintenance_usage_log","manage maintenance.usage.log","model_maintenance_usage_log","stock.group_stock_manager",1,1,1,1
|
"manage_maintenance_usage_log","manage maintenance.usage.log","model_maintenance_usage_log","maintenance.group_equipment_manager",1,1,1,1
|
||||||
"access_maintenance_usage_log","access maintenance.usage.log","model_maintenance_usage_log","base.group_user",1,0,1,0
|
"access_maintenance_usage_log","access maintenance.usage.log","model_maintenance_usage_log","base.group_user",1,0,1,0
|
||||||
|
1
monitoring_log_requests
Symbolic link
1
monitoring_log_requests
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
./external/camptocamp-cloud-platform/monitoring_log_requests
|
||||||
1
monitoring_statsd
Symbolic link
1
monitoring_statsd
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
./external/camptocamp-cloud-platform/monitoring_statsd
|
||||||
1
monitoring_status
Symbolic link
1
monitoring_status
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
./external/camptocamp-cloud-platform/monitoring_status
|
||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Hibou RMAs',
|
'name': 'Hibou RMAs',
|
||||||
'version': '13.0.1.2.0',
|
'version': '13.0.1.3.0',
|
||||||
'category': 'Warehouse',
|
'category': 'Warehouse',
|
||||||
'author': 'Hibou Corp.',
|
'author': 'Hibou Corp.',
|
||||||
'license': 'OPL-1',
|
'license': 'OPL-1',
|
||||||
'website': 'https://hibou.io/',
|
'website': 'https://hibou.io/',
|
||||||
'depends': [
|
'depends': [
|
||||||
|
'hibou_professional',
|
||||||
'stock',
|
'stock',
|
||||||
'delivery',
|
'delivery',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -42,4 +42,15 @@
|
|||||||
<field name="in_procure_method">make_to_stock</field>
|
<field name="in_procure_method">make_to_stock</field>
|
||||||
<field name="in_require_return" eval="True"/>
|
<field name="in_require_return" eval="True"/>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<record id="template_rtv" model="rma.template">
|
||||||
|
<field name="name">Return To Vendor</field>
|
||||||
|
<field name="create_out_picking" eval="True"/>
|
||||||
|
<field name="out_type_id" ref="stock.picking_type_out"/>
|
||||||
|
<field name="out_location_id" ref="stock.stock_location_stock"/>
|
||||||
|
<field name="out_location_dest_id" ref="stock.stock_location_suppliers"/>
|
||||||
|
<field name="out_procure_method">make_to_stock</field>
|
||||||
|
<field name="invoice_done" eval="True"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
</odoo>
|
</odoo>
|
||||||
@@ -52,6 +52,7 @@ class RMATemplate(models.Model):
|
|||||||
company_id = fields.Many2one('res.company', 'Company')
|
company_id = fields.Many2one('res.company', 'Company')
|
||||||
responsible_user_ids = fields.Many2many('res.users', string='Responsible Users',
|
responsible_user_ids = fields.Many2many('res.users', string='Responsible Users',
|
||||||
help='Users that get activities when creating RMA.')
|
help='Users that get activities when creating RMA.')
|
||||||
|
next_rma_template_id = fields.Many2one('rma.template', string='Next RMA Template')
|
||||||
|
|
||||||
def _portal_try_create(self, request_user, res_id, **kw):
|
def _portal_try_create(self, request_user, res_id, **kw):
|
||||||
if self.usage == 'stock_picking':
|
if self.usage == 'stock_picking':
|
||||||
@@ -201,6 +202,7 @@ class RMA(models.Model):
|
|||||||
('cancel', 'Cancelled'),
|
('cancel', 'Cancelled'),
|
||||||
], string='State', default='draft', copy=False)
|
], string='State', default='draft', copy=False)
|
||||||
company_id = fields.Many2one('res.company', 'Company')
|
company_id = fields.Many2one('res.company', 'Company')
|
||||||
|
parent_id = fields.Many2one('rma.rma')
|
||||||
template_id = fields.Many2one('rma.template', string='Type', required=True)
|
template_id = fields.Many2one('rma.template', string='Type', required=True)
|
||||||
template_create_in_picking = fields.Boolean(related='template_id.create_in_picking')
|
template_create_in_picking = fields.Boolean(related='template_id.create_in_picking')
|
||||||
template_create_out_picking = fields.Boolean(related='template_id.create_out_picking')
|
template_create_out_picking = fields.Boolean(related='template_id.create_out_picking')
|
||||||
@@ -215,6 +217,7 @@ class RMA(models.Model):
|
|||||||
customer_description = fields.Html(string='Customer Instructions', related='template_id.customer_description')
|
customer_description = fields.Html(string='Customer Instructions', related='template_id.customer_description')
|
||||||
template_usage = fields.Selection(string='Template Usage', related='template_id.usage')
|
template_usage = fields.Selection(string='Template Usage', related='template_id.usage')
|
||||||
validity_date = fields.Datetime(string='Expiration Date')
|
validity_date = fields.Datetime(string='Expiration Date')
|
||||||
|
claim_number = fields.Char(string='Claim Number')
|
||||||
invoice_ids = fields.Many2many('account.move',
|
invoice_ids = fields.Many2many('account.move',
|
||||||
'rma_invoice_rel',
|
'rma_invoice_rel',
|
||||||
'rma_id',
|
'rma_id',
|
||||||
@@ -363,6 +366,26 @@ class RMA(models.Model):
|
|||||||
'in_picking_id': in_picking_id.id if in_picking_id else False,
|
'in_picking_id': in_picking_id.id if in_picking_id else False,
|
||||||
'out_picking_id': out_picking_id.id if out_picking_id else False})
|
'out_picking_id': out_picking_id.id if out_picking_id else False})
|
||||||
|
|
||||||
|
def _next_rma_values(self):
|
||||||
|
return {
|
||||||
|
'template_id': self.template_id.next_rma_template_id.id,
|
||||||
|
# Partners should be set when confirming or using the RTV wizard
|
||||||
|
# 'partner_id': self.partner_id.id,
|
||||||
|
# 'partner_shipping_id': self.partner_shipping_id.id,
|
||||||
|
'parent_id': self.id,
|
||||||
|
'lines': [(0, 0, {
|
||||||
|
'product_id': l.product_id.id,
|
||||||
|
'product_uom_id': l.product_uom_id.id,
|
||||||
|
'product_uom_qty': l.product_uom_qty,
|
||||||
|
}) for l in self.lines]
|
||||||
|
}
|
||||||
|
|
||||||
|
def _next_rma(self):
|
||||||
|
if self.template_id.next_rma_template_id:
|
||||||
|
# currently we do not want to automatically confirm them
|
||||||
|
# this is because we want to mass confirm and set picking to one partner/vendor
|
||||||
|
_ = self.create(self._next_rma_values())
|
||||||
|
|
||||||
def action_done(self):
|
def action_done(self):
|
||||||
for rma in self:
|
for rma in self:
|
||||||
if rma.in_picking_id and rma.in_picking_id.state not in ('done', 'cancel'):
|
if rma.in_picking_id and rma.in_picking_id.state not in ('done', 'cancel'):
|
||||||
@@ -370,6 +393,67 @@ class RMA(models.Model):
|
|||||||
if rma.out_picking_id and rma.out_picking_id.state not in ('done', 'cancel'):
|
if rma.out_picking_id and rma.out_picking_id.state not in ('done', 'cancel'):
|
||||||
raise UserError(_('Outbound picking not complete or cancelled.'))
|
raise UserError(_('Outbound picking not complete or cancelled.'))
|
||||||
self.write({'state': 'done'})
|
self.write({'state': 'done'})
|
||||||
|
self._done_invoice()
|
||||||
|
self._next_rma()
|
||||||
|
|
||||||
|
def _done_invoice(self):
|
||||||
|
for rma in self.filtered(lambda r: r.template_id.invoice_done):
|
||||||
|
# If you do NOT want to take part in the default invoicing functionality
|
||||||
|
# then your usage method (e.g. _invoice_values_sale_order) should be
|
||||||
|
# defined, and return nothing or extend _invoice_values to do the same
|
||||||
|
usage = rma.template_usage or ''
|
||||||
|
if hasattr(rma, '_invoice_values_' + usage):
|
||||||
|
values = getattr(rma, '_invoice_values_' + usage)()
|
||||||
|
else:
|
||||||
|
values = rma._invoice_values()
|
||||||
|
if values:
|
||||||
|
if hasattr(rma, '_invoice_' + usage):
|
||||||
|
getattr(rma, '_invoice_' + usage)(values)
|
||||||
|
else:
|
||||||
|
rma._invoice(values)
|
||||||
|
|
||||||
|
def _invoice(self, invoice_values):
|
||||||
|
self.invoice_ids += self.env['account.move'].with_context(default_type=invoice_values['type']).create(
|
||||||
|
invoice_values)
|
||||||
|
|
||||||
|
def _invoice_values(self):
|
||||||
|
self.ensure_one()
|
||||||
|
# special case for vendor return
|
||||||
|
supplier = self._context.get('rma_supplier')
|
||||||
|
if supplier is None and self.out_picking_id and self.out_picking_id.location_dest_id.usage == 'supplier':
|
||||||
|
supplier = True
|
||||||
|
|
||||||
|
fiscal_position_id = self.env['account.fiscal.position'].get_fiscal_position(
|
||||||
|
self.partner_id.id, delivery_id=self.partner_shipping_id.id)
|
||||||
|
|
||||||
|
invoice_values = {
|
||||||
|
'type': 'in_refund' if supplier else 'out_refund',
|
||||||
|
'partner_id': self.partner_id.id,
|
||||||
|
'fiscal_position_id': fiscal_position_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
line_commands = []
|
||||||
|
for rma_line in self.lines:
|
||||||
|
product = rma_line.product_id
|
||||||
|
accounts = product.product_tmpl_id.get_product_accounts()
|
||||||
|
account = accounts['expense'] if supplier else accounts['income']
|
||||||
|
qty = rma_line.product_uom_qty
|
||||||
|
uom = rma_line.product_uom_id
|
||||||
|
price = product.standard_price if supplier else product.lst_price
|
||||||
|
if uom != product.uom_id:
|
||||||
|
price = product.uom_id._compute_price(price, uom)
|
||||||
|
line_commands.append((0, 0, {
|
||||||
|
'product_id': product.id,
|
||||||
|
'product_uom_id': uom.id,
|
||||||
|
'name': product.name,
|
||||||
|
'price_unit': price,
|
||||||
|
'quantity': qty,
|
||||||
|
'account_id': account.id,
|
||||||
|
'tax_ids': [(6, 0, product.taxes_id.ids)],
|
||||||
|
}))
|
||||||
|
if line_commands:
|
||||||
|
invoice_values['invoice_line_ids'] = line_commands
|
||||||
|
return invoice_values
|
||||||
|
|
||||||
def action_cancel(self):
|
def action_cancel(self):
|
||||||
for rma in self:
|
for rma in self:
|
||||||
@@ -382,12 +466,18 @@ class RMA(models.Model):
|
|||||||
'state': 'draft', 'in_picking_id': False, 'out_picking_id': False})
|
'state': 'draft', 'in_picking_id': False, 'out_picking_id': False})
|
||||||
|
|
||||||
def _create_in_picking(self):
|
def _create_in_picking(self):
|
||||||
|
if self._context.get('rma_in_picking_id'):
|
||||||
|
# allow passing/setting by context to allow many RMA's to include the same pickings
|
||||||
|
return self.env['stock.picking'].browse(self._context.get('rma_in_picking_id'))
|
||||||
if self.template_usage and hasattr(self, '_create_in_picking_' + self.template_usage):
|
if self.template_usage and hasattr(self, '_create_in_picking_' + self.template_usage):
|
||||||
return getattr(self, '_create_in_picking_' + self.template_usage)()
|
return getattr(self, '_create_in_picking_' + self.template_usage)()
|
||||||
values = self.template_id._values_for_in_picking(self)
|
values = self.template_id._values_for_in_picking(self)
|
||||||
return self.env['stock.picking'].sudo().create(values)
|
return self.env['stock.picking'].sudo().create(values)
|
||||||
|
|
||||||
def _create_out_picking(self):
|
def _create_out_picking(self):
|
||||||
|
if self._context.get('rma_out_picking_id'):
|
||||||
|
# allow passing/setting by context to allow many RMA's to include the same pickings
|
||||||
|
return self.env['stock.picking'].browse(self._context.get('rma_out_picking_id'))
|
||||||
if self.template_usage and hasattr(self, '_create_out_picking_' + self.template_usage):
|
if self.template_usage and hasattr(self, '_create_out_picking_' + self.template_usage):
|
||||||
return getattr(self, '_create_out_picking_' + self.template_usage)()
|
return getattr(self, '_create_out_picking_' + self.template_usage)()
|
||||||
values = self.template_id._values_for_out_picking(self)
|
values = self.template_id._values_for_out_picking(self)
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ class TestRMA(common.TransactionCase):
|
|||||||
self.product1 = self.env.ref('product.product_product_24')
|
self.product1 = self.env.ref('product.product_product_24')
|
||||||
self.template_missing = self.env.ref('rma.template_missing_item')
|
self.template_missing = self.env.ref('rma.template_missing_item')
|
||||||
self.template_return = self.env.ref('rma.template_picking_return')
|
self.template_return = self.env.ref('rma.template_picking_return')
|
||||||
|
self.template_rtv = self.env.ref('rma.template_rtv')
|
||||||
self.partner1 = self.env.ref('base.res_partner_2')
|
self.partner1 = self.env.ref('base.res_partner_2')
|
||||||
self.user1 = self.env.ref('base.user_demo')
|
self.user1 = self.env.ref('base.user_demo')
|
||||||
|
# Additional partner in tests or vendor in Return To Vendor
|
||||||
|
self.partner2 = self.env.ref('base.res_partner_12')
|
||||||
|
|
||||||
def test_00_basic_rma(self):
|
def test_00_basic_rma(self):
|
||||||
self.template_missing.responsible_user_ids += self.user1
|
self.template_missing.responsible_user_ids += self.user1
|
||||||
@@ -244,3 +247,62 @@ class TestRMA(common.TransactionCase):
|
|||||||
# RMA cannot be completed because the inbound picking state is confirmed
|
# RMA cannot be completed because the inbound picking state is confirmed
|
||||||
with self.assertRaises(UserError):
|
with self.assertRaises(UserError):
|
||||||
rma2.action_done()
|
rma2.action_done()
|
||||||
|
|
||||||
|
def test_30_next_rma_rtv(self):
|
||||||
|
self.template_return.usage = False
|
||||||
|
self.template_return.in_require_return = False
|
||||||
|
self.template_return.next_rma_template_id = self.template_rtv
|
||||||
|
rma = self.env['rma.rma'].create({
|
||||||
|
'template_id': self.template_return.id,
|
||||||
|
'partner_id': self.partner1.id,
|
||||||
|
'partner_shipping_id': self.partner1.id,
|
||||||
|
})
|
||||||
|
self.assertEqual(rma.state, 'draft')
|
||||||
|
rma_line = self.env['rma.line'].create({
|
||||||
|
'rma_id': rma.id,
|
||||||
|
'product_id': self.product1.id,
|
||||||
|
'product_uom_id': self.product1.uom_id.id,
|
||||||
|
'product_uom_qty': 2.0,
|
||||||
|
})
|
||||||
|
rma.action_confirm()
|
||||||
|
# Should have made pickings
|
||||||
|
self.assertEqual(rma.state, 'confirmed')
|
||||||
|
|
||||||
|
# No outbound picking
|
||||||
|
self.assertFalse(rma.out_picking_id)
|
||||||
|
# Good inbound picking
|
||||||
|
self.assertTrue(rma.in_picking_id)
|
||||||
|
self.assertEqual(rma_line.product_id, rma.in_picking_id.move_lines.product_id)
|
||||||
|
self.assertEqual(rma_line.product_uom_qty, rma.in_picking_id.move_lines.product_uom_qty)
|
||||||
|
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
rma.action_done()
|
||||||
|
|
||||||
|
rma.in_picking_id.move_lines.quantity_done = 2.0
|
||||||
|
rma.in_picking_id.action_done()
|
||||||
|
rma.action_done()
|
||||||
|
self.assertEqual(rma.state, 'done')
|
||||||
|
|
||||||
|
# RTV RMA
|
||||||
|
rma_rtv = self.env['rma.rma'].search([('parent_id', '=', rma.id)])
|
||||||
|
self.assertTrue(rma_rtv)
|
||||||
|
self.assertEqual(rma_rtv.state, 'draft')
|
||||||
|
|
||||||
|
wiz = self.env['rma.make.rtv'].with_context(active_model='rma.rma', active_ids=rma_rtv.ids).create({})
|
||||||
|
self.assertTrue(wiz.rma_line_ids)
|
||||||
|
wiz.partner_id = self.partner2
|
||||||
|
wiz.create_batch()
|
||||||
|
self.assertTrue(rma_rtv.out_picking_id)
|
||||||
|
self.assertEqual(rma_rtv.out_picking_id.partner_id, self.partner2)
|
||||||
|
self.assertEqual(rma_rtv.state, 'confirmed')
|
||||||
|
|
||||||
|
# ship and finish
|
||||||
|
rma_rtv.out_picking_id.move_lines.quantity_done = 2.0
|
||||||
|
rma_rtv.out_picking_id.action_done()
|
||||||
|
rma_rtv.action_done()
|
||||||
|
self.assertEqual(rma_rtv.state, 'done')
|
||||||
|
|
||||||
|
# ensure invoice and type
|
||||||
|
rtv_invoice = rma_rtv.invoice_ids
|
||||||
|
self.assertTrue(rtv_invoice)
|
||||||
|
self.assertEqual(rtv_invoice.type, 'in_refund')
|
||||||
|
|||||||
@@ -138,8 +138,8 @@
|
|||||||
<t t-foreach="rma.lines" t-as="line">
|
<t t-foreach="rma.lines" t-as="line">
|
||||||
<div class="row purchases_vertical_align">
|
<div class="row purchases_vertical_align">
|
||||||
<div class="col-lg-3 text-center">
|
<div class="col-lg-3 text-center">
|
||||||
<img t-attf-src="/web/image/product.product/#{line.product_id.id}/image_64"
|
<img class="mr4 float-left o_portal_product_img" t-if="line.product_id.image_128" t-att-src="image_data_uri(line.product_id.image_128)" alt="Product Image" width="64"/>
|
||||||
width="64" alt="Product image"></img>
|
<img class="mr4 float-left o_portal_product_img" t-if="not line.product_id.image_128" src="/web/static/src/img/placeholder.png" alt="Product Image" width="64"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-7">
|
<div class="col-lg-7">
|
||||||
<span t-esc="line.product_id.name"/>
|
<span t-esc="line.product_id.name"/>
|
||||||
@@ -237,8 +237,8 @@
|
|||||||
<t t-foreach="picking.move_lines" t-as="line">
|
<t t-foreach="picking.move_lines" t-as="line">
|
||||||
<div class="row purchases_vertical_align">
|
<div class="row purchases_vertical_align">
|
||||||
<div class="col-lg-2 text-center">
|
<div class="col-lg-2 text-center">
|
||||||
<img t-attf-src="/web/image/product.product/#{line.product_id.id}/image_64"
|
<img class="mr4 float-left o_portal_product_img" t-if="line.product_id.image_128" t-att-src="image_data_uri(line.product_id.image_128)" alt="Product Image" width="64"/>
|
||||||
width="64" alt="Product image"></img>
|
<img class="mr4 float-left o_portal_product_img" t-if="not line.product_id.image_128" src="/web/static/src/img/placeholder.png" alt="Product Image" width="64"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4">
|
||||||
<span t-esc="line.product_id.name"/>
|
<span t-esc="line.product_id.name"/>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<group>
|
<group>
|
||||||
<group>
|
<group>
|
||||||
|
<field name="parent_id" readonly="1" attrs="{'invisible': [('parent_id', '=', False)]}"/>
|
||||||
<field name="template_usage" invisible="1"/>
|
<field name="template_usage" invisible="1"/>
|
||||||
<field name="template_create_in_picking" invisible="1"/>
|
<field name="template_create_in_picking" invisible="1"/>
|
||||||
<field name="template_create_out_picking" invisible="1"/>
|
<field name="template_create_out_picking" invisible="1"/>
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
<button string="Add lines" type="object" name="action_add_picking_lines" attrs="{'invisible': ['|', ('stock_picking_id', '=', False), ('state', '!=', 'draft')]}"/>
|
<button string="Add lines" type="object" name="action_add_picking_lines" attrs="{'invisible': ['|', ('stock_picking_id', '=', False), ('state', '!=', 'draft')]}"/>
|
||||||
</group>
|
</group>
|
||||||
<group>
|
<group>
|
||||||
|
<field name="claim_number"/>
|
||||||
<field name="validity_date"/>
|
<field name="validity_date"/>
|
||||||
<field name="tag_ids" widget="many2many_tags" placeholder="Tags" options="{'no_create': True}"/>
|
<field name="tag_ids" widget="many2many_tags" placeholder="Tags" options="{'no_create': True}"/>
|
||||||
<field name="partner_id" options="{'no_create_edit': True}" attrs="{'readonly': [('state', '!=', 'draft')]}"/>
|
<field name="partner_id" options="{'no_create_edit': True}" attrs="{'readonly': [('state', '!=', 'draft')]}"/>
|
||||||
@@ -137,6 +139,7 @@
|
|||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<search string="Search RMA">
|
<search string="Search RMA">
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
|
<field name="claim_number"/>
|
||||||
<field name="partner_id"/>
|
<field name="partner_id"/>
|
||||||
<field name="template_id"/>
|
<field name="template_id"/>
|
||||||
<field name="stock_picking_id"/>
|
<field name="stock_picking_id"/>
|
||||||
@@ -162,7 +165,6 @@
|
|||||||
<h1>
|
<h1>
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<group>
|
<group>
|
||||||
<group>
|
<group>
|
||||||
@@ -172,6 +174,7 @@
|
|||||||
<field name="company_id" options="{'no_create': True}"/>
|
<field name="company_id" options="{'no_create': True}"/>
|
||||||
<field name="portal_ok"/>
|
<field name="portal_ok"/>
|
||||||
<field name="invoice_done" help="This feature is implemented in specific RMA types automatically when enabled."/>
|
<field name="invoice_done" help="This feature is implemented in specific RMA types automatically when enabled."/>
|
||||||
|
<field name="next_rma_template_id"/>
|
||||||
</group>
|
</group>
|
||||||
<group>
|
<group>
|
||||||
<field name="responsible_user_ids" domain="[('share', '=', False)]" widget="many2many_tags"/>
|
<field name="responsible_user_ids" domain="[('share', '=', False)]" widget="many2many_tags"/>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||||
|
|
||||||
from . import rma_lines
|
from . import rma_lines
|
||||||
|
from . import rma_make_rtv
|
||||||
|
|||||||
90
rma/wizard/rma_make_rtv.py
Normal file
90
rma/wizard/rma_make_rtv.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
|
||||||
|
class RMAMakeRTV(models.TransientModel):
|
||||||
|
_name = 'rma.make.rtv'
|
||||||
|
_description = 'Make RTV Batch'
|
||||||
|
|
||||||
|
partner_id = fields.Many2one('res.partner', string='Vendor')
|
||||||
|
partner_shipping_id = fields.Many2one('res.partner', string='Shipping Address')
|
||||||
|
rma_line_ids = fields.One2many('rma.make.rtv.line', 'rma_make_rtv_id', string='Lines')
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def default_get(self, fields):
|
||||||
|
result = super().default_get(fields)
|
||||||
|
if 'rma_line_ids' in fields and self._context.get('active_model') == 'rma.rma' and self._context.get('active_ids'):
|
||||||
|
active_ids = self._context.get('active_ids')
|
||||||
|
rmas = self.env['rma.rma'].browse(active_ids)
|
||||||
|
result['rma_line_ids'] = [(0, 0, {
|
||||||
|
'rma_id': r.id,
|
||||||
|
'rma_state': r.state,
|
||||||
|
'rma_claim_number': r.claim_number,
|
||||||
|
}) for r in rmas]
|
||||||
|
rma_partner = rmas.mapped('partner_id')
|
||||||
|
if rma_partner:
|
||||||
|
result['partner_id'] = rma_partner[0].id
|
||||||
|
return result
|
||||||
|
|
||||||
|
def create_batch(self):
|
||||||
|
self.ensure_one()
|
||||||
|
if self.rma_line_ids.filtered(lambda rl: rl.rma_id.state != 'draft'):
|
||||||
|
raise UserError('All RMAs must be in the draft state.')
|
||||||
|
rma_partner = self.rma_line_ids.mapped('rma_id.partner_id')
|
||||||
|
if rma_partner and len(rma_partner) != 1:
|
||||||
|
raise UserError('All RMAs must be for the same partner.')
|
||||||
|
elif not rma_partner and not self.partner_id:
|
||||||
|
raise UserError('Please select a Vendor')
|
||||||
|
elif not rma_partner:
|
||||||
|
rma_partner = self.partner_id
|
||||||
|
rma_partner_shipping = self.partner_shipping_id or rma_partner
|
||||||
|
# update all RMA's to the currently selected vendor
|
||||||
|
self.rma_line_ids.mapped('rma_id').write({
|
||||||
|
'partner_id': rma_partner.id,
|
||||||
|
'partner_shipping_id': rma_partner_shipping.id,
|
||||||
|
})
|
||||||
|
if len(self.rma_line_ids.mapped('rma_id.template_id')) != 1:
|
||||||
|
raise UserError('All RMAs must be of the same template.')
|
||||||
|
|
||||||
|
in_values = None
|
||||||
|
out_values = None
|
||||||
|
for rma in self.rma_line_ids.mapped('rma_id'):
|
||||||
|
if rma.template_id.create_in_picking:
|
||||||
|
if not in_values:
|
||||||
|
in_values = rma.template_id._values_for_in_picking(rma)
|
||||||
|
in_values['origin'] = [in_values['origin']]
|
||||||
|
else:
|
||||||
|
other_in_values = rma.template_id._values_for_in_picking(rma)
|
||||||
|
in_values['move_lines'] += other_in_values['move_lines']
|
||||||
|
if rma.template_id.create_out_picking:
|
||||||
|
if not out_values:
|
||||||
|
out_values = rma.template_id._values_for_out_picking(rma)
|
||||||
|
out_values['origin'] = [out_values['origin']]
|
||||||
|
else:
|
||||||
|
other_out_values = rma.template_id._values_for_out_picking(rma)
|
||||||
|
out_values['move_lines'] += other_out_values['move_lines']
|
||||||
|
in_picking_id = False
|
||||||
|
out_picking_id = False
|
||||||
|
if in_values:
|
||||||
|
in_values['origin'] = ', '.join(in_values['origin'])
|
||||||
|
in_picking = self.env['stock.picking'].sudo().create(in_values)
|
||||||
|
in_picking_id = in_picking.id
|
||||||
|
if out_values:
|
||||||
|
out_values['origin'] = ', '.join(out_values['origin'])
|
||||||
|
out_picking = self.env['stock.picking'].sudo().create(out_values)
|
||||||
|
out_picking_id = out_picking.id
|
||||||
|
rmas = self.rma_line_ids.mapped('rma_id').with_context(rma_in_picking_id=in_picking_id, rma_out_picking_id=out_picking_id)
|
||||||
|
# action_confirm known to be multi-aware and makes only one context
|
||||||
|
rmas.action_confirm()
|
||||||
|
|
||||||
|
|
||||||
|
class RMAMakeRTVLine(models.TransientModel):
|
||||||
|
_name = 'rma.make.rtv.line'
|
||||||
|
_description = 'Make RTV Batch RMA'
|
||||||
|
|
||||||
|
rma_make_rtv_id = fields.Many2one('rma.make.rtv')
|
||||||
|
rma_id = fields.Many2one('rma.rma')
|
||||||
|
rma_state = fields.Selection(related='rma_id.state')
|
||||||
|
rma_claim_number = fields.Char(related='rma_id.claim_number', readonly=False)
|
||||||
41
rma/wizard/rma_make_rtv_views.xml
Normal file
41
rma/wizard/rma_make_rtv_views.xml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_rma_make_rtv" model="ir.ui.view">
|
||||||
|
<field name="name">Return To Vendor</field>
|
||||||
|
<field name="model">rma.make.rtv</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="RMA Make RTV">
|
||||||
|
<p class="oe_grey">
|
||||||
|
RMAs will be batched to pick simultaneously.
|
||||||
|
</p>
|
||||||
|
<group>
|
||||||
|
<field name="partner_id"/>
|
||||||
|
<field name="partner_shipping_id"/>
|
||||||
|
<field name="rma_line_ids" nolabel="1" colspan="4">
|
||||||
|
<tree decoration-warning="rma_state != 'draft'" editable="bottom">
|
||||||
|
<field name="rma_id" readonly="1" force_save="1"/>
|
||||||
|
<field name="rma_state" invisible="1"/>
|
||||||
|
<field name="rma_claim_number"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</group>
|
||||||
|
<footer>
|
||||||
|
<button name="create_batch" string="Confirm" type="object" class="btn-primary"/>
|
||||||
|
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_view_rma_make_rtv" model="ir.actions.act_window">
|
||||||
|
<field name="name">RMA Make RTV</field>
|
||||||
|
<field name="type">ir.actions.act_window</field>
|
||||||
|
<field name="res_model">rma.make.rtv</field>
|
||||||
|
<field name="view_mode">form</field>
|
||||||
|
<field name="target">new</field>
|
||||||
|
<field name="binding_model_id" ref="rma.model_rma_rma" />
|
||||||
|
<field name="binding_view_types">list</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
{
|
{
|
||||||
'name': 'RMA - Product Cores',
|
'name': 'RMA - Product Cores',
|
||||||
'author': 'Hibou Corp. <hello@hibou.io>',
|
'author': 'Hibou Corp. <hello@hibou.io>',
|
||||||
'version': '13.0.1.0.0',
|
'version': '13.0.1.0.1',
|
||||||
'license': 'OPL-1',
|
'license': 'OPL-1',
|
||||||
'category': 'Tools',
|
'category': 'Tools',
|
||||||
'summary': 'RMA Product Cores',
|
'summary': 'RMA Product Cores',
|
||||||
@@ -12,6 +12,7 @@ RMA Product Cores - Return core products from customers.
|
|||||||
""",
|
""",
|
||||||
'website': 'https://hibou.io/',
|
'website': 'https://hibou.io/',
|
||||||
'depends': [
|
'depends': [
|
||||||
|
'hibou_professional',
|
||||||
'product_cores',
|
'product_cores',
|
||||||
'rma_sale',
|
'rma_sale',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -120,6 +120,10 @@ class RMA(models.Model):
|
|||||||
return res2
|
return res2
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
def _invoice_values_product_core_sale(self):
|
||||||
|
# the RMA invoice API will not be used as invoicing will happen at the SO level
|
||||||
|
return False
|
||||||
|
|
||||||
def _get_dirty_core_from_service_line(self, line):
|
def _get_dirty_core_from_service_line(self, line):
|
||||||
original_product_line = line.core_line_id
|
original_product_line = line.core_line_id
|
||||||
return original_product_line.product_id.product_core_id
|
return original_product_line.product_id.product_core_id
|
||||||
|
|||||||
@@ -42,8 +42,8 @@
|
|||||||
<hr/>
|
<hr/>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-1 text-center">
|
<div class="col-lg-1 text-center">
|
||||||
<img t-attf-src="/web/image/product.product/#{product.id}/image_64"
|
<img class="mr4 float-left o_portal_product_img" t-if="product.image_128" t-att-src="image_data_uri(product.image_128)" alt="Product Image" width="64"/>
|
||||||
width="64" alt="Product image"></img>
|
<img class="mr4 float-left o_portal_product_img" t-if="not product.image_128" src="/web/static/src/img/placeholder.png" alt="Product Image" width="64"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-4">
|
<div class="col-lg-4">
|
||||||
<span t-esc="product.name"/>
|
<span t-esc="product.name"/>
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Hibou RMAs for Sale Orders',
|
'name': 'Hibou RMAs for Sale Orders',
|
||||||
'version': '13.0.1.1.0',
|
'version': '13.0.1.2.0',
|
||||||
'category': 'Sale',
|
'category': 'Sale',
|
||||||
'author': 'Hibou Corp.',
|
'author': 'Hibou Corp.',
|
||||||
'license': 'OPL-1',
|
'license': 'OPL-1',
|
||||||
'website': 'https://hibou.io/',
|
'website': 'https://hibou.io/',
|
||||||
'depends': [
|
'depends': [
|
||||||
|
'hibou_professional',
|
||||||
'rma',
|
'rma',
|
||||||
'sale',
|
'sale',
|
||||||
'sales_team',
|
'sales_team',
|
||||||
|
|||||||
@@ -14,3 +14,13 @@ class ProductTemplate(models.Model):
|
|||||||
'A positive number will allow the product to be '
|
'A positive number will allow the product to be '
|
||||||
'returned up to that number of days. A negative '
|
'returned up to that number of days. A negative '
|
||||||
'number prevents the return of the product.')
|
'number prevents the return of the product.')
|
||||||
|
|
||||||
|
rma_sale_warranty_validity = fields.Integer(string='RMA Eligible Days (Sale Warranty)',
|
||||||
|
help='Determines the number of days from the time '
|
||||||
|
'of the sale that the product is eligible to '
|
||||||
|
'be returned for warranty claims. '
|
||||||
|
'0 (default) will allow the product to be '
|
||||||
|
'returned for an indefinite period of time. '
|
||||||
|
'A positive number will allow the product to be '
|
||||||
|
'returned up to that number of days. A negative '
|
||||||
|
'number prevents the return of the product.')
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ class RMATemplate(models.Model):
|
|||||||
_inherit = 'rma.template'
|
_inherit = 'rma.template'
|
||||||
|
|
||||||
usage = fields.Selection(selection_add=[('sale_order', 'Sale Order')])
|
usage = fields.Selection(selection_add=[('sale_order', 'Sale Order')])
|
||||||
|
sale_order_warranty = fields.Boolean(string='Sale Order Warranty',
|
||||||
|
help='Determines if the regular return validity or '
|
||||||
|
'Warranty validity is used.')
|
||||||
so_decrement_order_qty = fields.Boolean(string='SO Decrement Ordered Qty.',
|
so_decrement_order_qty = fields.Boolean(string='SO Decrement Ordered Qty.',
|
||||||
help='When completing the RMA, the Ordered Quantity will be decremented by '
|
help='When completing the RMA, the Ordered Quantity will be decremented by '
|
||||||
'the RMA qty.')
|
'the RMA qty.')
|
||||||
@@ -87,6 +90,9 @@ class RMATemplate(models.Model):
|
|||||||
return super(RMATemplate, self)._portal_values(request_user, res_id=res_id)
|
return super(RMATemplate, self)._portal_values(request_user, res_id=res_id)
|
||||||
|
|
||||||
def _rma_sale_line_validity(self, so_line):
|
def _rma_sale_line_validity(self, so_line):
|
||||||
|
if self.sale_order_warranty:
|
||||||
|
validity_days = so_line.product_id.rma_sale_warranty_validity
|
||||||
|
else:
|
||||||
validity_days = so_line.product_id.rma_sale_validity
|
validity_days = so_line.product_id.rma_sale_validity
|
||||||
if validity_days < 0:
|
if validity_days < 0:
|
||||||
return ''
|
return ''
|
||||||
@@ -195,6 +201,10 @@ class RMA(models.Model):
|
|||||||
pass
|
pass
|
||||||
return sale_orders.mapped('invoice_ids') - original_invoices
|
return sale_orders.mapped('invoice_ids') - original_invoices
|
||||||
|
|
||||||
|
def _invoice_values_sale_order(self):
|
||||||
|
# the RMA invoice API will not be used as invoicing will happen at the SO level
|
||||||
|
return False
|
||||||
|
|
||||||
def action_add_so_lines(self):
|
def action_add_so_lines(self):
|
||||||
make_line_obj = self.env['rma.sale.make.lines']
|
make_line_obj = self.env['rma.sale.make.lines']
|
||||||
for rma in self:
|
for rma in self:
|
||||||
|
|||||||
@@ -171,3 +171,136 @@ class TestRMASale(TestRMA):
|
|||||||
# RMA cannot be completed because the inbound picking state is confirmed
|
# RMA cannot be completed because the inbound picking state is confirmed
|
||||||
with self.assertRaises(UserError):
|
with self.assertRaises(UserError):
|
||||||
rma2.action_done()
|
rma2.action_done()
|
||||||
|
|
||||||
|
def test_30_product_sale_return_warranty(self):
|
||||||
|
self.template_sale_return.write({
|
||||||
|
'usage': 'sale_order',
|
||||||
|
'invoice_done': True,
|
||||||
|
'sale_order_warranty': True,
|
||||||
|
'in_to_refund': True,
|
||||||
|
'so_decrement_order_qty': False, # invoice on decremented delivered not decremented order
|
||||||
|
'next_rma_template_id': self.template_rtv.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
validity = 100 # eligible for 100 days
|
||||||
|
warranty_validity = validity + 100 # eligible for 200 days
|
||||||
|
|
||||||
|
self.product1.write({
|
||||||
|
'rma_sale_validity': validity,
|
||||||
|
'rma_sale_warranty_validity': warranty_validity,
|
||||||
|
'type': 'product',
|
||||||
|
'invoice_policy': 'delivery',
|
||||||
|
'tracking': 'serial',
|
||||||
|
'standard_price': 1.5,
|
||||||
|
})
|
||||||
|
|
||||||
|
order = self.env['sale.order'].create({
|
||||||
|
'partner_id': self.partner1.id,
|
||||||
|
'partner_invoice_id': self.partner1.id,
|
||||||
|
'partner_shipping_id': self.partner1.id,
|
||||||
|
'user_id': self.user1.id,
|
||||||
|
'order_line': [(0, 0, {
|
||||||
|
'product_id': self.product1.id,
|
||||||
|
'product_uom_qty': 1.0,
|
||||||
|
'product_uom': self.product1.uom_id.id,
|
||||||
|
'price_unit': 10.0,
|
||||||
|
})]
|
||||||
|
})
|
||||||
|
order.action_confirm()
|
||||||
|
self.assertTrue(order.state in ('sale', 'done'))
|
||||||
|
self.assertEqual(len(order.picking_ids), 1, 'Tests only run with single stage delivery.')
|
||||||
|
|
||||||
|
# Try to RMA item not delivered yet
|
||||||
|
rma = self.env['rma.rma'].create({
|
||||||
|
'template_id': self.template_sale_return.id,
|
||||||
|
'partner_id': self.partner1.id,
|
||||||
|
'partner_shipping_id': self.partner1.id,
|
||||||
|
'sale_order_id': order.id,
|
||||||
|
})
|
||||||
|
self.assertEqual(rma.state, 'draft')
|
||||||
|
# Do not allow warranty return.
|
||||||
|
self.product1.rma_sale_warranty_validity = -1
|
||||||
|
wizard = self.env['rma.sale.make.lines'].with_user(self.user1).create({'rma_id': rma.id})
|
||||||
|
self.assertEqual(wizard.line_ids.qty_delivered, 0.0)
|
||||||
|
wizard.line_ids.product_uom_qty = 1.0
|
||||||
|
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
wizard.add_lines()
|
||||||
|
|
||||||
|
# Allows returns, but not forever
|
||||||
|
self.product1.rma_sale_warranty_validity = warranty_validity
|
||||||
|
original_date_order = order.date_order
|
||||||
|
order.write({'date_order': original_date_order - timedelta(days=warranty_validity+1)})
|
||||||
|
wizard = self.env['rma.sale.make.lines'].with_user(self.user1).create({'rma_id': rma.id})
|
||||||
|
self.assertEqual(wizard.line_ids.qty_delivered, 0.0)
|
||||||
|
wizard.line_ids.product_uom_qty = 1.0
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
wizard.add_lines()
|
||||||
|
|
||||||
|
# Allows returns due to date, due to warranty option
|
||||||
|
order.write({'date_order': original_date_order - timedelta(days=validity+1)})
|
||||||
|
wizard = self.env['rma.sale.make.lines'].with_user(self.user1).create({'rma_id': rma.id})
|
||||||
|
self.assertEqual(wizard.line_ids.qty_delivered, 0.0)
|
||||||
|
wizard.line_ids.product_uom_qty = 1.0
|
||||||
|
wizard.add_lines()
|
||||||
|
|
||||||
|
# finish outbound so that we can invoice.
|
||||||
|
order.picking_ids.action_assign()
|
||||||
|
pack_opt = order.picking_ids.move_line_ids[0]
|
||||||
|
lot = self.env['stock.production.lot'].create({
|
||||||
|
'product_id': self.product1.id,
|
||||||
|
'name': 'X100',
|
||||||
|
'product_uom_id': self.product1.uom_id.id,
|
||||||
|
'company_id': self.env.user.company_id.id,
|
||||||
|
})
|
||||||
|
pack_opt.qty_done = 1.0
|
||||||
|
pack_opt.lot_id = lot
|
||||||
|
order.picking_ids.button_validate()
|
||||||
|
self.assertEqual(order.picking_ids.state, 'done')
|
||||||
|
self.assertEqual(order.order_line.qty_delivered, 1.0)
|
||||||
|
|
||||||
|
# Invoice the order so that only the core product is invoiced at the end...
|
||||||
|
self.assertFalse(order.invoice_ids)
|
||||||
|
wiz = self.env['sale.advance.payment.inv'].with_context(active_ids=order.ids).create({})
|
||||||
|
wiz.create_invoices()
|
||||||
|
order.flush()
|
||||||
|
self.assertTrue(order.invoice_ids)
|
||||||
|
order_invoice = order.invoice_ids
|
||||||
|
|
||||||
|
self.assertEqual(rma.lines.product_id, self.product1)
|
||||||
|
rma.action_confirm()
|
||||||
|
self.assertTrue(rma.in_picking_id)
|
||||||
|
self.assertEqual(rma.in_picking_id.state, 'assigned')
|
||||||
|
pack_opt = rma.in_picking_id.move_line_ids[0]
|
||||||
|
pack_opt.lot_id = lot.id
|
||||||
|
pack_opt.qty_done = 1.0
|
||||||
|
rma.in_picking_id.button_validate()
|
||||||
|
self.assertEqual(rma.in_picking_id.state, 'done')
|
||||||
|
order.flush()
|
||||||
|
# self.assertEqual(order.order_line.qty_delivered, 0.0)
|
||||||
|
rma.action_done()
|
||||||
|
self.assertEqual(rma.state, 'done')
|
||||||
|
order.flush()
|
||||||
|
|
||||||
|
rma_invoice = rma.invoice_ids
|
||||||
|
self.assertTrue(rma_invoice)
|
||||||
|
sale_line = rma_invoice.invoice_line_ids.filtered(lambda l: l.sale_line_ids)
|
||||||
|
so_line = sale_line.sale_line_ids
|
||||||
|
self.assertTrue(sale_line)
|
||||||
|
self.assertEqual(sale_line.price_unit, so_line.price_unit)
|
||||||
|
|
||||||
|
# Invoices do not have their anglo-saxon cost lines until they post
|
||||||
|
order_invoice.post()
|
||||||
|
rma_invoice.post()
|
||||||
|
|
||||||
|
# Find the return to vendor RMA
|
||||||
|
rtv_rma = self.env['rma.rma'].search([('parent_id', '=', rma.id)])
|
||||||
|
self.assertTrue(rtv_rma)
|
||||||
|
self.assertFalse(rtv_rma.out_picking_id)
|
||||||
|
|
||||||
|
wiz = self.env['rma.make.rtv'].with_context(active_model='rma.rma', active_ids=rtv_rma.ids).create({})
|
||||||
|
self.assertTrue(wiz.rma_line_ids)
|
||||||
|
wiz.partner_id = self.partner2
|
||||||
|
wiz.create_batch()
|
||||||
|
self.assertTrue(rtv_rma.out_picking_id)
|
||||||
|
self.assertEqual(rtv_rma.out_picking_id.partner_id, self.partner2)
|
||||||
|
|||||||
@@ -57,8 +57,8 @@
|
|||||||
<t t-set="validity" t-value="rma_template._rma_sale_line_validity(line)"/>
|
<t t-set="validity" t-value="rma_template._rma_sale_line_validity(line)"/>
|
||||||
<div class="row" t-attf-class="row #{'' if validity == 'valid' else 'text-muted'}">
|
<div class="row" t-attf-class="row #{'' if validity == 'valid' else 'text-muted'}">
|
||||||
<div class="col-lg-1 text-center">
|
<div class="col-lg-1 text-center">
|
||||||
<img t-attf-src="/web/image/product.product/#{line.product_id.id}/image_64"
|
<img class="mr4 float-left o_portal_product_img" t-if="line.product_id.image_128" t-att-src="image_data_uri(line.product_id.image_128)" alt="Product Image" width="64"/>
|
||||||
width="64" alt="Product image"></img>
|
<img class="mr4 float-left o_portal_product_img" t-if="not line.product_id.image_128" src="/web/static/src/img/placeholder.png" alt="Product Image" width="64"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-3">
|
<div class="col-lg-3">
|
||||||
<span t-esc="line.product_id.name"/>
|
<span t-esc="line.product_id.name"/>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<xpath expr="//page[@name='sales']/group[@name='sale']" position="inside">
|
<xpath expr="//page[@name='sales']/group[@name='sale']" position="inside">
|
||||||
<group name="rma_sale" string="RMA Sales">
|
<group name="rma_sale" string="RMA Sales">
|
||||||
<field name="rma_sale_validity" string="Eligible Days"/>
|
<field name="rma_sale_validity" string="Eligible Days"/>
|
||||||
|
<field name="rma_sale_warranty_validity" string="Warranty Eligible Days"/>
|
||||||
</group>
|
</group>
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
<xpath expr="//page[@name='sales']/group[@name='sale']" position="inside">
|
<xpath expr="//page[@name='sales']/group[@name='sale']" position="inside">
|
||||||
<group name="rma_sale" string="RMA Sales">
|
<group name="rma_sale" string="RMA Sales">
|
||||||
<field name="rma_sale_validity" string="Eligible Days"/>
|
<field name="rma_sale_validity" string="Eligible Days"/>
|
||||||
|
<field name="rma_sale_warranty_validity" string="Warranty Eligible Days"/>
|
||||||
</group>
|
</group>
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<xpath expr="//field[@name='usage']" position="after">
|
<xpath expr="//field[@name='usage']" position="after">
|
||||||
<field name="so_decrement_order_qty" string="Decrement Ordered Qty" attrs="{'invisible': [('usage', '!=', 'sale_order')]}"/>
|
<field name="so_decrement_order_qty" string="Decrement Ordered Qty" attrs="{'invisible': [('usage', '!=', 'sale_order')]}"/>
|
||||||
|
<field name="sale_order_warranty" string="Warranty" attrs="{'invisible': [('usage', '!=', 'sale_order')]}"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|||||||
1
sale_credit_limit/__init__.py
Normal file
1
sale_credit_limit/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import models
|
||||||
29
sale_credit_limit/__manifest__.py
Normal file
29
sale_credit_limit/__manifest__.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
'name': 'Sale Credit Limit',
|
||||||
|
'summary': 'Uses credit limit on Partners to warn salespeople if they are over their limit.',
|
||||||
|
'version': '13.0.1.0.0',
|
||||||
|
'author': "Hibou Corp.",
|
||||||
|
'category': 'Sale',
|
||||||
|
'license': 'AGPL-3',
|
||||||
|
'complexity': 'expert',
|
||||||
|
'images': [],
|
||||||
|
'website': "https://hibou.io",
|
||||||
|
'description': """
|
||||||
|
Uses credit limit on Partners to warn salespeople if they are over their limit.
|
||||||
|
|
||||||
|
When confirming a sale order, the current sale order total will be considered and a Sale Order Exception
|
||||||
|
will be created if the total would put them over their credit limit.
|
||||||
|
""",
|
||||||
|
'depends': [
|
||||||
|
'sale',
|
||||||
|
'account',
|
||||||
|
'sale_exception',
|
||||||
|
],
|
||||||
|
'demo': [],
|
||||||
|
'data': [
|
||||||
|
'data/sale_exceptions.xml',
|
||||||
|
'views/partner_views.xml',
|
||||||
|
],
|
||||||
|
'auto_install': False,
|
||||||
|
'installable': True,
|
||||||
|
}
|
||||||
19
sale_credit_limit/data/sale_exceptions.xml
Normal file
19
sale_credit_limit/data/sale_exceptions.xml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="excep_sale_credit_limit" model="exception.rule">
|
||||||
|
<field name="name">Invoice Partner credit limit exceeded.</field>
|
||||||
|
<field name="description">The Customer or Invoice Address has a credit limit.
|
||||||
|
This sale order, or the customer has an outstanding balance that, exceeds their credit limit.</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
<field name="model">sale.order</field>
|
||||||
|
<field name="code">
|
||||||
|
partner = sale.partner_invoice_id.commercial_partner_id
|
||||||
|
partner_balance = partner.credit + sale.amount_total
|
||||||
|
if partner.credit_limit and partner.credit_limit <= partner_balance:
|
||||||
|
failed = True
|
||||||
|
</field>
|
||||||
|
<field name="active" eval="True" />
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
1
sale_credit_limit/models/__init__.py
Normal file
1
sale_credit_limit/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import sale
|
||||||
18
sale_credit_limit/models/sale.py
Normal file
18
sale_credit_limit/models/sale.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from odoo import api, models, tools
|
||||||
|
|
||||||
|
|
||||||
|
class SaleOrder(models.Model):
|
||||||
|
_inherit = 'sale.order'
|
||||||
|
|
||||||
|
@api.onchange('partner_invoice_id')
|
||||||
|
def _onchange_partner_invoice_id(self):
|
||||||
|
for so in self:
|
||||||
|
partner = so.partner_invoice_id.commercial_partner_id
|
||||||
|
if partner.credit_limit and partner.credit_limit <= partner.credit:
|
||||||
|
m = 'Partner outstanding receivables %s is above their credit limit of %s' \
|
||||||
|
% (tools.format_amount(self.env, partner.credit, so.currency_id),
|
||||||
|
tools.format_amount(self.env, partner.credit_limit, so.currency_id))
|
||||||
|
return {
|
||||||
|
'warning': {'title': 'Sale Credit Limit',
|
||||||
|
'message': m}
|
||||||
|
}
|
||||||
1
sale_credit_limit/tests/__init__.py
Normal file
1
sale_credit_limit/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import test_sale_credit_exception
|
||||||
32
sale_credit_limit/tests/test_sale_credit_exception.py
Normal file
32
sale_credit_limit/tests/test_sale_credit_exception.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
|
||||||
|
from odoo.addons.sale_exception.tests.test_sale_exception import TestSaleException
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaleCreditException(TestSaleException):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(TestSaleCreditException, self).setUp()
|
||||||
|
|
||||||
|
def test_sale_order_credit_limit_exception(self):
|
||||||
|
self.sale_exception_confirm = self.env['sale.exception.confirm']
|
||||||
|
exception = self.env.ref('sale_credit_limit.excep_sale_credit_limit')
|
||||||
|
exception.active = True
|
||||||
|
partner = self.env.ref('base.res_partner_12')
|
||||||
|
partner.credit_limit = 100.00
|
||||||
|
p = self.env.ref('product.product_product_25_product_template')
|
||||||
|
so1 = self.env['sale.order'].create({
|
||||||
|
'partner_id': partner.id,
|
||||||
|
'partner_invoice_id': partner.id,
|
||||||
|
'partner_shipping_id': partner.id,
|
||||||
|
'order_line': [(0, 0, {'name': p.name,
|
||||||
|
'product_id': p.id,
|
||||||
|
'product_uom_qty': 2,
|
||||||
|
'product_uom': p.uom_id.id,
|
||||||
|
'price_unit': p.list_price})],
|
||||||
|
'pricelist_id': self.env.ref('product.list0').id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# confirm quotation
|
||||||
|
so1.action_confirm()
|
||||||
|
self.assertTrue(so1.state == 'draft')
|
||||||
|
self.assertFalse(so1.ignore_exception)
|
||||||
15
sale_credit_limit/views/partner_views.xml
Normal file
15
sale_credit_limit/views/partner_views.xml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_partner_form_inherit" model="ir.ui.view">
|
||||||
|
<field name="name">res.partner.form.inherit</field>
|
||||||
|
<field name="model">res.partner</field>
|
||||||
|
<field name="inherit_id" ref="base.view_partner_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//group[@name='accounting_entries']" position="inside">
|
||||||
|
<field name="credit_limit" widget="monetary" attrs="{'invisible': [('parent_id', '!=', False)]}"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" ?>
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
<odoo>
|
<odoo>
|
||||||
<template id="portal_my_task_markdown" inherit_id='project.portal_my_task' customize_show="True" name="Timesheet Use Markdown">
|
<template id="portal_my_task_markdown" inherit_id='hr_timesheet.portal_timesheet_table' customize_show="True" name="Timesheet Use Markdown">
|
||||||
<xpath expr="//t[@t-esc='timesheet.name']" position="replace">
|
<xpath expr="//t[@t-esc='timesheet.name']" position="replace">
|
||||||
<div t-if="timesheet.name_markdown" t-field="timesheet.name_markdown" />
|
<div t-if="timesheet.name_markdown" t-field="timesheet.name_markdown" />
|
||||||
<t t-else="" t-esc="timesheet.name" />
|
<t t-else="" t-esc="timesheet.name" />
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<sheet>
|
<sheet>
|
||||||
<group>
|
<group>
|
||||||
<group>
|
<group>
|
||||||
|
<field name="company_id" invisible="1"/>
|
||||||
<field name="project_id" invisible="1"/>
|
<field name="project_id" invisible="1"/>
|
||||||
<field name="task_id" invisible="1"/>
|
<field name="task_id" invisible="1"/>
|
||||||
<field name="user_id" invisible="1" groups="hr_timesheet.group_timesheet_manager"/>
|
<field name="user_id" invisible="1" groups="hr_timesheet.group_timesheet_manager"/>
|
||||||
|
|||||||
Reference in New Issue
Block a user