diff --git a/purchase_order_product_recommendation_supermarket/README.rst b/purchase_order_product_recommendation_supermarket/README.rst new file mode 100644 index 0000000..4ec0488 --- /dev/null +++ b/purchase_order_product_recommendation_supermarket/README.rst @@ -0,0 +1,30 @@ +================================================= +Purchase Order Product Recommendation Supermarket +================================================= + +Extends “purchase_order_product_recommendation” to add functionalities for food shops + +Purpose +======= + +#. Allows to indicate the number of days for which the quantity calculation will be made. If this field is defined, the quantity ordered will be calculated as “quantity/day * no. of days to cover”. +#. Allows to define the quantities according to the packaging of the products. +#. New check field “Do not take into account days with 0 stock”. In the case of activating this check, the desired behavior would be to disregard in the calculation of daily sales the days when the stock of that product was at 0 or less than 0. +#. New column indicating the scraps of the product in the period under analysis + +Configuration +============= + +To configure this module, you need to: + +#. Mark the desired settings in the wizard + +Usage +===== + +To use this module, you need to: + +#. Go to the purchase order form view. +#. Click on the "Recommend Products" button. +#. In the wizard that appears, configure the desired settings. +#. Click "Confirm" to generate the product recommendations. diff --git a/purchase_order_product_recommendation_supermarket/__init__.py b/purchase_order_product_recommendation_supermarket/__init__.py new file mode 100644 index 0000000..5cb1c49 --- /dev/null +++ b/purchase_order_product_recommendation_supermarket/__init__.py @@ -0,0 +1 @@ +from . import wizards diff --git a/purchase_order_product_recommendation_supermarket/__manifest__.py b/purchase_order_product_recommendation_supermarket/__manifest__.py new file mode 100644 index 0000000..51393df --- /dev/null +++ b/purchase_order_product_recommendation_supermarket/__manifest__.py @@ -0,0 +1,14 @@ +# Copyright 2025 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Purchase Order Product Recommendation Supermarket", + "summary": """Extends “purchase_order_product_recommendation” to add functionalities for food shops""", + "version": "16.0.0.1.0", + "license": "AGPL-3", + "author": "Criptomart", + "website": "https://criptomart.net", + "depends": ["purchase_order_product_recommendation"], + "data": ["wizards/purchase_order_recommendation.xml"], + "demo": [], +} diff --git a/purchase_order_product_recommendation_supermarket/i18n/es.po b/purchase_order_product_recommendation_supermarket/i18n/es.po new file mode 100644 index 0000000..47fa02e --- /dev/null +++ b/purchase_order_product_recommendation_supermarket/i18n/es.po @@ -0,0 +1,138 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * purchase_order_product_recommendation_supermarket +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-06-10 15:20+0000\n" +"PO-Revision-Date: 2025-06-10 17:33+0200\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 3.6\n" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__avg_days_between_orders +msgid "Average days between orders" +msgstr "Promedio de días entre pedidos" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__avg_days_between_orders +msgid "Average number of days between orders for this vendor." +msgstr "Número medio de días entre pedidos para este proveedor." + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation_line__days_without_stock +msgid "Days without stock" +msgstr "Días sin existencias" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__ignore_zero_stock_days +msgid "" +"If enabled, days when the product stock was 0 or less will not be " +"considered in the daily sales calculation." +msgstr "" +"Si se activa, los días en los que el stock del producto era 0 o negativo no " +"se tendrán en cuenta en el cálculo de las ventas diarias." + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__ignore_zero_stock_days +msgid "Ignore days with zero stock" +msgstr "Ignorar los días sin existencias" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__order_days +msgid "" +"Indicate for how many days the new order should cover the stock. If not " +"set, the default module behavior is kept." +msgstr "" +"Indica durante cuántos días la nueva orden debe cubrir el stock. Si no se " +"establece, se mantiene el comportamiento por defecto del módulo." + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__order_by_packages +msgid "Indicates if the order should be made by packages." +msgstr "Indica si el pedido debe realizarse por paquetes." + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__order_by_packages +msgid "Order by packages" +msgstr "Pedir por paquetes" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__order_days +msgid "Order coverage (days)" +msgstr "Días a cubrir" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation_line__packaging_id +msgid "Packaging" +msgstr "Empaquetado" + +#. module: purchase_order_product_recommendation_supermarket +#: model_terms:ir.ui.view,arch_db:purchase_order_product_recommendation_supermarket.view_purchase_order_recommendation_wizard_form_supermarket +msgid "Packaging Contained Qty" +msgstr "Ctd contenida en el paquete" + +#. module: purchase_order_product_recommendation_supermarket +#: model_terms:ir.ui.view,arch_db:purchase_order_product_recommendation_supermarket.view_purchase_order_recommendation_wizard_form_supermarket +msgid "Packaging Qty" +msgstr "Ctd Paquetes" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation_line__packaging_qty +msgid "Packaging Quantity" +msgstr "Cantidad de paquetes" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation_line__packaging_contained_qty +msgid "Packaging Quantity Contained" +msgstr "Ctd contenida en el paquete" + +#. module: purchase_order_product_recommendation_supermarket +#: model_terms:ir.ui.view,arch_db:purchase_order_product_recommendation_supermarket.view_purchase_order_recommendation_wizard_form_supermarket +msgid "Qty scrapped" +msgstr "Ctd desechada" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation_line__packaging_qty +msgid "Quantity contained in the selected packaging for this product." +msgstr "Cantidad contenida en el envase seleccionado para este producto." + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation_line__packaging_contained_qty +msgid "Quantity of packages to order for this product." +msgstr "Cantidad de paquetes a pedir para este producto." + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model,name:purchase_order_product_recommendation_supermarket.model_purchase_order_recommendation_line +msgid "Recommended product for current purchase order" +msgstr "Producto recomendado para el pedido de compra en curso" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model,name:purchase_order_product_recommendation_supermarket.model_purchase_order_recommendation +msgid "Recommended products for current purchase order" +msgstr "Productos recomendados para el pedido de compra en curso" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__total_days +msgid "Total days" +msgstr "Días totales" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__total_days +msgid "" +"Total number of days between the start and end dates of the recommendation." +msgstr "" +"Número total de días entre las fechas de inicio y fin de la recomendación." + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation_line__units_scrapped +msgid "Units Scrapped" +msgstr "Ctd desechada" diff --git a/purchase_order_product_recommendation_supermarket/i18n/purchase_order_product_recommendation_supermarket.pot b/purchase_order_product_recommendation_supermarket/i18n/purchase_order_product_recommendation_supermarket.pot new file mode 100644 index 0000000..007ccf3 --- /dev/null +++ b/purchase_order_product_recommendation_supermarket/i18n/purchase_order_product_recommendation_supermarket.pot @@ -0,0 +1,131 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * purchase_order_product_recommendation_supermarket +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-06-10 15:19+0000\n" +"PO-Revision-Date: 2025-06-10 15:19+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__avg_days_between_orders +msgid "Average days between orders" +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__avg_days_between_orders +msgid "Average number of days between orders for this vendor." +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation_line__days_without_stock +msgid "Days without stock" +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__ignore_zero_stock_days +msgid "" +"If enabled, days when the product stock was 0 or less will not be considered" +" in the daily sales calculation." +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__ignore_zero_stock_days +msgid "Ignore days with zero stock" +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__order_days +msgid "" +"Indicate for how many days the new order should cover the stock. If not set," +" the default module behavior is kept." +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__order_by_packages +msgid "Indicates if the order should be made by packages." +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__order_by_packages +msgid "Order by packages" +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__order_days +msgid "Order coverage (days)" +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation_line__packaging_id +msgid "Packaging" +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model_terms:ir.ui.view,arch_db:purchase_order_product_recommendation_supermarket.view_purchase_order_recommendation_wizard_form_supermarket +msgid "Packaging Contained Qty" +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model_terms:ir.ui.view,arch_db:purchase_order_product_recommendation_supermarket.view_purchase_order_recommendation_wizard_form_supermarket +msgid "Packaging Qty" +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation_line__packaging_qty +msgid "Packaging Quantity" +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation_line__packaging_contained_qty +msgid "Packaging Quantity Contained" +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model_terms:ir.ui.view,arch_db:purchase_order_product_recommendation_supermarket.view_purchase_order_recommendation_wizard_form_supermarket +msgid "Qty scrapped" +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation_line__packaging_qty +msgid "Quantity contained in the selected packaging for this product." +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation_line__packaging_contained_qty +msgid "Quantity of packages to order for this product." +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model,name:purchase_order_product_recommendation_supermarket.model_purchase_order_recommendation_line +msgid "Recommended product for current purchase order" +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model,name:purchase_order_product_recommendation_supermarket.model_purchase_order_recommendation +msgid "Recommended products for current purchase order" +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__total_days +msgid "Total days" +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__total_days +msgid "" +"Total number of days between the start and end dates of the recommendation." +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation_line__units_scrapped +msgid "Units Scrapped" +msgstr "" diff --git a/purchase_order_product_recommendation_supermarket/wizards/__init__.py b/purchase_order_product_recommendation_supermarket/wizards/__init__.py new file mode 100644 index 0000000..f0ed6f1 --- /dev/null +++ b/purchase_order_product_recommendation_supermarket/wizards/__init__.py @@ -0,0 +1 @@ +from . import purchase_order_recommendation diff --git a/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.py b/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.py new file mode 100644 index 0000000..f16c2d5 --- /dev/null +++ b/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.py @@ -0,0 +1,217 @@ +from odoo import api, fields, models +import math +from datetime import timedelta, datetime, time + + +class PurchaseOrderRecommendationSupermarketWizard(models.TransientModel): + _inherit = "purchase.order.recommendation" + + order_days = fields.Integer( + string="Order coverage (days)", + help="Indicate for how many days the new order should cover the stock. If not set, the default module behavior is kept.", + ) + order_by_packages = fields.Boolean( + string="Order by packages", + default=False, + help="Indicates if the order should be made by packages.", + ) + ignore_zero_stock_days = fields.Boolean( + string="Ignore days with zero stock", + default=False, + help="If enabled, days when the product stock was 0 or less will not be considered in the daily sales calculation.", + ) + total_days = fields.Integer( + string="Total days", + compute="_compute_total_days", + readonly=True, + help="Total number of days between the start and end dates of the recommendation.", + ) + avg_days_between_orders = fields.Float( + string="Average days between orders", + compute="_compute_avg_days_between_orders", + help="Average number of days between orders for this vendor.", + readonly=True, + ) + + @api.depends("date_begin", "date_end") + def _compute_total_days(self): + for rec in self: + if rec.date_begin and rec.date_end: + rec.total_days = self._get_total_days() + else: + rec.total_days = 0 + + @api.depends("order_id") + def _compute_avg_days_between_orders(self): + if not self.order_id.partner_id: + self.avg_days_between_orders = 0.0 + return + + orders = self.env["purchase.order"].search( + [ + ("partner_id", "=", self.order_id.partner_id.id), + ("state", "in", ["purchase", "done"]), + ], + order="date_order asc", + ) + + dates = [o.date_order.date() for o in orders if o.date_order] + if len(dates) < 2: + self.avg_days_between_orders = 0.0 + return + + day_diffs = [ + (dates[i + 1] - dates[i]).days + for i in range(len(dates) - 1) + if (dates[i + 1] - dates[i]).days > 0 + ] + + if not day_diffs: + self.avg_days_between_orders = 0.0 + else: + self.avg_days_between_orders = sum(day_diffs) / len(day_diffs) + + @api.onchange( + "order_days", + "order_by_packages", + "ignore_zero_stock_days", + ) + def _onchange_order_fields(self): + self._generate_recommendations() + + def _prepare_wizard_line(self, vals, order_line=False): + """Used to create the wizard line""" + res = super()._prepare_wizard_line(vals, order_line=order_line) + product_id = order_line and order_line.product_id or vals["product_id"] + qty_to_order = res["units_included"] + + if self.ignore_zero_stock_days: + days_with_stock = self._get_total_days() - self._get_days_out_of_stock( + product_id + ) + res["units_avg_delivered"] = ( + vals.get("qty_delivered", 0) / days_with_stock + if days_with_stock != 0 + else 1 + ) + + if self.order_days != 0: + qty_to_order = (self.order_days * res["units_avg_delivered"]) - res[ + "units_virtual_available" + ] + + # Adjust qty_to_order to packaging multiples if order_by_packages is checked + if self.order_by_packages and product_id.packaging_ids: + packaging_qty = product_id.packaging_ids[:1].qty + if packaging_qty: + qty_to_order = math.ceil(qty_to_order / packaging_qty) * packaging_qty + res["units_included"] = qty_to_order + + # Get quantities scrapped + domain = self._get_move_line_domain(product_id, src="internal", dst="inventory") + found_scrapped = self.env["stock.move.line"].read_group( + domain, ["product_id", "qty_done"], ["product_id"] + ) + if len(found_scrapped): + res["units_scrapped"] = found_scrapped[0]["qty_done"] + + return res + + def _get_days_out_of_stock(self, product): + """ + Returns the number of days between date_begin and date_end + where the given product had zero or negative stock (qty_available). + """ + days_out_of_stock = 0 + date_from = self.date_begin + date_to = self.date_end + if not date_from or not date_to: + return 0 + # Loop through each day in the range + for n in range((date_to - date_from).days + 1): + day = date_from + timedelta(days=n) + qty = product.with_context( + to_date=datetime.combine(day, time(23, 59, 59)) + ).qty_available + if qty <= 0: + days_out_of_stock += 1 + return days_out_of_stock + + def _get_products(self): + """Overwrite because filter by cateogory is not working in the base method.""" + products = self._get_supplier_products() + # Filter products by category if set. + # It will apply to show_all_partner_products as well + if self.product_category_ids: + products = products.filtered( + lambda x: x.categ_id.id in self.product_category_ids.ids + ) + print(self.product_category_ids) + return products + + +class PurchaseOrderRecommendationLine(models.TransientModel): + _inherit = "purchase.order.recommendation.line" + + packaging_id = fields.Many2one( + comodel_name="product.packaging", + string="Packaging", + compute="_compute_first_packaging_id", + readonly=True, + ) + packaging_qty = fields.Integer( + string="Packaging Quantity", + help="Quantity contained in the selected packaging for this product.", + compute="_compute_packaging_qty", + inverse="_inverse_packaging_qty", + store=True, + readonly=False, + ) + + packaging_contained_qty = fields.Float( + string="Packaging Quantity Contained", + help="Quantity of packages to order for this product.", + related="packaging_id.qty", + readonly=True, + ) + days_without_stock = fields.Integer( + string="Days without stock", + compute="_compute_days_without_stock", + readonly=True, + ) + units_scrapped = fields.Float( + readonly=True, + ) + + @api.depends("wizard_id.date_begin", "wizard_id.date_end") + def _compute_days_without_stock(self): + for rec in self: + if rec.product_id: + rec.days_without_stock = rec.wizard_id._get_days_out_of_stock( + rec.product_id + ) + else: + rec.days_without_stock = 0 + + @api.onchange("packaging_qty") + def _inverse_packaging_qty(self): + for rec in self: + if rec.packaging_id and rec.packaging_id.qty: + rec.units_included = rec.packaging_qty * rec.packaging_id.qty + + @api.depends("packaging_id", "units_included") + def _compute_packaging_qty(self): + for rec in self: + if rec.packaging_id and rec.packaging_id.qty: + rec.packaging_qty = int(rec.units_included // rec.packaging_id.qty) + else: + rec.packaging_qty = 0 + + @api.depends("product_id") + def _compute_first_packaging_id(self): + for rec in self: + rec.packaging_id = ( + rec.product_id.packaging_ids[:1].id + if rec.product_id and rec.product_id.packaging_ids + else False + ) diff --git a/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.xml b/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.xml new file mode 100644 index 0000000..9748c9d --- /dev/null +++ b/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.xml @@ -0,0 +1,26 @@ + + + + purchase.order.recommendation.wizard.form.supermarket + purchase.order.recommendation + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file