Initial commit

This commit is contained in:
gsinghpal
2026-02-22 01:22:18 -05:00
commit 5200d5baf0
2394 changed files with 386834 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import models
from . import wizard

View File

@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
{
'name': 'Create Sale Order to Purchase Order',
'author': "Edge Technologies",
'version': '19.0.1.0.0',
'live_test_url': 'https://youtu.be/UyuzOP1pVKA',
'images': ["static/description/main_screenshot.png"],
'summary': 'Convert sale order to purchase order - create PO from SO quickly',
'description': """
This app is useful for create and view purchase order from sale order in Odoo.
Updated for Odoo 19 compatibility.
""",
'depends': ['base', 'sale_management', 'purchase'],
"license": "OPL-1",
'data': [
'security/ir.model.access.csv',
'wizard/purchase_order_wiz.xml',
'wizard/match_sale_order_wiz.xml',
'views/sale_order_to_purchase_order_app.xml',
],
'installable': True,
'auto_install': False,
'category': 'Sales',
}

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import sale_order_to_purchase_order_app

View File

@@ -0,0 +1,243 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError
class SaleOrder(models.Model):
_inherit = 'sale.order'
custom_purchase_count = fields.Integer(
string='Purchase',
compute='_compute_custom_purchase_count'
)
def _compute_custom_purchase_count(self):
for so in self:
po_count = self.env['purchase.order'].search_count([
('sale_ord_id', '=', so.id)
])
so.custom_purchase_count = po_count
def action_view_custom_purchase(self):
"""Open the list of purchase orders linked to this sale order"""
purchases = self.env['purchase.order'].search([
('sale_ord_id', '=', self.id)
])
return {
'name': _('Purchase Order'),
'view_mode': 'list,form',
'res_model': 'purchase.order',
'domain': [('id', 'in', purchases.ids)],
'type': 'ir.actions.act_window',
}
class PurchaseOrder(models.Model):
_inherit = 'purchase.order'
sale_ord_id = fields.Many2one(
'sale.order',
string='Sale Order',
readonly=True,
help='The sale order from which this purchase order was created'
)
marked_for = fields.Char(
string='Marked For',
readonly=True,
help='The customer/person this purchase order is marked for (from Sales Order)'
)
custom_sale_count = fields.Integer(
string='Sales',
compute='_compute_custom_sale_count'
)
def _compute_custom_sale_count(self):
for po in self:
if po.sale_ord_id:
po.custom_sale_count = 1
else:
po.custom_sale_count = 0
def action_view_linked_sale_order(self):
"""Open the linked sale order"""
self.ensure_one()
if not self.sale_ord_id:
raise UserError(_("No Sale Order is linked to this Purchase Order."))
return {
'name': _('Sale Order'),
'view_mode': 'form',
'res_model': 'sale.order',
'res_id': self.sale_ord_id.id,
'type': 'ir.actions.act_window',
}
def _open_match_wizard(self, search_hint=''):
"""Open the match sale order wizard for manual selection"""
wizard = self.env['match.sale.order.wiz'].create({
'purchase_order_id': self.id,
'search_hint': search_hint,
})
return {
'name': _('Match Sale Order'),
'type': 'ir.actions.act_window',
'res_model': 'match.sale.order.wiz',
'res_id': wizard.id,
'view_mode': 'form',
'target': 'new',
}
def action_match_sale_order(self):
"""
Match this PO to a Sale Order based on x_marked_for field.
If no match or multiple matches, opens a wizard for manual selection.
"""
self.ensure_one()
import re
if self.sale_ord_id:
raise UserError(_("This Purchase Order is already linked to Sale Order: %s") % self.sale_ord_id.name)
# Get the x_marked_for value
marked_for_value = getattr(self, 'x_marked_for', None)
# If no x_marked_for, open wizard directly for manual selection
if not marked_for_value:
return self._open_match_wizard('')
matching_partners = None
marked_for_str = str(marked_for_value)
search_hint = marked_for_str
# Check if x_marked_for contains a partner ID reference like "res.partner(11519,)"
partner_id_match = re.search(r'res\.partner\((\d+)', marked_for_str)
if partner_id_match:
partner_id = int(partner_id_match.group(1))
partner = self.env['res.partner'].browse(partner_id).exists()
if partner:
matching_partners = partner
search_hint = partner.name # Use actual name for search hint
# If not a partner ID reference, search by name
if not matching_partners:
matching_partners = self.env['res.partner'].search([
'|',
('name', 'ilike', marked_for_str),
('display_name', 'ilike', marked_for_str)
])
# If no matching partners found, open wizard for manual selection
if not matching_partners:
return self._open_match_wizard(search_hint)
# Find Sale Orders for these partners
matching_sales = self.env['sale.order'].search([
('partner_id', 'in', matching_partners.ids)
])
# If no Sale Orders found, open wizard for manual selection
if not matching_sales:
return self._open_match_wizard(matching_partners[0].name if matching_partners else search_hint)
# If multiple Sale Orders, open wizard for manual selection
if len(matching_sales) > 1:
return self._open_match_wizard(matching_partners[0].name if matching_partners else search_hint)
# Exactly one match - link it automatically
customer_name = matching_partners[0].name
self.write({
'sale_ord_id': matching_sales.id,
'marked_for': customer_name,
})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Success'),
'message': _('Linked to Sale Order: %s') % matching_sales.name,
'type': 'success',
'sticky': False,
}
}
def action_batch_match_sale_orders(self):
"""
Batch match multiple POs to Sale Orders based on x_marked_for field.
Called from list view action.
"""
import re
matched = 0
skipped = 0
errors = []
for po in self:
if po.sale_ord_id:
skipped += 1
continue
marked_for_value = getattr(po, 'x_marked_for', None)
if not marked_for_value:
skipped += 1
continue
marked_for_str = str(marked_for_value)
matching_partners = None
# Check if x_marked_for contains a partner ID reference
partner_id_match = re.search(r'res\.partner\((\d+)', marked_for_str)
if partner_id_match:
partner_id = int(partner_id_match.group(1))
matching_partners = self.env['res.partner'].browse(partner_id).exists()
# If not a partner ID reference, search by name
if not matching_partners:
matching_partners = self.env['res.partner'].search([
'|',
('name', 'ilike', marked_for_str),
('display_name', 'ilike', marked_for_str)
])
if not matching_partners:
errors.append(_("PO %s: No customer found for '%s'") % (po.name, marked_for_str))
continue
# Find Sale Orders for these partners
matching_sales = self.env['sale.order'].search([
('partner_id', 'in', matching_partners.ids)
])
if not matching_sales:
errors.append(_("PO %s: No SO found for '%s'") % (po.name, matching_partners[0].name))
continue
if len(matching_sales) > 1:
errors.append(_("PO %s: Multiple SOs (%d) for '%s'") % (po.name, len(matching_sales), matching_partners[0].name))
continue
# Exactly one match - link it with actual customer name
customer_name = matching_partners[0].name
po.write({
'sale_ord_id': matching_sales.id,
'marked_for': customer_name,
})
matched += 1
message = _("Matched: %d, Skipped: %d") % (matched, skipped)
if errors:
message += "\n" + "\n".join(errors[:5]) # Show first 5 errors
if len(errors) > 5:
message += _("\n... and %d more errors") % (len(errors) - 5)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Batch Match Complete'),
'message': message,
'type': 'info' if matched > 0 else 'warning',
'sticky': True,
}
}

View File

@@ -0,0 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_purchaseorder_wiz,access_purchaseorder_wiz,model_purchaseorder_wiz,base.group_user,1,1,1,1
access_purchase_product_wiz,access_purchase_product_wiz,model_purchase_product_wiz,base.group_user,1,1,1,1
access_match_sale_order_wiz,access_match_sale_order_wiz,model_match_sale_order_wiz,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_purchaseorder_wiz access_purchaseorder_wiz model_purchaseorder_wiz base.group_user 1 1 1 1
3 access_purchase_product_wiz access_purchase_product_wiz model_purchase_product_wiz base.group_user 1 1 1 1
4 access_match_sale_order_wiz access_match_sale_order_wiz model_match_sale_order_wiz base.group_user 1 1 1 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -0,0 +1,195 @@
<!DOCTYPE html>
<html>
<head>
<title>Quick Sale Orders to Purchase Order</title>
<style>
</style>
</head>
<body>
<div>
<section class="oe_container lead title_box" style="box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);text-align: center;">
<div >
<div>
<h2 class="oe_slogan title" style="margin-bottom: 10px;padding-top: 10px; background: -webkit-linear-gradient(#4c59a8, #21bec2);-webkit-background-clip: text;-webkit-text-fill-color: transparent;">
<b> Easy to Create Sale Orders to Purchase Order </b>
</h2>
<hr style="width: 80%;background: -webkit-gradient(linear, 0 0, 100% 0, from(white), to(white), color-stop(50%, #1c2792));"></hr>
<p class="oe_slogan" style="padding: 5px 10px;"> This app is useful for create and view purchase order from sale order in odoo and convert sale to purchase order easily in one click.</p>
</div>
</div>
<hr style="width: 80%;"></hr>
<div>
<div style="margin: 0px 20px;">
<div class="row">
<div class="col-sm border feature-div" style="margin: 0px 10px;box-shadow: 10px 10px 10px #c7c7c7;" >
<section style="max-height: 85%;margin: auto;display: contents;">
<div style="margin-top: 15px;">
<img class="img img-responsive" src="features/f1.png" style="border-radius: 10%; max-width: 100px;max-height: 100px; min-height: 100px;margin: auto;" />
</div>
<h3>
<font style="vertical-align: inherit;">
Purchase Order
</font>
</h3>
<p style="margin-bottom: 10px;">
<font style="vertical-align: inherit;font-size: 15px;"> User can create purchase order from sale order.</font>
</p>
</section>
</div>
<div class="col-sm border feature-div" style="margin: 0px 10px;box-shadow: 10px 10px 10px #c7c7c7;" >
<section style="max-height: 85%;margin: auto;display: contents;">
<div style="margin-top: 15px;">
<img class="img img-responsive" src="features/f2.png" style="border-radius: 10%; max-width: 100px;max-height: 100px; min-height: 100px;margin: auto;" />
</div>
<h3>
<font style="vertical-align: inherit;">
Sale Order to Purchase Order
</font>
</h3>
<p style="margin-bottom: 10px;">
<font style="vertical-align: inherit;font-size: 15px;"> User can see linked purchase order in sale orders.</font>
</p>
</section>
</div>
<div class="col-sm border feature-div" style="margin: 0px 10px;box-shadow: 10px 10px 10px #c7c7c7;" >
<section style="max-height: 85%;margin: auto;display: contents;">
<div style="margin-top: 15px;">
<img class="img img-responsive" src="features/f3.png" style="border-radius: 10%; max-width: 100px;max-height: 100px; min-height: 100px;margin: auto;" />
</div>
<h3>
<font style="vertical-align: inherit;">
Validation on Empty Order Lines
</font>
</h3>
<p style="margin-bottom: 10px;">
<font style="vertical-align: inherit;font-size: 15px;"> Validation apply on empty sale order lines.</font>
</p>
</section>
</div>
</div>
</div>
</div>
<div >
<div class="oe_slogan oe_spaced text-center">
<a class="btn mt8 ml8 button" style="background-color: #13b9af;color: #FFFFFF !important;font-size: 20px;font-weight: bold;border-radius: 7px;min-height: 45px;box-shadow: 5px 8px 10px #0d4a45; min-width: 300px;margin: 10px 0px;"href="mailto:edgetechnologies.odoo@gmail.com?subject=Request%20Demo%20for%20Sale%20Order%20to%20Purchase%20Order"> Request a Demo</a>
<a class="btn mt8 ml8 button" style="background-color: #13b9af;color: #FFFFFF !important;font-size: 20px;font-weight: bold;border-radius: 7px;min-height: 45px;box-shadow: 5px 8px 10px #0d4a45;min-width: 300px;margin: 10px 0px;" href="https://youtu.be/UyuzOP1pVKA" target="_blank"> Video Tutorial</a>
</div>
</div>
<hr style="width: 80%;"></hr>
<div style="min-height: 20px;"></div>
</section>
<section class="oe_container lead title_box" style="box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);text-align: center;">
<div style="margin-bottom: 40px;">
<div>
<h2 class="oe_slogan title" style="margin-bottom: 10px; background: -webkit-linear-gradient(#0e99ac, #4c4ca8);-webkit-background-clip: text;-webkit-text-fill-color: transparent;">
<b> Description</b>
</h2>
<hr style="width: 80%;height: 1px;background: -webkit-gradient(linear, 0 0, 100% 0, from(#d58383), to(#5d4da6), color-stop(50%, #0b1255));"></hr>
</div>
</div>
<div>
<div style="margin-bottom: 40px;">
<h3 class="oe_slogan" style="color: #091383;font-weight: initial ;margin: auto;">Create Purchase Order From Sale Order</h3>
<p style="padding: 5px 10px;">View of create purchase order button in sale order.</p>
<br/>
<img class="img-responsive" style="display: block;width: 95%;margin: auto;box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);" src="1_so_to_po.png"/>
</div>
</div>
<div>
<div style="margin-bottom: 40px;">
<h3 class="oe_slogan" style="color: #091383;font-weight: initial ;margin: auto;">Wizard of Purchase Order in Sale Order</h3>
<p style="padding: 5px 10px;">View of purchase order wizard in sale for create purchase order.</p>
<br/>
<img class="img-responsive" style="display: block;width: 95%;margin: auto;box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);" src="2_so_to_po.png"/>
</div>
</div>
<div>
<div style="margin-bottom: 40px;">
<h3 class="oe_slogan" style="color: #091383;font-weight: initial ;margin: auto;">Purchase Order in Sale Order</h3>
<p style="padding: 5px 10px;">View of purchase order with referenced sale order.</p>
<br/>
<img class="img-responsive" style="display: block;width: 95%;margin: auto;box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);" src="3_so_to_po.png"/>
</div>
</div>
<div>
<div style="margin-bottom: 40px;">
<h3 class="oe_slogan" style="color: #091383;font-weight: initial ;margin: auto;">Linked Purchase Order With Sale Order</h3>
<p style="padding: 5px 10px;">View of linked purchase order which created from wizard in sale order.</p>
<br/>
<img class="img-responsive" style="display: block;width: 95%;margin: auto;box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);" src="4_so_to_po.png"/>
</div>
</div>
<hr style="width: 80%;"></hr>
<div style="min-height: 20px;"></div>
</section>
<section class="oe_container lead title_box" style="box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);text-align: center;">
<div>
<div>
<h2 class="oe_slogan title" style="margin-bottom: 10px; background: -webkit-linear-gradient(#0e99ac, #4c4ca8);-webkit-background-clip: text;-webkit-text-fill-color: transparent;">
<b> Our Services</b>
</h2>
<hr style="width: 80%;height: 1px;background: -webkit-gradient(linear, 0 0, 100% 0, from(#d58383), to(#5d4da6), color-stop(50%, #0b1255));"></hr>
</div>
</div>
<div class="row">
<a style="display: inline-grid;margin: 10px;" class="col-sm">
<img class="img img-responsive" style="max-height: 100px;max-width: 100px; margin: auto;" src="services/customization.png"/>
<span>Customization</span>
</a>
<a style="display: inline-grid; margin: 10px;" class="col-sm">
<img class="img img-responsive" style="max-height: 100px;max-width: 100px;margin: auto;" src="services/integration.png"/>
<span>Integration</span>
</a>
<a style="display: inline-grid;margin: 10px;" class="col-sm">
<img class="img img-responsive" style="max-height: 100px;max-width: 100px;margin: auto;" src="services/installation.png"/>
<span>Installation</span>
</a>
</div>
<div class="row">
<a style="display: inline-grid;margin: 10px;" class="col-sm">
<img class="img img-responsive" style="max-height: 100px;max-width: 100px;margin: auto;" src="services/create.gif"/>
<span>Create & Update</span>
</a>
<a style="display: inline-grid;margin: 10px;" class="col-sm">
<img class="img img-responsive" style="max-height: 100px;max-width: 100px;margin: auto;" src="services/migration.png"/>
<span>Migration</span>
</a>
<a style="display: inline-grid;margin: 10px;" class="col-sm">
<img class="img img-responsive" style="max-height: 100px;max-width: 100px;margin: auto;" src="services/support.png"/>
<span>Support</span>
</a>
</div>
<div >
<div >
<h2 class="oe_slogan title" style="margin-bottom: 10px; background: -webkit-linear-gradient(#0e99ac, #4c4ca8);-webkit-background-clip: text;-webkit-text-fill-color: transparent;">
<b> Get In Touch</b>
</h2>
<hr style="width: 80%;height: 1px;background: -webkit-gradient(linear, 0 0, 100% 0, from(#d58383), to(#5d4da6), color-stop(50%, #0b1255));"></hr>
<p class="oe_slogan" style="margin-bottom: 0px;margin-top: 15px;"> You will get 60 Days free support in case any bugs or issue. </p>
<div >
<p class="oe_slogan" style="margin: 0px;margin-right: 5px;"> For any query or support contact us without hesitation
on</p><a href="mailto:edgetechnologies.odoo@gmail.com?subject=Support%20Request%20for%20Sale%20Order%20To%20Purchase%20Order" target="_top"> edgetechnologies.odoo@gmail.com</a>
</div>
</div>
<div>
<a href="mailto:edgetechnologies.odoo@gmail.com?subject=Support%20Request%20for%20Sale%20Order%20To%20Purchase%20Order" target="_top"> <i class='fa fa-envelope' style="min-width: 50px;background: #12b9af;padding: 10px;font-size: 30px;color: #ffffff;border-radius: 10px;min-height: 50px;box-shadow: 5px 8px 10px #0d4a45;"></i></a>
<a href="#" target="_blank"> <i class='fa fa-facebook' style="min-width: 50px;background: #12b9af;padding: 10px;font-size: 30px;color: #ffffff;border-radius: 10px;min-height: 50px;box-shadow: 5px 8px 10px #0d4a45;"></i></a>
<a href="#" target="_blank"> <i class='fa fa-linkedin' style="min-width: 50px;background: #12b9af;padding: 10px;font-size: 30px;color: #ffffff;border-radius: 10px;min-height: 50px;box-shadow: 5px 8px 10px #0d4a45;"></i></a>
<a href="https://www.youtube.com/channel/UCrljky1245I7MhRNq_Tp46g" target="_blank"> <i class='fa fa-youtube' style="min-width: 50px;background: #12b9af;padding: 10px;font-size: 30px;color: #ffffff;border-radius: 10px;min-height: 50px;box-shadow: 5px 8px 10px #0d4a45;"></i></a>
</div>
</div>
<hr style="width: 80%;"></hr>
<div style="min-height: 20px;"></div>
</section>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="sale_order_to_purchase_order_view_form" model="ir.ui.view">
<field name="name">sale.order.to.purchase.order.form</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//form//header//button[@name='action_quotation_send']" position="after">
<button name="%(purchase_order_wizard_action)d" class="btn-primary" type="action" string="Create PO"/>
</xpath>
<xpath expr="//button[@name='action_view_invoice']" position="before">
<button type="object" name="action_view_custom_purchase" class="oe_stat_button" invisible ="custom_purchase_count == 0" icon="fa-file">
<field name="custom_purchase_count" widget="statinfo" string="Purchase"/>
</button>
</xpath>
</field>
</record>
<record id="purchase_order_sale_ord_view_form" model="ir.ui.view">
<field name="name">purchase.order.sale.ord.form</field>
<field name="model">purchase.order</field>
<field name="inherit_id" ref="purchase.purchase_order_form"/>
<field name="arch" type="xml">
<!-- Add Match SO button in header -->
<xpath expr="//header" position="inside">
<button name="action_match_sale_order" type="object" string="Match Sale Order"
class="btn-secondary" invisible="sale_ord_id"
confirm="This will try to match this PO to a Sale Order based on the 'Marked For' field. Continue?"/>
</xpath>
<!-- Add stat button for linked Sale Order -->
<xpath expr="//div[@name='button_box']" position="inside">
<button type="object" name="action_view_linked_sale_order" class="oe_stat_button"
invisible="custom_sale_count == 0" icon="fa-shopping-cart">
<field name="custom_sale_count" widget="statinfo" string="Sale Order"/>
</button>
</xpath>
<!-- Add Sale Order and Marked For fields -->
<xpath expr="//field[@name='date_order']" position="before">
<field name="sale_ord_id" string="Sale Order"/>
<field name="marked_for" string="Marked For"/>
</xpath>
</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import purchase_order_wiz
from . import match_sale_order_wiz

View File

@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError
class MatchSaleOrderWizard(models.TransientModel):
_name = 'match.sale.order.wiz'
_description = "Match Sale Order Wizard"
purchase_order_id = fields.Many2one(
'purchase.order',
string='Purchase Order',
required=True,
readonly=True
)
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
required=False,
help="Select the Sale Order to link to this Purchase Order"
)
search_hint = fields.Char(
string='Search Hint',
readonly=True,
help="Suggested search term based on Marked For field"
)
def action_confirm(self):
"""Link the selected Sale Order to the Purchase Order"""
self.ensure_one()
if not self.sale_order_id:
raise UserError(_("Please select a Sale Order."))
# Get the customer name for marked_for field
customer_name = self.sale_order_id.partner_id.name or self.sale_order_id.partner_id.display_name
# Update the Purchase Order
self.purchase_order_id.write({
'sale_ord_id': self.sale_order_id.id,
'marked_for': customer_name,
})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Success'),
'message': _('Linked Purchase Order %s to Sale Order %s') % (
self.purchase_order_id.name,
self.sale_order_id.name
),
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
}
}

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Match Sale Order Wizard Form -->
<record id="view_match_sale_order_wizard" model="ir.ui.view">
<field name="name">match.sale.order.wiz.form</field>
<field name="model">match.sale.order.wiz</field>
<field name="arch" type="xml">
<form string="Match Sale Order">
<field name="purchase_order_id" invisible="1"/>
<group>
<p class="text-muted" colspan="2">
Search and select the Sale Order to link to this Purchase Order.
</p>
</group>
<group>
<field name="search_hint" string="Suggested Search" invisible="not search_hint"/>
<field name="sale_order_id"
string="Sale Order"
options="{'no_create': True, 'no_quick_create': True}"
context="{'search_default_name': search_hint}"
/>
</group>
<footer>
<button name="action_confirm" string="Link Sale Order" type="object" class="btn-primary"/>
<button special="cancel" string="Cancel" class="btn-secondary"/>
</footer>
</form>
</field>
</record>
<!-- Action for the wizard -->
<record id="action_match_sale_order_wizard" model="ir.actions.act_window">
<field name="name">Match Sale Order</field>
<field name="res_model">match.sale.order.wiz</field>
<field name="type">ir.actions.act_window</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,209 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from collections import defaultdict
class PurchaseOrderWizard(models.TransientModel):
_name = 'purchaseorder.wiz'
_description = "Purchase Order Wizard"
sale_order_id = fields.Many2one('sale.order', string='Sale Order')
product_line_ids = fields.One2many(
'purchase.product.wiz',
'wizard_id',
string='Products'
)
# Batch vendor assignment - uses onchange to apply immediately
batch_vendor_id = fields.Many2one(
'res.partner',
string='Assign Vendor to All',
domain="[('is_company', '=', True)]",
help="Select a vendor here to assign it to ALL products at once"
)
@api.onchange('batch_vendor_id')
def _onchange_batch_vendor_id(self):
"""When user selects a batch vendor, apply it to selected lines only"""
if self.batch_vendor_id:
# Get selected lines, or all lines if none selected
selected_lines = self.product_line_ids.filtered(lambda l: l.selected)
lines_to_update = selected_lines if selected_lines else self.product_line_ids
for line in lines_to_update:
line.vendor_id = self.batch_vendor_id
# Update price from vendor if available
if line.product_id:
seller = line.product_id.seller_ids.filtered(
lambda s: s.partner_id.id == self.batch_vendor_id.id
)[:1]
if seller and seller.price:
line.price_unit = seller.price
line.price_subtotal = line.price_unit * line.product_uom_qty
# Clear selection after applying
for line in selected_lines:
line.selected = False
@api.model
def default_get(self, fields_list):
"""Load SO lines into wizard"""
res = super().default_get(fields_list)
active_ids = self._context.get('active_ids')
if not active_ids:
return res
sale_order = self.env['sale.order'].browse(active_ids[0])
if not sale_order.order_line:
raise UserError(_("Please add some valid sale order lines...!"))
res['sale_order_id'] = sale_order.id
# Load all products
product_lines = []
for line in sale_order.order_line:
if line.display_type in ('line_section', 'line_note'):
continue
# Ensure description is never empty
description = line.name or line.product_id.display_name or line.product_id.name or 'Product'
product_lines.append((0, 0, {
'product_id': line.product_id.id,
'description': description,
'product_uom_qty': line.product_uom_qty,
'price_unit': line.price_unit,
'product_uom': line.product_uom_id.id,
'price_subtotal': line.price_subtotal,
'so_line_id': line.id,
'vendor_id': False,
}))
res['product_line_ids'] = product_lines
return res
def create_po(self):
"""Generate purchase orders grouped by vendor"""
self.ensure_one()
# Group products by vendor
vendor_products = defaultdict(list)
for product_line in self.product_line_ids:
if product_line.vendor_id:
vendor_products[product_line.vendor_id.id].append(product_line)
if not vendor_products:
raise UserError(_("Please assign at least one product to a vendor before creating Purchase Orders."))
now = fields.Datetime.now()
created_pos = self.env['purchase.order']
for vendor_id, product_lines in vendor_products.items():
partner = self.env['res.partner'].browse(vendor_id)
fpos = self.env['account.fiscal.position'].sudo()._get_fiscal_position(partner)
# Use SO commitment date or current date for date_planned
date_planned = self.sale_order_id.commitment_date or now
# Get the customer name for "Marked For" field
marked_for_name = self.sale_order_id.partner_id.name or self.sale_order_id.partner_id.display_name
# Create the Purchase Order
purchase_order = self.env['purchase.order'].create({
'partner_id': partner.id,
'partner_ref': partner.ref,
'company_id': self.sale_order_id.company_id.id,
'currency_id': self.env.company.currency_id.id,
'dest_address_id': False,
'origin': self.sale_order_id.name,
'payment_term_id': partner.property_supplier_payment_term_id.id,
'date_order': now,
'fiscal_position_id': fpos.id,
'sale_ord_id': self.sale_order_id.id,
'marked_for': marked_for_name,
})
# Create PO lines
for product_line in product_lines:
values = product_line._prepare_purchase_line_values(purchase_order, date_planned)
self.env['purchase.order.line'].create(values)
created_pos |= purchase_order
# Return action based on number of POs created
if len(created_pos) == 1:
return {
'name': _('Purchase Order'),
'view_mode': 'form',
'res_model': 'purchase.order',
'view_id': self.env.ref('purchase.purchase_order_form').id,
'res_id': created_pos.id,
'type': 'ir.actions.act_window',
'target': 'current',
}
else:
return {
'name': _('Purchase Orders'),
'view_mode': 'list,form',
'res_model': 'purchase.order',
'domain': [('id', 'in', created_pos.ids)],
'type': 'ir.actions.act_window',
'target': 'current',
}
class PurchaseProductWiz(models.TransientModel):
_name = 'purchase.product.wiz'
_description = "Purchase Product Wizard Line"
wizard_id = fields.Many2one('purchaseorder.wiz', string='Wizard', ondelete='cascade')
selected = fields.Boolean(string='Select', default=False)
so_line_id = fields.Many2one('sale.order.line', string='SO Line')
product_id = fields.Many2one('product.product', string='Product')
product_uom = fields.Many2one('uom.uom', string='Unit of Measure')
description = fields.Char(string='Description')
product_uom_qty = fields.Float(string='Quantity')
price_unit = fields.Float(string='Unit Price')
price_subtotal = fields.Float(string='Subtotal')
vendor_id = fields.Many2one(
'res.partner',
string='Vendor',
domain="[('is_company', '=', True)]"
)
@api.onchange('vendor_id')
def _onchange_vendor_id(self):
"""Update price based on vendor's price list if available"""
if self.vendor_id and self.product_id:
seller = self.product_id.seller_ids.filtered(
lambda s: s.partner_id.id == self.vendor_id.id
)[:1]
if seller and seller.price:
self.price_unit = seller.price
self.price_subtotal = self.price_unit * self.product_uom_qty
def _prepare_purchase_line_values(self, purchase_order, date_planned):
"""Returns the values to create the purchase order line."""
self.ensure_one()
# Use the wizard's UoM, or fall back to the product's default UoM
product_uom = self.product_uom or self.product_id.uom_id
# Ensure name is never empty - required field on purchase.order.line
name = self.description
if not name:
name = self.product_id.display_name or self.product_id.name or 'Product'
if self.product_id.default_code and self.product_id.default_code not in name:
name = '[%s] %s' % (self.product_id.default_code, name)
return {
'name': name,
'product_qty': self.product_uom_qty,
'product_id': self.product_id.id,
'product_uom_id': product_uom.id,
'price_unit': self.price_unit,
'date_planned': date_planned,
'order_id': purchase_order.id,
}

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- Main Wizard Form -->
<record id="view_purchase_order_wizard" model="ir.ui.view">
<field name="name">purchase.order.wizard.form</field>
<field name="model">purchaseorder.wiz</field>
<field name="arch" type="xml">
<form string="Create Purchase Orders">
<field name="sale_order_id" invisible="1"/>
<!-- Batch Assign Vendor -->
<group>
<group string="Assign Vendor to Selected">
<field name="batch_vendor_id" options="{'no_create': True}" placeholder="Select vendor to assign..."/>
</group>
<group>
<p class="text-muted mt-4">
<b>Tip:</b> Check the boxes below to select products, then pick a vendor above to assign to selected only.
If nothing is selected, vendor applies to all.
</p>
</group>
</group>
<separator/>
<!-- Products List -->
<field name="product_line_ids" nolabel="1">
<list editable="bottom" create="false">
<field name="selected" string="Select"/>
<field name="vendor_id" string="Vendor" placeholder="Select vendor..." widget="many2one" options="{'no_create': True, 'no_quick_create': True}"/>
<field name="product_id" string="Product" readonly="1" force_save="1"/>
<field name="description" string="Description" readonly="1" force_save="1" column_invisible="1"/>
<field name="product_uom_qty" string="Qty" readonly="1" force_save="1"/>
<field name="product_uom" string="UoM" readonly="1" force_save="1" groups="uom.group_uom" optional="hide"/>
<field name="price_unit" string="Unit Price" force_save="1"/>
<field name="price_subtotal" string="Subtotal" readonly="1" force_save="1"/>
<field name="so_line_id" column_invisible="1" force_save="1"/>
</list>
</field>
<footer>
<button name="create_po" string="Create Purchase Orders" type="object" class="btn-primary"/>
<button special="cancel" string="Cancel" class="btn-secondary"/>
</footer>
</form>
</field>
</record>
<!-- Main Action -->
<record id="purchase_order_wizard_action" model="ir.actions.act_window">
<field name="name">Create Purchase Order</field>
<field name="res_model">purchaseorder.wiz</field>
<field name="type">ir.actions.act_window</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</data>
</odoo>