diff --git a/.gitmodules b/.gitmodules index e0ee1821..9ef0222b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 diff --git a/account_invoice_change/__init__.py b/account_invoice_change/__init__.py new file mode 100644 index 00000000..40272379 --- /dev/null +++ b/account_invoice_change/__init__.py @@ -0,0 +1 @@ +from . import wizard diff --git a/account_invoice_change/__manifest__.py b/account_invoice_change/__manifest__.py new file mode 100644 index 00000000..7cb8459d --- /dev/null +++ b/account_invoice_change/__manifest__.py @@ -0,0 +1,30 @@ +{ + 'name': 'Account Invoice Change', + 'author': 'Hibou Corp. ', + '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, +} diff --git a/account_invoice_change/tests/__init__.py b/account_invoice_change/tests/__init__.py new file mode 100644 index 00000000..defd2696 --- /dev/null +++ b/account_invoice_change/tests/__init__.py @@ -0,0 +1 @@ +from . import test_invoice_change diff --git a/account_invoice_change/tests/test_invoice_change.py b/account_invoice_change/tests/test_invoice_change.py new file mode 100644 index 00000000..3809241c --- /dev/null +++ b/account_invoice_change/tests/test_invoice_change.py @@ -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) diff --git a/account_invoice_change/wizard/__init__.py b/account_invoice_change/wizard/__init__.py new file mode 100644 index 00000000..8ec5d6da --- /dev/null +++ b/account_invoice_change/wizard/__init__.py @@ -0,0 +1 @@ +from . import invoice_change \ No newline at end of file diff --git a/account_invoice_change/wizard/invoice_change.py b/account_invoice_change/wizard/invoice_change.py new file mode 100644 index 00000000..def9583f --- /dev/null +++ b/account_invoice_change/wizard/invoice_change.py @@ -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 diff --git a/account_invoice_change/wizard/invoice_change_views.xml b/account_invoice_change/wizard/invoice_change_views.xml new file mode 100644 index 00000000..5852570c --- /dev/null +++ b/account_invoice_change/wizard/invoice_change_views.xml @@ -0,0 +1,48 @@ + + + + Invoice Change + account.invoice.change + +
+ + + + + + + + + +
+
+ +
+
+ + + Invoice Change Wizard + ir.actions.act_window + account.invoice.change + form + form + new + + + + account.invoice.form.inherit + account.invoice + + + + + + + +
\ No newline at end of file diff --git a/hr_expense_lead/views/hr_expense_views.xml b/hr_expense_lead/views/hr_expense_views.xml new file mode 100644 index 00000000..be8ef68a --- /dev/null +++ b/hr_expense_lead/views/hr_expense_views.xml @@ -0,0 +1,24 @@ + + + + hr.expense.form.inherit + hr.expense + + + + + + + + + + hr.expense.filter.inherit + hr.expense + + + + + + + + \ No newline at end of file diff --git a/hr_expense_vendor/README.rst b/hr_expense_vendor/README.rst new file mode 100644 index 00000000..e702d50b --- /dev/null +++ b/hr_expense_vendor/README.rst @@ -0,0 +1,30 @@ +************************* +Hibou - HR Expense Vendor +************************* + +Records the vendor paid on expenses. + +For more information and add-ons, visit `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 `_. + +Copyright Hibou Corp. 2018 diff --git a/hr_payroll_attendance/__init__.py b/hr_payroll_attendance/__init__.py new file mode 100755 index 00000000..f1f0fe2e --- /dev/null +++ b/hr_payroll_attendance/__init__.py @@ -0,0 +1,2 @@ +from . import hr_payslip +from . import hr_contract diff --git a/hr_payroll_attendance/__manifest__.py b/hr_payroll_attendance/__manifest__.py new file mode 100755 index 00000000..4aa42648 --- /dev/null +++ b/hr_payroll_attendance/__manifest__.py @@ -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. ', + 'license': 'AGPL-3', + 'category': 'Human Resources', + 'data': [ + 'hr_contract_view.xml', + ], + 'depends': [ + 'hr_payroll', + 'hr_attendance', + ], +} diff --git a/hr_payroll_attendance/hr_contract.py b/hr_payroll_attendance/hr_contract.py new file mode 100755 index 00000000..ebb0381f --- /dev/null +++ b/hr_payroll_attendance/hr_contract.py @@ -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) diff --git a/hr_payroll_attendance/hr_contract_view.xml b/hr_payroll_attendance/hr_contract_view.xml new file mode 100755 index 00000000..5022cc3c --- /dev/null +++ b/hr_payroll_attendance/hr_contract_view.xml @@ -0,0 +1,22 @@ + + + + + hr.contract.form.inherit + hr.contract + 20 + + + + + + + + / pay period + / hour + + + + + + diff --git a/hr_payroll_attendance/hr_payslip.py b/hr_payroll_attendance/hr_payslip.py new file mode 100755 index 00000000..e4561032 --- /dev/null +++ b/hr_payroll_attendance/hr_payslip.py @@ -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 diff --git a/hr_payroll_attendance_holidays/__init__.py b/hr_payroll_attendance_holidays/__init__.py new file mode 100755 index 00000000..5d988fa2 --- /dev/null +++ b/hr_payroll_attendance_holidays/__init__.py @@ -0,0 +1 @@ +from . import hr_payslip diff --git a/hr_payroll_attendance_holidays/__manifest__.py b/hr_payroll_attendance_holidays/__manifest__.py new file mode 100755 index 00000000..b9ca046e --- /dev/null +++ b/hr_payroll_attendance_holidays/__manifest__.py @@ -0,0 +1,15 @@ +{ + 'name': 'Payroll Attendance Holidays', + 'author': 'Hibou Corp. ', + '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, +} diff --git a/hr_payroll_attendance_holidays/hr_payslip.py b/hr_payroll_attendance_holidays/hr_payslip.py new file mode 100755 index 00000000..f60f0c22 --- /dev/null +++ b/hr_payroll_attendance_holidays/hr_payslip.py @@ -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(' ', '_') diff --git a/hr_payroll_holidays/__manifest__.py b/hr_payroll_holidays/__manifest__.py index 9a4cac2c..dfca2408 100755 --- a/hr_payroll_holidays/__manifest__.py +++ b/hr_payroll_holidays/__manifest__.py @@ -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. """, diff --git a/hr_payroll_holidays/hr_payslip.py b/hr_payroll_holidays/hr_payslip.py index 9f999266..f9ffb4b1 100755 --- a/hr_payroll_holidays/hr_payslip.py +++ b/hr_payroll_holidays/hr_payslip.py @@ -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'), ] diff --git a/hr_payroll_timesheet/hr_contract_view.xml b/hr_payroll_timesheet/hr_contract_view.xml index abd831f5..6b8edde4 100755 --- a/hr_payroll_timesheet/hr_contract_view.xml +++ b/hr_payroll_timesheet/hr_contract_view.xml @@ -8,7 +8,7 @@ - + diff --git a/hr_payroll_timesheet/hr_payslip.py b/hr_payroll_timesheet/hr_payslip.py index 5d09c267..b320495b 100755 --- a/hr_payroll_timesheet/hr_payslip.py +++ b/hr_payroll_timesheet/hr_payslip.py @@ -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 diff --git a/l10n_us_hr_payroll/tests/test_us_payslip.py b/l10n_us_hr_payroll/tests/test_us_payslip.py index 7ad4cb51..76360b5f 100755 --- a/l10n_us_hr_payroll/tests/test_us_payslip.py +++ b/l10n_us_hr_payroll/tests/test_us_payslip.py @@ -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({ diff --git a/maintenance_equipment_charge/README.rst b/maintenance_equipment_charge/README.rst new file mode 100644 index 00000000..de089dd7 --- /dev/null +++ b/maintenance_equipment_charge/README.rst @@ -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 `_. + + +============= +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 `_. + +Copyright Hibou Corp. 2018 diff --git a/maintenance_equipment_charge/__manifest__.py b/maintenance_equipment_charge/__manifest__.py index 83c833ff..e862db98 100644 --- a/maintenance_equipment_charge/__manifest__.py +++ b/maintenance_equipment_charge/__manifest__.py @@ -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', diff --git a/maintenance_notebook/README.rst b/maintenance_notebook/README.rst new file mode 100644 index 00000000..1fbc238a --- /dev/null +++ b/maintenance_notebook/README.rst @@ -0,0 +1,22 @@ +**************************** +Hibou - Maintenance Notebook +**************************** + +Base module that creates tabs used in `maintenance_repair `_ and `maintenance_timesheet `_ + +For more information and add-ons, visit `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 `_. + +Copyright Hibou Corp. 2018 diff --git a/maintenance_repair/README.rst b/maintenance_repair/README.rst new file mode 100644 index 00000000..1b2a6ab9 --- /dev/null +++ b/maintenance_repair/README.rst @@ -0,0 +1,33 @@ +************************** +Hibou - Maintenance Repair +************************** + +Keep track of parts required to repair equipment. + +For more information and add-ons, visit `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 `_. + +Copyright Hibou Corp. 2018 diff --git a/maintenance_timesheet/README.rst b/maintenance_timesheet/README.rst new file mode 100644 index 00000000..81bbdb7f --- /dev/null +++ b/maintenance_timesheet/README.rst @@ -0,0 +1,34 @@ +***************************** +Hibou - Maintenance Timesheet +***************************** + +Record time on maintenance requests. + +For more information and add-ons, visit `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 `_. + +Copyright Hibou Corp. 2018 diff --git a/maintenance_usage/README.rst b/maintenance_usage/README.rst new file mode 100644 index 00000000..865420ea --- /dev/null +++ b/maintenance_usage/README.rst @@ -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 `_. + +============= +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 `_. + +Copyright Hibou Corp. 2018 diff --git a/newrelic/__init__.py b/newrelic/__init__.py new file mode 100644 index 00000000..39a2e959 --- /dev/null +++ b/newrelic/__init__.py @@ -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') \ No newline at end of file diff --git a/newrelic/__manifest__.py b/newrelic/__manifest__.py new file mode 100644 index 00000000..d842276c --- /dev/null +++ b/newrelic/__manifest__.py @@ -0,0 +1,12 @@ +{ + 'name': 'NewRelic Instrumentation', + 'description': 'Wraps requests etc.', + 'version': '1.0', + 'website': 'https://hibou.io/', + 'author': 'Hibou Corp. ', + 'license': 'AGPL-3', + 'category': 'Tool', + 'depends': [ + 'web', + ], +} diff --git a/newrelic/controllers/__init__.py b/newrelic/controllers/__init__.py new file mode 100644 index 00000000..12a7e529 --- /dev/null +++ b/newrelic/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/newrelic/controllers/main.py b/newrelic/controllers/main.py new file mode 100644 index 00000000..acd1dccb --- /dev/null +++ b/newrelic/controllers/main.py @@ -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 diff --git a/project_description/__init__.py b/project_description/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/project_description/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/project_description/__manifest__.py b/project_description/__manifest__.py new file mode 100644 index 00000000..19513c2a --- /dev/null +++ b/project_description/__manifest__.py @@ -0,0 +1,22 @@ +{ + 'name': 'Project Description', + 'version': '11.0.1.0.0', + 'author': 'Hibou Corp. ', + '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, +} diff --git a/project_description/models/__init__.py b/project_description/models/__init__.py new file mode 100644 index 00000000..351a3ad3 --- /dev/null +++ b/project_description/models/__init__.py @@ -0,0 +1 @@ +from . import project diff --git a/project_description/models/project.py b/project_description/models/project.py new file mode 100644 index 00000000..371bd248 --- /dev/null +++ b/project_description/models/project.py @@ -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') diff --git a/project_description/views/project_views.xml b/project_description/views/project_views.xml new file mode 100644 index 00000000..6fb6efcd --- /dev/null +++ b/project_description/views/project_views.xml @@ -0,0 +1,30 @@ + + + + project.project.form.inherit + project.project + + + + + +
+ + + + + + + project.task.form.inherit + project.task + + + + + +
+ + + + + \ No newline at end of file diff --git a/sale_planner/__init__.py b/sale_planner/__init__.py new file mode 100644 index 00000000..134df274 --- /dev/null +++ b/sale_planner/__init__.py @@ -0,0 +1,2 @@ +from . import wizard +from . import models diff --git a/sale_planner/__manifest__.py b/sale_planner/__manifest__.py new file mode 100644 index 00000000..75bc2f24 --- /dev/null +++ b/sale_planner/__manifest__.py @@ -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, +} diff --git a/sale_planner/models/__init__.py b/sale_planner/models/__init__.py new file mode 100644 index 00000000..35dedabf --- /dev/null +++ b/sale_planner/models/__init__.py @@ -0,0 +1,3 @@ +from . import sale +from . import stock +from . import delivery diff --git a/sale_planner/models/delivery.py b/sale_planner/models/delivery.py new file mode 100644 index 00000000..b46cebcb --- /dev/null +++ b/sale_planner/models/delivery.py @@ -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)) diff --git a/sale_planner/models/sale.py b/sale_planner/models/sale.py new file mode 100644 index 00000000..6d5e46b0 --- /dev/null +++ b/sale_planner/models/sale.py @@ -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 diff --git a/sale_planner/models/stock.py b/sale_planner/models/stock.py new file mode 100644 index 00000000..4d72f4f4 --- /dev/null +++ b/sale_planner/models/stock.py @@ -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.") diff --git a/sale_planner/tests/__init__.py b/sale_planner/tests/__init__.py new file mode 100644 index 00000000..25366b57 --- /dev/null +++ b/sale_planner/tests/__init__.py @@ -0,0 +1 @@ +from . import test_planner diff --git a/sale_planner/tests/test_planner.py b/sale_planner/tests/test_planner.py new file mode 100644 index 00000000..b2d3bd61 --- /dev/null +++ b/sale_planner/tests/test_planner.py @@ -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) diff --git a/sale_planner/views/delivery.xml b/sale_planner/views/delivery.xml new file mode 100644 index 00000000..16e36513 --- /dev/null +++ b/sale_planner/views/delivery.xml @@ -0,0 +1,13 @@ + + + + delivery.carrier.form.calendar + delivery.carrier + + + + + + + + \ No newline at end of file diff --git a/sale_planner/views/sale.xml b/sale_planner/views/sale.xml new file mode 100644 index 00000000..aec860a2 --- /dev/null +++ b/sale_planner/views/sale.xml @@ -0,0 +1,17 @@ + + + + sale.order.form.planner + sale.order + + + +