mirror of
https://gitlab.com/hibou-io/hibou-odoo/suite.git
synced 2025-01-20 12:37:31 +02:00
Merge commit '5f238825b661980a249236c07f8d86c0030ae474' into 11.0-payroll-test
This commit is contained in:
4
.gitmodules
vendored
4
.gitmodules
vendored
@@ -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
|
||||
|
||||
1
account_invoice_change/__init__.py
Normal file
1
account_invoice_change/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import wizard
|
||||
30
account_invoice_change/__manifest__.py
Normal file
30
account_invoice_change/__manifest__.py
Normal 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,
|
||||
}
|
||||
1
account_invoice_change/tests/__init__.py
Normal file
1
account_invoice_change/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_invoice_change
|
||||
60
account_invoice_change/tests/test_invoice_change.py
Normal file
60
account_invoice_change/tests/test_invoice_change.py
Normal 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)
|
||||
1
account_invoice_change/wizard/__init__.py
Normal file
1
account_invoice_change/wizard/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import invoice_change
|
||||
56
account_invoice_change/wizard/invoice_change.py
Normal file
56
account_invoice_change/wizard/invoice_change.py
Normal 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
|
||||
48
account_invoice_change/wizard/invoice_change_views.xml
Normal file
48
account_invoice_change/wizard/invoice_change_views.xml
Normal 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>
|
||||
1
account_invoice_change_analytic/__init__.py
Normal file
1
account_invoice_change_analytic/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import wizard
|
||||
23
account_invoice_change_analytic/__manifest__.py
Normal file
23
account_invoice_change_analytic/__manifest__.py
Normal 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,
|
||||
}
|
||||
1
account_invoice_change_analytic/tests/__init__.py
Normal file
1
account_invoice_change_analytic/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_invoice_change
|
||||
46
account_invoice_change_analytic/tests/test_invoice_change.py
Normal file
46
account_invoice_change_analytic/tests/test_invoice_change.py
Normal 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)
|
||||
1
account_invoice_change_analytic/wizard/__init__.py
Normal file
1
account_invoice_change_analytic/wizard/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import invoice_change
|
||||
41
account_invoice_change_analytic/wizard/invoice_change.py
Normal file
41
account_invoice_change_analytic/wizard/invoice_change.py
Normal 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()
|
||||
@@ -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>
|
||||
1
account_invoice_margin/__init__.py
Normal file
1
account_invoice_margin/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
23
account_invoice_margin/__manifest__.py
Executable file
23
account_invoice_margin/__manifest__.py
Executable 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,
|
||||
}
|
||||
1
account_invoice_margin/models/__init__.py
Normal file
1
account_invoice_margin/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import account_invoice
|
||||
62
account_invoice_margin/models/account_invoice.py
Normal file
62
account_invoice_margin/models/account_invoice.py
Normal 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'))
|
||||
1
account_invoice_margin/tests/__init__.py
Normal file
1
account_invoice_margin/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_invoice_margin
|
||||
64
account_invoice_margin/tests/test_invoice_margin.py
Normal file
64
account_invoice_margin/tests/test_invoice_margin.py
Normal 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)
|
||||
18
account_invoice_margin/views/account_invoice_views.xml
Normal file
18
account_invoice_margin/views/account_invoice_views.xml
Normal 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
4
auth_admin/__init__.py
Executable file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import controllers
|
||||
from . import models
|
||||
from . import wizard
|
||||
30
auth_admin/__manifest__.py
Executable file
30
auth_admin/__manifest__.py
Executable 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',
|
||||
],
|
||||
}
|
||||
2
auth_admin/controllers/__init__.py
Executable file
2
auth_admin/controllers/__init__.py
Executable file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import main
|
||||
41
auth_admin/controllers/main.py
Executable file
41
auth_admin/controllers/main.py
Executable 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
2
auth_admin/models/__init__.py
Executable file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import res_users
|
||||
90
auth_admin/models/res_users.py
Executable file
90
auth_admin/models/res_users.py
Executable 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
14
auth_admin/views/res_users.xml
Executable file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="auth_admin_view_users_tree" model="ir.ui.view">
|
||||
<field name="name">auth_admin.res.users.tree</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//tree" position="inside">
|
||||
<field name="share" invisible="1"/>
|
||||
<button string="Generate Login" type="object" name="admin_auth_generate_login" attrs="{'invisible': [('share', '=', False)]}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
2
auth_admin/wizard/__init__.py
Executable file
2
auth_admin/wizard/__init__.py
Executable file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import portal_wizard
|
||||
27
auth_admin/wizard/portal_wizard.py
Executable file
27
auth_admin/wizard/portal_wizard.py
Executable 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)
|
||||
18
auth_admin/wizard/portal_wizard_views.xml
Executable file
18
auth_admin/wizard/portal_wizard_views.xml
Executable file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
|
||||
<record id="portal_wizard" model="ir.ui.view">
|
||||
<field name="name">Portal Access Management - Auth Admin</field>
|
||||
<field name="model">portal.wizard</field>
|
||||
<field name="inherit_id" ref="portal.wizard_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='in_portal']" position="after">
|
||||
<field name="force_login_url" 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
1
dbfilter_from_header
Symbolic link
@@ -0,0 +1 @@
|
||||
external/hibou-oca/server-tools/dbfilter_from_header
|
||||
29
delivery_partner/README.rst
Normal file
29
delivery_partner/README.rst
Normal file
@@ -0,0 +1,29 @@
|
||||
*********************************
|
||||
Hibou - Partner Shipping Accounts
|
||||
*********************************
|
||||
|
||||
Records shipping account numbers on partners.
|
||||
|
||||
For more information and add-ons, visit `Hibou.io <https://hibou.io/>`_.
|
||||
|
||||
|
||||
=============
|
||||
Main Features
|
||||
=============
|
||||
|
||||
* New model: Customer Shipping Account
|
||||
* Includes manager-level access permissions.
|
||||
|
||||
.. image:: https://user-images.githubusercontent.com/15882954/41176601-e40f8558-6b15-11e8-998e-6a7ee5709c0f.png
|
||||
:alt: 'Register Payment Detail'
|
||||
:width: 988
|
||||
:align: left
|
||||
|
||||
|
||||
=======
|
||||
License
|
||||
=======
|
||||
|
||||
Please see `LICENSE <https://github.com/hibou-io/hibou-odoo-suite/blob/11.0/LICENSE>`_.
|
||||
|
||||
Copyright Hibou Corp. 2018
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
28
delivery_partner_dhl/README.rst
Normal file
28
delivery_partner_dhl/README.rst
Normal 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
|
||||
28
delivery_partner_fedex/README.rst
Normal file
28
delivery_partner_fedex/README.rst
Normal 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
|
||||
29
delivery_partner_ups/README.rst
Normal file
29
delivery_partner_ups/README.rst
Normal 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
|
||||
1
external/hibou-oca/server-tools
vendored
Submodule
1
external/hibou-oca/server-tools
vendored
Submodule
Submodule external/hibou-oca/server-tools added at 4bd10c5946
36
hr_department_project/README.rst
Normal file
36
hr_department_project/README.rst
Normal 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
|
||||
1
hr_employee_activity/__init__.py
Normal file
1
hr_employee_activity/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import hr_employee_activity
|
||||
21
hr_employee_activity/__manifest__.py
Normal file
21
hr_employee_activity/__manifest__.py
Normal 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',
|
||||
}
|
||||
6
hr_employee_activity/hr_employee_activity.py
Normal file
6
hr_employee_activity/hr_employee_activity.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from odoo import models
|
||||
|
||||
|
||||
class HrEmployee(models.Model):
|
||||
_name = 'hr.employee'
|
||||
_inherit = ['hr.employee', 'mail.activity.mixin']
|
||||
13
hr_employee_activity/hr_employee_activity_views.xml
Normal file
13
hr_employee_activity/hr_employee_activity_views.xml
Normal 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>
|
||||
1
hr_expense_lead/__init__.py
Normal file
1
hr_expense_lead/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
21
hr_expense_lead/__manifest__.py
Normal file
21
hr_expense_lead/__manifest__.py
Normal 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,
|
||||
}
|
||||
2
hr_expense_lead/models/__init__.py
Normal file
2
hr_expense_lead/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import crm_lead
|
||||
from . import hr_expense_lead
|
||||
16
hr_expense_lead/models/crm_lead.py
Normal file
16
hr_expense_lead/models/crm_lead.py
Normal 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'))
|
||||
7
hr_expense_lead/models/hr_expense_lead.py
Normal file
7
hr_expense_lead/models/hr_expense_lead.py
Normal 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')
|
||||
1
hr_expense_lead/tests/__init__.py
Normal file
1
hr_expense_lead/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_expenses
|
||||
16
hr_expense_lead/tests/test_expenses.py
Normal file
16
hr_expense_lead/tests/test_expenses.py
Normal 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)
|
||||
18
hr_expense_lead/views/crm_views.xml
Normal file
18
hr_expense_lead/views/crm_views.xml
Normal 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>
|
||||
24
hr_expense_lead/views/hr_expense_views.xml
Normal file
24
hr_expense_lead/views/hr_expense_views.xml
Normal 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>
|
||||
30
hr_expense_vendor/README.rst
Normal file
30
hr_expense_vendor/README.rst
Normal 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
|
||||
2
hr_payroll_attendance/__init__.py
Executable file
2
hr_payroll_attendance/__init__.py
Executable file
@@ -0,0 +1,2 @@
|
||||
from . import hr_payslip
|
||||
from . import hr_contract
|
||||
16
hr_payroll_attendance/__manifest__.py
Executable file
16
hr_payroll_attendance/__manifest__.py
Executable 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',
|
||||
],
|
||||
}
|
||||
7
hr_payroll_attendance/hr_contract.py
Executable file
7
hr_payroll_attendance/hr_contract.py
Executable 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)
|
||||
22
hr_payroll_attendance/hr_contract_view.xml
Executable file
22
hr_payroll_attendance/hr_contract_view.xml
Executable 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>
|
||||
93
hr_payroll_attendance/hr_payslip.py
Executable file
93
hr_payroll_attendance/hr_payslip.py
Executable 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
|
||||
1
hr_payroll_attendance_holidays/__init__.py
Executable file
1
hr_payroll_attendance_holidays/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
from . import hr_payslip
|
||||
15
hr_payroll_attendance_holidays/__manifest__.py
Executable file
15
hr_payroll_attendance_holidays/__manifest__.py
Executable 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,
|
||||
}
|
||||
52
hr_payroll_attendance_holidays/hr_payslip.py
Executable file
52
hr_payroll_attendance_holidays/hr_payslip.py
Executable 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(' ', '_')
|
||||
@@ -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.
|
||||
""",
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
42
maintenance_equipment_charge/README.rst
Normal file
42
maintenance_equipment_charge/README.rst
Normal 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
|
||||
@@ -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',
|
||||
|
||||
22
maintenance_notebook/README.rst
Normal file
22
maintenance_notebook/README.rst
Normal 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
|
||||
33
maintenance_repair/README.rst
Normal file
33
maintenance_repair/README.rst
Normal 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
|
||||
34
maintenance_timesheet/README.rst
Normal file
34
maintenance_timesheet/README.rst
Normal 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
|
||||
41
maintenance_usage/README.rst
Normal file
41
maintenance_usage/README.rst
Normal 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
71
newrelic/__init__.py
Normal 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
12
newrelic/__manifest__.py
Normal 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',
|
||||
],
|
||||
}
|
||||
1
newrelic/controllers/__init__.py
Normal file
1
newrelic/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import main
|
||||
39
newrelic/controllers/main.py
Normal file
39
newrelic/controllers/main.py
Normal 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
|
||||
1
project_description/__init__.py
Normal file
1
project_description/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
22
project_description/__manifest__.py
Normal file
22
project_description/__manifest__.py
Normal 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,
|
||||
}
|
||||
1
project_description/models/__init__.py
Normal file
1
project_description/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import project
|
||||
12
project_description/models/project.py
Normal file
12
project_description/models/project.py
Normal 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')
|
||||
30
project_description/views/project_views.xml
Normal file
30
project_description/views/project_views.xml
Normal 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
2
sale_planner/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import wizard
|
||||
from . import models
|
||||
41
sale_planner/__manifest__.py
Normal file
41
sale_planner/__manifest__.py
Normal 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,
|
||||
}
|
||||
3
sale_planner/models/__init__.py
Normal file
3
sale_planner/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import sale
|
||||
from . import stock
|
||||
from . import delivery
|
||||
69
sale_planner/models/delivery.py
Normal file
69
sale_planner/models/delivery.py
Normal 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))
|
||||
16
sale_planner/models/sale.py
Normal file
16
sale_planner/models/sale.py
Normal 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
|
||||
9
sale_planner/models/stock.py
Normal file
9
sale_planner/models/stock.py
Normal 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.")
|
||||
1
sale_planner/tests/__init__.py
Normal file
1
sale_planner/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import test_planner
|
||||
204
sale_planner/tests/test_planner.py
Normal file
204
sale_planner/tests/test_planner.py
Normal 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)
|
||||
13
sale_planner/views/delivery.xml
Normal file
13
sale_planner/views/delivery.xml
Normal 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>
|
||||
17
sale_planner/views/sale.xml
Normal file
17
sale_planner/views/sale.xml
Normal 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>
|
||||
13
sale_planner/views/stock.xml
Normal file
13
sale_planner/views/stock.xml
Normal 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>
|
||||
1
sale_planner/wizard/__init__.py
Normal file
1
sale_planner/wizard/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import order_planner
|
||||
590
sale_planner/wizard/order_planner.py
Normal file
590
sale_planner/wizard/order_planner.py
Normal 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)
|
||||
40
sale_planner/wizard/order_planner_views.xml
Normal file
40
sale_planner/wizard/order_planner_views.xml
Normal 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>
|
||||
15
sale_planner_delivery_route/README.rst
Normal file
15
sale_planner_delivery_route/README.rst
Normal 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
|
||||
2
sale_planner_delivery_route/__init__.py
Normal file
2
sale_planner_delivery_route/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import wizard
|
||||
from . import models
|
||||
29
sale_planner_delivery_route/__manifest__.py
Normal file
29
sale_planner_delivery_route/__manifest__.py
Normal 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
Reference in New Issue
Block a user