[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>
This commit is contained in:
parent
a67181ab42
commit
6ba554c91b
21 changed files with 1993 additions and 37 deletions
|
|
@ -9,6 +9,7 @@ from odoo import fields
|
|||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.sale.controllers import portal as sale_portal
|
||||
from odoo.addons.website_sale.controllers.main import WebsiteSale
|
||||
|
||||
from . import website_sale_i18n as _i18n
|
||||
|
|
@ -649,13 +650,27 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
"home_delivery": effective_home_delivery,
|
||||
"consumer_group_id": consumer_group_id,
|
||||
"commitment_date": commitment_date,
|
||||
# Follow the group order's company rather than the website's:
|
||||
# payment providers are filtered by the order's company, and the
|
||||
# picking batch is created under the group order's.
|
||||
"company_id": group_order.company_id.id,
|
||||
}
|
||||
# Get salesperson for order creation (portal users need this)
|
||||
salesperson = self._get_salesperson_for_order(current_user.partner_id)
|
||||
if salesperson:
|
||||
order_vals["user_id"] = salesperson.id
|
||||
|
||||
sale_order = request.env["sale.order"].sudo().create(order_vals)
|
||||
sale_order = (
|
||||
request.env["sale.order"]
|
||||
.sudo()
|
||||
.with_company(group_order.company_id)
|
||||
.create(order_vals)
|
||||
)
|
||||
# sale.order only subscribes the customer in action_quotation_sent and
|
||||
# _action_confirm, so a draft has no followers — and the portal rule is
|
||||
# `message_partner_ids child_of ...`. Without this the member cannot
|
||||
# see their own draft in /my/orders, nor open its payment page there.
|
||||
sale_order.message_subscribe(partner_ids=current_user.partner_id.ids)
|
||||
return sale_order
|
||||
|
||||
def _decode_json_body(self):
|
||||
|
|
@ -678,6 +693,44 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
self, group_order, status=status
|
||||
)
|
||||
|
||||
def _build_already_placed_response(self, partner_id, group_order):
|
||||
"""Return a 409 payload when the member already placed this cycle.
|
||||
|
||||
Only relevant with online payment on: paying confirms the order, so no
|
||||
draft is left behind and nothing else would stop the member from
|
||||
building — and paying for — a second one. Returns None when there is
|
||||
nothing to block, so callers can use it as a guard.
|
||||
"""
|
||||
if not group_order or not group_order.online_payment:
|
||||
return None
|
||||
placed_order = self._find_placed_cycle_order(partner_id, group_order)
|
||||
if not placed_order:
|
||||
return None
|
||||
|
||||
_logger.info(
|
||||
"[PAYMENT] Blocking a second order for partner %s in group order %s: "
|
||||
"%s is already placed",
|
||||
partner_id,
|
||||
group_order.id,
|
||||
placed_order.name,
|
||||
)
|
||||
return request.make_response(
|
||||
json.dumps(
|
||||
{
|
||||
"error": request.env._(
|
||||
"You already placed an order for this cycle."
|
||||
),
|
||||
"already_placed": True,
|
||||
"sale_order_id": placed_order.id,
|
||||
"redirect_url": self._eskaera_payment_confirmation_url(
|
||||
group_order, placed_order
|
||||
),
|
||||
}
|
||||
),
|
||||
[("Content-Type", "application/json")],
|
||||
status=409,
|
||||
)
|
||||
|
||||
def _validate_items_for_group_order(self, items, group_order):
|
||||
"""Delegate availability validation to validators helper."""
|
||||
return _validators._validate_items_for_group_order(
|
||||
|
|
@ -704,6 +757,20 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
request,
|
||||
)
|
||||
|
||||
def _find_placed_cycle_order(self, partner_id, group_order):
|
||||
"""Find the partner's already-placed sale.order for the active cycle.
|
||||
|
||||
Returns the recordset (limit=1) or an empty recordset. Used to stop a
|
||||
member who already paid from building a second order for the same
|
||||
cycle: their first one is confirmed, so no draft is left to reuse.
|
||||
"""
|
||||
return _validators._find_placed_cycle_order(
|
||||
self,
|
||||
partner_id,
|
||||
group_order,
|
||||
request,
|
||||
)
|
||||
|
||||
def _get_group_order_by_slug(self, group_order_slug):
|
||||
"""Return the consumer group order published under `group_order_slug`."""
|
||||
return (
|
||||
|
|
@ -876,10 +943,26 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
# Get translated labels for JavaScript (same as checkout)
|
||||
labels = self.get_checkout_labels()
|
||||
|
||||
# With online payment on, paying confirms the order, so a member who
|
||||
# already paid has no draft left to reuse. Look their placed order up
|
||||
# here so the template only has to render the banner.
|
||||
placed_order = (
|
||||
self._find_placed_cycle_order(request.env.user.partner_id.id, group_order)
|
||||
if group_order.online_payment
|
||||
else request.env["sale.order"]
|
||||
)
|
||||
placed_order_url = (
|
||||
self._eskaera_payment_confirmation_url(group_order, placed_order)
|
||||
if placed_order
|
||||
else ""
|
||||
)
|
||||
|
||||
return request.render(
|
||||
"website_sale_aplicoop.eskaera_shop",
|
||||
{
|
||||
"group_order": group_order,
|
||||
"placed_order": placed_order,
|
||||
"placed_order_url": placed_order_url,
|
||||
"products": products,
|
||||
"filtered_product_tags": filtered_products_dict,
|
||||
"any_product_has_tags": any_product_has_tags,
|
||||
|
|
@ -1209,6 +1292,11 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
[("Content-Type", "application/json")],
|
||||
)
|
||||
|
||||
if placed_response := self._build_already_placed_response(
|
||||
request.env.user.partner_id.id, group_order
|
||||
):
|
||||
return placed_response
|
||||
|
||||
# Validate that the product is available in this order (use discovery logic)
|
||||
available_products = group_order._get_products_for_group_order(
|
||||
group_order.id
|
||||
|
|
@ -1336,6 +1424,20 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
if group_order.state != "open":
|
||||
return request.redirect("/eskaera")
|
||||
|
||||
# A member who already placed (and paid) an order for this cycle has
|
||||
# nothing to check out: send them to the confirmation of that order
|
||||
# instead of letting them build a duplicate. Only applies with online
|
||||
# payment on — otherwise orders are only confirmed by the cutoff cron,
|
||||
# well after the checkout page stops being reachable.
|
||||
if group_order.online_payment:
|
||||
placed_order = self._find_placed_cycle_order(
|
||||
request.env.user.partner_id.id, group_order
|
||||
)
|
||||
if placed_order:
|
||||
return request.redirect(
|
||||
self._eskaera_payment_confirmation_url(group_order, placed_order)
|
||||
)
|
||||
|
||||
# Los datos del carrito vienen desde localStorage en el frontend
|
||||
# Esta página solo muestra resumen y botón de confirmación
|
||||
|
||||
|
|
@ -1386,6 +1488,36 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
# Convert to JSON string for safe embedding in script tag
|
||||
labels_json = json.dumps(labels, ensure_ascii=False)
|
||||
|
||||
# Online payment turns the primary button from "save a draft" into
|
||||
# "confirm and pay". Everything the template needs is resolved here so
|
||||
# it only reads attributes.
|
||||
online_payment = bool(group_order.online_payment)
|
||||
payment_available = online_payment and self._has_available_payment_method(
|
||||
group_order
|
||||
)
|
||||
if online_payment:
|
||||
checkout_button = {
|
||||
"label": labels.get("confirm_and_pay", "Confirm and pay"),
|
||||
"hint": labels.get(
|
||||
"confirm_and_pay_hint", "Confirm the order and go to payment"
|
||||
),
|
||||
"done_label": labels.get(
|
||||
"order_ready_for_payment", "Order ready for payment"
|
||||
),
|
||||
"icon": "fa-credit-card",
|
||||
"tooltip_key": "confirm_and_pay",
|
||||
}
|
||||
else:
|
||||
checkout_button = {
|
||||
"label": labels.get("save_draft", "Save Draft"),
|
||||
"hint": labels.get("save_order_as_draft", "Save order as draft"),
|
||||
"done_label": labels.get(
|
||||
"order_saved_as_draft", "Order saved as draft"
|
||||
),
|
||||
"icon": "fa-save",
|
||||
"tooltip_key": "save_draft",
|
||||
}
|
||||
|
||||
# Prepare template context with explicit debug info
|
||||
template_context = {
|
||||
"group_order": group_order,
|
||||
|
|
@ -1397,6 +1529,14 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
),
|
||||
"labels": labels,
|
||||
"labels_json": labels_json,
|
||||
"online_payment": online_payment,
|
||||
"payment_available": payment_available,
|
||||
"checkout_button": checkout_button,
|
||||
"no_payment_method_message": labels.get(
|
||||
"no_payment_method",
|
||||
"Online payment is not available right now. "
|
||||
"Please contact your group.",
|
||||
),
|
||||
}
|
||||
|
||||
_logger.warning("Template context keys: %s", list(template_context.keys()))
|
||||
|
|
@ -1405,6 +1545,178 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
"website_sale_aplicoop.eskaera_checkout", template_context
|
||||
)
|
||||
|
||||
# === Online payment ===
|
||||
|
||||
def _eskaera_payment_url(self, group_order):
|
||||
"""Return the payment step URL of `group_order`."""
|
||||
return self._eskaera_url(group_order, suffix="/payment")
|
||||
|
||||
def _eskaera_payment_confirmation_url(self, group_order, sale_order):
|
||||
"""Return the landing URL shown once `sale_order` has been paid."""
|
||||
return self._eskaera_url(
|
||||
group_order, suffix=f"/payment/confirmation/{sale_order.id}"
|
||||
)
|
||||
|
||||
def _has_available_payment_method(self, group_order):
|
||||
"""Whether any payment provider could serve this group order.
|
||||
|
||||
A cheap pre-check for the checkout button: with payment mandatory, an
|
||||
order with no usable provider would send the member to a dead end. The
|
||||
authoritative filtering (country, currency, amount, tokenization) still
|
||||
happens on the payment page through `_get_compatible_providers`.
|
||||
"""
|
||||
Provider = request.env["payment.provider"].sudo()
|
||||
providers = Provider.search(
|
||||
[
|
||||
*Provider._check_company_domain(group_order.company_id.id),
|
||||
("state", "in", ["enabled", "test"]),
|
||||
("is_published", "=", True),
|
||||
]
|
||||
)
|
||||
website_id = request.website.id
|
||||
return any(
|
||||
not provider.website_id or provider.website_id.id == website_id
|
||||
for provider in providers
|
||||
)
|
||||
|
||||
def _get_eskaera_payment_values(self, group_order, order_sudo):
|
||||
"""Build the payment form context for an eskaera order.
|
||||
|
||||
Reuses `sale`'s portal helper rather than `website_sale`'s
|
||||
`_get_shop_payment_values`: the latter also runs
|
||||
`_get_shop_payment_errors`, which blocks whenever the order has
|
||||
shippable products and no delivery method — exactly an eskaera order,
|
||||
which is collected at the co-op and never carries a `carrier_id`.
|
||||
|
||||
`transaction_route` is left at its default, `/my/orders/<id>/transaction`,
|
||||
because that route does not run `_check_cart_is_ready_to_be_paid()`
|
||||
(which would demand a carrier); `/shop/payment/transaction/<id>` does.
|
||||
"""
|
||||
values = sale_portal.CustomerPortal._get_payment_values(
|
||||
self, order_sudo, website_id=request.website.id
|
||||
)
|
||||
values.update(
|
||||
{
|
||||
# The submit button is rendered outside the form on purpose:
|
||||
# website_sale's payment_form.js binds every
|
||||
# [name="o_payment_submit_button"] in the document on top of
|
||||
# payment_form.js's own handler, which is delegated inside
|
||||
# #o_payment_form. A button inside the form gets both, and one
|
||||
# click would start two transactions.
|
||||
"display_submit_button": False,
|
||||
"submit_button_label": request.env._("Confirm and pay"),
|
||||
"landing_route": self._eskaera_payment_confirmation_url(
|
||||
group_order, order_sudo
|
||||
),
|
||||
}
|
||||
)
|
||||
return values
|
||||
|
||||
@http.route(
|
||||
["/eskaera/<string:group_order_slug>/payment"],
|
||||
type="http",
|
||||
auth="user",
|
||||
website=True,
|
||||
)
|
||||
def eskaera_payment(self, group_order_slug, **post):
|
||||
"""Payment step: pick a method and pay the order placed at checkout."""
|
||||
group_order = self._get_group_order_by_slug(group_order_slug)
|
||||
if not group_order or not group_order.online_payment:
|
||||
return request.redirect(self._eskaera_url(group_order, suffix="/checkout"))
|
||||
|
||||
# Entry gate only. Once an order exists the transaction route and the
|
||||
# landing page stay reachable even if the cycle closes meanwhile,
|
||||
# otherwise a member who is already at the provider would come back to
|
||||
# a redirect and their payment would be orphaned.
|
||||
if group_order.state != "open":
|
||||
return request.redirect("/eskaera")
|
||||
|
||||
partner = request.env.user.partner_id
|
||||
try:
|
||||
self._validate_user_group_access(group_order, request.env.user)
|
||||
except ValueError:
|
||||
return request.redirect("/eskaera")
|
||||
|
||||
placed_order = self._find_placed_cycle_order(partner.id, group_order)
|
||||
if placed_order:
|
||||
return request.redirect(
|
||||
self._eskaera_payment_confirmation_url(group_order, placed_order)
|
||||
)
|
||||
|
||||
order_sudo = self._find_recent_draft_order(partner.id, group_order)
|
||||
if not order_sudo:
|
||||
return request.redirect(self._eskaera_url(group_order, suffix="/checkout"))
|
||||
|
||||
# A transaction already under way must not be duplicated: the first one
|
||||
# to reach `done` confirms the order, so a second would be an overpay.
|
||||
last_tx = order_sudo.get_portal_last_transaction()
|
||||
if last_tx and last_tx.state in ("pending", "authorized", "done"):
|
||||
if last_tx.state == "done":
|
||||
return request.redirect(
|
||||
self._eskaera_payment_confirmation_url(group_order, order_sudo)
|
||||
)
|
||||
return request.render(
|
||||
"website_sale_aplicoop.eskaera_payment",
|
||||
{
|
||||
"group_order": group_order,
|
||||
"sale_order": order_sudo,
|
||||
"pending_transaction": last_tx,
|
||||
},
|
||||
)
|
||||
|
||||
# `_get_payment_values` prices the form as amount_total - amount_paid;
|
||||
# a non-positive amount has nothing left to charge and would silently
|
||||
# render the "no payment method" warning.
|
||||
if order_sudo.currency_id.compare_amounts(order_sudo.amount_total, 0) <= 0:
|
||||
return request.redirect(
|
||||
self._eskaera_payment_confirmation_url(group_order, order_sudo)
|
||||
)
|
||||
|
||||
values = self._get_eskaera_payment_values(group_order, order_sudo)
|
||||
values.update(
|
||||
{
|
||||
"group_order": group_order,
|
||||
"sale_order": order_sudo,
|
||||
"pending_transaction": False,
|
||||
}
|
||||
)
|
||||
return request.render("website_sale_aplicoop.eskaera_payment", values)
|
||||
|
||||
@http.route(
|
||||
["/eskaera/<string:group_order_slug>/payment/confirmation/<int:order_id>"],
|
||||
type="http",
|
||||
auth="user",
|
||||
website=True,
|
||||
)
|
||||
def eskaera_payment_confirmation(self, group_order_slug, order_id, **post):
|
||||
"""Landing page after paying: show the outcome and free the cart.
|
||||
|
||||
Deliberately tolerant about the order state. The browser is sent here
|
||||
by `/payment/status` once post-processing has run, but that is
|
||||
asynchronous and may still be pending, so the page reports whatever
|
||||
the order says instead of asserting it was confirmed. The group order
|
||||
may also have closed while the member was at the provider, which must
|
||||
not hide their own order from them.
|
||||
"""
|
||||
group_order = self._get_group_order_by_slug(group_order_slug)
|
||||
if not group_order:
|
||||
return request.redirect("/eskaera")
|
||||
|
||||
order_sudo = request.env["sale.order"].sudo().browse(order_id).exists()
|
||||
if not order_sudo or order_sudo.partner_id != request.env.user.partner_id:
|
||||
return request.redirect("/eskaera")
|
||||
|
||||
last_tx = order_sudo.get_portal_last_transaction()
|
||||
return request.render(
|
||||
"website_sale_aplicoop.eskaera_payment_confirmation",
|
||||
{
|
||||
"group_order": group_order,
|
||||
"sale_order": order_sudo,
|
||||
"transaction": last_tx,
|
||||
"is_paid": order_sudo.state in ("sale", "done"),
|
||||
},
|
||||
)
|
||||
|
||||
@http.route(
|
||||
["/eskaera/check-status"],
|
||||
type="http",
|
||||
|
|
@ -1682,6 +1994,11 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
status=400,
|
||||
)
|
||||
|
||||
if placed_response := self._build_already_placed_response(
|
||||
current_user.partner_id.id, group_order
|
||||
):
|
||||
return placed_response
|
||||
|
||||
# Find the most recent draft sale.order for this partner in active period
|
||||
# The helper _find_recent_draft_order computes the period criteria itself,
|
||||
# so we only need to call it here.
|
||||
|
|
@ -1923,6 +2240,11 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
)
|
||||
return self._build_group_order_unavailable_response(group_order)
|
||||
|
||||
if placed_response := self._build_already_placed_response(
|
||||
current_user.partner_id.id, group_order
|
||||
):
|
||||
return placed_response
|
||||
|
||||
existing_drafts = self._find_recent_draft_order(
|
||||
current_user.partner_id.id, group_order
|
||||
)
|
||||
|
|
@ -1972,15 +2294,21 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
except Exception:
|
||||
pickup_slot_label = None
|
||||
|
||||
response_data = {
|
||||
"success": True,
|
||||
"message": request.env._("Order saved as draft"),
|
||||
"sale_order_id": sale_order.id,
|
||||
"pickup_slot_label": pickup_slot_label,
|
||||
}
|
||||
# With online payment on, saving the cart is only the first half of
|
||||
# placing the order: the frontend follows this URL to the payment
|
||||
# step. The server builds it so the client never assembles routes.
|
||||
if group_order.online_payment:
|
||||
response_data["message"] = request.env._("Order ready for payment")
|
||||
response_data["redirect_url"] = self._eskaera_payment_url(group_order)
|
||||
|
||||
return request.make_response(
|
||||
json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"message": request.env._("Order saved as draft"),
|
||||
"sale_order_id": sale_order.id,
|
||||
"pickup_slot_label": pickup_slot_label,
|
||||
}
|
||||
),
|
||||
json.dumps(response_data),
|
||||
[("Content-Type", "application/json")],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -91,6 +91,13 @@ def _get_translated_labels(self, lang=None, request_obj=None):
|
|||
"save_draft": tr("Save Draft"),
|
||||
"save_order_as_draft": tr("Save order as draft"),
|
||||
"order_saved_as_draft": tr("Order saved as draft"),
|
||||
"confirm_and_pay": tr("Confirm and pay"),
|
||||
"confirm_and_pay_hint": tr("Confirm the order and go to payment"),
|
||||
"order_ready_for_payment": tr("Order ready for payment"),
|
||||
"already_placed": tr("You already placed an order for this cycle."),
|
||||
"no_payment_method": tr(
|
||||
"Online payment is not available right now. Please contact your group."
|
||||
),
|
||||
"save_cart": tr("Save Cart"),
|
||||
"reload_cart": tr("Reload Cart"),
|
||||
"proceed_to_checkout": tr("Proceed to Checkout"),
|
||||
|
|
|
|||
|
|
@ -62,26 +62,32 @@ def _get_salesperson_for_order(self, partner):
|
|||
return False
|
||||
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
A draft only counts as "current cycle" when it satisfies BOTH of these
|
||||
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, a draft whose pickup_date
|
||||
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 reused.
|
||||
2) When group_order.pickup_date is set, the draft's pickup_date matches
|
||||
it exactly. Without this, a draft created "now" for a stale/previous
|
||||
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 reused instead of starting a fresh cart.
|
||||
would be wrongly matched instead of starting a fresh cart.
|
||||
|
||||
Drafts failing either check belong to a previous cycle and must not be
|
||||
reused — otherwise stale carts come back when the user re-enters the
|
||||
order page.
|
||||
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
|
||||
|
||||
|
|
@ -102,12 +108,14 @@ def _find_recent_draft_order(self, partner_id, group_order, request_obj=None):
|
|||
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", "=", "draft"),
|
||||
("create_date", "<=", f"{period_end} 23:59:59"),
|
||||
("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:
|
||||
|
|
@ -118,6 +126,34 @@ def _find_recent_draft_order(self, partner_id, group_order, request_obj=None):
|
|||
)
|
||||
|
||||
|
||||
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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue