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
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/sale_planner/views/stock.xml b/sale_planner/views/stock.xml
new file mode 100644
index 00000000..d0195d86
--- /dev/null
+++ b/sale_planner/views/stock.xml
@@ -0,0 +1,13 @@
+
+
+
+ stock.warehouse.shipping.calendar
+ stock.warehouse
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/sale_planner/wizard/__init__.py b/sale_planner/wizard/__init__.py
new file mode 100644
index 00000000..235b12a8
--- /dev/null
+++ b/sale_planner/wizard/__init__.py
@@ -0,0 +1 @@
+from . import order_planner
diff --git a/sale_planner/wizard/order_planner.py b/sale_planner/wizard/order_planner.py
new file mode 100644
index 00000000..dd48dd78
--- /dev/null
+++ b/sale_planner/wizard/order_planner.py
@@ -0,0 +1,590 @@
+from math import sin, cos, sqrt, atan2, radians
+from json import dumps, loads
+from copy import deepcopy
+from datetime import datetime
+from logging import getLogger
+
+_logger = getLogger(__name__)
+
+try:
+ from uszipcode import ZipcodeSearchEngine
+except ImportError:
+ _logger.warn('module "uszipcode" cannot be loaded, falling back to Google API')
+ ZipcodeSearchEngine = None
+
+from odoo import api, fields, models, tools
+from odoo.addons.base_geolocalize.models.res_partner import geo_find, geo_query_address
+
+
+class FakeCollection():
+ def __init__(self, vals):
+ self.vals = vals
+
+ def __iter__(self):
+ for v in self.vals:
+ yield v
+
+ def filtered(self, f):
+ return filter(f, self.vals)
+
+
+class FakePartner():
+ def __init__(self, **kwargs):
+ """
+ 'delivery.carrier'.verify_carrier(contact) ->
+ country_id,
+ state_id,
+ zip
+ company
+
+ city,
+
+ `distance calculations` ->
+ date_localization,
+ partner_latitude,
+ partner_longitude
+
+ computes them when accessed
+ """
+ self.partner_latitude = 0.0
+ self.partner_longitude = 0.0
+ self.is_company = False
+ for attr, value in kwargs.items():
+ _logger.warn(' ' + str(attr) + ': ' + str(value))
+ setattr(self, attr, value)
+
+ @property
+ def date_localization(self):
+ if not hasattr(self, 'date_localization'):
+ self.date_localization = 'TODAY!'
+ # The fast way.
+ if ZipcodeSearchEngine and self.zip:
+ with ZipcodeSearchEngine() as search:
+ zipcode = search.by_zipcode(self.zip)
+ if zipcode:
+ self.partner_latitude = zipcode['Latitude']
+ self.partner_longitude = zipcode['Longitude']
+ return self.date_localization
+
+ # The slow way.
+ result = geo_find(geo_query_address(
+ city=self.city,
+ state=self.state_id.name,
+ country=self.country_id.name,
+ ))
+ if result:
+ self.partner_latitude = result[0]
+ self.partner_longitude = result[1]
+
+ return self.date_localization
+
+
+class FakeOrderLine():
+ def __init__(self, **kwargs):
+ """
+ 'delivery.carrier'.get_price_available(order) ->
+ state,
+ is_delivery,
+ product_uom._compute_quantity,
+ product_uom_qty,
+ product_id
+ price_total
+
+
+ """
+ self.state = 'draft'
+ self.is_delivery = False
+ self.product_uom = self
+
+ for attr, value in kwargs.items():
+ setattr(self, attr, value)
+
+ def _compute_quantity(self, qty=1, uom=None):
+ """
+ This is a non-implementation for when someone wants to call product_uom._compute_quantity
+ :param qty:
+ :param uom:
+ :return:
+ """
+ return qty
+
+
+class FakeSaleOrder():
+ """
+ partner_id :: used in shipping
+ partner_shipping_id :: is used in several places
+ order_line :: can be a FakeCollection of FakeOrderLine's or Odoo 'sale.order.line'
+ carrier_id :: can be empty, will be overwritten when walking through carriers
+
+ 'delivery.carrier'.get_shipping_price_from_so(orders) ->
+ id, (int)
+ name, (String)
+ currency_id, (Odoo 'res.currency')
+ company_id, (Odoo 'res.company')
+ warehouse_id, (Odoo 'stock.warehouse')
+ carrier_id, (Odoo 'delivery.carrier')
+
+ SaleOrderMakePlan.generate_shipping_options() ->
+ pricelist_id, (Odoo 'product.pricelist')
+ """
+ def __init__(self, **kwargs):
+ self.carrier_id = None
+ self.id = 0
+ self.name = 'Quote'
+ self.team_id = None
+ self.project_id = None
+ self.amount_total = 0.0
+ for attr, value in kwargs.items():
+ setattr(self, attr, value)
+
+ def __iter__(self):
+ """
+ Emulate a recordset of a single order.
+ """
+ yield self
+
+
+def distance(lat_1, lon_1, lat_2, lon_2):
+ R = 6373.0
+
+ lat1 = radians(lat_1)
+ lon1 = radians(lon_1)
+ lat2 = radians(lat_2)
+ lon2 = radians(lon_2)
+
+ dlon = lon2 - lon1
+ dlat = lat2 - lat1
+
+ a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
+ c = 2 * atan2(sqrt(a), sqrt(1 - a))
+
+ return R * c
+
+
+class SaleOrderMakePlan(models.TransientModel):
+ _name = 'sale.order.make.plan'
+ _description = 'Plan Order'
+
+ order_id = fields.Many2one(
+ 'sale.order', 'Sale Order',
+ )
+ planning_option_ids = fields.One2many('sale.order.planning.option', 'plan_id', 'Options')
+
+ @api.model
+ def plan_order(self, vals):
+ pass
+
+ @api.multi
+ def select_option(self, option):
+ for plan in self:
+ self.plan_order_option(plan.order_id, option)
+
+ def _order_fields_for_option(self, option):
+ return {
+ 'warehouse_id': option.warehouse_id.id,
+ 'requested_date': option.requested_date,
+ 'date_planned': option.date_planned,
+ 'carrier_id': option.carrier_id.id,
+ }
+
+ @api.model
+ def plan_order_option(self, order, option):
+ if option.sub_options:
+ sub_options = option.sub_options
+ if isinstance(sub_options, str):
+ sub_options = loads(sub_options)
+ if not isinstance(sub_options, dict):
+ _logger.warn('Cannot apply option with corrupt sub_options')
+ return False
+ order_lines = order.order_line
+ for wh_id, wh_vals in sub_options.items():
+ wh_id = int(wh_id)
+ if wh_id == option.warehouse_id.id:
+ continue
+ order_lines.filtered(lambda line: line.product_id.id in wh_vals['product_ids']).write({
+ 'warehouse_id': wh_id,
+ 'date_planned': wh_vals.get('date_planned'),
+ })
+
+ order_fields = self._order_fields_for_option(option)
+
+ order.write(order_fields)
+
+ if option.carrier_id:
+ order._create_delivery_line(option.carrier_id, option.shipping_price)
+
+
+ @api.model
+ def create(self, values):
+ planner = super(SaleOrderMakePlan, self).create(values)
+
+ for option_vals in self.generate_order_options(planner.order_id):
+ option_vals['plan_id'] = planner.id
+ planner.planning_option_ids |= self.env['sale.order.planning.option'].create(option_vals)
+
+ return planner
+
+ def _fake_order(self, order):
+ return FakeSaleOrder(**{
+ 'id': order.id,
+ 'name': order.name,
+ 'partner_id': order.partner_id,
+ 'partner_shipping_id': order.partner_shipping_id,
+ 'order_line': order.order_line,
+ 'currency_id': order.currency_id,
+ 'company_id': order.company_id,
+ 'warehouse_id': order.warehouse_id,
+ 'amount_total': order.amount_total,
+ 'pricelist_id': order.pricelist_id,
+ 'env': self.env,
+ })
+
+ @api.model
+ def generate_order_options(self, order, plan_shipping=True):
+ fake_order = self._fake_order(order)
+ base_option = self.generate_base_option(fake_order)
+ # do we need shipping?
+ # we need to collect it because we want multi-warehouse shipping amounts.
+ if order.carrier_id:
+ base_option['carrier_id'] = order.carrier_id.id
+
+ if plan_shipping and not self.env.context.get('skip_plan_shipping'):
+ options = self.generate_shipping_options(base_option, fake_order)
+ else:
+ options = [base_option]
+
+ return options
+
+ def get_warehouses(self, warehouse_id=None):
+ warehouse = self.env['stock.warehouse'].sudo()
+ if warehouse_id:
+ return warehouse.search([('id', '=', warehouse_id)])
+
+ if self.env.context.get('warehouse_domain'):
+ #potential bug here if this is textual
+ return warehouse.search(self.env.context.get('warehouse_domain'))
+
+ irconfig_parameter = self.env['ir.config_parameter'].sudo()
+ if irconfig_parameter.get_param('sale.order.planner.warehouse_domain'):
+ domain = tools.safe_eval(irconfig_parameter.get_param('sale.order.planner.warehouse_domain'))
+ return warehouse.search(domain)
+
+ return warehouse.search([])
+
+ def get_shipping_carriers(self, carrier_id=None):
+ Carrier = self.env['delivery.carrier'].sudo()
+ if carrier_id:
+ return Carrier.search([('id', '=', carrier_id)])
+
+ if self.env.context.get('carrier_domain'):
+ # potential bug here if this is textual
+ return Carrier.search(self.env.context.get('carrier_domain'))
+
+ irconfig_parameter = self.env['ir.config_parameter'].sudo()
+ if irconfig_parameter.get_param('sale.order.planner.carrier_domain'):
+ domain = tools.safe_eval(irconfig_parameter.get_param('sale.order.planner.carrier_domain'))
+ return Carrier.search(domain)
+
+ return Carrier.search([])
+
+ def generate_base_option(self, order_fake):
+ product_lines = list(filter(lambda line: line.product_id.type == 'product', order_fake.order_line))
+ if not product_lines:
+ return {}
+
+ buy_qty = {line.product_id.id: line.product_uom_qty for line in product_lines}
+
+ products = self.env['product.product']
+ for line in product_lines:
+ products |= line.product_id
+
+ warehouses = self.get_warehouses()
+ product_stock = self._fetch_product_stock(warehouses, products)
+ sub_options = {}
+ wh_date_planning = {}
+
+ p_len = len(products)
+ full_candidates = set()
+ partial_candidates = set()
+ for wh_id, stock in product_stock.items():
+ available = sum(1 for p_id, p_vals in stock.items() if self._is_in_stock(p_vals, buy_qty[p_id]))
+ if available == p_len:
+ full_candidates.add(wh_id)
+ elif available > 0:
+ partial_candidates.add(wh_id)
+
+ if full_candidates:
+ if len(full_candidates) == 1:
+ warehouse = warehouses.filtered(lambda wh: wh.id in full_candidates)
+ date_planned = self._next_warehouse_shipping_date(warehouse)
+ order_fake.warehouse_id = warehouse
+ return {'warehouse_id': warehouse.id, 'date_planned': date_planned}
+
+ warehouse = self._find_closest_warehouse_by_partner(
+ warehouses.filtered(lambda wh: wh.id in full_candidates), order_fake.partner_shipping_id)
+ date_planned = self._next_warehouse_shipping_date(warehouse)
+ order_fake.warehouse_id = warehouse
+ return {'warehouse_id': warehouse.id, 'date_planned': date_planned}
+
+ if partial_candidates:
+ if len(partial_candidates) == 1:
+ warehouse = warehouses.filtered(lambda wh: wh.id in partial_candidates)
+ order_fake.warehouse_id = warehouse
+ return {'warehouse_id': warehouse.id}
+
+ sorted_warehouses = self._sort_warehouses_by_partner(warehouses.filtered(lambda wh: wh.id in partial_candidates), order_fake.partner_shipping_id)
+ primary_wh = sorted_warehouses[0] #partial_candidates means there is at least one warehouse
+ primary_wh_date_planned = self._next_warehouse_shipping_date(primary_wh)
+ wh_date_planning[primary_wh.id] = primary_wh_date_planned
+ for wh in sorted_warehouses:
+ if not buy_qty:
+ continue
+ stock = product_stock[wh.id]
+ for p_id, p_vals in stock.items():
+ if p_id in buy_qty and self._is_in_stock(p_vals, buy_qty[p_id]):
+ if wh.id not in sub_options:
+ sub_options[wh.id] = {
+ 'date_planned': self._next_warehouse_shipping_date(wh),
+ 'product_ids': [],
+ 'product_skus': [],
+ }
+ sub_options[wh.id]['product_ids'].append(p_id)
+ sub_options[wh.id]['product_skus'].append(p_vals['sku'])
+ del buy_qty[p_id]
+
+ if not buy_qty:
+ # item_details can fulfil all items.
+ # this is good!!
+ order_fake.warehouse_id = primary_wh
+ return {'warehouse_id': primary_wh.id, 'date_planned': primary_wh_date_planned, 'sub_options': sub_options}
+
+ # warehouses cannot fulfil all requested items!!
+ order_fake.warehouse_id = primary_wh
+ return {'warehouse_id': primary_wh.id}
+
+ # nobody has stock!
+ primary_wh = self._find_closest_warehouse_by_partner(warehouses, order_fake.partner_shipping_id)
+ order_fake.warehouse_id = primary_wh
+ return {'warehouse_id': primary_wh.id}
+
+ def _is_in_stock(self, p_stock, buy_qty):
+ return p_stock['real_qty_available'] >= buy_qty
+
+ def _find_closest_warehouse_by_partner(self, warehouses, partner):
+ if not partner.date_localization:
+ partner.geo_localize()
+ return self._find_closest_warehouse(warehouses, partner.partner_latitude, partner.partner_longitude)
+
+ def _find_closest_warehouse(self, warehouses, latitude, longitude):
+ distances = {distance(latitude, longitude, wh.partner_id.partner_latitude, wh.partner_id.partner_longitude): wh.id for wh in warehouses}
+ wh_id = distances[min(distances)]
+ return warehouses.filtered(lambda wh: wh.id == wh_id)
+
+ def _sort_warehouses_by_partner(self, warehouses, partner):
+ if not partner.date_localization:
+ partner.geo_localize()
+ return self._sort_warehouses(warehouses, partner.partner_latitude, partner.partner_longitude)
+
+ def _sort_warehouses(self, warehouses, latitude, longitude):
+ distances = {distance(latitude, longitude, wh.partner_id.partner_latitude, wh.partner_id.partner_longitude): wh.id for wh in warehouses}
+ wh_distances = sorted(distances)
+ return [warehouses.filtered(lambda wh: wh.id == distances[d]) for d in wh_distances]
+
+ def _next_warehouse_shipping_date(self, warehouse):
+ return fields.Datetime.to_string(warehouse.shipping_calendar_id.plan_days(0.01,
+ fields.Datetime.from_string(fields.Datetime.now()),
+ compute_leaves=True))
+
+ @api.model
+ def _fetch_product_stock(self, warehouses, products):
+ output = {}
+ for wh in warehouses:
+ products = products.with_context({'location': wh.lot_stock_id.id})
+ output[wh.id] = {
+ p.id: {
+ 'qty_available': p.qty_available,
+ 'virtual_available': p.virtual_available,
+ 'incoming_qty': p.incoming_qty,
+ 'outgoing_qty': p.outgoing_qty,
+ 'real_qty_available': p.qty_available - p.outgoing_qty,
+ 'sku': p.default_code
+ } for p in products}
+ return output
+
+ def generate_shipping_options(self, base_option, order_fake):
+ # generate a carrier_id, amount, requested_date (promise date)
+ # if base_option['carrier_id'] then that is the only carrier we want to collect rates for.
+ carriers = self.get_shipping_carriers(base_option.get('carrier_id'))
+
+ if not base_option.get('sub_options'):
+ options = []
+ # this locic comes from "delivery.models.sale_order.SaleOrder"
+ for carrier in carriers:
+ option = self._generate_shipping_carrier_option(base_option, order_fake, carrier)
+ if option:
+ options.append(option)
+
+ return options
+ else:
+ warehouses = self.get_warehouses()
+ original_order_fake_warehouse_id = order_fake.warehouse_id
+ original_order_fake_order_line = order_fake.order_line
+ options = []
+ for carrier in carriers:
+ new_base_option = deepcopy(base_option)
+ has_error = False
+ for wh_id, wh_vals in base_option['sub_options'].items():
+ if has_error:
+ continue
+ order_fake.warehouse_id = warehouses.filtered(lambda wh: wh.id == wh_id)
+ order_fake.order_line = FakeCollection(filter(lambda line: line.product_id.id in wh_vals['product_ids'], original_order_fake_order_line))
+ wh_option = self._generate_shipping_carrier_option(wh_vals, order_fake, carrier)
+ if not wh_option:
+ has_error = True
+ else:
+ new_base_option['sub_options'][wh_id] = wh_option
+
+ if has_error:
+ continue
+ # now that we've collected, we can roll up some details.
+
+ new_base_option['carrier_id'] = carrier.id
+ new_base_option['shipping_price'] = self._get_shipping_price_for_options(new_base_option['sub_options'])
+ new_base_option['requested_date'] = self._get_max_requested_date(new_base_option['sub_options'])
+ new_base_option['transit_days'] = self._get_max_transit_days(new_base_option['sub_options'])
+ options.append(new_base_option)
+
+ #restore values in case more processing occurs
+ order_fake.warehouse_id = original_order_fake_warehouse_id
+ order_fake.order_line = original_order_fake_order_line
+ if not options:
+ options.append(base_option)
+ return options
+
+ def _get_shipping_price_for_options(self, sub_options):
+ return sum(wh_option.get('shipping_price', 0.0) for wh_option in sub_options.values())
+
+ def _get_max_requested_date(self, sub_options):
+ max_requested_date = None
+ for option in sub_options.values():
+ requested_date = option.get('requested_date')
+ if requested_date and isinstance(requested_date, str):
+ requested_date = fields.Datetime.from_string(requested_date)
+ if requested_date and not max_requested_date:
+ max_requested_date = requested_date
+ elif requested_date:
+ if requested_date > max_requested_date:
+ max_requested_date = requested_date
+ if max_requested_date:
+ return fields.Datetime.to_string(max_requested_date)
+ return max_requested_date
+
+ def _get_max_transit_days(self, sub_options):
+ return max(wh_option.get('transit_days', 0) for wh_option in sub_options.values())
+
+ def _generate_shipping_carrier_option(self, base_option, order_fake, carrier):
+ # some carriers look at the order carrier_id
+ order_fake.carrier_id = carrier
+
+ # this logic comes from "delivery.models.sale_order.SaleOrder"
+ try:
+ date_delivered = None
+ transit_days = 0
+ if carrier.delivery_type not in ['fixed', 'base_on_rule']:
+ result = carrier.get_shipping_price_for_plan(order_fake, base_option.get('date_planned'))
+ if result:
+ price_unit, transit_days, date_delivered = result[0]
+ else:
+ price_unit = carrier.get_shipping_price_from_so(order_fake)[0]
+ else:
+ carrier = carrier.verify_carrier(order_fake.partner_shipping_id)
+ if not carrier:
+ return None
+ #price_unit = carrier.get_price_available(order)
+ # ^^ ends up calling carrier.get_price_from_picking(order_total, weight, volume, quantity)
+ order_total = order_fake.amount_total
+ weight = sum((line.product_id.weight or 0.0) * line.product_uom_qty for line in order_fake.order_line if line.product_id.type == 'product')
+ volume = sum((line.product_id.volume or 0.0) * line.product_uom_qty for line in order_fake.order_line if line.product_id.type == 'product')
+ quantity = sum((line.product_uom_qty or 0.0) for line in order_fake.order_line if line.product_id.type == 'product')
+ price_unit = carrier.get_price_from_picking(order_total, weight, volume, quantity)
+ if order_fake.company_id.currency_id.id != order_fake.pricelist_id.currency_id.id:
+ price_unit = order_fake.company_id.currency_id.with_context(date=order_fake.date_order).compute(price_unit, order_fake.pricelist_id.currency_id)
+
+ final_price = float(price_unit) * (1.0 + (float(carrier.margin) / 100.0))
+ option = deepcopy(base_option)
+ option['carrier_id'] = carrier.id
+ option['shipping_price'] = final_price
+ option['requested_date'] = fields.Datetime.to_string(date_delivered) if date_delivered and isinstance(date_delivered, datetime) else date_delivered
+ option['transit_days'] = transit_days
+ return option
+ except Exception as e:
+ # _logger.warn("Exception collecting carrier rates: " + str(e))
+ # _logger.exception(e)
+ pass
+
+ return None
+
+
+
+class SaleOrderPlanningOption(models.TransientModel):
+ _name = 'sale.order.planning.option'
+ _description = 'Order Planning Option'
+
+ def create(self, values):
+ if 'sub_options' in values and not isinstance(values['sub_options'], str):
+ values['sub_options'] = dumps(values['sub_options'])
+ return super(SaleOrderPlanningOption, self).create(values)
+
+ @api.multi
+ def _compute_sub_options_text(self):
+ for option in self:
+ sub_options = option.sub_options
+ if sub_options and not isinstance(sub_options, dict):
+ sub_options = loads(sub_options)
+ if not isinstance(sub_options, dict):
+ option.sub_options_text = ''
+ continue
+
+ line = ''
+ for wh_id, wh_option in sub_options.items():
+ product_skus = wh_option.get('product_skus', [])
+ product_skus = ', '.join(product_skus)
+ requested_date = wh_option.get('requested_date', '')
+ if requested_date:
+ requested_date = self._context_datetime(requested_date)
+ date_planned = wh_option.get('date_planned', '')
+ if date_planned:
+ date_planned = self._context_datetime(date_planned)
+ shipping_price = float(wh_option.get('shipping_price', 0.0))
+ transit_days = int(wh_option.get('transit_days', 0))
+
+ line += """WH %d :: %s
+ Date Planned: %s
+ Requested Date: %s
+ Transit Days: %d
+ Shipping Price: %.2f
+
+""" % (int(wh_id), product_skus, date_planned, requested_date, transit_days, shipping_price)
+
+ option.sub_options_text = line
+
+ plan_id = fields.Many2one('sale.order.make.plan', 'Plan', ondelete='cascade')
+ warehouse_id = fields.Many2one('stock.warehouse', string='Warehouse')
+ date_planned = fields.Datetime('Planned Date')
+ requested_date = fields.Datetime('Requested Date')
+ carrier_id = fields.Many2one('delivery.carrier', string='Carrier')
+ transit_days = fields.Integer('Transit Days')
+ shipping_price = fields.Float('Shipping Price')
+ sub_options = fields.Text('Sub Options JSON')
+ sub_options_text = fields.Text('Sub Options', compute=_compute_sub_options_text)
+
+ @api.multi
+ def select_plan(self):
+ for option in self:
+ option.plan_id.select_option(option)
+ return
+
+ def _context_datetime(self, date):
+ date = fields.Datetime.from_string(date)
+ date = fields.Datetime.context_timestamp(self, date)
+ return fields.Datetime.to_string(date)
diff --git a/sale_planner/wizard/order_planner_views.xml b/sale_planner/wizard/order_planner_views.xml
new file mode 100644
index 00000000..bf30f96d
--- /dev/null
+++ b/sale_planner/wizard/order_planner_views.xml
@@ -0,0 +1,40 @@
+
+
+
+ view.plan.sale.order
+ sale.order.make.plan
+ form
+
+
+
+
+
+ Plan Sale Order
+ sale.order.make.plan
+ form
+ form
+
+ new
+
+
\ No newline at end of file