Skip to content

Commit 0093144

Browse files
committed
[ADD] custom_vendor_portal: Enhance Vendor Portal with Purchase Order Creation
- Implemented vendor filtering by country, vendor, category, and product name. - Added pagination support for better navigation of vendor products. - Displayed product details with vendor pricing in the vendor portal. - Introduced a Create Purchase button to open a modal for PO creation. - Modal dynamically loads vendors for the selected product. - Added a quantity input field for purchase orders. - Implemented backend logic to create or merge draft purchase orders. - Enhanced UI with proper sorting, filtering, and responsive design. - Added JavaScript to handle modal interactions and success messages.
1 parent 4c650f3 commit 0093144

File tree

5 files changed

+318
-0
lines changed

5 files changed

+318
-0
lines changed

custom_vendor_portal/__init__.py

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# -*- coding: utf-8 -*-
2+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
3+
4+
from . import controllers

custom_vendor_portal/__manifest__.py

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# -*- coding: utf-8 -*-
2+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
3+
4+
{
5+
'name': 'Custom Vendor Portal',
6+
'version': '1.0',
7+
'summary': 'Vendor Portal with Purchase Order Creation',
8+
'author': 'Nisarg Mistry',
9+
'category': 'Website',
10+
'depends': ['website', 'purchase'],
11+
'data': [
12+
'views/vendor_portal_template.xml',
13+
],
14+
'installable': True,
15+
'license': 'LGPL-3'
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# -*- coding: utf-8 -*-
2+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
3+
4+
from . import vendor_portal
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# -*- coding: utf-8 -*-
2+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
3+
4+
from datetime import date
5+
from odoo import http
6+
from odoo.http import request
7+
8+
9+
class VendorPortal(http.Controller):
10+
11+
@http.route(['/vendor-portal', '/vendor-portal/page/<int:page>'], type='http', auth="user", website=True)
12+
def vendor_portal(self, vendor_country=None, vendor_id=None, category_id=None, product_name=None, page=1, **kwargs):
13+
env = request.env
14+
per_page = 15
15+
offset = (int(page) - 1) * per_page
16+
domain = []
17+
if vendor_country:
18+
domain.append(('seller_ids.partner_id.country_id', '=', int(vendor_country)))
19+
if vendor_id:
20+
domain.append(('seller_ids.partner_id', '=', int(vendor_id)))
21+
if category_id:
22+
domain.append(('categ_id', '=', int(category_id)))
23+
if product_name:
24+
domain.append(('name', 'ilike', product_name))
25+
26+
Product = env['product.template'].sudo()
27+
total_products = Product.search_count(domain)
28+
products = Product.search(domain, order="name asc", offset=offset, limit=per_page)
29+
matching_products = Product.search([('name', 'ilike', product_name or '')], order="name asc").mapped("name")
30+
product_data = [{
31+
'id': product.id,
32+
'name': product.name,
33+
'image_url': f"/web/image/product.template/{product.id}/image_1920",
34+
'min_price': min([vendor.price for vendor in product.seller_ids if vendor.price] or [0]),
35+
'max_price': max([vendor.price for vendor in product.seller_ids if vendor.price] or [0]),
36+
'vendors': [
37+
{'id': vendor.partner_id.id, 'name': vendor.partner_id.name, 'price': vendor.price}
38+
for vendor in product.seller_ids if vendor.partner_id.active
39+
]
40+
} for product in products]
41+
42+
SupplierInfo = env['product.supplierinfo'].sudo()
43+
vendor_country_ids = SupplierInfo.search([]).mapped('partner_id.country_id.id')
44+
countries = env['res.country'].sudo().search([('id', 'in', vendor_country_ids)], order="name asc")
45+
vendor_ids = SupplierInfo.search([]).mapped('partner_id.id')
46+
vendors = env['res.partner'].sudo().browse(vendor_ids).sorted(key=lambda v: v.name)
47+
categories = env['product.category'].sudo().search([], order="name asc")
48+
url_args = {
49+
'vendor_country': vendor_country or '',
50+
'vendor_id': vendor_id or '',
51+
'category_id': category_id or '',
52+
'product_name': product_name or ''
53+
}
54+
pager = request.website.pager(
55+
url="/vendor-portal",
56+
total=total_products,
57+
page=int(page),
58+
step=per_page,
59+
scope=5,
60+
url_args=url_args
61+
)
62+
return request.render("custom_vendor_portal.vendor_portal_template", {
63+
'products': product_data,
64+
'countries': countries,
65+
'vendors': vendors,
66+
'categories': categories,
67+
'selected_country': int(vendor_country) if vendor_country else None,
68+
'selected_vendor': int(vendor_id) if vendor_id else None,
69+
'selected_category': int(category_id) if category_id else None,
70+
'search_product': product_name or '',
71+
'matching_products': matching_products,
72+
'pager': pager
73+
})
74+
75+
@http.route('/create-purchase-order', type='http', auth='user', methods=['POST'], csrf=False)
76+
def create_purchase_order(self, **post):
77+
product_tmpl_id = post.get('product_id')
78+
vendor_id = post.get('vendor_id')
79+
quantity = int(post.get('quantity', 1))
80+
81+
if not product_tmpl_id or not vendor_id or quantity <= 0:
82+
return request.redirect('/vendor-portal?error=missing_data')
83+
84+
env = request.env
85+
product_tmpl_id, vendor_id = int(product_tmpl_id), int(vendor_id)
86+
product = env['product.product'].sudo().search([('product_tmpl_id', '=', product_tmpl_id)], limit=1)
87+
vendor = env['res.partner'].sudo().browse(vendor_id)
88+
supplier_info = env['product.supplierinfo'].sudo().search([
89+
('product_tmpl_id', '=', product_tmpl_id),
90+
('partner_id', '=', vendor_id)
91+
], limit=1)
92+
price = supplier_info.price if supplier_info else product.standard_price
93+
existing_po = env['purchase.order'].sudo().search([
94+
('partner_id', '=', vendor.id),
95+
('state', '=', 'draft'),
96+
('create_uid', '=', env.user.id)
97+
], limit=1)
98+
99+
if existing_po:
100+
order_line = existing_po.order_line.filtered(lambda l: l.product_id.id == product.id)
101+
if order_line:
102+
order_line.sudo().write({'product_qty': order_line.product_qty + quantity, 'price_unit': price})
103+
else:
104+
env['purchase.order.line'].sudo().create({
105+
'order_id': existing_po.id,
106+
'product_id': product.id,
107+
'product_qty': quantity,
108+
'price_unit': price
109+
})
110+
po_id = existing_po.id
111+
else:
112+
po_id = env['purchase.order'].sudo().create({
113+
'partner_id': vendor.id,
114+
'date_order': date.today(),
115+
'create_uid': env.user.id,
116+
'order_line': [(0, 0, {'product_id': product.id, 'product_qty': quantity, 'price_unit': price})]
117+
}).id
118+
119+
return request.redirect(f'/vendor-portal?success=po_created&po_id={po_id}')
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<odoo>
3+
<record id="website_menu_vendor_portal" model="website.menu">
4+
<field name="name">Vendor Portal</field>
5+
<field name="url">/vendor-portal</field>
6+
<field name="parent_id" ref="website.main_menu"/>
7+
</record>
8+
9+
<template id="vendor_portal_template" name="Vendor Portal">
10+
<t t-call="website.layout">
11+
<div class="container my-5">
12+
<h2 class="text-center text-primary fw-bold mb-4">Vendor Portal</h2>
13+
<form id="filterForm" method="GET" action="/vendor-portal" class="row g-3 mb-4 align-items-end">
14+
<div class="col-md-3">
15+
<label class="form-label">Vendor's Country</label>
16+
<select name="vendor_country" class="form-select">
17+
<option value="">Select Country</option>
18+
<t t-foreach="countries" t-as="country">
19+
<option t-att-value="country['id']"
20+
t-att-selected="selected_country == country['id'] and 'selected' or None">
21+
<t t-esc="country['name']"/>
22+
</option>
23+
</t>
24+
</select>
25+
</div>
26+
<div class="col-md-3">
27+
<label class="form-label">Vendor</label>
28+
<select name="vendor_id" class="form-select">
29+
<option value="">Select Vendor</option>
30+
<t t-foreach="vendors" t-as="vendor">
31+
<option t-att-value="vendor['id']"
32+
t-att-selected="selected_vendor == vendor['id'] and 'selected' or None">
33+
<t t-esc="vendor['name']"/>
34+
</option>
35+
</t>
36+
</select>
37+
</div>
38+
<div class="col-md-3">
39+
<label class="form-label">Product Category</label>
40+
<select name="category_id" class="form-select">
41+
<option value="">Select Category</option>
42+
<t t-foreach="categories" t-as="category">
43+
<option t-att-value="category['id']"
44+
t-att-selected="selected_category == category['id'] and 'selected' or None">
45+
<t t-esc="category['name']"/>
46+
</option>
47+
</t>
48+
</select>
49+
</div>
50+
<div class="col-md-2">
51+
<label class="form-label">Product</label>
52+
<input type="text" id="productSearch" name="product_name" class="form-control" placeholder="Search Product"
53+
t-att-value="search_product" autocomplete="off"/>
54+
<datalist id="product_list">
55+
<t t-foreach="matching_products" t-as="product">
56+
<option t-att-value="product"/>
57+
</t>
58+
</datalist>
59+
</div>
60+
<div class="col-md-1 text-end">
61+
<button type="submit" class="btn btn-primary w-100">SEARCH</button>
62+
</div>
63+
</form>
64+
<div class="row g-4">
65+
<t t-foreach="products" t-as="product">
66+
<div class="col-md-12">
67+
<div class="card shadow-sm border-0 p-3">
68+
<div class="row align-items-center">
69+
<div class="col-md-3 text-center">
70+
<img t-att-src="product['image_url']"
71+
class="img-fluid rounded"
72+
alt="Product Image"
73+
style="max-width: 150px;"/>
74+
</div>
75+
<div class="col-md-6">
76+
<h4 class="fw-semibold mb-2">
77+
<t t-esc="product['name']"/>
78+
</h4>
79+
<p class="text-muted">Vendors:</p>
80+
<ul class="list-group">
81+
<t t-foreach="product['vendors']" t-as="vendor">
82+
<li class="list-group-item d-flex justify-content-between">
83+
<span><t t-esc="vendor['name']"/></span>
84+
<span class="fw-bold text-success">💰 <t t-esc="vendor['price']"/> $</span>
85+
</li>
86+
</t>
87+
</ul>
88+
</div>
89+
<div class="col-md-3 text-end">
90+
<p class="mb-1 text-muted">Min Price: <b><t t-esc="product['min_price']"/></b> $</p>
91+
<p class="mb-2 text-muted">Max Price: <b><t t-esc="product['max_price']"/></b> $</p>
92+
<button class="btn btn-primary open-purchase-modal"
93+
t-att-data-product-id="product['id']"
94+
t-att-data-vendors="json.dumps(product['vendors'])"
95+
data-bs-toggle="modal"
96+
data-bs-target="#purchaseModal">
97+
Create Purchase
98+
</button>
99+
</div>
100+
</div>
101+
</div>
102+
</div>
103+
</t>
104+
</div>
105+
<div class="my-4 d-flex justify-content-center">
106+
<t t-call="website.pager" t-set="pager" t-set-options="{'url': '/vendor-portal'}"/>
107+
</div>
108+
<div class="modal fade" id="purchaseModal" tabindex="-1" aria-hidden="true">
109+
<div class="modal-dialog">
110+
<div class="modal-content">
111+
<div class="modal-header">
112+
<h5 class="modal-title">Create Purchase Order</h5>
113+
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
114+
</div>
115+
<form action="/create-purchase-order" method="post">
116+
<div class="modal-body">
117+
<label for="vendorSelect" class="form-label">Select Vendor</label>
118+
<select id="vendorSelect" name="vendor_id" class="form-select" required='True'>
119+
<option value="">Select Vendor</option>
120+
</select>
121+
<label for="quantityInput" class="form-label mt-2">Quantity</label>
122+
<input type="number" id="quantityInput" name="quantity" class="form-control" value="1" min="1" required='True'/>
123+
<input type="hidden" id="productInput" name="product_id"/>
124+
</div>
125+
<div class="modal-footer">
126+
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
127+
<button type="submit" class="btn btn-primary">Confirm</button>
128+
</div>
129+
</form>
130+
</div>
131+
</div>
132+
</div>
133+
<script>
134+
document.addEventListener("DOMContentLoaded", function () {
135+
const productSearch = document.getElementById("productSearch");
136+
productSearch.removeAttribute("list");
137+
productSearch.addEventListener("input", function () {
138+
if (this.value.length > 0) {
139+
this.setAttribute("list", "product_list");
140+
} else {
141+
this.removeAttribute("list");
142+
}
143+
});
144+
document.querySelectorAll(".open-purchase-modal").forEach(button => {
145+
button.addEventListener("click", function () {
146+
let productId = this.getAttribute("data-product-id");
147+
let vendors = JSON.parse(this.getAttribute("data-vendors"));
148+
149+
document.getElementById("productInput").value = productId;
150+
let vendorSelect = document.getElementById("vendorSelect");
151+
vendorSelect.innerHTML = '<option value="">Select Vendor</option>';
152+
153+
vendors.forEach(vendor => {
154+
let option = document.createElement("option");
155+
option.value = vendor.id;
156+
option.textContent = vendor.name + " - $" + vendor.price;
157+
vendorSelect.appendChild(option);
158+
});
159+
});
160+
});
161+
const urlParams = new URLSearchParams(window.location.search);
162+
if (urlParams.has('success') &amp;&amp; urlParams.get('success') === 'po_created') {
163+
const poId = urlParams.get('po_id');
164+
alert("Purchase Order Created Successfully! PO ID: " + poId);
165+
urlParams.delete('success');
166+
urlParams.delete('po_id');
167+
const newUrl = window.location.pathname + (urlParams.toString() ? '?' + urlParams.toString() : '');
168+
window.history.replaceState({}, document.title, newUrl);
169+
}
170+
});
171+
</script>
172+
</div>
173+
</t>
174+
</template>
175+
</odoo>

0 commit comments

Comments
 (0)