Initial commit: Odoo 18.0-20251222 extra-addons
Some checks failed
pre-commit / pre-commit (push) Has been cancelled
tests / Detect unreleased dependencies (push) Has been cancelled
tests / test with OCB (push) Has been cancelled
tests / test with Odoo (push) Has been cancelled

This commit is contained in:
tocmo0nlord
2026-03-13 20:43:25 +00:00
parent 36e847a7df
commit adbe430761
9472 changed files with 1265727 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import sale_order_recommendation

View 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

View 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>