addons-cm/website_sale_aplicoop/models/group_order.py
snt ce393b6034 [FIX] TestConfirmEskaera_Integration: limpieza de decoradores @patch y corrección de bugs
- Convertir 4 tests de decorador @patch a context manager 'with patch(...)' para evitar RuntimeError en LocalProxy de Werkzeug
- Corregir patrón env(user=..., context=dict(...)) en Odoo 18 (sin .with_context())
- Agregar website real al mock para integración con helpers de pricing (_get_pricing_info)
- Añadir pickup_date en fixture de existing_order para que _find_recent_draft_order localice correctamente
- BUGFIX: Agregar (5,) a order_line para limpiar líneas previas al actualizar pedido existente

Resultado: 0 failed, 0 errors de 4 tests en Docker para TestConfirmEskaera_Integration

BREAKING: _create_or_update_sale_order ahora limpia las líneas anteriores con (5,) antes de asignar las nuevas cuando se actualiza un pedido existente. Comportamiento previo (duplicación de líneas) era un bug.
2026-04-08 17:26:57 +02:00

936 lines
34 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(
default=False,
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
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),
)
# Sort products: in-stock first, then out-of-stock, maintaining sequence+name within each group
# is_out_of_stock is Boolean: False (in stock) comes first, True (out of stock) comes last
return products.sorted(
lambda p: (
p.is_out_of_stock, # Boolean: False < True, so in-stock products first
p.product_tmpl_id.website_sequence,
(p.name or "").lower(),
)
)
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
@api.depends("cutoff_date", "pickup_day")
def _compute_pickup_date(self):
"""Compute pickup date as the first occurrence of pickup_day AFTER cutoff_date.
This ensures pickup always comes after cutoff, maintaining logical order.
"""
from datetime import datetime
_logger.info("_compute_pickup_date called for %d records", len(self))
for record in self:
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()
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()
confirmed_sale_orders |= sale_order
except Exception:
failed_sale_orders |= sale_order
_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,
)
if confirmed_sale_orders:
# Create picking batches only for confirmed sale orders
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,
)
except Exception:
_logger.exception(
"Cron: Error confirming sale orders for group order %s (%s)",
self.id,
self.name,
)
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 consumer_group_id.
Args:
sale_orders: Recordset of confirmed sale.order
"""
self.ensure_one()
StockPickingBatch = self.env["stock.picking.batch"].sudo()
# Group sale orders by consumer_group_id
groups = {}
for so in sale_orders:
group_id = so.consumer_group_id.id or False
if group_id not in groups:
groups[group_id] = self.env["sale.order"]
groups[group_id] |= so
for consumer_group_id, group_sale_orders in groups.items():
# Get pickings without batch
pickings = group_sale_orders.picking_ids.filtered(
lambda p: p.state not in ("done", "cancel") and not p.batch_id
)
if not pickings:
continue
# Get consumer group name for batch description
consumer_group = self.env["res.partner"].browse(consumer_group_id)
batch_desc = (
f"{self.name} - {consumer_group.name}" if consumer_group else self.name
)
# Create the batch
batch = StockPickingBatch.create(
{
"description": batch_desc,
"company_id": self.company_id.id,
"picking_type_id": pickings[0].picking_type_id.id,
"scheduled_date": self.pickup_date,
}
)
# Assign pickings to the batch
pickings.write({"batch_id": batch.id})
_logger.info(
"Cron: Created batch %s with %d pickings for group order %s, "
"consumer group %s",
batch.name,
len(pickings),
self.name,
consumer_group.name if consumer_group else "N/A",
)