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..cce9a189 --- /dev/null +++ b/sale_planner/__manifest__.py @@ -0,0 +1,43 @@ +{ + 'name': 'Sale Order Planner', + 'summary': 'Plans order dates and warehouses.', + 'version': '14.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_sourced_by_line', + 'base_geolocalize', + 'delivery', + 'resource', + 'stock', + ], + 'demo': [], + 'data': [ + 'security/ir.model.access.csv', + 'wizard/order_planner_views.xml', + 'views/sale.xml', + 'views/stock.xml', + 'views/delivery.xml', + 'views/product.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..ff524f40 --- /dev/null +++ b/sale_planner/models/__init__.py @@ -0,0 +1,7 @@ +from . import delivery +from . import partner +from . import planning +from . import product +from . import resource +from . import sale +from . import stock diff --git a/sale_planner/models/delivery.py b/sale_planner/models/delivery.py new file mode 100644 index 00000000..c8526c34 --- /dev/null +++ b/sale_planner/models/delivery.py @@ -0,0 +1,106 @@ +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 rate_shipment_date_planned(self, order, date_planned=None): + """ + For every sale order, compute the price of the shipment and potentially when it will arrive. + :param order: `sale.order` + :param date_planned: The date the shipment is expected to leave/be picked up by carrier. + :return: rate in the same form the normal `rate_shipment` method does BUT + -- Additional keys + - transit_days: int + - date_delivered: string + """ + self.ensure_one() + if hasattr(self, '%s_rate_shipment_date_planned' % self.delivery_type): + # New API Odoo 11 - Carrier specific override. + return getattr(self, '%s_rate_shipment_date_planned' % self.delivery_type)(order, date_planned) + + rate = self.with_context(date_planned=date_planned).rate_shipment(order) + if rate and date_planned: + if rate.get('date_delivered'): + date_delivered = rate['date_delivered'] + transit_days = self.calculate_transit_days(date_planned, date_delivered) + if not rate.get('transit_days') or transit_days < rate.get('transit_days'): + rate['transit_days'] = transit_days + elif rate.get('transit_days'): + rate['date_delivered'] = self.calculate_date_delivered(date_planned, rate.get('transit_days')) + elif rate: + date_delivered = rate.get('date_delivered') + if date_delivered and not rate.get('transit_days'): + # we could have a date delivered based on shipping it "now" + # so we can still calculate the transit days + rate['transit_days'] = self.calculate_transit_days(fields.Datetime.now(), date_delivered) + rate.pop('date_delivered') # because we don't have a date_planned, we cannot have a guarenteed delivery + + return rate + + 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 + + current_date_planned = self.delivery_calendar_id.plan_days(1, date_planned, compute_leaves=True) + if not current_date_planned: + return self._calculate_transit_days_naive(date_planned, date_delivered) + if current_date_planned == date_planned: + date_planned += timedelta(days=1) + else: + date_planned = current_date_planned + 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 + + last_day = self.delivery_calendar_id.plan_days_end(effective_transit_days, date_planned, compute_leaves=True) + if not last_day: + return self._calculate_date_delivered_naive(date_planned, transit_days) + + return last_day + + def _calculate_date_delivered_naive(self, date_planned, transit_days): + return date_planned + timedelta(days=transit_days) diff --git a/sale_planner/models/partner.py b/sale_planner/models/partner.py new file mode 100644 index 00000000..a87ff4f0 --- /dev/null +++ b/sale_planner/models/partner.py @@ -0,0 +1,31 @@ +from odoo import api, fields, models + +try: + from uszipcode import SearchEngine +except ImportError: + SearchEngine = None + + +class Partner(models.Model): + _inherit = 'res.partner' + + def geo_localize(self): + # We need country names in English below + for partner in self.with_context(lang='en_US'): + try: + if SearchEngine and partner.zip: + with SearchEngine() as search: + zipcode = search.by_zipcode(str(self.zip).split('-')[0]) + if zipcode and zipcode.lat: + partner.write({ + 'partner_latitude': zipcode.lat, + 'partner_longitude': zipcode.lng, + 'date_localization': fields.Date.context_today(partner), + }) + else: + super(Partner, partner).geo_localize() + else: + super(Partner, partner).geo_localize() + except: + pass + return True diff --git a/sale_planner/models/planning.py b/sale_planner/models/planning.py new file mode 100644 index 00000000..47d645af --- /dev/null +++ b/sale_planner/models/planning.py @@ -0,0 +1,16 @@ +from odoo import api, fields, models + + +class PlanningPolicy(models.Model): + _name = 'sale.order.planning.policy' + + name = fields.Char(string='Name') + carrier_filter_id = fields.Many2one( + 'ir.filters', + string='Delivery Carrier Filter', + ) + warehouse_filter_id = fields.Many2one( + 'ir.filters', + string='Warehouse Filter', + ) + always_closest_warehouse = fields.Boolean(string='Always Plan Closest Warehouse') diff --git a/sale_planner/models/product.py b/sale_planner/models/product.py new file mode 100644 index 00000000..55cb677e --- /dev/null +++ b/sale_planner/models/product.py @@ -0,0 +1,21 @@ +from odoo import api, fields, models + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + property_planning_policy_id = fields.Many2one('sale.order.planning.policy', company_dependent=True, + string="Order Planner Policy", + help="The Order Planner Policy to use when making a sale order planner.") + + def get_planning_policy(self): + self.ensure_one() + return self.property_planning_policy_id or self.categ_id.property_planning_policy_categ_id + + +class ProductCategory(models.Model): + _inherit = 'product.category' + + property_planning_policy_categ_id = fields.Many2one('sale.order.planning.policy', company_dependent=True, + string="Order Planner Policy", + help="The Order Planner Policy to use when making a sale order planner.") diff --git a/sale_planner/models/resource.py b/sale_planner/models/resource.py new file mode 100644 index 00000000..07556ce7 --- /dev/null +++ b/sale_planner/models/resource.py @@ -0,0 +1,46 @@ +from functools import partial +from datetime import timedelta +from odoo import api, models +from odoo.addons.resource.models.resource import make_aware + + +class ResourceCalendar(models.Model): + _inherit = 'resource.calendar' + + def plan_days_end(self, days, day_dt, compute_leaves=False, domain=None): + """ + Override to `plan_days` that allows you to get the nearest 'end' including today. + """ + day_dt, revert = make_aware(day_dt) + + # which method to use for retrieving intervals + if compute_leaves: + get_intervals = partial(self._work_intervals, domain=domain) + else: + get_intervals = self._attendance_intervals + + if days >= 0: + found = set() + delta = timedelta(days=14) + for n in range(100): + dt = day_dt + delta * n + for start, stop, meta in get_intervals(dt, dt + delta): + found.add(start.date()) + if len(found) >= days: + return revert(stop) + return False + + elif days < 0: + days = abs(days) + found = set() + delta = timedelta(days=14) + for n in range(100): + dt = day_dt - delta * n + for start, stop, meta in reversed(get_intervals(dt - delta, dt)): + found.add(start.date()) + if len(found) == days: + return revert(start) + return False + + else: + return revert(day_dt) diff --git a/sale_planner/models/sale.py b/sale_planner/models/sale.py new file mode 100644 index 00000000..30396792 --- /dev/null +++ b/sale_planner/models/sale.py @@ -0,0 +1,15 @@ +from odoo import api, fields, models + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + 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/security/ir.model.access.csv b/sale_planner/security/ir.model.access.csv new file mode 100644 index 00000000..57dffffb --- /dev/null +++ b/sale_planner/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_sale_order_make_plan,access_sale_order_make_plan,model_sale_order_make_plan,base.group_user,1,1,1,1 +access_sale_order_planning_option,access_sale_order_planning_option,model_sale_order_planning_option,base.group_user,1,1,1,1 +access_sale_order_planning_policy,access_sale_order_planning_policy,model_sale_order_planning_policy,base.group_user,1,1,1,1 \ No newline at end of file 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..4bc58ad8 --- /dev/null +++ b/sale_planner/tests/test_planner.py @@ -0,0 +1,479 @@ +from odoo.tests import common +from datetime import datetime, timedelta +from json import loads as json_decode +from logging import getLogger + +_logger = getLogger(__name__) + + +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, + }) + hour_from = (self.today.hour - 1) % 24 + hour_to = (self.today.hour + 1) % 24 + if hour_to < hour_from: + hour_to, hour_from = hour_from, hour_to + + self.warehouse_calendar_1 = self.env['resource.calendar'].create({ + 'name': 'Washington Warehouse Hours', + 'tz': 'UTC', + 'attendance_ids': [ + (0, 0, {'name': 'today', + 'dayofweek': str(self.today.weekday()), + 'hour_from': hour_from, + 'hour_to': hour_to, + 'day_period': 'morning'}), + + (0, 0, {'name': 'tomorrow', + 'dayofweek': str(self.tomorrow.weekday()), + 'hour_from': hour_from, + 'hour_to': hour_to, + 'day_period': 'morning'}), + + ] + }) + self.warehouse_calendar_2 = self.env['resource.calendar'].create({ + 'name': 'Colorado Warehouse Hours', + 'tz': 'UTC', + 'attendance_ids': [ + (0, 0, {'name': 'tomorrow', + 'dayofweek': str(self.tomorrow.weekday()), + 'hour_from': hour_from, + 'hour_to': hour_to, + 'day_period': 'morning'}), + ] + }) + self.warehouse_1 = self.env['stock.warehouse'].create({ + 'name': 'Washington Warehouse', + 'partner_id': self.warehouse_partner_1.id, + 'code': 'TWH1', + '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': 'TWH2', + '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_12 = self.env['product.template'].create({ + 'name': 'Product for WH1 Second', + 'type': 'product', + 'standard_price': 1.0, + }) + self.product_1 = self.product_1.product_variant_id + self.product_12 = self.product_12.product_variant_id + self.product_2 = self.env['product.template'].create({ + 'name': 'Product for WH2', + 'type': 'product', + 'standard_price': 1.0, + }) + self.product_22 = self.env['product.template'].create({ + 'name': 'Product for WH2 Second', + 'type': 'product', + 'standard_price': 1.0, + }) + self.product_2 = self.product_2.product_variant_id + self.product_22 = self.product_22.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.quant'].create({ + 'location_id': self.warehouse_1.lot_stock_id.id, + 'product_id': self.product_1.id, + 'quantity': 100, + }) + self.env['stock.quant'].create({ + 'location_id': self.warehouse_1.lot_stock_id.id, + 'product_id': self.product_12.id, + 'quantity': 100, + }) + self.env['stock.quant'].create({ + 'location_id': self.warehouse_1.lot_stock_id.id, + 'product_id': self.product_both.id, + 'quantity': 100, + }) + self.env['stock.quant'].create({ + 'location_id': self.warehouse_2.lot_stock_id.id, + 'product_id': self.product_2.id, + 'quantity': 100, + }) + self.env['stock.quant'].create({ + 'location_id': self.warehouse_2.lot_stock_id.id, + 'product_id': self.product_22.id, + 'quantity': 100, + }) + self.env['stock.quant'].create({ + 'location_id': self.warehouse_2.lot_stock_id.id, + 'product_id': self.product_both.id, + 'quantity': 100, + }) + + self.policy_closest = self.env['sale.order.planning.policy'].create({ + 'always_closest_warehouse': True, + }) + self.policy_other = self.env['sale.order.planning.policy'].create({}) + self.wh_filter_1 = self.env['ir.filters'].create({ + 'name': 'TWH1 Only', + 'domain': "[('id', '=', %d)]" % (self.warehouse_1.id, ), + 'model_id': 'stock.warehouse', + }) + self.wh_filter_2 = self.env['ir.filters'].create({ + 'name': 'TWH2 Only', + 'domain': "[('id', '=', %d)]" % (self.warehouse_2.id,), + 'model_id': 'stock.warehouse', + }) + self.policy_wh_1 = self.env['sale.order.planning.policy'].create({ + 'warehouse_filter_id': self.wh_filter_1.id, + }) + self.policy_wh_2 = self.env['sale.order.planning.policy'].create({ + 'warehouse_filter_id': self.wh_filter_2.id, + }) + + def both_wh_ids(self): + return [self.warehouse_1.id, self.warehouse_2.id] + + def test_10_planner_creation_internals(self): + """ + Tests certain internal representations and that we can create a basic plan. + """ + 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_21_planner_creation(self): + """ + Scenario where only one warehouse has inventory on the order line. + This is "the closest" warehouse. + """ + 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_2.id).qty_available, 0) + self.product_1.invalidate_cache(fnames=['qty_available'], ids=self.product_1.ids) + self.assertEqual(self.product_1.with_context(warehouse=self.warehouse_1.id).qty_available, 100) + self.product_1.invalidate_cache(fnames=['qty_available'], ids=self.product_1.ids) + + 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_22_planner_creation(self): + """ + Scenario where only one warehouse has inventory on the order line. + This is "the further" warehouse. + """ + 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.product_2.invalidate_cache(fnames=['qty_available'], ids=self.product_2.ids) + self.assertEqual(self.product_2.with_context(warehouse=self.warehouse_2.id).qty_available, 100) + self.product_2.invalidate_cache(fnames=['qty_available'], ids=self.product_2.ids) + 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_31_planner_creation_split(self): + """ + Scenario where only one warehouse has inventory on each of the order line. + This will cause two pickings to be created, one for each warehouse. + """ + 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': 'demo2', + }) + 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_32_planner_creation_no_split(self): + """ + Scenario where only "the further" warehouse has inventory on whole order, but + the "closest" warehouse only has inventory on one item. + This will simply plan out of the "the further" warehouse. + """ + 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) + + def test_42_policy_force_closest(self): + """ + Scenario where an item may not be in stock at "the closest" warehouse, but an item is only allowed + to come from "the closest" warehouse. + """ + self.product_2.property_planning_policy_id = self.policy_closest + 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_1.id).qty_available, 100) + # Close warehouse doesn't have product. + self.assertEqual(self.product_2.with_context(warehouse=self.warehouse_1.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_43_policy_merge(self): + """ + Scenario that will make a complicated scenario specifically: + - 3 policy groups + - 2 base options with sub_options (all base options with same warehouse) + """ + self.product_both.property_planning_policy_id = self.policy_closest + self.product_12.property_planning_policy_id = self.policy_other + self.product_22.property_planning_policy_id = self.policy_other + + 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_1.id, + 'name': 'demo', + }) + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_2.id, + 'name': 'demo', + }) + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_12.id, + 'name': 'demo', + }) + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_22.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.assertTrue(planner.planning_option_ids, 'Must have one or more plans.') + self.assertEqual(planner.planning_option_ids.warehouse_id, self.warehouse_1) + self.assertTrue(planner.planning_option_ids.sub_options) + + sub_options = json_decode(planner.planning_option_ids.sub_options) + _logger.error(sub_options) + wh_1_ids = sorted([self.product_both.id, self.product_1.id, self.product_12.id]) + wh_2_ids = sorted([self.product_2.id, self.product_22.id]) + self.assertEqual(sorted(sub_options[str(self.warehouse_1.id)]['product_ids']), wh_1_ids) + self.assertEqual(sorted(sub_options[str(self.warehouse_2.id)]['product_ids']), wh_2_ids) + + def test_44_policy_merge_2(self): + """ + Scenario that will make a complicated scenario specifically: + - 3 policy groups + - 2 base options from different warehouses + """ + self.product_both.property_planning_policy_id = self.policy_other + self.product_12.property_planning_policy_id = self.policy_closest + self.product_22.property_planning_policy_id = self.policy_other + + 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_1.id, + 'name': 'demo', + }) + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_2.id, + 'name': 'demo', + }) + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_12.id, + 'name': 'demo', + }) + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_22.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.assertTrue(planner.planning_option_ids, 'Must have one or more plans.') + self.assertEqual(planner.planning_option_ids.warehouse_id, self.warehouse_2) + self.assertTrue(planner.planning_option_ids.sub_options) + + sub_options = json_decode(planner.planning_option_ids.sub_options) + _logger.error(sub_options) + wh_1_ids = sorted([self.product_1.id, self.product_12.id]) + wh_2_ids = sorted([self.product_both.id, self.product_2.id, self.product_22.id]) + self.assertEqual(sorted(sub_options[str(self.warehouse_1.id)]['product_ids']), wh_1_ids) + self.assertEqual(sorted(sub_options[str(self.warehouse_2.id)]['product_ids']), wh_2_ids) + + def test_45_policy_merge_3(self): + """ + Different order of products for test_44 + - 3 policy groups + - 2 base options from different warehouses + """ + self.product_both.property_planning_policy_id = self.policy_other + self.product_12.property_planning_policy_id = self.policy_closest + self.product_22.property_planning_policy_id = self.policy_other + + + 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.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_12.id, + 'name': 'demo', + }) + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_22.id, + 'name': 'demo', + }) + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_both.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.assertTrue(planner.planning_option_ids, 'Must have one or more plans.') + self.assertEqual(planner.planning_option_ids.warehouse_id, self.warehouse_1) + self.assertTrue(planner.planning_option_ids.sub_options) + + sub_options = json_decode(planner.planning_option_ids.sub_options) + _logger.error(sub_options) + wh_1_ids = sorted([self.product_1.id, self.product_12.id]) + wh_2_ids = sorted([self.product_both.id, self.product_2.id, self.product_22.id]) + self.assertEqual(sorted(sub_options[str(self.warehouse_1.id)]['product_ids']), wh_1_ids) + self.assertEqual(sorted(sub_options[str(self.warehouse_2.id)]['product_ids']), wh_2_ids) + + def test_51_policy_specific_warehouse(self): + """ + Force one item to TWH2. + """ + self.product_both.property_planning_policy_id = self.policy_wh_2 + + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_both.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.assertTrue(planner.planning_option_ids, 'Must have one or more plans.') + self.assertEqual(planner.planning_option_ids.warehouse_id, self.warehouse_2) 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/product.xml b/sale_planner/views/product.xml new file mode 100644 index 00000000..aed5aec5 --- /dev/null +++ b/sale_planner/views/product.xml @@ -0,0 +1,23 @@ + + + + product.template.common.form.inherit + product.template + + + + + + + + + product.category.form.inherit + product.category + + + + + + + + \ 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 + + + +