From 97fcb936623d0c6a7a538e8868d32f818236428d Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Wed, 9 May 2018 09:27:45 -0700 Subject: [PATCH 01/21] Initial commit of `sale_planner` for 11.0 --- sale_planner/__init__.py | 2 + sale_planner/__manifest__.py | 40 ++ sale_planner/models/__init__.py | 3 + sale_planner/models/delivery.py | 69 +++ sale_planner/models/sale.py | 16 + sale_planner/models/stock.py | 9 + sale_planner/tests/__init__.py | 1 + sale_planner/tests/test_planner.py | 204 +++++++ sale_planner/views/delivery.xml | 13 + sale_planner/views/sale.xml | 17 + sale_planner/views/stock.xml | 13 + sale_planner/wizard/__init__.py | 1 + sale_planner/wizard/order_planner.py | 590 ++++++++++++++++++++ sale_planner/wizard/order_planner_views.xml | 40 ++ 14 files changed, 1018 insertions(+) create mode 100644 sale_planner/__init__.py create mode 100644 sale_planner/__manifest__.py create mode 100644 sale_planner/models/__init__.py create mode 100644 sale_planner/models/delivery.py create mode 100644 sale_planner/models/sale.py create mode 100644 sale_planner/models/stock.py create mode 100644 sale_planner/tests/__init__.py create mode 100644 sale_planner/tests/test_planner.py create mode 100644 sale_planner/views/delivery.xml create mode 100644 sale_planner/views/sale.xml create mode 100644 sale_planner/views/stock.xml create mode 100644 sale_planner/wizard/__init__.py create mode 100644 sale_planner/wizard/order_planner.py create mode 100644 sale_planner/wizard/order_planner_views.xml 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..a6bddc5c --- /dev/null +++ b/sale_planner/__manifest__.py @@ -0,0 +1,40 @@ +{ + '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', + ], + '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 + + + +