Group orders are not confirmed until the cutoff date, so draft/sent sale.order lines never generate stock.moves and are invisible to virtual_available. This change makes the shop aware of that demand. - group.order._compute_draft_sale_demand: queries sale.order.line in draft/sent state (mirroring sale_stock forecasted report logic) and returns pending demand per product.id in the product's own UoM. - _get_products_for_group_order: delegates to new _apply_stock_filter_and_sort which excludes storable products whose forecasted net qty (virtual_available − draft demand) <= 0, unless allow_out_of_stock_order. - _compute_stock_ribbons: reads draft_demand_by_product from ORM context so is_out_of_stock / is_low_stock / dynamic_ribbon_id reflect net qty. - Controller: new _prepare_draft_stock_data helper calculates demand once per request, injects context, and builds product_max_qty dict. Applied in eskaera_shop, load_eskaera_page and load_products_ajax. - Template: qty input gets max and data-max-qty from product_max_qty. - JS: blocks add-to-cart if requested quantity exceeds data-max-qty. - Fixes type check: type=='consu' → is_storable=True (Odoo 18 semantics). - 21 new tests in test_forecasted_stock.py covering demand calculation, ribbon logic with context, and group order filtering. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1204 lines
44 KiB
Python
1204 lines
44 KiB
Python
# Copyright 2025-Today Criptomart
|
||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||
|
||
import logging
|
||
from datetime import timedelta
|
||
|
||
from odoo import api
|
||
from odoo import fields
|
||
from odoo import models
|
||
from odoo.exceptions import ValidationError
|
||
|
||
_logger = logging.getLogger(__name__)
|
||
|
||
|
||
class GroupOrder(models.Model):
|
||
_name = "group.order"
|
||
_description = "Consumer Group Order"
|
||
_inherit = ["mail.thread", "mail.activity.mixin"]
|
||
_order = "sequence, start_date desc"
|
||
|
||
def _get_order_type_selection(self):
|
||
"""Return order type selection options with translations."""
|
||
return [
|
||
("regular", self.env._("Regular Order")),
|
||
("special", self.env._("Special Order")),
|
||
("promotional", self.env._("Promotional Order")),
|
||
]
|
||
|
||
def _get_period_selection(self):
|
||
"""Return period selection options with translations."""
|
||
return [
|
||
("once", self.env._("One-time")),
|
||
("weekly", self.env._("Weekly")),
|
||
("biweekly", self.env._("Biweekly")),
|
||
("monthly", self.env._("Monthly")),
|
||
]
|
||
|
||
def _get_day_selection(self):
|
||
"""Return day of week selection options with translations."""
|
||
return [
|
||
("0", self.env._("Monday")),
|
||
("1", self.env._("Tuesday")),
|
||
("2", self.env._("Wednesday")),
|
||
("3", self.env._("Thursday")),
|
||
("4", self.env._("Friday")),
|
||
("5", self.env._("Saturday")),
|
||
("6", self.env._("Sunday")),
|
||
]
|
||
|
||
def _get_state_selection(self):
|
||
"""Return state selection options with translations."""
|
||
return [
|
||
("draft", self.env._("Draft")),
|
||
("open", self.env._("Open")),
|
||
("closed", self.env._("Closed")),
|
||
("cancelled", self.env._("Cancelled")),
|
||
]
|
||
|
||
# === Multicompañía ===
|
||
company_id = fields.Many2one(
|
||
"res.company",
|
||
required=True,
|
||
default=lambda self: self.env.company,
|
||
tracking=True,
|
||
help="Company that owns this consumer group order",
|
||
)
|
||
|
||
# === Secuencia ===
|
||
sequence = fields.Integer(
|
||
default=10,
|
||
help="Sequence for ordering group orders in the website list",
|
||
)
|
||
|
||
# === Campos básicos ===
|
||
name = fields.Char(
|
||
required=True,
|
||
tracking=True,
|
||
translate=True,
|
||
help="Display name of this consumer group order",
|
||
)
|
||
group_ids = fields.Many2many(
|
||
"res.partner",
|
||
"group_order_group_rel",
|
||
"order_id",
|
||
"group_id",
|
||
required=True,
|
||
domain=[("is_group", "=", True)],
|
||
tracking=True,
|
||
help="Consumer groups that can participate in this order",
|
||
)
|
||
type = fields.Selection(
|
||
selection=_get_order_type_selection,
|
||
required=True,
|
||
default="regular",
|
||
tracking=True,
|
||
help="Type of consumer group order: Regular, Special (one-time), or Promotional",
|
||
)
|
||
|
||
# === Fechas ===
|
||
start_date = fields.Date(
|
||
required=False,
|
||
tracking=True,
|
||
help="Day when the consumer group order opens for purchases",
|
||
)
|
||
end_date = fields.Date(
|
||
required=False,
|
||
tracking=True,
|
||
help="If empty, the consumer group order is permanent",
|
||
)
|
||
|
||
# === Período y días ===
|
||
period = fields.Selection(
|
||
selection=_get_period_selection,
|
||
required=True,
|
||
default="weekly",
|
||
tracking=True,
|
||
help="How often this consumer group order repeats",
|
||
)
|
||
pickup_day = fields.Selection(
|
||
selection=_get_day_selection,
|
||
required=False,
|
||
tracking=True,
|
||
help="Day of the week when members pick up their orders",
|
||
)
|
||
cutoff_day = fields.Selection(
|
||
selection=_get_day_selection,
|
||
required=False,
|
||
tracking=True,
|
||
help="Day when purchases stop and the consumer group order is locked for this week.",
|
||
)
|
||
|
||
# === Home delivery ===
|
||
home_delivery = fields.Boolean(
|
||
compute="_compute_home_delivery",
|
||
store=True,
|
||
tracking=True,
|
||
help="Whether this consumer group order includes home delivery service",
|
||
)
|
||
delivery_product_id = fields.Many2one(
|
||
"product.product",
|
||
domain=[("type", "=", "service")],
|
||
tracking=True,
|
||
help="Product to use for home delivery (service type)",
|
||
)
|
||
delivery_date = fields.Date(
|
||
compute="_compute_delivery_date",
|
||
store=True,
|
||
readonly=True,
|
||
help="Calculated delivery date (pickup date + 1 day)",
|
||
)
|
||
|
||
# === Computed date fields ===
|
||
pickup_date = fields.Date(
|
||
compute="_compute_pickup_date",
|
||
store=True,
|
||
readonly=True,
|
||
help="Calculated next occurrence of pickup day",
|
||
)
|
||
cutoff_date = fields.Date(
|
||
compute="_compute_cutoff_date",
|
||
store=True,
|
||
readonly=True,
|
||
help="Calculated next occurrence of cutoff day",
|
||
)
|
||
|
||
# === Asociaciones ===
|
||
supplier_ids = fields.Many2many(
|
||
"res.partner",
|
||
"group_order_supplier_rel",
|
||
"order_id",
|
||
"supplier_id",
|
||
domain=[("supplier_rank", ">", 0)],
|
||
tracking=True,
|
||
help="Products from these suppliers will be available.",
|
||
)
|
||
product_ids = fields.Many2many(
|
||
"product.product",
|
||
"group_order_product_rel",
|
||
"order_id",
|
||
"product_id",
|
||
tracking=True,
|
||
help="Directly assigned products.",
|
||
)
|
||
category_ids = fields.Many2many(
|
||
"product.category",
|
||
"group_order_category_rel",
|
||
"order_id",
|
||
"category_id",
|
||
tracking=True,
|
||
help="Products in these categories will be available",
|
||
)
|
||
excluded_product_ids = fields.Many2many(
|
||
"product.product",
|
||
"group_order_excluded_product_rel",
|
||
"order_id",
|
||
"product_id",
|
||
tracking=True,
|
||
help="Products explicitly excluded from this order (blacklist has absolute priority)",
|
||
)
|
||
excluded_supplier_ids = fields.Many2many(
|
||
"res.partner",
|
||
"group_order_excluded_supplier_rel",
|
||
"order_id",
|
||
"supplier_id",
|
||
domain=[("supplier_rank", ">", 0)],
|
||
tracking=True,
|
||
help="Suppliers excluded from this order. Products with these suppliers as main seller will not be available (blacklist has absolute priority)",
|
||
)
|
||
excluded_category_ids = fields.Many2many(
|
||
"product.category",
|
||
"group_order_excluded_category_rel",
|
||
"order_id",
|
||
"category_id",
|
||
tracking=True,
|
||
help="Categories excluded from this order. Products in these categories and all their subcategories will not be available (blacklist has absolute priority)",
|
||
)
|
||
|
||
# === Estado ===
|
||
state = fields.Selection(
|
||
selection=_get_state_selection,
|
||
default="draft",
|
||
tracking=True,
|
||
)
|
||
|
||
# === Descripción e imagen ===
|
||
description = fields.Text(
|
||
translate=True,
|
||
help="Free text description for this consumer group order",
|
||
)
|
||
delivery_notice = fields.Text(
|
||
translate=True,
|
||
help="Notice about home delivery displayed to users (shown when home delivery is enabled)",
|
||
)
|
||
image = fields.Binary(
|
||
help="Image displayed alongside the consumer group order name",
|
||
attachment=True,
|
||
)
|
||
display_image = fields.Binary(
|
||
compute="_compute_display_image",
|
||
store=True,
|
||
help="Image to display: uses consumer group order image if set, otherwise group image",
|
||
attachment=True,
|
||
)
|
||
|
||
@api.depends("image", "group_ids")
|
||
def _compute_display_image(self):
|
||
"""Use order image if set, otherwise use first group image."""
|
||
for record in self:
|
||
if record.image:
|
||
record.display_image = record.image
|
||
elif record.group_ids and record.group_ids[0].image_1920:
|
||
record.display_image = record.group_ids[0].image_1920
|
||
else:
|
||
record.display_image = False
|
||
|
||
@api.depends("delivery_product_id")
|
||
def _compute_home_delivery(self):
|
||
for record in self:
|
||
record.home_delivery = bool(record.delivery_product_id)
|
||
|
||
available_products_count = fields.Integer(
|
||
compute="_compute_available_products_count",
|
||
store=False,
|
||
help="Total count of available products from all sources",
|
||
)
|
||
|
||
@api.depends(
|
||
"product_ids",
|
||
"category_ids",
|
||
"supplier_ids",
|
||
"excluded_product_ids",
|
||
"excluded_supplier_ids",
|
||
"excluded_category_ids",
|
||
)
|
||
def _compute_available_products_count(self):
|
||
"""Count all available products from all sources."""
|
||
for record in self:
|
||
products = self._get_products_for_group_order(record.id)
|
||
record.available_products_count = len(products)
|
||
|
||
@api.constrains("company_id", "group_ids")
|
||
def _check_company_groups(self):
|
||
"""Validate that groups belong to the same company."""
|
||
for record in self:
|
||
for group in record.group_ids:
|
||
if group.company_id and group.company_id != record.company_id:
|
||
raise ValidationError(
|
||
self.env._(
|
||
"Group %(group)s belongs to company %(group_company)s, "
|
||
"not to %(record_company)s.",
|
||
group=group.name,
|
||
group_company=group.company_id.name,
|
||
record_company=record.company_id.name,
|
||
)
|
||
)
|
||
|
||
@api.constrains("start_date", "end_date")
|
||
def _check_dates(self):
|
||
for record in self:
|
||
if record.start_date and record.end_date:
|
||
if record.start_date > record.end_date:
|
||
raise ValidationError(
|
||
self.env._("Start date cannot be greater than end date")
|
||
)
|
||
|
||
def action_open(self):
|
||
"""Open order for purchases."""
|
||
self.write({"state": "open"})
|
||
|
||
def action_close(self):
|
||
"""Close order."""
|
||
self.write({"state": "closed"})
|
||
|
||
def action_cancel(self):
|
||
"""Cancel order."""
|
||
self.write({"state": "cancelled"})
|
||
|
||
def action_reset_to_draft(self):
|
||
"""Reset order back to draft state."""
|
||
self.write({"state": "draft"})
|
||
|
||
def get_active_orders_for_week(self):
|
||
"""Get active orders for the current week.
|
||
|
||
Respects the allowed_company_ids context if defined.
|
||
"""
|
||
today = fields.Date.today()
|
||
week_start = today - timedelta(days=today.weekday())
|
||
week_end = week_start + timedelta(days=6)
|
||
|
||
domain = [
|
||
("state", "=", "open"),
|
||
"|",
|
||
("start_date", "=", False), # No start_date = always active
|
||
("start_date", "<=", week_end),
|
||
"|",
|
||
("end_date", "=", False),
|
||
("end_date", ">=", week_start),
|
||
]
|
||
|
||
# Apply company filter if allowed_company_ids in context
|
||
if self.env.context.get("allowed_company_ids"):
|
||
domain.append(
|
||
("company_id", "in", self.env.context.get("allowed_company_ids"))
|
||
)
|
||
|
||
return self.search(domain)
|
||
|
||
@api.model
|
||
def _get_products_for_group_order(self, order_id):
|
||
"""Model helper: return product.product recordset for a given order id.
|
||
|
||
Discovery logic is owned by `group.order` so it stays close to the
|
||
order configuration. IMPORTANT: the result is the UNION of all
|
||
association sources (direct products, categories, suppliers), not a
|
||
single-branch fallback. This prevents dropping products that are
|
||
associated through multiple fields and avoids returning only one
|
||
association.
|
||
|
||
Sources included (union):
|
||
- explicit `product_ids`
|
||
- products in `category_ids` (all products whose `categ_id` matches)
|
||
- products from `supplier_ids` via `product.template.seller_ids`
|
||
|
||
Filter restrictions:
|
||
- active = True (product is not archived)
|
||
- is_published = True (product is published on website)
|
||
- sale_ok = True (product can be sold)
|
||
|
||
The returned recordset is a `product.product` set with duplicates
|
||
removed by standard recordset union semantics.
|
||
"""
|
||
order = self.browse(order_id)
|
||
if not order.exists():
|
||
return self.env["product.product"].browse()
|
||
|
||
# Common domain for all searches: active, published, and sale_ok
|
||
base_domain = [
|
||
("active", "=", True),
|
||
("product_tmpl_id.is_published", "=", True),
|
||
("product_tmpl_id.sale_ok", "=", True),
|
||
]
|
||
|
||
products = self.env["product.product"].browse()
|
||
|
||
# 1) Direct products assigned to order
|
||
if order.product_ids:
|
||
products |= order.product_ids.filtered(
|
||
lambda p: p.active
|
||
and p.product_tmpl_id.is_published
|
||
and p.product_tmpl_id.sale_ok
|
||
)
|
||
|
||
# 2) Products in categories assigned to order (including all subcategories)
|
||
if order.category_ids:
|
||
# Collect all category IDs including descendants
|
||
all_category_ids = []
|
||
|
||
def get_all_descendants(categories):
|
||
"""Recursively collect all descendant category IDs."""
|
||
for cat in categories:
|
||
all_category_ids.append(cat.id)
|
||
if cat.child_id:
|
||
get_all_descendants(cat.child_id)
|
||
|
||
get_all_descendants(order.category_ids)
|
||
|
||
# Search for products in all categories and their descendants
|
||
cat_products = self.env["product.product"].search(
|
||
[("categ_id", "in", all_category_ids)] + base_domain
|
||
)
|
||
products |= cat_products
|
||
|
||
# 3) Products from suppliers (via product.template.seller_ids)
|
||
if order.supplier_ids:
|
||
product_templates = self.env["product.template"].search(
|
||
[
|
||
("seller_ids.partner_id", "in", order.supplier_ids.ids),
|
||
("is_published", "=", True),
|
||
("sale_ok", "=", True),
|
||
]
|
||
)
|
||
supplier_products = product_templates.mapped(
|
||
"product_variant_ids"
|
||
).filtered("active")
|
||
products |= supplier_products
|
||
|
||
# 4) Apply product blacklist filter (absolute priority)
|
||
if order.excluded_product_ids:
|
||
excluded_count = len(products & order.excluded_product_ids)
|
||
products = products - order.excluded_product_ids
|
||
_logger.info(
|
||
"Group order %d: Excluded %d products from product blacklist (total: %d)",
|
||
order.id,
|
||
excluded_count,
|
||
len(products),
|
||
)
|
||
|
||
# 5) Apply supplier blacklist filter (absolute priority)
|
||
# Exclude products whose main seller is in the excluded suppliers list
|
||
if order.excluded_supplier_ids:
|
||
# Filter products where main_seller_id is in excluded_supplier_ids
|
||
excluded_by_supplier = products.filtered(
|
||
lambda p: p.product_tmpl_id.main_seller_id
|
||
and p.product_tmpl_id.main_seller_id in order.excluded_supplier_ids
|
||
)
|
||
if excluded_by_supplier:
|
||
products = products - excluded_by_supplier
|
||
_logger.info(
|
||
"Group order %d: Excluded %d products from supplier blacklist (main sellers: %s) (total: %d)",
|
||
order.id,
|
||
len(excluded_by_supplier),
|
||
", ".join(order.excluded_supplier_ids.mapped("name")),
|
||
len(products),
|
||
)
|
||
|
||
# 6) Apply category blacklist filter (absolute priority)
|
||
# Exclude products in excluded categories and all their subcategories (recursive)
|
||
if order.excluded_category_ids:
|
||
# Collect all excluded category IDs including descendants
|
||
excluded_cat_ids = []
|
||
|
||
def get_all_excluded_descendants(categories):
|
||
"""Recursively collect all excluded category IDs including children."""
|
||
for cat in categories:
|
||
excluded_cat_ids.append(cat.id)
|
||
if cat.child_id:
|
||
get_all_excluded_descendants(cat.child_id)
|
||
|
||
get_all_excluded_descendants(order.excluded_category_ids)
|
||
|
||
# Filter products whose category is in the excluded list
|
||
excluded_by_category = products.filtered(
|
||
lambda p: p.categ_id.id in excluded_cat_ids
|
||
)
|
||
if excluded_by_category:
|
||
products = products - excluded_by_category
|
||
_logger.info(
|
||
"Group order %d: Excluded %d products from category blacklist (categories: %s, including subcategories) (total: %d)",
|
||
order.id,
|
||
len(excluded_by_category),
|
||
", ".join(order.excluded_category_ids.mapped("name")),
|
||
len(products),
|
||
)
|
||
|
||
return self._apply_stock_filter_and_sort(products, order)
|
||
|
||
def _apply_stock_filter_and_sort(self, products, order):
|
||
"""Filter out storable products with no forecasted net stock, then sort.
|
||
|
||
Forecasted net qty = virtual_available − draft/sent sale.order demand.
|
||
Products with allow_out_of_stock_order=True or non-storable are never excluded.
|
||
"""
|
||
draft_demand = self._compute_draft_sale_demand(products)
|
||
before = len(products)
|
||
products = products.filtered(
|
||
lambda p: getattr(p, "allow_out_of_stock_order", True)
|
||
or not getattr(p.product_tmpl_id, "is_storable", False)
|
||
or (p.virtual_available - draft_demand.get(p.id, 0.0)) > 0
|
||
)
|
||
if len(products) < before:
|
||
_logger.info(
|
||
"Group order %d: Excluded %d products with no forecasted net stock (total: %d)",
|
||
order.id,
|
||
before - len(products),
|
||
len(products),
|
||
)
|
||
return products.sorted(
|
||
lambda p: (
|
||
p.is_out_of_stock,
|
||
p.product_tmpl_id.website_sequence,
|
||
(p.name or "").lower(),
|
||
)
|
||
)
|
||
|
||
@api.model
|
||
def _compute_draft_sale_demand(self, products):
|
||
"""Return pending draft/sent sale.order demand per product.id (qty in product UoM).
|
||
|
||
Mirrors the logic in sale_stock/report/stock_forecasted.py used by the
|
||
"Informe pronosticado" report, which includes quotations not yet confirmed.
|
||
"""
|
||
if not products:
|
||
return {}
|
||
sol = (
|
||
self.env["sale.order.line"]
|
||
.sudo()
|
||
.search(
|
||
[
|
||
("state", "in", ["draft", "sent"]),
|
||
("product_id", "in", products.ids),
|
||
]
|
||
)
|
||
)
|
||
demand = {p.id: 0.0 for p in products}
|
||
for line in sol:
|
||
pid = line.product_id.id
|
||
if pid in demand:
|
||
demand[pid] += line.product_uom._compute_quantity(
|
||
line.product_uom_qty, line.product_id.uom_id
|
||
)
|
||
return demand
|
||
|
||
def _get_products_paginated(self, order_id, page=1, per_page=20):
|
||
"""Get paginated products for a group order.
|
||
|
||
Args:
|
||
order_id: ID of the group order
|
||
page: Page number (1-indexed)
|
||
per_page: Number of products per page
|
||
|
||
Returns:
|
||
tuple: (products_page, total_count, has_next)
|
||
- products_page: recordset of product.product for this page
|
||
- total_count: total number of products in order
|
||
- has_next: boolean indicating if there are more pages
|
||
"""
|
||
all_products = self._get_products_for_group_order(order_id)
|
||
total_count = len(all_products)
|
||
|
||
# Calculate pagination
|
||
offset = (page - 1) * per_page
|
||
products_page = all_products[offset : offset + per_page]
|
||
|
||
has_next = offset + per_page < total_count
|
||
|
||
return products_page, total_count, has_next
|
||
|
||
# === Pickup slots helpers ===
|
||
pickup_slot_ids = fields.One2many(
|
||
"group.order.slot",
|
||
"group_order_id",
|
||
string="Pickup slots",
|
||
help="Different pickup time slots available for this order (weekday + time)",
|
||
tracking=True,
|
||
)
|
||
|
||
pickup_slots_count = fields.Integer(
|
||
compute="_compute_pickup_slots_count",
|
||
store=False,
|
||
help="Number of pickup slots configured for this order",
|
||
)
|
||
|
||
next_pickup_slot_id = fields.Many2one(
|
||
"group.order.slot",
|
||
string="Next Pickup Slot",
|
||
compute="_compute_next_pickup_slot",
|
||
store=True,
|
||
help="The pickup slot assigned for the next cycle (computed)",
|
||
)
|
||
|
||
next_pickup_datetime = fields.Datetime(
|
||
string="Next Pickup Datetime",
|
||
compute="_compute_next_pickup_slot",
|
||
store=True,
|
||
help="Datetime of the next pickup occurrence for the selected slot",
|
||
)
|
||
|
||
@api.depends("pickup_slot_ids")
|
||
def _compute_pickup_slots_count(self):
|
||
"""Simple count of configured slots for quick UI badges."""
|
||
for record in self:
|
||
record.pickup_slots_count = len(record.pickup_slot_ids or [])
|
||
|
||
@api.depends(
|
||
"pickup_slot_ids",
|
||
"pickup_slot_ids.start_hour",
|
||
"pickup_slot_ids.weekday",
|
||
"cutoff_date",
|
||
"start_date",
|
||
)
|
||
def _compute_next_pickup_slot(self):
|
||
"""Compute the next pickup slot and its concrete datetime.
|
||
|
||
Rules:
|
||
- If slots are configured, compute for each active slot the next
|
||
occurrence (date + start_hour) strictly AFTER the reference date
|
||
(cutoff_date if present, otherwise start_date or today).
|
||
- Select the slot whose occurrence datetime is the soonest (minimum).
|
||
- If no slots are configured, leave fields empty (fallback handled
|
||
by existing pickup_day logic).
|
||
"""
|
||
from datetime import datetime
|
||
from datetime import time
|
||
|
||
for record in self:
|
||
record.next_pickup_slot_id = False
|
||
record.next_pickup_datetime = False
|
||
|
||
slots = record.pickup_slot_ids.filtered(lambda s: s.active)
|
||
if not slots:
|
||
continue
|
||
|
||
# Determine reference date (use cutoff_date if present)
|
||
if record.cutoff_date:
|
||
reference_date = record.cutoff_date
|
||
else:
|
||
today = datetime.now().date()
|
||
if record.start_date and record.start_date < today:
|
||
reference_date = today
|
||
else:
|
||
reference_date = record.start_date or today
|
||
|
||
candidate_datetimes = []
|
||
for slot in slots:
|
||
try:
|
||
slot_weekday = int(slot.weekday)
|
||
except Exception:
|
||
# Skip malformed slot
|
||
continue
|
||
|
||
current_weekday = reference_date.weekday()
|
||
days_ahead = slot_weekday - current_weekday
|
||
# Ensure NEXT occurrence AFTER reference (not same-day)
|
||
if days_ahead <= 0:
|
||
days_ahead += 7
|
||
|
||
target_date = reference_date + timedelta(days=days_ahead)
|
||
|
||
# Convert start_hour float to time
|
||
sh = float(slot.start_hour or 0.0)
|
||
sh_h = int(sh)
|
||
sh_m = int(round((sh - sh_h) * 60))
|
||
try:
|
||
slot_dt = datetime.combine(target_date, time(sh_h, sh_m))
|
||
except Exception:
|
||
# Fallback to date-only
|
||
slot_dt = datetime.combine(target_date, time(0, 0))
|
||
|
||
candidate_datetimes.append((slot_dt, slot))
|
||
|
||
if not candidate_datetimes:
|
||
continue
|
||
|
||
# Choose earliest datetime
|
||
candidate_datetimes.sort(key=lambda x: x[0])
|
||
chosen_dt, chosen_slot = candidate_datetimes[0]
|
||
|
||
# Assign results (store datetime as timezone-naive; Odoo will convert)
|
||
record.next_pickup_slot_id = chosen_slot
|
||
record.next_pickup_datetime = chosen_dt
|
||
|
||
@api.depends("cutoff_date", "pickup_day", "pickup_slot_ids", "next_pickup_datetime")
|
||
def _compute_pickup_date(self):
|
||
"""Compute pickup date.
|
||
|
||
If pickup slots are configured, derive `pickup_date` from the computed
|
||
`next_pickup_datetime`. Otherwise, fall back to the previous
|
||
single-day `pickup_day` behavior.
|
||
"""
|
||
from datetime import datetime
|
||
|
||
_logger.info("_compute_pickup_date called for %d records", len(self))
|
||
for record in self:
|
||
# If slots exist, prefer the computed next_pickup_datetime
|
||
if record.pickup_slot_ids:
|
||
if record.next_pickup_datetime:
|
||
try:
|
||
dt = (
|
||
fields.Datetime.to_datetime(record.next_pickup_datetime)
|
||
if isinstance(record.next_pickup_datetime, str)
|
||
else record.next_pickup_datetime
|
||
)
|
||
record.pickup_date = dt.date()
|
||
except Exception:
|
||
record.pickup_date = None
|
||
else:
|
||
record.pickup_date = None
|
||
continue
|
||
|
||
# Fallback: original single pickup_day logic
|
||
if not record.pickup_day:
|
||
record.pickup_date = None
|
||
continue
|
||
|
||
target_weekday = int(record.pickup_day)
|
||
|
||
# Start from cutoff_date if available, otherwise from today/start_date
|
||
if record.cutoff_date:
|
||
reference_date = record.cutoff_date
|
||
else:
|
||
today = datetime.now().date()
|
||
if record.start_date and record.start_date < today:
|
||
reference_date = today
|
||
else:
|
||
reference_date = record.start_date or today
|
||
|
||
current_weekday = reference_date.weekday()
|
||
|
||
# Calculate days to NEXT occurrence of pickup_day from reference
|
||
days_ahead = target_weekday - current_weekday
|
||
if days_ahead <= 0:
|
||
days_ahead += 7
|
||
|
||
pickup_date = reference_date + timedelta(days=days_ahead)
|
||
|
||
record.pickup_date = pickup_date
|
||
_logger.info(
|
||
"Computed pickup_date for order %d: %s (pickup_day=%s, reference=%s)",
|
||
record.id,
|
||
record.pickup_date,
|
||
record.pickup_day,
|
||
reference_date,
|
||
)
|
||
|
||
@api.depends("cutoff_day", "start_date")
|
||
def _compute_cutoff_date(self):
|
||
"""Compute the cutoff date (deadline to place orders before pickup).
|
||
|
||
The cutoff date is the NEXT occurrence of cutoff_day from today.
|
||
This is when members can no longer place orders.
|
||
|
||
Example (as of Monday 2026-02-09):
|
||
- cutoff_day = 6 (Sunday) → cutoff_date = 2026-02-15 (next Sunday)
|
||
- pickup_day = 1 (Tuesday) → pickup_date = 2026-02-17 (Tuesday after cutoff)
|
||
"""
|
||
from datetime import datetime
|
||
|
||
_logger.info("_compute_cutoff_date called for %d records", len(self))
|
||
for record in self:
|
||
if record.cutoff_day:
|
||
target_weekday = int(record.cutoff_day)
|
||
today = datetime.now().date()
|
||
|
||
# Use today as reference if start_date is in the past, otherwise use start_date
|
||
if record.start_date and record.start_date < today:
|
||
reference_date = today
|
||
else:
|
||
reference_date = record.start_date or today
|
||
|
||
current_weekday = reference_date.weekday()
|
||
|
||
# Calculate days to NEXT occurrence of cutoff_day
|
||
days_ahead = target_weekday - current_weekday
|
||
|
||
if days_ahead < 0:
|
||
# Target day already passed this week
|
||
# Jump to next week's occurrence
|
||
days_ahead += 7
|
||
# If days_ahead == 0, cutoff is today (allowed)
|
||
|
||
record.cutoff_date = reference_date + timedelta(days=days_ahead)
|
||
_logger.info(
|
||
"Computed cutoff_date for order %d: %s (target_weekday=%d, current=%d, days=%d)",
|
||
record.id,
|
||
record.cutoff_date,
|
||
target_weekday,
|
||
current_weekday,
|
||
days_ahead,
|
||
)
|
||
else:
|
||
record.cutoff_date = None
|
||
|
||
@api.depends("pickup_date")
|
||
def _compute_delivery_date(self):
|
||
"""Compute delivery date as pickup date + 1 day."""
|
||
_logger.info("_compute_delivery_date called for %d records", len(self))
|
||
for record in self:
|
||
if record.pickup_date:
|
||
record.delivery_date = record.pickup_date + timedelta(days=1)
|
||
_logger.info(
|
||
"Computed delivery_date for order %d: %s",
|
||
record.id,
|
||
record.delivery_date,
|
||
)
|
||
else:
|
||
record.delivery_date = None
|
||
|
||
# === Constraints ===
|
||
|
||
# Restricción eliminada: ahora se permite cualquier combinación de cutoff_day y pickup_day
|
||
|
||
# === Onchange Methods ===
|
||
|
||
@api.onchange("cutoff_day", "start_date")
|
||
def _onchange_cutoff_day(self):
|
||
"""Force recompute cutoff_date on UI change for immediate feedback."""
|
||
self._compute_cutoff_date()
|
||
|
||
@api.onchange("pickup_day", "cutoff_day", "start_date")
|
||
def _onchange_pickup_day(self):
|
||
"""Force recompute pickup_date on UI change for immediate feedback."""
|
||
self._compute_pickup_date()
|
||
|
||
# === Cron Methods ===
|
||
|
||
@api.model
|
||
def _cron_update_dates(self):
|
||
"""Cron job to recalculate dates for active orders daily.
|
||
|
||
This ensures that computed dates stay up-to-date as time passes.
|
||
Only updates orders in 'draft' or 'open' states.
|
||
"""
|
||
orders = self.search([("state", "in", ["draft", "open"])])
|
||
cron_started_at = fields.Datetime.now()
|
||
_logger.info(
|
||
"Cron: Starting group.order date update at %s for %d active orders (ids=%s)",
|
||
cron_started_at,
|
||
len(orders),
|
||
orders.ids,
|
||
)
|
||
processed_orders = 0
|
||
failed_orders = []
|
||
for order in orders:
|
||
before_values = {
|
||
"state": order.state,
|
||
"period": order.period,
|
||
"start_date": order.start_date,
|
||
"end_date": order.end_date,
|
||
"cutoff_day": order.cutoff_day,
|
||
"pickup_day": order.pickup_day,
|
||
"cutoff_date": order.cutoff_date,
|
||
"pickup_date": order.pickup_date,
|
||
"delivery_date": order.delivery_date,
|
||
"home_delivery": order.home_delivery,
|
||
}
|
||
_logger.info(
|
||
"Cron: Processing group order %s (%s) with values before recompute: %s",
|
||
order.id,
|
||
order.name,
|
||
before_values,
|
||
)
|
||
try:
|
||
# Confirm BEFORE recomputing dates: cutoff_date still points to the
|
||
# current cycle's cutoff (today or past), so the check works correctly.
|
||
# After confirmation, recompute dates so they advance to the next cycle.
|
||
order._confirm_linked_sale_orders()
|
||
order._compute_cutoff_date()
|
||
order._compute_pickup_date()
|
||
order._compute_delivery_date()
|
||
processed_orders += 1
|
||
_logger.info(
|
||
"Cron: Finished group order %s (%s). Dates after recompute: cutoff=%s, pickup=%s, delivery=%s",
|
||
order.id,
|
||
order.name,
|
||
order.cutoff_date,
|
||
order.pickup_date,
|
||
order.delivery_date,
|
||
)
|
||
except Exception:
|
||
failed_orders.append(order.id)
|
||
_logger.exception(
|
||
"Cron: Error while processing group order %s (%s). Initial values were: %s",
|
||
order.id,
|
||
order.name,
|
||
before_values,
|
||
)
|
||
_logger.info(
|
||
"Cron: Date update completed. processed=%d failed=%d failed_ids=%s",
|
||
processed_orders,
|
||
len(failed_orders),
|
||
failed_orders,
|
||
)
|
||
|
||
def _confirm_linked_sale_orders(self):
|
||
"""Confirm draft/sent sale orders linked to this group order.
|
||
|
||
This is triggered by the daily cron so that weekly orders generated
|
||
from the website are confirmed automatically once dates are refreshed.
|
||
After confirmation, creates picking batches grouped by consumer group.
|
||
|
||
Only confirms orders if the cutoff date has already passed.
|
||
"""
|
||
self.ensure_one()
|
||
|
||
today = fields.Date.today()
|
||
|
||
if not self.cutoff_date:
|
||
_logger.warning(
|
||
"Cron: Group order %s (%s) has no cutoff_date (state=%s, period=%s, cutoff_day=%s, start_date=%s). Skipping sale order confirmation for this cycle.",
|
||
self.id,
|
||
self.name,
|
||
self.state,
|
||
self.period,
|
||
self.cutoff_day,
|
||
self.start_date,
|
||
)
|
||
return
|
||
|
||
# Skip if cutoff hasn't passed yet (the cycle is still open for orders)
|
||
if self.cutoff_date >= today:
|
||
_logger.info(
|
||
"Cron: Skipping group order %s (%s) - cutoff date %s not yet passed (today=%s)",
|
||
self.id,
|
||
self.name,
|
||
self.cutoff_date,
|
||
today,
|
||
)
|
||
return
|
||
|
||
SaleOrder = self.env["sale.order"].sudo()
|
||
sale_orders = SaleOrder.search(
|
||
[
|
||
("group_order_id", "=", self.id),
|
||
("state", "in", ["draft", "sent"]),
|
||
]
|
||
)
|
||
|
||
if not sale_orders:
|
||
_logger.info(
|
||
"Cron: No sale orders to confirm for group order %s (%s)",
|
||
self.id,
|
||
self.name,
|
||
)
|
||
return
|
||
|
||
_logger.info(
|
||
"Cron: Confirming %d sale orders for group order %s (%s)",
|
||
len(sale_orders),
|
||
self.id,
|
||
self.name,
|
||
)
|
||
|
||
try:
|
||
# Freeze cycle dates in sale orders BEFORE confirming and BEFORE
|
||
# group order dates are recomputed by the cron caller.
|
||
# This avoids displaying next cycle dates in already confirmed orders.
|
||
pickup_orders = sale_orders.filtered(lambda so: not so.home_delivery)
|
||
delivery_orders = sale_orders.filtered(lambda so: so.home_delivery)
|
||
|
||
if pickup_orders:
|
||
pickup_orders.write(
|
||
{
|
||
"pickup_day": self.pickup_day,
|
||
"pickup_date": self.pickup_date,
|
||
"commitment_date": self.pickup_date,
|
||
}
|
||
)
|
||
|
||
if delivery_orders:
|
||
delivery_commitment_date = self.delivery_date or self.pickup_date
|
||
delivery_orders.write(
|
||
{
|
||
"pickup_day": self.pickup_day,
|
||
"pickup_date": self.pickup_date,
|
||
"commitment_date": delivery_commitment_date,
|
||
}
|
||
)
|
||
|
||
_logger.info(
|
||
"Cron: Snapshot dates applied to %d sale orders for group order %s (%s): pickup_date=%s, delivery_date=%s",
|
||
len(sale_orders),
|
||
self.id,
|
||
self.name,
|
||
self.pickup_date,
|
||
self.delivery_date,
|
||
)
|
||
|
||
# Confirm each order in an isolated savepoint so one bad product
|
||
# route configuration doesn't block all remaining orders.
|
||
confirmed_sale_orders = SaleOrder.browse()
|
||
failed_sale_orders = SaleOrder.browse()
|
||
failure_reasons = {}
|
||
|
||
for sale_order in sale_orders:
|
||
try:
|
||
with self.env.cr.savepoint():
|
||
# Do not block sales confirmation due to procurement
|
||
# route issues. This cron confirms business orders first
|
||
# and handles stock exceptions operationally.
|
||
sale_order.with_context(from_orderpoint=True).action_confirm()
|
||
# Flush INSIDE the savepoint. Odoo raises many
|
||
# ValidationErrors lazily at flush/recompute time, which
|
||
# would otherwise escape this savepoint (it has already
|
||
# been released on block exit) and poison the
|
||
# transaction, aborting every remaining sale order. By
|
||
# forcing the flush here, a failing order is rolled back
|
||
# to its savepoint and the loop continues cleanly.
|
||
self.env.flush_all()
|
||
confirmed_sale_orders |= sale_order
|
||
except Exception as exc:
|
||
failed_sale_orders |= sale_order
|
||
failure_reasons[sale_order.id] = str(exc) or exc.__class__.__name__
|
||
_logger.exception(
|
||
"Cron: Error confirming sale order %s (%s) for group order %s (%s). "
|
||
"Order skipped; remaining orders will continue.",
|
||
sale_order.id,
|
||
sale_order.name,
|
||
self.id,
|
||
self.name,
|
||
)
|
||
|
||
batches = self.env["stock.picking.batch"]
|
||
if confirmed_sale_orders:
|
||
# Create picking batches only for confirmed sale orders
|
||
batches = self._create_picking_batches_for_sale_orders(
|
||
confirmed_sale_orders
|
||
)
|
||
self._log_missing_procurement_warnings(confirmed_sale_orders)
|
||
|
||
if failed_sale_orders:
|
||
_logger.warning(
|
||
"Cron: %d/%d sale orders failed during confirmation for group order %s (%s). "
|
||
"failed_sale_order_ids=%s",
|
||
len(failed_sale_orders),
|
||
len(sale_orders),
|
||
self.id,
|
||
self.name,
|
||
failed_sale_orders.ids,
|
||
)
|
||
self._post_failed_sale_orders_message(
|
||
batches, failed_sale_orders, failure_reasons
|
||
)
|
||
except Exception:
|
||
_logger.exception(
|
||
"Cron: Error confirming sale orders for group order %s (%s)",
|
||
self.id,
|
||
self.name,
|
||
)
|
||
|
||
def _post_failed_sale_orders_message(
|
||
self, batches, failed_sale_orders, failure_reasons=None
|
||
):
|
||
"""Post a note on the picking batch wall about sale orders that failed.
|
||
|
||
Operators read the batch chatter to follow up manually on orders that
|
||
could not be confirmed (and are therefore NOT part of the batch).
|
||
|
||
Args:
|
||
batches: stock.picking.batch recordset to notify (may be empty).
|
||
failed_sale_orders: sale.order recordset that failed to confirm.
|
||
failure_reasons: optional {sale_order_id: error message} mapping.
|
||
"""
|
||
self.ensure_one()
|
||
if not failed_sale_orders:
|
||
return
|
||
|
||
failure_reasons = failure_reasons or {}
|
||
from markupsafe import Markup
|
||
from markupsafe import escape
|
||
|
||
items = Markup()
|
||
for sale_order in failed_sale_orders:
|
||
reason = failure_reasons.get(sale_order.id)
|
||
label = sale_order.name or self.env._("(unnamed)")
|
||
partner = sale_order.partner_id.display_name or ""
|
||
item = escape(f"{label} ({partner})") if partner else escape(label)
|
||
if reason:
|
||
item += Markup(": ") + escape(reason)
|
||
items += Markup("<li>%s</li>") % item
|
||
|
||
body = (
|
||
escape(
|
||
self.env._(
|
||
"%(count)d sale order(s) linked to group order "
|
||
"“%(name)s” failed to confirm and are NOT part of this "
|
||
"batch. Please review and confirm them manually:",
|
||
count=len(failed_sale_orders),
|
||
name=self.name,
|
||
)
|
||
)
|
||
+ Markup("<ul>%s</ul>") % items
|
||
)
|
||
|
||
if not batches:
|
||
# No batch was created (all confirmations failed), so there is no
|
||
# wall to post on. The warning logged by the caller is the record.
|
||
_logger.warning(
|
||
"Cron: No picking batch available to report %d failed sale "
|
||
"orders for group order %s (%s): ids=%s",
|
||
len(failed_sale_orders),
|
||
self.id,
|
||
self.name,
|
||
failed_sale_orders.ids,
|
||
)
|
||
return
|
||
|
||
for batch in batches:
|
||
batch.message_post(body=body, subtype_xmlid="mail.mt_note")
|
||
|
||
def _log_missing_procurement_warnings(self, sale_orders):
|
||
"""Log warnings for confirmed orders with stockable lines lacking moves.
|
||
|
||
This helps operations detect products with missing route/procurement
|
||
configuration while still allowing sale order confirmation.
|
||
"""
|
||
self.ensure_one()
|
||
|
||
for sale_order in sale_orders:
|
||
problematic_lines = sale_order.order_line.filtered(
|
||
lambda line: line.product_id
|
||
and getattr(line.product_id, "is_storable", False)
|
||
and line.product_uom_qty > 0
|
||
and not line.move_ids
|
||
)
|
||
|
||
if not problematic_lines:
|
||
continue
|
||
|
||
product_labels = []
|
||
for line in problematic_lines:
|
||
code = line.product_id.default_code or "NO-CODE"
|
||
name = line.product_id.display_name or line.product_id.name
|
||
product_labels.append(f"[{code}] {name}")
|
||
|
||
_logger.warning(
|
||
"Cron: Sale order %s (%s) confirmed but %d stockable lines have no stock moves. "
|
||
"Likely missing replenishment routes/rules. group_order=%s (%s), products=%s",
|
||
sale_order.id,
|
||
sale_order.name,
|
||
len(problematic_lines),
|
||
self.id,
|
||
self.name,
|
||
product_labels,
|
||
)
|
||
|
||
def _create_picking_batches_for_sale_orders(self, sale_orders):
|
||
"""Create stock.picking.batch grouped by picking type for this group order.
|
||
|
||
Args:
|
||
sale_orders: Recordset of confirmed sale.order
|
||
|
||
Returns:
|
||
stock.picking.batch recordset with the batches created here.
|
||
"""
|
||
self.ensure_one()
|
||
StockPickingBatch = self.env["stock.picking.batch"].sudo()
|
||
created_batches = StockPickingBatch.browse()
|
||
|
||
# Create batches per group order, not per consumer group.
|
||
# If multiple picking types exist, keep one batch per picking type.
|
||
grouped_pickings = {}
|
||
pickings = sale_orders.picking_ids.filtered(
|
||
lambda p: p.state not in ("done", "cancel") and not p.batch_id
|
||
)
|
||
for picking in pickings:
|
||
grouped_pickings.setdefault(
|
||
picking.picking_type_id.id, self.env["stock.picking"]
|
||
)
|
||
grouped_pickings[picking.picking_type_id.id] |= picking
|
||
|
||
scheduled_date = None
|
||
if self.pickup_date:
|
||
scheduled_date = fields.Datetime.to_datetime(self.pickup_date)
|
||
elif self.delivery_date:
|
||
scheduled_date = fields.Datetime.to_datetime(
|
||
self.delivery_date - timedelta(days=1)
|
||
)
|
||
|
||
for picking_type_id, pickings in grouped_pickings.items():
|
||
if not pickings:
|
||
continue
|
||
|
||
batch_desc = self.name
|
||
batch = StockPickingBatch.create(
|
||
{
|
||
"description": batch_desc,
|
||
"company_id": self.company_id.id,
|
||
"picking_type_id": picking_type_id,
|
||
"scheduled_date": scheduled_date,
|
||
}
|
||
)
|
||
|
||
pickings.write({"batch_id": batch.id})
|
||
created_batches |= batch
|
||
|
||
_logger.info(
|
||
"Cron: Created batch %s with %d pickings for group order %s",
|
||
batch.name,
|
||
len(pickings),
|
||
self.name,
|
||
)
|
||
|
||
return created_batches
|