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/mass_mailing_partner'
# Conflicts: # .gitmodules
This commit is contained in:
7
.gitmodules
vendored
7
.gitmodules
vendored
@@ -13,3 +13,10 @@
|
||||
[submodule "external/hibou-oca/social"]
|
||||
path = external/hibou-oca/social
|
||||
url = https://github.com/hibou-io/oca-social.git
|
||||
[submodule "external/camptocamp-cloud-platform"]
|
||||
path = external/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
|
||||
>>>>>>> .gitmodules
|
||||
|
||||
60
attachment_minio/README.md
Normal file
60
attachment_minio/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Use MinIO (or Amazon S3) for Attachment/filestore
|
||||
|
||||
MinIO provides S3 API compatible storage to scale out without a shared filesystem like NFS.
|
||||
|
||||
This module will store the bucket used in the attachment database object, thus allowing
|
||||
you to retain read-only access to the filestore by simply overriding the bucket.
|
||||
|
||||
## Setup details
|
||||
|
||||
Before installing this app, you should add several System Parameters (the most important of
|
||||
which is `ir_attachment.location`), OR set them through the config file as described later.
|
||||
|
||||
**The in database System Parameters will act as overrides to the Config File versions.**
|
||||
|
||||
| Key | Example Value | Default Value |
|
||||
|-----------------------------------|---------------|---------------|
|
||||
| ir_attachment.location | s3 | |
|
||||
| ir_attachment.location.host | minio:9000 | |
|
||||
| ir_attachment.location.bucket | odoo | |
|
||||
| ir_attachment.location.region | us-west-1 | us-west-1 |
|
||||
| ir_attachment.location.access_key | minio | |
|
||||
| ir_attachment.location.secret_key | minio_secret | |
|
||||
| ir_attachment.location.secure | 1 | |
|
||||
|
||||
**Config File:**
|
||||
|
||||
```
|
||||
attachment_minio_host = minio:9000
|
||||
attachment_minio_region = us-west-1
|
||||
attachment_minio_access_key = minio
|
||||
attachment_minio_secret_key = minio_secret
|
||||
attachment_minio_bucket = odoo
|
||||
attachment_minio_secure = False
|
||||
```
|
||||
|
||||
In general, they should all be specified other than "region" (if you are not using AWS S3)
|
||||
and "secure" which should be set if the "host" needs to be accessed over SSL/TLS.
|
||||
|
||||
Install `attachment_minio` and during the installation `base_attachment_object_storage` should move
|
||||
your existing filestore attachment files into the database or object storage.
|
||||
|
||||
For example, you can run a shell command like the following to set the parameter:
|
||||
|
||||
```
|
||||
env['ir.config_parameter'].set_param('ir_attachment.location', 's3')
|
||||
# If already installed...
|
||||
# env['ir.attachment'].force_storage()
|
||||
env.cr.commit()
|
||||
```
|
||||
|
||||
If `attachment_minio` is not already installed, you can then install it and the migration
|
||||
should be noted in the logs. **Ensure that the timeouts are long enough that the migration can finish.**
|
||||
|
||||
### Base Setup
|
||||
|
||||
This module utilizes `base_attachment_object_storage`
|
||||
|
||||
The System Parameter `ir_attachment.storage.force.database` can be customized to
|
||||
force storage of files in the database. See the documentation of the module
|
||||
`base_attachment_object_storage`.
|
||||
2
attachment_minio/__init__.py
Executable file
2
attachment_minio/__init__.py
Executable file
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
|
||||
75
attachment_minio/__manifest__.py
Executable file
75
attachment_minio/__manifest__.py
Executable file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "Attachment MinIO",
|
||||
"version": "13.0.1.0.0",
|
||||
"depends": [
|
||||
"base_attachment_object_storage",
|
||||
],
|
||||
"author": "Hibou Corp.",
|
||||
"license": "AGPL-3",
|
||||
"description": """
|
||||
# Use MinIO (or Amazon S3) for Attachment/filestore
|
||||
|
||||
MinIO provides S3 API compatible storage to scale out without a shared filesystem like NFS.
|
||||
|
||||
This module will store the bucket used in the attachment database object, thus allowing
|
||||
you to retain read-only access to the filestore by simply overriding the bucket.
|
||||
|
||||
## Setup details
|
||||
|
||||
Before installing this app, you should add several System Parameters (the most important of
|
||||
which is `ir_attachment.location`), OR set them through the config file as described later.
|
||||
|
||||
**The in database System Parameters will act as overrides to the Config File versions.**
|
||||
|
||||
| Key | Example Value | Default Value |
|
||||
|-----------------------------------|---------------|---------------|
|
||||
| ir_attachment.location | s3 | |
|
||||
| ir_attachment.location.host | minio:9000 | |
|
||||
| ir_attachment.location.bucket | odoo | |
|
||||
| ir_attachment.location.region | us-west-1 | us-west-1 |
|
||||
| ir_attachment.location.access_key | minio | |
|
||||
| ir_attachment.location.secret_key | minio_secret | |
|
||||
| ir_attachment.location.secure | 1 | |
|
||||
|
||||
**Config File:**
|
||||
|
||||
```
|
||||
attachment_minio_host = minio:9000
|
||||
attachment_minio_region = us-west-1
|
||||
attachment_minio_access_key = minio
|
||||
attachment_minio_secret_key = minio_secret
|
||||
attachment_minio_bucket = odoo
|
||||
attachment_minio_secure = False
|
||||
```
|
||||
|
||||
In general, they should all be specified other than "region" (if you are not using AWS S3)
|
||||
and "secure" which should be set if the "host" needs to be accessed over SSL/TLS.
|
||||
|
||||
Install `attachment_minio` and during the installation `base_attachment_object_storage` should move
|
||||
your existing filestore attachment files into the database or object storage.
|
||||
|
||||
For example, you can run a shell command like the following to set the parameter:
|
||||
|
||||
```
|
||||
env['ir.config_parameter'].set_param('ir_attachment.location', 's3')
|
||||
# If already installed...
|
||||
# env['ir.attachment'].force_storage()
|
||||
env.cr.commit()
|
||||
```
|
||||
|
||||
If `attachment_minio` is not already installed, you can then install it and the migration
|
||||
should be noted in the logs. **Ensure that the timeouts are long enough that the migration can finish.**
|
||||
|
||||
""",
|
||||
"summary": "",
|
||||
"website": "",
|
||||
"category": 'Tools',
|
||||
"auto_install": False,
|
||||
"installable": True,
|
||||
"application": False,
|
||||
"external_dependencies": {
|
||||
"python": [
|
||||
"minio",
|
||||
],
|
||||
},
|
||||
}
|
||||
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()
|
||||
1
attachment_minio/models/__init__.py
Normal file
1
attachment_minio/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import ir_attachment
|
||||
112
attachment_minio/models/ir_attachment.py
Normal file
112
attachment_minio/models/ir_attachment.py
Normal file
@@ -0,0 +1,112 @@
|
||||
# Copyright 2020 Hibou Corp.
|
||||
# Copyright 2016 Camptocamp SA
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
|
||||
|
||||
import io
|
||||
import base64
|
||||
import logging
|
||||
from minio import Minio
|
||||
from minio.error import NoSuchKey
|
||||
|
||||
from odoo import api, exceptions, models, tools
|
||||
from ..s3uri import S3Uri
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MinioAttachment(models.Model):
|
||||
_inherit = 'ir.attachment'
|
||||
|
||||
@api.model
|
||||
def _get_minio_client(self):
|
||||
config = tools.config
|
||||
params = self.env['ir.config_parameter'].sudo()
|
||||
host = params.get_param('ir_attachment.location.host') or config.get('attachment_minio_host')
|
||||
region = params.get_param('ir_attachment.location.region') or config.get('attachment_minio_region', 'us-west-1')
|
||||
access_key = params.get_param('ir_attachment.location.access_key') or config.get('attachment_minio_access_key')
|
||||
secret_key = params.get_param('ir_attachment.location.secret_key') or config.get('attachment_minio_secret_key')
|
||||
secure = params.get_param('ir_attachment.location.secure') or config.get('attachment_minio_secure')
|
||||
if not all((host, access_key, secret_key)):
|
||||
raise exceptions.UserError('Incorrect configuration of attachment_minio.')
|
||||
return Minio(host,
|
||||
access_key=access_key,
|
||||
secret_key=secret_key,
|
||||
region=region,
|
||||
secure=bool(secure))
|
||||
|
||||
@api.model
|
||||
def _get_minio_bucket(self, client, name=None):
|
||||
config = tools.config
|
||||
params = self.env['ir.config_parameter'].sudo()
|
||||
bucket = name or params.get_param('ir_attachment.location.bucket') or config.get('attachment_minio_bucket')
|
||||
if not bucket:
|
||||
raise exceptions.UserError('Incorrect configuration of attachment_minio -- Missing bucket.')
|
||||
if not client.bucket_exists(bucket):
|
||||
region = params.get_param('ir_attachment.location.region', 'us-west-1')
|
||||
client.make_bucket(bucket, region)
|
||||
return bucket
|
||||
|
||||
@api.model
|
||||
def _get_minio_key(self, sha):
|
||||
# scatter files across 256 dirs
|
||||
# This mirrors Odoo's own object storage so that it is easier to migrate
|
||||
# to or from external storage.
|
||||
fname = sha[:2] + '/' + sha
|
||||
return fname
|
||||
|
||||
@api.model
|
||||
def _get_minio_fname(self, bucket, key):
|
||||
return 's3://%s/%s' % (bucket, key)
|
||||
|
||||
# core API methods from base_attachment_object_storage
|
||||
|
||||
def _get_stores(self):
|
||||
res = super(MinioAttachment, self)._get_stores()
|
||||
res.append('s3')
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def _store_file_read(self, fname, bin_size=False):
|
||||
if fname.startswith('s3://'):
|
||||
client = self._get_minio_client()
|
||||
s3uri = S3Uri(fname)
|
||||
bucket = self._get_minio_bucket(client, name=s3uri.bucket())
|
||||
try:
|
||||
response = client.get_object(bucket, s3uri.item())
|
||||
return base64.b64encode(response.read())
|
||||
except NoSuchKey:
|
||||
_logger.info('attachment "%s" missing from remote object storage', (fname, ))
|
||||
return ''
|
||||
return super(MinioAttachment, self)._store_file_read(fname, bin_size=bin_size)
|
||||
|
||||
@api.model
|
||||
def _store_file_write(self, key, bin_data):
|
||||
if self._storage() == 's3':
|
||||
client = self._get_minio_client()
|
||||
bucket = self._get_minio_bucket(client)
|
||||
minio_key = self._get_minio_key(key)
|
||||
with io.BytesIO(bin_data) as bin_data_io:
|
||||
client.put_object(bucket, minio_key, bin_data_io, len(bin_data),
|
||||
content_type=self.mimetype)
|
||||
return self._get_minio_fname(bucket, minio_key)
|
||||
return super(MinioAttachment, self)._store_file_write(key, bin_data)
|
||||
|
||||
@api.model
|
||||
def _store_file_delete(self, fname):
|
||||
if fname.startswith('s3://'):
|
||||
client = self._get_minio_client()
|
||||
try:
|
||||
s3uri = S3Uri(fname)
|
||||
except ValueError:
|
||||
# Cannot delete unparsable file
|
||||
return True
|
||||
bucket_name = s3uri.bucket()
|
||||
if bucket_name == self._get_minio_bucket(client):
|
||||
try:
|
||||
client.remove_object(bucket_name, s3uri.item())
|
||||
except NoSuchKey:
|
||||
_logger.info('unable to remove missing attachment "%s" from remote object storage', (fname, ))
|
||||
else:
|
||||
_logger.info('skip delete "%s" because of bucket-mismatch', (fname, ))
|
||||
return
|
||||
return super(MinioAttachment, self)._store_file_delete(fname)
|
||||
22
attachment_minio/s3uri.py
Normal file
22
attachment_minio/s3uri.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2016 Camptocamp SA
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html)
|
||||
|
||||
import re
|
||||
|
||||
|
||||
class S3Uri(object):
|
||||
|
||||
_url_re = re.compile("^s3:///*([^/]*)/?(.*)", re.IGNORECASE | re.UNICODE)
|
||||
|
||||
def __init__(self, uri):
|
||||
match = self._url_re.match(uri)
|
||||
if not match:
|
||||
raise ValueError("%s: is not a valid S3 URI" % (uri,))
|
||||
self._bucket, self._item = match.groups()
|
||||
|
||||
def bucket(self):
|
||||
return self._bucket
|
||||
|
||||
def item(self):
|
||||
return self._item
|
||||
3
auth_admin/__init__.py
Executable file
3
auth_admin/__init__.py
Executable file
@@ -0,0 +1,3 @@
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import wizard
|
||||
28
auth_admin/__manifest__.py
Executable file
28
auth_admin/__manifest__.py
Executable file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
'name': 'Auth Admin',
|
||||
'author': 'Hibou Corp. <hello@hibou.io>',
|
||||
'category': 'Hidden',
|
||||
'version': '13.0.1.0.0',
|
||||
'description':
|
||||
"""
|
||||
Login as other user
|
||||
===================
|
||||
|
||||
Provides a way for an authenticated user, with certain permissions, to login as a different user.
|
||||
Can also create a URL that logs in as that user.
|
||||
|
||||
Out of the box, only allows you to generate a login for an 'External User', e.g. portal users.
|
||||
|
||||
*2017-11-15* New button to generate the login on the Portal User Wizard (Action on Contact)
|
||||
""",
|
||||
'depends': [
|
||||
'base',
|
||||
'website',
|
||||
'portal',
|
||||
],
|
||||
'auto_install': False,
|
||||
'data': [
|
||||
'views/res_users.xml',
|
||||
'wizard/portal_wizard_views.xml',
|
||||
],
|
||||
}
|
||||
2
auth_admin/controllers/__init__.py
Executable file
2
auth_admin/controllers/__init__.py
Executable file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import main
|
||||
39
auth_admin/controllers/main.py
Executable file
39
auth_admin/controllers/main.py
Executable file
@@ -0,0 +1,39 @@
|
||||
from odoo import http, exceptions
|
||||
from ..models.res_users import check_admin_auth_login
|
||||
|
||||
from logging import getLogger
|
||||
_logger = getLogger(__name__)
|
||||
|
||||
|
||||
class AuthAdmin(http.Controller):
|
||||
|
||||
@http.route(['/auth_admin'], type='http', auth='public', website=True)
|
||||
def index(self, *args, **post):
|
||||
u = post.get('u')
|
||||
e = post.get('e')
|
||||
o = post.get('o')
|
||||
h = post.get('h')
|
||||
|
||||
if not all([u, e, o, h]):
|
||||
exceptions.Warning('Invalid Request')
|
||||
|
||||
u = str(u)
|
||||
e = str(e)
|
||||
o = str(o)
|
||||
h = str(h)
|
||||
|
||||
try:
|
||||
user = check_admin_auth_login(http.request.env, u, e, o, h)
|
||||
|
||||
http.request.session.uid = user.id
|
||||
http.request.session.login = user.login
|
||||
http.request.session.password = ''
|
||||
http.request.session.auth_admin = int(o)
|
||||
http.request.uid = user.id
|
||||
uid = http.request.session.authenticate(http.request.session.db, user.login, 'x')
|
||||
if uid is not False:
|
||||
http.request.params['login_success'] = True
|
||||
return http.redirect_with_hash('/my/home')
|
||||
return http.local_redirect('/my/home')
|
||||
except (exceptions.Warning, ) as e:
|
||||
return http.Response(e.message, status=400)
|
||||
1
auth_admin/models/__init__.py
Executable file
1
auth_admin/models/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
from . import res_users
|
||||
92
auth_admin/models/res_users.py
Executable file
92
auth_admin/models/res_users.py
Executable file
@@ -0,0 +1,92 @@
|
||||
from odoo import models, api, exceptions
|
||||
from odoo.http import request
|
||||
from datetime import datetime
|
||||
from time import mktime
|
||||
import hmac
|
||||
from hashlib import sha256
|
||||
|
||||
from logging import getLogger
|
||||
_logger = getLogger(__name__)
|
||||
|
||||
|
||||
def admin_auth_generate_login(env, user):
|
||||
"""
|
||||
Generates a URL to allow the current user to login as the portal user.
|
||||
|
||||
:param env: Odoo environment
|
||||
:param user: `res.users` in
|
||||
:return:
|
||||
"""
|
||||
if not env['res.partner'].check_access_rights('write'):
|
||||
return None
|
||||
u = str(user.id)
|
||||
now = datetime.utcnow()
|
||||
fifteen = int(mktime(now.timetuple())) + (15 * 60)
|
||||
e = str(fifteen)
|
||||
o = str(env.uid)
|
||||
|
||||
config = env['ir.config_parameter'].sudo()
|
||||
key = str(config.search([('key', '=', 'database.secret')], limit=1).value)
|
||||
h = hmac.new(key.encode(), (u + e + o).encode(), sha256)
|
||||
|
||||
base_url = str(config.search([('key', '=', 'web.base.url')], limit=1).value)
|
||||
|
||||
_logger.warn('login url for user id: ' + u + ' original user id: ' + o)
|
||||
|
||||
return base_url + '/auth_admin?u=' + u + '&e=' + e + '&o=' + o + '&h=' + h.hexdigest()
|
||||
|
||||
|
||||
def check_admin_auth_login(env, u_user_id, e_expires, o_org_user_id, hash_):
|
||||
"""
|
||||
Checks that the parameters are valid and that the user exists.
|
||||
|
||||
:param env: Odoo environment
|
||||
:param u_user_id: Desired user id to login as.
|
||||
:param e_expires: Expiration timestamp
|
||||
:param o_org_user_id: Original user id.
|
||||
:param hash_: HMAC generated hash
|
||||
:return: `res.users`
|
||||
"""
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = int(mktime(now.timetuple()))
|
||||
fifteen = now + (15 * 60)
|
||||
|
||||
config = env['ir.config_parameter'].sudo()
|
||||
key = str(config.search([('key', '=', 'database.secret')], limit=1).value)
|
||||
|
||||
myh = hmac.new(key.encode(), str(str(u_user_id) + str(e_expires) + str(o_org_user_id)).encode(), sha256)
|
||||
|
||||
if not hmac.compare_digest(hash_, myh.hexdigest()):
|
||||
raise exceptions.AccessDenied('Invalid Request')
|
||||
|
||||
if not (now <= int(e_expires) <= fifteen):
|
||||
raise exceptions.AccessDenied('Expired')
|
||||
|
||||
user = env['res.users'].sudo().search([('id', '=', int(u_user_id))], limit=1)
|
||||
if not user.id:
|
||||
raise exceptions.AccessDenied('Invalid User')
|
||||
return user
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = 'res.users'
|
||||
|
||||
def admin_auth_generate_login(self):
|
||||
self.ensure_one()
|
||||
|
||||
login_url = admin_auth_generate_login(self.env, self)
|
||||
if login_url:
|
||||
raise exceptions.Warning(login_url)
|
||||
|
||||
return False
|
||||
|
||||
def _check_credentials(self, password):
|
||||
try:
|
||||
return super(ResUsers, self)._check_credentials(password)
|
||||
except exceptions.AccessDenied:
|
||||
if request and hasattr(request, 'session') and request.session.get('auth_admin'):
|
||||
_logger.warn('_check_credentials for user id: ' + \
|
||||
str(request.session.uid) + ' original user id: ' + str(request.session.auth_admin))
|
||||
else:
|
||||
raise
|
||||
14
auth_admin/views/res_users.xml
Executable file
14
auth_admin/views/res_users.xml
Executable file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="auth_admin_view_users_tree" model="ir.ui.view">
|
||||
<field name="name">auth_admin.res.users.tree</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//tree" position="inside">
|
||||
<field name="share" invisible="1"/>
|
||||
<button string="Generate Login" type="object" name="admin_auth_generate_login" attrs="{'invisible': [('share', '=', False)]}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
1
auth_admin/wizard/__init__.py
Executable file
1
auth_admin/wizard/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
from . import portal_wizard
|
||||
31
auth_admin/wizard/portal_wizard.py
Executable file
31
auth_admin/wizard/portal_wizard.py
Executable file
@@ -0,0 +1,31 @@
|
||||
from odoo import api, fields, models
|
||||
from ..models.res_users import admin_auth_generate_login
|
||||
|
||||
|
||||
class PortalWizard(models.TransientModel):
|
||||
_inherit = 'portal.wizard'
|
||||
|
||||
def admin_auth_generate_login(self):
|
||||
self.ensure_one()
|
||||
self.user_ids.admin_auth_generate_login()
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": self._name,
|
||||
"views": [[False, "form"]],
|
||||
"res_id": self.id,
|
||||
"target": "new",
|
||||
}
|
||||
|
||||
|
||||
class PortalWizardUser(models.TransientModel):
|
||||
_inherit = 'portal.wizard.user'
|
||||
|
||||
force_login_url = fields.Char(string='Force Login URL')
|
||||
|
||||
def admin_auth_generate_login(self):
|
||||
ir_model_access = self.env['ir.model.access']
|
||||
for row in self.filtered(lambda r: r.in_portal):
|
||||
user = row.partner_id.user_ids[0] if row.partner_id.user_ids else None
|
||||
if ir_model_access.check('res.partner', mode='unlink') and user:
|
||||
row.force_login_url = admin_auth_generate_login(self.env, user)
|
||||
self.filtered(lambda r: not r.in_portal).update({'force_login_url': ''})
|
||||
18
auth_admin/wizard/portal_wizard_views.xml
Executable file
18
auth_admin/wizard/portal_wizard_views.xml
Executable file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
|
||||
<record id="portal_wizard" model="ir.ui.view">
|
||||
<field name="name">Portal Access Management - Auth Admin</field>
|
||||
<field name="model">portal.wizard</field>
|
||||
<field name="inherit_id" ref="portal.wizard_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='in_portal']" position="after">
|
||||
<field name="force_login_url"/>
|
||||
</xpath>
|
||||
<xpath expr="//button[last()]" position="after">
|
||||
<button string="Generate Login URL" type="object" name="admin_auth_generate_login" class="btn-primary" />
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
1
base_attachment_object_storage
Symbolic link
1
base_attachment_object_storage
Symbolic link
@@ -0,0 +1 @@
|
||||
./external/camptocamp-cloud-platform/base_attachment_object_storage
|
||||
1
delivery_fedex_hibou/__init__.py
Normal file
1
delivery_fedex_hibou/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
19
delivery_fedex_hibou/__manifest__.py
Normal file
19
delivery_fedex_hibou/__manifest__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
'name': 'Hibou Fedex Shipping',
|
||||
'version': '13.0.1.0.0',
|
||||
'category': 'Stock',
|
||||
'author': "Hibou Corp.",
|
||||
'license': 'AGPL-3',
|
||||
'website': 'https://hibou.io/',
|
||||
'depends': [
|
||||
'delivery_fedex',
|
||||
'delivery_hibou',
|
||||
],
|
||||
'data': [
|
||||
'views/stock_views.xml',
|
||||
],
|
||||
'demo': [
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
}
|
||||
2
delivery_fedex_hibou/models/__init__.py
Normal file
2
delivery_fedex_hibou/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import delivery_fedex
|
||||
from . import stock
|
||||
453
delivery_fedex_hibou/models/delivery_fedex.py
Normal file
453
delivery_fedex_hibou/models/delivery_fedex.py
Normal file
@@ -0,0 +1,453 @@
|
||||
import logging
|
||||
from odoo import fields, models, tools, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.addons.delivery_fedex.models.delivery_fedex import _convert_curr_iso_fdx
|
||||
from .fedex_request import FedexRequest
|
||||
|
||||
pdf = tools.pdf
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeliveryFedex(models.Model):
|
||||
_inherit = 'delivery.carrier'
|
||||
|
||||
fedex_service_type = fields.Selection(selection_add=[
|
||||
('GROUND_HOME_DELIVERY', 'GROUND_HOME_DELIVERY'),
|
||||
# ('FEDEX_EXPRESS_SAVER', 'FEDEX_EXPRESS_SAVER'), # included in 13.0, ensure it stays there...
|
||||
])
|
||||
|
||||
def _get_fedex_is_third_party(self, order=None, picking=None):
|
||||
third_party_account = self.get_third_party_account(order=order, picking=picking)
|
||||
if third_party_account:
|
||||
if not third_party_account.delivery_type == 'fedex':
|
||||
raise ValidationError('Non-FedEx Shipping Account indicated during FedEx shipment.')
|
||||
return True
|
||||
return False
|
||||
|
||||
def _get_fedex_payment_account_number(self, order=None, picking=None):
|
||||
"""
|
||||
Common hook to customize what Fedex Account number to use.
|
||||
:return: FedEx Account Number
|
||||
"""
|
||||
# Provided by Hibou Odoo Suite `delivery_hibou`
|
||||
third_party_account = self.get_third_party_account(order=order, picking=picking)
|
||||
if third_party_account:
|
||||
if not third_party_account.delivery_type == 'fedex':
|
||||
raise ValidationError('Non-FedEx Shipping Account indicated during FedEx shipment.')
|
||||
return third_party_account.name
|
||||
return self.fedex_account_number
|
||||
|
||||
def _get_fedex_account_number(self, order=None, picking=None):
|
||||
if order:
|
||||
# third_party_account = self.get_third_party_account(order=order, picking=picking)
|
||||
# if third_party_account:
|
||||
# if not third_party_account.delivery_type == 'fedex':
|
||||
# raise ValidationError('Non-FedEx Shipping Account indicated during FedEx shipment.')
|
||||
# return third_party_account.name
|
||||
if order.warehouse_id.fedex_account_number:
|
||||
return order.warehouse_id.fedex_account_number
|
||||
return self.fedex_account_number
|
||||
if picking:
|
||||
if picking.picking_type_id.warehouse_id.fedex_account_number:
|
||||
return picking.picking_type_id.warehouse_id.fedex_account_number
|
||||
return self.fedex_account_number
|
||||
|
||||
def _get_fedex_meter_number(self, order=None, picking=None):
|
||||
if order:
|
||||
if order.warehouse_id.fedex_meter_number:
|
||||
return order.warehouse_id.fedex_meter_number
|
||||
return self.fedex_meter_number
|
||||
if picking:
|
||||
if picking.picking_type_id.warehouse_id.fedex_meter_number:
|
||||
return picking.picking_type_id.warehouse_id.fedex_meter_number
|
||||
return self.fedex_meter_number
|
||||
|
||||
def _get_fedex_recipient_is_residential(self, partner):
|
||||
if self.fedex_service_type.find('HOME') >= 0:
|
||||
return True
|
||||
return not partner.is_company
|
||||
|
||||
"""
|
||||
Overrides to use Hibou Delivery methods to get shipper etc. and to add 'transit_days' to result.
|
||||
"""
|
||||
def fedex_rate_shipment(self, order):
|
||||
max_weight = self._fedex_convert_weight(self.fedex_default_packaging_id.max_weight, self.fedex_weight_unit)
|
||||
price = 0.0
|
||||
is_india = order.partner_shipping_id.country_id.code == 'IN' and order.company_id.partner_id.country_id.code == 'IN'
|
||||
|
||||
# Estimate weight of the sales order; will be definitely recomputed on the picking field "weight"
|
||||
est_weight_value = sum([(line.product_id.weight * line.product_uom_qty) for line in order.order_line if not line.display_type]) or 0.0
|
||||
weight_value = self._fedex_convert_weight(est_weight_value, self.fedex_weight_unit)
|
||||
|
||||
# Some users may want to ship very lightweight items; in order to give them a rating, we round the
|
||||
# converted weight of the shipping to the smallest value accepted by FedEx: 0.01 kg or lb.
|
||||
# (in the case where the weight is actually 0.0 because weights are not set, don't do this)
|
||||
if weight_value > 0.0:
|
||||
weight_value = max(weight_value, 0.01)
|
||||
|
||||
order_currency = order.currency_id
|
||||
superself = self.sudo()
|
||||
|
||||
# Hibou Delivery methods for collecting details in an overridable way
|
||||
shipper_company = superself.get_shipper_company(order=order)
|
||||
shipper_warehouse = superself.get_shipper_warehouse(order=order)
|
||||
recipient = superself.get_recipient(order=order)
|
||||
acc_number = superself._get_fedex_account_number(order=order)
|
||||
meter_number = superself._get_fedex_meter_number(order=order)
|
||||
order_name = superself.get_order_name(order=order)
|
||||
residential = self._get_fedex_recipient_is_residential(recipient)
|
||||
date_planned = None
|
||||
if self.env.context.get('date_planned'):
|
||||
date_planned = self.env.context.get('date_planned')
|
||||
|
||||
# Authentication stuff
|
||||
srm = FedexRequest(self.log_xml, request_type="rating", prod_environment=self.prod_environment)
|
||||
srm.web_authentication_detail(superself.fedex_developer_key, superself.fedex_developer_password)
|
||||
srm.client_detail(acc_number, meter_number)
|
||||
|
||||
# Build basic rating request and set addresses
|
||||
srm.transaction_detail(order_name)
|
||||
srm.shipment_request(
|
||||
self.fedex_droppoff_type,
|
||||
self.fedex_service_type,
|
||||
self.fedex_default_packaging_id.shipper_package_code,
|
||||
self.fedex_weight_unit,
|
||||
self.fedex_saturday_delivery,
|
||||
)
|
||||
pkg = self.fedex_default_packaging_id
|
||||
|
||||
srm.set_currency(_convert_curr_iso_fdx(order_currency.name))
|
||||
srm.set_shipper(shipper_company, shipper_warehouse)
|
||||
srm.set_recipient(recipient, residential=residential)
|
||||
|
||||
if max_weight and weight_value > max_weight:
|
||||
total_package = int(weight_value / max_weight)
|
||||
last_package_weight = weight_value % max_weight
|
||||
|
||||
for sequence in range(1, total_package + 1):
|
||||
srm.add_package(
|
||||
max_weight,
|
||||
package_code=pkg.shipper_package_code,
|
||||
package_height=pkg.height,
|
||||
package_width=pkg.width,
|
||||
package_length=pkg.length,
|
||||
sequence_number=sequence,
|
||||
mode='rating',
|
||||
)
|
||||
if last_package_weight:
|
||||
total_package = total_package + 1
|
||||
srm.add_package(
|
||||
last_package_weight,
|
||||
package_code=pkg.shipper_package_code,
|
||||
package_height=pkg.height,
|
||||
package_width=pkg.width,
|
||||
package_length=pkg.length,
|
||||
sequence_number=total_package,
|
||||
mode='rating',
|
||||
)
|
||||
srm.set_master_package(weight_value, total_package)
|
||||
else:
|
||||
srm.add_package(
|
||||
weight_value,
|
||||
package_code=pkg.shipper_package_code,
|
||||
package_height=pkg.height,
|
||||
package_width=pkg.width,
|
||||
package_length=pkg.length,
|
||||
mode='rating',
|
||||
)
|
||||
srm.set_master_package(weight_value, 1)
|
||||
|
||||
# Commodities for customs declaration (international shipping)
|
||||
if self.fedex_service_type in ['INTERNATIONAL_ECONOMY', 'INTERNATIONAL_PRIORITY'] or is_india:
|
||||
total_commodities_amount = 0.0
|
||||
commodity_country_of_manufacture = order.warehouse_id.partner_id.country_id.code
|
||||
|
||||
for line in order.order_line.filtered(lambda l: l.product_id.type in ['product', 'consu'] and not l.display_type):
|
||||
commodity_amount = line.price_reduce_taxinc
|
||||
total_commodities_amount += (commodity_amount * line.product_uom_qty)
|
||||
commodity_description = line.product_id.name
|
||||
commodity_number_of_piece = '1'
|
||||
commodity_weight_units = self.fedex_weight_unit
|
||||
commodity_weight_value = self._fedex_convert_weight(line.product_id.weight * line.product_uom_qty, self.fedex_weight_unit)
|
||||
commodity_quantity = line.product_uom_qty
|
||||
commodity_quantity_units = 'EA'
|
||||
commodity_harmonized_code = line.product_id.hs_code or ''
|
||||
srm.commodities(_convert_curr_iso_fdx(order_currency.name), commodity_amount, commodity_number_of_piece, commodity_weight_units, commodity_weight_value, commodity_description, commodity_country_of_manufacture, commodity_quantity, commodity_quantity_units, commodity_harmonized_code)
|
||||
srm.customs_value(_convert_curr_iso_fdx(order_currency.name), total_commodities_amount, "NON_DOCUMENTS")
|
||||
srm.duties_payment(order.warehouse_id.partner_id, acc_number, superself.fedex_duty_payment)
|
||||
|
||||
request = srm.rate(date_planned=date_planned)
|
||||
|
||||
warnings = request.get('warnings_message')
|
||||
if warnings:
|
||||
_logger.info(warnings)
|
||||
|
||||
if not request.get('errors_message'):
|
||||
if _convert_curr_iso_fdx(order_currency.name) in request['price']:
|
||||
price = request['price'][_convert_curr_iso_fdx(order_currency.name)]
|
||||
else:
|
||||
_logger.info("Preferred currency has not been found in FedEx response")
|
||||
company_currency = order.company_id.currency_id
|
||||
if _convert_curr_iso_fdx(company_currency.name) in request['price']:
|
||||
amount = request['price'][_convert_curr_iso_fdx(company_currency.name)]
|
||||
price = company_currency._convert(amount, order_currency, order.company_id, order.date_order or fields.Date.today())
|
||||
else:
|
||||
amount = request['price']['USD']
|
||||
price = company_currency._convert(amount, order_currency, order.company_id, order.date_order or fields.Date.today())
|
||||
else:
|
||||
return {'success': False,
|
||||
'price': 0.0,
|
||||
'error_message': _('Error:\n%s') % request['errors_message'],
|
||||
'warning_message': False}
|
||||
|
||||
return {'success': True,
|
||||
'price': price,
|
||||
'error_message': False,
|
||||
'transit_days': request.get('transit_days', False),
|
||||
'date_delivered': request.get('date_delivered', False),
|
||||
'warning_message': _('Warning:\n%s') % warnings if warnings else False}
|
||||
|
||||
"""
|
||||
Overrides to use Hibou Delivery methods to get shipper etc. and add insurance.
|
||||
"""
|
||||
def fedex_send_shipping(self, pickings):
|
||||
res = []
|
||||
|
||||
for picking in pickings:
|
||||
|
||||
srm = FedexRequest(self.log_xml, request_type="shipping", prod_environment=self.prod_environment)
|
||||
superself = self.sudo()
|
||||
|
||||
shipper_company = superself.get_shipper_company(picking=picking)
|
||||
shipper_warehouse = superself.get_shipper_warehouse(picking=picking)
|
||||
recipient = superself.get_recipient(picking=picking)
|
||||
acc_number = superself._get_fedex_account_number(picking=picking)
|
||||
meter_number = superself._get_fedex_meter_number(picking=picking)
|
||||
payment_acc_number = superself._get_fedex_payment_account_number()
|
||||
order_name = superself.get_order_name(picking=picking)
|
||||
attn = superself.get_attn(picking=picking)
|
||||
insurance_value = superself.get_insurance_value(picking=picking)
|
||||
residential = self._get_fedex_recipient_is_residential(recipient)
|
||||
|
||||
srm.web_authentication_detail(superself.fedex_developer_key, superself.fedex_developer_password)
|
||||
srm.client_detail(acc_number, meter_number)
|
||||
|
||||
srm.transaction_detail(picking.id)
|
||||
|
||||
package_type = picking.package_ids and picking.package_ids[0].packaging_id.shipper_package_code or self.fedex_default_packaging_id.shipper_package_code
|
||||
srm.shipment_request(self.fedex_droppoff_type, self.fedex_service_type, package_type, self.fedex_weight_unit, self.fedex_saturday_delivery)
|
||||
srm.set_currency(_convert_curr_iso_fdx(picking.company_id.currency_id.name))
|
||||
srm.set_shipper(shipper_company, shipper_warehouse)
|
||||
srm.set_recipient(recipient, attn=attn, residential=residential)
|
||||
|
||||
srm.shipping_charges_payment(payment_acc_number, third_party=bool(self.get_third_party_account(picking=picking)))
|
||||
|
||||
srm.shipment_label('COMMON2D', self.fedex_label_file_type, self.fedex_label_stock_type, 'TOP_EDGE_OF_TEXT_FIRST', 'SHIPPING_LABEL_FIRST')
|
||||
|
||||
order = picking.sale_id
|
||||
company = shipper_company
|
||||
order_currency = picking.sale_id.currency_id or picking.company_id.currency_id
|
||||
|
||||
net_weight = self._fedex_convert_weight(picking.shipping_weight, self.fedex_weight_unit)
|
||||
|
||||
# Commodities for customs declaration (international shipping)
|
||||
if self.fedex_service_type in ['INTERNATIONAL_ECONOMY', 'INTERNATIONAL_PRIORITY'] or (picking.partner_id.country_id.code == 'IN' and picking.picking_type_id.warehouse_id.partner_id.country_id.code == 'IN'):
|
||||
|
||||
commodity_currency = order_currency
|
||||
total_commodities_amount = 0.0
|
||||
commodity_country_of_manufacture = picking.picking_type_id.warehouse_id.partner_id.country_id.code
|
||||
|
||||
for operation in picking.move_line_ids:
|
||||
commodity_amount = operation.move_id.sale_line_id.price_reduce_taxinc or operation.product_id.list_price
|
||||
total_commodities_amount += (commodity_amount * operation.qty_done)
|
||||
commodity_description = operation.product_id.name
|
||||
commodity_number_of_piece = '1'
|
||||
commodity_weight_units = self.fedex_weight_unit
|
||||
commodity_weight_value = self._fedex_convert_weight(operation.product_id.weight * operation.qty_done, self.fedex_weight_unit)
|
||||
commodity_quantity = operation.qty_done
|
||||
commodity_quantity_units = 'EA'
|
||||
commodity_harmonized_code = operation.product_id.hs_code or ''
|
||||
srm.commodities(_convert_curr_iso_fdx(commodity_currency.name), commodity_amount, commodity_number_of_piece, commodity_weight_units, commodity_weight_value, commodity_description, commodity_country_of_manufacture, commodity_quantity, commodity_quantity_units, commodity_harmonized_code)
|
||||
srm.customs_value(_convert_curr_iso_fdx(commodity_currency.name), total_commodities_amount, "NON_DOCUMENTS")
|
||||
srm.duties_payment(shipper_warehouse.partner_id, acc_number, superself.fedex_duty_payment)
|
||||
send_etd = superself.env['ir.config_parameter'].get_param("delivery_fedex.send_etd")
|
||||
srm.commercial_invoice(self.fedex_document_stock_type, send_etd)
|
||||
|
||||
package_count = len(picking.package_ids) or 1
|
||||
|
||||
# For india picking courier is not accepted without this details in label.
|
||||
po_number = order.display_name or False
|
||||
dept_number = False
|
||||
if picking.partner_id.country_id.code == 'IN' and picking.picking_type_id.warehouse_id.partner_id.country_id.code == 'IN':
|
||||
po_number = 'B2B' if picking.partner_id.commercial_partner_id.is_company else 'B2C'
|
||||
dept_number = 'BILL D/T: SENDER'
|
||||
|
||||
# TODO RIM master: factorize the following crap
|
||||
|
||||
################
|
||||
# Multipackage #
|
||||
################
|
||||
if package_count > 1:
|
||||
|
||||
# Note: Fedex has a complex multi-piece shipping interface
|
||||
# - Each package has to be sent in a separate request
|
||||
# - First package is called "master" package and holds shipping-
|
||||
# related information, including addresses, customs...
|
||||
# - Last package responses contains shipping price and code
|
||||
# - If a problem happens with a package, every previous package
|
||||
# of the shipping has to be cancelled separately
|
||||
# (Why doing it in a simple way when the complex way exists??)
|
||||
|
||||
master_tracking_id = False
|
||||
package_labels = []
|
||||
carrier_tracking_ref = ""
|
||||
|
||||
for sequence, package in enumerate(picking.package_ids, start=1):
|
||||
|
||||
package_weight = self._fedex_convert_weight(package.shipping_weight, self.fedex_weight_unit)
|
||||
packaging = package.packaging_id
|
||||
|
||||
# Hibou Delivery
|
||||
# Add more details to package.
|
||||
srm._add_package(
|
||||
package_weight,
|
||||
package_code=packaging.shipper_package_code,
|
||||
package_height=packaging.height,
|
||||
package_width=packaging.width,
|
||||
package_length=packaging.length,
|
||||
sequence_number=sequence,
|
||||
po_number=po_number,
|
||||
dept_number=dept_number,
|
||||
# reference=picking.display_name,
|
||||
reference=('%s-%d' % (order_name, sequence)), # above "reference" is new in 13.0, using new name but old value
|
||||
insurance=insurance_value,
|
||||
)
|
||||
srm.set_master_package(net_weight, package_count, master_tracking_id=master_tracking_id)
|
||||
request = srm.process_shipment()
|
||||
package_name = package.name or sequence
|
||||
|
||||
warnings = request.get('warnings_message')
|
||||
if warnings:
|
||||
_logger.info(warnings)
|
||||
|
||||
# First package
|
||||
if sequence == 1:
|
||||
if not request.get('errors_message'):
|
||||
master_tracking_id = request['master_tracking_id']
|
||||
package_labels.append((package_name, srm.get_label()))
|
||||
carrier_tracking_ref = request['tracking_number']
|
||||
else:
|
||||
raise UserError(request['errors_message'])
|
||||
|
||||
# Intermediary packages
|
||||
elif sequence > 1 and sequence < package_count:
|
||||
if not request.get('errors_message'):
|
||||
package_labels.append((package_name, srm.get_label()))
|
||||
carrier_tracking_ref = carrier_tracking_ref + "," + request['tracking_number']
|
||||
else:
|
||||
raise UserError(request['errors_message'])
|
||||
|
||||
# Last package
|
||||
elif sequence == package_count:
|
||||
# recuperer le label pdf
|
||||
if not request.get('errors_message'):
|
||||
package_labels.append((package_name, srm.get_label()))
|
||||
|
||||
if _convert_curr_iso_fdx(order_currency.name) in request['price']:
|
||||
carrier_price = request['price'][_convert_curr_iso_fdx(order_currency.name)]
|
||||
else:
|
||||
_logger.info("Preferred currency has not been found in FedEx response")
|
||||
company_currency = picking.company_id.currency_id
|
||||
if _convert_curr_iso_fdx(company_currency.name) in request['price']:
|
||||
amount = request['price'][_convert_curr_iso_fdx(company_currency.name)]
|
||||
carrier_price = company_currency._convert(
|
||||
amount, order_currency, company, order.date_order or fields.Date.today())
|
||||
else:
|
||||
amount = request['price']['USD']
|
||||
carrier_price = company_currency._convert(
|
||||
amount, order_currency, company, order.date_order or fields.Date.today())
|
||||
|
||||
carrier_tracking_ref = carrier_tracking_ref + "," + request['tracking_number']
|
||||
|
||||
logmessage = _("Shipment created into Fedex<br/>"
|
||||
"<b>Tracking Numbers:</b> %s<br/>"
|
||||
"<b>Packages:</b> %s") % (carrier_tracking_ref, ','.join([pl[0] for pl in package_labels]))
|
||||
if self.fedex_label_file_type != 'PDF':
|
||||
attachments = [('LabelFedex-%s.%s' % (pl[0], self.fedex_label_file_type), pl[1]) for pl in package_labels]
|
||||
if self.fedex_label_file_type == 'PDF':
|
||||
attachments = [('LabelFedex.pdf', pdf.merge_pdf([pl[1] for pl in package_labels]))]
|
||||
picking.message_post(body=logmessage, attachments=attachments)
|
||||
shipping_data = {'exact_price': carrier_price,
|
||||
'tracking_number': carrier_tracking_ref}
|
||||
res = res + [shipping_data]
|
||||
else:
|
||||
raise UserError(request['errors_message'])
|
||||
|
||||
# TODO RIM handle if a package is not accepted (others should be deleted)
|
||||
|
||||
###############
|
||||
# One package #
|
||||
###############
|
||||
elif package_count == 1:
|
||||
packaging = picking.package_ids[:1].packaging_id or picking.carrier_id.fedex_default_packaging_id
|
||||
srm._add_package(
|
||||
net_weight,
|
||||
package_code=packaging.shipper_package_code,
|
||||
package_height=packaging.height,
|
||||
package_width=packaging.width,
|
||||
package_length=packaging.length,
|
||||
po_number=po_number,
|
||||
dept_number=dept_number,
|
||||
# reference=picking.display_name,
|
||||
reference=order_name, # above "reference" is new in 13.0, using new name but old value
|
||||
insurance=insurance_value,
|
||||
)
|
||||
srm.set_master_package(net_weight, 1)
|
||||
|
||||
# Ask the shipping to fedex
|
||||
request = srm.process_shipment()
|
||||
|
||||
warnings = request.get('warnings_message')
|
||||
if warnings:
|
||||
_logger.info(warnings)
|
||||
|
||||
if not request.get('errors_message'):
|
||||
|
||||
if _convert_curr_iso_fdx(order_currency.name) in request['price']:
|
||||
carrier_price = request['price'][_convert_curr_iso_fdx(order_currency.name)]
|
||||
else:
|
||||
_logger.info("Preferred currency has not been found in FedEx response")
|
||||
company_currency = picking.company_id.currency_id
|
||||
if _convert_curr_iso_fdx(company_currency.name) in request['price']:
|
||||
amount = request['price'][_convert_curr_iso_fdx(company_currency.name)]
|
||||
carrier_price = company_currency._convert(
|
||||
amount, order_currency, company, order.date_order or fields.Date.today())
|
||||
else:
|
||||
amount = request['price']['USD']
|
||||
carrier_price = company_currency._convert(
|
||||
amount, order_currency, company, order.date_order or fields.Date.today())
|
||||
|
||||
carrier_tracking_ref = request['tracking_number']
|
||||
logmessage = (_("Shipment created into Fedex <br/> <b>Tracking Number : </b>%s") % (carrier_tracking_ref))
|
||||
|
||||
fedex_labels = [('LabelFedex-%s-%s.%s' % (carrier_tracking_ref, index, self.fedex_label_file_type), label)
|
||||
for index, label in enumerate(srm._get_labels(self.fedex_label_file_type))]
|
||||
picking.message_post(body=logmessage, attachments=fedex_labels)
|
||||
shipping_data = {'exact_price': carrier_price,
|
||||
'tracking_number': carrier_tracking_ref}
|
||||
res = res + [shipping_data]
|
||||
else:
|
||||
raise UserError(request['errors_message'])
|
||||
|
||||
##############
|
||||
# No package #
|
||||
##############
|
||||
else:
|
||||
raise UserError(_('No packages for this picking'))
|
||||
if self.return_label_on_delivery:
|
||||
self.get_return_label(picking, tracking_number=request['tracking_number'], origin_date=request['date'])
|
||||
commercial_invoice = srm.get_document()
|
||||
if commercial_invoice:
|
||||
fedex_documents = [('DocumentFedex.pdf', commercial_invoice)]
|
||||
picking.message_post(body='Fedex Documents', attachments=fedex_documents)
|
||||
return res
|
||||
203
delivery_fedex_hibou/models/fedex_request.py
Normal file
203
delivery_fedex_hibou/models/fedex_request.py
Normal file
@@ -0,0 +1,203 @@
|
||||
from zeep.exceptions import Fault
|
||||
from odoo.addons.delivery_fedex.models import fedex_request
|
||||
from odoo.tools import remove_accents
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
STATECODE_REQUIRED_COUNTRIES = fedex_request.STATECODE_REQUIRED_COUNTRIES
|
||||
|
||||
|
||||
def sanitize_name(name):
|
||||
if isinstance(name, str):
|
||||
return name.replace('[', '').replace(']', '')
|
||||
return 'Unknown'
|
||||
|
||||
|
||||
class FedexRequest(fedex_request.FedexRequest):
|
||||
_transit_days = {
|
||||
'ONE_DAYS': 1,
|
||||
'ONE_DAY': 1,
|
||||
'TWO_DAYS': 2,
|
||||
'THREE_DAYS': 3,
|
||||
'FOUR_DAYS': 4,
|
||||
'FIVE_DAYS': 5,
|
||||
'SIX_DAYS': 6,
|
||||
'SEVEN_DAYS': 7,
|
||||
'EIGHT_DAYS': 8,
|
||||
'NINE_DAYS': 9,
|
||||
'TEN_DAYS': 10,
|
||||
}
|
||||
|
||||
_service_transit_days = {
|
||||
'FEDEX_2_DAY': 2,
|
||||
'FEDEX_2_DAY_AM': 2,
|
||||
'FIRST_OVERNIGHT': 1,
|
||||
'PRIORITY_OVERNIGHT': 1,
|
||||
'STANDARD_OVERNIGHT': 1,
|
||||
}
|
||||
|
||||
def set_recipient(self, recipient_partner, attn=None, residential=False):
|
||||
"""
|
||||
Adds ATTN: and sanitizes against known 'illegal' common characters in names.
|
||||
:param recipient_partner: default
|
||||
:param attn: NEW add to contact name as an ' ATTN: $attn'
|
||||
:param residential: NEW allow ground home delivery
|
||||
:return:
|
||||
"""
|
||||
Contact = self.factory.Contact()
|
||||
if recipient_partner.is_company:
|
||||
Contact.PersonName = ''
|
||||
Contact.CompanyName = sanitize_name(remove_accents(recipient_partner.name))
|
||||
else:
|
||||
Contact.PersonName = sanitize_name(remove_accents(recipient_partner.name))
|
||||
Contact.CompanyName = sanitize_name(remove_accents(recipient_partner.parent_id.name)) or ''
|
||||
|
||||
if attn:
|
||||
Contact.PersonName = Contact.PersonName + ' ATTN: ' + str(attn)
|
||||
|
||||
Contact.PhoneNumber = recipient_partner.phone or ''
|
||||
|
||||
Address = self.factory.Address()
|
||||
Address.StreetLines = [remove_accents(recipient_partner.street) or '', remove_accents(recipient_partner.street2) or '']
|
||||
Address.City = remove_accents(recipient_partner.city) or ''
|
||||
if recipient_partner.country_id.code in STATECODE_REQUIRED_COUNTRIES:
|
||||
Address.StateOrProvinceCode = recipient_partner.state_id.code or ''
|
||||
else:
|
||||
Address.StateOrProvinceCode = ''
|
||||
Address.PostalCode = recipient_partner.zip or ''
|
||||
Address.CountryCode = recipient_partner.country_id.code or ''
|
||||
|
||||
if residential:
|
||||
Address.Residential = True
|
||||
|
||||
self.RequestedShipment.Recipient = self.factory.Party()
|
||||
self.RequestedShipment.Recipient.Contact = Contact
|
||||
self.RequestedShipment.Recipient.Address = Address
|
||||
|
||||
def add_package(self, weight_value, package_code=False, package_height=0, package_width=0, package_length=0, sequence_number=False, mode='shipping', reference=False, insurance=False):
|
||||
# TODO remove in master and change the signature of a public method
|
||||
return self._add_package(weight_value=weight_value, package_code=package_code, package_height=package_height, package_width=package_width,
|
||||
package_length=package_length, sequence_number=sequence_number, mode=mode, po_number=False, dept_number=False, reference=reference, insurance=insurance)
|
||||
|
||||
def _add_package(self, weight_value, package_code=False, package_height=0, package_width=0, package_length=0, sequence_number=False, mode='shipping', po_number=False, dept_number=False, reference=False, insurance=False):
|
||||
package = self.factory.RequestedPackageLineItem()
|
||||
package_weight = self.factory.Weight()
|
||||
package_weight.Value = weight_value
|
||||
package_weight.Units = self.RequestedShipment.TotalWeight.Units
|
||||
|
||||
if insurance:
|
||||
insured = self.factory.Money()
|
||||
insured.Amount = insurance
|
||||
# TODO at some point someone might need currency here
|
||||
insured.Currency = 'USD'
|
||||
package.InsuredValue = insured
|
||||
|
||||
package.PhysicalPackaging = 'BOX'
|
||||
if package_code == 'YOUR_PACKAGING':
|
||||
package.Dimensions = self.factory.Dimensions()
|
||||
package.Dimensions.Height = package_height
|
||||
package.Dimensions.Width = package_width
|
||||
package.Dimensions.Length = package_length
|
||||
# TODO in master, add unit in product packaging and perform unit conversion
|
||||
package.Dimensions.Units = "IN" if self.RequestedShipment.TotalWeight.Units == 'LB' else 'CM'
|
||||
if po_number:
|
||||
po_reference = self.factory.CustomerReference()
|
||||
po_reference.CustomerReferenceType = 'P_O_NUMBER'
|
||||
po_reference.Value = po_number
|
||||
package.CustomerReferences.append(po_reference)
|
||||
if dept_number:
|
||||
dept_reference = self.factory.CustomerReference()
|
||||
dept_reference.CustomerReferenceType = 'DEPARTMENT_NUMBER'
|
||||
dept_reference.Value = dept_number
|
||||
package.CustomerReferences.append(dept_reference)
|
||||
if reference:
|
||||
customer_reference = self.factory.CustomerReference()
|
||||
customer_reference.CustomerReferenceType = 'CUSTOMER_REFERENCE'
|
||||
customer_reference.Value = reference
|
||||
package.CustomerReferences.append(customer_reference)
|
||||
|
||||
package.Weight = package_weight
|
||||
if mode == 'rating':
|
||||
package.GroupPackageCount = 1
|
||||
if sequence_number:
|
||||
package.SequenceNumber = sequence_number
|
||||
else:
|
||||
self.hasOnePackage = True
|
||||
|
||||
if mode == 'rating':
|
||||
self.RequestedShipment.RequestedPackageLineItems.append(package)
|
||||
else:
|
||||
self.RequestedShipment.RequestedPackageLineItems = package
|
||||
|
||||
def shipping_charges_payment(self, shipping_charges_payment_account, third_party=False):
|
||||
"""
|
||||
Allow 'shipping_charges_payment_account' to be considered 'third_party'
|
||||
:param shipping_charges_payment_account: default
|
||||
:param third_party: NEW add to indicate that the 'shipping_charges_payment_account' is third party.
|
||||
:return:
|
||||
"""
|
||||
self.RequestedShipment.ShippingChargesPayment = self.factory.Payment()
|
||||
self.RequestedShipment.ShippingChargesPayment.PaymentType = 'SENDER' if not third_party else 'THIRD_PARTY'
|
||||
Payor = self.factory.Payor()
|
||||
Payor.ResponsibleParty = self.factory.Party()
|
||||
Payor.ResponsibleParty.AccountNumber = shipping_charges_payment_account
|
||||
self.RequestedShipment.ShippingChargesPayment.Payor = Payor
|
||||
|
||||
# Rating stuff
|
||||
|
||||
def rate(self, date_planned=None):
|
||||
"""
|
||||
Response will contain 'transit_days' key with number of days.
|
||||
:param date_planned: Planned Outgoing shipment. Used to have FedEx tell us how long it will take for the package to arrive.
|
||||
:return:
|
||||
"""
|
||||
if date_planned:
|
||||
self.RequestedShipment.ShipTimestamp = date_planned
|
||||
|
||||
formatted_response = {'price': {}}
|
||||
del self.ClientDetail['Region']
|
||||
if self.hasCommodities:
|
||||
self.RequestedShipment.CustomsClearanceDetail.Commodities = self.listCommodities
|
||||
|
||||
try:
|
||||
self.response = self.client.service.getRates(WebAuthenticationDetail=self.WebAuthenticationDetail,
|
||||
ClientDetail=self.ClientDetail,
|
||||
TransactionDetail=self.TransactionDetail,
|
||||
Version=self.VersionId,
|
||||
RequestedShipment=self.RequestedShipment,
|
||||
ReturnTransitAndCommit=True) # New ReturnTransitAndCommit for CommitDetails in response
|
||||
if (self.response.HighestSeverity != 'ERROR' and self.response.HighestSeverity != 'FAILURE'):
|
||||
if not getattr(self.response, "RateReplyDetails", False):
|
||||
raise Exception("No rating found")
|
||||
for rating in self.response.RateReplyDetails[0].RatedShipmentDetails:
|
||||
formatted_response['price'][rating.ShipmentRateDetail.TotalNetFedExCharge.Currency] = float(rating.ShipmentRateDetail.TotalNetFedExCharge.Amount)
|
||||
if len(self.response.RateReplyDetails[0].RatedShipmentDetails) == 1:
|
||||
if 'CurrencyExchangeRate' in self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail and self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail['CurrencyExchangeRate']:
|
||||
formatted_response['price'][self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail.CurrencyExchangeRate.FromCurrency] = float(self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail.TotalNetFedExCharge.Amount) / float(self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail.CurrencyExchangeRate.Rate)
|
||||
|
||||
# Hibou Delivery Planning
|
||||
if hasattr(self.response.RateReplyDetails[0], 'DeliveryTimestamp') and self.response.RateReplyDetails[0].DeliveryTimestamp:
|
||||
formatted_response['date_delivered'] = self.response.RateReplyDetails[0].DeliveryTimestamp
|
||||
elif hasattr(self.response.RateReplyDetails[0], 'CommitDetails') and hasattr(self.response.RateReplyDetails[0].CommitDetails[0], 'CommitTimestamp'):
|
||||
formatted_response['date_delivered'] = self.response.RateReplyDetails[0].CommitDetails[0].CommitTimestamp
|
||||
formatted_response['transit_days'] = self._service_transit_days.get(self.response.RateReplyDetails[0].CommitDetails[0].ServiceType, 0)
|
||||
elif hasattr(self.response.RateReplyDetails[0], 'CommitDetails') and hasattr(self.response.RateReplyDetails[0].CommitDetails[0], 'TransitTime'):
|
||||
transit_days = self.response.RateReplyDetails[0].CommitDetails[0].TransitTime
|
||||
transit_days = self._transit_days.get(transit_days, 0)
|
||||
formatted_response['transit_days'] = transit_days
|
||||
else:
|
||||
errors_message = '\n'.join([("%s: %s" % (n.Code, n.Message)) for n in self.response.Notifications if (n.Severity == 'ERROR' or n.Severity == 'FAILURE')])
|
||||
formatted_response['errors_message'] = errors_message
|
||||
|
||||
if any([n.Severity == 'WARNING' for n in self.response.Notifications]):
|
||||
warnings_message = '\n'.join([("%s: %s" % (n.Code, n.Message)) for n in self.response.Notifications if n.Severity == 'WARNING'])
|
||||
formatted_response['warnings_message'] = warnings_message
|
||||
|
||||
except Fault as fault:
|
||||
formatted_response['errors_message'] = fault
|
||||
except IOError:
|
||||
formatted_response['errors_message'] = "Fedex Server Not Found"
|
||||
except Exception as e:
|
||||
formatted_response['errors_message'] = e.args[0]
|
||||
|
||||
return formatted_response
|
||||
8
delivery_fedex_hibou/models/stock.py
Normal file
8
delivery_fedex_hibou/models/stock.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class StockWarehouse(models.Model):
|
||||
_inherit = 'stock.warehouse'
|
||||
|
||||
fedex_account_number = fields.Char(string='FedEx Account Number')
|
||||
fedex_meter_number = fields.Char(string='FedEx Meter Number')
|
||||
14
delivery_fedex_hibou/views/stock_views.xml
Normal file
14
delivery_fedex_hibou/views/stock_views.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="fedex_view_warehouse" model="ir.ui.view">
|
||||
<field name="name">stock.warehouse</field>
|
||||
<field name="model">stock.warehouse</field>
|
||||
<field name="inherit_id" ref="stock.view_warehouse" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='partner_id']" position="after">
|
||||
<field name="fedex_account_number"/>
|
||||
<field name="fedex_meter_number"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
1
delivery_hibou/__init__.py
Normal file
1
delivery_hibou/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
24
delivery_hibou/__manifest__.py
Normal file
24
delivery_hibou/__manifest__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
'name': 'Delivery Hibou',
|
||||
'summary': 'Adds underlying pinnings for things like "RMA Return Labels"',
|
||||
'version': '13.0.1.0.0',
|
||||
'author': "Hibou Corp.",
|
||||
'category': 'Stock',
|
||||
'license': 'AGPL-3',
|
||||
'images': [],
|
||||
'website': "https://hibou.io",
|
||||
'description': """
|
||||
This is a collection of "typical" carrier needs, and a bridge into Hibou modules like `delivery_partner` and `sale_planner`.
|
||||
""",
|
||||
'depends': [
|
||||
'delivery',
|
||||
'delivery_partner',
|
||||
],
|
||||
'demo': [],
|
||||
'data': [
|
||||
'views/delivery_views.xml',
|
||||
'views/stock_views.xml',
|
||||
],
|
||||
'auto_install': False,
|
||||
'installable': True,
|
||||
}
|
||||
2
delivery_hibou/models/__init__.py
Normal file
2
delivery_hibou/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import delivery
|
||||
from . import stock
|
||||
159
delivery_hibou/models/delivery.py
Normal file
159
delivery_hibou/models/delivery.py
Normal file
@@ -0,0 +1,159 @@
|
||||
from odoo import fields, models
|
||||
from odoo.addons.stock.models.stock_move import PROCUREMENT_PRIORITIES
|
||||
|
||||
|
||||
class DeliveryCarrier(models.Model):
|
||||
_inherit = 'delivery.carrier'
|
||||
|
||||
automatic_insurance_value = fields.Float(string='Automatic Insurance Value',
|
||||
help='Will be used during shipping to determine if the '
|
||||
'picking\'s value warrants insurance being added.')
|
||||
procurement_priority = fields.Selection(PROCUREMENT_PRIORITIES,
|
||||
string='Procurement Priority',
|
||||
help='Priority for this carrier. Will affect pickings '
|
||||
'and procurements related to this carrier.')
|
||||
|
||||
# Utility
|
||||
|
||||
def get_insurance_value(self, order=None, picking=None):
|
||||
value = 0.0
|
||||
if order:
|
||||
if order.order_line:
|
||||
value = sum(order.order_line.filtered(lambda l: l.type != 'service').mapped('price_subtotal'))
|
||||
else:
|
||||
return value
|
||||
if picking:
|
||||
value = picking.declared_value()
|
||||
if picking.require_insurance == 'no':
|
||||
value = 0.0
|
||||
elif picking.require_insurance == 'auto' and self.automatic_insurance_value and self.automatic_insurance_value > value:
|
||||
value = 0.0
|
||||
return value
|
||||
|
||||
def get_third_party_account(self, order=None, picking=None):
|
||||
if order and order.shipping_account_id:
|
||||
return order.shipping_account_id
|
||||
if picking and picking.shipping_account_id:
|
||||
return picking.shipping_account_id
|
||||
return None
|
||||
|
||||
def get_order_name(self, order=None, picking=None):
|
||||
if order:
|
||||
return order.name
|
||||
if picking:
|
||||
if picking.sale_id:
|
||||
return picking.sale_id.name # + ' - ' + picking.name
|
||||
return picking.name
|
||||
return ''
|
||||
|
||||
def get_attn(self, order=None, picking=None):
|
||||
if order:
|
||||
return order.client_order_ref
|
||||
if picking and picking.sale_id:
|
||||
return picking.sale_id.client_order_ref
|
||||
# If Picking has a reference, decide what it is.
|
||||
return False
|
||||
|
||||
def _classify_picking(self, picking):
|
||||
if picking.picking_type_id.code == 'incoming' and picking.location_id.usage == 'supplier' and picking.location_dest_id.usage == 'customer':
|
||||
return 'dropship'
|
||||
elif picking.picking_type_id.code == 'incoming' and picking.location_id.usage == 'customer' and picking.location_dest_id.usage == 'supplier':
|
||||
return 'dropship_in'
|
||||
elif picking.picking_type_id.code == 'incoming':
|
||||
return 'in'
|
||||
return 'out'
|
||||
|
||||
def is_amazon(self, order=None, picking=None):
|
||||
"""
|
||||
Amazon MWS orders potentially need to be flagged for
|
||||
clean up on the carrier's side.
|
||||
|
||||
Override to return based on criteria in your company.
|
||||
:return:
|
||||
"""
|
||||
return False
|
||||
|
||||
# Shipper Company
|
||||
|
||||
def get_shipper_company(self, order=None, picking=None):
|
||||
"""
|
||||
Shipper Company: The `res.partner` that provides the name of where the shipment is coming from.
|
||||
"""
|
||||
if order:
|
||||
return order.company_id.partner_id
|
||||
if picking:
|
||||
return getattr(self, ('_get_shipper_company_%s' % (self._classify_picking(picking),)),
|
||||
self._get_shipper_company_out)(picking)
|
||||
return None
|
||||
|
||||
def _get_shipper_company_dropship(self, picking):
|
||||
return picking.company_id.partner_id
|
||||
|
||||
def _get_shipper_company_dropship_in(self, picking):
|
||||
return picking.company_id.partner_id
|
||||
|
||||
def _get_shipper_company_in(self, picking):
|
||||
return picking.company_id.partner_id
|
||||
|
||||
def _get_shipper_company_out(self, picking):
|
||||
return picking.company_id.partner_id
|
||||
|
||||
# Shipper Warehouse
|
||||
|
||||
def get_shipper_warehouse(self, order=None, picking=None):
|
||||
"""
|
||||
Shipper Warehouse: The `res.partner` that is basically the physical address a shipment is coming from.
|
||||
"""
|
||||
if order:
|
||||
return order.warehouse_id.partner_id
|
||||
if picking:
|
||||
return getattr(self, ('_get_shipper_warehouse_%s' % (self._classify_picking(picking),)),
|
||||
self._get_shipper_warehouse_out)(picking)
|
||||
return None
|
||||
|
||||
def _get_shipper_warehouse_dropship(self, picking):
|
||||
return picking.partner_id
|
||||
|
||||
def _get_shipper_warehouse_dropship_in(self, picking):
|
||||
if picking.sale_id:
|
||||
picking.sale_id.partner_shipping_id
|
||||
return self._get_shipper_warehouse_dropship_in_no_sale(picking)
|
||||
|
||||
def _get_shipper_warehouse_dropship_in_no_sale(self, picking):
|
||||
return picking.company_id.partner_id
|
||||
|
||||
def _get_shipper_warehouse_in(self, picking):
|
||||
return picking.partner_id
|
||||
|
||||
def _get_shipper_warehouse_out(self, picking):
|
||||
return picking.picking_type_id.warehouse_id.partner_id
|
||||
|
||||
# Recipient
|
||||
|
||||
def get_recipient(self, order=None, picking=None):
|
||||
"""
|
||||
Recipient: The `res.partner` receiving the shipment.
|
||||
"""
|
||||
if order:
|
||||
return order.partner_shipping_id
|
||||
if picking:
|
||||
return getattr(self, ('_get_recipient_%s' % (self._classify_picking(picking),)),
|
||||
self._get_recipient_out)(picking)
|
||||
return None
|
||||
|
||||
def _get_recipient_dropship(self, picking):
|
||||
if picking.sale_id:
|
||||
return picking.sale_id.partner_shipping_id
|
||||
return picking.partner_id
|
||||
|
||||
def _get_recipient_dropship_no_sale(self, picking):
|
||||
return picking.company_id.partner_id
|
||||
|
||||
def _get_recipient_dropship_in(self, picking):
|
||||
return picking.picking_type_id.warehouse_id.partner_id
|
||||
|
||||
def _get_recipient_in(self, picking):
|
||||
return picking.picking_type_id.warehouse_id.partner_id
|
||||
|
||||
def _get_recipient_out(self, picking):
|
||||
return picking.partner_id
|
||||
48
delivery_hibou/models/stock.py
Normal file
48
delivery_hibou/models/stock.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class StockPicking(models.Model):
|
||||
_inherit = 'stock.picking'
|
||||
|
||||
shipping_account_id = fields.Many2one('partner.shipping.account', string='Shipping Account')
|
||||
require_insurance = fields.Selection([
|
||||
('auto', 'Automatic'),
|
||||
('yes', 'Yes'),
|
||||
('no', 'No'),
|
||||
], string='Require Insurance', default='auto',
|
||||
help='If your carrier supports it, auto should be calculated off of the "Automatic Insurance Value" field.')
|
||||
|
||||
@api.depends('move_lines.priority', 'carrier_id')
|
||||
def _compute_priority(self):
|
||||
with_carrier_priority = self.filtered(lambda p: p.carrier_id.procurement_priority)
|
||||
for picking in with_carrier_priority:
|
||||
picking.priority = picking.carrier_id.procurement_priority
|
||||
super(StockPicking, (self-with_carrier_priority))._compute_priority()
|
||||
|
||||
@api.model
|
||||
def create(self, values):
|
||||
origin = values.get('origin')
|
||||
if origin and not values.get('shipping_account_id'):
|
||||
so = self.env['sale.order'].search([('name', '=', str(origin))], limit=1)
|
||||
if so and so.shipping_account_id:
|
||||
values['shipping_account_id'] = so.shipping_account_id.id
|
||||
|
||||
res = super(StockPicking, self).create(values)
|
||||
return res
|
||||
|
||||
def declared_value(self):
|
||||
self.ensure_one()
|
||||
cost = sum([(l.product_id.standard_price * l.qty_done) for l in self.move_line_ids] or [0.0])
|
||||
if not cost:
|
||||
# Assume Full Value
|
||||
cost = sum([(l.product_id.standard_price * l.product_uom_qty) for l in self.move_lines] or [0.0])
|
||||
return cost
|
||||
|
||||
|
||||
class StockMove(models.Model):
|
||||
_inherit = 'stock.move'
|
||||
|
||||
def _prepare_procurement_values(self):
|
||||
res = super(StockMove, self)._prepare_procurement_values()
|
||||
res['priority'] = self.picking_id.priority or self.priority
|
||||
return res
|
||||
1
delivery_hibou/tests/__init__.py
Normal file
1
delivery_hibou/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_delivery_hibou
|
||||
148
delivery_hibou/tests/test_delivery_hibou.py
Normal file
148
delivery_hibou/tests/test_delivery_hibou.py
Normal file
@@ -0,0 +1,148 @@
|
||||
from odoo.tests import common
|
||||
|
||||
|
||||
class TestDeliveryHibou(common.TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestDeliveryHibou, self).setUp()
|
||||
self.partner = self.env.ref('base.res_partner_address_13')
|
||||
self.product = self.env.ref('product.product_product_7')
|
||||
# Create Shipping Account
|
||||
self.shipping_account = self.env['partner.shipping.account'].create({
|
||||
'name': '123123',
|
||||
'delivery_type': 'other',
|
||||
})
|
||||
# Create Carrier
|
||||
self.delivery_product = self.env['product.product'].create({
|
||||
'name': 'Test Carrier1 Delivery',
|
||||
'type': 'service',
|
||||
})
|
||||
self.carrier = self.env['delivery.carrier'].create({
|
||||
'name': 'Test Carrier1',
|
||||
'product_id': self.delivery_product.id,
|
||||
})
|
||||
|
||||
def test_delivery_hibou(self):
|
||||
# Assign a new shipping account
|
||||
self.partner.shipping_account_id = self.shipping_account
|
||||
|
||||
# Assign values to new Carrier
|
||||
test_insurance_value = 600
|
||||
test_procurement_priority = '2'
|
||||
self.carrier.automatic_insurance_value = test_insurance_value
|
||||
self.carrier.procurement_priority = test_procurement_priority
|
||||
|
||||
|
||||
sale_order = self.env['sale.order'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'partner_shipping_id': self.partner.id,
|
||||
'partner_invoice_id': self.partner.id,
|
||||
'shipping_account_id': self.shipping_account.id,
|
||||
'order_line': [(0, 0, {
|
||||
'product_id': self.product.id,
|
||||
})]
|
||||
})
|
||||
self.assertFalse(sale_order.carrier_id)
|
||||
action = sale_order.action_open_delivery_wizard()
|
||||
form = common.Form(self.env[action['res_model']].with_context(**action.get('context', {})))
|
||||
form.carrier_id = self.carrier
|
||||
wizard = form.save()
|
||||
wizard.button_confirm()
|
||||
|
||||
#sale_order.set_delivery_line()
|
||||
self.assertEqual(sale_order.carrier_id, self.carrier)
|
||||
sale_order.action_confirm()
|
||||
# Make sure 3rd party Shipping Account is set.
|
||||
self.assertEqual(sale_order.shipping_account_id, self.shipping_account)
|
||||
|
||||
self.assertTrue(sale_order.picking_ids)
|
||||
# Priority coming from Carrier procurement_priority
|
||||
self.assertEqual(sale_order.picking_ids.priority, test_procurement_priority)
|
||||
# 3rd party Shipping Account copied from Sale Order
|
||||
self.assertEqual(sale_order.picking_ids.shipping_account_id, self.shipping_account)
|
||||
self.assertEqual(sale_order.carrier_id.get_third_party_account(order=sale_order), self.shipping_account)
|
||||
|
||||
# Test attn
|
||||
test_ref = 'TEST100'
|
||||
self.assertEqual(sale_order.carrier_id.get_attn(order=sale_order), False)
|
||||
sale_order.client_order_ref = test_ref
|
||||
self.assertEqual(sale_order.carrier_id.get_attn(order=sale_order), test_ref)
|
||||
# The picking should get this ref as well
|
||||
self.assertEqual(sale_order.picking_ids.carrier_id.get_attn(picking=sale_order.picking_ids), test_ref)
|
||||
|
||||
# Test order_name
|
||||
self.assertEqual(sale_order.carrier_id.get_order_name(order=sale_order), sale_order.name)
|
||||
# The picking should get the same 'order_name'
|
||||
self.assertEqual(sale_order.picking_ids.carrier_id.get_order_name(picking=sale_order.picking_ids), sale_order.name)
|
||||
|
||||
def test_carrier_hibou_out(self):
|
||||
test_insurance_value = 4000
|
||||
self.carrier.automatic_insurance_value = test_insurance_value
|
||||
|
||||
picking_out = self.env.ref('stock.outgoing_shipment_main_warehouse')
|
||||
picking_out.action_assign()
|
||||
self.assertEqual(picking_out.state, 'assigned')
|
||||
|
||||
picking_out.carrier_id = self.carrier
|
||||
|
||||
# This relies heavily on the 'stock' demo data.
|
||||
# Should only have a single move_line_ids and it should not be done at all.
|
||||
self.assertEqual(picking_out.move_line_ids.mapped('qty_done'), [0.0])
|
||||
self.assertEqual(picking_out.move_line_ids.mapped('product_uom_qty'), [15.0])
|
||||
self.assertEqual(picking_out.move_line_ids.mapped('product_id.standard_price'), [3300.0])
|
||||
|
||||
# The 'value' is assumed to be all of the product value from the initial demand.
|
||||
self.assertEqual(picking_out.declared_value(), 15.0 * 3300.0)
|
||||
self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), picking_out.declared_value())
|
||||
|
||||
# Workflow where user explicitly opts out of insurance on the picking level.
|
||||
picking_out.require_insurance = 'no'
|
||||
self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), 0.0)
|
||||
picking_out.require_insurance = 'auto'
|
||||
|
||||
# Lets choose to only delivery one piece at the moment.
|
||||
# This does not meet the minimum on the carrier to have insurance value.
|
||||
picking_out.move_line_ids.qty_done = 1.0
|
||||
self.assertEqual(picking_out.declared_value(), 3300.0)
|
||||
self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), 0.0)
|
||||
# Workflow where user opts in to insurance.
|
||||
picking_out.require_insurance = 'yes'
|
||||
self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), 3300.0)
|
||||
picking_out.require_insurance = 'auto'
|
||||
|
||||
# Test with picking having 3rd party account.
|
||||
self.assertEqual(picking_out.carrier_id.get_third_party_account(picking=picking_out), None)
|
||||
picking_out.shipping_account_id = self.shipping_account
|
||||
self.assertEqual(picking_out.carrier_id.get_third_party_account(picking=picking_out), self.shipping_account)
|
||||
|
||||
# Shipment Time Methods!
|
||||
self.assertEqual(picking_out.carrier_id._classify_picking(picking=picking_out), 'out')
|
||||
self.assertEqual(picking_out.carrier_id.get_shipper_company(picking=picking_out),
|
||||
picking_out.company_id.partner_id)
|
||||
self.assertEqual(picking_out.carrier_id.get_shipper_warehouse(picking=picking_out),
|
||||
picking_out.picking_type_id.warehouse_id.partner_id)
|
||||
self.assertEqual(picking_out.carrier_id.get_recipient(picking=picking_out),
|
||||
picking_out.partner_id)
|
||||
# This picking has no `sale_id`
|
||||
# Right now ATTN requires a sale_id, which this picking doesn't have (none of the stock ones do)
|
||||
self.assertEqual(picking_out.carrier_id.get_attn(picking=picking_out), False)
|
||||
self.assertEqual(picking_out.carrier_id.get_order_name(picking=picking_out), picking_out.name)
|
||||
|
||||
def test_carrier_hibou_in(self):
|
||||
picking_in = self.env.ref('stock.incomming_shipment1')
|
||||
self.assertEqual(picking_in.state, 'assigned')
|
||||
|
||||
picking_in.carrier_id = self.carrier
|
||||
# This relies heavily on the 'stock' demo data.
|
||||
# Should only have a single move_line_ids and it should not be done at all.
|
||||
self.assertEqual(picking_in.move_line_ids.mapped('qty_done'), [0.0])
|
||||
self.assertEqual(picking_in.move_line_ids.mapped('product_uom_qty'), [35.0])
|
||||
self.assertEqual(picking_in.move_line_ids.mapped('product_id.standard_price'), [55.0])
|
||||
|
||||
self.assertEqual(picking_in.carrier_id._classify_picking(picking=picking_in), 'in')
|
||||
self.assertEqual(picking_in.carrier_id.get_shipper_company(picking=picking_in),
|
||||
picking_in.company_id.partner_id)
|
||||
self.assertEqual(picking_in.carrier_id.get_shipper_warehouse(picking=picking_in),
|
||||
picking_in.partner_id)
|
||||
self.assertEqual(picking_in.carrier_id.get_recipient(picking=picking_in),
|
||||
picking_in.picking_type_id.warehouse_id.partner_id)
|
||||
14
delivery_hibou/views/delivery_views.xml
Normal file
14
delivery_hibou/views/delivery_views.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="view_delivery_carrier_form" model="ir.ui.view">
|
||||
<field name="name">hibou.delivery.carrier.form</field>
|
||||
<field name="model">delivery.carrier</field>
|
||||
<field name="inherit_id" ref="delivery.view_delivery_carrier_form" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='integration_level']" position="after">
|
||||
<field name="automatic_insurance_value"/>
|
||||
<field name="procurement_priority"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
16
delivery_hibou/views/stock_views.xml
Normal file
16
delivery_hibou/views/stock_views.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_picking_withcarrier_out_form" model="ir.ui.view">
|
||||
<field name="name">hibou.delivery.stock.picking_withcarrier.form.view</field>
|
||||
<field name="model">stock.picking</field>
|
||||
<field name="inherit_id" ref="delivery.view_picking_withcarrier_out_form" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='carrier_id']" position="before">
|
||||
<field name="require_insurance" attrs="{'readonly': [('state', 'in', ('done', 'cancel'))]}"/>
|
||||
<field name="shipping_account_id" attrs="{'readonly': [('state', 'in', ('done', 'cancel'))]}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
29
delivery_partner/README.rst
Normal file
29
delivery_partner/README.rst
Normal file
@@ -0,0 +1,29 @@
|
||||
*********************************
|
||||
Hibou - Partner Shipping Accounts
|
||||
*********************************
|
||||
|
||||
Records shipping account numbers on partners.
|
||||
|
||||
For more information and add-ons, visit `Hibou.io <https://hibou.io/>`_.
|
||||
|
||||
|
||||
=============
|
||||
Main Features
|
||||
=============
|
||||
|
||||
* New model: Customer Shipping Account
|
||||
* Includes manager-level access permissions.
|
||||
|
||||
.. image:: https://user-images.githubusercontent.com/15882954/41176601-e40f8558-6b15-11e8-998e-6a7ee5709c0f.png
|
||||
:alt: 'Register Payment Detail'
|
||||
:width: 988
|
||||
:align: left
|
||||
|
||||
|
||||
=======
|
||||
License
|
||||
=======
|
||||
|
||||
Please see `LICENSE <https://github.com/hibou-io/hibou-odoo-suite/blob/11.0/LICENSE>`_.
|
||||
|
||||
Copyright Hibou Corp. 2018
|
||||
1
delivery_partner/__init__.py
Normal file
1
delivery_partner/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
24
delivery_partner/__manifest__.py
Executable file
24
delivery_partner/__manifest__.py
Executable file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
'name': 'Partner Shipping Accounts',
|
||||
'author': 'Hibou Corp. <hello@hibou.io>',
|
||||
'version': '13.0.1.0.0',
|
||||
'category': 'Stock',
|
||||
'sequence': 95,
|
||||
'summary': 'Record shipping account numbers on partners.',
|
||||
'description': """
|
||||
Record shipping account numbers on partners.
|
||||
|
||||
* Customer Shipping Account Model
|
||||
""",
|
||||
'website': 'https://hibou.io/',
|
||||
'depends': [
|
||||
'delivery',
|
||||
'contacts',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/delivery_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
}
|
||||
1
delivery_partner/models/__init__.py
Normal file
1
delivery_partner/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import delivery
|
||||
47
delivery_partner/models/delivery.py
Normal file
47
delivery_partner/models/delivery.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class Partner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
shipping_account_ids = fields.One2many('partner.shipping.account', 'partner_id', string='Shipping Accounts')
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
shipping_account_id = fields.Many2one('partner.shipping.account', string='Shipping Account')
|
||||
|
||||
|
||||
class PartnerShippingAccount(models.Model):
|
||||
_name = 'partner.shipping.account'
|
||||
|
||||
name = fields.Char(string='Account Num.', required=True)
|
||||
description = fields.Char(string='Description')
|
||||
partner_id = fields.Many2one('res.partner', string='Partner', help='Leave blank to allow as a generic 3rd party shipper.')
|
||||
delivery_type = fields.Selection([
|
||||
('other', 'Other'),
|
||||
], string='Carrier', required=True)
|
||||
note = fields.Text(string='Internal Note')
|
||||
|
||||
def name_get(self):
|
||||
delivery_types = self._fields['delivery_type']._description_selection(self.env)
|
||||
|
||||
def get_name(value):
|
||||
name = [n for v, n in delivery_types if v == value]
|
||||
return name[0] if name else 'Undefined'
|
||||
|
||||
res = []
|
||||
for acc in self:
|
||||
if acc.description:
|
||||
res.append((acc.id, acc.description))
|
||||
else:
|
||||
res.append((acc.id, '%s: %s' % (get_name(acc.delivery_type), acc.name)))
|
||||
return res
|
||||
|
||||
@api.constrains('name', 'delivery_type')
|
||||
def _check_validity(self):
|
||||
for acc in self:
|
||||
check = getattr(acc, acc.delivery_type + '_check_validity', None)
|
||||
if check:
|
||||
return check()
|
||||
2
delivery_partner/security/ir.model.access.csv
Normal file
2
delivery_partner/security/ir.model.access.csv
Normal file
@@ -0,0 +1,2 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_partner_shipping_account,partner.shipping.account,model_partner_shipping_account,base.group_partner_manager,1,1,1,1
|
||||
|
93
delivery_partner/views/delivery_views.xml
Normal file
93
delivery_partner/views/delivery_views.xml
Normal file
@@ -0,0 +1,93 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="partner_shipping_account_view_tree" model="ir.ui.view">
|
||||
<field name="name">partner.shipping.account.tree</field>
|
||||
<field name="model">partner.shipping.account</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Shipping Accounts">
|
||||
<field name="description"/>
|
||||
<field name="name"/>
|
||||
<field name="delivery_type"/>
|
||||
<field name="partner_id"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="partner_shipping_account_view_form" model="ir.ui.view">
|
||||
<field name="name">partner.shipping.account.form</field>
|
||||
<field name="model">partner.shipping.account</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Shipping Account">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="partner_id"/>
|
||||
<field name="description"/>
|
||||
<field name="name"/>
|
||||
<field name="delivery_type"/>
|
||||
</group>
|
||||
<group name="carrier"/>
|
||||
</group>
|
||||
<field name="note" placeholder="Any additional notes..."/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="partner_shipping_account_view_search" model="ir.ui.view">
|
||||
<field name="name">partner.shipping.account.search</field>
|
||||
<field name="model">partner.shipping.account</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Shipping Account Search">
|
||||
<field name="description"/>
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="delivery_type"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="partner_shipping_account_action_main" model="ir.actions.act_window">
|
||||
<field name="name">Shipping Accounts</field>
|
||||
<field name="res_model">partner.shipping.account</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="help" type="html">
|
||||
<p>
|
||||
No accounts
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="partner_shipping_account_menu_main" name="Partner Shipping Accounts"
|
||||
action="delivery_partner.partner_shipping_account_action_main"
|
||||
sequence="20" parent="contacts.res_partner_menu_config"/>
|
||||
|
||||
<!-- Inherited -->
|
||||
<record id="view_partner_property_form_inherit" model="ir.ui.view">
|
||||
<field name="name">res.partner.carrier.property.form.inherit</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="inherit_id" ref="delivery.view_partner_property_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='property_delivery_carrier_id']" position="after">
|
||||
<field name="shipping_account_ids" context="{'default_partner_id': active_id}">
|
||||
<tree>
|
||||
<field name="description"/>
|
||||
<field name="name"/>
|
||||
<field name="delivery_type"/>
|
||||
</tree>
|
||||
</field>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_order_form_inherit" model="ir.ui.view">
|
||||
<field name="name">sale.order.form.inherit</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='payment_term_id']" position="before">
|
||||
<field name="shipping_account_id" options="{'no_create': True, 'no_open': True}" domain="['|', ('partner_id', '=', False), ('partner_id', '=', partner_id)]"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
1
external/camptocamp-cloud-platform
vendored
Submodule
1
external/camptocamp-cloud-platform
vendored
Submodule
Submodule external/camptocamp-cloud-platform added at 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>
|
||||
3
hr_commission/__init__.py
Normal file
3
hr_commission/__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
|
||||
27
hr_commission/__manifest__.py
Normal file
27
hr_commission/__manifest__.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||
|
||||
{
|
||||
'name': 'Hibou Commissions',
|
||||
'author': 'Hibou Corp. <hello@hibou.io>',
|
||||
'version': '13.0.1.0.1',
|
||||
'category': 'Accounting/Commissions',
|
||||
'license': 'OPL-1',
|
||||
'website': 'https://hibou.io/',
|
||||
'depends': [
|
||||
# 'account_invoice_margin', # optional
|
||||
'hibou_professional',
|
||||
'hr_contract',
|
||||
],
|
||||
'data': [
|
||||
'security/commission_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'views/account_views.xml',
|
||||
'views/commission_views.xml',
|
||||
'views/hr_views.xml',
|
||||
'views/partner_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'auto_install': False,
|
||||
}
|
||||
8
hr_commission/models/__init__.py
Executable file
8
hr_commission/models/__init__.py
Executable file
@@ -0,0 +1,8 @@
|
||||
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||
|
||||
from . import account
|
||||
from . import commission
|
||||
from . import hr
|
||||
from . import partner
|
||||
from . import res_company
|
||||
from . import res_config_settings
|
||||
50
hr_commission/models/account.py
Normal file
50
hr_commission/models/account.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = 'account.move'
|
||||
|
||||
commission_ids = fields.One2many(comodel_name='hr.commission', inverse_name='source_move_id', string='Commissions')
|
||||
commission_count = fields.Integer(string='Number of Commissions', compute='_compute_commission_count')
|
||||
|
||||
@api.depends('state', 'commission_ids')
|
||||
def _compute_commission_count(self):
|
||||
for move in self:
|
||||
move.commission_count = len(move.commission_ids)
|
||||
return True
|
||||
|
||||
def open_commissions(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Invoice Commissions',
|
||||
'res_model': 'hr.commission',
|
||||
'view_mode': 'tree,form',
|
||||
'context': {'search_default_source_move_id': self[0].id}
|
||||
}
|
||||
|
||||
def post(self):
|
||||
res = super(AccountMove, self).post()
|
||||
invoices = self.filtered(lambda m: m.is_invoice())
|
||||
if invoices:
|
||||
self.env['hr.commission'].invoice_validated(invoices)
|
||||
return res
|
||||
|
||||
def action_invoice_paid(self):
|
||||
res = super(AccountMove, self).action_invoice_paid()
|
||||
self.env['hr.commission'].invoice_paid(self)
|
||||
return res
|
||||
|
||||
def amount_for_commission(self):
|
||||
# TODO Should toggle in Config Params
|
||||
if hasattr(self, 'margin') and self.company_id.commission_amount_type == 'on_invoice_margin':
|
||||
sign = -1 if self.type in ['in_refund', 'out_refund'] else 1
|
||||
return self.margin * sign
|
||||
return self.amount_total_signed
|
||||
|
||||
def action_cancel(self):
|
||||
res = super(AccountMove, self).action_cancel()
|
||||
for move in self:
|
||||
move.sudo().commission_ids.unlink()
|
||||
return res
|
||||
329
hr_commission/models/commission.py
Normal file
329
hr_commission/models/commission.py
Normal file
@@ -0,0 +1,329 @@
|
||||
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.tools import float_is_zero
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class Commission(models.Model):
|
||||
_name = 'hr.commission'
|
||||
_description = 'Commission'
|
||||
_order = 'id desc'
|
||||
|
||||
state = fields.Selection([
|
||||
('draft', 'New'),
|
||||
('done', 'Confirmed'),
|
||||
('paid', 'Paid'),
|
||||
('cancel', 'Cancelled'),
|
||||
], 'Status', default='draft')
|
||||
employee_id = fields.Many2one('hr.employee', required=1)
|
||||
user_id = fields.Many2one('res.users', related='employee_id.user_id')
|
||||
source_move_id = fields.Many2one('account.move')
|
||||
contract_id = fields.Many2one('hr.contract')
|
||||
structure_id = fields.Many2one('hr.commission.structure')
|
||||
rate_type = fields.Selection([
|
||||
('normal', 'Normal'),
|
||||
('structure', 'Structure'),
|
||||
('admin', 'Admin'),
|
||||
('manual', 'Manual'),
|
||||
], 'Rate Type', default='normal')
|
||||
rate = fields.Float('Rate')
|
||||
base_total = fields.Float('Base Total')
|
||||
base_amount = fields.Float(string='Base Amount')
|
||||
amount = fields.Float(string='Amount')
|
||||
move_id = fields.Many2one('account.move', ondelete='set null')
|
||||
move_date = fields.Date(related='move_id.date', store=True)
|
||||
company_id = fields.Many2one('res.company', 'Company', required=True,
|
||||
default=lambda s: s.env['res.company']._company_default_get('hr.commission'))
|
||||
memo = fields.Char(string='Memo')
|
||||
accounting_date = fields.Date('Force Accounting Date',
|
||||
help="Choose the accounting date at which you want to value the commission "
|
||||
"moves created by the commission instead of the default one.")
|
||||
payment_id = fields.Many2one('hr.commission.payment', string='Commission Payment', ondelete='set null')
|
||||
|
||||
@api.depends('employee_id', 'source_move_id')
|
||||
def name_get(self):
|
||||
res = []
|
||||
for commission in self:
|
||||
name = ''
|
||||
if commission.source_move_id:
|
||||
name += commission.source_move_id.name
|
||||
if commission.employee_id:
|
||||
if name:
|
||||
name += ' - ' + commission.employee_id.name
|
||||
else:
|
||||
name += commission.employee_id.name
|
||||
res.append((commission.id, name))
|
||||
return res
|
||||
|
||||
@api.onchange('rate_type')
|
||||
def _onchange_rate_type(self):
|
||||
for commission in self.filtered(lambda c: c.rate_type == 'manual'):
|
||||
commission.rate = 100.0
|
||||
|
||||
@api.onchange('source_move_id', 'contract_id', 'rate_type', 'base_amount', 'rate')
|
||||
def _compute_amount(self):
|
||||
for commission in self:
|
||||
# Determine rate (if needed)
|
||||
if commission.structure_id and commission.rate_type == 'structure':
|
||||
line = commission.structure_id.line_ids.filtered(lambda l: l.employee_id == commission.employee_id)
|
||||
commission.rate = line.get_rate()
|
||||
elif commission.contract_id and commission.rate_type != 'manual':
|
||||
if commission.rate_type == 'normal':
|
||||
commission.rate = commission.contract_id.commission_rate
|
||||
else:
|
||||
commission.rate = commission.contract_id.admin_commission_rate
|
||||
|
||||
rounding = 2
|
||||
if commission.source_move_id:
|
||||
rounding = commission.source_move_id.company_currency_id.rounding
|
||||
commission.base_total = commission.source_move_id.amount_total_signed
|
||||
commission.base_amount = commission.source_move_id.amount_for_commission()
|
||||
|
||||
amount = (commission.base_amount * commission.rate) / 100.0
|
||||
if float_is_zero(amount, precision_rounding=rounding):
|
||||
amount = 0.0
|
||||
commission.amount = amount
|
||||
|
||||
|
||||
@api.model
|
||||
def create(self, values):
|
||||
res = super(Commission, self).create(values)
|
||||
res._compute_amount()
|
||||
if res.amount == 0.0 and res.state == 'draft':
|
||||
res.state = 'done'
|
||||
return res
|
||||
|
||||
def unlink(self):
|
||||
if self.filtered(lambda c: c.move_id):
|
||||
raise UserError('You cannot delete a commission when it has an accounting entry.')
|
||||
return super(Commission, self).unlink()
|
||||
|
||||
def _filter_source_moves_for_creation(self, moves):
|
||||
return moves.filtered(lambda i: i.user_id and not i.commission_ids)
|
||||
|
||||
@api.model
|
||||
def _commissions_to_confirm(self, moves):
|
||||
commissions = moves.mapped('commission_ids')
|
||||
return commissions.filtered(lambda c: c.state != 'cancel' and not c.move_id)
|
||||
|
||||
@api.model
|
||||
def invoice_validated(self, moves):
|
||||
employee_obj = self.env['hr.employee'].sudo()
|
||||
commission_obj = self.sudo()
|
||||
for move in self._filter_source_moves_for_creation(moves):
|
||||
move_amount = move.amount_for_commission()
|
||||
|
||||
# Does the invoice have a commission structure?
|
||||
partner = move.partner_id
|
||||
commission_structure = partner.commission_structure_id
|
||||
while not commission_structure and partner:
|
||||
partner = partner.parent_id
|
||||
commission_structure = partner.commission_structure_id
|
||||
|
||||
if commission_structure:
|
||||
commission_structure.create_for_source_move(move, move_amount)
|
||||
else:
|
||||
employee = employee_obj.search([('user_id', '=', move.user_id.id)], limit=1)
|
||||
contract = employee.contract_id
|
||||
if all((employee, contract)):
|
||||
move.commission_ids += commission_obj.create({
|
||||
'employee_id': employee.id,
|
||||
'contract_id': contract.id,
|
||||
'source_move_id': move.id,
|
||||
'base_amount': move_amount,
|
||||
'rate_type': 'normal',
|
||||
'company_id': move.company_id.id,
|
||||
})
|
||||
|
||||
# Admin/Coach commission.
|
||||
employee = employee.coach_id
|
||||
contract = employee.contract_id
|
||||
if all((employee, contract)):
|
||||
move.commission_ids += commission_obj.create({
|
||||
'employee_id': employee.id,
|
||||
'contract_id': contract.id,
|
||||
'source_move_id': move.id,
|
||||
'base_amount': move_amount,
|
||||
'rate_type': 'admin',
|
||||
'company_id': move.company_id.id,
|
||||
})
|
||||
|
||||
if move.commission_ids and move.company_id.commission_type == 'on_invoice':
|
||||
commissions = self._commissions_to_confirm(move)
|
||||
commissions.sudo().action_confirm()
|
||||
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def invoice_paid(self, moves):
|
||||
commissions = self._commissions_to_confirm(moves)
|
||||
commissions.sudo().action_confirm()
|
||||
return True
|
||||
|
||||
def action_confirm(self):
|
||||
move_obj = self.env['account.move'].sudo()
|
||||
|
||||
for commission in self:
|
||||
if commission.state == 'cancel':
|
||||
continue
|
||||
if commission.move_id or commission.amount == 0.0:
|
||||
commission.write({'state': 'done'})
|
||||
continue
|
||||
|
||||
journal = commission.company_id.commission_journal_id
|
||||
if not journal or not journal.default_debit_account_id or not journal.default_credit_account_id:
|
||||
raise UserError('Commission Journal not configured.')
|
||||
|
||||
liability_account = commission.company_id.commission_liability_id
|
||||
if not liability_account:
|
||||
liability_account = commission.employee_id.address_home_id.property_account_payable_id
|
||||
if not liability_account:
|
||||
raise UserError('Commission liability account must be configured if employee\'s don\'t have AP setup.')
|
||||
|
||||
date = commission.source_move_id.date if commission.source_move_id else fields.Date.context_today(commission)
|
||||
|
||||
# Already paid.
|
||||
payments = commission.source_move_id._get_reconciled_payments()
|
||||
if payments:
|
||||
date = max(payments.mapped('payment_date'))
|
||||
if commission.accounting_date:
|
||||
date = commission.accounting_date
|
||||
|
||||
ref = 'Commission for ' + commission.name_get()[0][1]
|
||||
if commission.memo:
|
||||
ref += ' :: ' + commission.memo
|
||||
|
||||
move = move_obj.create({
|
||||
'date': date,
|
||||
'ref': ref,
|
||||
'journal_id': journal.id,
|
||||
'type': 'entry',
|
||||
'line_ids': [
|
||||
(0, 0, {
|
||||
'name': ref,
|
||||
'partner_id': commission.employee_id.address_home_id.id,
|
||||
'account_id': liability_account.id,
|
||||
'credit': commission.amount if commission.amount > 0.0 else 0.0,
|
||||
'debit': 0.0 if commission.amount > 0.0 else -commission.amount,
|
||||
}),
|
||||
(0, 0, {
|
||||
'name': ref,
|
||||
'partner_id': commission.employee_id.address_home_id.id,
|
||||
'account_id': journal.default_credit_account_id.id if commission.amount > 0.0 else journal.default_debit_account_id.id,
|
||||
'credit': 0.0 if commission.amount > 0.0 else -commission.amount,
|
||||
'debit': commission.amount if commission.amount > 0.0 else 0.0,
|
||||
}),
|
||||
],
|
||||
})
|
||||
move.post()
|
||||
commission.write({'state': 'done', 'move_id': move.id})
|
||||
return True
|
||||
|
||||
def action_mark_paid(self):
|
||||
if self.filtered(lambda c: c.state != 'done'):
|
||||
raise UserError('You cannot mark a commission "paid" if it is not already "done".')
|
||||
if not self:
|
||||
raise UserError('You must have at least one "done" commission.')
|
||||
payments = self._mark_paid()
|
||||
action = self.env.ref('hr_commission.action_hr_commission_payment').read()[0]
|
||||
action['res_ids'] = payments.ids
|
||||
return action
|
||||
|
||||
def _mark_paid(self):
|
||||
employees = self.mapped('employee_id')
|
||||
payments = self.env['hr.commission.payment']
|
||||
for employee in employees:
|
||||
commissions = self.filtered(lambda c: c.employee_id == employee)
|
||||
min_date = False
|
||||
max_date = False
|
||||
for commission in commissions:
|
||||
if not min_date or (commission.move_date and min_date > commission.move_date):
|
||||
min_date = commission.move_date
|
||||
if not max_date or (commission.move_date and max_date < commission.move_date):
|
||||
max_date = commission.move_date
|
||||
payment = payments.create({
|
||||
'employee_id': employee.id,
|
||||
'name': ('Commissions %s - %s' % (min_date, max_date)),
|
||||
'date': fields.Date.today(),
|
||||
})
|
||||
payments += payment
|
||||
commissions.write({'state': 'paid', 'payment_id': payment.id})
|
||||
return payments
|
||||
|
||||
def action_cancel(self):
|
||||
for commission in self:
|
||||
if commission.move_id:
|
||||
commission.move_id.write({'state': 'draft'})
|
||||
commission.move_id.unlink()
|
||||
commission.write({'state': 'cancel'})
|
||||
return True
|
||||
|
||||
def action_draft(self):
|
||||
for commission in self.filtered(lambda c: c.state == 'cancel'):
|
||||
commission.write({'state': 'draft'})
|
||||
|
||||
|
||||
class CommissionPayment(models.Model):
|
||||
_name = 'hr.commission.payment'
|
||||
_description = 'Commission Payment'
|
||||
_order = 'id desc'
|
||||
|
||||
name = fields.Char(string='Name')
|
||||
employee_id = fields.Many2one('hr.employee', required=1)
|
||||
user_id = fields.Many2one('res.users', related='employee_id.user_id')
|
||||
date = fields.Date(string='Date')
|
||||
commission_ids = fields.One2many('hr.commission', 'payment_id', string='Paid Commissions', readonly=True)
|
||||
commission_count = fields.Integer(string='Commission Count', compute='_compute_commission_stats', store=True)
|
||||
commission_amount = fields.Float(string='Commission Amount', compute='_compute_commission_stats', store=True)
|
||||
|
||||
@api.depends('commission_ids')
|
||||
def _compute_commission_stats(self):
|
||||
for payment in self:
|
||||
payment.commission_count = len(payment.commission_ids)
|
||||
payment.commission_amount = sum(payment.commission_ids.mapped('amount'))
|
||||
|
||||
|
||||
class CommissionStructure(models.Model):
|
||||
_name = 'hr.commission.structure'
|
||||
_description = 'Commission Structure'
|
||||
_order = 'id desc'
|
||||
|
||||
name = fields.Char(string='Name')
|
||||
line_ids = fields.One2many('hr.commission.structure.line', 'structure_id', string='Lines')
|
||||
|
||||
def create_for_source_move(self, move, amount):
|
||||
self.ensure_one()
|
||||
commission_obj = self.env['hr.commission'].sudo()
|
||||
|
||||
for line in self.line_ids:
|
||||
employee = line.employee_id
|
||||
rate = line.get_rate()
|
||||
if all((employee, rate)):
|
||||
contract = False
|
||||
if not line.rate:
|
||||
# The rate must have come from the contract.
|
||||
contract = employee.contract_id
|
||||
move.commission_ids += commission_obj.create({
|
||||
'employee_id': employee.id,
|
||||
'structure_id': self.id,
|
||||
'source_move_id': move.id,
|
||||
'base_amount': amount,
|
||||
'rate_type': 'structure',
|
||||
'contract_id': contract.id if contract else False,
|
||||
'company_id': move.company_id.id,
|
||||
})
|
||||
|
||||
|
||||
class CommissionStructureLine(models.Model):
|
||||
_name = 'hr.commission.structure.line'
|
||||
_description = 'Commission Structure Line'
|
||||
|
||||
structure_id = fields.Many2one('hr.commission.structure', string='Structure', required=True)
|
||||
employee_id = fields.Many2one('hr.employee', string='Employee', required=True)
|
||||
rate = fields.Float(string='Commission %', default=0.0, help='Leave 0.0 to use the employee\'s current contract rate.')
|
||||
|
||||
def get_rate(self):
|
||||
if not self.rate:
|
||||
return self.employee_id.contract_id.commission_rate
|
||||
return self.rate
|
||||
11
hr_commission/models/hr.py
Normal file
11
hr_commission/models/hr.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class Contract(models.Model):
|
||||
_inherit = 'hr.contract'
|
||||
|
||||
commission_rate = fields.Float(string='Commission %', default=0.0)
|
||||
admin_commission_rate = fields.Float(string='Admin Commission %', default=0.0)
|
||||
|
||||
9
hr_commission/models/partner.py
Normal file
9
hr_commission/models/partner.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class Partner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
commission_structure_id = fields.Many2one('hr.commission.structure', string='Commission Structure')
|
||||
18
hr_commission/models/res_company.py
Normal file
18
hr_commission/models/res_company.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
commission_journal_id = fields.Many2one('account.journal', string='Commission Journal')
|
||||
commission_liability_id = fields.Many2one('account.account', string='Commission Liability Account')
|
||||
commission_type = fields.Selection([
|
||||
('on_invoice', 'On Invoice Validation'),
|
||||
('on_invoice_paid', 'On Invoice Paid'),
|
||||
], string='Pay Commission', default='on_invoice_paid')
|
||||
commission_amount_type = fields.Selection([
|
||||
('on_invoice_margin', 'On Invoice Margin'),
|
||||
('on_invoice_total', 'On Invoice Total'),
|
||||
], string='Commission Base', default='on_invoice_margin')
|
||||
12
hr_commission/models/res_config_settings.py
Normal file
12
hr_commission/models/res_config_settings.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
commission_journal_id = fields.Many2one(related='company_id.commission_journal_id', readonly=False)
|
||||
commission_liability_id = fields.Many2one(related='company_id.commission_liability_id', readonly=False)
|
||||
commission_type = fields.Selection(related='company_id.commission_type', readonly=False)
|
||||
commission_amount_type = fields.Selection(related='company_id.commission_amount_type', readonly=False)
|
||||
34
hr_commission/security/commission_security.xml
Normal file
34
hr_commission/security/commission_security.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
|
||||
<record id="hr_commission_rule" model="ir.rule">
|
||||
<field name="name">Commission User</field>
|
||||
<field name="model_id" ref="model_hr_commission"/>
|
||||
<field name="domain_force">[('user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_commission_rule_manager" model="ir.rule">
|
||||
<field name="name">Commission Manager</field>
|
||||
<field name="model_id" ref="model_hr_commission"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('account.group_account_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_commission_payment_rule" model="ir.rule">
|
||||
<field name="name">Commission Payment User</field>
|
||||
<field name="model_id" ref="model_hr_commission_payment"/>
|
||||
<field name="domain_force">[('user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_commission_payment_rule_manager" model="ir.rule">
|
||||
<field name="name">Commission Payment Manager</field>
|
||||
<field name="model_id" ref="model_hr_commission_payment"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('account.group_account_manager'))]"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
9
hr_commission/security/ir.model.access.csv
Normal file
9
hr_commission/security/ir.model.access.csv
Normal file
@@ -0,0 +1,9 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_commission_user,commission user,model_hr_commission,base.group_user,1,0,0,0
|
||||
access_commission_manager,commission manager,model_hr_commission,account.group_account_manager,1,1,1,1
|
||||
access_commission_payment_user,commission payment user,model_hr_commission_payment,base.group_user,1,0,0,0
|
||||
access_commission_payment_manager,commission payment manager,model_hr_commission_payment,account.group_account_manager,1,1,1,1
|
||||
access_commission_structure_user,commission structure user,model_hr_commission_structure,base.group_user,1,0,0,0
|
||||
access_commission_structure_manager,commission structure manager,model_hr_commission_structure,account.group_account_manager,1,1,1,1
|
||||
access_commission_structure_line_user,commission structure line user,model_hr_commission_structure_line,base.group_user,1,0,0,0
|
||||
access_commission_structure_line_manager,commission structure line manager,model_hr_commission_structure_line,account.group_account_manager,1,1,1,1
|
||||
|
BIN
hr_commission/static/description/icon.png
Normal file
BIN
hr_commission/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
3
hr_commission/tests/__init__.py
Executable file
3
hr_commission/tests/__init__.py
Executable file
@@ -0,0 +1,3 @@
|
||||
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||
|
||||
from . import test_commission
|
||||
232
hr_commission/tests/test_commission.py
Normal file
232
hr_commission/tests/test_commission.py
Normal file
@@ -0,0 +1,232 @@
|
||||
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||
|
||||
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):
|
||||
# TODO refactor tests to not require sale.order
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = self.browse_ref('base.user_demo')
|
||||
self.employee = self.browse_ref('hr.employee_qdp') # This is the employee associated with above user.
|
||||
|
||||
def _createUser(self):
|
||||
return self.env['res.users'].create({
|
||||
'name': 'Coach',
|
||||
'email': 'coach',
|
||||
})
|
||||
|
||||
def _createEmployee(self, user):
|
||||
return self.env['hr.employee'].create({
|
||||
'birthday': '1985-03-14',
|
||||
'country_id': self.ref('base.us'),
|
||||
'department_id': self.ref('hr.dep_rd'),
|
||||
'gender': 'male',
|
||||
'name': 'Jared',
|
||||
'address_home_id': user.partner_id.id,
|
||||
'user_id': user.id,
|
||||
})
|
||||
|
||||
def _createContract(self, employee, commission_rate, admin_commission_rate=0.0):
|
||||
return self.env['hr.contract'].create({
|
||||
'date_start': '2016-01-01',
|
||||
'date_end': '2030-12-31',
|
||||
'name': 'Contract for tests',
|
||||
'wage': 1000.0,
|
||||
# 'type_id': self.ref('hr_contract.hr_contract_type_emp'),
|
||||
'employee_id': employee.id,
|
||||
'resource_calendar_id': self.ref('resource.resource_calendar_std'),
|
||||
'commission_rate': commission_rate,
|
||||
'admin_commission_rate': admin_commission_rate,
|
||||
'state': 'open', # if not "Running" then no automatic selection when Payslip is created in 11.0
|
||||
})
|
||||
|
||||
def _createInvoiceableSaleOrder(self, user):
|
||||
product = self.env.ref('sale.advance_product_0')
|
||||
partner = self.env.ref('base.res_partner_12')
|
||||
sale = self.env['sale.order'].create({
|
||||
'partner_id': partner.id,
|
||||
'user_id': user.id,
|
||||
'order_line': [(0, 0, {
|
||||
'name': 'test deposit',
|
||||
'product_id': product.id,
|
||||
'product_uom_qty': 1.0,
|
||||
'product_uom': product.uom_id.id,
|
||||
'price_unit': 5.0,
|
||||
})]
|
||||
})
|
||||
self.assertEqual(sale.user_id, user)
|
||||
sale.action_confirm()
|
||||
self.assertTrue(sale.state in ('sale', 'done'))
|
||||
self.assertEqual(sale.invoice_status, 'to invoice')
|
||||
return sale
|
||||
|
||||
def test_commission(self):
|
||||
# find and configure company commissions journal
|
||||
commission_journal = self.env['account.journal'].search([('type', '=', 'general')], limit=1)
|
||||
self.assertTrue(commission_journal)
|
||||
expense_account = self.env.ref('l10n_generic_coa.1_expense')
|
||||
commission_journal.default_debit_account_id = expense_account
|
||||
commission_journal.default_credit_account_id = expense_account
|
||||
self.env.user.company_id.commission_journal_id = commission_journal
|
||||
|
||||
coach = self._createEmployee(self.browse_ref('base.user_root'))
|
||||
coach_contract = self._createContract(coach, 12.0, admin_commission_rate=2.0)
|
||||
user = self.user
|
||||
emp = self.employee
|
||||
emp.address_home_id = user.partner_id # Important field for payables.
|
||||
emp.coach_id = coach
|
||||
|
||||
contract = self._createContract(emp, 5.0)
|
||||
|
||||
so = self._createInvoiceableSaleOrder(user)
|
||||
inv = so._create_invoices()
|
||||
self.assertFalse(inv.commission_ids, 'Commissions exist when invoice is created.')
|
||||
inv.action_post() # validate
|
||||
self.assertEqual(inv.state, 'posted')
|
||||
self.assertEqual(inv.invoice_payment_state, 'not_paid')
|
||||
self.assertTrue(inv.commission_ids, 'Commissions not created when invoice is validated.')
|
||||
|
||||
user_commission = inv.commission_ids.filtered(lambda c: c.employee_id.id == emp.id)
|
||||
self.assertEqual(len(user_commission), 1, 'Incorrect commission count %d (expect 1)' % len(user_commission))
|
||||
self.assertEqual(user_commission.state, 'draft', 'Commission is not draft.')
|
||||
self.assertFalse(user_commission.move_id, 'Commission has existing journal entry.')
|
||||
|
||||
# Amounts
|
||||
commission_rate = contract.commission_rate
|
||||
self.assertEqual(commission_rate, 5.0)
|
||||
expected = (inv.amount_for_commission() * commission_rate) / 100.0
|
||||
actual = user_commission.amount
|
||||
self.assertAlmostEqual(actual, expected, int(inv.company_currency_id.rounding))
|
||||
|
||||
# Pay.
|
||||
pay_journal = self.env['account.journal'].search([('type', '=', 'bank')], limit=1)
|
||||
payment = self.env['account.payment'].create({
|
||||
'payment_type': 'inbound',
|
||||
'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id,
|
||||
'partner_type': 'customer',
|
||||
'partner_id': inv.partner_id.id,
|
||||
'amount': inv.amount_residual,
|
||||
'currency_id': inv.currency_id.id,
|
||||
'journal_id': pay_journal.id,
|
||||
})
|
||||
payment.post()
|
||||
|
||||
receivable_line = payment.move_line_ids.filtered('credit')
|
||||
inv.js_assign_outstanding_line(receivable_line.id)
|
||||
self.assertEqual(inv.invoice_payment_state, 'paid', 'Invoice is not paid.')
|
||||
|
||||
user_commission = inv.commission_ids.filtered(lambda c: c.employee_id.id == emp.id)
|
||||
self.assertEqual(user_commission.state, 'done', 'Commission is not done.')
|
||||
self.assertTrue(user_commission.move_id, 'Commission didn\'t create a journal entry.')
|
||||
inv.company_currency_id.rounding
|
||||
|
||||
# Coach/Admin commissions
|
||||
coach_commission = inv.commission_ids.filtered(lambda c: c.employee_id.id == coach.id)
|
||||
self.assertEqual(len(coach_commission), 1, 'Incorrect commission count %d (expect 1)' % len(coach_commission))
|
||||
|
||||
commission_rate = coach_contract.admin_commission_rate
|
||||
expected = (inv.amount_for_commission() * commission_rate) / 100.0
|
||||
actual = coach_commission.amount
|
||||
self.assertAlmostEqual(
|
||||
actual,
|
||||
expected,
|
||||
int(inv.company_currency_id.rounding))
|
||||
|
||||
# Use the "Mark Paid" button
|
||||
result_action = user_commission.action_mark_paid()
|
||||
self.assertEqual(user_commission.state, 'paid')
|
||||
self.assertTrue(user_commission.payment_id)
|
||||
|
||||
def test_commission_on_invoice(self):
|
||||
# Set to be On Invoice instead of On Invoice Paid
|
||||
self.env.user.company_id.commission_type = 'on_invoice'
|
||||
|
||||
# find and configure company commissions journal
|
||||
commission_journal = self.env['account.journal'].search([('type', '=', 'general')], limit=1)
|
||||
self.assertTrue(commission_journal)
|
||||
expense_account = self.env.ref('l10n_generic_coa.1_expense')
|
||||
commission_journal.default_debit_account_id = expense_account
|
||||
commission_journal.default_credit_account_id = expense_account
|
||||
self.env.user.company_id.commission_journal_id = commission_journal
|
||||
|
||||
|
||||
coach = self._createEmployee(self.browse_ref('base.user_root'))
|
||||
coach_contract = self._createContract(coach, 12.0, admin_commission_rate=2.0)
|
||||
user = self.user
|
||||
emp = self.employee
|
||||
emp.address_home_id = user.partner_id # Important field for payables.
|
||||
emp.coach_id = coach
|
||||
|
||||
contract = self._createContract(emp, 5.0)
|
||||
|
||||
so = self._createInvoiceableSaleOrder(user)
|
||||
inv = so._create_invoices()
|
||||
self.assertFalse(inv.commission_ids, 'Commissions exist when invoice is created.')
|
||||
inv.action_post() # validate
|
||||
self.assertEqual(inv.state, 'posted')
|
||||
self.assertTrue(inv.commission_ids, 'Commissions not created when invoice is validated.')
|
||||
|
||||
user_commission = inv.commission_ids.filtered(lambda c: c.employee_id.id == emp.id)
|
||||
self.assertEqual(len(user_commission), 1, 'Incorrect commission count %d (expect 1)' % len(user_commission))
|
||||
self.assertEqual(user_commission.state, 'done', 'Commission is not done.')
|
||||
self.assertTrue(user_commission.move_id, 'Commission missing journal entry.')
|
||||
|
||||
# Use the "Mark Paid" button
|
||||
user_commission.action_mark_paid()
|
||||
self.assertEqual(user_commission.state, 'paid')
|
||||
|
||||
def test_commission_structure(self):
|
||||
# Set to be On Invoice instead of On Invoice Paid
|
||||
self.env.user.company_id.commission_type = 'on_invoice'
|
||||
|
||||
# find and configure company commissions journal
|
||||
commission_journal = self.env['account.journal'].search([('type', '=', 'general')], limit=1)
|
||||
self.assertTrue(commission_journal)
|
||||
expense_account = self.env.ref('l10n_generic_coa.1_expense')
|
||||
commission_journal.default_debit_account_id = expense_account
|
||||
commission_journal.default_credit_account_id = expense_account
|
||||
self.env.user.company_id.commission_journal_id = commission_journal
|
||||
|
||||
|
||||
coach = self._createEmployee(self.browse_ref('base.user_root'))
|
||||
coach_contract = self._createContract(coach, 12.0, admin_commission_rate=2.0)
|
||||
user = self.user
|
||||
emp = self.employee
|
||||
emp.address_home_id = user.partner_id # Important field for payables.
|
||||
emp.coach_id = coach
|
||||
|
||||
contract = self._createContract(emp, 5.0)
|
||||
|
||||
so = self._createInvoiceableSaleOrder(user)
|
||||
|
||||
# Create and set commission structure
|
||||
commission_structure = self.env['hr.commission.structure'].create({
|
||||
'name': 'Test Structure',
|
||||
'line_ids': [
|
||||
(0, 0, {'employee_id': emp.id, 'rate': 13.0}),
|
||||
(0, 0, {'employee_id': coach.id, 'rate': 0.0}), # This means it will use the coach's contract normal rate
|
||||
],
|
||||
})
|
||||
so.partner_id.commission_structure_id = commission_structure
|
||||
|
||||
inv = so._create_invoices()
|
||||
self.assertFalse(inv.commission_ids, 'Commissions exist when invoice is created.')
|
||||
inv.action_post() # validate
|
||||
self.assertEqual(inv.state, 'posted')
|
||||
self.assertTrue(inv.commission_ids, 'Commissions not created when invoice is validated.')
|
||||
|
||||
user_commission = inv.commission_ids.filtered(lambda c: c.employee_id.id == emp.id)
|
||||
self.assertEqual(len(user_commission), 1, 'Incorrect commission count %d (expect 1)' % len(user_commission))
|
||||
self.assertEqual(user_commission.state, 'done', 'Commission is not done.')
|
||||
self.assertEqual(user_commission.rate, 13.0)
|
||||
|
||||
coach_commission = inv.commission_ids.filtered(lambda c: c.employee_id.id == coach.id)
|
||||
self.assertEqual(len(coach_commission), 1, 'Incorrect commission count %d (expect 1)' % len(coach_commission))
|
||||
self.assertEqual(coach_commission.state, 'done', 'Commission is not done.')
|
||||
self.assertEqual(coach_commission.rate, 12.0, 'Commission rate should be the contract rate.')
|
||||
18
hr_commission/views/account_views.xml
Normal file
18
hr_commission/views/account_views.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_move_form_inherit" model="ir.ui.view">
|
||||
<field name="name">account.move.form.inherit</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="inherit_id" ref="account.view_move_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button class="oe_stat_button" name="open_commissions" icon="fa-money" type="object"
|
||||
attrs="{'invisible': [('commission_count', '=', 0)]}">
|
||||
<field name="commission_count" string="Commissions" widget="statinfo"/>
|
||||
</button>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
264
hr_commission/views/commission_views.xml
Normal file
264
hr_commission/views/commission_views.xml
Normal file
@@ -0,0 +1,264 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_hr_commission_form" model="ir.ui.view">
|
||||
<field name="name">hr.commission.form</field>
|
||||
<field name="model">hr.commission</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Commission" class="oe_form_nomargin">
|
||||
<header>
|
||||
<button name="action_confirm" string="Confirm" class="btn-primary" type="object" attrs="{'invisible': [('state', '!=', 'draft')]}"/>
|
||||
<button name="action_draft" string="Set Draft" class="btn-default" type="object" attrs="{'invisible': [('state', '!=', 'cancel')]}"/>
|
||||
<button name="action_mark_paid" string="Mark Paid" class="btn-default" type="object" attrs="{'invisible': [('state', '!=', 'done')]}"/>
|
||||
<button name="action_cancel" string="Cancel" class="btn-default" type="object" attrs="{'invisible': [('state', '=', 'cancel')]}"/>
|
||||
<field name="state" widget="statusbar" on_change="1" modifiers="{'readonly': true}"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="employee_id" modifiers="{'readonly': [('state', '!=', 'draft')]}"/>
|
||||
<field name="contract_id" domain="[('employee_id', '=', employee_id)]" modifiers="{'readonly': [('state', '!=', 'draft')]}"/>
|
||||
<field name="structure_id" modifiers="{'readonly': [('state', '!=', 'draft')]}"/>
|
||||
<field name="source_move_id" modifiers="{'readonly': [('state', '!=', 'draft')]}"/>
|
||||
<field name="company_id" groups="base.group_multi_company" modifiers="{'readonly': [('state', '!=', 'draft')]}"/>
|
||||
<field name="memo" modifiers="{'readonly': [('state', 'not in', ('draft', 'done'))]}"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="rate_type" modifiers="{'readonly': [('state', '!=', 'draft')]}"/>
|
||||
<field name="rate" attrs="{'readonly': [('contract_id', '!=', False), ('rate_type', '!=', 'manual')]}"/>
|
||||
<field name="base_total" modifiers="{'readonly': [('state', '!=', 'draft')]}"/>
|
||||
<field name="base_amount" modifiers="{'readonly': [('state', '!=', 'draft')]}"/>
|
||||
<field name="amount" readonly="1" force_save="1"/>
|
||||
<field name="move_date" readonly="1" attrs="{'invisible': [('move_date', '=', False)]}"/>
|
||||
<field name="accounting_date"
|
||||
attrs="{'invisible': ['|', ('rate_type', '!=', 'manual'), ('move_date', '!=', False)], 'readonly': [('state', '!=', 'draft')]}"/>
|
||||
<field name="move_id" attrs="{'invisible': [('move_id', '=', False)], 'readonly': [('state', '!=', 'draft')]}"/>
|
||||
<field name="payment_id" attrs="{'invisible': [('payment_id', '=', False)]}" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_hr_commission_tree" model="ir.ui.view">
|
||||
<field name="name">hr.commission.tree</field>
|
||||
<field name="model">hr.commission</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree decoration-info="state == 'draft'" decoration-muted="state == 'cancel'">
|
||||
<field name="create_date"/>
|
||||
<field name="source_move_id"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="contract_id"/>
|
||||
<field name="base_total" string="Invoice Total" sum="Invoice Total"/>
|
||||
<field name="base_amount" string="Margin" sum="Margin Total"/>
|
||||
<field name="amount" string="Commission" sum="Commission Total"/>
|
||||
<field name="state"/>
|
||||
<field name="move_date"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_hr_commission_pivot" model="ir.ui.view">
|
||||
<field name="name">hr.commission.pivot</field>
|
||||
<field name="model">hr.commission</field>
|
||||
<field name="arch" type="xml">
|
||||
<pivot string="Commissions">
|
||||
<field name="create_date" type="row" interval="week"/>
|
||||
<field name="state" type="row"/>
|
||||
<field name="employee_id" type="col"/>
|
||||
<field name="amount" type="measure"/>
|
||||
</pivot>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_hr_commission_pivot_graph" model="ir.ui.view">
|
||||
<field name="name">hr.commission.graph</field>
|
||||
<field name="model">hr.commission</field>
|
||||
<field name="arch" type="xml">
|
||||
<graph string="Commissions" stacked="True">
|
||||
<field name="create_date" type="row" interval="week"/>
|
||||
<field name="state" type="row"/>
|
||||
<field name="amount" type="measure"/>
|
||||
</graph>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_hr_commission_search" model="ir.ui.view">
|
||||
<field name="name">hr.commission.search</field>
|
||||
<field name="model">hr.commission</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Commission">
|
||||
<field name="source_move_id"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="contract_id"/>
|
||||
<separator/>
|
||||
<filter string="New" name="new" domain="[('state', '=', 'draft')]"/>
|
||||
<filter string="Confirmed" name="confirmed" domain="[('state', '=', 'done')]"/>
|
||||
<filter string="Paid" name="paid" domain="[('state', '=', 'paid')]"/>
|
||||
<filter string="Cancelled" name="cancelled" domain="[('state', '=', 'cancel')]"/>
|
||||
<group expand="0" name="group_by" string="Group By">
|
||||
<filter name="group_state" string="Status" domain="[]" context="{'group_by': 'state'}"/>
|
||||
<filter name="group_employee" string="Employee" domain="[]" context="{'group_by': 'employee_id'}"/>
|
||||
<filter name="group_invoice" string="Invoice" domain="[]" context="{'group_by': 'source_move_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_hr_commission" model="ir.actions.act_window">
|
||||
<field name="name">Commissions</field>
|
||||
<field name="res_model">hr.commission</field>
|
||||
<field name="view_mode">tree,form,pivot,graph</field>
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
action="action_hr_commission"
|
||||
id="menu_action_account_commission_root"
|
||||
parent="account.menu_finance_receivables"
|
||||
sequence="5"
|
||||
/>
|
||||
|
||||
<menuitem
|
||||
action="action_hr_commission"
|
||||
id="menu_action_account_commission_form"
|
||||
parent="menu_action_account_commission_root"
|
||||
sequence="5"
|
||||
/>
|
||||
|
||||
<menuitem
|
||||
action="action_hr_commission"
|
||||
id="menu_action_account_commission_form"
|
||||
parent="account.menu_finance_entries"
|
||||
sequence="90"
|
||||
/>
|
||||
|
||||
<menuitem
|
||||
action="action_hr_commission"
|
||||
id="menu_action_account_commission_form2"
|
||||
parent="menu_action_account_commission_form"
|
||||
sequence="90"
|
||||
/>
|
||||
|
||||
<record id="action_commission_mark_paid" model="ir.actions.server">
|
||||
<field name="name">Mark Paid</field>
|
||||
<field name="type">ir.actions.server</field>
|
||||
<field name="state">code</field>
|
||||
<field name="model_id" ref="hr_commission.model_hr_commission"/>
|
||||
<field name="binding_model_id" ref="hr_commission.model_hr_commission"/>
|
||||
<field name="code">
|
||||
action = records.action_mark_paid()
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Commission Payments -->
|
||||
<record id="view_hr_commission_payment_form" model="ir.ui.view">
|
||||
<field name="name">hr.commission.payment.form</field>
|
||||
<field name="model">hr.commission.payment</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Commission Payment">
|
||||
<header/>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box"/>
|
||||
<h1><field name="name" placeholder="Commission Payment Description"/></h1>
|
||||
<group>
|
||||
<group>
|
||||
<field name="date"/>
|
||||
<field name="employee_id" readonly="1"/>
|
||||
<field name="commission_count"/>
|
||||
<field name="commission_amount"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="commission_ids" nolabel="1" readonly="1">
|
||||
<tree>
|
||||
<field name="source_move_id"/>
|
||||
<field name="amount"/>
|
||||
<field name="move_date"/>
|
||||
</tree>
|
||||
</field>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_hr_commission_payment_tree" model="ir.ui.view">
|
||||
<field name="name">hr.commission.payment.tree</field>
|
||||
<field name="model">hr.commission.payment</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<field name="date"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="name"/>
|
||||
<field name="commission_count" sum="Commission Count Total"/>
|
||||
<field name="commission_amount" sum="Commission Amount Total"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_hr_commission_payment_search" model="ir.ui.view">
|
||||
<field name="name">hr.commission.payment.search</field>
|
||||
<field name="model">hr.commission.payment</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Commission Payment">
|
||||
<field name="employee_id"/>
|
||||
<group expand="0" name="group_by" string="Group By">
|
||||
<filter name="group_employee" string="Employee" domain="[]" context="{'group_by': 'employee_id'}"/>
|
||||
<filter name="group_date" string="Date" domain="[]" context="{'group_by': 'date'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_hr_commission_payment" model="ir.actions.act_window">
|
||||
<field name="name">Commission Payments</field>
|
||||
<field name="res_model">hr.commission.payment</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem
|
||||
action="action_hr_commission_payment"
|
||||
id="menu_action_account_commission_payment_form"
|
||||
parent="menu_action_account_commission_root"
|
||||
sequence="10"
|
||||
/>
|
||||
|
||||
<menuitem
|
||||
action="action_hr_commission_payment"
|
||||
id="menu_action_account_commission_payment_form"
|
||||
parent="menu_action_account_commission_form"
|
||||
sequence="100"
|
||||
/>
|
||||
|
||||
<!-- Commission Structure -->
|
||||
<record id="view_hr_commission_structure_form" model="ir.ui.view">
|
||||
<field name="name">hr.commission.structure.form</field>
|
||||
<field name="model">hr.commission.structure</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Commission Structure" class="oe_form_nomargin">
|
||||
<header/>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box"/>
|
||||
<div class="oe_title">
|
||||
<field name="name"/>
|
||||
</div>
|
||||
<group>
|
||||
<field name="line_ids" nolabel="1">
|
||||
<tree editable="bottom">
|
||||
<field name="structure_id" invisible="1"/>
|
||||
<field name="employee_id"/>
|
||||
<field name="rate"/>
|
||||
</tree>
|
||||
</field>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
16
hr_commission/views/hr_views.xml
Normal file
16
hr_commission/views/hr_views.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="hr_contract_view_form_inherit" model="ir.ui.view">
|
||||
<field name="name">hr.contract.form.inherit</field>
|
||||
<field name="model">hr.contract</field>
|
||||
<field name="inherit_id" ref="hr_contract.hr_contract_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//group/group" position="inside">
|
||||
<field name="commission_rate"/>
|
||||
<field name="admin_commission_rate"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
15
hr_commission/views/partner_views.xml
Normal file
15
hr_commission/views/partner_views.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
|
||||
<record id="res_partner_view_form_inherit" model="ir.ui.view">
|
||||
<field name="name">res.parter.view.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="//field[@name='property_account_payable_id']" position="after">
|
||||
<field name="commission_structure_id"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
46
hr_commission/views/res_config_settings_views.xml
Normal file
46
hr_commission/views/res_config_settings_views.xml
Normal file
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.account</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="priority" eval="50"/>
|
||||
<field name="inherit_id" ref="account.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@id='invoicing_settings']" position="after">
|
||||
<h2>Commissions</h2>
|
||||
<div class="row mt16 o_settings_container" id="commission_settings" groups="account.group_account_user">
|
||||
<div class="col-xs-12 col-md-6 o_setting_box" title="These taxes are set in any new product created.">
|
||||
<div class="o_setting_left_pane"/>
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="fa fa-lg fa-building-o" title="Values set here are company-specific." groups="base.group_multi_company"/>
|
||||
<div class="text-muted">
|
||||
Commission journal default accounts can be thought of as the 'expense' side of the commission. If a Liability account
|
||||
is not chosen, then the employee's home address partner's Account Payable will be used instead.
|
||||
</div>
|
||||
<div class="content-group">
|
||||
<div class="row mt16">
|
||||
<label string="Journal" for="commission_journal_id" class="col-md-3 o_light_label"/>
|
||||
<field name="commission_journal_id" domain="[('company_id', '=', company_id)]"/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label string="Liability Account" for="commission_liability_id" class="col-md-3 o_light_label"/>
|
||||
<field name="commission_liability_id" domain="[('company_id', '=', company_id)]"/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label string="Pay Commission" for="commission_type" class="col-md-3 o_light_label"/>
|
||||
<field name="commission_type"/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label string="Commission Base" for="commission_amount_type" class="col-md-3 o_light_label"/>
|
||||
<field name="commission_amount_type"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
1
hr_expense_change/__init__.py
Normal file
1
hr_expense_change/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import wizard
|
||||
30
hr_expense_change/__manifest__.py
Normal file
30
hr_expense_change/__manifest__.py
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
'name': 'HR Expense Change',
|
||||
'author': 'Hibou Corp. <hello@hibou.io>',
|
||||
'version': '13.0.1.0.0',
|
||||
'category': 'Employees',
|
||||
'sequence': 95,
|
||||
'summary': 'Technical foundation for changing expenses.',
|
||||
'description': """
|
||||
Technical foundation for changing expenses.
|
||||
|
||||
Creates wizard and permissions for making expense changes that can be
|
||||
handled by other individual modules.
|
||||
|
||||
This module implements, as examples, how to change the Date fields.
|
||||
|
||||
Abstractly, individual 'changes' should come from specific 'fields' or capability
|
||||
modules that handle the consequences of changing that field in whatever state the
|
||||
the invoice is currently in.
|
||||
|
||||
""",
|
||||
'website': 'https://hibou.io/',
|
||||
'depends': [
|
||||
'hr_expense',
|
||||
],
|
||||
'data': [
|
||||
'wizard/expense_change_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
}
|
||||
1
hr_expense_change/tests/__init__.py
Normal file
1
hr_expense_change/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_expense_change
|
||||
20
hr_expense_change/tests/test_expense_change.py
Normal file
20
hr_expense_change/tests/test_expense_change.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from odoo.addons.hr_expense.tests.test_expenses import TestAccountEntry
|
||||
|
||||
|
||||
class TestAccountEntry(TestAccountEntry):
|
||||
|
||||
def test_expense_change_basic(self):
|
||||
# posts expense and gets move ready at self.expense.account_move_id.id
|
||||
self.test_account_entry()
|
||||
self.assertEqual(self.expense.expense_line_ids.date, self.expense.account_move_id.date)
|
||||
|
||||
ctx = {'active_model': 'hr.expense', 'active_ids': self.expense.expense_line_ids.ids}
|
||||
change = self.env['hr.expense.change'].with_context(ctx).create({})
|
||||
self.assertEqual(change.date, self.expense.expense_line_ids.date)
|
||||
|
||||
change_date = '2018-01-01'
|
||||
change.write({'date': change_date})
|
||||
|
||||
change.affect_change()
|
||||
self.assertEqual(change_date, self.expense.expense_line_ids.date)
|
||||
self.assertEqual(change_date, self.expense.account_move_id.date)
|
||||
1
hr_expense_change/wizard/__init__.py
Normal file
1
hr_expense_change/wizard/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import expense_change
|
||||
54
hr_expense_change/wizard/expense_change.py
Normal file
54
hr_expense_change/wizard/expense_change.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class ExpenseChangeWizard(models.TransientModel):
|
||||
_name = 'hr.expense.change'
|
||||
_description = 'Expense Change'
|
||||
|
||||
expense_id = fields.Many2one('hr.expense', string='Expense', readonly=True, required=True)
|
||||
expense_company_id = fields.Many2one('res.company', related='expense_id.company_id')
|
||||
date = fields.Date(string='Expense Date')
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
rec = super(ExpenseChangeWizard, self).default_get(fields)
|
||||
context = dict(self._context or {})
|
||||
active_model = context.get('active_model')
|
||||
active_ids = context.get('active_ids')
|
||||
|
||||
# Checks on context parameters
|
||||
if not active_model or not active_ids:
|
||||
raise UserError(
|
||||
_("Programmation error: wizard action executed without active_model or active_ids in context."))
|
||||
if active_model != 'hr.expense':
|
||||
raise UserError(_(
|
||||
"Programmation error: the expected model for this action is 'hr.expense'. The provided one is '%d'.") % active_model)
|
||||
|
||||
# Checks on received expense records
|
||||
expense = self.env[active_model].browse(active_ids)
|
||||
if len(expense) != 1:
|
||||
raise UserError(_("Expense Change expects only one expense."))
|
||||
rec.update({
|
||||
'expense_id': expense.id,
|
||||
'date': expense.date,
|
||||
})
|
||||
return rec
|
||||
|
||||
def _new_expense_vals(self):
|
||||
vals = {}
|
||||
if self.expense_id.date != self.date:
|
||||
vals['date'] = self.date
|
||||
return vals
|
||||
|
||||
def affect_change(self):
|
||||
self.ensure_one()
|
||||
vals = self._new_expense_vals()
|
||||
old_date = self.expense_id.date
|
||||
if vals:
|
||||
self.expense_id.write(vals)
|
||||
if 'date' in vals and self.expense_id.sheet_id.account_move_id:
|
||||
self.expense_id.sheet_id.account_move_id.write({'date': vals['date']})
|
||||
self.expense_id.sheet_id.account_move_id.line_ids\
|
||||
.filtered(lambda l: l.date_maturity == old_date).write({'date_maturity': vals['date']})
|
||||
return True
|
||||
65
hr_expense_change/wizard/expense_change_views.xml
Normal file
65
hr_expense_change/wizard/expense_change_views.xml
Normal file
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
|
||||
<record id="hr_expense_change_form" model="ir.ui.view">
|
||||
<field name="name">Expense Change</field>
|
||||
<field name="model">hr.expense.change</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Expense Change">
|
||||
<group>
|
||||
<group name="group_left">
|
||||
<field name="expense_id" invisible="1"/>
|
||||
<field name="expense_company_id" invisible="1"/>
|
||||
<field name="date"/>
|
||||
</group>
|
||||
<group name="group_right"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="affect_change" string="Change" type="object" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-default" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_view_hr_expense_change" model="ir.actions.act_window">
|
||||
<field name="name">Expense Change Wizard</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">hr.expense.change</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
<!-- Add Button to Existing Forms -->
|
||||
<record id="hr_expense_view_form_inherit" model="ir.ui.view">
|
||||
<field name="name">hr.expense.form.inherit</field>
|
||||
<field name="model">hr.expense</field>
|
||||
<field name="inherit_id" ref="hr_expense.hr_expense_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='state']" position="before">
|
||||
<button name="%(action_view_hr_expense_change)d" string="Change"
|
||||
type="action" class="btn-secondary"
|
||||
attrs="{'invisible': [('state', 'in', ('draft', 'refused'))]}"
|
||||
context="{'default_expense_id': active_id}"
|
||||
groups="account.group_account_manager" />
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_hr_expense_sheet_form_inherit" model="ir.ui.view">
|
||||
<field name="name">hr.expense.sheet.form.inherit</field>
|
||||
<field name="model">hr.expense.sheet</field>
|
||||
<field name="inherit_id" ref="hr_expense.view_hr_expense_sheet_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='expense_line_ids']/tree" position="inside">
|
||||
<field name="id" invisible="1"/>
|
||||
<button name="%(action_view_hr_expense_change)d" string="Change" icon="fa-exchange"
|
||||
type="action"
|
||||
attrs="{'invisible': [('state', 'in', ('draft', 'refused'))]}"
|
||||
context="{'default_expense_id': id}"
|
||||
groups="account.group_account_manager" />
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
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))
|
||||
|
||||
3
hr_payroll_commission/__init__.py
Normal file
3
hr_payroll_commission/__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
|
||||
29
hr_payroll_commission/__manifest__.py
Normal file
29
hr_payroll_commission/__manifest__.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||
|
||||
{
|
||||
'name': 'Commissions in Payslips',
|
||||
'author': 'Hibou Corp.',
|
||||
'version': '13.0.1.0.0',
|
||||
'license': 'OPL-1',
|
||||
'category': 'Accounting/Commissions',
|
||||
'sequence': 95,
|
||||
'summary': 'Reimburse Commissions in Payslips',
|
||||
'description': """
|
||||
Reimburse Commissions in Payslips
|
||||
""",
|
||||
'depends': [
|
||||
'hr_commission',
|
||||
'hr_payroll',
|
||||
],
|
||||
'data': [
|
||||
'views/hr_commission_views.xml',
|
||||
'views/hr_payslip_views.xml',
|
||||
'data/hr_payroll_commission_data.xml',
|
||||
],
|
||||
'demo': [
|
||||
'data/hr_payroll_commission_demo.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': True,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Salary Other Input -->
|
||||
<record id="commission_other_input" model="hr.payslip.input.type">
|
||||
<field name="name">Commissions</field>
|
||||
<field name="code">COMMISSION</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
20
hr_payroll_commission/data/hr_payroll_commission_demo.xml
Normal file
20
hr_payroll_commission/data/hr_payroll_commission_demo.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="hr_salary_rule_commission" model="hr.salary.rule">
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">
|
||||
result = inputs.COMMISSION.amount > 0.0 if inputs.COMMISSION else False
|
||||
</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute">
|
||||
result = inputs.COMMISSION.amount if inputs.COMMISSION else 0
|
||||
</field>
|
||||
<field name="code">COMMISSION</field>
|
||||
<field name="category_id" ref="hr_payroll.BASIC"/>
|
||||
<field name="name">Commissions</field>
|
||||
<field name="sequence" eval="190"/>
|
||||
<field name="struct_id" ref="hr_payroll.structure_002"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
4
hr_payroll_commission/models/__init__.py
Normal file
4
hr_payroll_commission/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||
|
||||
from . import hr_commission
|
||||
from . import hr_payslip
|
||||
13
hr_payroll_commission/models/hr_commission.py
Normal file
13
hr_payroll_commission/models/hr_commission.py
Normal file
@@ -0,0 +1,13 @@
|
||||
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models, _
|
||||
|
||||
|
||||
class CommissionPayment(models.Model):
|
||||
_inherit = 'hr.commission.payment'
|
||||
|
||||
pay_in_payslip = fields.Boolean(string="Reimburse In Next Payslip")
|
||||
payslip_id = fields.Many2one('hr.payslip', string="Payslip", readonly=True)
|
||||
|
||||
def action_report_in_next_payslip(self):
|
||||
self.filtered(lambda p: not p.payslip_id).write({'pay_in_payslip': True})
|
||||
63
hr_payroll_commission/models/hr_payslip.py
Normal file
63
hr_payroll_commission/models/hr_payslip.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class HrPayslip(models.Model):
|
||||
_inherit = 'hr.payslip'
|
||||
|
||||
commission_payment_ids = fields.One2many(
|
||||
'hr.commission.payment', 'payslip_id', string='Commissions',
|
||||
help="Commissions to reimburse to employee.",
|
||||
states={'draft': [('readonly', False)], 'verify': [('readonly', False)]})
|
||||
commission_count = fields.Integer(compute='_compute_commission_count')
|
||||
|
||||
@api.depends('commission_payment_ids.commission_ids', 'commission_payment_ids.payslip_id')
|
||||
def _compute_commission_count(self):
|
||||
for payslip in self:
|
||||
payslip.commission_count = len(payslip.mapped('commission_payment_ids.commission_ids'))
|
||||
|
||||
@api.onchange('input_line_ids')
|
||||
def _onchange_input_line_ids(self):
|
||||
commission_type = self.env.ref('hr_payroll_commission.commission_other_input', raise_if_not_found=False)
|
||||
if not self.input_line_ids.filtered(lambda line: line.input_type_id == commission_type):
|
||||
self.commission_payment_ids.write({'payslip_id': False})
|
||||
|
||||
@api.onchange('employee_id', 'struct_id', 'contract_id', 'date_from', 'date_to')
|
||||
def _onchange_employee(self):
|
||||
res = super()._onchange_employee()
|
||||
if self.state == 'draft':
|
||||
self.commission_payment_ids = self.env['hr.commission.payment'].search([
|
||||
('employee_id', '=', self.employee_id.id),
|
||||
('pay_in_payslip', '=', True),
|
||||
('payslip_id', '=', False)])
|
||||
self._onchange_commission_payment_ids()
|
||||
return res
|
||||
|
||||
@api.onchange('commission_payment_ids')
|
||||
def _onchange_commission_payment_ids(self):
|
||||
commission_type = self.env.ref('hr_payroll_commission.commission_other_input', raise_if_not_found=False)
|
||||
if not commission_type:
|
||||
return
|
||||
|
||||
total = sum(self.commission_payment_ids.mapped('commission_amount'))
|
||||
if not total:
|
||||
return
|
||||
|
||||
lines_to_keep = self.input_line_ids.filtered(lambda x: x.input_type_id != commission_type)
|
||||
input_lines_vals = [(5, 0, 0)] + [(4, line.id, False) for line in lines_to_keep]
|
||||
input_lines_vals.append((0, 0, {
|
||||
'amount': total,
|
||||
'input_type_id': commission_type.id,
|
||||
}))
|
||||
self.update({'input_line_ids': input_lines_vals})
|
||||
|
||||
def open_commissions(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Reimbursed Commissions'),
|
||||
'res_model': 'hr.commission',
|
||||
'view_mode': 'tree,form',
|
||||
'domain': [('id', 'in', self.mapped('commission_payment_ids.commission_ids').ids)],
|
||||
}
|
||||
BIN
hr_payroll_commission/static/description/icon.png
Normal file
BIN
hr_payroll_commission/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
3
hr_payroll_commission/tests/__init__.py
Executable file
3
hr_payroll_commission/tests/__init__.py
Executable file
@@ -0,0 +1,3 @@
|
||||
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||
|
||||
from . import test_payslip_commission
|
||||
37
hr_payroll_commission/tests/test_payslip_commission.py
Normal file
37
hr_payroll_commission/tests/test_payslip_commission.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from odoo.addons.hr_commission.tests import test_commission
|
||||
|
||||
|
||||
class TestCommissionPayslip(test_commission.TestCommission):
|
||||
|
||||
def test_commission(self):
|
||||
super().test_commission()
|
||||
commission_type = self.env.ref('hr_payroll_commission.commission_other_input')
|
||||
payslip = self.env['hr.payslip'].create({
|
||||
'name': 'test slip',
|
||||
'employee_id': self.employee.id,
|
||||
'date_from': date.today() - timedelta(days=1),
|
||||
'date_to': date.today() + timedelta(days=14),
|
||||
})
|
||||
payslip._onchange_employee()
|
||||
self.assertFalse(payslip.commission_payment_ids)
|
||||
|
||||
# find unpaid commission payments from super().test_commission()
|
||||
commission_payments = self.env['hr.commission.payment'].search([
|
||||
('employee_id', '=', self.employee.id),
|
||||
])
|
||||
self.assertTrue(commission_payments)
|
||||
|
||||
# press the button to pay it via payroll
|
||||
commission_payments.action_report_in_next_payslip()
|
||||
|
||||
payslip._onchange_employee()
|
||||
# has attached commission payments
|
||||
self.assertTrue(payslip.commission_payment_ids)
|
||||
commission_input_lines = payslip.input_line_ids.filtered(lambda l: l.input_type_id == commission_type)
|
||||
self.assertTrue(commission_input_lines)
|
||||
self.assertEqual(sum(commission_input_lines.mapped('amount')),
|
||||
sum(commission_payments.mapped('commission_amount')))
|
||||
23
hr_payroll_commission/views/hr_commission_views.xml
Normal file
23
hr_payroll_commission/views/hr_commission_views.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_hr_commission_payment_form_inherit" model="ir.ui.view">
|
||||
<field name="name">hr.expense.sheet.view.form.payroll</field>
|
||||
<field name="model">hr.commission.payment</field>
|
||||
<field name="inherit_id" ref="hr_commission.view_hr_commission_payment_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='employee_id']" position="after">
|
||||
<field name="pay_in_payslip" attrs="{'invisible': ['|', ('pay_in_payslip', '=', False), ('payslip_id', '!=', False)]}"/>
|
||||
<field name="payslip_id" attrs="{'invisible':[('payslip_id','=',False)]}"/>
|
||||
</xpath>
|
||||
<xpath expr="//form/header" position="inside">
|
||||
<button name="action_report_in_next_payslip" type="object"
|
||||
string="Report in Next Payslip"
|
||||
groups="account.group_account_manager"
|
||||
attrs="{'invisible': ['|', ('pay_in_payslip', '=', True), ('payslip_id', '!=', False)]}"
|
||||
class="oe_highlight"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
20
hr_payroll_commission/views/hr_payslip_views.xml
Normal file
20
hr_payroll_commission/views/hr_payslip_views.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="hr_payslip_view_form_inherit" model="ir.ui.view">
|
||||
<field name="name">hr.payslip.view.form.inherit</field>
|
||||
<field name="model">hr.payslip</field>
|
||||
<field name="inherit_id" ref="hr_payroll.view_hr_payslip_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button class="oe_stat_button" name="open_commissions" type="object" icon="fa-dollar" attrs="{'invisible': [('commission_count', '=', 0)]}">
|
||||
<field string="Commissions" name="commission_count" widget="statinfo"/>
|
||||
</button>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='number']" position="after">
|
||||
<field name="commission_payment_ids" invisible="1"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
1
hr_payroll_gamification/__init__.py
Normal file
1
hr_payroll_gamification/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
20
hr_payroll_gamification/__manifest__.py
Executable file
20
hr_payroll_gamification/__manifest__.py
Executable file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
'name': 'Payroll Gamification',
|
||||
'description': 'Payroll Gamification',
|
||||
'version': '13.0.1.0.1',
|
||||
'website': 'https://hibou.io/',
|
||||
'author': 'Hibou Corp. <hello@hibou.io>',
|
||||
'license': 'AGPL-3',
|
||||
'category': 'Human Resources',
|
||||
'data': [
|
||||
'data/payroll_data.xml',
|
||||
'views/gamification_views.xml',
|
||||
],
|
||||
'demo': [
|
||||
'data/payroll_demo.xml',
|
||||
],
|
||||
'depends': [
|
||||
'hr_gamification',
|
||||
'hr_payroll',
|
||||
],
|
||||
}
|
||||
10
hr_payroll_gamification/data/payroll_data.xml
Normal file
10
hr_payroll_gamification/data/payroll_data.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
|
||||
<!-- Salary Other Input -->
|
||||
<record id="badge_other_input" model="hr.payslip.input.type">
|
||||
<field name="name">Badges</field>
|
||||
<field name="code">BADGES</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
16
hr_payroll_gamification/data/payroll_demo.xml
Normal file
16
hr_payroll_gamification/data/payroll_demo.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="hr_salary_rule_gamification" model="hr.salary.rule">
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = inputs.BADGES.amount > 0.0 if inputs.BADGES else False</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute">result = inputs.BADGES.amount if inputs.BADGES else 0</field>
|
||||
<field name="code">BADGES</field>
|
||||
<field name="category_id" ref="hr_payroll.BASIC"/>
|
||||
<field name="name">Badges</field>
|
||||
<field name="sequence" eval="90"/>
|
||||
<field name="struct_id" ref="hr_payroll.structure_002"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
2
hr_payroll_gamification/models/__init__.py
Normal file
2
hr_payroll_gamification/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import gamification
|
||||
from . import payroll
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user