diff --git a/stock_delivery_planner/__init__.py b/stock_delivery_planner/__init__.py new file mode 100644 index 00000000..c7120225 --- /dev/null +++ b/stock_delivery_planner/__init__.py @@ -0,0 +1,4 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import models +from . import wizard diff --git a/stock_delivery_planner/__manifest__.py b/stock_delivery_planner/__manifest__.py new file mode 100644 index 00000000..ef9f881b --- /dev/null +++ b/stock_delivery_planner/__manifest__.py @@ -0,0 +1,29 @@ +{ + 'name': 'Stock Delivery Planner', + 'summary': 'Get rates and choose carrier for delivery.', + 'version': '16.0.1.1.0', + 'author': "Hibou Corp.", + 'category': 'Warehouse', + 'license': 'OPL-1', + 'website': "https://hibou.io", + 'description': """ +Stock Delivery Planner +====================== + +Re-rate deliveries at packing time to find lowest-priced delivery method that still meets the expected delivery date. + +""", + 'depends': [ + 'delivery_hibou', + 'sale_planner', + 'stock', + ], + 'data': [ + 'security/ir.model.access.csv', + 'views/res_config_settings_views.xml', + 'views/stock_views.xml', + 'wizard/stock_delivery_planner_views.xml', + ], + 'auto_install': False, + 'installable': True, +} diff --git a/stock_delivery_planner/i18n/es.po b/stock_delivery_planner/i18n/es.po new file mode 100644 index 00000000..9bb03fe8 --- /dev/null +++ b/stock_delivery_planner/i18n/es.po @@ -0,0 +1,203 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_delivery_planner +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-10-12 01:47+0000\n" +"PO-Revision-Date: 2021-10-12 01:47+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: stock_delivery_planner +#: model_terms:ir.ui.view,arch_db:stock_delivery_planner.res_config_settings_view_form +msgid "" +"Add a carrier that represents the 'base rate' for a carrier's type.
\n" +" For example, you should add 1 FedEx carrier here and let us build up the\n" +" rates for your other FedEx shipping methods." +msgstr "" +"Agregar un transportista que representa la 'tarifa base' para el tipo de transportista
\n" +" Por ejemplo, usted debe agregar un transportista de FedEx aquí y dejarnos acumular \n" +" tarifas para sus otros metodos de envío de FedEx" + +#. module: stock_delivery_planner +#: model:ir.model,name:stock_delivery_planner.model_res_config_settings +msgid "Config Settings" +msgstr "Opciones de configuración" + +#. module: stock_delivery_planner +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner__create_uid +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner_option__create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: stock_delivery_planner +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner__create_date +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner_option__create_date +msgid "Created on" +msgstr "Creado en" + +#. module: stock_delivery_planner +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner_option__days_different +msgid "Days Different" +msgstr "Días Diferentes" + +#. module: stock_delivery_planner +#: model:ir.model.fields.selection,name:stock_delivery_planner.selection__stock_delivery_planner_option__selection__deselected +msgid "De-selected" +msgstr "Desmarcar Selección" + +#. module: stock_delivery_planner +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner_option__carrier_id +msgid "Delivery Method" +msgstr "Métodos de Envío" + +#. module: stock_delivery_planner +#: model_terms:ir.ui.view,arch_db:stock_delivery_planner.res_config_settings_view_form +msgid "Delivery Planner" +msgstr "Planificador de Entregas" + +#. module: stock_delivery_planner +#: model:ir.model.fields,field_description:stock_delivery_planner.field_res_config_settings__stock_delivery_planner_base_carrier_ids +msgid "Delivery Planner Base Carriers" +msgstr "Planificador de Entregas de Transportistas Base" + +#. module: stock_delivery_planner +#: code:addons/stock_delivery_planner/wizard/stock_delivery_planner.py:0 +#, python-format +msgid "Delivery Rate Planner" +msgstr "Planificador de Tarifas de Entrega" + +#. module: stock_delivery_planner +#: model_terms:ir.ui.view,arch_db:stock_delivery_planner.view_stock_delivery_planner +msgid "Discard" +msgstr "Descartar" + +#. module: stock_delivery_planner +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner__display_name +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner_option__display_name +msgid "Display Name" +msgstr "Nombre para mostrar" + +#. module: stock_delivery_planner +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner_option__requested_date +msgid "Expected Delivery Date" +msgstr "Fecha de Entrega Esperada" + +#. module: stock_delivery_planner +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner__id +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner_option__id +msgid "ID" +msgstr "ID" + +#. module: stock_delivery_planner +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner____last_update +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner_option____last_update +msgid "Last Modified on" +msgstr "Última Modificación el" + +#. module: stock_delivery_planner +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner__write_uid +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner_option__write_uid +msgid "Last Updated by" +msgstr "Última Actualización por" + +#. module: stock_delivery_planner +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner__write_date +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner_option__write_date +msgid "Last Updated on" +msgstr "Última Actualización el" + +#. module: stock_delivery_planner +#: model:ir.model.fields.selection,name:stock_delivery_planner.selection__stock_delivery_planner_option__selection__ +msgid "None" +msgstr "Ninguno" + +#. module: stock_delivery_planner +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner__plan_option_ids +msgid "Options" +msgstr "Opciones" + +#. module: stock_delivery_planner +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner_option__package_id +msgid "Package" +msgstr "Paquete" + +#. module: stock_delivery_planner +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner__packages_planned +msgid "Packages Planned" +msgstr "Paquetes Planificadas" + +#. module: stock_delivery_planner +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner_option__plan_id +#: model_terms:ir.ui.view,arch_db:stock_delivery_planner.view_stock_delivery_planner +msgid "Plan" +msgstr "Planificar" + +#. module: stock_delivery_planner +#: code:addons/stock_delivery_planner/models/stock.py:0 +#, python-format +msgid "Plan Delivery" +msgstr "Planificar Entrega" + +#. module: stock_delivery_planner +#: model_terms:ir.ui.view,arch_db:stock_delivery_planner.view_picking_form +msgid "Plan Shipment" +msgstr "Planificar Envío" + +#. module: stock_delivery_planner +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner_option__date_planned +msgid "Planned Date" +msgstr "Fecha Planificada" + +#. module: stock_delivery_planner +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner_option__sale_requested_date +msgid "Sale Order Delivery Date" +msgstr "Fecha de Entrega del Pedido de Venta" + +#. module: stock_delivery_planner +#: model_terms:ir.ui.view,arch_db:stock_delivery_planner.view_stock_delivery_planner +msgid "Select" +msgstr "Seleccionar" + +#. module: stock_delivery_planner +#: model:ir.model.fields.selection,name:stock_delivery_planner.selection__stock_delivery_planner_option__selection__selected +msgid "Selected" +msgstr "Seleccionado" + +#. module: stock_delivery_planner +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner_option__selection +msgid "Selection" +msgstr "Selección" + +#. module: stock_delivery_planner +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner_option__price +msgid "Shipping Price" +msgstr "Precio de Envío" + +#. module: stock_delivery_planner +#: model:ir.model,name:stock_delivery_planner.model_stock_delivery_planner_option +msgid "Stock Delivery Planner Option" +msgstr "Opcion de Planificador de Entrega de Stock" + +#. module: stock_delivery_planner +#: model:ir.model,name:stock_delivery_planner.model_stock_delivery_planner +msgid "Stock Delivery Planner Wizard" +msgstr "Asistente de Planificador de Entrega de Stock" + +#. module: stock_delivery_planner +#: model:ir.model,name:stock_delivery_planner.model_stock_picking +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner__picking_id +msgid "Transfer" +msgstr "Transferir" + +#. module: stock_delivery_planner +#: model:ir.model.fields,field_description:stock_delivery_planner.field_stock_delivery_planner_option__transit_days +msgid "Transit Days" +msgstr "Días de Tránsito" diff --git a/stock_delivery_planner/models/__init__.py b/stock_delivery_planner/models/__init__.py new file mode 100644 index 00000000..03ea18f8 --- /dev/null +++ b/stock_delivery_planner/models/__init__.py @@ -0,0 +1,4 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import res_config_settings +from . import stock diff --git a/stock_delivery_planner/models/res_config_settings.py b/stock_delivery_planner/models/res_config_settings.py new file mode 100644 index 00000000..8e581549 --- /dev/null +++ b/stock_delivery_planner/models/res_config_settings.py @@ -0,0 +1,43 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import api, fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + stock_delivery_planner_base_carrier_ids = fields.Many2many('delivery.carrier', + string='Delivery Planner Base Carriers', + compute='_compute_stock_delivery_planner_base_carrier_ids', + inverse='_inverse_stock_delivery_planner_base_carrier_ids') + + def _compute_stock_delivery_planner_base_carrier_ids_ids(self): + # used to compute the field and update in get_values + get_param = self.env['ir.config_parameter'].sudo().get_param + company_id = self.company_id.id or self.env.company.id + carrier_ids = get_param('stock.delivery.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 + + def _compute_stock_delivery_planner_base_carrier_ids(self): + for settings in self: + carrier_ids = settings._compute_stock_delivery_planner_base_carrier_ids_ids() + carriers = self.env['delivery.carrier'].browse(carrier_ids) + settings.stock_delivery_planner_base_carrier_ids = carriers + + def _inverse_stock_delivery_planner_base_carrier_ids(self): + set_param = self.env['ir.config_parameter'].sudo().set_param + company_id = self.company_id.id or self.env.company.id + for settings in self: + carrier_ids = ','.join(str(i) for i in settings.stock_delivery_planner_base_carrier_ids.ids) + set_param('stock.delivery.planner.carrier_ids.%s' % (company_id, ), carrier_ids) + + @api.model + def get_values(self): + res = super(ResConfigSettings, self).get_values() + res['stock_delivery_planner_base_carrier_ids'] = [(6, 0, self._compute_stock_delivery_planner_base_carrier_ids_ids())] + return res diff --git a/stock_delivery_planner/models/stock.py b/stock_delivery_planner/models/stock.py new file mode 100644 index 00000000..5cf43780 --- /dev/null +++ b/stock_delivery_planner/models/stock.py @@ -0,0 +1,58 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import fields, models, tools, _ +from odoo.exceptions import UserError + + +class StockPicking(models.Model): + _inherit = 'stock.picking' + + def action_plan_delivery(self): + context = dict(self.env.context or {}) + planner_model = self.env['stock.delivery.planner'] + for picking in self: + planner = planner_model.create({ + 'picking_id': picking.id, + }) + return { + 'name': _('Plan Delivery'), + 'type': 'ir.actions.act_window', + 'res_model': 'stock.delivery.planner', + 'res_id': planner.id, + 'view_type': 'form', + 'view_mode': 'form', + 'view_id': self.env.ref('stock_delivery_planner.view_stock_delivery_planner').id, + 'target': 'new', + 'context': context, + } + + # def get_shipping_carriers(self, carrier_id=None, domain=None): + def get_shipping_carriers(self): + Carrier = self.env['delivery.carrier'].sudo() + # if carrier_id: + # return Carrier.browse(carrier_id) + # + # if domain: + # if not isinstance(domain, (list, tuple)): + # domain = tools.safe_eval(domain) + # else: + domain = [] + + if self.env.context.get('carrier_domain'): + # potential bug here if this is textual + domain.extend(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.extend(tools.safe_eval(irconfig_parameter.get_param('sale.order.planner.carrier_domain'))) + + return Carrier.search(domain) + + +class Warehouse(models.Model): + _inherit = 'stock.warehouse' + + delivery_planner_carrier_ids = fields.Many2many('delivery.carrier', + relation='delivery_planner_carrier_wh_rel', + string='Delivery Planner Base Carriers', + help='Overrides the global carriers.') diff --git a/stock_delivery_planner/security/ir.model.access.csv b/stock_delivery_planner/security/ir.model.access.csv new file mode 100644 index 00000000..204f355c --- /dev/null +++ b/stock_delivery_planner/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_stock_delivery_planner_user,stock.delivery.planner user,model_stock_delivery_planner,stock.group_stock_user,1,1,1,0 +access_stock_delivery_planner_option_user,stock.delivery.planner.option user,model_stock_delivery_planner_option,stock.group_stock_user,1,1,1,0 \ No newline at end of file diff --git a/stock_delivery_planner/tests/__init__.py b/stock_delivery_planner/tests/__init__.py new file mode 100644 index 00000000..83aa212b --- /dev/null +++ b/stock_delivery_planner/tests/__init__.py @@ -0,0 +1 @@ +from . import test_stock_delivery_planner diff --git a/stock_delivery_planner/tests/test_stock_delivery_planner.py b/stock_delivery_planner/tests/test_stock_delivery_planner.py new file mode 100644 index 00000000..81563f62 --- /dev/null +++ b/stock_delivery_planner/tests/test_stock_delivery_planner.py @@ -0,0 +1,144 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import fields +from odoo.tests.common import Form, TransactionCase + + +class TestStockDeliveryPlanner(TransactionCase): + def setUp(self): + """ + NOTE: demo Fedex credentials may not work. Test credentials may not return all service types. + Configuring production credentials may be necessary for this test to run. + """ + super(TestStockDeliveryPlanner, self).setUp() + try: + self.fedex_ground = self.browse_ref('delivery_fedex.delivery_carrier_fedex_us') + except ValueError: + self.skipTest('FedEx Shipping Connector demo data is required to run this test.') + self.env['ir.config_parameter'].sudo().set_param('sale.order.planner.carrier_domain', + "[('id', 'in', (%d,))]" % self.fedex_ground.id) + self.env['ir.config_parameter'].sudo().set_param('stock.delivery.planner.carrier_ids.%s' % (self.env.company.id, ), + "%d" % self.fedex_ground.id) + # Does it make sense to set default package in fedex_rate_shipment_multi + # instead of relying on a correctly configured delivery method? + self.fedex_package = self.browse_ref('delivery_fedex.fedex_packaging_FEDEX_25KG_BOX') + self.default_package = self.browse_ref('delivery_fedex.fedex_packaging_YOUR_PACKAGING') + self.fedex_ground.fedex_default_packaging_id = self.default_package + # PRIORITY_OVERNIGHT might not be available depending on time of day? + self.fedex_ground.fedex_service_type = 'FEDEX_GROUND' + + self.fedex_2_day = self.fedex_ground.copy() + self.fedex_2_day.name = 'Test FedEx Delivery' + self.fedex_2_day.fedex_service_type = 'FEDEX_2_DAY' + + delivery_calendar = self.env['resource.calendar'].create({ + 'name': 'Test Delivery Calendar', + 'tz': 'US/Central', + 'attendance_ids': [ + (0, 0, {'name': 'Monday', 'dayofweek': '0', 'hour_from': 0, 'hour_to': 24, 'day_period': 'morning'}), + (0, 0, {'name': 'Tuesday', 'dayofweek': '1', 'hour_from': 0, 'hour_to': 24, 'day_period': 'morning'}), + (0, 0, {'name': 'Wednesday', 'dayofweek': '2', 'hour_from': 0, 'hour_to': 24, 'day_period': 'morning'}), + (0, 0, {'name': 'Thursday', 'dayofweek': '3', 'hour_from': 0, 'hour_to': 24, 'day_period': 'morning'}), + (0, 0, {'name': 'Friday', 'dayofweek': '4', 'hour_from': 0, 'hour_to': 24, 'day_period': 'morning'}), + ], + }) + self.fedex_ground.delivery_calendar_id = delivery_calendar + self.fedex_2_day.delivery_calendar_id = delivery_calendar + self.env['stock.warehouse'].search([]).write({'shipping_calendar_id': delivery_calendar.id}) + + # needs a valid address for sender and recipient + 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_ia = self.env['res.country.state'].search([('name', '=', 'Iowa')], limit=1) + self.env.user.company_id.partner_id.write({ + 'street': '321 1st St', + 'city': 'Ames', + 'state_id': self.state_ia.id, + 'zip': '50010', + 'country_id': self.country_usa.id, + }) + pricelist = self.browse_ref('product.list0').copy({ + 'currency_id': self.env.ref('base.USD').id, + 'sequence': 999, + }) + self.partner = self.env['res.partner'].create({ + 'name': 'Test Customer', + 'street': '1234 Test Street', + 'city': 'Marysville', + 'state_id': self.state_wa.id, + 'zip': '98270', + 'country_id': self.country_usa.id, + 'property_product_pricelist': pricelist.id, + 'is_company': True, + # 'partner_latitude': 48.05636, + # 'partner_longitude': -122.14922, + }) + + # self.product = self.browse_ref('product.product_product_27') # [FURN_8855] Drawer + # self.product.weight = 5.0 + # self.product.volume = 0.1 + self.env['ir.config_parameter'].sudo().set_param('product.weight_in_lbs', '1') + self.product = self.env['product.product'].create({ + 'name': 'Test Ship Product', + 'type': 'product', + 'weight': 1.0, + }) + self.env['stock.change.product.qty'].create({ + 'product_id': self.product.id, + 'product_tmpl_id': self.product.product_tmpl_id.id, + 'new_quantity': 10.0, + }).change_product_qty() + + so = Form(self.env['sale.order']) + so.partner_id = self.partner + with so.order_line.new() as line: + line.product_id = self.product + line.product_uom_qty = 5.0 + line.price_unit = 100.0 + self.sale_order = so.save() + + order_plan_action = self.sale_order.action_planorder() + order_plan = self.env[order_plan_action['res_model']].browse(order_plan_action['res_id']) + order_plan.planning_option_ids.filtered(lambda o: o.carrier_id == self.fedex_ground).select_plan() + + self.sale_order.action_confirm() + self.picking = self.sale_order.picking_ids + + def test_00_test_one_package(self): + """Delivery is packed in one package""" + self.assertTrue(self.sale_order.requested_date, 'Order has not been planned') + self.assertEqual(len(self.picking), 1) + grp_pack = self.env.ref('stock.group_tracking_lot') + self.env.user.write({'groups_id': [(4, grp_pack.id)]}) + + self.assertEqual(self.picking.carrier_id, self.fedex_ground, 'Carrier did not carry over to Delivery Order') + self.assertEqual(self.picking.weight, 5.0) + self.assertEqual(self.picking.shipping_weight, 0.0) + + self.picking.move_line_ids.filtered(lambda ml: ml.product_id == self.product).qty_done = 5.0 + packing_action = self.picking.action_put_in_pack() + packing_wizard = Form(self.env[packing_action['res_model']].with_context(packing_action['context'])) + packing_wizard.delivery_package_type_id = self.fedex_package + choose_delivery_package = packing_wizard.save() + choose_delivery_package.action_put_in_pack() + self.assertEqual(self.picking.shipping_weight, 5.0) + + action = self.picking.action_plan_delivery() + planner = self.env[action['res_model']].browse(action['res_id']) + + self.assertEqual(planner.picking_id, self.picking) + self.assertGreater(len(planner.plan_option_ids), 1) + + plan_option = planner.plan_option_ids.filtered(lambda o: o.carrier_id == self.fedex_2_day) + self.assertEqual(len(plan_option), 1) + self.assertGreater(plan_option.price, 0.0) + self.assertEqual(plan_option.date_planned.date(), fields.Date().today()) + self.assertTrue(plan_option.requested_date) + self.assertEqual(plan_option.transit_days, 2) + self.assertEqual(plan_option.sale_requested_date, self.sale_order.requested_date) + # Order Planner expects to ship tomorrow: we are shipping a day early and using + # 2-day shipping instead of 3, giving us 2 days difference + self.assertEqual(plan_option.days_different, -2.0) + + plan_option.select_plan() + self.assertEqual(self.picking.carrier_id, self.fedex_2_day) diff --git a/stock_delivery_planner/views/res_config_settings_views.xml b/stock_delivery_planner/views/res_config_settings_views.xml new file mode 100644 index 00000000..6420b7b7 --- /dev/null +++ b/stock_delivery_planner/views/res_config_settings_views.xml @@ -0,0 +1,30 @@ + + + + + res.config.settings.view.form.inherit + res.config.settings + + + + +

Delivery Planner

+
+
+
+
+
+
+
+ + + + + diff --git a/stock_delivery_planner/views/stock_views.xml b/stock_delivery_planner/views/stock_views.xml new file mode 100644 index 00000000..137477e2 --- /dev/null +++ b/stock_delivery_planner/views/stock_views.xml @@ -0,0 +1,26 @@ + + + + + stock.picking.form.inherit.delivery.planner + stock.picking + + + +