Merge branch 'mig/15.0/sale_sourced_by_line' into '15.0'

mig/15.0/sale_sourced_by_line into 15.0

See merge request hibou-io/hibou-odoo/suite!1096
This commit is contained in:
Jared Kipe
2021-10-06 16:06:38 +00:00
8 changed files with 244 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
============================
Hibou - Sale Sourced by Line
============================
Adds warehouse and planned date fields to sale order lines. Will split the delivery orders
to every distinct warehouse.
Additionally, adds fields per line and to the sale order to set the planned date on generated
delivery orders.
=======
License
=======
Please see `LICENSE <https://github.com/hibou-io/hibou-odoo-suite/blob/master/LICENSE>`_.
Copyright Hibou Corp. 2018

View File

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

View File

@@ -0,0 +1,33 @@
{
'name': 'Sale Sourced by Line',
'summary': 'Multiple warehouse source locations for Sale order',
'version': '15.0.1.0.0',
'author': "Hibou Corp.,Odoo Community Association (OCA)",
'category': 'Warehouse',
'license': 'AGPL-3',
'complexity': 'expert',
'images': [],
'website': "https://hibou.io",
'description': """
Sale Sourced by Line
====================
Adds the possibility to source a line of sale order from a specific
warehouse instead of using the warehouse of the sale order. Additionally,
adds the ability to set the planned date of the outgoing shipments on a
per order or per order line basis.
This module was inspired by a module by the same name from the OCA for 9.0,
however it does not necessarily work in the same ways or have the same features.
""",
'depends': [
'sale_stock',
],
'demo': [],
'data': [
'views/sale_views.xml',
],
'auto_install': False,
'installable': True,
}

View File

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

View File

@@ -0,0 +1,95 @@
from collections import defaultdict
from odoo import api, fields, models
class SaleOrder(models.Model):
_inherit = 'sale.order'
date_planned = fields.Datetime('Planned Date')
requested_date = fields.Datetime('Requested Date')
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
# In 14, this field exists, but isn't stored and is merely related to the
# order's warehouse_id, it is only used in computation of availability
warehouse_id = fields.Many2one('stock.warehouse', string='Warehouse',
compute=None, related=None, store=True)
date_planned = fields.Datetime('Planned Date')
def _prepare_procurement_values(self, group_id=False):
vals = super(SaleOrderLine, self)._prepare_procurement_values(group_id=group_id)
if self.warehouse_id:
vals.update({'warehouse_id': self.warehouse_id})
if self.date_planned:
vals.update({'date_planned': self.date_planned})
elif self.order_id.date_planned:
vals.update({'date_planned': self.order_id.date_planned})
return vals
# Needs modifications to not actually set a warehouse on the line as it is now stored
@api.depends(
'product_id', 'customer_lead', 'product_uom_qty', 'product_uom', 'order_id.commitment_date',
'move_ids', 'move_ids.forecast_expected_date', 'move_ids.forecast_availability')
def _compute_qty_at_date(self):
""" Compute the quantity forecasted of product at delivery date. There are
two cases:
1. The quotation has a commitment_date, we take it as delivery date
2. The quotation hasn't commitment_date, we compute the estimated delivery
date based on lead time"""
treated = self.browse()
# If the state is already in sale the picking is created and a simple forecasted quantity isn't enough
# Then used the forecasted data of the related stock.move
for line in self.filtered(lambda l: l.state == 'sale'):
if not line.display_qty_widget:
continue
moves = line.move_ids
line.forecast_expected_date = max(moves.filtered("forecast_expected_date").mapped("forecast_expected_date"), default=False)
line.qty_available_today = 0
line.virtual_available_at_date = 0
for move in moves:
line.qty_available_today += move.product_uom._compute_quantity(move.reserved_availability, line.product_uom)
line.virtual_available_at_date += move.product_id.uom_id._compute_quantity(move.forecast_availability, line.product_uom)
line.scheduled_date = line.order_id.commitment_date or line._expected_date()
line.free_qty_today = False
treated |= line
qty_processed_per_product = defaultdict(lambda: 0)
grouped_lines = defaultdict(lambda: self.env['sale.order.line'])
# We first loop over the SO lines to group them by warehouse and schedule
# date in order to batch the read of the quantities computed field.
for line in self.filtered(lambda l: l.state in ('draft', 'sent')):
if not (line.product_id and line.display_qty_widget):
continue
grouped_lines[(line.warehouse_id.id or line.order_id.warehouse_id.id, line.order_id.commitment_date or line._expected_date())] |= line
for (warehouse, scheduled_date), lines in grouped_lines.items():
product_qties = lines.mapped('product_id').with_context(to_date=scheduled_date, warehouse=warehouse).read([
'qty_available',
'free_qty',
'virtual_available',
])
qties_per_product = {
product['id']: (product['qty_available'], product['free_qty'], product['virtual_available'])
for product in product_qties
}
for line in lines:
line.scheduled_date = scheduled_date
qty_available_today, free_qty_today, virtual_available_at_date = qties_per_product[line.product_id.id]
line.qty_available_today = qty_available_today - qty_processed_per_product[line.product_id.id]
line.free_qty_today = free_qty_today - qty_processed_per_product[line.product_id.id]
line.virtual_available_at_date = virtual_available_at_date - qty_processed_per_product[line.product_id.id]
line.forecast_expected_date = False
if line.product_uom and line.product_id.uom_id and line.product_uom != line.product_id.uom_id:
line.qty_available_today = line.product_id.uom_id._compute_quantity(line.qty_available_today, line.product_uom)
line.free_qty_today = line.product_id.uom_id._compute_quantity(line.free_qty_today, line.product_uom)
line.virtual_available_at_date = line.product_id.uom_id._compute_quantity(line.virtual_available_at_date, line.product_uom)
qty_processed_per_product[line.product_id.id] += line.product_uom_qty
treated |= lines
remaining = (self - treated)
remaining.virtual_available_at_date = False
remaining.scheduled_date = False
remaining.forecast_expected_date = False
remaining.free_qty_today = False
remaining.qty_available_today = False

View File

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

View File

@@ -0,0 +1,63 @@
from odoo.tests import common
class TestSaleSources(common.TransactionCase):
def setUp(self):
super(TestSaleSources, self).setUp()
self.partner = self.env.ref('base.res_partner_2')
self.product_1 = self.env['product.product'].create({
'type': 'consu',
'name': 'Test Product 1',
})
self.product_2 = self.env['product.product'].create({
'type': 'consu',
'name': 'Test Product 2',
})
self.wh_1 = self.env.ref('stock.warehouse0')
self.wh_2 = self.env['stock.warehouse'].create({
'name': 'Test WH2',
'code': 'TWH2',
})
def test_plan_one_warehouse(self):
so = self.env['sale.order'].create({
'warehouse_id': self.wh_1.id,
'partner_id': self.partner.id,
'date_planned': '2018-01-01',
'order_line': [(0, 0, {
'product_id': self.product_1.id,
'product_uom_qty': 1.0,
'product_uom': self.product_1.uom_id.id,
'price_unit': 10.0,
}),
(0, 0, {
'product_id': self.product_2.id,
'product_uom_qty': 1.0,
'product_uom': self.product_2.uom_id.id,
'price_unit': 10.0,
})]
})
so.action_confirm()
self.assertTrue(so.state in ('sale', 'done'))
self.assertEqual(len(so.picking_ids), 1)
self.assertEqual(len(so.picking_ids.filtered(lambda p: p.picking_type_id.warehouse_id == self.wh_1)), 1)
self.assertEqual(len(so.picking_ids.filtered(lambda p: p.picking_type_id.warehouse_id == self.wh_2)), 0)
def test_plan_two_warehouses(self):
so = self.env['sale.order'].create({
'warehouse_id': self.wh_1.id,
'partner_id': self.partner.id,
'date_planned': '2018-01-01',
'order_line': [(0, 0, {'product_id': self.product_1.id}),
(0, 0, {'product_id': self.product_2.id,
'date_planned': '2018-02-01', 'warehouse_id': self.wh_2.id})]
})
# in 13 default computation, this would result in a failure
self.assertTrue(so.order_line.filtered(lambda l: l.warehouse_id))
so.action_confirm()
self.assertTrue(so.state in ('sale', 'done'))
self.assertEqual(len(so.picking_ids), 2)
self.assertEqual(len(so.picking_ids.filtered(lambda p: p.picking_type_id.warehouse_id == self.wh_1)), 1)
self.assertEqual(len(so.picking_ids.filtered(lambda p: p.picking_type_id.warehouse_id == self.wh_2)), 1)

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_order_form" model="ir.ui.view">
<field name="name">sale.order.form.warehouse</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='order_line']/form/group/group/field[@name='route_id']" position="before">
<field name="date_planned"/>
<field name="warehouse_id"/>
</xpath>
<xpath expr="//field[@name='order_line']/tree/field[@name='route_id']" position="before">
<field name="date_planned"/>
<field name="warehouse_id"/>
</xpath>
<xpath expr="//label[@for='commitment_date']" position="before">
<field name="date_planned" />
<field name="requested_date" />
</xpath>
</field>
</record>
<record id="view_order_line_tree" model="ir.ui.view">
<field name="name">sale.order.line.tree.warehouse</field>
<field name="model">sale.order.line</field>
<field name="inherit_id" ref="sale.view_order_line_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='route_id']" position="before">
<field name="date_planned"/>
<field name="warehouse_id"/>
</xpath>
</field>
</record>
</odoo>