Merge commit '5f238825b661980a249236c07f8d86c0030ae474' into 11.0-payroll-test

This commit is contained in:
Jared Kipe
2018-07-03 14:57:19 -07:00
153 changed files with 3720 additions and 33 deletions

4
.gitmodules vendored
View File

@@ -1,3 +1,7 @@
[submodule "external/hibou-oca/account-analytic"]
path = external/hibou-oca/account-analytic
url = https://github.com/hibou-io/oca-account-analytic.git
[submodule "external/hibou-oca/server-tools"]
path = external/hibou-oca/server-tools
url = https://github.com/hibou-io/oca-server-tools.git

View File

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

View File

@@ -0,0 +1,30 @@
{
'name': 'Account Invoice Change',
'author': 'Hibou Corp. <hello@hibou.io>',
'version': '11.0.1.0.0',
'category': 'Accounting',
'sequence': 95,
'summary': 'Technical foundation for changing invoices.',
'description': """
Technical foundation for changing invoices.
Creates wizard and permissions for making invoice changes that can be
handled by other individual modules.
This module implements, as examples, how to change the Salesperson and 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': [
'account',
],
'data': [
'wizard/invoice_change_views.xml',
],
'installable': True,
'application': False,
}

View File

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

View File

@@ -0,0 +1,60 @@
from odoo.addons.account.tests.account_test_users import AccountTestUsers
from odoo import fields
class TestInvoiceChange(AccountTestUsers):
def test_invoice_change_basic(self):
self.account_invoice_obj = self.env['account.invoice']
self.payment_term = self.env.ref('account.account_payment_term_advance')
self.journalrec = self.env['account.journal'].search([('type', '=', 'sale')])[0]
self.partner3 = self.env.ref('base.res_partner_3')
account_user_type = self.env.ref('account.data_account_type_receivable')
self.account_rec1_id = self.account_model.sudo(self.account_manager.id).create(dict(
code="cust_acc",
name="customer account",
user_type_id=account_user_type.id,
reconcile=True,
))
invoice_line_data = [
(0, 0,
{
'product_id': self.env.ref('product.product_product_5').id,
'quantity': 10.0,
'account_id': self.env['account.account'].search(
[('user_type_id', '=', self.env.ref('account.data_account_type_revenue').id)], limit=1).id,
'name': 'product test 5',
'price_unit': 100.00,
}
)
]
self.invoice_basic = self.account_invoice_obj.sudo(self.account_user.id).create(dict(
name="Test Customer Invoice",
reference_type="none",
payment_term_id=self.payment_term.id,
journal_id=self.journalrec.id,
partner_id=self.partner3.id,
account_id=self.account_rec1_id.id,
invoice_line_ids=invoice_line_data
))
self.assertEqual(self.invoice_basic.state, 'draft')
self.invoice_basic.action_invoice_open()
self.assertEqual(self.invoice_basic.state, 'open')
self.assertEqual(self.invoice_basic.date, fields.Date.today())
self.assertEqual(self.invoice_basic.user_id, self.account_user)
self.assertEqual(self.invoice_basic.move_id.date, fields.Date.today())
self.assertEqual(self.invoice_basic.move_id.line_ids[0].date, fields.Date.today())
ctx = {'active_model': 'account.invoice', 'active_ids': [self.invoice_basic.id]}
change = self.env['account.invoice.change'].with_context(ctx).create({})
self.assertEqual(change.date, self.invoice_basic.date)
self.assertEqual(change.user_id, self.invoice_basic.user_id)
change_date = '2018-01-01'
change_user = self.env.user
change.write({'user_id': change_user.id, 'date': change_date})
change.affect_change()
self.assertEqual(self.invoice_basic.date, change_date)
self.assertEqual(self.invoice_basic.user_id, change_user)
self.assertEqual(self.invoice_basic.move_id.date, change_date)
self.assertEqual(self.invoice_basic.move_id.line_ids[0].date, change_date)

View File

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

View File

@@ -0,0 +1,56 @@
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class InvoiceChangeWizard(models.TransientModel):
_name = 'account.invoice.change'
_description = 'Invoice Change'
invoice_id = fields.Many2one('account.invoice', string='Invoice', readonly=True, required=True)
invoice_company_id = fields.Many2one('res.company', readonly=True, related='invoice_id.company_id')
user_id = fields.Many2one('res.users', string='Salesperson')
date = fields.Date(string='Accounting Date')
@api.model
def default_get(self, fields):
rec = super(InvoiceChangeWizard, 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 != 'account.invoice':
raise UserError(_(
"Programmation error: the expected model for this action is 'account.invoice'. The provided one is '%d'.") % active_model)
# Checks on received invoice records
invoice = self.env[active_model].browse(active_ids)
if len(invoice) != 1:
raise UserError(_("Invoice Change expects only one invoice."))
rec.update({
'invoice_id': invoice.id,
'user_id': invoice.user_id.id,
'date': invoice.date,
})
return rec
def _new_invoice_vals(self):
vals = {}
if self.invoice_id.user_id != self.user_id:
vals['user_id'] = self.user_id.id
if self.invoice_id.date != self.date:
vals['date'] = self.date
return vals
@api.multi
def affect_change(self):
self.ensure_one()
vals = self._new_invoice_vals()
if vals:
self.invoice_id.write(vals)
if 'date' in vals and self.invoice_id.move_id:
self.invoice_id.move_id.write({'date': vals['date']})
return True

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="account_invoice_change_form" model="ir.ui.view">
<field name="name">Invoice Change</field>
<field name="model">account.invoice.change</field>
<field name="arch" type="xml">
<form string="Invoice Change">
<group>
<group name="group_left">
<field name="invoice_id" invisible="1"/>
<field name="invoice_company_id" invisible="1"/>
<field name="user_id"/>
<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_account_invoice_change" model="ir.actions.act_window">
<field name="name">Invoice Change Wizard</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">account.invoice.change</field>
<field name="view_type">form</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<record id="invoice_form_inherit" model="ir.ui.view">
<field name="name">account.invoice.form.inherit</field>
<field name="model">account.invoice</field>
<field name="inherit_id" ref="account.invoice_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='state']" position="before">
<button name="%(action_view_account_invoice_change)d" string="Change"
type="action" class="btn-secondary"
attrs="{'invisible': [('state', 'in', ('sale', 'done', 'cancel'))]}"
context="{'default_invoice_id': id}"
groups="account.group_account_manager" />
</xpath>
</field>
</record>
</odoo>

View File

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

View File

@@ -0,0 +1,23 @@
{
'name': 'Account Invoice Change - Analytic',
'author': 'Hibou Corp. <hello@hibou.io>',
'version': '11.0.1.0.0',
'category': 'Accounting',
'sequence': 95,
'summary': 'Change Analytic Account on Invoice.',
'description': """
Adds fields and functionality to change the analytic account on all invoice lines
and subsequent documents.
""",
'website': 'https://hibou.io/',
'depends': [
'account_invoice_change',
'analytic',
],
'data': [
'wizard/invoice_change_views.xml',
],
'installable': True,
'application': False,
}

View File

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

View File

@@ -0,0 +1,46 @@
from odoo.addons.account_invoice_change.tests.test_invoice_change import TestInvoiceChange
class TestWizard(TestInvoiceChange):
def test_invoice_change_basic(self):
self.analytic_account = self.env['account.analytic.account'].create({
'name': 'test account',
})
self.analytic_account2 = self.env['account.analytic.account'].create({
'name': 'test account2',
})
super(TestWizard, self).test_invoice_change_basic()
# Tests Adding an Analytic Account
self.assertFalse(self.invoice_basic.invoice_line_ids.mapped('account_analytic_id'))
ctx = {'active_model': 'account.invoice', 'active_ids': [self.invoice_basic.id]}
change = self.env['account.invoice.change'].with_context(ctx).create({})
change.analytic_account_id = self.analytic_account
change.affect_change()
self.assertTrue(self.invoice_basic.invoice_line_ids.mapped('account_analytic_id'))
self.assertEqual(self.invoice_basic.move_id.mapped('line_ids.analytic_account_id'), self.analytic_account)
# Tests Removing Analytic Account
new_invoice = self.invoice_basic.copy()
new_invoice.invoice_line_ids.account_analytic_id = self.analytic_account
new_invoice.action_invoice_open()
self.assertEqual(new_invoice.state, 'open')
self.assertEqual(new_invoice.move_id.mapped('line_ids.analytic_account_id'), self.analytic_account)
ctx = {'active_model': 'account.invoice', 'active_ids': [new_invoice.id]}
change = self.env['account.invoice.change'].with_context(ctx).create({})
change.analytic_account_id = False
change.affect_change()
self.assertFalse(new_invoice.invoice_line_ids.mapped('account_analytic_id'))
self.assertFalse(new_invoice.move_id.mapped('line_ids.analytic_account_id'))
# Tests Changing Analytic Account
new_invoice = self.invoice_basic.copy()
new_invoice.invoice_line_ids.account_analytic_id = self.analytic_account
new_invoice.action_invoice_open()
self.assertEqual(new_invoice.state, 'open')
self.assertEqual(new_invoice.move_id.mapped('line_ids.analytic_account_id'), self.analytic_account)
ctx = {'active_model': 'account.invoice', 'active_ids': [new_invoice.id]}
change = self.env['account.invoice.change'].with_context(ctx).create({})
change.analytic_account_id = self.analytic_account2
change.affect_change()
self.assertEqual(new_invoice.move_id.mapped('line_ids.analytic_account_id'), self.analytic_account2)

View File

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

View File

@@ -0,0 +1,41 @@
from odoo import api, fields, models, _
class InvoiceChangeWizard(models.TransientModel):
_inherit = 'account.invoice.change'
analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account')
def _analytic_account_id(self, invoice):
analytics = invoice.invoice_line_ids.mapped('account_analytic_id')
if len(analytics):
return analytics[0].id
return False
@api.model
def default_get(self, fields):
rec = super(InvoiceChangeWizard, self).default_get(fields)
invoice = self.env['account.invoice'].browse(rec['invoice_id'])
rec.update({
'analytic_account_id': self._analytic_account_id(invoice),
})
return rec
@api.multi
def affect_change(self):
old_analytic_id = self._analytic_account_id(self.invoice_id)
res = super(InvoiceChangeWizard, self).affect_change()
self._affect_analytic_change(old_analytic_id)
return res
def _affect_analytic_change(self, old_analytic_id):
if old_analytic_id != self.analytic_account_id.id:
self.invoice_id.invoice_line_ids \
.filtered(lambda l: l.account_analytic_id.id == old_analytic_id) \
.write({'account_analytic_id': self.analytic_account_id.id})
if self.invoice_id.move_id:
lines_to_affect = self.invoice_id.move_id \
.line_ids.filtered(lambda l: l.analytic_account_id.id == old_analytic_id)
lines_to_affect.write({'analytic_account_id': self.analytic_account_id.id})
lines_to_affect.create_analytic_lines()

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="account_invoice_change_form_inherit" model="ir.ui.view">
<field name="name">account.invoice.change.form.inherit</field>
<field name="model">account.invoice.change</field>
<field name="inherit_id" ref="account_invoice_change.account_invoice_change_form"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='group_right']" position="inside">
<field name="analytic_account_id" domain="[('company_id', '=', invoice_company_id)]"/>
</xpath>
</field>
</record>
</odoo>

View File

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

View File

@@ -0,0 +1,23 @@
{
'name': 'Invoice Margin',
'author': 'Hibou Corp. <hello@hibou.io>',
'version': '11.0.1.0.0',
'category': 'Accounting',
'sequence': 95,
'summary': 'Invoices include margin calculation.',
'description': """
Invoices include margin calculation.
If the invoice line comes from a sale order line, the cost will come
from the sale order line.
""",
'website': 'https://hibou.io/',
'depends': [
'account',
'sale_margin',
],
'data': [
'views/account_invoice_views.xml',
],
'installable': True,
'application': False,
}

View File

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

View File

@@ -0,0 +1,62 @@
from odoo import api, fields, models
from odoo.addons import decimal_precision as dp
class AccountInvoiceLine(models.Model):
_inherit = "account.invoice.line"
margin = fields.Float(compute='_product_margin', digits=dp.get_precision('Product Price'), store=True)
purchase_price = fields.Float(string='Cost', digits=dp.get_precision('Product Price'))
def _compute_margin(self, invoice_id, product_id, product_uom_id, sale_line_ids):
# if sale_line_ids and don't re-browse
for line in sale_line_ids:
return line.purchase_price
frm_cur = invoice_id.company_currency_id
to_cur = invoice_id.currency_id
purchase_price = product_id.standard_price
if product_uom_id != product_id.uom_id:
purchase_price = product_id.uom_id._compute_price(purchase_price, product_uom_id)
ctx = self.env.context.copy()
ctx['date'] = invoice_id.date if invoice_id.date else fields.Date.context_today(invoice_id)
price = frm_cur.with_context(ctx).compute(purchase_price, to_cur, round=False)
return price
@api.onchange('product_id', 'uom_id')
def product_id_change_margin(self):
if not self.product_id or not self.uom_id:
return
self.purchase_price = self._compute_margin(self.invoice_id, self.product_id, self.uom_id, self.sale_line_ids)
@api.model
def create(self, vals):
line = super(AccountInvoiceLine, self).create(vals)
line.product_id_change_margin()
return line
@api.depends('product_id', 'purchase_price', 'quantity', 'price_unit', 'price_subtotal')
def _product_margin(self):
for line in self:
currency = line.invoice_id.currency_id
price = line.purchase_price
if line.product_id and not price:
date = line.invoice_id.date if line.invoice_id.date else fields.Date.context_today(line.invoice_id)
from_cur = line.invoice_id.company_currency_id.with_context(date=date)
price = from_cur.compute(line.product_id.standard_price, currency, round=False)
line.margin = currency.round(line.price_subtotal - (price * line.quantity))
class AccountInvoice(models.Model):
_inherit = "account.invoice"
margin = fields.Monetary(compute='_product_margin',
help="It gives profitability by calculating the difference between the Unit Price and the cost.",
currency_field='currency_id',
digits=dp.get_precision('Product Price'),
store=True)
@api.depends('invoice_line_ids.margin')
def _product_margin(self):
for invoice in self:
invoice.margin = sum(invoice.invoice_line_ids.mapped('margin'))

View File

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

View File

@@ -0,0 +1,64 @@
from odoo.addons.sale_margin.tests.test_sale_margin import TestSaleMargin
class TestInvoiceMargin(TestSaleMargin):
def setUp(self):
super(TestInvoiceMargin, self).setUp()
self.AccountInvoice = self.env['account.invoice']
def test_invoice_margin(self):
""" Test the sale_margin module in Odoo. """
# Create a sales order for product Graphics Card.
sale_order_so11 = self.SaleOrder.create({
'name': 'Test_SO011',
'order_line': [
(0, 0, {
'name': '[CARD] Graphics Card',
'purchase_price': 700.0,
'price_unit': 1000.0,
'product_uom': self.product_uom_id,
'product_uom_qty': 10.0,
'state': 'draft',
'product_id': self.product_id}),
(0, 0, {
'name': 'Line without product_uom',
'price_unit': 1000.0,
'purchase_price': 700.0,
'product_uom_qty': 10.0,
'state': 'draft',
'product_id': self.product_id})
],
'partner_id': self.partner_id,
'partner_invoice_id': self.partner_invoice_address_id,
'partner_shipping_id': self.partner_invoice_address_id,
'pricelist_id': self.pricelist_id})
# Confirm the sales order.
sale_order_so11.action_confirm()
# Verify that margin field gets bind with the value.
self.assertEqual(sale_order_so11.margin, 6000.00, "Sales order margin should be 6000.00")
# Invoice the sales order.
inv_id = sale_order_so11.action_invoice_create()
inv = self.AccountInvoice.browse(inv_id)
self.assertEqual(inv.margin, sale_order_so11.margin)
account = self.env['account.account'].search([('internal_type', '=', 'other')], limit=1)
inv = self.AccountInvoice.create({
'partner_id': self.partner_id,
'invoice_line_ids': [
(0, 0, {
'account_id': account.id,
'name': '[CARD] Graphics Card',
'purchase_price': 600.0,
'price_unit': 1000.0,
'quantity': 10.0,
'product_id': self.product_id}),
(0, 0, {
'account_id': account.id,
'name': 'Line without product_uom',
'price_unit': 1000.0,
'purchase_price': 800.0,
'quantity': 10.0,})
],
})
self.assertEqual(inv.margin, 6000.0)

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record model="ir.ui.view" id="invoice_margin_form">
<field name="name">account.invoice.margin.view.form</field>
<field name="model">account.invoice</field>
<field name="inherit_id" ref="account.invoice_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='residual']" position="after">
<field name="margin" groups="base.group_user"/>
</xpath>
<xpath expr="//field[@name='invoice_line_ids']//field[@name='price_unit']" position="after">
<field name="purchase_price" groups="base.group_user"/>
</xpath>
</field>
</record>
</odoo>

4
auth_admin/__init__.py Executable file
View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import controllers
from . import models
from . import wizard

30
auth_admin/__manifest__.py Executable file
View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
{
'name': 'Auth Admin',
'author': 'Hibou Corp. <hello@hibou.io>',
'category': 'Hidden',
'version': '11.0.0.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

41
auth_admin/controllers/main.py Executable file
View File

@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
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)

2
auth_admin/models/__init__.py Executable file
View File

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

90
auth_admin/models/res_users.py Executable file
View File

@@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
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.Warning('Invalid Request')
if not (now <= int(e_expires) <= fifteen):
raise exceptions.Warning('Expired')
user = env['res.users'].sudo().search([('id', '=', int(u_user_id))], limit=1)
if not user.id:
raise exceptions.Warning('Invalid User')
return user
class ResUsers(models.Model):
_inherit = 'res.users'
@api.multi
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
@api.model
def check_credentials(self, password):
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))
return True
return super(ResUsers, self).check_credentials(password)

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>

2
auth_admin/wizard/__init__.py Executable file
View File

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

View File

@@ -0,0 +1,27 @@
from odoo import api, fields, models
from ..models.res_users import admin_auth_generate_login
class PortalWizard(models.TransientModel):
_inherit = 'portal.wizard'
@api.multi
def admin_auth_generate_login(self):
self.ensure_one()
self.user_ids.admin_auth_generate_login()
return {'type': 'ir.actions.do_nothing'}
class PortalWizardUser(models.TransientModel):
_inherit = 'portal.wizard.user'
force_login_url = fields.Char(string='Force Login URL')
@api.multi
def admin_auth_generate_login(self):
ir_model_access = self.env['ir.model.access']
for row in self:
row.force_login_url = ''
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 row.in_portal and user:
row.force_login_url = admin_auth_generate_login(self.env, user)

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" readonly="1"/>
</xpath>
<xpath expr="//button[last()]" position="after">
<button string="Generate Login URL" type="object" name="admin_auth_generate_login" class="btn-default" />
</xpath>
</field>
</record>
</odoo>

1
dbfilter_from_header Symbolic link
View File

@@ -0,0 +1 @@
external/hibou-oca/server-tools/dbfilter_from_header

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

@@ -17,6 +17,7 @@ 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'),
@@ -33,7 +34,10 @@ class PartnerShippingAccount(models.Model):
res = []
for acc in self:
res.append((acc.id, '%s: %s' % (get_name(acc.delivery_type), acc.name)))
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')

View File

@@ -5,6 +5,7 @@
<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"/>
@@ -21,6 +22,7 @@
<group>
<group>
<field name="partner_id"/>
<field name="description"/>
<field name="name"/>
<field name="delivery_type"/>
</group>
@@ -37,6 +39,7 @@
<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"/>
@@ -69,6 +72,7 @@
<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>

View File

@@ -0,0 +1,28 @@
*************************************
Hibou - DHL Partner Shipping Accounts
*************************************
Adds DHL shipping accounts.
For more information and add-ons, visit `Hibou.io <https://hibou.io/>`_.
=============
Main Features
=============
* Adds DHL to the delivery type selection field.
* Validates entered DHL account numbers are the correct length.
.. image:: https://user-images.githubusercontent.com/15882954/41176760-825c6802-6b16-11e8-91b6-188b32146626.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,28 @@
***************************************
Hibou - FedEx Partner Shipping Accounts
***************************************
Adds FedEx shipping accounts.
For more information and add-ons, visit `Hibou.io <https://hibou.io/>`_.
=============
Main Features
=============
* Adds FedEx to the delivery type selection field.
* Validates entered FedEx account numbers are the correct length.
.. image:: https://user-images.githubusercontent.com/15882954/41176817-b7353356-6b16-11e8-8545-3e59b7b350ae.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,29 @@
***************************************
Hibou - UPS Partner Shipping Accounts
***************************************
Adds UPS shipping accounts.
For more information and add-ons, visit `Hibou.io <https://hibou.io/>`_.
=============
Main Features
=============
* Adds UPS to the delivery type selection field.
* Adds new required field of UPS Account ZIP.
* Validates entered UPS account numbers are the correct length.
.. image:: https://user-images.githubusercontent.com/15882954/41176879-e7dc5a66-6b16-11e8-82a2-9b6cd0c909fd.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,36 @@
*****************************
Hibou - HR Department Project
*****************************
Define a default project for every department.
For more information and add-ons, visit `Hibou.io <https://hibou.io/>`_.
=============
Main Features
=============
* Adds new smart button to HR Department form view for projects which displays the number of projects for that department.
* New project tree view for department-specific projects.
* Adds new Department field to projects.
* Adds new filter to group projects by Department.
.. image:: https://user-images.githubusercontent.com/15882954/41183026-42afc7b4-6b2d-11e8-9531-f3e56b92b332.png
:alt: 'Project Create'
:width: 988
:align: left
.. image:: https://user-images.githubusercontent.com/15882954/41183324-fa790b84-6b2e-11e8-956b-3724a4b49e56.png
:alt: 'Department 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 hr_employee_activity

View File

@@ -0,0 +1,21 @@
{
'name': 'HR Employee Activity',
'version': '11.0.1.0.0',
'author': 'Hibou Corp. <hello@hibou.io>',
'website': 'https://hibou.io/',
'license': 'AGPL-3',
'category': 'Employees',
'complexity': 'easy',
'description': """
This module adds activity to the `hr.employee` model.
""",
'depends': [
'hr',
],
'data': [
'hr_employee_activity_views.xml',
],
'installable': True,
'auto_install': False,
'category': 'Hidden',
}

View File

@@ -0,0 +1,6 @@
from odoo import models
class HrEmployee(models.Model):
_name = 'hr.employee'
_inherit = ['hr.employee', 'mail.activity.mixin']

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_employee_form_activity" model="ir.ui.view">
<field name="name">hr.employee.form.activity</field>
<field name="model">hr.employee</field>
<field name="inherit_id" ref="hr.view_employee_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='message_ids']" position="before">
<field name="activity_ids" widget="mail_activity"/>
</xpath>
</field>
</record>
</odoo>

View File

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

View File

@@ -0,0 +1,21 @@
{
'name': 'HR Expense Lead',
'version': '11.0.1.0.0',
'author': 'Hibou Corp. <hello@hibou.io>',
'category': 'Human Resources',
'summary': 'Assign Opportunity/Lead to expenses for reporting.',
'description': """
Assign Opportunity/Lead to expenses for reporting.
""",
'website': 'https://hibou.io/',
'depends': [
'hr_expense',
'crm',
],
'data': [
'views/hr_expense_views.xml',
'views/crm_views.xml'
],
'installable': True,
'auto_install': False,
}

View File

@@ -0,0 +1,2 @@
from . import crm_lead
from . import hr_expense_lead

View File

@@ -0,0 +1,16 @@
from odoo import api, fields, models
class CRMLead(models.Model):
_inherit = 'crm.lead'
expense_total_amount = fields.Float(string='Expenses Total',
compute='_compute_expense_total_amount',
compute_sudo=True)
expense_ids = fields.One2many('hr.expense', 'lead_id', string='Expenses')
@api.multi
@api.depends('expense_ids.total_amount')
def _compute_expense_total_amount(self):
for lead in self:
lead.expense_total_amount = sum(lead.expense_ids.mapped('total_amount'))

View File

@@ -0,0 +1,7 @@
from odoo import api, fields, models
class HRExpenseLead(models.Model):
_inherit = 'hr.expense'
lead_id = fields.Many2one('crm.lead', string='Lead')

View File

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

View File

@@ -0,0 +1,16 @@
from odoo.tests import common
class TestCheckVendor(common.TransactionCase):
def test_fields(self):
lead = self.env['crm.lead'].create({'name': 'Test Lead'})
expense = self.env['hr.expense'].create({
'name': 'Test Expense',
'product_id': self.env['product.product'].search([('can_be_expensed', '=', True)], limit=1).id,
'unit_amount': 34.0,
})
self.assertFalse(lead.expense_ids)
self.assertEqual(lead.expense_total_amount, 0.0)
expense.lead_id = lead
self.assertTrue(lead.expense_ids)
self.assertEqual(lead.expense_total_amount, 34.0)

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="crm_lead_form_opportunity_inherit" model="ir.ui.view">
<field name="name">crm.lead.form.opportunity.inherit</field>
<field name="model">crm.lead</field>
<field name="inherit_id" ref="crm.crm_case_form_view_oppor"/>
<field name="arch" type="xml">
<xpath expr="//sheet/div[@name='button_box']" position="inside">
<button name="%(hr_expense.hr_expense_actions_all)d" class="oe_stat_button" icon="fa-money" type="action" context="{'search_default_lead_id': active_id}">
<div class="o_stat_info">
<field name="expense_total_amount" widget="monetary" nolabel="1" options="{'currency_field': 'company_currency'}"/>
<span class="o_stat_text"> Expenses</span>
</div>
</button>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_hr_expense_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_form_view"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='employee_id']" position="after">
<field name="lead_id"/>
</xpath>
</field>
</record>
<record id="view_hr_expense_filter_inherit" model="ir.ui.view">
<field name="name">hr.expense.filter.inherit</field>
<field name="model">hr.expense</field>
<field name="inherit_id" ref="hr_expense.view_hr_expense_filter"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='product_id']" position="after">
<field name="lead_id"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,30 @@
*************************
Hibou - HR Expense Vendor
*************************
Records the vendor paid on expenses.
For more information and add-ons, visit `Hibou.io <https://hibou.io/>`_.
=============
Main Features
=============
* Validates presence of assigned vendor to process a Company Paid Expense.
* If the expense is company paid, then the vendor will be the partner used when creating the journal entry, this makes it much easier to reconcile.
* Additionally, adds the expense reference to the journal entry to make it easier to reconcile in either case.
.. image:: https://user-images.githubusercontent.com/15882954/41182457-9b692f92-6b2a-11e8-9d5a-d8ef2f19f198.png
:alt: 'Expense 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,2 @@
from . import hr_payslip
from . import hr_contract

View File

@@ -0,0 +1,16 @@
{
'name': 'Attendance on Payslips',
'description': 'Get Attendence numbers onto Employee Payslips.',
'version': '11.0.1.0.0',
'website': 'https://hibou.io/',
'author': 'Hibou Corp. <hello@hibou.io>',
'license': 'AGPL-3',
'category': 'Human Resources',
'data': [
'hr_contract_view.xml',
],
'depends': [
'hr_payroll',
'hr_attendance',
],
}

View File

@@ -0,0 +1,7 @@
from odoo import models, fields
class HrContract(models.Model):
_inherit = 'hr.contract'
paid_hourly_attendance = fields.Boolean(string="Paid Hourly Attendance", default=False)

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="hr_contract_form_inherit" model="ir.ui.view">
<field name="name">hr.contract.form.inherit</field>
<field name="model">hr.contract</field>
<field name="priority">20</field>
<field name="inherit_id" ref="hr_contract.hr_contract_view_form"/>
<field name="arch" type="xml">
<data>
<xpath expr="//field[@name='advantages']" position="after">
<field name="paid_hourly_attendance"/>
</xpath>
<xpath expr="//div[@name='wage']/span" position="replace">
<span attrs="{'invisible': [('paid_hourly_attendance', '=', True)]}">/ pay period</span>
<span attrs="{'invisible': [('paid_hourly_attendance', '=', False)]}">/ hour</span>
</xpath>
</data>
</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,93 @@
from datetime import datetime
from collections import defaultdict
from odoo import api, models
from odoo.exceptions import ValidationError
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT
class HrPayslip(models.Model):
_inherit = 'hr.payslip'
@api.model
def get_worked_day_lines(self, contracts, date_from, date_to):
def create_empty_worked_lines(employee, contract, date_from, date_to):
attn = {
'name': 'Attendance',
'sequence': 10,
'code': 'ATTN',
'number_of_days': 0.0,
'number_of_hours': 0.0,
'contract_id': contract.id,
}
valid_attn = [
('employee_id', '=', employee.id),
('check_in', '>=', date_from),
('check_in', '<=', date_to),
]
return attn, valid_attn
work = []
for contract in contracts.filtered(lambda c: c.paid_hourly_attendance):
worked_attn, valid_attn = create_empty_worked_lines(
contract.employee_id,
contract,
date_from,
date_to
)
days = set()
for attn in self.env['hr.attendance'].search(valid_attn):
if not attn.check_out:
raise ValidationError('This pay period must not have any open attendances.')
if attn.worked_hours:
# Avoid in/outs
attn_start_time = datetime.strptime(attn.check_in, DEFAULT_SERVER_DATETIME_FORMAT)
attn_iso = attn_start_time.isocalendar()
if not attn_iso in days:
worked_attn['number_of_days'] += 1
days.add(attn_iso)
worked_attn['number_of_hours'] += attn.worked_hours
worked_attn['number_of_hours'] = round(worked_attn['number_of_hours'], 2)
work.append(worked_attn)
res = super(HrPayslip, self).get_worked_day_lines(contracts.filtered(lambda c: not c.paid_hourly_attendance), date_from, date_to)
res.extend(work)
return res
@api.multi
def hour_break_down(self, code):
"""
:param code: what kind of worked days you need aggregated
:return: dict: keys are isocalendar tuples, values are hours.
"""
self.ensure_one()
if code == 'ATTN':
attns = self.env['hr.attendance'].search([
('employee_id', '=', self.employee_id.id),
('check_in', '>=', self.date_from),
('check_in', '<=', self.date_to),
])
day_values = defaultdict(float)
for attn in attns:
if not attn.check_out:
raise ValidationError('This pay period must not have any open attendances.')
if attn.worked_hours:
# Avoid in/outs
attn_start_time = datetime.strptime(attn.check_in, DEFAULT_SERVER_DATETIME_FORMAT)
attn_iso = attn_start_time.isocalendar()
day_values[attn_iso] += attn.worked_hours
return day_values
elif hasattr(super(HrPayslip, self), 'hour_break_down'):
return super(HrPayslip, self).hour_break_down(code)
@api.multi
def hours_break_down_week(self, code):
"""
:param code: hat kind of worked days you need aggregated
:return: dict: keys are isocalendar weeks, values are hours.
"""
days = self.hour_break_down(code)
weeks = defaultdict(float)
for isoday, hours in days.items():
weeks[isoday[1]] += hours
return weeks

View File

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

View File

@@ -0,0 +1,15 @@
{
'name': 'Payroll Attendance Holidays',
'author': 'Hibou Corp. <hello@hibou.io>',
'version': '11.0.1.0.0',
'category': 'Human Resources',
'sequence': 95,
'summary': 'Holiday Pay',
'description': """
Simplifies getting approved Holiday Leaves onto an employee Payslip.
""",
'website': 'https://hibou.io/',
'depends': ['hr_payroll_attendance', 'hr_holidays'],
'installable': True,
'application': False,
}

View File

@@ -0,0 +1,52 @@
from odoo import models, api
from odoo.addons.hr_holidays.models.hr_holidays import HOURS_PER_DAY
class HrPayslip(models.Model):
_inherit = 'hr.payslip'
@api.model
def get_worked_day_lines(self, contracts, date_from, date_to):
leaves = {}
for contract in contracts.filtered(lambda c: c.paid_hourly_attendance):
for leave in self._fetch_valid_leaves(contract.employee_id.id, date_from, date_to):
leave_code = self._create_leave_code(leave.holiday_status_id.name)
if leave_code in leaves:
leaves[leave_code]['number_of_days'] += leave.number_of_days_temp
leaves[leave_code]['number_of_hours'] += leave.number_of_days_temp * HOURS_PER_DAY
else:
leaves[leave_code] = {
'name': leave.holiday_status_id.name,
'sequence': 15,
'code': leave_code,
'number_of_days': leave.number_of_days_temp,
'number_of_hours': leave.number_of_days_temp * HOURS_PER_DAY,
'contract_id': contract.id,
}
res = super(HrPayslip, self).get_worked_day_lines(contracts, date_from, date_to)
res.extend(leaves.values())
return res
@api.multi
def action_payslip_done(self):
for slip in self.filtered(lambda s: s.contract_id.paid_hourly_attendance):
leaves = self._fetch_valid_leaves(slip.employee_id.id, slip.date_from, slip.date_to)
leaves.write({'payslip_status': True})
return super(HrPayslip, self).action_payslip_done()
def _fetch_valid_leaves(self, employee_id, date_from, date_to):
valid_leaves = [
('employee_id', '=', employee_id),
('state', '=', 'validate'),
('date_from', '>=', date_from),
('date_from', '<=', date_to),
('payslip_status', '=', False),
('type', '=', 'remove'),
]
return self.env['hr.holidays'].search(valid_leaves)
def _create_leave_code(self, name):
return 'L_' + name.replace(' ', '_')

View File

@@ -6,7 +6,7 @@
'version': '11.0.0.0.0',
'category': 'Human Resources',
'sequence': 95,
'summary': 'Register payments for Payroll Payslips',
'summary': 'Holiday Pay',
'description': """
Simplifies getting approved Holiday Leaves onto an employee Payslip.
""",

View File

@@ -42,7 +42,7 @@ class HrPayslip(models.Model):
('employee_id', '=', employee_id),
('state', '=', 'validate'),
('date_from', '>=', date_from),
('date_to', '<=', date_to),
('date_from', '<=', date_to),
('payslip_status', '=', False),
('type', '=', 'remove'),
]

View File

@@ -8,7 +8,7 @@
<field name="inherit_id" ref="hr_contract.hr_contract_view_form"/>
<field name="arch" type="xml">
<data>
<xpath expr="//field[@name='wage']" position="after">
<xpath expr="//field[@name='advantages']" position="after">
<field name="paid_hourly"/>
</xpath>
</data>

View File

@@ -1,5 +1,8 @@
# -*- coding: utf-8 -*-
from datetime import datetime
from collections import defaultdict
from odoo import api, models
from odoo.exceptions import ValidationError
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT
class HrPayslip(models.Model):
@@ -7,44 +10,80 @@ class HrPayslip(models.Model):
@api.model
def get_worked_day_lines(self, contracts, date_from, date_to):
def create_empty_worked_lines(employee_id, contract_id, date_from, date_to):
attendance = {
'name': 'Timesheet Attendance',
def create_empty_worked_lines(employee, contract, date_from, date_to):
attn = {
'name': 'Attendance',
'sequence': 10,
'code': 'ATTN',
'number_of_days': 0.0,
'number_of_hours': 0.0,
'contract_id': contract_id,
'contract_id': contract.id,
}
valid_days = [
('sheet_id.employee_id', '=', employee_id),
('sheet_id.state', '=', 'done'),
('sheet_id.date_from', '>=', date_from),
('sheet_id.date_to', '<=', date_to),
valid_attn = [
('employee_id', '=', employee.id),
('check_in', '>=', date_from),
('check_in', '<=', date_to),
]
return attendance, valid_days
attendances = []
return attn, valid_attn
work = []
for contract in contracts:
attendance, valid_days = create_empty_worked_lines(
contract.employee_id.id,
contract.id,
worked_attn, valid_attn = create_empty_worked_lines(
contract.employee_id,
contract,
date_from,
date_to
)
for day in self.env['hr_timesheet_sheet.sheet.day'].search(valid_days):
if day.total_attendance >= 0.0:
attendance['number_of_days'] += 1
attendance['number_of_hours'] += day.total_attendance
# needed so that the shown hours matches any calculations you use them for
attendance['number_of_hours'] = round(attendance['number_of_hours'], 2)
attendances.append(attendance)
days = set()
for attn in self.env['hr.attendance'].search(valid_attn):
if not attn.check_out:
raise ValidationError('This pay period must not have any open attendances.')
if attn.worked_hours:
# Avoid in/outs
attn_start_time = datetime.strptime(attn.check_in, DEFAULT_SERVER_DATETIME_FORMAT)
attn_iso = attn_start_time.isocalendar()
if not attn_iso in days:
worked_attn['number_of_days'] += 1
days.add(attn_iso)
worked_attn['number_of_hours'] += attn.worked_hours
worked_attn['number_of_hours'] = round(worked_attn['number_of_hours'], 2)
work.append(worked_attn)
res = super(HrPayslip, self).get_worked_day_lines(contracts, date_from, date_to)
res.extend(attendances)
res.extend(work)
return res
@api.multi
def hour_break_down(self, code):
"""
:param code: what kind of worked days you need aggregated
:return: dict: keys are isocalendar tuples, values are hours.
"""
self.ensure_one()
if code == 'ATTN':
attns = self.env['hr.attendance'].search([
('employee_id', '=', self.employee_id.id),
('check_in', '>=', self.date_from),
('check_in', '<=', self.date_to),
])
day_values = defaultdict(float)
for attn in attns:
if not attn.check_out:
raise ValidationError('This pay period must not have any open attendances.')
if attn.worked_hours:
# Avoid in/outs
attn_start_time = datetime.strptime(attn.check_in, DEFAULT_SERVER_DATETIME_FORMAT)
attn_iso = attn_start_time.isocalendar()
day_values[attn_iso] += attn.worked_hours
return day_values
elif hasattr(super(HrPayslip, self), 'hours'):
return super(HrPayslip, self).hours(code)
@api.multi
def hours_break_down_week(self, code):
days = self.hour_break_down(code)
weeks = defaultdict(float)
for isoday, hours in days.items():
weeks[isoday[1]] += hours
return weeks

View File

@@ -59,7 +59,7 @@ class TestUsPayslip(common.TransactionCase):
if not struct_id:
struct_id = self.ref('l10n_us_hr_payroll.hr_payroll_salary_structure_us_employee')
return self.env['hr.contract'].create({
values = {
'date_start': '2016-01-01',
'date_end': '2030-12-31',
'name': 'Contract for Jared 2016',
@@ -76,8 +76,13 @@ class TestUsPayslip(common.TransactionCase):
'external_wages': external_wages,
'futa_type': futa_type,
'state': 'open', # if not "Running" then no automatic selection when Payslip is created
'journal_id': self.env['account.journal'].search([('type', '=', 'general')], limit=1).id,
})
}
try:
values['journal_id'] = self.env['account.journal'].search([('type', '=', 'general')], limit=1).id
except KeyError:
pass
return self.env['hr.contract'].create(values)
def _createPayslip(self, employee, date_from, date_to):
return self.env['hr.payslip'].create({

View File

@@ -0,0 +1,42 @@
************************************
Hibou - Maintenance Equipment Charge
************************************
Record related equipment charges, for example fuel charges.
For more information and add-ons, visit `Hibou.io <https://hibou.io/>`_.
=============
Main Features
=============
* New Models: Equipment Charge and Equipment Charge Type
* New smart button on the equipment form view for Charges.
* Adds Equipment Charge views: form, tree, graph, pivot and calendar.
* Adds filters to group equipment charges by: Charge Type, Equipment, Employee and Department.
* By default, **Employees** have the ability to create and view **Charges** and **Charge Types**, while **Inventory Managers** have the ability to update and delete them.
.. image:: https://user-images.githubusercontent.com/15882954/41184422-143b5cc4-6b35-11e8-9dcc-6c16ac31b869.png
:alt: 'Equipment Detail'
:width: 988
:align: left
.. image:: https://user-images.githubusercontent.com/15882954/41184430-27f2c586-6b35-11e8-94f4-9b4efa1fcfe9.png
:alt: 'Equipment Charges Detail'
:width: 988
:align: left
.. image:: https://user-images.githubusercontent.com/15882954/41184451-3ee3cc18-6b35-11e8-9488-445538501be8.png
:alt: 'Equipment Charge 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

@@ -13,6 +13,7 @@ Record related equipment charges, for example fuel charges.
'website': 'https://www.odoo.com/page/manufacturing',
'depends': [
'hr_maintenance',
'stock'
],
'data': [
'security/ir.model.access.csv',

View File

@@ -0,0 +1,22 @@
****************************
Hibou - Maintenance Notebook
****************************
Base module that creates tabs used in `maintenance_repair <https://github.com/hibou-io/hibou-odoo-suite/tree/11.0/maintenance_repair>`_ and `maintenance_timesheet <https://github.com/hibou-io/hibou-odoo-suite/tree/11.0/maintenance_timesheet>`_
For more information and add-ons, visit `Hibou.io <https://hibou.io/>`_.
.. image:: https://user-images.githubusercontent.com/15882954/41258483-2666f906-6d85-11e8-9f74-a50aaa6b527b.png
:alt: 'Equipment 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,33 @@
**************************
Hibou - Maintenance Repair
**************************
Keep track of parts required to repair equipment.
For more information and add-ons, visit `Hibou.io <https://hibou.io/>`_.
=============
Main Features
=============
* Consume products on Maintenance Requests.
* New Model: Maintenance Request Repair Line
* New 'Parts' notebook tab on Maintenance Request form.
* New Equipment Repair filter 'To Repair' to view maintenance requests with part line items that have not yet been consumed.
* Tally for total cost of Parts.
* Includes Employee permissions for managing maintenance request repair line items
.. image:: https://user-images.githubusercontent.com/15882954/41262389-6665024a-6d95-11e8-9d94-236c635e1cf2.png
:alt: 'Equipment Request 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,34 @@
*****************************
Hibou - Maintenance Timesheet
*****************************
Record time on maintenance requests.
For more information and add-ons, visit `Hibou.io <https://hibou.io/>`_.
=============
Main Features
=============
* Adds Timesheets to Maintenance Requests to record time and labor costs.
* New 'Timesheets' notebook tab on Maintenance Request form.
.. image:: https://user-images.githubusercontent.com/15882954/41261982-394a10b8-6d93-11e8-9602-c19a3e20065d.png
:alt: 'Equipment Detail'
:width: 988
:align: left
=====
Notes
=====
* In order to add time sheets, you must first select a Billing Project from the dropdown menu.
=======
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,41 @@
*************************
Hibou - Maintenance Usage
*************************
Keep track of usage on different types of equipment.
For more information and add-ons, visit `Hibou.io <https://hibou.io/>`_.
=============
Main Features
=============
* New Model: Maintenance Usage Log
* Adds new fields for usage on equipments.
* Adds new descriptive UOM on categories.
* Allows users to create preventative maintenance requests based on usage.
* Creates Maintenance Log based on changes in usage or employee ownership, to provide a report on equipment changes over time.
.. image:: https://user-images.githubusercontent.com/15882954/41305818-f62a43b6-6e28-11e8-9d30-80d06b273354.png
:alt: 'Equipment Detail'
:width: 988
:align: left
New Equipment Usage View
.. image:: https://user-images.githubusercontent.com/15882954/41305848-09a038ec-6e29-11e8-9ad5-7b3d34bd7b64.png
:alt: 'Equipment Usage 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

71
newrelic/__init__.py Normal file
View File

@@ -0,0 +1,71 @@
from . import controllers
from logging import getLogger
_logger = getLogger(__name__)
try:
import odoo
target = odoo.service.server.server
try:
instrumented = target._nr_instrumented
except AttributeError:
instrumented = target._nr_instrumented = False
if instrumented:
_logger.info("NewRelic instrumented already")
else:
import odoo.tools.config as config
import newrelic.agent
try:
newrelic.agent.initialize(config['new_relic_config_file'], config['new_relic_environment'])
except KeyError:
try:
newrelic.agent.initialize(config['new_relic_config_file'])
except KeyError:
_logger.info('NewRelic setting up from env variables')
newrelic.agent.initialize()
# Main WSGI Application
target._nr_instrumented = True
target.app = newrelic.agent.WSGIApplicationWrapper(target.app)
# Workers new WSGI Application
target = odoo.service.wsgi_server
target.application_unproxied = newrelic.agent.WSGIApplicationWrapper(target.application_unproxied)
# Error handling
def should_ignore(exc, value, tb):
from werkzeug.exceptions import HTTPException
# Werkzeug HTTPException can be raised internally by Odoo or in
# user code if they mix Odoo with Werkzeug. Filter based on the
# HTTP status code.
if isinstance(value, HTTPException):
if newrelic.agent.ignore_status_code(value.code):
return True
def _nr_wrapper_handle_exception_(wrapped):
def _handle_exception(*args, **kwargs):
transaction = newrelic.agent.current_transaction()
if transaction is None:
return wrapped(*args, **kwargs)
transaction.record_exception(ignore_errors=should_ignore)
name = newrelic.agent.callable_name(args[1])
with newrelic.agent.FunctionTrace(transaction, name):
return wrapped(*args, **kwargs)
return _handle_exception
target = odoo.http.WebRequest
target._handle_exception = _nr_wrapper_handle_exception_(target._handle_exception)
except ImportError:
_logger.warn('newrelic python module not installed or other missing module')

12
newrelic/__manifest__.py Normal file
View File

@@ -0,0 +1,12 @@
{
'name': 'NewRelic Instrumentation',
'description': 'Wraps requests etc.',
'version': '1.0',
'website': 'https://hibou.io/',
'author': 'Hibou Corp. <hello@hibou.io>',
'license': 'AGPL-3',
'category': 'Tool',
'depends': [
'web',
],
}

View File

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

View File

@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
from odoo import http, tools
import odoo.addons.bus.controllers.main
try:
import newrelic
import newrelic.agent
except ImportError:
newrelic = None
class BusController(odoo.addons.bus.controllers.main.BusController):
@http.route()
def send(self, channel, message):
if newrelic:
newrelic.agent.ignore_transaction()
return super(BusController, self).send(channel, message)
@http.route()
def poll(self, channels, last, options=None):
if newrelic:
newrelic.agent.ignore_transaction()
return super(BusController, self).poll(channels, last, options)
try:
if tools.config['debug_mode']:
class TestErrors(http.Controller):
@http.route('/test_errors_404', auth='public')
def test_errors_404(self):
import werkzeug
return werkzeug.exceptions.NotFound('Successful test of 404')
@http.route('/test_errors_500', auth='public')
def test_errors_500(self):
raise ValueError
except KeyError:
pass

View File

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

View File

@@ -0,0 +1,22 @@
{
'name': 'Project Description',
'version': '11.0.1.0.0',
'author': 'Hibou Corp. <hello@hibou.io>',
'website': 'https://hibou.io/',
'license': 'AGPL-3',
'category': 'Tools',
'complexity': 'easy',
'description': """
Adds description onto Projects that will be displayed on tasks.
Useful for keeping project specific notes that are needed whenever
you're working on a task in that project.
""",
'depends': [
'project',
],
'data': [
'views/project_views.xml',
],
'installable': True,
'auto_install': False,
}

View File

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

View File

@@ -0,0 +1,12 @@
from odoo import api, fields, models
class Project(models.Model):
_inherit = 'project.project'
note = fields.Html(string='Note')
class ProjectTask(models.Model):
_inherit = 'project.task'
project_note = fields.Html(related='project_id.note')

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="edit_project_inherit" model="ir.ui.view">
<field name="name">project.project.form.inherit</field>
<field name="model">project.project</field>
<field name="inherit_id" ref="project.edit_project" />
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page name="note_page" string="Notes">
<field name="note" nolabel="1" type="html"/>
<div class="oe_clear"/>
</page>
</xpath>
</field>
</record>
<record id="view_task_form2_inherit" model="ir.ui.view">
<field name="name">project.task.form.inherit</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="project.view_task_form2" />
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page name="project_note_page" string="Project Notes">
<field name="project_note" nolabel="1" type="html" readonly="1"/>
<div class="oe_clear"/>
</page>
</xpath>
</field>
</record>
</odoo>

2
sale_planner/__init__.py Normal file
View File

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

View File

@@ -0,0 +1,41 @@
{
'name': 'Sale Order Planner',
'summary': 'Plans order dates and warehouses.',
'version': '11.0.1.0.0',
'author': "Hibou Corp.",
'category': 'Sale',
'license': 'AGPL-3',
'complexity': 'expert',
'images': [],
'website': "https://hibou.io",
'description': """
Sale Order Planner
==================
Plans sales order dates based on available warehouses and shipping methods.
Adds shipping calendar to warehouse to plan delivery orders based on availability
of the warehouse or warehouse staff.
Adds shipping calendar to individual shipping methods to estimate delivery based
on the specific method's characteristics. (e.g. Do they deliver on Saturday?)
""",
'depends': [
'sale_order_dates',
'sale_sourced_by_line',
'base_geolocalize',
'delivery',
'resource',
],
'demo': [],
'data': [
'wizard/order_planner_views.xml',
'views/sale.xml',
'views/stock.xml',
'views/delivery.xml',
],
'auto_install': False,
'installable': True,
}

View File

@@ -0,0 +1,3 @@
from . import sale
from . import stock
from . import delivery

View File

@@ -0,0 +1,69 @@
from datetime import timedelta
from odoo import api, fields, models
class DeliveryCarrier(models.Model):
_inherit = 'delivery.carrier'
delivery_calendar_id = fields.Many2one(
'resource.calendar', 'Delivery Calendar',
help="This calendar represents days that the carrier will deliver the package.")
# -------------------------- #
# API for external providers #
# -------------------------- #
def get_shipping_price_for_plan(self, orders, date_planned):
''' For every sale order, compute the price of the shipment
:param orders: A recordset of sale orders
:param date_planned: Date to say that the shipment is leaving.
:return list: A list of floats, containing the estimated price for the shipping of the sale order
'''
self.ensure_one()
if hasattr(self, '%s_get_shipping_price_for_plan' % self.delivery_type):
return getattr(self, '%s_get_shipping_price_for_plan' % self.delivery_type)(orders, date_planned)
def calculate_transit_days(self, date_planned, date_delivered):
self.ensure_one()
if isinstance(date_planned, str):
date_planned = fields.Datetime.from_string(date_planned)
if isinstance(date_delivered, str):
date_delivered = fields.Datetime.from_string(date_delivered)
transit_days = 0
while date_planned < date_delivered:
if transit_days > 10:
break
interval = self.delivery_calendar_id.schedule_days(1, date_planned, compute_leaves=True)
if not interval:
return self._calculate_transit_days_naive(date_planned, date_delivered)
date_planned = interval[0][1]
transit_days += 1
if transit_days > 1:
transit_days -= 1
return transit_days
def _calculate_transit_days_naive(self, date_planned, date_delivered):
return abs((date_delivered - date_planned).days)
def calculate_date_delivered(self, date_planned, transit_days):
self.ensure_one()
if isinstance(date_planned, str):
date_planned = fields.Datetime.from_string(date_planned)
# date calculations needs an extra day
effective_transit_days = transit_days + 1
interval = self.delivery_calendar_id.schedule_days(effective_transit_days, date_planned, compute_leaves=True)
if not interval:
return self._calculate_date_delivered_naive(date_planned, transit_days)
return fields.Datetime.to_string(interval[-1][1])
def _calculate_date_delivered_naive(self, date_planned, transit_days):
return fields.Datetime.to_string(date_planned + timedelta(days=transit_days))

View File

@@ -0,0 +1,16 @@
from odoo import api, fields, models
class SaleOrder(models.Model):
_inherit = 'sale.order'
@api.multi
def action_planorder(self):
plan_obj = self.env['sale.order.make.plan']
for order in self:
plan = plan_obj.create({
'order_id': order.id,
})
action = self.env.ref('sale_planner.action_plan_sale_order').read()[0]
action['res_id'] = plan.id
return action

View File

@@ -0,0 +1,9 @@
from odoo import api, fields, models
class Warehouse(models.Model):
_inherit = 'stock.warehouse'
shipping_calendar_id = fields.Many2one(
'resource.calendar', 'Shipping Calendar',
help="This calendar represents shipping availability from the warehouse.")

View File

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

View File

@@ -0,0 +1,204 @@
from odoo.tests import common
from datetime import datetime, timedelta
class TestPlanner(common.TransactionCase):
# @todo Test date planning!
def setUp(self):
super(TestPlanner, self).setUp()
self.today = datetime.today()
self.tomorrow = datetime.today() + timedelta(days=1)
# This partner has a parent
self.country_usa = self.env['res.country'].search([('name', '=', 'United States')], limit=1)
self.state_wa = self.env['res.country.state'].search([('name', '=', 'Washington')], limit=1)
self.state_co = self.env['res.country.state'].search([('name', '=', 'Colorado')], limit=1)
self.partner_wa = self.env['res.partner'].create({
'name': 'Jared',
'street': '1234 Test Street',
'city': 'Marysville',
'state_id': self.state_wa.id,
'zip': '98270',
'country_id': self.country_usa.id,
'partner_latitude': 48.05636,
'partner_longitude': -122.14922,
})
self.warehouse_partner_1 = self.env['res.partner'].create({
'name': 'WH1',
'street': '1234 Test Street',
'city': 'Lynnwood',
'state_id': self.state_wa.id,
'zip': '98036',
'country_id': self.country_usa.id,
'partner_latitude': 47.82093,
'partner_longitude': -122.31513,
})
self.warehouse_partner_2 = self.env['res.partner'].create({
'name': 'WH2',
'street': '1234 Test Street',
'city': 'Craig',
'state_id': self.state_co.id,
'zip': '81625',
'country_id': self.country_usa.id,
'partner_latitude': 40.51525,
'partner_longitude': -107.54645,
})
self.warehouse_calendar_1 = self.env['resource.calendar'].create({
'name': 'Washington Warehouse Hours',
'attendance_ids': [
(0, 0, {'name': 'today',
'dayofweek': str(self.today.weekday()),
'hour_from': (self.today.hour - 1) % 24,
'hour_to': (self.today.hour + 1) % 24}),
(0, 0, {'name': 'tomorrow',
'dayofweek': str(self.tomorrow.weekday()),
'hour_from': (self.tomorrow.hour - 1) % 24,
'hour_to': (self.tomorrow.hour + 1) % 24}),
]
})
self.warehouse_calendar_2 = self.env['resource.calendar'].create({
'name': 'Colorado Warehouse Hours',
'attendance_ids': [
(0, 0, {'name': 'tomorrow',
'dayofweek': str(self.tomorrow.weekday()),
'hour_from': (self.tomorrow.hour - 1) % 24,
'hour_to': (self.tomorrow.hour + 1) % 24}),
]
})
self.warehouse_1 = self.env['stock.warehouse'].create({
'name': 'Washington Warehouse',
'partner_id': self.warehouse_partner_1.id,
'code': 'WH1',
'shipping_calendar_id': self.warehouse_calendar_1.id,
})
self.warehouse_2 = self.env['stock.warehouse'].create({
'name': 'Colorado Warehouse',
'partner_id': self.warehouse_partner_2.id,
'code': 'WH2',
'shipping_calendar_id': self.warehouse_calendar_2.id,
})
self.so = self.env['sale.order'].create({
'partner_id': self.partner_wa.id,
'warehouse_id': self.warehouse_1.id,
})
self.product_1 = self.env['product.template'].create({
'name': 'Product for WH1',
'type': 'product',
'standard_price': 1.0,
})
self.product_1 = self.product_1.product_variant_id
self.product_2 = self.env['product.template'].create({
'name': 'Product for WH2',
'type': 'product',
'standard_price': 1.0,
})
self.product_2 = self.product_2.product_variant_id
self.product_both = self.env['product.template'].create({
'name': 'Product for Both',
'type': 'product',
'standard_price': 1.0,
})
self.product_both = self.product_both.product_variant_id
self.env['stock.change.product.qty'].create({
'location_id': self.warehouse_1.lot_stock_id.id,
'product_id': self.product_1.id,
'new_quantity': 100,
}).change_product_qty()
self.env['stock.change.product.qty'].create({
'location_id': self.warehouse_1.lot_stock_id.id,
'product_id': self.product_both.id,
'new_quantity': 100,
}).change_product_qty()
self.env['stock.change.product.qty'].create({
'location_id': self.warehouse_2.lot_stock_id.id,
'product_id': self.product_2.id,
'new_quantity': 100,
}).change_product_qty()
self.env['stock.change.product.qty'].create({
'location_id': self.warehouse_2.lot_stock_id.id,
'product_id': self.product_both.id,
'new_quantity': 100,
}).change_product_qty()
def both_wh_ids(self):
return [self.warehouse_1.id, self.warehouse_2.id]
def test_planner_creation_internals(self):
self.env['sale.order.line'].create({
'order_id': self.so.id,
'product_id': self.product_1.id,
'name': 'demo',
})
both_wh_ids = self.both_wh_ids()
planner = self.env['sale.order.make.plan'].with_context(warehouse_domain=[('id', 'in', both_wh_ids)], skip_plan_shipping=True).create({'order_id': self.so.id})
self.assertEqual(set(both_wh_ids), set(planner.get_warehouses().ids))
fake_order = planner._fake_order(self.so)
base_option = planner.generate_base_option(fake_order)
self.assertTrue(base_option, 'Must have base option.')
self.assertEqual(self.warehouse_1.id, base_option['warehouse_id'])
def test_planner_creation(self):
self.env['sale.order.line'].create({
'order_id': self.so.id,
'product_id': self.product_1.id,
'name': 'demo',
})
self.assertEqual(self.product_1.with_context(warehouse=self.warehouse_1.id).qty_available, 100)
self.assertEqual(self.product_1.with_context(warehouse=self.warehouse_2.id).qty_available, 0)
both_wh_ids = self.both_wh_ids()
planner = self.env['sale.order.make.plan'].with_context(warehouse_domain=[('id', 'in', both_wh_ids)], skip_plan_shipping=True).create({'order_id': self.so.id})
self.assertTrue(planner.planning_option_ids, 'Must have one or more plans.')
self.assertEqual(planner.planning_option_ids.warehouse_id, self.warehouse_1)
self.assertFalse(planner.planning_option_ids[0].sub_options)
def test_planner_creation_2(self):
self.env['sale.order.line'].create({
'order_id': self.so.id,
'product_id': self.product_2.id,
'name': 'demo',
})
self.assertEqual(self.product_2.with_context(warehouse=self.warehouse_1.id).qty_available, 0)
self.assertEqual(self.product_2.with_context(warehouse=self.warehouse_2.id).qty_available, 100)
both_wh_ids = self.both_wh_ids()
planner = self.env['sale.order.make.plan'].with_context(warehouse_domain=[('id', 'in', both_wh_ids)], skip_plan_shipping=True).create({'order_id': self.so.id})
self.assertTrue(planner.planning_option_ids, 'Must have one or more plans.')
self.assertEqual(planner.planning_option_ids.warehouse_id, self.warehouse_2)
self.assertFalse(planner.planning_option_ids[0].sub_options)
def test_planner_creation_split(self):
self.env['sale.order.line'].create({
'order_id': self.so.id,
'product_id': self.product_1.id,
'name': 'demo',
})
self.env['sale.order.line'].create({
'order_id': self.so.id,
'product_id': self.product_2.id,
'name': 'demo',
})
self.assertEqual(self.product_1.with_context(warehouse=self.warehouse_1.id).qty_available, 100)
self.assertEqual(self.product_2.with_context(warehouse=self.warehouse_2.id).qty_available, 100)
both_wh_ids = self.both_wh_ids()
planner = self.env['sale.order.make.plan'].with_context(warehouse_domain=[('id', 'in', both_wh_ids)], skip_plan_shipping=True).create({'order_id': self.so.id})
self.assertTrue(planner.planning_option_ids, 'Must have one or more plans.')
self.assertTrue(planner.planning_option_ids[0].sub_options)
def test_planner_creation_no_split(self):
self.env['sale.order.line'].create({
'order_id': self.so.id,
'product_id': self.product_both.id,
'name': 'demo',
})
self.env['sale.order.line'].create({
'order_id': self.so.id,
'product_id': self.product_2.id,
'name': 'demo',
})
self.assertEqual(self.product_both.with_context(warehouse=self.warehouse_2.id).qty_available, 100)
self.assertEqual(self.product_2.with_context(warehouse=self.warehouse_2.id).qty_available, 100)
both_wh_ids = self.both_wh_ids()
planner = self.env['sale.order.make.plan'].with_context(warehouse_domain=[('id', 'in', both_wh_ids)], skip_plan_shipping=True).create({'order_id': self.so.id})
self.assertTrue(planner.planning_option_ids, 'Must have one or more plans.')
self.assertEqual(planner.planning_option_ids.warehouse_id, self.warehouse_2)
self.assertFalse(planner.planning_option_ids[0].sub_options)

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_delivery_carrier_form_calendar" model="ir.ui.view">
<field name="name">delivery.carrier.form.calendar</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="delivery_calendar_id" />
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_order_form_planner" model="ir.ui.view">
<field name="name">sale.order.form.planner</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form" />
<field name="arch" type="xml">
<xpath expr="//header/button[@name='action_confirm']" position="before">
<button name="action_planorder"
type="object"
attrs="{'invisible': [('state', 'not in', ('draft'))]}"
string="Plan"
class="oe_highlight"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_warehouse_shipping_calendar" model="ir.ui.view">
<field name="name">stock.warehouse.shipping.calendar</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="shipping_calendar_id" />
</xpath>
</field>
</record>
</odoo>

View File

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

View File

@@ -0,0 +1,590 @@
from math import sin, cos, sqrt, atan2, radians
from json import dumps, loads
from copy import deepcopy
from datetime import datetime
from logging import getLogger
_logger = getLogger(__name__)
try:
from uszipcode import ZipcodeSearchEngine
except ImportError:
_logger.warn('module "uszipcode" cannot be loaded, falling back to Google API')
ZipcodeSearchEngine = None
from odoo import api, fields, models, tools
from odoo.addons.base_geolocalize.models.res_partner import geo_find, geo_query_address
class FakeCollection():
def __init__(self, vals):
self.vals = vals
def __iter__(self):
for v in self.vals:
yield v
def filtered(self, f):
return filter(f, self.vals)
class FakePartner():
def __init__(self, **kwargs):
"""
'delivery.carrier'.verify_carrier(contact) ->
country_id,
state_id,
zip
company
city,
`distance calculations` ->
date_localization,
partner_latitude,
partner_longitude
computes them when accessed
"""
self.partner_latitude = 0.0
self.partner_longitude = 0.0
self.is_company = False
for attr, value in kwargs.items():
_logger.warn(' ' + str(attr) + ': ' + str(value))
setattr(self, attr, value)
@property
def date_localization(self):
if not hasattr(self, 'date_localization'):
self.date_localization = 'TODAY!'
# The fast way.
if ZipcodeSearchEngine and self.zip:
with ZipcodeSearchEngine() as search:
zipcode = search.by_zipcode(self.zip)
if zipcode:
self.partner_latitude = zipcode['Latitude']
self.partner_longitude = zipcode['Longitude']
return self.date_localization
# The slow way.
result = geo_find(geo_query_address(
city=self.city,
state=self.state_id.name,
country=self.country_id.name,
))
if result:
self.partner_latitude = result[0]
self.partner_longitude = result[1]
return self.date_localization
class FakeOrderLine():
def __init__(self, **kwargs):
"""
'delivery.carrier'.get_price_available(order) ->
state,
is_delivery,
product_uom._compute_quantity,
product_uom_qty,
product_id
price_total
"""
self.state = 'draft'
self.is_delivery = False
self.product_uom = self
for attr, value in kwargs.items():
setattr(self, attr, value)
def _compute_quantity(self, qty=1, uom=None):
"""
This is a non-implementation for when someone wants to call product_uom._compute_quantity
:param qty:
:param uom:
:return:
"""
return qty
class FakeSaleOrder():
"""
partner_id :: used in shipping
partner_shipping_id :: is used in several places
order_line :: can be a FakeCollection of FakeOrderLine's or Odoo 'sale.order.line'
carrier_id :: can be empty, will be overwritten when walking through carriers
'delivery.carrier'.get_shipping_price_from_so(orders) ->
id, (int)
name, (String)
currency_id, (Odoo 'res.currency')
company_id, (Odoo 'res.company')
warehouse_id, (Odoo 'stock.warehouse')
carrier_id, (Odoo 'delivery.carrier')
SaleOrderMakePlan.generate_shipping_options() ->
pricelist_id, (Odoo 'product.pricelist')
"""
def __init__(self, **kwargs):
self.carrier_id = None
self.id = 0
self.name = 'Quote'
self.team_id = None
self.project_id = None
self.amount_total = 0.0
for attr, value in kwargs.items():
setattr(self, attr, value)
def __iter__(self):
"""
Emulate a recordset of a single order.
"""
yield self
def distance(lat_1, lon_1, lat_2, lon_2):
R = 6373.0
lat1 = radians(lat_1)
lon1 = radians(lon_1)
lat2 = radians(lat_2)
lon2 = radians(lon_2)
dlon = lon2 - lon1
dlat = lat2 - lat1
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
c = 2 * atan2(sqrt(a), sqrt(1 - a))
return R * c
class SaleOrderMakePlan(models.TransientModel):
_name = 'sale.order.make.plan'
_description = 'Plan Order'
order_id = fields.Many2one(
'sale.order', 'Sale Order',
)
planning_option_ids = fields.One2many('sale.order.planning.option', 'plan_id', 'Options')
@api.model
def plan_order(self, vals):
pass
@api.multi
def select_option(self, option):
for plan in self:
self.plan_order_option(plan.order_id, option)
def _order_fields_for_option(self, option):
return {
'warehouse_id': option.warehouse_id.id,
'requested_date': option.requested_date,
'date_planned': option.date_planned,
'carrier_id': option.carrier_id.id,
}
@api.model
def plan_order_option(self, order, option):
if option.sub_options:
sub_options = option.sub_options
if isinstance(sub_options, str):
sub_options = loads(sub_options)
if not isinstance(sub_options, dict):
_logger.warn('Cannot apply option with corrupt sub_options')
return False
order_lines = order.order_line
for wh_id, wh_vals in sub_options.items():
wh_id = int(wh_id)
if wh_id == option.warehouse_id.id:
continue
order_lines.filtered(lambda line: line.product_id.id in wh_vals['product_ids']).write({
'warehouse_id': wh_id,
'date_planned': wh_vals.get('date_planned'),
})
order_fields = self._order_fields_for_option(option)
order.write(order_fields)
if option.carrier_id:
order._create_delivery_line(option.carrier_id, option.shipping_price)
@api.model
def create(self, values):
planner = super(SaleOrderMakePlan, self).create(values)
for option_vals in self.generate_order_options(planner.order_id):
option_vals['plan_id'] = planner.id
planner.planning_option_ids |= self.env['sale.order.planning.option'].create(option_vals)
return planner
def _fake_order(self, order):
return FakeSaleOrder(**{
'id': order.id,
'name': order.name,
'partner_id': order.partner_id,
'partner_shipping_id': order.partner_shipping_id,
'order_line': order.order_line,
'currency_id': order.currency_id,
'company_id': order.company_id,
'warehouse_id': order.warehouse_id,
'amount_total': order.amount_total,
'pricelist_id': order.pricelist_id,
'env': self.env,
})
@api.model
def generate_order_options(self, order, plan_shipping=True):
fake_order = self._fake_order(order)
base_option = self.generate_base_option(fake_order)
# do we need shipping?
# we need to collect it because we want multi-warehouse shipping amounts.
if order.carrier_id:
base_option['carrier_id'] = order.carrier_id.id
if plan_shipping and not self.env.context.get('skip_plan_shipping'):
options = self.generate_shipping_options(base_option, fake_order)
else:
options = [base_option]
return options
def get_warehouses(self, warehouse_id=None):
warehouse = self.env['stock.warehouse'].sudo()
if warehouse_id:
return warehouse.search([('id', '=', warehouse_id)])
if self.env.context.get('warehouse_domain'):
#potential bug here if this is textual
return warehouse.search(self.env.context.get('warehouse_domain'))
irconfig_parameter = self.env['ir.config_parameter'].sudo()
if irconfig_parameter.get_param('sale.order.planner.warehouse_domain'):
domain = tools.safe_eval(irconfig_parameter.get_param('sale.order.planner.warehouse_domain'))
return warehouse.search(domain)
return warehouse.search([])
def get_shipping_carriers(self, carrier_id=None):
Carrier = self.env['delivery.carrier'].sudo()
if carrier_id:
return Carrier.search([('id', '=', carrier_id)])
if self.env.context.get('carrier_domain'):
# potential bug here if this is textual
return Carrier.search(self.env.context.get('carrier_domain'))
irconfig_parameter = self.env['ir.config_parameter'].sudo()
if irconfig_parameter.get_param('sale.order.planner.carrier_domain'):
domain = tools.safe_eval(irconfig_parameter.get_param('sale.order.planner.carrier_domain'))
return Carrier.search(domain)
return Carrier.search([])
def generate_base_option(self, order_fake):
product_lines = list(filter(lambda line: line.product_id.type == 'product', order_fake.order_line))
if not product_lines:
return {}
buy_qty = {line.product_id.id: line.product_uom_qty for line in product_lines}
products = self.env['product.product']
for line in product_lines:
products |= line.product_id
warehouses = self.get_warehouses()
product_stock = self._fetch_product_stock(warehouses, products)
sub_options = {}
wh_date_planning = {}
p_len = len(products)
full_candidates = set()
partial_candidates = set()
for wh_id, stock in product_stock.items():
available = sum(1 for p_id, p_vals in stock.items() if self._is_in_stock(p_vals, buy_qty[p_id]))
if available == p_len:
full_candidates.add(wh_id)
elif available > 0:
partial_candidates.add(wh_id)
if full_candidates:
if len(full_candidates) == 1:
warehouse = warehouses.filtered(lambda wh: wh.id in full_candidates)
date_planned = self._next_warehouse_shipping_date(warehouse)
order_fake.warehouse_id = warehouse
return {'warehouse_id': warehouse.id, 'date_planned': date_planned}
warehouse = self._find_closest_warehouse_by_partner(
warehouses.filtered(lambda wh: wh.id in full_candidates), order_fake.partner_shipping_id)
date_planned = self._next_warehouse_shipping_date(warehouse)
order_fake.warehouse_id = warehouse
return {'warehouse_id': warehouse.id, 'date_planned': date_planned}
if partial_candidates:
if len(partial_candidates) == 1:
warehouse = warehouses.filtered(lambda wh: wh.id in partial_candidates)
order_fake.warehouse_id = warehouse
return {'warehouse_id': warehouse.id}
sorted_warehouses = self._sort_warehouses_by_partner(warehouses.filtered(lambda wh: wh.id in partial_candidates), order_fake.partner_shipping_id)
primary_wh = sorted_warehouses[0] #partial_candidates means there is at least one warehouse
primary_wh_date_planned = self._next_warehouse_shipping_date(primary_wh)
wh_date_planning[primary_wh.id] = primary_wh_date_planned
for wh in sorted_warehouses:
if not buy_qty:
continue
stock = product_stock[wh.id]
for p_id, p_vals in stock.items():
if p_id in buy_qty and self._is_in_stock(p_vals, buy_qty[p_id]):
if wh.id not in sub_options:
sub_options[wh.id] = {
'date_planned': self._next_warehouse_shipping_date(wh),
'product_ids': [],
'product_skus': [],
}
sub_options[wh.id]['product_ids'].append(p_id)
sub_options[wh.id]['product_skus'].append(p_vals['sku'])
del buy_qty[p_id]
if not buy_qty:
# item_details can fulfil all items.
# this is good!!
order_fake.warehouse_id = primary_wh
return {'warehouse_id': primary_wh.id, 'date_planned': primary_wh_date_planned, 'sub_options': sub_options}
# warehouses cannot fulfil all requested items!!
order_fake.warehouse_id = primary_wh
return {'warehouse_id': primary_wh.id}
# nobody has stock!
primary_wh = self._find_closest_warehouse_by_partner(warehouses, order_fake.partner_shipping_id)
order_fake.warehouse_id = primary_wh
return {'warehouse_id': primary_wh.id}
def _is_in_stock(self, p_stock, buy_qty):
return p_stock['real_qty_available'] >= buy_qty
def _find_closest_warehouse_by_partner(self, warehouses, partner):
if not partner.date_localization:
partner.geo_localize()
return self._find_closest_warehouse(warehouses, partner.partner_latitude, partner.partner_longitude)
def _find_closest_warehouse(self, warehouses, latitude, longitude):
distances = {distance(latitude, longitude, wh.partner_id.partner_latitude, wh.partner_id.partner_longitude): wh.id for wh in warehouses}
wh_id = distances[min(distances)]
return warehouses.filtered(lambda wh: wh.id == wh_id)
def _sort_warehouses_by_partner(self, warehouses, partner):
if not partner.date_localization:
partner.geo_localize()
return self._sort_warehouses(warehouses, partner.partner_latitude, partner.partner_longitude)
def _sort_warehouses(self, warehouses, latitude, longitude):
distances = {distance(latitude, longitude, wh.partner_id.partner_latitude, wh.partner_id.partner_longitude): wh.id for wh in warehouses}
wh_distances = sorted(distances)
return [warehouses.filtered(lambda wh: wh.id == distances[d]) for d in wh_distances]
def _next_warehouse_shipping_date(self, warehouse):
return fields.Datetime.to_string(warehouse.shipping_calendar_id.plan_days(0.01,
fields.Datetime.from_string(fields.Datetime.now()),
compute_leaves=True))
@api.model
def _fetch_product_stock(self, warehouses, products):
output = {}
for wh in warehouses:
products = products.with_context({'location': wh.lot_stock_id.id})
output[wh.id] = {
p.id: {
'qty_available': p.qty_available,
'virtual_available': p.virtual_available,
'incoming_qty': p.incoming_qty,
'outgoing_qty': p.outgoing_qty,
'real_qty_available': p.qty_available - p.outgoing_qty,
'sku': p.default_code
} for p in products}
return output
def generate_shipping_options(self, base_option, order_fake):
# generate a carrier_id, amount, requested_date (promise date)
# if base_option['carrier_id'] then that is the only carrier we want to collect rates for.
carriers = self.get_shipping_carriers(base_option.get('carrier_id'))
if not base_option.get('sub_options'):
options = []
# this locic comes from "delivery.models.sale_order.SaleOrder"
for carrier in carriers:
option = self._generate_shipping_carrier_option(base_option, order_fake, carrier)
if option:
options.append(option)
return options
else:
warehouses = self.get_warehouses()
original_order_fake_warehouse_id = order_fake.warehouse_id
original_order_fake_order_line = order_fake.order_line
options = []
for carrier in carriers:
new_base_option = deepcopy(base_option)
has_error = False
for wh_id, wh_vals in base_option['sub_options'].items():
if has_error:
continue
order_fake.warehouse_id = warehouses.filtered(lambda wh: wh.id == wh_id)
order_fake.order_line = FakeCollection(filter(lambda line: line.product_id.id in wh_vals['product_ids'], original_order_fake_order_line))
wh_option = self._generate_shipping_carrier_option(wh_vals, order_fake, carrier)
if not wh_option:
has_error = True
else:
new_base_option['sub_options'][wh_id] = wh_option
if has_error:
continue
# now that we've collected, we can roll up some details.
new_base_option['carrier_id'] = carrier.id
new_base_option['shipping_price'] = self._get_shipping_price_for_options(new_base_option['sub_options'])
new_base_option['requested_date'] = self._get_max_requested_date(new_base_option['sub_options'])
new_base_option['transit_days'] = self._get_max_transit_days(new_base_option['sub_options'])
options.append(new_base_option)
#restore values in case more processing occurs
order_fake.warehouse_id = original_order_fake_warehouse_id
order_fake.order_line = original_order_fake_order_line
if not options:
options.append(base_option)
return options
def _get_shipping_price_for_options(self, sub_options):
return sum(wh_option.get('shipping_price', 0.0) for wh_option in sub_options.values())
def _get_max_requested_date(self, sub_options):
max_requested_date = None
for option in sub_options.values():
requested_date = option.get('requested_date')
if requested_date and isinstance(requested_date, str):
requested_date = fields.Datetime.from_string(requested_date)
if requested_date and not max_requested_date:
max_requested_date = requested_date
elif requested_date:
if requested_date > max_requested_date:
max_requested_date = requested_date
if max_requested_date:
return fields.Datetime.to_string(max_requested_date)
return max_requested_date
def _get_max_transit_days(self, sub_options):
return max(wh_option.get('transit_days', 0) for wh_option in sub_options.values())
def _generate_shipping_carrier_option(self, base_option, order_fake, carrier):
# some carriers look at the order carrier_id
order_fake.carrier_id = carrier
# this logic comes from "delivery.models.sale_order.SaleOrder"
try:
date_delivered = None
transit_days = 0
if carrier.delivery_type not in ['fixed', 'base_on_rule']:
result = carrier.get_shipping_price_for_plan(order_fake, base_option.get('date_planned'))
if result:
price_unit, transit_days, date_delivered = result[0]
else:
price_unit = carrier.get_shipping_price_from_so(order_fake)[0]
else:
carrier = carrier.verify_carrier(order_fake.partner_shipping_id)
if not carrier:
return None
#price_unit = carrier.get_price_available(order)
# ^^ ends up calling carrier.get_price_from_picking(order_total, weight, volume, quantity)
order_total = order_fake.amount_total
weight = sum((line.product_id.weight or 0.0) * line.product_uom_qty for line in order_fake.order_line if line.product_id.type == 'product')
volume = sum((line.product_id.volume or 0.0) * line.product_uom_qty for line in order_fake.order_line if line.product_id.type == 'product')
quantity = sum((line.product_uom_qty or 0.0) for line in order_fake.order_line if line.product_id.type == 'product')
price_unit = carrier.get_price_from_picking(order_total, weight, volume, quantity)
if order_fake.company_id.currency_id.id != order_fake.pricelist_id.currency_id.id:
price_unit = order_fake.company_id.currency_id.with_context(date=order_fake.date_order).compute(price_unit, order_fake.pricelist_id.currency_id)
final_price = float(price_unit) * (1.0 + (float(carrier.margin) / 100.0))
option = deepcopy(base_option)
option['carrier_id'] = carrier.id
option['shipping_price'] = final_price
option['requested_date'] = fields.Datetime.to_string(date_delivered) if date_delivered and isinstance(date_delivered, datetime) else date_delivered
option['transit_days'] = transit_days
return option
except Exception as e:
# _logger.warn("Exception collecting carrier rates: " + str(e))
# _logger.exception(e)
pass
return None
class SaleOrderPlanningOption(models.TransientModel):
_name = 'sale.order.planning.option'
_description = 'Order Planning Option'
def create(self, values):
if 'sub_options' in values and not isinstance(values['sub_options'], str):
values['sub_options'] = dumps(values['sub_options'])
return super(SaleOrderPlanningOption, self).create(values)
@api.multi
def _compute_sub_options_text(self):
for option in self:
sub_options = option.sub_options
if sub_options and not isinstance(sub_options, dict):
sub_options = loads(sub_options)
if not isinstance(sub_options, dict):
option.sub_options_text = ''
continue
line = ''
for wh_id, wh_option in sub_options.items():
product_skus = wh_option.get('product_skus', [])
product_skus = ', '.join(product_skus)
requested_date = wh_option.get('requested_date', '')
if requested_date:
requested_date = self._context_datetime(requested_date)
date_planned = wh_option.get('date_planned', '')
if date_planned:
date_planned = self._context_datetime(date_planned)
shipping_price = float(wh_option.get('shipping_price', 0.0))
transit_days = int(wh_option.get('transit_days', 0))
line += """WH %d :: %s
Date Planned: %s
Requested Date: %s
Transit Days: %d
Shipping Price: %.2f
""" % (int(wh_id), product_skus, date_planned, requested_date, transit_days, shipping_price)
option.sub_options_text = line
plan_id = fields.Many2one('sale.order.make.plan', 'Plan', ondelete='cascade')
warehouse_id = fields.Many2one('stock.warehouse', string='Warehouse')
date_planned = fields.Datetime('Planned Date')
requested_date = fields.Datetime('Requested Date')
carrier_id = fields.Many2one('delivery.carrier', string='Carrier')
transit_days = fields.Integer('Transit Days')
shipping_price = fields.Float('Shipping Price')
sub_options = fields.Text('Sub Options JSON')
sub_options_text = fields.Text('Sub Options', compute=_compute_sub_options_text)
@api.multi
def select_plan(self):
for option in self:
option.plan_id.select_option(option)
return
def _context_datetime(self, date):
date = fields.Datetime.from_string(date)
date = fields.Datetime.context_timestamp(self, date)
return fields.Datetime.to_string(date)

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_plan_sale_order" model="ir.ui.view">
<field name="name">view.plan.sale.order</field>
<field name="model">sale.order.make.plan</field>
<field name="type">form</field>
<field name="arch" type="xml">
<form>
<field name="planning_option_ids">
<tree>
<field name="warehouse_id" />
<field name="date_planned" />
<field name="requested_date" />
<field name="transit_days" />
<field name="carrier_id" />
<field name="shipping_price" />
<field name="sub_options_text" />
<button class="eo_highlight"
name="select_plan"
string="Select"
type="object" />
</tree>
</field>
<footer>
<button class="oe_link"
special="cancel"
string="Cancel" />
</footer>
</form>
</field>
</record>
<record id="action_plan_sale_order" model="ir.actions.act_window">
<field name="name">Plan Sale Order</field>
<field name="res_model">sale.order.make.plan</field>
<field name="view_type">form</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_plan_sale_order" />
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,15 @@
**************************************************
Hibou - Sale Planner with Warehouse Delivery Route
**************************************************
Calculates the closest delivery route during order planning.
For more information and add-ons, visit `Hibou.io <https://hibou.io/>`_.
=======
License
=======
Please see `LICENSE <https://github.com/hibou-io/hibou-odoo-suite/blob/master/LICENSE>`_.
Copyright Hibou Corp. 2018

View File

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

View File

@@ -0,0 +1,29 @@
{
'name': 'Sale Order Planner - Delivery Route',
'summary': 'Plans to the closest delivery route.',
'version': '11.0.1.0.0',
'author': "Hibou Corp.",
'category': 'Sale',
'license': 'AGPL-3',
'complexity': 'expert',
'images': [],
'website': "https://hibou.io",
'description': """
Sale Order Planner - Delivery Route
===================================
Plans to the closest delivery route.
""",
'depends': [
'stock_delivery_route',
'sale_planner',
],
'demo': [],
'data': [
'wizard/order_planner_views.xml',
'views/stock_views.xml',
],
'auto_install': True,
'installable': True,
}

Some files were not shown because too many files have changed in this diff Show More