Compare commits

...

3 commits

11 changed files with 422 additions and 48 deletions

View file

@ -1 +1,2 @@
from . import models
from . import wizards

View file

@ -9,6 +9,11 @@
"author": "Criptomart",
"website": "https://criptomart.net",
"depends": ["purchase_order_product_recommendation"],
"data": ["wizards/purchase_order_recommendation.xml"],
"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,91 @@ 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"
msgstr "Incluir estadísticas del periodo previo"
#. module: purchase_order_product_recommendation_supermarket
#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__include_previous_period
msgid "If disabled, the previous period (same number of days immediately preceding) is NOT queried. This saves a read_group on stock move lines and skips zero-stock day analysis for that window."
msgstr "Si se desactiva, no se consulta el periodo previo (mismo número de días inmediatamente anteriores). Ahorra tiempo en el cálculo al no consultar movimientos de stock y omite el análisis de días sin stock para esa ventana de tiempo."
#. module: purchase_order_product_recommendation_supermarket
#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_product_template__po_min_suggest_if_forecast_le_zero
msgid "Suggest minimum purchase when forecast <= 0"
msgstr "Sugerir compra mínima cuando el stock previsto <= 0"
#. module: purchase_order_product_recommendation_supermarket
#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_product_template__po_min_suggest_if_forecast_le_zero
msgid "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."
msgstr "Si se activa, el asistente de recomendación de compras propondrá siempre al menos 1 unidad (o 1 paquete completo al pedir por paquetes) cuando el stock previsto sea menor o igual que 0."
#. 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 "Purchase Recommendation"
msgstr "Recomendación de compra"
#. module: purchase_order_product_recommendation_supermarket
#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation_line__units_avg_delivered_prev
msgid "Average daily consumption during the previous period"
@ -103,7 +188,7 @@ msgid ""
msgstr ""
"Indica durante cuantos días el nuevo pedido debe cubrir el stock. Si no se "
"rellena este campo las recomendaciones mostradas se calcularán para cubrir "
"un periodo equivalente al mostrado en días totales."
"un periodo equivalente al mostrado en días analizados."
#. module: purchase_order_product_recommendation_supermarket
#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__order_by_packages

View file

@ -15,6 +15,91 @@ 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"
msgstr ""
#. module: purchase_order_product_recommendation_supermarket
#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation__include_previous_period
msgid "If disabled, the previous period (same number of days immediately preceding) is NOT queried. This saves a read_group on stock move lines and skips zero-stock day analysis for that window."
msgstr ""
#. module: purchase_order_product_recommendation_supermarket
#: model:ir.model.fields,field_description:purchase_order_product_recommendation_supermarket.field_product_template__po_min_suggest_if_forecast_le_zero
msgid "Suggest minimum purchase when forecast <= 0"
msgstr ""
#. module: purchase_order_product_recommendation_supermarket
#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_product_template__po_min_suggest_if_forecast_le_zero
msgid "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."
msgstr ""
#. 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 "Purchase Recommendation"
msgstr ""
#. module: purchase_order_product_recommendation_supermarket
#: model:ir.model.fields,help:purchase_order_product_recommendation_supermarket.field_purchase_order_recommendation_line__units_avg_delivered_prev
msgid "Average daily consumption during the previous period"

View file

@ -0,0 +1 @@
from . import product_template

View file

@ -0,0 +1,107 @@
from odoo import models, fields, api
from datetime import date
class ProductTemplate(models.Model):
_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.",
)
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

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_product_template_form_po_min_suggest" model="ir.ui.view">
<field name="name">product.template.form.po.min.suggest</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_form_view"/>
<field name="arch" type="xml">
<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>
</odoo>

View file

@ -20,6 +20,11 @@ class PurchaseOrderRecommendationSupermarketWizard(models.TransientModel):
default=False,
help="If enabled, days when the product stock was 0 or less will not be considered in the daily sales calculation.",
)
include_previous_period = fields.Boolean(
string="Include previous period stats",
default=False,
help="If disabled, the previous period (same number of days immediately preceding) is NOT queried. This saves a read_group on stock move lines and skips zero-stock day analysis for that window.",
)
total_days = fields.Integer(
string="Total days",
compute="_compute_total_days",
@ -176,6 +181,15 @@ class PurchaseOrderRecommendationSupermarketWizard(models.TransientModel):
(self.order_days * res["units_avg_delivered"])
- res["units_virtual_available"],
)
# Force a minimum suggested quantity when forecast <= 0 and product configured
# We apply this BEFORE packaging adjustment so packages logic can upscale it.
if (
product_id.po_min_suggest_if_forecast_le_zero
and res["units_virtual_available"] <= 0
):
# If ordering by packages we later bump to one full package.
qty_to_order = max(qty_to_order, 1)
res["units_included_original"] = qty_to_order
# Adjust qty_to_order to packaging multiples if order_by_packages is checked
@ -193,6 +207,7 @@ class PurchaseOrderRecommendationSupermarketWizard(models.TransientModel):
if len(found_scrapped):
res["units_scrapped"] = found_scrapped[0]["qty_done"]
if self.include_previous_period:
days = self._get_total_days()
prev_date_end = self.date_begin - timedelta(days=1)
prev_date_begin = self.date_begin - timedelta(days=days)
@ -239,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."""
@ -273,6 +295,10 @@ class PurchaseOrderRecommendationSupermarketWizard(models.TransientModel):
class PurchaseOrderRecommendationLine(models.TransientModel):
_inherit = "purchase.order.recommendation.line"
include_previous_period = fields.Boolean(
related="wizard_id.include_previous_period", store=False, readonly=True
)
packaging_id = fields.Many2one(
comodel_name="product.packaging",
string="Packaging",

View file

@ -25,6 +25,7 @@
<field name="show_all_products" position="after">
<field name="order_by_packages" />
<field name="ignore_zero_stock_days" />
<field name="include_previous_period" />
<field name="last_order_total_amount" widget="monetary" options="{'currency_field': 'currency_id'}" />
<field name="order_total_amount" widget="monetary" options="{'currency_field': 'currency_id'}" />
<field name="currency_id" invisible="1"/>
@ -32,6 +33,15 @@
<field name="product_id" position="attributes">
<attribute name="optional">hide</attribute>
</field>
<field name="price_unit" position="attributes">
<attribute name="optional">show</attribute>
</field>
<field name="units_available" position="attributes">
<attribute name="optional">show</attribute>
</field>
<field name="units_avg_delivered" position="attributes">
<attribute name="optional">show</attribute>
</field>
<field name="product_name" position="attributes">
<attribute name="optional">show</attribute>
</field>
@ -39,8 +49,9 @@
<field name="units_scrapped" string="Qty scrapped" optional="hide" />
<field name="stock_duration" string="Stock Duration" optional="hide" />
<field name="days_without_stock" optional="hide" />
<field name="units_delivered_prev" string="Prev Period" optional="hide" />
<field name="units_avg_delivered_prev" string="Avg Prev Period" optional="hide" />
<field name="include_previous_period" invisible="1"/>
<field name="units_delivered_prev" string="Prev Period" optional="hide" attrs="{'invisible': [('include_previous_period','=',False)]}" />
<field name="units_avg_delivered_prev" string="Avg Prev Period" optional="hide" attrs="{'invisible': [('include_previous_period','=',False)]}" />
<field name="packaging_id" optional="show" />
<field name="packaging_contained_qty" string="Packaging Contained Qty" optional="hide" />
<field name="subtotal_amount" string="Subtotal Amount" optional="hide" />