Problem : When a user selects a product in the product field of the order line in a new sale order form, the system should check if this product, associated with the same customer, already exists in any sale orders that are in the confirmed stage. If such a product is found, a popup warning with some action buttons should be displayed.
While one might consider using the onchange method in the Odoo backend to achieve this, the onchange API in Odoo does not support triggering popup wizards. Therefore, we need to use the OWL (Odoo Web Library) framework to perform this check and trigger the popup.
To implement this solution, we will extend the existing component, use RPC (Remote Procedure Call), and ORM (Object-Relational Mapping) API to access the database in the backend and pass the necessary values to the frontend.
Solution:
if you check in the product Field in sale order line you can see there is a widget - sol_product_many2one , so we need to extend this widget and add our custom logic
Identify the Existing Product Field:
Locate the existing product field in Odoo: odoo/addons/sale/static/src/js/sale_product_field.js.
Create a New JS File:
In your custom module, create a new JS file under custom_module/src/components.
Import the existing field as follows:
`/** @odoo-module */
import { registry } from "@web/core/registry";
import { many2OneField } from '@web/views/fields/many2one/many2one_field';
import { Component } from "@odoo/owl";
import { jsonrpc } from "@web/core/network/rpc_service";
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { Dialog } from "@web/core/dialog/dialog";
import { SaleOrderLineProductField } from '@sale/js/sale_product_field'`
important : do not forget to add - /** @odoo-module */ - on top of the file unless it will throw error
Create a Component:
Create a new component and extend from the existing product field.
// Define a class DuplicateProductDialog that extends the Component class
export class DuplicateProductDialog extends Component {
// Define the components used in this class, in this case, Dialog
static components = { Dialog };
// Define the properties (props) for the component
static props = {
close: Function, // Function to close the dialog
title: String, // Title of the dialog
orders: Array, // Array of orders to be displayed
onAddProduct: Function, // Function to handle adding a product
onRemoveProduct: Function // Function to handle removing a product
};
// Define the template for this component
static template = "custom_module.DuplicateProductDialog";
// Setup method to initialize the class
setup() {
// Set the title of the dialog from the props
this.title = this.props.title;
}
/**
* Public method to handle adding a product
* @public
* @param {number} orderId - The ID of the order to which the product will be added
*/
addProduct(orderId) {
// Call the onAddProduct function passed in the props
this.props.onAddProduct();
// Close the dialog
this.props.close();
}
/**
* Public method to handle removing a product
* @public
*/
removeProduct() {
// Call the onRemoveProduct function passed in the props
this.props.onRemoveProduct();
}
}
So we are going to replace the existing productField widget
// Define a class SaleOrderproductField that extends the SaleOrderLineProductField class
export class SaleOrderproductField extends SaleOrderLineProductField {
// Setup method to initialize the class
setup() {
// Call the setup method of the parent class
super.setup();
// Initialize the dialog service
this.dialog = useService("dialog");
}
// Asynchronous method to update the record with the provided value
async updateRecord(value) {
// Call the updateRecord method of the parent class
super.updateRecord(value);
// Check for duplicate products after updating the record
const is_duplicate = await this._onCheckproductUpdate(value);
}
// Asynchronous method to check for duplicate products in the sale order
async _onCheckproductUpdate(product) {
const partnerId = this.context.partner_id; // Get the partner ID from the context
const customerName = document.getElementsByName("partner_id")[0].querySelector(".o-autocomplete--input").value; // Get the customer name from the input field
const productId = product[0]; // Get the product ID from the product array
// Check if the customer name is not provided
if (!customerName) {
alert("Please Choose Customer"); // Alert the user to choose a customer
return true; // Return true indicating a duplicate product scenario
}
// Fetch sale order lines that match the given criteria
const saleOrderLines = await jsonrpc("/web/dataset/call_kw/sale.order.line/search_read", {
model: 'sale.order.line',
method: "search_read",
args: [
[
["order_partner_id", "=", partnerId],
["product_template_id", "=", productId],
["state", "=", "sale"]
]
],
kwargs: {
fields: ['id', 'product_uom_qty', 'order_id', 'move_ids', 'name', 'state'],
order: "name"
}
});
const reservedOrders = []; // Array to hold reserved orders
let stockMoves = []; // Array to hold stock moves
// Check if any sale order lines are found
if (saleOrderLines.length > 0) {
// Iterate through each sale order line
for (const line of saleOrderLines) {
// Fetch stock moves associated with the sale order line
const stockMoves = await jsonrpc("/web/dataset/call_kw/stock.move/search_read", {
model: 'stock.move',
method: "search_read",
args: [[
['sale_line_id', '=', line.id],
]],
kwargs: {
fields: ['name', 'state']
}
});
// Check if any stock moves are found
if (stockMoves.length > 0) {
// Add the order details to the reserved orders array
reservedOrders.push({
order_number: line['order_id'][1], // Order number
order_id: line['order_id'][0], // Order ID
product_info: line['name'], // Product information
product_qty: line['product_uom_qty'] // Product quantity
});
}
}
}
// Check if there are any reserved orders
if (reservedOrders.length > 0) {
// Show a dialog with duplicate product warning
this.dialog.add(DuplicateProductDialog, {
title: _t("Warning For %s", product[1]), // Warning title with product name
orders: reservedOrders, // List of reserved orders
onAddProduct: async (product) => {
return true; // Callback for adding product
},
onRemoveProduct: async (product) => {
const currentRow = document.getElementsByClassName('o_data_row o_selected_row o_row_draggable o_is_false')[0]; // Get the currently selected row
if (currentRow) {
currentRow.remove(); // Remove the current row
}
},
});
return true; // Return true indicating a duplicate product scenario
} else {
return false; // Return false indicating no duplicate products found
}
}
}
once we have this, we need to export this
SaleOrderproductField.template = "web.Many2OneField";
export const saleOrderproductField = {
...many2OneField,
component: SaleOrderproductField,
};
registry.category("fields").add("so_product_many2one", saleOrderproductField);
Integrate the New Widget:
Attach this new widget to the inherited sale order form as shown below:
<xpath expr="//field[@name='product_template_id']" position="attributes">
<attribute name="widget">so_product_many2one</attribute>
</xpath>
Create a Popup Wizard View:
Create a popup wizard view and define the required props (title and orders) inside the component to avoid errors in debug mode.
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="custom_module.DuplicateProductDialog">
<Dialog size="'md'" title="title" modalRef="modalRef">
<table class="table">
<thead>
</thead>
<tbody>
<t t-foreach="this.props.orders" t-as="item" t-key="item.order_id">
<div class="d-flex align-items-start flex-column mb-3">
<tr>
<td>
<p>
The product
<t t-out="item.product_info" />
is already reserved for this customer under order number
<span t-esc="item.order_number" />
with a quantity of
<strong>
<t t-out="item.product_qty" />
</strong>
. Please confirm if you still want to add this line item to the order
<button class="btn btn-primary me-1" t-on-click="() => this.addProduct(item.id)">
Add
</button>
<button class="btn btn-primary ms-1" t-on-click="() => this.removeProduct(item.id)">
Remove
</button>
</p>
</td>
</tr>
</div>
</t>
</tbody>
</table>
</Dialog>
</t>
</templates>
So we passed the props title, orders. Important to define these props inside Component
export class DuplicateProductDialog extends Component {
static components = { Dialog };
static props = {
close: Function,
title: String,
orders: Array,
onAddProduct: Function,
onRemoveProduct: Function,
};
otherwise, thise will throw an error as below on debug mode = 1 situation
OwlError: Invalid props for component ( https://www.odoo.com/forum/help-1/owlerror-invalid-props-for-component-taxgroupcomponent-currency-is-undefined-should-be-a-value-213238 )
So once we have everything, restart Odoo, upgrade module and try to create some Sale Orders with a particular customer with same product. The confirm one or two sale orders and try to create a new sale order for same customer and choose same product, then it should trigger a popup warning window.
OWL framework is a very important part of Odoo framework, but lack of proper documentation is a hurdle for Odoo developers, hope this could be a simple help.
wishes
full code for JS
/** @odoo-module */
import { registry } from "@web/core/registry";
import { many2OneField } from '@web/views/fields/many2one/many2one_field';
import { Component } from "@odoo/owl";
import { jsonrpc } from "@web/core/network/rpc_service";
import { _t } from "@web/core/l10n/translation";
import { useService } from "@web/core/utils/hooks";
import { Dialog } from "@web/core/dialog/dialog";
import { SaleOrderLineProductField } from '@sale/js/sale_product_field'
export class DuplicateProductDialog extends Component {
static components = { Dialog };
static props = {
close: Function,
title: String,
orders: Array,
onAddProduct: Function,
onRemoveProduct: Function,
};
static template = "custom_module.DuplicateProductDialog";
setup() {
this.title = this.props.title;
}
/**
* @public
*/
addProduct(orderId) {
this.props.onAddProduct();
this.props.close();
}
removeProduct() {
this.props.onRemoveProduct();
}
}
export class SaleOrderproductField extends SaleOrderLineProductField {
setup() {
super.setup();
this.dialog = useService("dialog");
}
async updateRecord (value){
super.updateRecord(value);
const is_duplicate = await this._onCheckproductUpdate(value);
}
async _onCheckproductUpdate(product) {
const partnerId = this.context.partner_id
const customerName = document.getElementsByName("partner_id")[0].querySelector(".o-autocomplete--input").value;
const productId = product[0];
if (!customerName ) {
alert("Please Choose Customer")
return true;
}
const saleOrderLines = await jsonrpc("/web/dataset/call_kw/sale.order.line/search_read", {
model: 'sale.order.line',
method: "search_read",
args: [
[
["order_partner_id", "=", partnerId],
["product_template_id", "=", productId],
["state","=","sale"]
]
],
kwargs: {
fields: ['id','product_uom_qty', 'order_id', 'move_ids', 'name', 'state'],
order: "name"
}
});
const reservedOrders = [];
let stockMoves = [];
if(saleOrderLines.length > 0){
for (const line of saleOrderLines) {
const stockMoves = await jsonrpc("/web/dataset/call_kw/stock.move/search_read", {
model: 'stock.move',
method: "search_read",
args: [[
['sale_line_id', '=', line.id],
]],
kwargs: {
fields: ['name', 'state']
}
});
if (stockMoves.length > 0) {
reservedOrders.push({
order_number: line['order_id'][1],
order_id: line['order_id'][0],
product_info:line['name'],
product_qty: line['product_uom_qty']
});
}
}
}
if (reservedOrders.length > 0) {
this.dialog.add(DuplicateProductDialog, {
title: _t("Warning For %s", product[1]),
orders: reservedOrders,
onAddProduct: async (product) => {
return true;
},
onRemoveProduct: async (product) => {
const currentRow = document.getElementsByClassName('o_data_row o_selected_row o_row_draggable o_is_false')[0]
if(currentRow){
currentRow.remove();
}
},
});
return true;
} else {
return false;
}
}
}
SaleOrderproductField.template = "web.Many2OneField";
export const saleOrderproductField = {
...many2OneField,
component: SaleOrderproductField,
};
registry.category("fields").add("so_product_many2one", saleOrderproductField);