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>
306 lines
11 KiB
Python
306 lines
11 KiB
Python
import logging
|
|
|
|
from odoo.http import request
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _to_bool(self, value):
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, (int, float)):
|
|
return value != 0
|
|
if isinstance(value, str):
|
|
normalized = value.strip().lower()
|
|
if normalized in {"1", "true", "t", "yes", "y", "on"}:
|
|
return True
|
|
if normalized in {"0", "false", "f", "no", "n", "off", ""}:
|
|
return False
|
|
return bool(value)
|
|
|
|
|
|
def _validate_user_group_access(self, group_order, current_user):
|
|
partner = current_user.partner_id
|
|
if not partner or not group_order:
|
|
raise ValueError("User is not a member of any consumer group in this order")
|
|
user_group_ids = set(partner.group_ids.ids)
|
|
for consumer_group in group_order.group_ids:
|
|
if consumer_group.id in user_group_ids:
|
|
return consumer_group.id
|
|
_logger.warning(
|
|
"_validate_user_group_access: user %s (%s) not member of any consumer group in order %s",
|
|
current_user.name,
|
|
current_user.id,
|
|
group_order.id,
|
|
)
|
|
raise ValueError("User is not a member of any consumer group in this order")
|
|
|
|
|
|
def _get_consumer_group_for_user(self, group_order, current_user):
|
|
partner = current_user.partner_id
|
|
if not partner or not group_order:
|
|
return False
|
|
user_group_ids = set(partner.group_ids.ids)
|
|
for consumer_group in group_order.group_ids:
|
|
if consumer_group.id in user_group_ids:
|
|
return consumer_group.id
|
|
_logger.warning(
|
|
"_get_consumer_group_for_user: User %s (%s) is not member of any consumer group in order %s",
|
|
current_user.name,
|
|
current_user.id,
|
|
group_order.id,
|
|
)
|
|
return False
|
|
|
|
|
|
def _get_salesperson_for_order(self, partner):
|
|
if partner.user_id and not partner.user_id._is_public():
|
|
return partner.user_id
|
|
commercial_partner = partner.commercial_partner_id
|
|
if commercial_partner.user_id and not commercial_partner.user_id._is_public():
|
|
return commercial_partner.user_id
|
|
return False
|
|
|
|
|
|
def _find_cycle_sale_order(
|
|
self, partner_id, group_order, states=("draft",), request_obj=None
|
|
):
|
|
"""Return the partner's sale.order for the active cycle, or empty.
|
|
|
|
An order only counts as "current cycle" when it satisfies BOTH of these
|
|
(neither is sufficient on its own — see the regression each one guards):
|
|
|
|
1) create_date falls within the active window — derived from
|
|
group_order.cutoff_date and the order period (7 days weekly, 14
|
|
biweekly, one month monthly; one-time orders use a single cycle
|
|
starting at start_date). Without this, an order whose pickup_date
|
|
happens to match the current one only because pickup_date froze
|
|
across cycles (observed in production) would be wrongly matched.
|
|
2) When group_order.pickup_date is set, the order's pickup_date matches
|
|
it exactly. Without this, an order created "now" for a stale/previous
|
|
pickup_date — but still inside the current create_date window —
|
|
would be wrongly matched instead of starting a fresh cart.
|
|
|
|
Orders failing either check belong to a previous cycle.
|
|
|
|
The upper bound of the create_date window only applies to drafts. Nothing
|
|
stops a member from ordering between the cutoff date and the cron run
|
|
that closes the cycle, so a *placed* order created in that gap is still
|
|
part of this cycle — dropping the bound is what keeps the duplicate-order
|
|
guard from letting them order twice.
|
|
"""
|
|
req = request_obj or request
|
|
|
|
if not group_order or not group_order.cutoff_date:
|
|
return req.env["sale.order"]
|
|
|
|
from datetime import timedelta
|
|
|
|
from dateutil.relativedelta import relativedelta
|
|
|
|
period_end = group_order.cutoff_date
|
|
if group_order.period == "weekly":
|
|
period_start = period_end - timedelta(days=6)
|
|
elif group_order.period == "biweekly":
|
|
period_start = period_end - timedelta(days=13)
|
|
elif group_order.period == "monthly":
|
|
period_start = period_end - relativedelta(months=1) + timedelta(days=1)
|
|
else: # once: single cycle, bounded by start_date when set
|
|
period_start = group_order.start_date
|
|
|
|
states = tuple(states)
|
|
domain = [
|
|
("partner_id", "=", partner_id),
|
|
("group_order_id", "=", group_order.id),
|
|
("state", "in", list(states)),
|
|
]
|
|
if states == ("draft",):
|
|
domain.append(("create_date", "<=", f"{period_end} 23:59:59"))
|
|
if period_start:
|
|
domain.append(("create_date", ">=", f"{period_start} 00:00:00"))
|
|
if group_order.pickup_date:
|
|
domain.append(("pickup_date", "=", group_order.pickup_date))
|
|
|
|
return (
|
|
req.env["sale.order"].sudo().search(domain, order="create_date desc", limit=1)
|
|
)
|
|
|
|
|
|
def _find_recent_draft_order(self, partner_id, group_order, request_obj=None):
|
|
"""Return the active-cycle draft sale.order for the partner, or empty.
|
|
|
|
Draft-only wrapper over `_find_cycle_sale_order`. Callers rely on this
|
|
never returning a placed order — `/eskaera/clear-cart` cancels whatever
|
|
it gets back, so widening it would cancel paid orders.
|
|
"""
|
|
return _find_cycle_sale_order(
|
|
self, partner_id, group_order, states=("draft",), request_obj=request_obj
|
|
)
|
|
|
|
|
|
def _find_placed_cycle_order(self, partner_id, group_order, request_obj=None):
|
|
"""Return the partner's already-placed order for the active cycle.
|
|
|
|
Used by the duplicate-order guard: once a member has paid, their order is
|
|
confirmed, so no draft remains and nothing else would stop them from
|
|
building — and paying for — a second order in the same cycle.
|
|
"""
|
|
return _find_cycle_sale_order(
|
|
self,
|
|
partner_id,
|
|
group_order,
|
|
states=("sale", "done"),
|
|
request_obj=request_obj,
|
|
)
|
|
|
|
|
|
def _validate_confirm_request(self, data, request_obj=None):
|
|
req = request_obj or request
|
|
order_id = data.get("order_id")
|
|
if not order_id:
|
|
raise ValueError("order_id is required")
|
|
try:
|
|
order_id = int(order_id)
|
|
except (ValueError, TypeError) as err:
|
|
raise ValueError(f"Invalid order_id format: {order_id}") from err
|
|
|
|
group_order = req.env["group.order"].sudo().browse(order_id)
|
|
if not group_order.exists():
|
|
raise ValueError(f"Order {order_id} not found")
|
|
if group_order.state != "open":
|
|
raise ValueError("Order is not available (not in open state)")
|
|
current_user = req.env.user
|
|
if not current_user.partner_id:
|
|
raise ValueError("User has no associated partner")
|
|
_validate_user_group_access(self, group_order, current_user)
|
|
items = data.get("items", [])
|
|
if not items:
|
|
raise ValueError("No items in cart")
|
|
_logger.info(
|
|
"_validate_confirm_request: Valid request for order %d with %d items",
|
|
order_id,
|
|
len(items),
|
|
)
|
|
return order_id, group_order, current_user, items
|
|
|
|
|
|
def _validate_draft_request(self, data, request_obj=None):
|
|
req = request_obj or request
|
|
order_id = data.get("order_id")
|
|
if not order_id:
|
|
raise ValueError("order_id is required")
|
|
try:
|
|
order_id = int(order_id)
|
|
except (ValueError, TypeError) as err:
|
|
raise ValueError(f"Invalid order_id format: {order_id}") from err
|
|
group_order = req.env["group.order"].sudo().browse(order_id)
|
|
if not group_order.exists():
|
|
raise ValueError(f"Order {order_id} not found")
|
|
current_user = req.env.user
|
|
if not current_user.partner_id:
|
|
raise ValueError("User has no associated partner")
|
|
_validate_user_group_access(self, group_order, current_user)
|
|
items = data.get("items", [])
|
|
if not items:
|
|
raise ValueError("No items in cart")
|
|
merge_action = data.get("merge_action")
|
|
existing_draft_id = data.get("existing_draft_id")
|
|
_logger.info(
|
|
"_validate_draft_request: Valid request for order %d with %d items (merge_action=%s)",
|
|
order_id,
|
|
len(items),
|
|
merge_action,
|
|
)
|
|
return (order_id, group_order, current_user, items, merge_action, existing_draft_id)
|
|
|
|
|
|
def _validate_confirm_json(self, data, request_obj=None):
|
|
req = request_obj or request
|
|
order_id = data.get("order_id")
|
|
if not order_id:
|
|
raise ValueError("order_id is required")
|
|
try:
|
|
order_id = int(order_id)
|
|
except (ValueError, TypeError) as err:
|
|
raise ValueError(f"Invalid order_id format: {order_id}") from err
|
|
group_order = req.env["group.order"].sudo().browse(order_id)
|
|
if not group_order.exists():
|
|
raise ValueError(f"Order {order_id} not found")
|
|
if group_order.state != "open":
|
|
raise ValueError(f"Order is {group_order.state}")
|
|
current_user = req.env.user
|
|
if not current_user.partner_id:
|
|
raise ValueError("User has no associated partner")
|
|
_validate_user_group_access(self, group_order, current_user)
|
|
items = data.get("items", [])
|
|
if not items:
|
|
raise ValueError("No items in cart")
|
|
is_delivery = _to_bool(self, data.get("is_delivery", False))
|
|
_logger.info(
|
|
"_validate_confirm_json: Valid request for order %d with %d items (is_delivery=%s)",
|
|
order_id,
|
|
len(items),
|
|
is_delivery,
|
|
)
|
|
return order_id, group_order, current_user, items, is_delivery
|
|
|
|
|
|
def _validate_items_for_group_order(self, items, group_order, request_obj=None):
|
|
req = request_obj or request
|
|
if not items:
|
|
return {
|
|
"available_items": [],
|
|
"unavailable_items": [],
|
|
"unavailable_products": set(),
|
|
"warning_message": "",
|
|
}
|
|
try:
|
|
available_products = req.env["group.order"]._get_products_for_group_order(
|
|
group_order.id
|
|
)
|
|
available_product_ids = set(available_products.ids)
|
|
except Exception as e:
|
|
_logger.error(
|
|
"Error getting available products for group_order %d: %s", group_order.id, e
|
|
)
|
|
return {
|
|
"available_items": items,
|
|
"unavailable_items": [],
|
|
"unavailable_products": set(),
|
|
"warning_message": "",
|
|
}
|
|
|
|
available_items = []
|
|
unavailable_items = []
|
|
unavailable_product_ids = set()
|
|
for item in items:
|
|
product_id = item.get("product_id")
|
|
if product_id in available_product_ids:
|
|
available_items.append(item)
|
|
else:
|
|
unavailable_items.append(item)
|
|
unavailable_product_ids.add(product_id)
|
|
|
|
warning_message = ""
|
|
if unavailable_items:
|
|
unavailable_names = [
|
|
item.get("product_name", "Unknown") for item in unavailable_items
|
|
]
|
|
warning_message = req.env._(
|
|
"%(count)d product(s) from your saved order are no longer available in this group order: %(names)s. Only available products will be loaded.",
|
|
count=len(unavailable_items),
|
|
names=", ".join(unavailable_names),
|
|
)
|
|
_logger.warning(
|
|
"load_order_from_history: %d unavailable items in group_order %d (products: %s)",
|
|
len(unavailable_items),
|
|
group_order.id,
|
|
unavailable_product_ids,
|
|
)
|
|
|
|
return {
|
|
"available_items": available_items,
|
|
"unavailable_items": unavailable_items,
|
|
"unavailable_products": unavailable_product_ids,
|
|
"warning_message": warning_message,
|
|
}
|