Aplicoop desde el repo de kidekoop

This commit is contained in:
snt 2026-02-11 15:32:11 +01:00
parent 69917d1ec2
commit 7cff89e418
93 changed files with 313992 additions and 0 deletions

View file

@ -0,0 +1,6 @@
from . import group_order
from . import product_extension
from . import res_partner_extension
from . import sale_order_extension
from . import js_translations

View file

@ -0,0 +1,488 @@
# 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

View file

@ -0,0 +1,170 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
"""
JavaScript Translation Strings
This file ensures that all JavaScript-related translatable strings are imported
into Odoo's translation system during module initialization.
CRITICAL: All strings that are dynamically rendered via JavaScript labels must
be included here with _() to ensure they are captured by Odoo's translation
extraction and loaded into the database.
See: docs/TRANSLATIONS_MASTER.md - "JavaScript Translations Must Be in js_translations.py"
"""
from odoo import _
def _register_translations():
"""
Register all JavaScript translation strings.
Called by Odoo's translation extraction system.
These calls populate the POT/PO files for translation.
"""
# ========================
# Action Labels
# ========================
_('Save Cart')
_('Reload Cart')
_('Browse Product Categories')
_('Proceed to Checkout')
_('Confirm Order')
_('Back to Cart')
_('Remove Item')
_('Add to Cart')
_('Save as Draft')
_('Load Draft')
_('Browse Product Categories')
# ========================
# Draft Modal Labels
# ========================
_('Draft Already Exists')
_('A saved draft already exists for this week.')
_('You have two options:')
_('Option 1: Merge with Existing Draft')
_('Combine your current cart with the existing draft.')
_('Existing draft has')
_('Current cart has')
_('item(s)')
_('Products will be merged by adding quantities. If a product exists in both, quantities will be combined.')
_('Option 2: Replace with Current Cart')
_('Delete the old draft and save only the current cart items.')
_('The existing draft will be permanently deleted.')
_('Merge')
_('Replace')
# ========================
# Draft Save/Load Confirmations
# ========================
_('Are you sure you want to save this cart as draft? Items to save: ')
_('You will be able to reload this cart later.')
_('Are you sure you want to load your last saved draft?')
_('This will replace the current items in your cart')
_('with the saved draft.')
# ========================
# Cart Messages (All Variations)
# ========================
_('Your cart is empty')
_('This order\'s cart is empty.')
_('This order\'s cart is empty')
_('added to cart')
_('items')
_('Your cart has been restored')
# ========================
# Confirmation & Validation
# ========================
_('Confirmation')
_('Confirm')
_('Cancel')
_('Please enter a valid quantity')
# ========================
# Error Messages
# ========================
_('Error: Order ID not found')
_('No draft orders found for this week')
_('Connection error')
_('Error loading order')
_('Error loading draft')
_('Unknown error')
_('Error saving cart')
_('Error processing response')
# ========================
# Success Messages
# ========================
_('Cart saved as draft successfully')
_('Draft order loaded successfully')
_('Draft merged successfully')
_('Draft replaced successfully')
_('Order loaded')
_('Thank you! Your order has been confirmed.')
_('Quantity updated')
# ========================
# Field Labels
# ========================
_('Product')
_('Supplier')
_('Price')
_('Quantity')
_('Subtotal')
_('Total')
# ========================
# Checkout Page Labels
# ========================
_('Home Delivery')
_('Delivery Information')
_('Delivery Information: Your order will be delivered at {pickup_day} {pickup_date}')
_('Your order will be delivered the day after pickup between 11:00 - 14:00')
_('Important')
_('Once you confirm this order, you will not be able to modify it. Please review carefully before confirming.')
# ========================
# Search & Filter Labels
# ========================
_('Search')
_('Search products...')
_('No products found')
_('Categories')
_('All categories')
# ========================
# Category Labels
# ========================
_('Order Type')
_('Order Period')
_('Cutoff Day')
_('Pickup Day')
_('Store Pickup Day')
_('Open until')
# ========================
# Portal Page Labels (New)
# ========================
_('Load in Cart')
_('Consumer Group')
_('Delivery Information')
_('Delivery Date:')
_('Pickup Date:')
_('Delivery Notice:')
_('No special delivery instructions')
_('Pickup Location:')
# ========================
# Day Names (Required for translations)
# ========================
_('Monday')
_('Tuesday')
_('Wednesday')
_('Thursday')
_('Friday')
_('Saturday')
_('Sunday')

View file

@ -0,0 +1,50 @@
# Copyright 2025 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from odoo import _, api, fields, models
class ProductProduct(models.Model):
_inherit = 'product.product'
group_order_ids = fields.Many2many(
'group.order',
'group_order_product_rel',
'product_id',
'order_id',
string='Group Orders',
readonly=True,
help='Group orders where this product is available',
)
@api.model
def _get_products_for_group_order(self, order_id):
"""Backward-compatible delegation to `group.order` discovery.
The canonical discovery logic lives on `group.order` to keep
responsibilities together. Keep this wrapper so existing callers
on `product.product` keep working.
"""
order = self.env['group.order'].browse(order_id)
if not order.exists():
return self.browse()
return order._get_products_for_group_order(order.id)
class ProductTemplate(models.Model):
_inherit = 'product.template'
group_order_ids = fields.Many2many(
'group.order',
compute='_compute_group_order_ids',
string='Consumer Group Orders',
readonly=True,
help='Consumer group orders where variants of this product are available',
)
@api.depends('product_variant_ids.group_order_ids')
def _compute_group_order_ids(self):
for template in self:
variants = template.product_variant_ids
template.group_order_ids = variants.mapped('group_order_ids')

View file

@ -0,0 +1,37 @@
# Copyright 2025-Today Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from odoo import _, fields, models
class ResPartner(models.Model):
_inherit = 'res.partner'
# Campo para identificar si un partner es un grupo
is_group = fields.Boolean(
string='Is a Consumer Group?',
help='Check this box if the partner represents a group of users',
default=False,
)
# Relación para los miembros de un grupo (si is_group es True)
member_ids = fields.Many2many(
'res.partner',
'res_partner_group_members_rel',
'group_id',
'member_id',
domain=[('is_group', '=', True)],
string='Consumer Groups',
help='Consumer Groups this partner belongs to',
)
# Inverse relation: group orders this group participates in
group_order_ids = fields.Many2many(
'group.order',
'group_order_group_rel',
'group_id',
'order_id',
string='Consumer Group Orders',
help='Group orders this consumer group participates in',
readonly=True,
)

View file

@ -0,0 +1,56 @@
# Copyright 2025 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from odoo import _, fields, models
class SaleOrder(models.Model):
_inherit = 'sale.order'
@staticmethod
def _get_pickup_day_selection(records):
"""Return pickup day selection options with translations."""
return [
('0', _('Monday')),
('1', _('Tuesday')),
('2', _('Wednesday')),
('3', _('Thursday')),
('4', _('Friday')),
('5', _('Saturday')),
('6', _('Sunday')),
]
pickup_day = fields.Selection(
selection=_get_pickup_day_selection,
string='Pickup Day',
help='Day of week when this order will be picked up (inherited from group order)',
)
group_order_id = fields.Many2one(
'group.order',
string='Consumer Group Order',
help='Reference to the consumer group order that originated this sale order',
)
pickup_date = fields.Date(
string='Pickup Date',
help='Calculated pickup/delivery date (inherited from consumer group order)',
)
home_delivery = fields.Boolean(
string='Home Delivery',
default=False,
help='Whether this order includes home delivery (inherited from consumer group order)',
)
def _get_name_portal_content_view(self):
"""Override to return custom portal content template with group order info.
This method is called by the portal template to determine which content
template to render. We return our custom template that includes the
group order information (Consumer Group, Delivery/Pickup info, etc.)
"""
self.ensure_one()
if self.group_order_id:
return 'website_sale_aplicoop.sale_order_portal_content_aplicoop'
return super()._get_name_portal_content_view()