Merge branch '11.0' into 11.0-test

This commit is contained in:
Jared Kipe
2018-09-20 14:45:42 -07:00
27 changed files with 1251 additions and 83 deletions

3
.gitmodules vendored
View File

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

View File

@@ -0,0 +1,28 @@
******************************
Hibou - Account Invoice Margin
******************************
Include a margin calculation on invoices.
For more information and add-ons, visit `Hibou.io <https://hibou.io/docs/hibou-odoo-suite-1/invoice-margin-156>`_.
=============
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 <https://github.com/hibou-io/hibou-odoo-suite/blob/11.0/LICENSE>`_.
Copyright Hibou Corp. 2018

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
from . import delivery
from . import stock

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_picking_withcarrier_out_form" model="ir.ui.view">
<field name="name">hibou.delivery.stock.picking_withcarrier.form.view</field>
<field name="model">stock.picking</field>
<field name="inherit_id" ref="delivery.view_picking_withcarrier_out_form" />
<field name="arch" type="xml">
<xpath expr="//field[@name='carrier_id']" position="before">
<field name="require_insurance" attrs="{'readonly': [('state', 'in', ('done', 'cancel'))]}"/>
<field name="shipping_account_id" attrs="{'readonly': [('state', 'in', ('done', 'cancel'))]}"/>
</xpath>
</field>
</record>
</odoo>

View File

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

View File

@@ -25,4 +25,15 @@
</field>
</record>
<record id="product_packaging_delivery_form" model="ir.ui.view">
<field name="name">stamps.product.packaging.form.delivery</field>
<field name="model">product.packaging</field>
<field name="inherit_id" ref="delivery.product_packaging_delivery_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='max_weight']" position='after'>
<field name="stamps_cubic_pricing"/>
</xpath>
</field>
</record>
</odoo>

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,6 @@
from . import sale
from . import stock
from . import delivery
from . import product
from . import planning
from . import partner

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="product_template_form_view_inherit" model="ir.ui.view">
<field name="name">product.template.common.form.inherit</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_form_view" />
<field name="arch" type="xml">
<xpath expr="//field[@name='categ_id']" position="after">
<field name="property_planning_policy_id"/>
</xpath>
</field>
</record>
<record id="product_category_form_view_inherit" model="ir.ui.view">
<field name="name">product.category.form.inherit</field>
<field name="model">product.category</field>
<field name="inherit_id" ref="product.product_category_form_view" />
<field name="arch" type="xml">
<xpath expr="//field[@name='parent_id']" position="after">
<field name="property_planning_policy_categ_id"/>
</xpath>
</field>
</record>
</odoo>

View File

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

1
stock_mts_mto_rule Symbolic link
View File

@@ -0,0 +1 @@
external/hibou-oca/stock-logistics-warehouse/stock_mts_mto_rule

View File

@@ -0,0 +1,28 @@
***********************************
Hibou - Reorder Rules Per Warehouse
***********************************
Run the inventory scheduler per warehouse.
For more information and add-ons, visit `Hibou.io <https://hibou.io/docs/hibou-odoo-suite-1/reorder-rules-per-warehouse-163>`_.
=============
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 <https://github.com/hibou-io/hibou-odoo-suite/blob/11.0/LICENSE>`_.
Copyright Hibou Corp. 2018