diff --git a/sale_planner/__init__.py b/sale_planner/__init__.py new file mode 100644 index 00000000..b0eafef5 --- /dev/null +++ b/sale_planner/__init__.py @@ -0,0 +1,4 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import wizard +from . import models diff --git a/sale_planner/__manifest__.py b/sale_planner/__manifest__.py new file mode 100644 index 00000000..ebd0c7ed --- /dev/null +++ b/sale_planner/__manifest__.py @@ -0,0 +1,44 @@ +{ + 'name': 'Sale Order Planner', + 'summary': 'Plans order dates and warehouses.', + 'version': '16.0.2.0.0', + 'author': "Hibou Corp.", + 'category': 'Sale', + 'license': 'OPL-1', + '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', + 'views/res_config_settings_views.xml', + ], + 'auto_install': False, + 'installable': True, +} diff --git a/sale_planner/i18n/es.po b/sale_planner/i18n/es.po new file mode 100644 index 00000000..3b9ab83a --- /dev/null +++ b/sale_planner/i18n/es.po @@ -0,0 +1,237 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_planner +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-10-12 01:39+0000\n" +"PO-Revision-Date: 2021-10-12 01:39+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_policy__always_closest_warehouse +msgid "Always Plan Closest Warehouse" +msgstr "Planifique siempre el almacén más cercano" + +#. module: sale_planner +#: model_terms:ir.ui.view,arch_db:sale_planner.view_plan_sale_order +msgid "Cancel" +msgstr "Cancelar" + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_option__carrier_id +msgid "Carrier" +msgstr "Transportista" + +#. module: sale_planner +#: model:ir.model,name:sale_planner.model_res_partner +msgid "Contact" +msgstr "Contacto" + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_make_plan__create_uid +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_option__create_uid +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_policy__create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_make_plan__create_date +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_option__create_date +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_policy__create_date +msgid "Created on" +msgstr "Creado el" + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_delivery_carrier__delivery_calendar_id +msgid "Delivery Calendar" +msgstr "Calendario de Entregas" + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_policy__carrier_filter_id +msgid "Delivery Carrier Filter" +msgstr "Filtro de Transportista de Entrega" + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_make_plan__display_name +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_option__display_name +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_policy__display_name +msgid "Display Name" +msgstr "Nombre para mostrar" + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_make_plan__id +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_option__id +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_policy__id +msgid "ID" +msgstr "ID" + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_make_plan____last_update +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_option____last_update +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_policy____last_update +msgid "Last Modified on" +msgstr "Última Modificación el" + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_make_plan__write_uid +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_option__write_uid +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_policy__write_uid +msgid "Last Updated by" +msgstr "Última Actualización por" + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_make_plan__write_date +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_option__write_date +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_policy__write_date +msgid "Last Updated on" +msgstr "Última Actualización el" + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_policy__name +msgid "Name" +msgstr "Nombre" + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_make_plan__planning_option_ids +msgid "Options" +msgstr "Opciones" + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_product_category__property_planning_policy_categ_id +#: model:ir.model.fields,field_description:sale_planner.field_product_product__property_planning_policy_id +#: model:ir.model.fields,field_description:sale_planner.field_product_template__property_planning_policy_id +msgid "Order Planner Policy" +msgstr "Política del planificador de pedidos" + +#. module: sale_planner +#: model:ir.model,name:sale_planner.model_sale_order_planning_option +msgid "Order Planning Option" +msgstr "Opciones del planificador de pedidos" + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_option__plan_id +#: model_terms:ir.ui.view,arch_db:sale_planner.view_order_form_planner +msgid "Plan" +msgstr "Planifica" + +#. module: sale_planner +#: model:ir.model,name:sale_planner.model_sale_order_make_plan +msgid "Plan Order" +msgstr "Planificar orden" + +#. module: sale_planner +#: model:ir.actions.act_window,name:sale_planner.action_plan_sale_order +msgid "Plan Sale Order" +msgstr "Planificar Pedido de Venta" + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_option__date_planned +msgid "Planned Date" +msgstr "Fecha Planificada" + +#. module: sale_planner +#: model:ir.model,name:sale_planner.model_product_category +msgid "Product Category" +msgstr "Categoría de producto" + +#. module: sale_planner +#: model:ir.model,name:sale_planner.model_product_template +msgid "Product Template" +msgstr "Plantilla del producto" + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_option__requested_date +msgid "Requested Date" +msgstr "Fecha Solicitada" + +#. module: sale_planner +#: model:ir.model,name:sale_planner.model_resource_calendar +msgid "Resource Working Time" +msgstr "Tiempo de trabajo de recursos" + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_make_plan__order_id +msgid "Sale Order" +msgstr "Pedido de Venta" + +#. module: sale_planner +#: model:ir.model,name:sale_planner.model_sale_order +msgid "Sales Order" +msgstr "Pedido de venta" + +#. module: sale_planner +#: model_terms:ir.ui.view,arch_db:sale_planner.view_plan_sale_order +msgid "Select" +msgstr "Seleccionar" + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_stock_warehouse__shipping_calendar_id +msgid "Shipping Calendar" +msgstr "Calendario de Envíos" + +#. module: sale_planner +#: model:ir.model,name:sale_planner.model_delivery_carrier +msgid "Shipping Methods" +msgstr "Métodos de Envío" + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_option__shipping_price +msgid "Shipping Price" +msgstr "Precio de Envío" + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_option__sub_options_text +msgid "Sub Options" +msgstr "Sub opciones" + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_option__sub_options +msgid "Sub Options JSON" +msgstr "Sub opciones JSON" + +#. module: sale_planner +#: model:ir.model.fields,help:sale_planner.field_product_category__property_planning_policy_categ_id +#: model:ir.model.fields,help:sale_planner.field_product_product__property_planning_policy_id +#: model:ir.model.fields,help:sale_planner.field_product_template__property_planning_policy_id +msgid "The Order Planner Policy to use when making a sale order planner." +msgstr "La Política del planificador de pedidos que se debe utilizar al realizar un planificador de pedidos de venta." + +#. module: sale_planner +#: model:ir.model.fields,help:sale_planner.field_delivery_carrier__delivery_calendar_id +msgid "" +"This calendar represents days that the carrier will deliver the package." +msgstr "Este calendario representa los días en que el transportista entregará el paquete." + +#. module: sale_planner +#: model:ir.model.fields,help:sale_planner.field_stock_warehouse__shipping_calendar_id +msgid "This calendar represents shipping availability from the warehouse." +msgstr "Este calendario representa la disponibilidad de envío desde el almacén." + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_option__transit_days +msgid "Transit Days" +msgstr "Días de tránsito" + +#. module: sale_planner +#: model:ir.model,name:sale_planner.model_stock_warehouse +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_option__warehouse_id +msgid "Warehouse" +msgstr "Almacén" + +#. module: sale_planner +#: model:ir.model.fields,field_description:sale_planner.field_sale_order_planning_policy__warehouse_filter_id +msgid "Warehouse Filter" +msgstr "Filtro de Almacen" + +#. module: sale_planner +#: model:ir.model,name:sale_planner.model_sale_order_planning_policy +msgid "sale.order.planning.policy" +msgstr "sale.order.planning.policy" diff --git a/sale_planner/models/__init__.py b/sale_planner/models/__init__.py new file mode 100644 index 00000000..95b4955a --- /dev/null +++ b/sale_planner/models/__init__.py @@ -0,0 +1,10 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import delivery +from . import partner +from . import planning +from . import product +from . import resource +from . import sale +from . import stock +from . import res_config_settings diff --git a/sale_planner/models/delivery.py b/sale_planner/models/delivery.py new file mode 100644 index 00000000..dc66ab81 --- /dev/null +++ b/sale_planner/models/delivery.py @@ -0,0 +1,111 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +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 not self.delivery_calendar_id: + return 0 + + 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..a5c8a39e --- /dev/null +++ b/sale_planner/models/partner.py @@ -0,0 +1,33 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +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..54969669 --- /dev/null +++ b/sale_planner/models/planning.py @@ -0,0 +1,18 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +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..add93077 --- /dev/null +++ b/sale_planner/models/product.py @@ -0,0 +1,23 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +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/res_config_settings.py b/sale_planner/models/res_config_settings.py new file mode 100644 index 00000000..06e2f899 --- /dev/null +++ b/sale_planner/models/res_config_settings.py @@ -0,0 +1,79 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import api, fields, models + + +def sale_planner_warehouse_ids(env, company): + get_param = env['ir.config_parameter'].sudo().get_param + warehouse_ids = get_param('sale.planner.warehouse_ids.%s' % (company.id, )) or [] + if warehouse_ids and isinstance(warehouse_ids, str): + try: + warehouse_ids = [int(i) for i in warehouse_ids.split(',')] + except: + warehouse_ids = [] + return warehouse_ids + + +def sale_planner_carrier_ids(env, company): + get_param = env['ir.config_parameter'].sudo().get_param + carrier_ids = get_param('sale.planner.carrier_ids.%s' % (company.id, )) or [] + if carrier_ids and isinstance(carrier_ids, str): + try: + carrier_ids = [int(c) for c in carrier_ids.split(',')] + except: + carrier_ids = [] + return carrier_ids + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + sale_planner_warehouse_ids = fields.Many2many('stock.warehouse', + string='Sale Order Planner Warehouses', + compute='_compute_sale_planner_warehouse_ids', + inverse='_inverse_sale_planner_warehouse_ids') + sale_planner_carrier_ids = fields.Many2many('delivery.carrier', + string='Sale Order Planner Carriers', + compute='_compute_sale_planner_carrier_ids', + inverse='_inverse_sale_planner_carrier_ids') + + def _compute_sale_planner_warehouse_ids_ids(self): + company = self.company_id or self.env.user.company_id + return sale_planner_warehouse_ids(self.env, company) + + def _compute_sale_planner_carrier_ids_ids(self): + company = self.company_id or self.env.user.company_id + return sale_planner_carrier_ids(self.env, company) + + def _compute_sale_planner_warehouse_ids(self): + for settings in self: + warehouse_ids = settings._compute_sale_planner_warehouse_ids_ids() + warehouses = self.env['stock.warehouse'].browse(warehouse_ids) + settings.sale_planner_warehouse_ids = warehouses + + def _compute_sale_planner_carrier_ids(self): + for settings in self: + carrier_ids = settings._compute_sale_planner_carrier_ids_ids() + carriers = self.env['delivery.carrier'].browse(carrier_ids) + settings.sale_planner_carrier_ids = carriers + + def _inverse_sale_planner_warehouse_ids(self): + set_param = self.env['ir.config_parameter'].sudo().set_param + company_id = self.company_id.id or self.env.user.company_id.id + for settings in self: + warehouse_ids = ','.join(str(i) for i in settings.sale_planner_warehouse_ids.ids) + set_param('sale.planner.warehouse_ids.%s' % (company_id, ), warehouse_ids) + + def _inverse_sale_planner_carrier_ids(self): + set_param = self.env['ir.config_parameter'].sudo().set_param + company_id = self.company_id.id or self.env.user.company_id.id + for settings in self: + carrier_ids = ','.join(str(i) for i in settings.sale_planner_carrier_ids.ids) + set_param('sale.planner.carrier_ids.%s' % (company_id, ), carrier_ids) + + @api.model + def get_values(self): + res = super(ResConfigSettings, self).get_values() + res['sale_planner_warehouse_ids'] = [(6, 0, self._compute_sale_planner_warehouse_ids_ids())] + res['sale_planner_carrier_ids'] = [(6, 0, self._compute_sale_planner_carrier_ids_ids())] + return res diff --git a/sale_planner/models/resource.py b/sale_planner/models/resource.py new file mode 100644 index 00000000..de7895aa --- /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_batch, domain=domain) + else: + get_intervals = self._attendance_intervals_batch + + 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)[False]: + 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))[False]: + 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..919de060 --- /dev/null +++ b/sale_planner/models/sale.py @@ -0,0 +1,17 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +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..10011e6c --- /dev/null +++ b/sale_planner/models/stock.py @@ -0,0 +1,15 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +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.") + sale_planner_carrier_ids = fields.Many2many('delivery.carrier', + relation='sale_planner_carrier_wh_rel', + string='Sale Order Planner Base Carriers', + help='Overrides the global carriers.') 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..8208aa7b --- /dev/null +++ b/sale_planner/tests/__init__.py @@ -0,0 +1,3 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +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..3acc20d5 --- /dev/null +++ b/sale_planner/tests/test_planner.py @@ -0,0 +1,481 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +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.warning(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.warning(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.warning(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/res_config_settings_views.xml b/sale_planner/views/res_config_settings_views.xml new file mode 100644 index 00000000..69b215f7 --- /dev/null +++ b/sale_planner/views/res_config_settings_views.xml @@ -0,0 +1,39 @@ + + + + + res.config.settings.view.form.inherit + res.config.settings + + + + +

Sale Order Planner

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