# 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, fields, 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 = 'start_date desc' @staticmethod def _get_order_type_selection(records): """Return order type selection options with translations.""" return [ ('regular', _('Regular Order')), ('special', _('Special Order')), ('promotional', _('Promotional Order')), ] @staticmethod def _get_period_selection(records): """Return period selection options with translations.""" return [ ('once', _('One-time')), ('weekly', _('Weekly')), ('biweekly', _('Biweekly')), ('monthly', _('Monthly')), ] @staticmethod def _get_day_selection(records): """Return day of week selection options with translations.""" return [ ('0', _('Monday')), ('1', _('Tuesday')), ('2', _('Wednesday')), ('3', _('Thursday')), ('4', _('Friday')), ('5', _('Saturday')), ('6', _('Sunday')), ] @staticmethod def _get_state_selection(records): """Return state selection options with translations.""" return [ ('draft', _('Draft')), ('open', _('Open')), ('closed', _('Closed')), ('cancelled', _('Cancelled')), ] # === Multicompañía === company_id = fields.Many2one( 'res.company', string='Company', required=True, default=lambda self: self.env.company, tracking=True, help='Company that owns this consumer group order', ) # === Campos básicos === name = fields.Char( string='Name', 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', string='Consumer Groups', 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, string='Order Type', required=True, default='regular', tracking=True, help='Type of consumer group order: Regular, Special (one-time), or Promotional', ) # === Fechas === start_date = fields.Date( string='Start Date', required=False, tracking=True, help='Day when the consumer group order opens for purchases', ) end_date = fields.Date( string='End 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, string='Recurrence Period', required=True, default='weekly', tracking=True, help='How often this consumer group order repeats', ) pickup_day = fields.Selection( selection=_get_day_selection, string='Pickup Day', required=False, tracking=True, help='Day of the week when members pick up their orders', ) cutoff_day = fields.Selection( selection=_get_day_selection, string='Cutoff Day', 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( string='Home Delivery', default=False, tracking=True, help='Whether this consumer group order includes home delivery service', ) delivery_product_id = fields.Many2one( 'product.product', string='Delivery Product', domain=[('type', '=', 'service')], tracking=True, help='Product to use for home delivery (service type)', ) delivery_date = fields.Date( string='Delivery Date', compute='_compute_delivery_date', store=False, readonly=True, help='Calculated delivery date (pickup date + 1 day)', ) # === Computed date fields === pickup_date = fields.Date( string='Pickup Date', compute='_compute_pickup_date', store=True, readonly=True, help='Calculated next occurrence of pickup day', ) cutoff_date = fields.Date( string='Cutoff 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', string='Suppliers', 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', string='Products', tracking=True, help='Directly assigned products.', ) category_ids = fields.Many2many( 'product.category', 'group_order_category_rel', 'order_id', 'category_id', string='Categories', tracking=True, help='Products in these categories will be available', ) # === Estado === state = fields.Selection( selection=_get_state_selection, string='State', default='draft', tracking=True, ) # === Descripción e imagen === description = fields.Text( string='Description', translate=True, help='Free text description for this consumer group order', ) delivery_notice = fields.Text( string='Delivery Notice', translate=True, help='Notice about home delivery displayed to users (shown when home delivery is enabled)', ) image = fields.Binary( string='Image', help='Image displayed alongside the consumer group order name', attachment=True, ) display_image = fields.Binary( string='Display Image', 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( string='Available Products Count', compute='_compute_available_products_count', store=False, help='Total count of available products from all sources', ) @api.depends('product_ids', 'category_ids', 'supplier_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( f'Group {group.name} belongs to company ' f'{group.company_id.name}, not to {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( '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 return products @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 or is today # Jump to next week's occurrence days_ahead += 7 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