Initial commit: Odoo 18.0-20251222 extra-addons
This commit is contained in:
3
sale_order_product_recommendation/wizards/__init__.py
Executable file
3
sale_order_product_recommendation/wizards/__init__.py
Executable file
@@ -0,0 +1,3 @@
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from . import sale_order_recommendation
|
||||
375
sale_order_product_recommendation/wizards/sale_order_recommendation.py
Executable file
375
sale_order_product_recommendation/wizards/sale_order_recommendation.py
Executable file
@@ -0,0 +1,375 @@
|
||||
# Copyright 2017 Jairo Llopis <jairo.llopis@tecnativa.com>
|
||||
# Copyright 2018 Carlos Dauden <carlos.dauden@tecnativa.com>
|
||||
# Copyright 2020 Tecnativa - Pedro M. Baeza
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.osv import expression
|
||||
from odoo.tools.safe_eval import safe_eval
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SaleOrderRecommendation(models.TransientModel):
|
||||
_name = "sale.order.recommendation"
|
||||
_description = "Recommended products for current sale order"
|
||||
|
||||
order_id = fields.Many2one(
|
||||
"sale.order",
|
||||
"Sale Order",
|
||||
default=lambda self: self._default_order_id(),
|
||||
required=True,
|
||||
readonly=True,
|
||||
ondelete="cascade",
|
||||
)
|
||||
line_ids = fields.One2many(
|
||||
"sale.order.recommendation.line", "wizard_id", "Products"
|
||||
)
|
||||
recommendations_order = fields.Selection(
|
||||
[
|
||||
("times_delivered desc", "Times delivered"),
|
||||
("units_delivered desc", "Units delivered"),
|
||||
("product_categ_complete_name asc", "Product category"),
|
||||
("product_default_code asc", "Product code"),
|
||||
("product_name asc", "Product name"),
|
||||
],
|
||||
required=True,
|
||||
default="times_delivered desc",
|
||||
)
|
||||
# Get default value from config settings
|
||||
use_delivery_address = fields.Boolean(string="Use delivery address")
|
||||
sale_recommendation_price_origin = fields.Selection(
|
||||
[("pricelist", "Pricelist"), ("last_sale_price", "Last sale price")],
|
||||
string="Product price origin",
|
||||
default="pricelist",
|
||||
)
|
||||
line_amount = fields.Integer(
|
||||
"Number of recommendations",
|
||||
default=15,
|
||||
required=True,
|
||||
help="The less, the faster they will be found.",
|
||||
)
|
||||
months = fields.Float(
|
||||
default=6,
|
||||
required=True,
|
||||
help="Consider these months backwards to generate recommendations.",
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _default_order_id(self):
|
||||
return self.env.context.get("active_id", False)
|
||||
|
||||
def _extended_recommendable_sale_order_lines_domain(self):
|
||||
"""Extra domain to include or exclude SO lines"""
|
||||
return safe_eval(self.env.user.company_id.sale_line_recommendation_domain)
|
||||
|
||||
def _recommendable_sale_order_lines_domain(self):
|
||||
"""Domain to find recent SO lines."""
|
||||
start = datetime.now() - timedelta(days=self.months * 30)
|
||||
start = fields.Datetime.to_string(start)
|
||||
partner = (
|
||||
self.order_id.partner_shipping_id
|
||||
if self.use_delivery_address
|
||||
else self.order_id.partner_id.commercial_partner_id
|
||||
)
|
||||
sale_order_partner_field = (
|
||||
"partner_shipping_id" if self.use_delivery_address else "partner_id"
|
||||
)
|
||||
# Search with sudo for get sale order from other commercials users
|
||||
other_sales = (
|
||||
self.env["sale.order"]
|
||||
.sudo()
|
||||
.search(
|
||||
[
|
||||
("company_id", "=", self.order_id.company_id.id),
|
||||
(sale_order_partner_field, "child_of", partner.id),
|
||||
("date_order", ">=", start),
|
||||
]
|
||||
)
|
||||
)
|
||||
domain = [
|
||||
("order_id", "in", (other_sales - self.order_id).ids),
|
||||
("product_id.active", "=", True),
|
||||
("product_id.sale_ok", "=", True),
|
||||
("qty_delivered", "!=", 0.0),
|
||||
]
|
||||
# Exclude delivery products
|
||||
# We can not use the method _is_delivery() from sale module because we are
|
||||
# doing a domain for a readgroup query
|
||||
if "is_delivery" in self.env["sale.order.line"]._fields:
|
||||
domain.append(("is_delivery", "=", False))
|
||||
extended_domain = self._extended_recommendable_sale_order_lines_domain()
|
||||
domain = expression.AND([domain, extended_domain])
|
||||
return domain
|
||||
|
||||
def _prepare_recommendation_line_vals(self, group_line, so_line=False):
|
||||
"""Return the vals dictionary for creating a new recommendation line.
|
||||
@param group_line: Dictionary returned by the read_group operation.
|
||||
@param so_line: Optional sales order line
|
||||
"""
|
||||
vals = {
|
||||
"product_id": group_line[0].id if group_line else so_line.product_id.id,
|
||||
"times_delivered": group_line[1] if group_line else 0.0,
|
||||
"units_delivered": group_line[2] if group_line else 0.0,
|
||||
}
|
||||
if so_line:
|
||||
vals["units_included"] = so_line.product_uom_qty
|
||||
vals["sale_line_id"] = so_line.id
|
||||
return vals
|
||||
|
||||
def _reopen_wizard(self):
|
||||
"""Tell the client to close the wizard and open it again."""
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": self._name,
|
||||
"res_id": self.id,
|
||||
"view_mode": "form",
|
||||
"target": "new",
|
||||
}
|
||||
|
||||
def action_reset(self):
|
||||
"""Empty the list of recommendations."""
|
||||
self.line_ids = False
|
||||
return self._reopen_wizard()
|
||||
|
||||
def generate_recommendations(self):
|
||||
"""Generate lines according to context sale order."""
|
||||
# Search delivered products in previous months
|
||||
# Search with sudo for get sale order from other commercials users
|
||||
found_lines = (
|
||||
self.env["sale.order.line"]
|
||||
.sudo()
|
||||
._read_group(
|
||||
self._recommendable_sale_order_lines_domain(),
|
||||
["product_id"],
|
||||
["__count", "qty_delivered:sum"],
|
||||
limit=self.line_amount,
|
||||
order="__count desc, qty_delivered:sum desc",
|
||||
)
|
||||
)
|
||||
found_dict = {data[0]: data for data in found_lines}
|
||||
recommendation_lines = self.env["sale.order.recommendation.line"]
|
||||
existing_product_ids = set()
|
||||
# Always recommend all products already present in the linked SO except delivery
|
||||
# carrier products
|
||||
for line in self.order_id.order_line.filtered(lambda ln: not ln._is_delivery()):
|
||||
found_line = found_dict.get(
|
||||
line.product_id,
|
||||
False,
|
||||
)
|
||||
new_line = recommendation_lines.new(
|
||||
self._prepare_recommendation_line_vals(found_line, line)
|
||||
)
|
||||
recommendation_lines += new_line
|
||||
existing_product_ids.add(line.product_id.id)
|
||||
# Add recent SO recommendations too
|
||||
for found_line in found_lines:
|
||||
if found_line[0].id in existing_product_ids:
|
||||
continue
|
||||
new_line = recommendation_lines.new(
|
||||
self._prepare_recommendation_line_vals(found_line)
|
||||
)
|
||||
recommendation_lines += new_line
|
||||
# Sort recommendations by user choice
|
||||
order_field, order_dir = map(str.lower, self.recommendations_order.split())
|
||||
# Priority order (which can have an str value "0" or "1") must always
|
||||
# default to DESC, no matter the order_dir; so we inverse it if it's ASC
|
||||
priority_multiplier = 1 if order_dir == "desc" else -1
|
||||
self.line_ids = recommendation_lines.sorted(
|
||||
key=lambda line: (
|
||||
"" if line[order_field] is False else line[order_field],
|
||||
int(line.product_id.sequence) * priority_multiplier,
|
||||
),
|
||||
reverse=order_dir == "desc",
|
||||
)
|
||||
if not self.line_ids:
|
||||
raise UserError(
|
||||
_("Nothing found! Modify your criteria or fill the order manually.")
|
||||
)
|
||||
# Reopen wizard
|
||||
return self._reopen_wizard()
|
||||
|
||||
def action_accept(self):
|
||||
"""Propagate recommendations to sale order."""
|
||||
sequence = max(self.order_id.mapped("order_line.sequence") or [0])
|
||||
to_remove = []
|
||||
sale_order_line_obj = self.env["sale.order.line"].sudo()
|
||||
new_line_vals = []
|
||||
force_zero_units_included = self.env.user.company_id.force_zero_units_included
|
||||
for wiz_line in self.line_ids:
|
||||
if (
|
||||
not wiz_line.sale_line_id
|
||||
and not wiz_line.units_included
|
||||
and not force_zero_units_included
|
||||
):
|
||||
continue
|
||||
if wiz_line.sale_line_id:
|
||||
if wiz_line.units_included or force_zero_units_included:
|
||||
wiz_line.sale_line_id.update(
|
||||
wiz_line._prepare_update_so_line_vals()
|
||||
)
|
||||
else:
|
||||
to_remove.append(wiz_line.sale_line_id.id)
|
||||
else:
|
||||
sequence += 1
|
||||
vals = wiz_line._prepare_new_so_line_vals(sequence)
|
||||
new_line_vals.append(vals)
|
||||
# Remove at the end and in reverse order for not having problems
|
||||
to_remove.reverse()
|
||||
sale_order_line_obj.browse(to_remove).unlink()
|
||||
if new_line_vals:
|
||||
sale_order_line_obj.create(new_line_vals)
|
||||
|
||||
|
||||
class SaleOrderRecommendationLine(models.TransientModel):
|
||||
_name = "sale.order.recommendation.line"
|
||||
_description = "Recommended product for current sale order"
|
||||
_order = "product_is_favorite desc, id"
|
||||
|
||||
currency_id = fields.Many2one(related="product_id.currency_id")
|
||||
partner_id = fields.Many2one(related="wizard_id.order_id.partner_id")
|
||||
product_id = fields.Many2one("product.product")
|
||||
product_name = fields.Char(related="product_id.name", readonly=True)
|
||||
product_categ_complete_name = fields.Char(
|
||||
string="Product category",
|
||||
related="product_id.categ_id.complete_name",
|
||||
readonly=True,
|
||||
store=True,
|
||||
)
|
||||
product_default_code = fields.Char(
|
||||
related="product_id.default_code", readonly=True, store=True
|
||||
)
|
||||
product_is_favorite = fields.Boolean(
|
||||
related="product_id.is_favorite", store=True, readonly=False
|
||||
)
|
||||
product_uom_readonly = fields.Boolean(related="sale_line_id.product_uom_readonly")
|
||||
product_uom_category_id = fields.Many2one(
|
||||
related="product_id.uom_id.category_id", depends=["product_id"]
|
||||
)
|
||||
price_unit = fields.Monetary(compute="_compute_price_unit")
|
||||
pricelist_id = fields.Many2one(related="wizard_id.order_id.pricelist_id")
|
||||
times_delivered = fields.Integer(readonly=True)
|
||||
units_delivered = fields.Float(readonly=True)
|
||||
units_included = fields.Float()
|
||||
wizard_id = fields.Many2one(
|
||||
"sale.order.recommendation",
|
||||
"Wizard",
|
||||
ondelete="cascade",
|
||||
required=True,
|
||||
readonly=True,
|
||||
)
|
||||
sale_line_id = fields.Many2one(comodel_name="sale.order.line")
|
||||
sale_uom_id = fields.Many2one(
|
||||
comodel_name="uom.uom",
|
||||
string="Unit of Measure",
|
||||
compute="_compute_sale_uom_id",
|
||||
store=True,
|
||||
readonly=False,
|
||||
domain="[('category_id', '=', product_uom_category_id)]",
|
||||
)
|
||||
|
||||
@api.depends("sale_line_id", "product_id")
|
||||
def _compute_sale_uom_id(self):
|
||||
for line in self:
|
||||
line.sale_uom_id = line.sale_line_id.product_uom or line.product_id.uom_id
|
||||
|
||||
@api.depends(
|
||||
"partner_id",
|
||||
"product_id",
|
||||
"pricelist_id",
|
||||
"units_included",
|
||||
"wizard_id.sale_recommendation_price_origin",
|
||||
)
|
||||
def _compute_price_unit(self):
|
||||
"""
|
||||
Get product price unit from product list price or from last sale price
|
||||
"""
|
||||
price_origin = (
|
||||
fields.first(self).wizard_id.sale_recommendation_price_origin or "pricelist"
|
||||
)
|
||||
for line in self:
|
||||
if price_origin == "pricelist":
|
||||
line.price_unit = line._get_unit_price_from_pricelist()
|
||||
else:
|
||||
line.price_unit = line._get_last_sale_price_product()
|
||||
|
||||
def _prepare_update_so_line_vals(self):
|
||||
vals = {"product_uom_qty": self.units_included}
|
||||
if not self.product_uom_readonly:
|
||||
vals["product_uom"] = self.sale_uom_id.id
|
||||
return vals
|
||||
|
||||
def _prepare_new_so_line_vals(self, sequence):
|
||||
vals = {
|
||||
"product_id": self.product_id.id,
|
||||
"sequence": sequence,
|
||||
"product_uom_qty": self.units_included,
|
||||
"order_id": self.wizard_id.order_id.id,
|
||||
}
|
||||
if not self.product_uom_readonly:
|
||||
vals["product_uom"] = self.sale_uom_id.id
|
||||
if self.wizard_id.sale_recommendation_price_origin == "last_sale_price":
|
||||
vals["price_unit"] = self.price_unit
|
||||
return vals
|
||||
|
||||
def _get_last_sale_price_product(self):
|
||||
"""
|
||||
Get last price from last order.
|
||||
Use sudo to read sale order from other users like as other commercials.
|
||||
"""
|
||||
self.ensure_one()
|
||||
so = (
|
||||
self.env["sale.order"]
|
||||
.sudo()
|
||||
.search(
|
||||
[
|
||||
("company_id", "=", self.wizard_id.order_id.company_id.id),
|
||||
("partner_id", "=", self.partner_id.id),
|
||||
("date_order", "!=", False),
|
||||
("state", "not in", ("draft", "sent", "cancel")),
|
||||
("order_line.product_id", "=", self.product_id.id),
|
||||
],
|
||||
limit=1,
|
||||
order="date_order DESC, id DESC",
|
||||
)
|
||||
)
|
||||
so_line = (
|
||||
self.env["sale.order.line"]
|
||||
.sudo()
|
||||
.search(
|
||||
[("order_id", "=", so.id), ("product_id", "=", self.product_id.id)],
|
||||
limit=1,
|
||||
order="id DESC",
|
||||
)
|
||||
.with_context(prefetch_fields=False)
|
||||
)
|
||||
return so_line.price_unit or 0.0
|
||||
|
||||
def _get_unit_price_from_pricelist(self):
|
||||
pricelist_rule_id = self.pricelist_id._get_product_rule(
|
||||
self.product_id,
|
||||
self.units_included or 1.0,
|
||||
uom=self.sale_uom_id or self.product_id.uom_id,
|
||||
date=self.wizard_id.order_id.date_order,
|
||||
)
|
||||
pricelist_rule = self.env["product.pricelist.item"].browse(pricelist_rule_id)
|
||||
price_rule = pricelist_rule._compute_price(
|
||||
self.product_id,
|
||||
self.units_included,
|
||||
self.sale_uom_id or self.product_id.uom_id,
|
||||
self.wizard_id.order_id.date_order,
|
||||
currency=self.currency_id,
|
||||
)
|
||||
price_unit = self.product_id._get_tax_included_unit_price(
|
||||
self.wizard_id.order_id.company_id,
|
||||
self.currency_id,
|
||||
self.wizard_id.order_id.date_order,
|
||||
"sale",
|
||||
fiscal_position=self.wizard_id.order_id.fiscal_position_id,
|
||||
product_price_unit=price_rule,
|
||||
product_currency=self.currency_id,
|
||||
)
|
||||
return price_unit
|
||||
193
sale_order_product_recommendation/wizards/sale_order_recommendation_view.xml
Executable file
193
sale_order_product_recommendation/wizards/sale_order_recommendation_view.xml
Executable file
@@ -0,0 +1,193 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<!-- Copyright 2017 Jairo Llopis <jairo.llopis@tecnativa.com>
|
||||
Copyright 2020 Tecnativa - Pedro M. Baeza
|
||||
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
|
||||
<odoo>
|
||||
<record id="sale_order_recommendation_view_form" model="ir.ui.view">
|
||||
<field name="name">Recommended Products for this Customer</field>
|
||||
<field name="model">sale.order.recommendation</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group invisible="line_ids">
|
||||
<group>
|
||||
<field name="order_id" invisible="1" />
|
||||
<field name="months" widget="numeric_step" />
|
||||
<field name="line_amount" widget="numeric_step" />
|
||||
</group>
|
||||
<group>
|
||||
<field name="sale_recommendation_price_origin" />
|
||||
<field name="use_delivery_address" />
|
||||
<field name="recommendations_order" />
|
||||
</group>
|
||||
</group>
|
||||
<group col="2">
|
||||
<field
|
||||
name="line_ids"
|
||||
nolabel="1"
|
||||
mode="list,kanban"
|
||||
colspan="2"
|
||||
invisible="not line_ids"
|
||||
>
|
||||
<list create="0" delete="0" editable="top">
|
||||
<field
|
||||
name="product_is_favorite"
|
||||
widget="boolean_favorite"
|
||||
optional="show"
|
||||
nolabel="1"
|
||||
/>
|
||||
<field name="currency_id" column_invisible="1" />
|
||||
<field name="sale_line_id" column_invisible="1" />
|
||||
<field
|
||||
name="product_categ_complete_name"
|
||||
optional="show"
|
||||
/>
|
||||
<field
|
||||
name="product_id"
|
||||
readonly="1"
|
||||
force_save="1"
|
||||
optional="hide"
|
||||
/>
|
||||
<field name="product_default_code" optional="show" />
|
||||
<field name="product_name" optional="show" />
|
||||
<field name="price_unit" optional="show" />
|
||||
<field name="times_delivered" optional="show" />
|
||||
<field name="units_delivered" optional="show" />
|
||||
<field name="units_included" widget="numeric_step" />
|
||||
<field
|
||||
name="product_uom_category_id"
|
||||
column_invisible="1"
|
||||
/>
|
||||
<field
|
||||
name="product_uom_readonly"
|
||||
column_invisible="1"
|
||||
/>
|
||||
<field
|
||||
name="sale_uom_id"
|
||||
optional="show"
|
||||
groups="uom.group_uom"
|
||||
readonly="product_uom_readonly"
|
||||
/>
|
||||
</list>
|
||||
<kanban
|
||||
class="o_kanban_mobile"
|
||||
quick_create="0"
|
||||
create="0"
|
||||
delete="0"
|
||||
>
|
||||
<field name="id" />
|
||||
<field name="product_id" />
|
||||
<field name="sale_line_id" />
|
||||
<field name="price_unit" />
|
||||
<field name="sale_uom_id" />
|
||||
<field name="times_delivered" />
|
||||
<field name="units_delivered" />
|
||||
<field name="units_included" />
|
||||
<field name="currency_id" />
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<div class="oe_kanban_global_click">
|
||||
<div class="o_kanban_image me-1">
|
||||
<img
|
||||
t-att-src="kanban_image('product.product', 'image_128', record.product_id.raw_value)"
|
||||
alt="Product"
|
||||
class="o_image_64_contain"
|
||||
/>
|
||||
</div>
|
||||
<div class="oe_kanban_details">
|
||||
<div class="o_kanban_record_top mb-0">
|
||||
<div
|
||||
class="o_kanban_record_headings"
|
||||
>
|
||||
<field
|
||||
name="product_id"
|
||||
class="o_kanban_record_title"
|
||||
/>
|
||||
</div>
|
||||
<field
|
||||
class="w-auto mb-0"
|
||||
name="product_is_favorite"
|
||||
widget="priority"
|
||||
/>
|
||||
</div>
|
||||
<div>Price: <field
|
||||
name="price_unit"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id', 'field_digits': True}"
|
||||
class="oe_inline mt-0 mb-0"
|
||||
/></div>
|
||||
<div>Delivered: <field
|
||||
name="times_delivered"
|
||||
/> times - <field
|
||||
name="units_delivered"
|
||||
/> <field name="sale_uom_id" /></div>
|
||||
<div>
|
||||
<strong>
|
||||
<span>To be included: </span>
|
||||
<field name="units_included" />
|
||||
<span />
|
||||
<field
|
||||
name="sale_uom_id"
|
||||
groups="uom.group_uom"
|
||||
/>
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
<form>
|
||||
<group>
|
||||
<field name="wizard_id" invisible="1" />
|
||||
<field
|
||||
name="product_id"
|
||||
readonly="1"
|
||||
force_save="1"
|
||||
/>
|
||||
<field name="price_unit" />
|
||||
<field
|
||||
name="units_included"
|
||||
widget="numeric_step"
|
||||
/>
|
||||
</group>
|
||||
</form>
|
||||
</field>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button
|
||||
invisible="line_ids"
|
||||
class="btn-primary"
|
||||
icon="fa-refresh"
|
||||
name="generate_recommendations"
|
||||
string="Get recommendations"
|
||||
type="object"
|
||||
/>
|
||||
<button
|
||||
invisible="not line_ids"
|
||||
name="action_accept"
|
||||
type="object"
|
||||
string="Accept"
|
||||
class="oe_highlight"
|
||||
/>
|
||||
<button
|
||||
invisible="not line_ids"
|
||||
confirm="You will lose any changes you have made. Are you sure?"
|
||||
name="action_reset"
|
||||
string="Change criteria"
|
||||
type="object"
|
||||
/>
|
||||
<button special="cancel" string="Cancel" />
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="sale_order_recommendation_action" model="ir.actions.act_window">
|
||||
<field name="name">Recommended Products for this Customer</field>
|
||||
<field name="res_model">sale.order.recommendation</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user