addons-cm/website_sale_aplicoop/models/sale_order_extension.py
GitHub Copilot 6ba554c91b [ADD] website_sale_aplicoop: online payment per group order
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>
2026-08-16 21:59:35 +02:00

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()