diff --git a/.gitmodules b/.gitmodules index ed7e540e..8919258c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -35,3 +35,6 @@ [submodule "external/hibou-oca/stock-logistics-workflow"] path = external/hibou-oca/stock-logistics-workflow url = https://github.com/hibou-io/oca-stock-logistics-workflow.git +[submodule "external/hibou-oca/stock-logistics-warehouse"] + path = external/hibou-oca/stock-logistics-warehouse + url = https://github.com/hibou-io/stock-logistics-warehouse.git diff --git a/account_invoice_margin/README.rst b/account_invoice_margin/README.rst new file mode 100644 index 00000000..a148ac23 --- /dev/null +++ b/account_invoice_margin/README.rst @@ -0,0 +1,28 @@ +****************************** +Hibou - Account Invoice Margin +****************************** + +Include a margin calculation on invoices. + +For more information and add-ons, visit `Hibou.io `_. + + +============= +Main Features +============= + +* Adds computed field `margin` to invoices to give the profitability by calculating the difference between the Unit Price and the Cost. + +.. image:: https://user-images.githubusercontent.com/15882954/45578631-880c0000-b837-11e8-9c4d-d2f15c3c0592.png + :alt: 'Customer Invoice' + :width: 988 + :align: left + + +======= +License +======= + +Please see `LICENSE `_. + +Copyright Hibou Corp. 2018 diff --git a/delivery_hibou/__init__.py b/delivery_hibou/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/delivery_hibou/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/delivery_hibou/__manifest__.py b/delivery_hibou/__manifest__.py new file mode 100644 index 00000000..e53e01b9 --- /dev/null +++ b/delivery_hibou/__manifest__.py @@ -0,0 +1,24 @@ +{ + 'name': 'Delivery Hibou', + 'summary': 'Adds underlying pinnings for things like "RMA Return Labels"', + 'version': '11.0.1.0.0', + 'author': "Hibou Corp.", + 'category': 'Stock', + 'license': 'AGPL-3', + 'images': [], + 'website': "https://hibou.io", + 'description': """ +This is a collection of "typical" carrier needs, and a bridge into Hibou modules like `delivery_partner` and `sale_planner`. +""", + 'depends': [ + 'delivery', + 'delivery_partner', + ], + 'demo': [], + 'data': [ + 'views/delivery_views.xml', + 'views/stock_views.xml', + ], + 'auto_install': False, + 'installable': True, +} diff --git a/delivery_hibou/models/__init__.py b/delivery_hibou/models/__init__.py new file mode 100644 index 00000000..29ce1386 --- /dev/null +++ b/delivery_hibou/models/__init__.py @@ -0,0 +1,2 @@ +from . import delivery +from . import stock diff --git a/delivery_hibou/models/delivery.py b/delivery_hibou/models/delivery.py new file mode 100644 index 00000000..d8b6d41b --- /dev/null +++ b/delivery_hibou/models/delivery.py @@ -0,0 +1,152 @@ +from odoo import fields, models +from odoo.addons.stock.models.stock_move import PROCUREMENT_PRIORITIES + + +class DeliveryCarrier(models.Model): + _inherit = 'delivery.carrier' + + automatic_insurance_value = fields.Float(string='Automatic Insurance Value', + help='Will be used during shipping to determine if the ' + 'picking\'s value warrants insurance being added.') + procurement_priority = fields.Selection(PROCUREMENT_PRIORITIES, + string='Procurement Priority', + help='Priority for this carrier. Will affect pickings ' + 'and procurements related to this carrier.') + + def get_insurance_value(self, order=None, picking=None): + value = 0.0 + if order: + if order.order_line: + value = sum(order.order_line.filtered(lambda l: l.type != 'service').mapped('price_subtotal')) + else: + return value + if picking: + value = picking.declared_value() + if picking.require_insurance == 'no': + value = 0.0 + elif picking.require_insurance == 'auto' and self.automatic_insurance_value and self.automatic_insurance_value > value: + value = 0.0 + return value + + def get_third_party_account(self, order=None, picking=None): + if order and order.shipping_account_id: + return order.shipping_account_id + if picking and picking.shipping_account_id: + return picking.shipping_account_id + return None + + def get_order_name(self, order=None, picking=None): + if order: + return order.name + if picking: + if picking.sale_id: + return picking.sale_id.name # + ' - ' + picking.name + return picking.name + return '' + + def get_attn(self, order=None, picking=None): + if order: + return order.client_order_ref + if picking and picking.sale_id: + return picking.sale_id.client_order_ref + # If Picking has a reference, decide what it is. + return False + + def _classify_picking(self, picking): + if picking.picking_type_id.code == 'incoming' and picking.location_id.usage == 'supplier' and picking.location_dest_id.usage == 'customer': + return 'dropship' + elif picking.picking_type_id.code == 'incoming' and picking.location_id.usage == 'customer' and picking.location_dest_id.usage == 'supplier': + return 'dropship_in' + elif picking.picking_type_id.code == 'incoming': + return 'in' + return 'out' + + # Shipper Company + + def get_shipper_company(self, order=None, picking=None): + """ + Shipper Company: The `res.partner` that provides the name of where the shipment is coming from. + """ + if order: + return order.company_id.partner_id + if picking: + return getattr(self, ('_get_shipper_company_%s' % (self._classify_picking(picking),)), + self._get_shipper_company_out)(picking) + return None + + def _get_shipper_company_dropship(self, picking): + return picking.company_id.partner_id + + def _get_shipper_company_dropship_in(self, picking): + return picking.company_id.partner_id + + def _get_shipper_company_in(self, picking): + return picking.company_id.partner_id + + def _get_shipper_company_out(self, picking): + return picking.company_id.partner_id + + # Shipper Warehouse + + def get_shipper_warehouse(self, order=None, picking=None): + """ + Shipper Warehouse: The `res.partner` that is basically the physical address a shipment is coming from. + """ + if order: + return order.warehouse_id.partner_id + if picking: + return getattr(self, ('_get_shipper_warehouse_%s' % (self._classify_picking(picking),)), + self._get_shipper_warehouse_out)(picking) + return None + + def _get_shipper_warehouse_dropship(self, picking): + return picking.partner_id + + def _get_shipper_warehouse_dropship_in(self, picking): + if picking.sale_id: + picking.sale_id.partner_shipping_id + return self._get_shipper_warehouse_dropship_in_no_sale(picking) + + def _get_shipper_warehouse_dropship_in_no_sale(self, picking): + return picking.company_id.partner_id + + def _get_shipper_warehouse_in(self, picking): + return picking.partner_id + + def _get_shipper_warehouse_out(self, picking): + return picking.picking_type_id.warehouse_id.partner_id + + # Recipient + + def get_recipient(self, order=None, picking=None): + """ + Recipient: The `res.partner` receiving the shipment. + """ + if order: + return order.partner_shipping_id + if picking: + return getattr(self, ('_get_recipient_%s' % (self._classify_picking(picking),)), + self._get_recipient_out)(picking) + return None + + def _get_recipient_dropship(self, picking): + if picking.sale_id: + return picking.sale_id.partner_shipping_id + return picking.sale_id.partner_shipping_id + + def _get_recipient_dropship_no_sale(self, picking): + return picking.company_id.partner_id + + def _get_recipient_dropship_in(self, picking): + return picking.picking_type_id.warehouse_id.partner_id + + def _get_recipient_in(self, picking): + return picking.picking_type_id.warehouse_id.partner_id + + def _get_recipient_out(self, picking): + return picking.partner_id + + + + + diff --git a/delivery_hibou/models/stock.py b/delivery_hibou/models/stock.py new file mode 100644 index 00000000..c1a6a792 --- /dev/null +++ b/delivery_hibou/models/stock.py @@ -0,0 +1,50 @@ +from odoo import api, fields, models + + +class StockPicking(models.Model): + _inherit = 'stock.picking' + + shipping_account_id = fields.Many2one('partner.shipping.account', string='Shipping Account') + require_insurance = fields.Selection([ + ('auto', 'Automatic'), + ('yes', 'Yes'), + ('no', 'No'), + ], string='Require Insurance', default='auto', + help='If your carrier supports it, auto should be calculated off of the "Automatic Insurance Value" field.') + + @api.one + @api.depends('move_lines.priority', 'carrier_id') + def _compute_priority(self): + if self.carrier_id.procurement_priority: + self.priority = self.carrier_id.procurement_priority + else: + super(StockPicking, self)._compute_priority() + + @api.model + def create(self, values): + origin = values.get('origin') + if origin and not values.get('shipping_account_id'): + so = self.env['sale.order'].search([('name', '=', str(origin))], limit=1) + if so and so.shipping_account_id: + values['shipping_account_id'] = so.shipping_account_id.id + + res = super(StockPicking, self).create(values) + return res + + def declared_value(self): + self.ensure_one() + cost = sum([(l.product_id.standard_price * l.qty_done) for l in self.move_line_ids] or [0.0]) + if not cost: + # Assume Full Value + cost = sum([(l.product_id.standard_price * l.product_uom_qty) for l in self.move_lines] or [0.0]) + return cost + + + +class StockMove(models.Model): + _inherit = 'stock.move' + + def _prepare_procurement_values(self): + res = super(StockMove, self)._prepare_procurement_values() + res['priority'] = self.picking_id.priority or self.priority + return res diff --git a/delivery_hibou/tests/__init__.py b/delivery_hibou/tests/__init__.py new file mode 100644 index 00000000..d4e6373e --- /dev/null +++ b/delivery_hibou/tests/__init__.py @@ -0,0 +1 @@ +from . import test_delivery_hibou diff --git a/delivery_hibou/tests/test_delivery_hibou.py b/delivery_hibou/tests/test_delivery_hibou.py new file mode 100644 index 00000000..68866f45 --- /dev/null +++ b/delivery_hibou/tests/test_delivery_hibou.py @@ -0,0 +1,160 @@ +from odoo.tests import common + + +class TestDeliveryHibou(common.TransactionCase): + + def setUp(self): + super(TestDeliveryHibou, self).setUp() + self.partner = self.env.ref('base.res_partner_address_13') + self.product = self.env.ref('product.product_product_7') + # Create Shipping Account + self.shipping_account = self.env['partner.shipping.account'].create({ + 'name': '123123', + 'delivery_type': 'other', + }) + # Create Carrier + self.delivery_product = self.env['product.product'].create({ + 'name': 'Test Carrier1 Delivery', + 'type': 'service', + }) + self.carrier = self.env['delivery.carrier'].create({ + 'name': 'Test Carrier1', + 'product_id': self.delivery_product.id, + }) + + def test_delivery_hibou(self): + # Assign a new shipping account + self.partner.shipping_account_id = self.shipping_account + + # Assign values to new Carrier + test_insurance_value = 600 + test_procurement_priority = '2' + self.carrier.automatic_insurance_value = test_insurance_value + self.carrier.procurement_priority = test_procurement_priority + + + sale_order = self.env['sale.order'].create({ + 'partner_id': self.partner.id, + 'partner_shipping_id': self.partner.id, + 'partner_invoice_id': self.partner.id, + 'carrier_id': self.carrier.id, + 'shipping_account_id': self.shipping_account.id, + 'order_line': [(0, 0, { + 'product_id': self.product.id, + })] + }) + sale_order.get_delivery_price() + sale_order.set_delivery_line() + sale_order.action_confirm() + # Make sure 3rd party Shipping Account is set. + self.assertEqual(sale_order.shipping_account_id, self.shipping_account) + + self.assertTrue(sale_order.picking_ids) + # Priority coming from Carrier procurement_priority + self.assertEqual(sale_order.picking_ids.priority, test_procurement_priority) + # 3rd party Shipping Account copied from Sale Order + self.assertEqual(sale_order.picking_ids.shipping_account_id, self.shipping_account) + self.assertEqual(sale_order.carrier_id.get_third_party_account(order=sale_order), self.shipping_account) + + # Test attn + test_ref = 'TEST100' + self.assertEqual(sale_order.carrier_id.get_attn(order=sale_order), False) + sale_order.client_order_ref = test_ref + self.assertEqual(sale_order.carrier_id.get_attn(order=sale_order), test_ref) + # The picking should get this ref as well + self.assertEqual(sale_order.picking_ids.carrier_id.get_attn(picking=sale_order.picking_ids), test_ref) + + # Test order_name + self.assertEqual(sale_order.carrier_id.get_order_name(order=sale_order), sale_order.name) + # The picking should get the same 'order_name' + self.assertEqual(sale_order.picking_ids.carrier_id.get_order_name(picking=sale_order.picking_ids), sale_order.name) + + def test_carrier_hibou_out(self): + test_insurance_value = 4000 + self.carrier.automatic_insurance_value = test_insurance_value + + picking_out = self.env.ref('stock.outgoing_shipment_main_warehouse') + self.assertEqual(picking_out.state, 'assigned') + + picking_out.carrier_id = self.carrier + + # This relies heavily on the 'stock' demo data. + # Should only have a single move_line_ids and it should not be done at all. + self.assertEqual(picking_out.move_line_ids.mapped('qty_done'), [0.0]) + self.assertEqual(picking_out.move_line_ids.mapped('product_uom_qty'), [15.0]) + self.assertEqual(picking_out.move_line_ids.mapped('product_id.standard_price'), [3300.0]) + + # The 'value' is assumed to be all of the product value from the initial demand. + self.assertEqual(picking_out.declared_value(), 15.0 * 3300.0) + self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), picking_out.declared_value()) + + # Workflow where user explicitly opts out of insurance on the picking level. + picking_out.require_insurance = 'no' + self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), 0.0) + picking_out.require_insurance = 'auto' + + # Lets choose to only delivery one piece at the moment. + # This does not meet the minimum on the carrier to have insurance value. + picking_out.move_line_ids.qty_done = 1.0 + self.assertEqual(picking_out.declared_value(), 3300.0) + self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), 0.0) + # Workflow where user opts in to insurance. + picking_out.require_insurance = 'yes' + self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), 3300.0) + picking_out.require_insurance = 'auto' + + # Test with picking having 3rd party account. + self.assertEqual(picking_out.carrier_id.get_third_party_account(picking=picking_out), None) + picking_out.shipping_account_id = self.shipping_account + self.assertEqual(picking_out.carrier_id.get_third_party_account(picking=picking_out), self.shipping_account) + + # Shipment Time Methods! + self.assertEqual(picking_out.carrier_id._classify_picking(picking=picking_out), 'out') + self.assertEqual(picking_out.carrier_id.get_shipper_company(picking=picking_out), + picking_out.company_id.partner_id) + self.assertEqual(picking_out.carrier_id.get_shipper_warehouse(picking=picking_out), + picking_out.picking_type_id.warehouse_id.partner_id) + self.assertEqual(picking_out.carrier_id.get_recipient(picking=picking_out), + picking_out.partner_id) + # This picking has no `sale_id` + # Right now ATTN requires a sale_id, which this picking doesn't have (none of the stock ones do) + self.assertEqual(picking_out.carrier_id.get_attn(picking=picking_out), False) + self.assertEqual(picking_out.carrier_id.get_order_name(picking=picking_out), picking_out.name) + + def test_carrier_hibou_in(self): + picking_in = self.env.ref('stock.incomming_shipment1') + self.assertEqual(picking_in.state, 'assigned') + + picking_in.carrier_id = self.carrier + # This relies heavily on the 'stock' demo data. + # Should only have a single move_line_ids and it should not be done at all. + self.assertEqual(picking_in.move_line_ids.mapped('qty_done'), [0.0]) + self.assertEqual(picking_in.move_line_ids.mapped('product_uom_qty'), [35.0]) + self.assertEqual(picking_in.move_line_ids.mapped('product_id.standard_price'), [55.0]) + + self.assertEqual(picking_in.carrier_id._classify_picking(picking=picking_in), 'in') + self.assertEqual(picking_in.carrier_id.get_shipper_company(picking=picking_in), + picking_in.company_id.partner_id) + self.assertEqual(picking_in.carrier_id.get_shipper_warehouse(picking=picking_in), + picking_in.partner_id) + self.assertEqual(picking_in.carrier_id.get_recipient(picking=picking_in), + picking_in.picking_type_id.warehouse_id.partner_id) + + + + + + + + + + + + + + + + + + + diff --git a/delivery_hibou/views/delivery_views.xml b/delivery_hibou/views/delivery_views.xml new file mode 100644 index 00000000..01208bbd --- /dev/null +++ b/delivery_hibou/views/delivery_views.xml @@ -0,0 +1,14 @@ + + + + hibou.delivery.carrier.form + delivery.carrier + + + + + + + + + \ No newline at end of file diff --git a/delivery_hibou/views/stock_views.xml b/delivery_hibou/views/stock_views.xml new file mode 100644 index 00000000..78067b01 --- /dev/null +++ b/delivery_hibou/views/stock_views.xml @@ -0,0 +1,16 @@ + + + + + hibou.delivery.stock.picking_withcarrier.form.view + stock.picking + + + + + + + + + + diff --git a/delivery_stamps/models/delivery_stamps.py b/delivery_stamps/models/delivery_stamps.py index 9965320d..8f346f55 100644 --- a/delivery_stamps/models/delivery_stamps.py +++ b/delivery_stamps/models/delivery_stamps.py @@ -36,6 +36,7 @@ class ProductPackaging(models.Model): _inherit = 'product.packaging' package_carrier_type = fields.Selection(selection_add=[('stamps', 'Stamps.com')]) + stamps_cubic_pricing = fields.Boolean(string="Stamps.com Use Cubic Pricing") class ProviderStamps(models.Model): @@ -72,6 +73,18 @@ class ProviderStamps(models.Model): return self.stamps_default_packaging_id.shipper_package_code return package.packaging_id.shipper_package_code if package.packaging_id.shipper_package_code in STAMPS_PACKAGE_TYPES else 'Package' + def _stamps_package_is_cubic_pricing(self, package=None): + if not package: + return self.stamps_default_packaging_id.stamps_cubic_pricing + return package.packaging_id.stamps_cubic_pricing + + def _stamps_package_dimensions(self, package=None): + if not package: + package_type = self.stamps_default_packaging_id + else: + package_type = package.packaging_id + return package_type.length, package_type.width, package_type.height + def _get_stamps_service(self): sudoself = self.sudo() config = StampsConfiguration(integration_id=sudoself.stamps_integration_id, @@ -136,23 +149,33 @@ class ProviderStamps(models.Model): for package in picking.package_ids: weight = self._stamps_convert_weight(package.shipping_weight) + l, w, h = self._stamps_package_dimensions(package=package) ret_val = service.create_shipping() ret_val.ShipDate = date.today().isoformat() ret_val.FromZIPCode = from_partner.zip ret_val.ToZIPCode = to_partner.zip ret_val.PackageType = self._stamps_package_type(package=package) + ret_val.CubicPricing = self._stamps_package_is_cubic_pricing(package=package) + ret_val.Length = l + ret_val.Width = w + ret_val.Height = h ret_val.ServiceType = self.stamps_service_type ret_val.WeightLb = weight ret.append((package.name + ret_val.ShipDate + str(ret_val.WeightLb), ret_val)) if not ret: weight = self._stamps_convert_weight(picking.shipping_weight) + l, w, h = self._stamps_package_dimensions() ret_val = service.create_shipping() ret_val.ShipDate = date.today().isoformat() ret_val.FromZIPCode = from_partner.zip ret_val.ToZIPCode = to_partner.zip ret_val.PackageType = self._stamps_package_type() + ret_val.CubicPricing = self._stamps_package_is_cubic_pricing() + ret_val.Length = l + ret_val.Width = w + ret_val.Height = h ret_val.ServiceType = self.stamps_service_type ret_val.WeightLb = weight ret.append((picking.name + ret_val.ShipDate + str(ret_val.WeightLb), ret_val)) @@ -168,11 +191,6 @@ class ProviderStamps(models.Model): service = self._get_stamps_service() for order in orders: - # has product with usps_exclude - if sum(1 for l in order.order_line if l.product_id.usps_exclude): - res.append(None) - continue - shipping = self._get_stamps_shipping_for_order(service, order, date_planned) rates = service.get_rates(shipping) if rates and len(rates) >= 1: @@ -198,6 +216,30 @@ class ProviderStamps(models.Model): res = res + [(0.0, 0, None)] return res + def stamps_rate_shipment(self, order): + self.ensure_one() + result = { + 'success': False, + 'price': 0.0, + 'error_message': 'Error Retrieving Response from Stamps.com', + 'warning_message': False + } + date_planned = None + if self.env.context.get('date_planned'): + date_planned = self.env.context.get('date_planned') + rate = self.stamps_get_shipping_price_for_plan(order, date_planned) + if rate: + price, transit_time, date_delivered = rate[0] + result.update({ + 'success': True, + 'price': price, + 'error_message': False, + 'transit_time': transit_time, + 'date_delivered': date_delivered, + }) + return result + return result + def stamps_send_shipping(self, pickings): res = [] service = self._get_stamps_service() diff --git a/delivery_stamps/views/delivery_stamps_view.xml b/delivery_stamps/views/delivery_stamps_view.xml index bb1d07f2..c4bb6fb0 100644 --- a/delivery_stamps/views/delivery_stamps_view.xml +++ b/delivery_stamps/views/delivery_stamps_view.xml @@ -25,4 +25,15 @@ + + stamps.product.packaging.form.delivery + product.packaging + + + + + + + + diff --git a/external/hibou-oca/stock-logistics-warehouse b/external/hibou-oca/stock-logistics-warehouse new file mode 160000 index 00000000..eb04e0ba --- /dev/null +++ b/external/hibou-oca/stock-logistics-warehouse @@ -0,0 +1 @@ +Subproject commit eb04e0ba5f7c75b8f482d935e19d16771126a7ca diff --git a/hr_payroll_payment/hr_payroll_register_payment.py b/hr_payroll_payment/hr_payroll_register_payment.py index 3114702d..4c7ee6a2 100755 --- a/hr_payroll_payment/hr_payroll_register_payment.py +++ b/hr_payroll_payment/hr_payroll_register_payment.py @@ -39,10 +39,11 @@ class HrPayrollRegisterPaymentWizard(models.TransientModel): context = dict(self._context or {}) active_ids = context.get('active_ids', []) payslip = self.env['hr.payslip'].browse(active_ids) - amount = 0.0 - for line in payslip.move_id.line_ids: - if line.account_id.internal_type == 'payable' and line.partner_id.id == payslip.employee_id.address_home_id.id: - amount += abs(line.balance) + amount = -sum(payslip.move_id.line_ids.filtered(lambda l: ( + l.account_id.internal_type == 'payable' + and l.partner_id.id == payslip.employee_id.address_home_id.id + and not l.reconciled) + ).mapped('balance')) return amount @api.model @@ -142,7 +143,7 @@ class HrPayrollRegisterPaymentWizard(models.TransientModel): if line.account_id.internal_type == 'payable': account_move_lines_to_reconcile |= line for line in payslip.move_id.line_ids: - if line.account_id.internal_type == 'payable' and line.partner_id.id == self.partner_id.id: + if line.account_id.internal_type == 'payable' and line.partner_id.id == self.partner_id.id and not line.reconciled: account_move_lines_to_reconcile |= line account_move_lines_to_reconcile.reconcile() diff --git a/rma/models/stock_picking.py b/rma/models/stock_picking.py index c2fbe651..ef8989c3 100644 --- a/rma/models/stock_picking.py +++ b/rma/models/stock_picking.py @@ -9,5 +9,18 @@ class StockPicking(models.Model): def send_to_shipper(self): res = False for pick in self.filtered(lambda p: not p.carrier_tracking_ref): + # deliver full order if no items are done. + pick_has_no_done = sum(pick.move_line_ids.mapped('qty_done')) == 0 + if pick_has_no_done: + pick._rma_complete() res = super(StockPicking, pick).send_to_shipper() + if pick_has_no_done: + pick._rma_complete_reverse() return res + + def _rma_complete(self): + for line in self.move_line_ids: + line.qty_done = line.product_uom_qty + + def _rma_complete_reverse(self): + self.move_line_ids.write({'qty_done': 0.0}) diff --git a/sale_planner/__manifest__.py b/sale_planner/__manifest__.py index 75bc2f24..bdb3d28c 100644 --- a/sale_planner/__manifest__.py +++ b/sale_planner/__manifest__.py @@ -28,6 +28,7 @@ on the specific method's characteristics. (e.g. Do they deliver on Saturday?) 'base_geolocalize', 'delivery', 'resource', + 'stock', ], 'demo': [], 'data': [ @@ -35,6 +36,7 @@ on the specific method's characteristics. (e.g. Do they deliver on Saturday?) 'views/sale.xml', 'views/stock.xml', 'views/delivery.xml', + 'views/product.xml', ], 'auto_install': False, 'installable': True, diff --git a/sale_planner/models/__init__.py b/sale_planner/models/__init__.py index 35dedabf..4257587e 100644 --- a/sale_planner/models/__init__.py +++ b/sale_planner/models/__init__.py @@ -1,3 +1,6 @@ from . import sale from . import stock from . import delivery +from . import product +from . import planning +from . import partner diff --git a/sale_planner/models/delivery.py b/sale_planner/models/delivery.py index b46cebcb..17a5eb25 100644 --- a/sale_planner/models/delivery.py +++ b/sale_planner/models/delivery.py @@ -25,6 +25,36 @@ class DeliveryCarrier(models.Model): 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: + if rate.get('date_delivered'): + rate.pop('date_delivered') + + return rate + def calculate_transit_days(self, date_planned, date_delivered): self.ensure_one() if isinstance(date_planned, str): @@ -36,11 +66,14 @@ class DeliveryCarrier(models.Model): 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: + 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) - date_planned = interval[0][1] + if current_date_planned == date_planned: + date_planned += timedelta(days=1) + else: + date_planned = current_date_planned transit_days += 1 if transit_days > 1: @@ -59,11 +92,11 @@ class DeliveryCarrier(models.Model): # 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: + last_day = self.delivery_calendar_id.plan_days(effective_transit_days, date_planned, compute_leaves=True) + if not last_day: return self._calculate_date_delivered_naive(date_planned, transit_days) - return fields.Datetime.to_string(interval[-1][1]) + return fields.Datetime.to_string(last_day) def _calculate_date_delivered_naive(self, date_planned, transit_days): return fields.Datetime.to_string(date_planned + timedelta(days=transit_days)) diff --git a/sale_planner/models/partner.py b/sale_planner/models/partner.py new file mode 100644 index 00000000..a0790ce7 --- /dev/null +++ b/sale_planner/models/partner.py @@ -0,0 +1,29 @@ +from odoo import api, fields, models + +try: + from uszipcode import ZipcodeSearchEngine +except ImportError: + ZipcodeSearchEngine = None + + +class Partner(models.Model): + _inherit = 'res.partner' + + @api.multi + def geo_localize(self): + # We need country names in English below + for partner in self.with_context(lang='en_US'): + if ZipcodeSearchEngine and partner.zip: + with ZipcodeSearchEngine() as search: + zipcode = search.by_zipcode(partner.zip) + if zipcode: + partner.write({ + 'partner_latitude': zipcode['Latitude'], + 'partner_longitude': zipcode['Longitude'], + 'date_localization': fields.Date.context_today(partner), + }) + else: + super(Partner, partner).geo_localize() + else: + super(Partner, partner).geo_localize() + return True diff --git a/sale_planner/models/planning.py b/sale_planner/models/planning.py new file mode 100644 index 00000000..47d645af --- /dev/null +++ b/sale_planner/models/planning.py @@ -0,0 +1,16 @@ +from odoo import api, fields, models + + +class PlanningPolicy(models.Model): + _name = 'sale.order.planning.policy' + + name = fields.Char(string='Name') + carrier_filter_id = fields.Many2one( + 'ir.filters', + string='Delivery Carrier Filter', + ) + warehouse_filter_id = fields.Many2one( + 'ir.filters', + string='Warehouse Filter', + ) + always_closest_warehouse = fields.Boolean(string='Always Plan Closest Warehouse') diff --git a/sale_planner/models/product.py b/sale_planner/models/product.py new file mode 100644 index 00000000..8c666a60 --- /dev/null +++ b/sale_planner/models/product.py @@ -0,0 +1,22 @@ +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.") + + @api.multi + 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/tests/test_planner.py b/sale_planner/tests/test_planner.py index b2d3bd61..35dad49c 100644 --- a/sale_planner/tests/test_planner.py +++ b/sale_planner/tests/test_planner.py @@ -1,5 +1,9 @@ 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): @@ -69,13 +73,13 @@ class TestPlanner(common.TransactionCase): self.warehouse_1 = self.env['stock.warehouse'].create({ 'name': 'Washington Warehouse', 'partner_id': self.warehouse_partner_1.id, - 'code': 'WH1', + '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': 'WH2', + 'code': 'TWH2', 'shipping_calendar_id': self.warehouse_calendar_2.id, }) self.so = self.env['sale.order'].create({ @@ -87,13 +91,25 @@ class TestPlanner(common.TransactionCase): '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', @@ -105,6 +121,11 @@ class TestPlanner(common.TransactionCase): '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_12.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, @@ -115,16 +136,45 @@ class TestPlanner(common.TransactionCase): '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_22.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() + 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_planner_creation_internals(self): + 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, @@ -138,7 +188,11 @@ class TestPlanner(common.TransactionCase): self.assertTrue(base_option, 'Must have base option.') self.assertEqual(self.warehouse_1.id, base_option['warehouse_id']) - def test_planner_creation(self): + 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, @@ -152,7 +206,11 @@ class TestPlanner(common.TransactionCase): 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): + 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, @@ -166,7 +224,11 @@ class TestPlanner(common.TransactionCase): 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): + 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, @@ -175,7 +237,7 @@ class TestPlanner(common.TransactionCase): self.env['sale.order.line'].create({ 'order_id': self.so.id, 'product_id': self.product_2.id, - 'name': 'demo', + '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) @@ -184,7 +246,12 @@ class TestPlanner(common.TransactionCase): 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): + 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, @@ -202,3 +269,194 @@ class TestPlanner(common.TransactionCase): self.assertTrue(planner.planning_option_ids, 'Must have one or more plans.') self.assertEqual(planner.planning_option_ids.warehouse_id, self.warehouse_2) self.assertFalse(planner.planning_option_ids[0].sub_options) + + def test_42_policy_force_closest(self): + """ + Scenario where an item may not be in stock at "the closest" warehouse, but an item is only allowed + to come from "the closest" warehouse. + """ + self.product_2.property_planning_policy_id = self.policy_closest + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_both.id, + 'name': 'demo', + }) + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_2.id, + 'name': 'demo', + }) + self.assertEqual(self.product_both.with_context(warehouse=self.warehouse_1.id).qty_available, 100) + # Close warehouse doesn't have product. + self.assertEqual(self.product_2.with_context(warehouse=self.warehouse_1.id).qty_available, 0) + both_wh_ids = self.both_wh_ids() + planner = self.env['sale.order.make.plan'].with_context(warehouse_domain=[('id', 'in', both_wh_ids)], + skip_plan_shipping=True).create({'order_id': self.so.id}) + self.assertTrue(planner.planning_option_ids, 'Must have one or more plans.') + self.assertEqual(planner.planning_option_ids.warehouse_id, self.warehouse_1) + self.assertFalse(planner.planning_option_ids[0].sub_options) + + def test_43_policy_merge(self): + """ + Scenario that will make a complicated scenario specifically: + - 3 policy groups + - 2 base options with sub_options (all base options with same warehouse) + """ + self.product_both.property_planning_policy_id = self.policy_closest + self.product_12.property_planning_policy_id = self.policy_other + self.product_22.property_planning_policy_id = self.policy_other + + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_both.id, + 'name': 'demo', + }) + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_1.id, + 'name': 'demo', + }) + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_2.id, + 'name': 'demo', + }) + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_12.id, + 'name': 'demo', + }) + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_22.id, + 'name': 'demo', + }) + both_wh_ids = self.both_wh_ids() + planner = self.env['sale.order.make.plan'].with_context(warehouse_domain=[('id', 'in', both_wh_ids)], + skip_plan_shipping=True).create({'order_id': self.so.id}) + self.assertTrue(planner.planning_option_ids, 'Must have one or more plans.') + self.assertEqual(planner.planning_option_ids.warehouse_id, self.warehouse_1) + self.assertTrue(planner.planning_option_ids.sub_options) + + sub_options = json_decode(planner.planning_option_ids.sub_options) + _logger.error(sub_options) + wh_1_ids = sorted([self.product_both.id, self.product_1.id, self.product_12.id]) + wh_2_ids = sorted([self.product_2.id, self.product_22.id]) + self.assertEqual(sorted(sub_options[str(self.warehouse_1.id)]['product_ids']), wh_1_ids) + self.assertEqual(sorted(sub_options[str(self.warehouse_2.id)]['product_ids']), wh_2_ids) + + def test_44_policy_merge_2(self): + """ + Scenario that will make a complicated scenario specifically: + - 3 policy groups + - 2 base options from different warehouses + """ + self.product_both.property_planning_policy_id = self.policy_other + self.product_12.property_planning_policy_id = self.policy_closest + self.product_22.property_planning_policy_id = self.policy_other + + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_both.id, + 'name': 'demo', + }) + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_1.id, + 'name': 'demo', + }) + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_2.id, + 'name': 'demo', + }) + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_12.id, + 'name': 'demo', + }) + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_22.id, + 'name': 'demo', + }) + both_wh_ids = self.both_wh_ids() + planner = self.env['sale.order.make.plan'].with_context(warehouse_domain=[('id', 'in', both_wh_ids)], + skip_plan_shipping=True).create({'order_id': self.so.id}) + self.assertTrue(planner.planning_option_ids, 'Must have one or more plans.') + self.assertEqual(planner.planning_option_ids.warehouse_id, self.warehouse_1, 'If this fails, it will probably pass next time.') + self.assertTrue(planner.planning_option_ids.sub_options) + + sub_options = json_decode(planner.planning_option_ids.sub_options) + _logger.error(sub_options) + wh_1_ids = sorted([self.product_1.id, self.product_12.id]) + wh_2_ids = sorted([self.product_both.id, self.product_2.id, self.product_22.id]) + self.assertEqual(sorted(sub_options[str(self.warehouse_1.id)]['product_ids']), wh_1_ids) + self.assertEqual(sorted(sub_options[str(self.warehouse_2.id)]['product_ids']), wh_2_ids) + + def test_45_policy_merge_3(self): + """ + Different order of products for test_44 + - 3 policy groups + - 2 base options from different warehouses + """ + self.product_both.property_planning_policy_id = self.policy_other + self.product_12.property_planning_policy_id = self.policy_closest + self.product_22.property_planning_policy_id = self.policy_other + + + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_1.id, + 'name': 'demo', + }) + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_2.id, + 'name': 'demo', + }) + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_12.id, + 'name': 'demo', + }) + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_22.id, + 'name': 'demo', + }) + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_both.id, + 'name': 'demo', + }) + both_wh_ids = self.both_wh_ids() + planner = self.env['sale.order.make.plan'].with_context(warehouse_domain=[('id', 'in', both_wh_ids)], + skip_plan_shipping=True).create({'order_id': self.so.id}) + self.assertTrue(planner.planning_option_ids, 'Must have one or more plans.') + self.assertEqual(planner.planning_option_ids.warehouse_id, self.warehouse_1) + self.assertTrue(planner.planning_option_ids.sub_options) + + sub_options = json_decode(planner.planning_option_ids.sub_options) + _logger.error(sub_options) + wh_1_ids = sorted([self.product_1.id, self.product_12.id]) + wh_2_ids = sorted([self.product_both.id, self.product_2.id, self.product_22.id]) + self.assertEqual(sorted(sub_options[str(self.warehouse_1.id)]['product_ids']), wh_1_ids) + self.assertEqual(sorted(sub_options[str(self.warehouse_2.id)]['product_ids']), wh_2_ids) + + def test_51_policy_specific_warehouse(self): + """ + Force one item to TWH2. + """ + self.product_both.property_planning_policy_id = self.policy_wh_2 + + self.env['sale.order.line'].create({ + 'order_id': self.so.id, + 'product_id': self.product_both.id, + 'name': 'demo', + }) + both_wh_ids = self.both_wh_ids() + planner = self.env['sale.order.make.plan'].with_context(warehouse_domain=[('id', 'in', both_wh_ids)], + skip_plan_shipping=True).create({'order_id': self.so.id}) + self.assertTrue(planner.planning_option_ids, 'Must have one or more plans.') + self.assertEqual(planner.planning_option_ids.warehouse_id, self.warehouse_2) diff --git a/sale_planner/views/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/wizard/order_planner.py b/sale_planner/wizard/order_planner.py index dd48dd78..470f1135 100644 --- a/sale_planner/wizard/order_planner.py +++ b/sale_planner/wizard/order_planner.py @@ -2,6 +2,7 @@ from math import sin, cos, sqrt, atan2, radians from json import dumps, loads from copy import deepcopy from datetime import datetime +from collections import defaultdict from logging import getLogger _logger = getLogger(__name__) @@ -50,7 +51,6 @@ class FakePartner(): 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 @@ -219,6 +219,8 @@ class SaleOrderMakePlan(models.TransientModel): planner = super(SaleOrderMakePlan, self).create(values) for option_vals in self.generate_order_options(planner.order_id): + if type(option_vals) != dict: + continue option_vals['plan_id'] = planner.id planner.planning_option_ids |= self.env['sale.order.planning.option'].create(option_vals) @@ -255,59 +257,70 @@ class SaleOrderMakePlan(models.TransientModel): return options - def get_warehouses(self, warehouse_id=None): + def get_warehouses(self, warehouse_id=None, domain=None): warehouse = self.env['stock.warehouse'].sudo() if warehouse_id: - return warehouse.search([('id', '=', warehouse_id)]) + return warehouse.browse(warehouse_id) + + if domain: + if not isinstance(domain, (list, tuple)): + domain = tools.safe_eval(domain) + else: + domain = [] if self.env.context.get('warehouse_domain'): - #potential bug here if this is textual - return warehouse.search(self.env.context.get('warehouse_domain')) + domain.extend(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) + domain.extend(tools.safe_eval(irconfig_parameter.get_param('sale.order.planner.warehouse_domain'))) - return warehouse.search([]) + return warehouse.search(domain) - def get_shipping_carriers(self, carrier_id=None): + def get_shipping_carriers(self, carrier_id=None, domain=None): Carrier = self.env['delivery.carrier'].sudo() if carrier_id: - return Carrier.search([('id', '=', 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 - return Carrier.search(self.env.context.get('carrier_domain')) + 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 = tools.safe_eval(irconfig_parameter.get_param('sale.order.planner.carrier_domain')) - return Carrier.search(domain) + domain.extend(tools.safe_eval(irconfig_parameter.get_param('sale.order.planner.carrier_domain'))) - return Carrier.search([]) + return Carrier.search(domain) - 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 {} + def _generate_base_option(self, order_fake, policy_group): + policy = False + flag_force_closest = False + warehouse_domain = False + if 'policy' in policy_group: + policy = policy_group['policy'] + flag_force_closest = policy.always_closest_warehouse + warehouse_domain = policy.warehouse_filter_id.domain + # Need to look at warehouse filter. + # Eventually need to look at shipping filter.... - 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) + warehouses = self.get_warehouses(domain=warehouse_domain) + if flag_force_closest: + warehouses = self._find_closest_warehouse_by_partner(warehouses, order_fake.partner_shipping_id) + product_stock = self._fetch_product_stock(warehouses, policy_group['products']) sub_options = {} wh_date_planning = {} - p_len = len(products) + p_len = len(policy_group['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])) + available = sum(1 for p_id, p_vals in stock.items() if self._is_in_stock(p_vals, policy_group['buy_qty'][p_id])) if available == p_len: full_candidates.add(wh_id) elif available > 0: @@ -316,32 +329,35 @@ class SaleOrderMakePlan(models.TransientModel): 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) + else: + 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 + #order_fake.warehouse_id = warehouse return {'warehouse_id': warehouse.id, 'date_planned': date_planned} + _logger.error(' partial_candidates: ' + str(partial_candidates)) if partial_candidates: + _logger.error(' using...') if len(partial_candidates) == 1: warehouse = warehouses.filtered(lambda wh: wh.id in partial_candidates) - order_fake.warehouse_id = warehouse + #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 + sorted_warehouses = self._sort_warehouses_by_partner( + warehouses.filtered(lambda wh: wh.id in partial_candidates), order_fake.partner_shipping_id) + _logger.error(' sorted_warehouses: ' + str(sorted_warehouses) + ' warehouses: ' + str(warehouses)) + 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: + _logger.error(' wh: ' + str(wh) + ' buy_qty: ' + str(policy_group['buy_qty'])) + if not policy_group['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]): + _logger.error(' p_id: ' + str(p_id) + ' p_vals: ' + str(p_vals)) + if p_id in policy_group['buy_qty'] and self._is_in_stock(p_vals, policy_group['buy_qty'][p_id]): if wh.id not in sub_options: sub_options[wh.id] = { 'date_planned': self._next_warehouse_shipping_date(wh), @@ -350,23 +366,211 @@ class SaleOrderMakePlan(models.TransientModel): } sub_options[wh.id]['product_ids'].append(p_id) sub_options[wh.id]['product_skus'].append(p_vals['sku']) - del buy_qty[p_id] + _logger.error(' removing: ' + str(p_id)) + del policy_group['buy_qty'][p_id] - if not buy_qty: + if not policy_group['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} + #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 + #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 + #order_fake.warehouse_id = primary_wh return {'warehouse_id': primary_wh.id} + def generate_base_option(self, order_fake): + _logger.error('generate_base_option:') + product_lines = list(filter(lambda line: line.product_id.type == 'product', order_fake.order_line)) + if not product_lines: + return {} + + buy_qty = defaultdict(int) + for line in product_lines: + buy_qty[line.product_id.id] += line.product_uom_qty + + products = self.env['product.product'] + for line in product_lines: + products |= line.product_id + + policy_groups = defaultdict(lambda: {'products': [], 'buy_qty': {}}) + for p in products: + policy = p.product_tmpl_id.get_planning_policy() + if policy: + policy_groups[policy.id]['products'].append(p) + policy_groups[policy.id]['buy_qty'][p.id] = buy_qty[p.id] + policy_groups[policy.id]['policy'] = policy + else: + policy_groups[0]['products'].append(p) + policy_groups[0]['buy_qty'][p.id] = buy_qty[p.id] + + + for _, policy_group in policy_groups.items(): + product_set = self.env['product.product'].browse() + for p in policy_group['products']: + product_set += p + policy_group['products'] = product_set + policy_group['base_option'] = self._generate_base_option(order_fake, policy_group) + + + option_policy_groups = defaultdict(lambda: {'products': self.env['product.product'].browse(), 'policies': self.env['sale.order.planning.policy'].browse(), 'date_planned': '1900', 'sub_options': [],}) + for policy_id, policy_group in policy_groups.items(): + base_option = policy_group['base_option'] + _logger.error(' base_option: ' + str(base_option)) + b_wh_id = base_option['warehouse_id'] + if 'policy' in policy_group: + option_policy_groups[b_wh_id]['policies'] += policy_group['policy'] + if option_policy_groups[b_wh_id].get('date_planned'): + # The first base_option without a date clears it + if base_option.get('date_planned'): + if base_option['date_planned'] > option_policy_groups[b_wh_id]['date_planned']: + option_policy_groups[b_wh_id]['date_planned'] = base_option['date_planned'] + else: + # One of our options has no plan date. Remove it. + del option_policy_groups[b_wh_id]['date_planned'] + if 'sub_options' in base_option: + option_policy_groups[b_wh_id]['sub_options'].append(base_option['sub_options']) + option_policy_groups[b_wh_id]['products'] += policy_group['products'] + option_policy_groups[b_wh_id]['warehouse_id'] = b_wh_id + + # clean up unused sub_options and collapse used ones + for o_wh_id, option_group in option_policy_groups.items(): + if not option_group['sub_options']: + del option_group['sub_options'] + else: + sub_options = defaultdict(lambda: {'date_planned': '1900', 'product_ids': [], 'product_skus': []}) + remaining_products = option_group['products'] + for options in option_group['sub_options']: + for wh_id, option in options.items(): + if sub_options[wh_id].get('date_planned'): + # The first option without a date clears it + if option.get('date_planned'): + if option['date_planned'] > sub_options[wh_id]['date_planned']: + sub_options[wh_id]['date_planned'] = option['date_planned'] + else: + del sub_options[wh_id]['date_planned'] + sub_options[wh_id]['product_ids'] += option['product_ids'] + sub_options[wh_id]['product_skus'] += option['product_skus'] + remaining_products = remaining_products.filtered(lambda p: p.id not in sub_options[wh_id]['product_ids']) + option_group['sub_options'] = sub_options + if remaining_products: + option_group['sub_options'][o_wh_id]['product_ids'] += remaining_products.ids + option_group['sub_options'][o_wh_id]['product_skus'] += remaining_products.mapped('default_code') + + + # At this point we should have all of the policy options collapsed. + # Collapse warehouse options. + base_option = {'date_planned': '1900', 'products': self.env['product.product'].browse()} + for wh_id, intermediate_option in option_policy_groups.items(): + _logger.error(' base_option: ' + str(base_option)) + _logger.error(' intermediate_option: ' + str(intermediate_option)) + if 'warehouse_id' not in base_option: + base_option['warehouse_id'] = wh_id + b_wh_id = base_option['warehouse_id'] + + if base_option.get('date_planned'): + if intermediate_option.get('date_planned'): + if intermediate_option['date_planned'] > base_option['date_planned']: + base_option['date_planned'] = intermediate_option['date_planned'] + else: + del base_option['date_planned'] + if 'sub_options' in base_option: + for _, option in base_option['sub_options'].items(): + del option['date_planned'] + if b_wh_id == wh_id: + if 'sub_options' in intermediate_option and 'sub_options' not in base_option: + # Base option will get new sub_options + intermediate_option['sub_options'][wh_id]['product_ids'] += base_option['products'].ids + intermediate_option['sub_options'][wh_id]['product_skus'] += base_option['products'].mapped('default_code') + base_option['sub_options'] = intermediate_option['sub_options'] + elif 'sub_options' in intermediate_option and 'sub_options' in base_option: + # Both have sub_options, merge + for o_wh_id, option in intermediate_option['sub_options'].items(): + if o_wh_id not in base_option['sub_options']: + base_option['sub_options'][o_wh_id] = option + else: + base_option['sub_options'][o_wh_id]['product_ids'] += option['product_ids'] + base_option['sub_options'][o_wh_id]['product_skus'] += option['product_skus'] + if base_option.get('date_planned'): + if option['date_planned'] > base_option['sub_options'][o_wh_id]['date_planned']: + base_option['sub_options'][o_wh_id]['date_planned'] = intermediate_option['date_planned'] + elif 'sub_options' in base_option: + # merge products from intermediate into base_option's sub_options + base_option['sub_options'][wh_id]['product_ids'] += intermediate_option['products'].ids + base_option['sub_options'][wh_id]['product_skus'] += intermediate_option['products'].mapped('default_code') + base_option['products'] += intermediate_option['products'] + else: + # Promote + if 'sub_options' not in intermediate_option and 'sub_options' not in base_option: + base_option['sub_options'] = { + wh_id: { + 'product_ids': intermediate_option['products'].ids, + 'product_skus': intermediate_option['products'].mapped('default_code'), + }, + b_wh_id: { + 'product_ids': base_option['products'].ids, + 'product_skus': base_option['products'].mapped('default_code'), + }, + } + if base_option.get('date_planned'): + base_option['sub_options'][wh_id]['date_planned'] = intermediate_option['date_planned'] + base_option['sub_options'][b_wh_id]['date_planned'] = base_option['date_planned'] + elif 'sub_options' in base_option and 'sub_options' not in intermediate_option: + if wh_id not in base_option['sub_options']: + base_option['sub_options'][wh_id] = { + 'product_ids': intermediate_option['products'].ids, + 'product_skus': intermediate_option['products'].mapped('default_code'), + } + if base_option.get('date_planned'): + base_option['sub_options'][wh_id]['date_planned'] = intermediate_option['date_planned'] + else: + base_option['sub_options'][wh_id]['product_ids'] += intermediate_option['products'].ids + base_option['sub_options'][wh_id]['product_skus'] += intermediate_option['products'].mapped('default_code') + if base_option.get('date_planned'): + if intermediate_option['date_planned'] > base_option['sub_options'][wh_id]['date_planned']: + base_option['sub_options'][wh_id]['date_planned'] = intermediate_option['date_planned'] + elif 'sub_options' in intermediate_option and 'sub_options' in base_option: + # Both have sub_options, merge + for o_wh_id, option in intermediate_option['sub_options'].items(): + if o_wh_id not in base_option['sub_options']: + base_option['sub_options'][o_wh_id] = option + else: + base_option['sub_options'][o_wh_id]['product_ids'] += option['product_ids'] + base_option['sub_options'][o_wh_id]['product_skus'] += option['product_skus'] + if base_option.get('date_planned'): + if option['date_planned'] > base_option['sub_options'][o_wh_id]['date_planned']: + base_option['sub_options'][o_wh_id]['date_planned'] = intermediate_option['date_planned'] + else: + # intermediate_option has sub_options but base_option doesn't + base_option['sub_options'] = { + b_wh_id: { + 'product_ids': base_option['products'].ids, + 'product_skus': base_option['products'].mapped('default_code'), + } + } + if base_option.get('date_planned'): + base_option['sub_options'][b_wh_id]['date_planned'] = base_option['date_planned'] + for o_wh_id, option in intermediate_option['sub_options'].items(): + if o_wh_id not in base_option['sub_options']: + base_option['sub_options'][o_wh_id] = option + else: + base_option['sub_options'][o_wh_id]['product_ids'] += option['product_ids'] + base_option['sub_options'][o_wh_id]['product_skus'] += option['product_skus'] + if base_option.get('date_planned'): + if option['date_planned'] > base_option['sub_options'][o_wh_id]['date_planned']: + base_option['sub_options'][o_wh_id]['date_planned'] = intermediate_option['date_planned'] + + del base_option['products'] + _logger.error(' returning: ' + str(base_option)) + order_fake.warehouse_id = self.get_warehouses(warehouse_id=base_option['warehouse_id']) + return base_option + def _is_in_stock(self, p_stock, buy_qty): return p_stock['real_qty_available'] >= buy_qty @@ -391,9 +595,9 @@ class SaleOrderMakePlan(models.TransientModel): 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)) + if warehouse.shipping_calendar_id: + return fields.Datetime.to_string(warehouse.shipping_calendar_id.plan_days(0.01, fields.Datetime.from_string(fields.Datetime.now()), compute_leaves=True)) + return False @api.model def _fetch_product_stock(self, warehouses, products): @@ -414,7 +618,17 @@ class SaleOrderMakePlan(models.TransientModel): 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')) + product_lines = list(filter(lambda line: line.product_id.type == 'product', order_fake.order_line)) + domain = [] + for line in product_lines: + policy = line.product_id.product_tmpl_id.get_planning_policy() + if policy and policy.carrier_filter_id: + domain.extend(tools.safe_eval(policy.carrier_filter_id.domain)) + carriers = self.get_shipping_carriers(base_option.get('carrier_id'), domain=domain) + _logger.info('generate_shipping_options:: base_optoin: ' + str(base_option) + ' order_fake: ' + str(order_fake) + ' carriers: ' + str(carriers)) + + if not carriers: + return base_option if not base_option.get('sub_options'): options = [] @@ -423,8 +637,9 @@ class SaleOrderMakePlan(models.TransientModel): option = self._generate_shipping_carrier_option(base_option, order_fake, carrier) if option: options.append(option) - - return options + if options: + return options + return [base_option] else: warehouses = self.get_warehouses() original_order_fake_warehouse_id = order_fake.warehouse_id @@ -488,14 +703,33 @@ class SaleOrderMakePlan(models.TransientModel): # this logic comes from "delivery.models.sale_order.SaleOrder" try: + result = None 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: + if hasattr(carrier, 'rate_shipment_date_planned'): + # New API + result = carrier.rate_shipment_date_planned(order_fake, base_option.get('date_planned')) + if result: + if not result.get('success'): + return None + price_unit, transit_days, date_delivered = result['price'], result.get('transit_days'), result.get('date_delivered') + elif hasattr(carrier, 'get_shipping_price_for_plan'): + # Old API + result = carrier.get_shipping_price_for_plan(order_fake, base_option.get('date_planned')) + if result and isinstance(result, list): price_unit, transit_days, date_delivered = result[0] - else: - price_unit = carrier.get_shipping_price_from_so(order_fake)[0] + elif not result: + rate = carrier.rate_shipment(order_fake) + if rate and rate.get('success'): + price_unit = rate['price'] + if rate.get('transit_days'): + transit_days = rate.get('transit_days') + if rate.get('date_delivered'): + date_delivered = rate.get('date_delivered') + else: + _logger.warn('returning None because carrier: ' + str(carrier)) + return None else: carrier = carrier.verify_carrier(order_fake.partner_shipping_id) if not carrier: @@ -514,13 +748,13 @@ class SaleOrderMakePlan(models.TransientModel): 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['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.info("Exception collecting carrier rates: " + str(e)) + # Want to see more? # _logger.exception(e) - pass return None diff --git a/stock_mts_mto_rule b/stock_mts_mto_rule new file mode 120000 index 00000000..db6ded6b --- /dev/null +++ b/stock_mts_mto_rule @@ -0,0 +1 @@ +external/hibou-oca/stock-logistics-warehouse/stock_mts_mto_rule \ No newline at end of file diff --git a/stock_warehouse_procurement/README.rst b/stock_warehouse_procurement/README.rst new file mode 100644 index 00000000..11985e25 --- /dev/null +++ b/stock_warehouse_procurement/README.rst @@ -0,0 +1,28 @@ +*********************************** +Hibou - Reorder Rules Per Warehouse +*********************************** + +Run the inventory scheduler per warehouse. + +For more information and add-ons, visit `Hibou.io `_. + + +============= +Main Features +============= + +* Extends the `stock.scheduler.compute` wizard to allow the inventory scheduler to run on demand per warehouse. + +.. image:: https://user-images.githubusercontent.com/15882954/45578023-f353d300-b833-11e8-8007-48fa3d96495a.png + :alt: 'Run Scheduler Wizard' + :width: 988 + :align: left + + +======= +License +======= + +Please see `LICENSE `_. + +Copyright Hibou Corp. 2018