Members can now pay their eskaera at checkout, through the standard Odoo payment machinery. Enabled per group order with a new `online_payment` boolean, off by default: an order without it behaves exactly as before, members save a draft and the cutoff cron confirms them in bulk. The flow mirrors website_sale's: the checkout button becomes "Confirm and pay", saving the cart redirects to a new /eskaera/<slug>/payment step that renders `payment.form` from `sale`'s `_get_payment_values`, and the standard /my/orders/<id>/transaction route takes it from there. This addon ships no provider and configures none; the co-op publishes whichever it wants. `website_sale`'s `_get_shop_payment_values` is deliberately not reused: it runs `_get_shop_payment_errors`, which blocks on shippable products without a delivery method — exactly an eskaera order, collected at the co-op with no carrier. For the same reason the transaction route stays the portal one, which does not call `_check_cart_is_ready_to_be_paid()`. Payment confirms the order, which has three consequences handled here: * `payment.transaction._check_amount_and_confirm_order` now confirms group orders with `from_orderpoint=True`, the way the cutoff cron already does. Without it a product with a broken replenishment route raises inside `_post_process`, and `/payment/status/poll` rolls back and re-raises: the member sees a payment error over a `done` transaction and the retry cron fails forever. * `_confirm_linked_sale_orders` also sweeps the cycle's already confirmed orders into the picking batch, scoped by `pickup_date`. Its early return on "no drafts" ran before any batching, so a fully prepaid cycle produced no batch at all. `_cron_batch_paid_orders_of_closed_cycles` covers the same hole for cycles closed by hand. * A duplicate-order guard answers 409 on save-order, add-to-cart and load-draft, and shows a notice on the shop, so a member whose order is already placed cannot build and pay for a second one. The payment policy lives in the model rather than the controller: there are three sale.order creation paths and two are live, so `_compute_require_payment` and `_compute_prepayment_percent` are extended instead of patching five vals dicts. Orders are also created under the group order's company, which is what filters the payment providers. Along the way: eskaera drafts were invisible in /my/orders. The portal rule is `message_partner_ids child_of` and sale.order only subscribes the customer on send or confirm, never on a draft create, so the `_prepare_orders_domain` override that includes drafts never had any effect. Fixed with an explicit `message_subscribe`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
192 lines
7.5 KiB
Python
192 lines
7.5 KiB
Python
# Copyright 2025 Criptomart
|
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
|
|
|
import logging
|
|
|
|
from odoo import api
|
|
from odoo import fields
|
|
from odoo import models
|
|
|
|
# Pylint: the explicit 'string' parameter is intentional for clarity in views.
|
|
# Some fields may trigger 'attribute-string-redundant' warnings; silence them
|
|
# locally where appropriate.
|
|
# pylint: disable=attribute-string-redundant
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SaleOrder(models.Model):
|
|
_inherit = "sale.order"
|
|
|
|
def _get_pickup_day_selection(self):
|
|
"""Return pickup day 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")),
|
|
]
|
|
|
|
pickup_day = fields.Selection(
|
|
selection=_get_pickup_day_selection,
|
|
help="Day of week when this order will be picked up",
|
|
)
|
|
|
|
group_order_id = fields.Many2one(
|
|
"group.order",
|
|
help="Reference to the consumer group order that originated this sale order",
|
|
)
|
|
|
|
consumer_group_id = fields.Many2one(
|
|
"res.partner",
|
|
domain="[('is_group', '=', True)]",
|
|
help="Consumer group for this order",
|
|
)
|
|
|
|
pickup_date = fields.Date(
|
|
help="Pickup/delivery date",
|
|
)
|
|
|
|
pickup_slot_label = fields.Char(
|
|
string="Pickup Slot Label",
|
|
compute="_compute_pickup_slot_label",
|
|
store=True,
|
|
readonly=True,
|
|
)
|
|
|
|
home_delivery = fields.Boolean(
|
|
default=False,
|
|
help="Whether this order includes home delivery",
|
|
)
|
|
|
|
@api.depends("company_id", "group_order_id", "group_order_id.online_payment")
|
|
def _compute_require_payment(self): # pylint: disable=missing-return
|
|
"""Let the group order decide whether its members pay online.
|
|
|
|
Orders outside a group order keep the company default.
|
|
|
|
No return: compute methods assign fields, and pylint-odoo's
|
|
`missing-return` does not know that about a `super()` call.
|
|
"""
|
|
super()._compute_require_payment()
|
|
for order in self:
|
|
if order.group_order_id:
|
|
order.require_payment = order.group_order_id.online_payment
|
|
|
|
@api.depends("require_payment", "group_order_id")
|
|
def _compute_prepayment_percent(self): # pylint: disable=missing-return
|
|
"""Group orders are paid in full, never with a down payment.
|
|
|
|
Written together with `require_payment` on purpose: the core compute
|
|
would otherwise pull `company_id.prepayment_percent`, which
|
|
`_check_prepayment_percent` rejects unless it is in (0, 1].
|
|
"""
|
|
super()._compute_prepayment_percent()
|
|
for order in self:
|
|
if order.group_order_id and order.require_payment:
|
|
order.prepayment_percent = 1.0
|
|
|
|
@api.depends(
|
|
"group_order_id",
|
|
"group_order_id.next_pickup_slot_id",
|
|
"group_order_id.next_pickup_slot_id.label",
|
|
"group_order_id.next_pickup_slot_id.start_hour",
|
|
"group_order_id.next_pickup_slot_id.end_hour",
|
|
"pickup_date",
|
|
"pickup_day",
|
|
)
|
|
def _compute_pickup_slot_label(self):
|
|
"""Compute a human readable label for the pickup information.
|
|
|
|
Priority:
|
|
1. Use the group order's current `next_pickup_slot_id` if available
|
|
2. Fallback to legacy `pickup_day` / `pickup_date` fields
|
|
Note: we deliberately do NOT store a Many2one reference to the
|
|
slot on the sale.order anymore — we compute the label dynamically
|
|
from the related group order to avoid persisting slot IDs.
|
|
"""
|
|
for order in self:
|
|
slot = False
|
|
if order.group_order_id and order.group_order_id.next_pickup_slot_id:
|
|
slot = order.group_order_id.next_pickup_slot_id
|
|
|
|
if slot:
|
|
if slot.label:
|
|
label = slot.label
|
|
else:
|
|
sh = float(slot.start_hour or 0.0)
|
|
eh = float(slot.end_hour or 0.0)
|
|
sh_h = int(sh)
|
|
sh_m = int(round((sh - sh_h) * 60))
|
|
eh_h = int(eh)
|
|
eh_m = int(round((eh - eh_h) * 60))
|
|
label = f"{sh_h:02d}:{sh_m:02d}-{eh_h:02d}:{eh_m:02d}"
|
|
|
|
if order.pickup_date:
|
|
try:
|
|
date_str = (
|
|
order.pickup_date.strftime("%d/%m/%Y")
|
|
if hasattr(order.pickup_date, "strftime")
|
|
else str(order.pickup_date)
|
|
)
|
|
label = f"{label} ({date_str})"
|
|
except (
|
|
Exception
|
|
) as exc: # log format errors, but don't break compute
|
|
_logger.debug(
|
|
"_compute_pickup_slot_label: failed to format pickup_date for order %s: %s",
|
|
order.id if order and order.id else None,
|
|
exc,
|
|
exc_info=True,
|
|
)
|
|
order.pickup_slot_label = label
|
|
else:
|
|
# Fallback to single-day fields
|
|
if order.pickup_day:
|
|
try:
|
|
day_map = dict(order._get_pickup_day_selection())
|
|
day_name = day_map.get(order.pickup_day, order.pickup_day)
|
|
except Exception as exc:
|
|
_logger.debug(
|
|
"_compute_pickup_slot_label: failed to map pickup_day for order %s: %s",
|
|
order.id if order and order.id else None,
|
|
exc,
|
|
exc_info=True,
|
|
)
|
|
day_name = order.pickup_day
|
|
|
|
if order.pickup_date:
|
|
try:
|
|
date_str = (
|
|
order.pickup_date.strftime("%d/%m/%Y")
|
|
if hasattr(order.pickup_date, "strftime")
|
|
else str(order.pickup_date)
|
|
)
|
|
order.pickup_slot_label = f"{day_name} ({date_str})"
|
|
except Exception as exc:
|
|
_logger.debug(
|
|
"_compute_pickup_slot_label: failed to format pickup_date (fallback) for order %s: %s",
|
|
order.id if order and order.id else None,
|
|
exc,
|
|
exc_info=True,
|
|
)
|
|
order.pickup_slot_label = day_name
|
|
else:
|
|
order.pickup_slot_label = day_name
|
|
else:
|
|
order.pickup_slot_label = False
|
|
|
|
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()
|