From 2c52a9f36e1f0a592b08b6c839417746dbfc72d4 Mon Sep 17 00:00:00 2001 From: luis Date: Mon, 6 Oct 2025 14:19:28 +0200 Subject: [PATCH] CriptoMart/red-supermercados-coop#1 purchase_order_product_recommendation_supermarket: add cron to store periods out of stock in products. compute days without stock based on this new model --- .../__manifest__.py | 2 + .../data/stock_out_cron.xml | 15 +++ .../i18n/es.po | 60 ++++++++++ ...der_product_recommendation_supermarket.pot | 60 ++++++++++ .../models/product_template.py | 105 +++++++++++++++++- .../security/ir.model.access.csv | 2 + .../views/product_template_view.xml | 29 ++++- .../wizards/purchase_order_recommendation.py | 33 +++--- 8 files changed, 285 insertions(+), 21 deletions(-) create mode 100644 purchase_order_product_recommendation_supermarket/data/stock_out_cron.xml create mode 100644 purchase_order_product_recommendation_supermarket/security/ir.model.access.csv diff --git a/purchase_order_product_recommendation_supermarket/__manifest__.py b/purchase_order_product_recommendation_supermarket/__manifest__.py index 6797bee..27a1a9b 100644 --- a/purchase_order_product_recommendation_supermarket/__manifest__.py +++ b/purchase_order_product_recommendation_supermarket/__manifest__.py @@ -10,8 +10,10 @@ "website": "https://criptomart.net", "depends": ["purchase_order_product_recommendation"], "data": [ + "security/ir.model.access.csv", "wizards/purchase_order_recommendation.xml", "views/product_template_view.xml", + "data/stock_out_cron.xml", ], "demo": [], } diff --git a/purchase_order_product_recommendation_supermarket/data/stock_out_cron.xml b/purchase_order_product_recommendation_supermarket/data/stock_out_cron.xml new file mode 100644 index 0000000..72843b6 --- /dev/null +++ b/purchase_order_product_recommendation_supermarket/data/stock_out_cron.xml @@ -0,0 +1,15 @@ + + + + Update Stock-out Periods + + code + model.cron_update_stock_out_periods() + + True + 1 + days + -1 + False + + diff --git a/purchase_order_product_recommendation_supermarket/i18n/es.po b/purchase_order_product_recommendation_supermarket/i18n/es.po index 937d6f4..b05b268 100644 --- a/purchase_order_product_recommendation_supermarket/i18n/es.po +++ b/purchase_order_product_recommendation_supermarket/i18n/es.po @@ -15,6 +15,66 @@ msgstr "" "Content-Transfer-Encoding: \n" "Plural-Forms: \n" +#. module: purchase_order_product_recommendation_supermarket +#: model_terms:ir.ui.view,arch_db:purchase_order_product_recommendation_supermarket.view_product_template_form_po_min_suggest +msgid "Stock-out Periods" +msgstr "Periodos sin stock" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_product_template__stock_out_period_ids +msgid "Stock-out Periods" +msgstr "Periodos sin stock" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_product_stock_out_period__start_date +msgid "Start Date" +msgstr "Fecha inicio" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_product_stock_out_period__end_date +msgid "End Date" +msgstr "Fecha fin" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_product_stock_out_period__start_date +msgid "First calendar day when the product had zero stock in this period." +msgstr "Primer día (calendario) en el que el producto estuvo sin stock en este periodo." + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_product_stock_out_period__end_date +msgid "Last calendar day (inclusive) the product remained without stock in this period." +msgstr "Último día (incluido) en el que el producto siguió sin stock en este periodo." + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_product_stock_out_period__is_open +msgid "Open" +msgstr "Abierto" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_product_stock_out_period__days_out +msgid "Days without Stock" +msgstr "Días sin stock" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_product_stock_out_period__product_tmpl_id +msgid "Product Template" +msgstr "Plantilla de producto" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_product_stock_out_period__product_tmpl_id +msgid "Product template this stock-out period belongs to." +msgstr "Plantilla de producto a la que pertenece este periodo sin stock." + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_product_stock_out_period__is_open +msgid "Indicates the period is still ongoing (stock not yet restored)." +msgstr "Indica que el periodo sigue en curso (el stock todavía no se ha recuperado)." + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_product_stock_out_period__days_out +msgid "Total number of calendar days (inclusive) without stock for this period." +msgstr "Número total de días naturales (incluidos) sin stock durante este periodo." + #. module: purchase_order_product_recommendation_supermarket #: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__include_previous_period msgid "Include previous period stats" 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 index 0f5845f..c2dd1d3 100644 --- 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 @@ -15,6 +15,66 @@ msgstr "" "Content-Transfer-Encoding: \n" "Plural-Forms: \n" +#. module: purchase_order_product_recommendation_supermarket +#: model_terms:ir.ui.view,arch_db:purchase_order_product_recommendation_supermarket.view_product_template_form_po_min_suggest +msgid "Stock-out Periods" +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_product_template__stock_out_period_ids +msgid "Stock-out Periods" +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_product_stock_out_period__start_date +msgid "Start Date" +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_product_stock_out_period__end_date +msgid "End Date" +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_product_stock_out_period__start_date +msgid "First calendar day when the product had zero stock in this period." +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_product_stock_out_period__end_date +msgid "Last calendar day (inclusive) the product remained without stock in this period." +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_product_stock_out_period__is_open +msgid "Open" +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_product_stock_out_period__days_out +msgid "Days without Stock" +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_product_stock_out_period__product_tmpl_id +msgid "Product Template" +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_product_stock_out_period__product_tmpl_id +msgid "Product template this stock-out period belongs to." +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_product_stock_out_period__is_open +msgid "Indicates the period is still ongoing (stock not yet restored)." +msgstr "" + +#. module: purchase_order_product_recommendation_supermarket +#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_product_stock_out_period__days_out +msgid "Total number of calendar days (inclusive) without stock for this period." +msgstr "" + #. module: purchase_order_product_recommendation_supermarket #: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__include_previous_period msgid "Include previous period stats" diff --git a/purchase_order_product_recommendation_supermarket/models/product_template.py b/purchase_order_product_recommendation_supermarket/models/product_template.py index e705777..f97150d 100644 --- a/purchase_order_product_recommendation_supermarket/models/product_template.py +++ b/purchase_order_product_recommendation_supermarket/models/product_template.py @@ -1,10 +1,107 @@ -from odoo import models, fields +from odoo import models, fields, api +from datetime import date class ProductTemplate(models.Model): - _inherit = 'product.template' + _inherit = "product.template" po_min_suggest_if_forecast_le_zero = fields.Boolean( - string='Suggest minimum purchase when forecast <= 0', - help='If enabled, the purchase recommendation wizard will always propose at least 1 unit (or 1 full package when ordering by packages) when the forecasted stock is less or equal to 0.' + string="Suggest minimum purchase when forecast <= 0", + help="If enabled, the purchase recommendation wizard will always propose at least 1 unit (or 1 full package when ordering by packages) when the forecasted stock is less or equal to 0.", ) + + stock_out_period_ids = fields.One2many( + "product.stock.out.period", + "product_tmpl_id", + string="Stock-out Periods", + help="Historical periods where this product had zero stock (qty_available <= 0).", + ) + + def cron_update_stock_out_periods(self): + """Scheduled every night: detect products currently without stock and + update (or create) an open stock-out period. While a period is open: + - end_date is kept updated each day with today's date + - days_out reflects (end_date - start_date).days + When stock returns the period is marked closed (is_open=False) but end_date + keeps the last day without stock (previous day if we consider stock restored today). + """ + # Consider only storable products + products = self.search([("type", "=", "product")]) + today = date.today() + # Prefetch qty_available efficiently (framework batches reads) + for product in products: + qty = product.qty_available + open_period = product.stock_out_period_ids.filtered(lambda p: p.is_open)[:1] + if qty <= 0: + if open_period: + # Refresh end_date to today (still open) + if open_period.end_date != today: + open_period.end_date = today + open_period._compute_days_out() + else: + self.env["product.stock.out.period"].create( + { + "product_tmpl_id": product.id, + "start_date": today, + "end_date": today, + "is_open": True, + } + ) + else: + if open_period: + # Close period: stock restored today -> last day without stock was yesterday + # We set end_date to max(start_date, today - 1) to avoid negative spans. + from datetime import timedelta + + closed_end = max(open_period.start_date, today - timedelta(days=1)) + if open_period.end_date != closed_end: + open_period.end_date = closed_end + open_period.is_open = False + open_period._compute_days_out() + + +class ProductStockOutPeriod(models.Model): + _name = "product.stock.out.period" + _description = "Product Stock-out Period" + _order = "start_date desc" + + product_tmpl_id = fields.Many2one( + "product.template", + required=True, + ondelete="cascade", + index=True, + string="Product Template", + help="Product template this stock-out period belongs to.", + ) + start_date = fields.Date( + required=True, + index=True, + string="Start Date", + help="First calendar day when the product had zero stock in this period.", + ) + end_date = fields.Date( + index=True, + string="End Date", + help="Last calendar day (inclusive) the product remained without stock in this period.", + ) + is_open = fields.Boolean( + string="Open", + default=False, + index=True, + help="Indicates the period is still ongoing (stock not yet restored).", + ) + days_out = fields.Integer( + string="Days without Stock", + compute="_compute_days_out", + store=True, + help="Total number of calendar days (inclusive) without stock for this period.", + ) + + @api.depends("start_date", "end_date", "is_open") + def _compute_days_out(self): + for rec in self: + if rec.start_date and rec.end_date: + # Inclusive count of days (both start and end) + rec.days_out = (rec.end_date - rec.start_date).days + 1 + else: + rec.days_out = 0 diff --git a/purchase_order_product_recommendation_supermarket/security/ir.model.access.csv b/purchase_order_product_recommendation_supermarket/security/ir.model.access.csv new file mode 100644 index 0000000..15f0899 --- /dev/null +++ b/purchase_order_product_recommendation_supermarket/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_product_stock_out_period_user,access.product.stock.out.period.user,model_product_stock_out_period,base.group_user,1,0,0,0 diff --git a/purchase_order_product_recommendation_supermarket/views/product_template_view.xml b/purchase_order_product_recommendation_supermarket/views/product_template_view.xml index 4d241d2..09d2982 100644 --- a/purchase_order_product_recommendation_supermarket/views/product_template_view.xml +++ b/purchase_order_product_recommendation_supermarket/views/product_template_view.xml @@ -5,10 +5,31 @@ product.template - - - - + + + + + + + + + + + + + +
+ + + + + + + +
+
+
+
diff --git a/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.py b/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.py index d8fd3c2..cc65484 100644 --- a/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.py +++ b/purchase_order_product_recommendation_supermarket/wizards/purchase_order_recommendation.py @@ -254,24 +254,31 @@ class PurchaseOrderRecommendationSupermarketWizard(models.TransientModel): line.unlink() def _get_days_out_of_stock(self, product): + """Compute days without stock using precomputed periods (product.stock.out.period). + + A day is counted if it belongs to any stored stock-out period overlapping + the target window [date_from, date_to]. The period model stores end_date daily + (or closure day). """ - 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.env.context.get("period_date_begin", self.date_begin) date_to = self.env.context.get("period_date_end", 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 + # Access template periods (product may be product.product or product.template) + product_tmpl = ( + product.product_tmpl_id if hasattr(product, "product_tmpl_id") else product + ) + periods = product_tmpl.stock_out_period_ids + total = 0 + for p in periods: + if not p.start_date or not p.end_date: + continue + # Overlap window + start = max(p.start_date, date_from) + end = min(p.end_date, date_to) + if start <= end: + total += (end - start).days + return total def _get_products(self): """Overwrite because filter by cateogory is not working in the base method."""