Merge branch '13.0' into 'mig/13.0/mass_mailing_partner'

# Conflicts:
#   .gitmodules
This commit is contained in:
Jared Kipe
2020-10-17 16:24:06 +00:00
283 changed files with 11608 additions and 13 deletions

7
.gitmodules vendored
View File

@@ -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

View 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
View File

@@ -0,0 +1,2 @@
from . import models

View 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",
],
},
}

View 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()

View File

@@ -0,0 +1 @@
from . import ir_attachment

View 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
View 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
View File

@@ -0,0 +1,3 @@
from . import controllers
from . import models
from . import wizard

28
auth_admin/__manifest__.py Executable file
View 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',
],
}

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import main

39
auth_admin/controllers/main.py Executable file
View 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
View File

@@ -0,0 +1 @@
from . import res_users

92
auth_admin/models/res_users.py Executable file
View 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
View 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
View File

@@ -0,0 +1 @@
from . import portal_wizard

View 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': ''})

View 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>

View File

@@ -0,0 +1 @@
./external/camptocamp-cloud-platform/base_attachment_object_storage

View File

@@ -0,0 +1 @@
from . import models

View 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,
}

View File

@@ -0,0 +1,2 @@
from . import delivery_fedex
from . import stock

View 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

View 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

View 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')

View 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>

View File

@@ -0,0 +1 @@
from . import models

View 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,
}

View File

@@ -0,0 +1,2 @@
from . import delivery
from . import stock

View 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

View 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

View File

@@ -0,0 +1 @@
from . import test_delivery_hibou

View 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)

View 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>

View 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>

View 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

View File

@@ -0,0 +1 @@
from . import models

View 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,
}

View File

@@ -0,0 +1 @@
from . import delivery

View 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()

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_partner_shipping_account partner.shipping.account model_partner_shipping_account base.group_partner_manager 1 1 1 1

View 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/hibou-shipbox vendored Submodule

Submodule external/hibou-shipbox added at e0a0f3e9c6

View File

@@ -0,0 +1,3 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from . import models

View 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',
}

View File

@@ -0,0 +1,3 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from . import update

View 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()

View 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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View 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,
};
});

View 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&amp;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 &amp;&amp; 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>

View 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>

View File

@@ -0,0 +1,3 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from . import models

View 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,
}

View 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

View 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

View 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

View 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)

View 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')

View 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')

View 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)

View 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>

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_commission_user commission user model_hr_commission base.group_user 1 0 0 0
3 access_commission_manager commission manager model_hr_commission account.group_account_manager 1 1 1 1
4 access_commission_payment_user commission payment user model_hr_commission_payment base.group_user 1 0 0 0
5 access_commission_payment_manager commission payment manager model_hr_commission_payment account.group_account_manager 1 1 1 1
6 access_commission_structure_user commission structure user model_hr_commission_structure base.group_user 1 0 0 0
7 access_commission_structure_manager commission structure manager model_hr_commission_structure account.group_account_manager 1 1 1 1
8 access_commission_structure_line_user commission structure line user model_hr_commission_structure_line base.group_user 1 0 0 0
9 access_commission_structure_line_manager commission structure line manager model_hr_commission_structure_line account.group_account_manager 1 1 1 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -0,0 +1,3 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from . import test_commission

View 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.')

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1 @@
from . import wizard

View 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,
}

View File

@@ -0,0 +1 @@
from . import test_expense_change

View 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)

View File

@@ -0,0 +1 @@
from . import expense_change

View 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

View 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>

View File

@@ -0,0 +1 @@
from . import wizard

View 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',
],
}

View File

@@ -0,0 +1 @@
from . import hr_payroll_payslips_by_employees

View File

@@ -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))

View File

@@ -0,0 +1,3 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from . import models

View 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,
}

View File

@@ -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>

View 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>

View 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

View 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})

View 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)],
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View 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

View 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')))

View 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>

View 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>

View File

@@ -0,0 +1 @@
from . import models

View 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',
],
}

View 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>

View 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>

View 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