Skip to content

Commit 0b48fda

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 0b48fda

14 files changed

+460
-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,12 @@
1+
from odoo import api, 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, default=lambda self: self._default_primary_warehouse())
7+
secondary_warehouse_id = fields.Many2one('stock.warehouse', string='Secondary Warehouse')
8+
9+
@api.model
10+
def _default_primary_warehouse(self):
11+
"""Fetch the default primary warehouse (e.g., the first available one)."""
12+
return self.env['stock.warehouse'].search([], limit=1).id
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,80 @@
1+
from odoo import api, fields, models
2+
from odoo.exceptions import ValidationError
3+
4+
class SaleOrderLine(models.Model):
5+
_inherit = 'sale.order.line'
6+
7+
warehouse_id = fields.Many2one('stock.warehouse', string='Warehouse', readonly=False, domain="[('id', 'in', available_warehouse_ids)]")
8+
available_warehouse_ids = fields.Many2many('stock.warehouse', compute='_compute_available_warehouses', store=True)
9+
on_hand_qty_warehouse_wise = fields.Json(string="Available Stocks", compute="_compute_on_hand_qty_warehouse_wise", store=True)
10+
forecast_qty_warehouse_wise = fields.Json(string="Stocks Forecast", compute="_compute_forecast_qty_warehouse_wise", store=True)
11+
12+
# -------------------------------------------------------------------------
13+
# COMPUTE METHODS
14+
# -------------------------------------------------------------------------
15+
16+
@api.depends('product_id')
17+
def _compute_available_warehouses(self):
18+
"""Compute available warehouses based on the product's primary and secondary warehouse"""
19+
20+
for line in self:
21+
if line.product_id and not line.product_id.primary_warehouse_id:
22+
raise ValidationError("Please add at least Primary warehouse")
23+
warehouse_ids = []
24+
if line.product_id.primary_warehouse_id:
25+
product = line.product_id.product_tmpl_id
26+
warehouse_ids.append(product.primary_warehouse_id.id)
27+
if product.secondary_warehouse_id:
28+
warehouse_ids.append(product.secondary_warehouse_id.id)
29+
line.available_warehouse_ids = warehouse_ids
30+
31+
@api.depends('product_id')
32+
def _compute_warehouse_id(self):
33+
"""Set the primary warehouse for the product in the order line."""
34+
35+
for line in self:
36+
if line.product_template_id and not line.warehouse_id:
37+
line.warehouse_id = line.product_template_id.primary_warehouse_id
38+
39+
@api.depends('product_id')
40+
def _compute_on_hand_qty_warehouse_wise(self):
41+
"""Compute in-hand stock for each warehouse."""
42+
43+
all_warehouses = self.env['stock.warehouse'].search([])
44+
for line in self:
45+
if not line.product_id:
46+
line.on_hand_qty_warehouse_wise = {}
47+
continue
48+
49+
available_data = {}
50+
for warehouse in all_warehouses:
51+
stock_qty = sum(self.env['stock.quant'].search([
52+
('product_id', '=', line.product_id.id),
53+
('location_id', 'child_of', warehouse.lot_stock_id.id)
54+
]).mapped('quantity'))
55+
available_data[warehouse.code] = stock_qty
56+
57+
if not available_data:
58+
available_data = {"No Stock": 0}
59+
60+
line.on_hand_qty_warehouse_wise = available_data
61+
62+
@api.depends('product_id')
63+
def _compute_forecast_qty_warehouse_wise(self):
64+
"""Compute forecasted stock for each warehouse."""
65+
66+
all_warehouses = self.env['stock.warehouse'].search([])
67+
for line in self:
68+
if not line.product_id:
69+
line.forecast_qty_warehouse_wise = {}
70+
continue
71+
72+
forecast_data = {}
73+
for warehouse in all_warehouses:
74+
res = self.env['stock.forecasted_product_product'].with_context(
75+
warehouse_id=warehouse.id).get_report_values(docids=line.product_id.ids)
76+
forecast_data[warehouse.code] = res['docs']['virtual_available']
77+
78+
if not forecast_data:
79+
forecast_data = {"No Forecast": 0}
80+
line.forecast_qty_warehouse_wise = forecast_data
Loading
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/** @odoo-module **/
2+
3+
import { patch } from "@web/core/utils/patch";
4+
import {
5+
QtyAtDateWidget,
6+
qtyAtDateWidget
7+
} from "@sale_stock/widgets/qty_at_date_widget";
8+
9+
/**
10+
* Patch the QtyAtDateWidget to support warehouse-wise quantity data
11+
*/
12+
patch(QtyAtDateWidget.prototype, {
13+
/**
14+
* Update calculation data with warehouse-wise quantities when available
15+
* @override
16+
*/
17+
updateCalcData() {
18+
const { data } = this.props.record;
19+
20+
// Check if required data is available
21+
if (!data.product_id || (!data.on_hand_qty_warehouse_wise && !data.forecast_qty_warehouse_wise)) {
22+
return super.updateCalcData();
23+
}
24+
25+
if (!data.on_hand_qty_warehouse_wise) {
26+
return;
27+
} else if (!data.forecast_qty_warehouse_wise) {
28+
return;
29+
}
30+
31+
// Set values from warehouse-wise data
32+
this.calcData.available_qty = data.on_hand_qty_warehouse_wise || {};
33+
this.calcData.forecast_qty = data.forecast_qty_warehouse_wise || {};
34+
35+
super.updateCalcData();
36+
},
37+
});
38+
39+
/**
40+
* Extended stock widget with additional field dependencies
41+
*/
42+
43+
patch(qtyAtDateWidget, {
44+
fieldDependencies: [
45+
{ name: "on_hand_qty_warehouse_wise", type: "object" },
46+
{ name: "forecast_qty_warehouse_wise", type: "object" },
47+
],
48+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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+
<xpath expr="//div[@class='p-2']" position="attributes">
6+
<attribute name="class">p-3 border rounded bg-white shadow</attribute>
7+
</xpath>
8+
9+
<xpath expr="//h6" position="replace">
10+
<h6 class="mb-2 text-muted">Availability</h6>
11+
</xpath>
12+
13+
<!-- Available Stock section -->
14+
<xpath expr="//tr[td/strong[text()='Available']]" position="replace">
15+
<tr>
16+
<td class="fw-bold">Available</td>
17+
<td class="ps-4">
18+
<div class="d-flex justify-content-between" t-foreach="Object.entries(props.calcData.available_qty)" t-as="warehouse" t-key="warehouse[0]">
19+
<span class="fw-bold"><t t-out="warehouse[0]"/> - </span>
20+
<t t-out="warehouse[1]"/> Units
21+
</div>
22+
</td>
23+
</tr>
24+
</xpath>
25+
26+
<!-- Forecasted Stock section -->
27+
<xpath expr="//tr[td/strong[text()='Forecasted Stock']]" position="replace">
28+
<tr>
29+
<td><strong>Forecasted Stock</strong><br/><small>On <span t-out="props.calcData.delivery_date"/></small></td>
30+
31+
<td class="ps-4">
32+
<div class="d-flex justify-content-between" t-foreach="Object.entries(props.calcData.forecast_qty)" t-as="warehouse" t-key="warehouse[0]">
33+
<span class="fw-bold"><t t-out="warehouse[0]"/> - </span>
34+
<t t-out="warehouse[1]"/> Units
35+
</div>
36+
</td>
37+
</tr>
38+
</xpath>
39+
</t>
40+
41+
</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)