Skip to content

Commit 773f8e0

Browse files
committed
[ADD] multi_warehouse_sale_order: enable warehouse selection in sale order line
After this commit: - Added Primary Warehouse and Secondary Warehouse fields in the Product Template. - Made Primary Warehouse mandatory when adding a new product. - Added a Warehouse field in the Sale Order Line. - Made the warehouse column visibility toggleable. - Allowed selection of Secondary Warehouse in SO Line if available. - Checked stock availability before confirming the order. - Tried to fulfill all products from single warehouse if possible. - Created a single Delivery Order when stock is available in one warehouse. - Used the secondary warehouse if the primary warehouse has no stock. - Created a separate DO for products available only in the secondary warehouse. - Updated order lines automatically based on warehouse selection. - Modified Stock Display Widget to show stock warehouse-wise. - Updated forecast stock to show warehouse-wise data. - Added an icon for the module.
1 parent 4c650f3 commit 773f8e0

14 files changed

+464
-0
lines changed
+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import models
2+
from . import tests
+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
'name': 'Multi Warehouse Sale Order',
3+
'version': '1.0',
4+
'category': 'Sales',
5+
'summary': 'Enable multi-warehouse delivery',
6+
'depends': ['sale_management', 'stock'],
7+
'license': 'LGPL-3',
8+
'author': 'Shiv Bhadaniya (sbha)',
9+
'data': [
10+
'views/product_template_views.xml',
11+
'views/sale_order_line_views.xml',
12+
'views/multi_warehouse_menus.xml',
13+
],
14+
'installable': True,
15+
'assets': {
16+
'web.assets_backend': [
17+
'multi_warehouse_sale_order/static/src/**/*',
18+
],
19+
},
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from . import product_template
2+
from . import sale_order_line
3+
from . import sale_order
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from odoo import fields, models
2+
3+
class ProductTemplate(models.Model):
4+
_inherit = 'product.template'
5+
6+
primary_warehouse_id = fields.Many2one('stock.warehouse', string='Primary Warehouse', required=True)
7+
secondary_warehouse_id = fields.Many2one('stock.warehouse', string='Secondary Warehouse')
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
from odoo import models
2+
from odoo.exceptions import ValidationError
3+
4+
class SaleOrder(models.Model):
5+
_inherit = 'sale.order'
6+
7+
# -------------------------------------------------------------------------
8+
# METHODS
9+
# -------------------------------------------------------------------------
10+
11+
def create_delivery_order(self, warehouse, order_lines):
12+
"""Create a delivery order (picking) for the given warehouse and order lines."""
13+
14+
delivery_picking = self.env['stock.picking'].create({
15+
'partner_id': self.partner_id.id,
16+
'picking_type_id': warehouse.out_type_id.id,
17+
'location_id': warehouse.lot_stock_id.id,
18+
'location_dest_id': self.partner_id.property_stock_customer.id,
19+
'origin': self.name,
20+
})
21+
22+
for line in order_lines:
23+
self.env['stock.move'].create({
24+
'picking_id': delivery_picking.id,
25+
'name': line.product_id.name,
26+
'product_id': line.product_id.id,
27+
'product_uom_qty': line.product_uom_qty,
28+
'product_uom': line.product_uom.id,
29+
'location_id': warehouse.lot_stock_id.id,
30+
'location_dest_id': self.partner_id.property_stock_customer.id,
31+
})
32+
33+
return delivery_picking
34+
35+
def check_stock_availability(self, product, warehouse):
36+
"""Check if the entire order can be fulfilled from a single warehouse."""
37+
38+
stock_quant = self.env['stock.quant'].search([
39+
('product_id', '=', product.id),
40+
('location_id', '=', warehouse.lot_stock_id.id)
41+
], limit=1)
42+
return stock_quant.quantity > 0 if stock_quant else False
43+
44+
def can_fulfill_from_single_warehouse(self, warehouse):
45+
if not self.order_line:
46+
return False
47+
48+
for line in self.order_line:
49+
if self.check_stock_availability(line.product_id, warehouse) == False:
50+
return False
51+
52+
return True
53+
54+
def group_lines_by_warehouse(self):
55+
"""Group order lines by available warehouse based on stock availability."""
56+
57+
warehouse_lines = {}
58+
for line in self.order_line:
59+
product = line.product_id
60+
primary_stock = self.check_stock_availability(
61+
product, product.primary_warehouse_id)
62+
secondary_stock = self.check_stock_availability(
63+
product, product.secondary_warehouse_id) if product.secondary_warehouse_id else False
64+
65+
if primary_stock:
66+
warehouse = product.primary_warehouse_id
67+
elif secondary_stock:
68+
warehouse = product.secondary_warehouse_id
69+
else:
70+
raise ValidationError(
71+
f"The product '{product.name}' has no stock in both primary and secondary warehouses.")
72+
73+
if warehouse not in warehouse_lines:
74+
warehouse_lines[warehouse] = []
75+
warehouse_lines[warehouse].append(line)
76+
77+
return warehouse_lines
78+
79+
# -------------------------------------------------------------------------
80+
# ACTIONS
81+
# -------------------------------------------------------------------------
82+
83+
def action_confirm(self):
84+
"""Confirm the sale order and create delivery orders based on stock availability."""
85+
86+
primary_warehouse = self.order_line[0].product_id.primary_warehouse_id
87+
secondary_warehouse = self.order_line[0].product_id.secondary_warehouse_id
88+
89+
if self.can_fulfill_from_single_warehouse(primary_warehouse):
90+
for line in self.order_line:
91+
line.warehouse_id = primary_warehouse # Assign all products to Primary Warehouse
92+
res = self.create_delivery_order(
93+
primary_warehouse, self.order_line)
94+
self.write({'picking_ids': [(4, res.id)]})
95+
elif secondary_warehouse and self.can_fulfill_from_single_warehouse(secondary_warehouse):
96+
for line in self.order_line:
97+
line.warehouse_id = secondary_warehouse # Assign all products to Secondary Warehouse
98+
res = self.create_delivery_order(
99+
secondary_warehouse, self.order_line)
100+
self.write({'picking_ids': [(4, res.id)]})
101+
else:
102+
warehouse_lines = self.group_lines_by_warehouse()
103+
for warehouse, lines in warehouse_lines.items():
104+
res = self.create_delivery_order(warehouse, lines)
105+
self.write({'picking_ids': [(4, res.id)]})
106+
107+
self.write({'state': 'sale'})
108+
return True
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from odoo import api, fields, models
2+
from odoo.exceptions import ValidationError
3+
4+
5+
class SaleOrderLine(models.Model):
6+
_inherit = 'sale.order.line'
7+
8+
warehouse_id = fields.Many2one('stock.warehouse', string='Warehouse', readonly=False, domain="[('id', 'in', available_warehouse_ids)]")
9+
available_warehouse_ids = fields.Many2many('stock.warehouse', compute='_compute_available_warehouses')
10+
on_hand_qty_warehouse_wise = fields.Json(string="Available Stocks")
11+
forecast_qty_warehouse_wise = fields.Json(string="Stocks Forecast")
12+
13+
# -------------------------------------------------------------------------
14+
# COMPUTE METHODS
15+
# -------------------------------------------------------------------------
16+
17+
@api.depends('product_id')
18+
def _compute_available_warehouses(self):
19+
"""Compute available warehouses based on the product's primary and secondary warehouse"""
20+
21+
for line in self:
22+
23+
if line.product_id and not line.product_id.primary_warehouse_id:
24+
raise ValidationError("Please add at least Primary warehouse")
25+
if line.product_id and line.product_id.primary_warehouse_id:
26+
product = line.product_id.product_tmpl_id
27+
warehouse_ids = [product.primary_warehouse_id.id]
28+
if product.secondary_warehouse_id:
29+
warehouse_ids.append(product.secondary_warehouse_id.id)
30+
31+
line.available_warehouse_ids = self.env['stock.warehouse'].browse(
32+
warehouse_ids)
33+
else:
34+
line.available_warehouse_ids = self.env['stock.warehouse']
35+
self.calculate_on_hand_qty_warehouse_wise()
36+
self.calculate_forecast_qty_warehouse_wise()
37+
38+
@api.depends('product_template_id')
39+
def _compute_warehouse_id(self):
40+
"""Set the primary warehouse for the product in the order line."""
41+
42+
for line in self:
43+
if line.product_template_id and not line.warehouse_id:
44+
line.warehouse_id = line.product_template_id.primary_warehouse_id
45+
46+
# -------------------------------------------------------------------------
47+
# METHODS
48+
# -------------------------------------------------------------------------
49+
50+
def calculate_on_hand_qty_warehouse_wise(self):
51+
"""Calculate in-hand stock for each warehouse."""
52+
53+
all_warehouses = self.env['stock.warehouse'].search([])
54+
for line in self:
55+
if not line.product_id:
56+
line.on_hand_qty_warehouse_wise = {}
57+
continue
58+
59+
available_data = {}
60+
for warehouse in all_warehouses:
61+
stock_qty = sum(self.env['stock.quant'].search([
62+
('product_id', '=', line.product_id.id),
63+
('location_id', 'child_of', warehouse.lot_stock_id.id)
64+
]).mapped('quantity'))
65+
available_data[warehouse.code] = stock_qty
66+
67+
if not available_data:
68+
available_data = {"No Stock": 0}
69+
70+
line.on_hand_qty_warehouse_wise = available_data
71+
72+
def calculate_forecast_qty_warehouse_wise(self):
73+
"""Calculate forecasted stock for each warehouse."""
74+
75+
all_warehouses = self.env['stock.warehouse'].search([])
76+
for line in self:
77+
if not line.product_id:
78+
line.forecast_qty_warehouse_wise = {}
79+
continue
80+
81+
forecast_data = {}
82+
for warehouse in all_warehouses:
83+
res = self.env['stock.forecasted_product_product'].with_context(
84+
warehouse_id=warehouse.id).get_report_values(docids=line.product_id.ids)
85+
forecast_data[warehouse.code] = res['docs']['virtual_available']
86+
87+
if not forecast_data:
88+
forecast_data = {"No Forecast": 0}
89+
line.forecast_qty_warehouse_wise = forecast_data
Loading
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/** @odoo-module **/
2+
import { patch } from "@web/core/utils/patch";
3+
import {
4+
QtyAtDateWidget,
5+
qtyAtDateWidget
6+
} from "@sale_stock/widgets/qty_at_date_widget";
7+
8+
/**
9+
* Patch the QtyAtDateWidget to support warehouse-wise quantity data
10+
*/
11+
patch(QtyAtDateWidget.prototype, {
12+
/**
13+
* Update calculation data with warehouse-wise quantities when available
14+
* @override
15+
*/
16+
updateCalcData() {
17+
const { data } = this.props.record;
18+
19+
// Check if required data is available
20+
if (!data.product_id || (!data.on_hand_qty_warehouse_wise && !data.forecast_qty_warehouse_wise)) {
21+
return super.updateCalcData();
22+
}
23+
24+
if (!data.on_hand_qty_warehouse_wise) {
25+
return;
26+
} else if (!data.forecast_qty_warehouse_wise) {
27+
return;
28+
}
29+
30+
// Set values from warehouse-wise data
31+
this.calcData.available_qty = data.on_hand_qty_warehouse_wise || {};
32+
this.calcData.forecast_qty = data.forecast_qty_warehouse_wise || {};
33+
34+
super.updateCalcData();
35+
},
36+
});
37+
38+
/**
39+
* Extended stock widget with additional field dependencies
40+
*/
41+
42+
patch(qtyAtDateWidget, {
43+
fieldDependencies: [
44+
{ name: "on_hand_qty_warehouse_wise", type: "object" },
45+
{ name: "forecast_qty_warehouse_wise", type: "object" },
46+
],
47+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<template id="multi_Warehouse_stock.QtyAtDateExtension" xml:space="preserve">
3+
4+
<t t-name="multi_Warehouse_stock.QtyAtDatePopover" t-inherit="sale_stock.QtyAtDatePopover" t-inherit-mode="extension">
5+
6+
<xpath expr="//div[@class='p-2']" position="attributes">
7+
<attribute name="class">p-3 border rounded bg-white shadow</attribute>
8+
</xpath>
9+
10+
<xpath expr="//h6" position="replace">
11+
<h6 class="mb-2 text-muted">Availability</h6>
12+
</xpath>
13+
14+
<!-- Available Stock section -->
15+
<xpath expr="//tr[td/strong[text()='Available']]" position="replace">
16+
<tr>
17+
<td class="fw-bold">Available</td>
18+
<td class="ps-4">
19+
<div class="d-flex justify-content-between" t-foreach="Object.entries(props.calcData.available_qty)" t-as="warehouse" t-key="warehouse[0]">
20+
<span class="fw-bold"><t t-out="warehouse[0]"/> - </span>
21+
<t t-out="warehouse[1]"/> Units
22+
</div>
23+
</td>
24+
</tr>
25+
</xpath>
26+
27+
<!-- Forecasted Stock section -->
28+
<xpath expr="//tr[td/strong[text()='Forecasted Stock']]" position="replace">
29+
<tr>
30+
<td><strong>Forecasted Stock</strong><br/><small>On <span t-out="props.calcData.delivery_date"/></small></td>
31+
32+
<td class="ps-4">
33+
<div class="d-flex justify-content-between" t-foreach="Object.entries(props.calcData.forecast_qty)" t-as="warehouse" t-key="warehouse[0]">
34+
<span class="fw-bold"><t t-out="warehouse[0]"/> - </span>
35+
<t t-out="warehouse[1]"/> Units
36+
</div>
37+
</td>
38+
</tr>
39+
</xpath>
40+
</t>
41+
42+
</template>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import test_multi_warehouse_sale_order

0 commit comments

Comments
 (0)