diff --git a/stock_delivery_planner/__init__.py b/stock_delivery_planner/__init__.py new file mode 100644 index 00000000..c7120225 --- /dev/null +++ b/stock_delivery_planner/__init__.py @@ -0,0 +1,4 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import models +from . import wizard diff --git a/stock_delivery_planner/__manifest__.py b/stock_delivery_planner/__manifest__.py new file mode 100644 index 00000000..47d85bb0 --- /dev/null +++ b/stock_delivery_planner/__manifest__.py @@ -0,0 +1,29 @@ +{ + 'name': 'Stock Delivery Planner', + 'summary': 'Get rates and choose carrier for delivery.', + 'version': '11.0.1.0.0', + 'author': "Hibou Corp.", + 'category': 'Warehouse', + 'license': 'OPL-1', + 'website': "https://hibou.io", + 'description': """ +Stock Delivery Planner +====================== + +Re-rate deliveries at packing time to find lowest-priced delivery method that still meets the expected delivery date. + +""", + 'depends': [ + 'delivery_hibou', + 'sale_planner', + 'stock', + ], + 'data': [ + 'security/ir.model.access.csv', + 'views/res_config_settings_views.xml', + 'views/stock_views.xml', + 'wizard/stock_delivery_planner_views.xml', + ], + 'auto_install': False, + 'installable': True, +} diff --git a/stock_delivery_planner/models/__init__.py b/stock_delivery_planner/models/__init__.py new file mode 100644 index 00000000..03ea18f8 --- /dev/null +++ b/stock_delivery_planner/models/__init__.py @@ -0,0 +1,4 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import res_config_settings +from . import stock diff --git a/stock_delivery_planner/models/res_config_settings.py b/stock_delivery_planner/models/res_config_settings.py new file mode 100644 index 00000000..6f145a6e --- /dev/null +++ b/stock_delivery_planner/models/res_config_settings.py @@ -0,0 +1,43 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import api, fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + stock_delivery_planner_base_carrier_ids = fields.Many2many('delivery.carrier', + string='Delivery Planner Base Carriers', + compute='_compute_stock_delivery_planner_base_carrier_ids', + inverse='_inverse_stock_delivery_planner_base_carrier_ids') + + def _compute_stock_delivery_planner_base_carrier_ids_ids(self): + # used to compute the field and update in get_values + get_param = self.env['ir.config_parameter'].sudo().get_param + company_id = self.company_id.id or self.env.user.company_id.id + carrier_ids = get_param('stock.delivery.planner.carrier_ids.%s' % (company_id,)) or [] + if carrier_ids and isinstance(carrier_ids, str): + try: + carrier_ids = [int(c) for c in carrier_ids.split(',')] + except: + carrier_ids = [] + return carrier_ids + + def _compute_stock_delivery_planner_base_carrier_ids(self): + for settings in self: + carrier_ids = settings._compute_stock_delivery_planner_base_carrier_ids_ids() + carriers = self.env['delivery.carrier'].browse(carrier_ids) + settings.stock_delivery_planner_base_carrier_ids = carriers + + def _inverse_stock_delivery_planner_base_carrier_ids(self): + set_param = self.env['ir.config_parameter'].sudo().set_param + company_id = self.company_id.id or self.env.user.company_id.id + for settings in self: + carrier_ids = ','.join(str(i) for i in settings.stock_delivery_planner_base_carrier_ids.ids) + set_param('stock.delivery.planner.carrier_ids.%s' % (company_id, ), carrier_ids) + + @api.model + def get_values(self): + res = super(ResConfigSettings, self).get_values() + res['stock_delivery_planner_base_carrier_ids'] = [(6, 0, self._compute_stock_delivery_planner_base_carrier_ids_ids())] + return res diff --git a/stock_delivery_planner/models/stock.py b/stock_delivery_planner/models/stock.py new file mode 100644 index 00000000..3f7fd6ea --- /dev/null +++ b/stock_delivery_planner/models/stock.py @@ -0,0 +1,50 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import api, fields, models, tools, _ +from odoo.exceptions import UserError + + +class StockPicking(models.Model): + _inherit = 'stock.picking' + + @api.multi + def action_plan_delivery(self): + context = dict(self.env.context or {}) + planner_model = self.env['stock.delivery.planner'] + for picking in self: + planner = planner_model.create({ + 'picking_id': picking.id, + }) + return { + 'name': _('Plan Delivery'), + 'type': 'ir.actions.act_window', + 'res_model': 'stock.delivery.planner', + 'res_id': planner.id, + 'view_type': 'form', + 'view_mode': 'form', + 'view_id': self.env.ref('stock_delivery_planner.view_stock_delivery_planner').id, + 'target': 'new', + 'context': context, + } + + # def get_shipping_carriers(self, carrier_id=None, domain=None): + def get_shipping_carriers(self): + Carrier = self.env['delivery.carrier'].sudo() + # if carrier_id: + # return Carrier.browse(carrier_id) + # + # if domain: + # if not isinstance(domain, (list, tuple)): + # domain = tools.safe_eval(domain) + # else: + domain = [] + + if self.env.context.get('carrier_domain'): + # potential bug here if this is textual + domain.extend(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.extend(tools.safe_eval(irconfig_parameter.get_param('sale.order.planner.carrier_domain'))) + + return Carrier.search(domain) diff --git a/stock_delivery_planner/security/ir.model.access.csv b/stock_delivery_planner/security/ir.model.access.csv new file mode 100644 index 00000000..204f355c --- /dev/null +++ b/stock_delivery_planner/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_stock_delivery_planner_user,stock.delivery.planner user,model_stock_delivery_planner,stock.group_stock_user,1,1,1,0 +access_stock_delivery_planner_option_user,stock.delivery.planner.option user,model_stock_delivery_planner_option,stock.group_stock_user,1,1,1,0 \ No newline at end of file diff --git a/stock_delivery_planner/tests/__init__.py b/stock_delivery_planner/tests/__init__.py new file mode 100644 index 00000000..83aa212b --- /dev/null +++ b/stock_delivery_planner/tests/__init__.py @@ -0,0 +1 @@ +from . import test_stock_delivery_planner diff --git a/stock_delivery_planner/tests/test_stock_delivery_planner.py b/stock_delivery_planner/tests/test_stock_delivery_planner.py new file mode 100644 index 00000000..bea2a1d6 --- /dev/null +++ b/stock_delivery_planner/tests/test_stock_delivery_planner.py @@ -0,0 +1,139 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import fields +from odoo.tests.common import Form, TransactionCase + + +class TestStockDeliveryPlanner(TransactionCase): + def setUp(self): + """ + NOTE: demo Fedex credentials may not work. Test credentials may not return all service types. + Configuring production credentials may be necessary for this test to run. + """ + super(TestStockDeliveryPlanner, self).setUp() + try: + self.fedex_ground = self.browse_ref('delivery_fedex.delivery_carrier_fedex_us') + except ValueError: + self.skipTest('FedEx Shipping Connector demo data is required to run this test.') + self.env['ir.config_parameter'].sudo().set_param('sale.order.planner.carrier_domain', + "[('id', 'in', (%d,))]" % self.fedex_ground.id) + self.env['ir.config_parameter'].sudo().set_param('stock.delivery.planner.carrier_ids.%s' % (self.env.user.company_id.id, ), + "%d" % self.fedex_ground.id) + # Does it make sense to set default package in fedex_rate_shipment_multi + # instead of relying on a correctly configured delivery method? + self.fedex_package = self.browse_ref('delivery_fedex.fedex_packaging_FEDEX_25KG_BOX') + self.default_package = self.browse_ref('delivery_fedex.fedex_packaging_YOUR_PACKAGING') + self.fedex_ground.fedex_default_packaging_id = self.default_package + self.fedex_ground.fedex_service_type = 'FEDEX_GROUND' + + self.fedex_2_day = self.fedex_ground.copy() + self.fedex_2_day.name = 'Test FedEx Delivery' + self.fedex_2_day.fedex_service_type = 'FEDEX_2_DAY' + + delivery_calendar = self.env['resource.calendar'].create({ + 'name': 'Test Delivery Calendar', + 'tz': 'US/Central', + 'attendance_ids': [ + (0, 0, {'name': 'Monday', 'dayofweek': '0', 'hour_from': 0, 'hour_to': 23.99, 'day_period': 'morning'}), + (0, 0, {'name': 'Tuesday', 'dayofweek': '1', 'hour_from': 0, 'hour_to': 23.99, 'day_period': 'morning'}), + (0, 0, {'name': 'Wednesday', 'dayofweek': '2', 'hour_from': 0, 'hour_to': 23.99, 'day_period': 'morning'}), + (0, 0, {'name': 'Thursday', 'dayofweek': '3', 'hour_from': 0, 'hour_to': 23.99, 'day_period': 'morning'}), + (0, 0, {'name': 'Friday', 'dayofweek': '4', 'hour_from': 0, 'hour_to': 23.99, 'day_period': 'morning'}), + ], + }) + self.fedex_ground.delivery_calendar_id = delivery_calendar + self.fedex_2_day.delivery_calendar_id = delivery_calendar + self.env['stock.warehouse'].search([]).write({'shipping_calendar_id': delivery_calendar.id}) + + # needs a valid address for sender and recipient + 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_ia = self.env['res.country.state'].search([('name', '=', 'Iowa')], limit=1) + self.env.user.company_id.partner_id.write({ + 'street': '321 1st St', + 'city': 'Ames', + 'state_id': self.state_ia.id, + 'zip': '50010', + 'country_id': self.country_usa.id, + }) + self.partner = self.env['res.partner'].create({ + 'name': 'Test Customer', + 'street': '1234 Test Street', + 'city': 'Marysville', + 'state_id': self.state_wa.id, + 'zip': '98270', + 'country_id': self.country_usa.id, + 'is_company': True, + # 'partner_latitude': 48.05636, + # 'partner_longitude': -122.14922, + 'customer': True, + }) + + # self.product = self.browse_ref('product.product_product_27') # [FURN_8855] Drawer + # self.product.weight = 5.0 + # self.product.volume = 0.1 + self.env['ir.config_parameter'].sudo().set_param('product.weight_in_lbs', '1') + self.product = self.env['product.product'].create({ + 'name': 'Test Ship Product', + 'type': 'product', + 'weight': 1.0, + }) + self.env['stock.change.product.qty'].create({ + 'product_id': self.product.id, + 'new_quantity': 10.0, + }).change_product_qty() + + so = Form(self.env['sale.order']) + so.partner_id = self.partner + so.carrier_id = self.env['delivery.carrier'].browse() + with so.order_line.new() as line: + line.product_id = self.product + line.product_uom_qty = 5.0 + line.price_unit = 100.0 + self.sale_order = so.save() + + order_plan_action = self.sale_order.action_planorder() + order_plan = self.env[order_plan_action['res_model']].browse(order_plan_action['res_id']) + order_plan.planning_option_ids.filtered(lambda o: o.carrier_id == self.fedex_ground).select_plan() + + self.sale_order.action_confirm() + self.picking = self.sale_order.picking_ids + + def test_00_test_one_package(self): + """Delivery is packed in one package""" + self.assertTrue(self.sale_order.requested_date, 'Order has not been planned') + self.assertEqual(len(self.picking), 1) + grp_pack = self.env.ref('stock.group_tracking_lot') + self.env.user.write({'groups_id': [(4, grp_pack.id)]}) + + self.assertEqual(self.picking.carrier_id, self.fedex_ground, 'Carrier did not carry over to Delivery Order') + self.assertEqual(self.picking.weight, 5.0) + self.assertEqual(self.picking.shipping_weight, 0.0) + + self.picking.move_line_ids.filtered(lambda ml: ml.product_id == self.product).qty_done = 5.0 + packing_action = self.picking.action_put_in_pack() + packing_wizard = Form(self.env[packing_action['res_model']].with_context(packing_action['context'])) + packing_wizard.delivery_packaging_id = self.fedex_package + choose_delivery_package = packing_wizard.save() + choose_delivery_package.action_put_in_pack() + self.assertEqual(self.picking.shipping_weight, 5.0) + + action = self.picking.action_plan_delivery() + planner = self.env[action['res_model']].browse(action['res_id']) + + self.assertEqual(planner.picking_id, self.picking) + self.assertGreater(len(planner.plan_option_ids), 1) + + plan_option = planner.plan_option_ids.filtered(lambda o: o.carrier_id == self.fedex_2_day) + self.assertEqual(len(plan_option), 1) + self.assertGreater(plan_option.price, 0.0) + self.assertEqual(plan_option.date_planned.date(), fields.Date().today()) + self.assertTrue(plan_option.requested_date) + self.assertEqual(plan_option.transit_days, 2) + self.assertEqual(plan_option.sale_requested_date, self.sale_order.requested_date) + # Order Planner expects to ship tomorrow: we are shipping a day early and using + # 2-day shipping instead of 3, giving us 2 days difference + self.assertEqual(plan_option.days_different, -2.0) + + plan_option.select_plan() + self.assertEqual(self.picking.carrier_id, self.fedex_2_day) diff --git a/stock_delivery_planner/views/res_config_settings_views.xml b/stock_delivery_planner/views/res_config_settings_views.xml new file mode 100644 index 00000000..bc8f2557 --- /dev/null +++ b/stock_delivery_planner/views/res_config_settings_views.xml @@ -0,0 +1,28 @@ + + + + + res.config.settings.view.form.inherit + res.config.settings + + + + +

Delivery Planner

+
+
+
+
+
+ + + + + diff --git a/stock_delivery_planner/views/stock_views.xml b/stock_delivery_planner/views/stock_views.xml new file mode 100644 index 00000000..a65334dd --- /dev/null +++ b/stock_delivery_planner/views/stock_views.xml @@ -0,0 +1,13 @@ + + + + stock.picking.form.inherit.delivery.planner + stock.picking + + + +