Initial commit of sale_planner for 11.0

This commit is contained in:
Jared Kipe
2018-05-09 09:27:45 -07:00
parent 09839639e2
commit 5892be202a
14 changed files with 1018 additions and 0 deletions

2
sale_planner/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from . import wizard
from . import models

View File

@@ -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,
}

View File

@@ -0,0 +1,3 @@
from . import sale
from . import stock
from . import delivery

View File

@@ -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))

View File

@@ -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

View File

@@ -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.")

View File

@@ -0,0 +1 @@
from . import test_planner

View File

@@ -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)

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_delivery_carrier_form_calendar" model="ir.ui.view">
<field name="name">delivery.carrier.form.calendar</field>
<field name="model">delivery.carrier</field>
<field name="inherit_id" ref="delivery.view_delivery_carrier_form" />
<field name="arch" type="xml">
<xpath expr="//field[@name='integration_level']" position="after">
<field name="delivery_calendar_id" />
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_order_form_planner" model="ir.ui.view">
<field name="name">sale.order.form.planner</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form" />
<field name="arch" type="xml">
<xpath expr="//header/button[@name='action_confirm']" position="before">
<button name="action_planorder"
type="object"
attrs="{'invisible': [('state', 'not in', ('draft'))]}"
string="Plan"
class="oe_highlight"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_warehouse_shipping_calendar" model="ir.ui.view">
<field name="name">stock.warehouse.shipping.calendar</field>
<field name="model">stock.warehouse</field>
<field name="inherit_id" ref="stock.view_warehouse" />
<field name="arch" type="xml">
<xpath expr="//field[@name='partner_id']" position="after">
<field name="shipping_calendar_id" />
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1 @@
from . import order_planner

View File

@@ -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)

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_plan_sale_order" model="ir.ui.view">
<field name="name">view.plan.sale.order</field>
<field name="model">sale.order.make.plan</field>
<field name="type">form</field>
<field name="arch" type="xml">
<form>
<field name="planning_option_ids">
<tree>
<field name="warehouse_id" />
<field name="date_planned" />
<field name="requested_date" />
<field name="transit_days" />
<field name="carrier_id" />
<field name="shipping_price" />
<field name="sub_options_text" />
<button class="eo_highlight"
name="select_plan"
string="Select"
type="object" />
</tree>
</field>
<footer>
<button class="oe_link"
special="cancel"
string="Cancel" />
</footer>
</form>
</field>
</record>
<record id="action_plan_sale_order" model="ir.actions.act_window">
<field name="name">Plan Sale Order</field>
<field name="res_model">sale.order.make.plan</field>
<field name="view_type">form</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_plan_sale_order" />
<field name="target">new</field>
</record>
</odoo>