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

This commit is contained in:
Luis 2025-10-06 14:19:28 +02:00
parent 65dd899963
commit 2c52a9f36e
8 changed files with 285 additions and 21 deletions

View file

@ -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": [],
}

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="ir_cron_update_stock_out_periods" model="ir.cron">
<field name="name">Update Stock-out Periods</field>
<field name="model_id" ref="product.model_product_template"/>
<field name="state">code</field>
<field name="code">model.cron_update_stock_out_periods()</field>
<field name="user_id" ref="base.user_root"/>
<field name="active">True</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="doall">False</field>
</record>
</odoo>

View file

@ -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"

View file

@ -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"

View file

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

View file

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_product_stock_out_period_user access.product.stock.out.period.user model_product_stock_out_period base.group_user 1 0 0 0

View file

@ -5,10 +5,31 @@
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_form_view"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='bill']" position="before">
<group string="Purchase Recommendation">
<field name="po_min_suggest_if_forecast_le_zero"/>
</group>
<xpath expr="//notebook" position="inside">
<page string="Purchase Recommendation">
<group>
<field name="po_min_suggest_if_forecast_le_zero"/>
</group>
<group string="Stock-out Periods">
<field name="stock_out_period_ids" context="{'default_product_tmpl_id': active_id}">
<tree editable="bottom">
<field name="start_date"/>
<field name="end_date"/>
<field name="days_out"/>
<field name="is_open"/>
</tree>
<form>
<group>
<field name="product_tmpl_id"/>
<field name="start_date"/>
<field name="end_date"/>
<field name="days_out"/>
<field name="is_open"/>
</group>
</form>
</field>
</group>
</page>
</xpath>
</field>
</record>

View file

@ -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."""