[FIX] website_sale_aplicoop: align pricing and drafts

This commit is contained in:
snt 2026-02-27 19:39:25 +01:00
parent aef57a3de4
commit a9c1f1f609
4 changed files with 258 additions and 533 deletions

View file

@ -6,6 +6,7 @@ import logging
from datetime import datetime from datetime import datetime
from datetime import timedelta from datetime import timedelta
from odoo import fields
from odoo import http from odoo import http
from odoo.http import request from odoo.http import request
@ -302,7 +303,6 @@ class AplicoopWebsiteSale(WebsiteSale):
# Agregar a los hijos de su padre # Agregar a los hijos de su padre
category_map[parent_id]["children"].append(cat_info) category_map[parent_id]["children"].append(cat_info)
# Ordenar raíces y sus hijos por nombre
def sort_hierarchy(items): def sort_hierarchy(items):
items.sort(key=lambda x: x["name"]) items.sort(key=lambda x: x["name"])
for item in items: for item in items:
@ -325,87 +325,49 @@ class AplicoopWebsiteSale(WebsiteSale):
Returns: Returns:
product.pricelist record or False if none found product.pricelist record or False if none found
""" """
env = request.env
website = request.website
pricelist = None pricelist = None
# Try to get configured Aplicoop pricelist first # 1) Configured pricelist from settings
try: try:
aplicoop_pricelist_id = ( param_value = (
request.env["ir.config_parameter"] env["ir.config_parameter"]
.sudo() .sudo()
.get_param("website_sale_aplicoop.pricelist_id") .get_param("website_sale_aplicoop.pricelist_id")
) )
if aplicoop_pricelist_id: if param_value:
pricelist = ( pricelist = (
request.env["product.pricelist"] env["product.pricelist"].browse(int(param_value)).exists() or None
.sudo()
.browse(int(aplicoop_pricelist_id))
) )
if pricelist.exists(): except Exception as e:
_logger.info( _logger.warning("_resolve_pricelist: error reading config param: %s", e)
"_resolve_pricelist: Using configured Aplicoop pricelist: %s (id=%s)",
pricelist.name,
pricelist.id,
)
return pricelist
else:
_logger.warning(
"_resolve_pricelist: Configured Aplicoop pricelist (id=%s) not found",
aplicoop_pricelist_id,
)
except Exception as err:
_logger.warning(
"_resolve_pricelist: Error getting Aplicoop pricelist: %s", str(err)
)
# Fallback to website pricelist # 2) Website current pricelist
try: if not pricelist:
pricelist = request.website._get_current_pricelist() try:
if pricelist: pricelist = website._get_current_pricelist()
_logger.info( except Exception as e:
"_resolve_pricelist: Using website pricelist: %s (id=%s)", _logger.warning(
pricelist.name, "_resolve_pricelist: fallback to website pricelist failed: %s", e
pricelist.id,
) )
return pricelist
except Exception as err:
_logger.warning(
"_resolve_pricelist: Error getting website pricelist: %s", str(err)
)
# Final fallback to first active pricelist # 3) First active pricelist as fallback
pricelist = ( if not pricelist:
request.env["product.pricelist"] pricelist = env["product.pricelist"].sudo().search([], limit=1)
.sudo()
.search([("active", "=", True)], limit=1)
)
if pricelist:
_logger.info(
"_resolve_pricelist: Using first active pricelist: %s (id=%s)",
pricelist.name,
pricelist.id,
)
return pricelist
_logger.error( return pricelist
"_resolve_pricelist: ERROR - No pricelist found! Pricing may fail."
)
return False
def _prepare_product_display_info(self, product, product_price_info): def _prepare_product_display_info(self, product, product_price_info):
"""Prepare all display information for a product in a QWeb-safe way. """Build display info for a product using precomputed prices.
This function pre-processes all values that might be None or require
conditional logic, so the template can use simple variable references
without complex expressions that confuse QWeb's parser.
Args: Args:
product: product.template record product (product.product): Product variant.
product_price_info: dict with 'price', 'list_price', etc. product_price_info: dict with price data keyed by product.id.
Returns: Returns:
dict with all pre-processed display values ready for template dict with display_price, safe_uom_category, quantity_step
""" """
# Safety: Get price, ensure it's a float
price_data = product_price_info.get(product.id, {}) price_data = product_price_info.get(product.id, {})
price = ( price = (
price_data.get("price", product.list_price) price_data.get("price", product.list_price)
@ -414,19 +376,14 @@ class AplicoopWebsiteSale(WebsiteSale):
) )
price_safe = float(price) if price else 0.0 price_safe = float(price) if price else 0.0
# Safety: Get UoM category name (use sudo for read-only display to avoid ACL issues)
uom_category_name = "" uom_category_name = ""
quantity_step = 1 # Default step for integer quantities (Units) quantity_step = 1
if product.uom_id: if product.uom_id:
uom = product.uom_id.sudo() uom = product.uom_id.sudo()
if uom.category_id: if uom.category_id:
uom_category_name = uom.category_id.sudo().name or "" uom_category_name = uom.category_id.sudo().name or ""
# Use XML IDs to detect fractional UoM categories (multilingual robust)
# This works regardless of translation/language
try: try:
# Get external ID for the UoM category
ir_model_data = request.env["ir.model.data"].sudo() ir_model_data = request.env["ir.model.data"].sudo()
external_id = ir_model_data.search( external_id = ir_model_data.search(
[ [
@ -437,18 +394,16 @@ class AplicoopWebsiteSale(WebsiteSale):
) )
if external_id: if external_id:
# Standard Odoo UoM categories requiring fractional step
fractional_categories = [ fractional_categories = [
"uom.product_uom_categ_kgm", # Weight (kg, g, ton, etc.) "uom.product_uom_categ_kgm",
"uom.product_uom_categ_vol", # Volume (L, m³, etc.) "uom.product_uom_categ_vol",
"uom.uom_categ_length", # Length/Distance (m, km, etc.) "uom.uom_categ_length",
"uom.uom_categ_surface", # Surface (m², ha, etc.) "uom.uom_categ_surface",
] ]
full_xmlid = f"{external_id.module}.{external_id.name}" full_xmlid = f"{external_id.module}.{external_id.name}"
if full_xmlid in fractional_categories: if full_xmlid in fractional_categories:
quantity_step = 0.1 quantity_step = 0.1
except Exception as e: except Exception as e:
# Fallback to integer step on error
_logger.warning( _logger.warning(
"_prepare_product_display_info: Error detecting UoM category XML ID for product %s: %s", "_prepare_product_display_info: Error detecting UoM category XML ID for product %s: %s",
product.id, product.id,
@ -461,6 +416,70 @@ class AplicoopWebsiteSale(WebsiteSale):
"quantity_step": quantity_step, "quantity_step": quantity_step,
} }
def _get_pricing_info(self, product, pricelist, quantity=1.0, partner=None):
"""Compute pricing with taxes like website_sale but using a given pricelist.
Returns a dict with:
- price_unit: raw pricelist price (before taxes), suitable for sale.order.line
- price: display price (tax included/excluded per website setting)
- list_price: display list price (pre-discount) with same tax display
- has_discounted_price: bool
"""
try:
env = request.env
website = request.website
except RuntimeError:
env = product.env
website = env["website"].get_current_website()
partner = partner or env.user.partner_id
currency = pricelist.currency_id
company = website.company_id or product.company_id or env.company
price, rule_id = pricelist._get_product_price_rule(
product=product,
quantity=quantity,
target_currency=currency,
)
price_before_discount = price
pricelist_item = env["product.pricelist.item"].browse(rule_id)
if pricelist_item and pricelist_item._show_discount_on_shop():
price_before_discount = pricelist_item._compute_price_before_discount(
product=product,
quantity=quantity or 1.0,
date=fields.Date.context_today(pricelist),
uom=product.uom_id,
currency=currency,
)
has_discounted_price = price_before_discount > price
fiscal_position = website.fiscal_position_id.sudo()
product_taxes = product.sudo().taxes_id._filter_taxes_by_company(company)
taxes = (
fiscal_position.map_tax(product_taxes) if product_taxes else product_taxes
)
tax_display = "total_included"
def compute_display(amount):
if not taxes:
return amount
return taxes.compute_all(amount, currency, 1, product, partner)[tax_display]
display_price = compute_display(price)
display_list_price = compute_display(price_before_discount)
return {
"price_unit": price,
"price": display_price,
"list_price": display_list_price,
"has_discounted_price": has_discounted_price,
"discount": display_list_price - display_price,
"tax_included": tax_display == "total_included",
}
def _compute_price_info(self, products, pricelist): def _compute_price_info(self, products, pricelist):
"""Compute price info dict for a list of products using the given pricelist. """Compute price info dict for a list of products using the given pricelist.
@ -473,23 +492,13 @@ class AplicoopWebsiteSale(WebsiteSale):
) )
if product_variant and pricelist: if product_variant and pricelist:
try: try:
price_info = product_variant._get_price( pricing = self._get_pricing_info(
qty=1.0, product_variant,
pricelist=pricelist, pricelist,
fposition=request.website.fiscal_position_id, quantity=1.0,
partner=request.env.user.partner_id,
) )
price = price_info.get("value", 0.0) product_price_info[product.id] = pricing
original_price = price_info.get("original_value", 0.0)
discount = price_info.get("discount", 0.0)
has_discount = discount > 0
product_price_info[product.id] = {
"price": price,
"list_price": original_price,
"has_discounted_price": has_discount,
"discount": discount,
"tax_included": price_info.get("tax_included", True),
}
except Exception as e: except Exception as e:
_logger.warning( _logger.warning(
"_compute_price_info: Error getting price for product %s (id=%s): %s. Using list_price fallback.", "_compute_price_info: Error getting price for product %s (id=%s): %s. Using list_price fallback.",
@ -498,19 +507,23 @@ class AplicoopWebsiteSale(WebsiteSale):
str(e), str(e),
) )
product_price_info[product.id] = { product_price_info[product.id] = {
"price_unit": product.list_price,
"price": product.list_price, "price": product.list_price,
"list_price": product.list_price, "list_price": product.list_price,
"has_discounted_price": False, "has_discounted_price": False,
"discount": 0.0, "discount": 0.0,
"tax_included": False, "tax_included": request.website.show_line_subtotals_tax_selection
!= "tax_excluded",
} }
else: else:
product_price_info[product.id] = { product_price_info[product.id] = {
"price_unit": product.list_price,
"price": product.list_price, "price": product.list_price,
"list_price": product.list_price, "list_price": product.list_price,
"has_discounted_price": False, "has_discounted_price": False,
"discount": 0.0, "discount": 0.0,
"tax_included": False, "tax_included": request.website.show_line_subtotals_tax_selection
!= "tax_excluded",
} }
return product_price_info return product_price_info
@ -814,7 +827,7 @@ class AplicoopWebsiteSale(WebsiteSale):
return order_id, group_order, current_user, items, is_delivery return order_id, group_order, current_user, items, is_delivery
def _process_cart_items(self, items, group_order): def _process_cart_items(self, items, group_order, pricelist=None):
"""Process cart items and build sale.order line data. """Process cart items and build sale.order line data.
Args: Args:
@ -828,12 +841,13 @@ class AplicoopWebsiteSale(WebsiteSale):
ValueError: if no valid items after processing ValueError: if no valid items after processing
""" """
sale_order_lines = [] sale_order_lines = []
pricelist = pricelist or self._resolve_pricelist()
partner = request.env.user.partner_id
for item in items: for item in items:
try: try:
product_id = int(item.get("product_id")) product_id = int(item.get("product_id"))
quantity = float(item.get("quantity", 1)) quantity = float(item.get("quantity", 1))
price = float(item.get("product_price", 0))
product = request.env["product.product"].sudo().browse(product_id) product = request.env["product.product"].sudo().browse(product_id)
if not product.exists(): if not product.exists():
@ -846,10 +860,17 @@ class AplicoopWebsiteSale(WebsiteSale):
product_in_lang = product.with_context(lang=request.env.lang) product_in_lang = product.with_context(lang=request.env.lang)
product_name = product_in_lang.name product_name = product_in_lang.name
pricing = self._get_pricing_info(
product,
pricelist,
quantity=quantity,
partner=partner,
)
line_data = { line_data = {
"product_id": product_id, "product_id": product_id,
"product_uom_qty": quantity, "product_uom_qty": quantity,
"price_unit": price or product.list_price, "price_unit": pricing.get("price_unit", product.list_price),
"name": product_name, # Force the translated product name "name": product_name, # Force the translated product name
} }
_logger.info("_process_cart_items: Adding line: %s", line_data) _logger.info("_process_cart_items: Adding line: %s", line_data)
@ -1018,19 +1039,7 @@ class AplicoopWebsiteSale(WebsiteSale):
return sale_order return sale_order
def _build_confirmation_message(self, sale_order, group_order, is_delivery): def _build_confirmation_message(self, sale_order, group_order, is_delivery):
"""Build localized confirmation message for confirm_eskaera. """Build localized confirmation message for confirm_eskaera."""
Translates message and pickup/delivery info according to user's language.
Handles day names and date formatting.
Args:
sale_order: sale.order record just created
group_order: group.order record
is_delivery: boolean indicating if home delivery
Returns:
dict with message, pickup_day, pickup_date, pickup_day_index
"""
# Get pickup day index, localized name and date string using helper # Get pickup day index, localized name and date string using helper
pickup_day_name, pickup_date_str, pickup_day_index = self._format_pickup_info( pickup_day_name, pickup_date_str, pickup_day_index = self._format_pickup_info(
group_order, is_delivery group_order, is_delivery
@ -1220,86 +1229,29 @@ class AplicoopWebsiteSale(WebsiteSale):
group_order, group_order,
current_user, current_user,
sale_order_lines, sale_order_lines,
merge_action,
existing_draft_id,
existing_drafts, existing_drafts,
order_id, order_id,
): ):
"""Handle merge/replace logic for drafts and return (sale_order, merge_success). """Replace existing draft (if any) with new lines, else create it."""
existing_drafts: recordset of existing draft orders (may be empty) if existing_drafts:
""" draft = existing_drafts[0].sudo()
# Merge
if merge_action == "merge" and existing_draft_id:
existing_draft = (
request.env["sale.order"].sudo().browse(int(existing_draft_id))
)
if existing_draft.exists():
for new_line_data in sale_order_lines:
product_id = new_line_data[2]["product_id"]
new_quantity = new_line_data[2]["product_uom_qty"]
new_price = new_line_data[2]["price_unit"]
# Capture product_id as default arg to avoid late-binding in lambda (fix B023)
existing_line = existing_draft.order_line.filtered(
lambda line, pid=product_id: line.product_id.id == pid
)
if existing_line:
# Use sudo() to avoid permission issues with portal users
existing_line.sudo().write(
{
"product_uom_qty": existing_line.product_uom_qty
+ new_quantity
}
)
_logger.info(
"Merged item: product_id=%d, new total quantity=%.2f",
product_id,
existing_line.product_uom_qty,
)
else:
# Use sudo() to avoid permission issues with portal users
existing_draft.order_line.sudo().create(
{
"order_id": existing_draft.id,
"product_id": product_id,
"product_uom_qty": new_quantity,
"price_unit": new_price,
}
)
_logger.info(
"Added new item to draft: product_id=%d, quantity=%.2f",
product_id,
new_quantity,
)
return existing_draft, True
# Replace
if merge_action == "replace" and existing_draft_id and existing_drafts:
existing_drafts.unlink()
_logger.info( _logger.info(
"Deleted existing draft(s) for replace: %s", "Replacing existing draft order %s for partner %s",
existing_drafts.mapped("id"), draft.id,
current_user.partner_id.id,
) )
order_vals = { draft.write(
"partner_id": current_user.partner_id.id, {
"order_line": sale_order_lines, "order_line": [(5, 0, 0)] + sale_order_lines,
"state": "draft", "group_order_id": order_id,
"group_order_id": order_id, "pickup_day": group_order.pickup_day,
"pickup_day": group_order.pickup_day, "pickup_date": group_order.pickup_date,
"pickup_date": group_order.pickup_date, "home_delivery": group_order.home_delivery,
"home_delivery": group_order.home_delivery, }
} )
# Get salesperson for order creation (portal users need this) return draft
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)
return sale_order, False
# Default: create new draft
order_vals = { order_vals = {
"partner_id": current_user.partner_id.id, "partner_id": current_user.partner_id.id,
"order_line": sale_order_lines, "order_line": sale_order_lines,
@ -1315,7 +1267,7 @@ class AplicoopWebsiteSale(WebsiteSale):
order_vals["user_id"] = salesperson.id order_vals["user_id"] = salesperson.id
sale_order = request.env["sale.order"].sudo().create(order_vals) sale_order = request.env["sale.order"].sudo().create(order_vals)
return sale_order, False return sale_order
def _decode_json_body(self): def _decode_json_body(self):
"""Safely decode JSON body from request. Returns dict or raises ValueError.""" """Safely decode JSON body from request. Returns dict or raises ValueError."""
@ -2045,7 +1997,9 @@ class AplicoopWebsiteSale(WebsiteSale):
# Build sale.order lines and create draft using helpers # Build sale.order lines and create draft using helpers
try: try:
sale_order_lines = self._process_cart_items(items, group_order) sale_order_lines = self._process_cart_items(
items, group_order, pricelist=self._resolve_pricelist()
)
except ValueError as e: except ValueError as e:
return request.make_response( return request.make_response(
json.dumps({"error": str(e)}), json.dumps({"error": str(e)}),
@ -2171,13 +2125,21 @@ class AplicoopWebsiteSale(WebsiteSale):
# Extract items from the draft order # Extract items from the draft order
items = [] items = []
pricelist = self._resolve_pricelist()
partner = current_user.partner_id
for line in draft_order.order_line: for line in draft_order.order_line:
pricing = self._get_pricing_info(
line.product_id,
pricelist,
quantity=line.product_uom_qty,
partner=partner,
)
items.append( items.append(
{ {
"product_id": line.product_id.id, "product_id": line.product_id.id,
"product_name": line.product_id.name, "product_name": line.product_id.name,
"quantity": line.product_uom_qty, "quantity": line.product_uom_qty,
"product_price": line.price_unit, "product_price": pricing.get("price", line.price_unit),
} }
) )
@ -2306,8 +2268,6 @@ class AplicoopWebsiteSale(WebsiteSale):
# Get cart items # Get cart items
items = data.get("items", []) items = data.get("items", [])
merge_action = data.get("merge_action") # 'merge' or 'replace'
existing_draft_id = data.get("existing_draft_id") # ID if replacing
if not items: if not items:
_logger.warning( _logger.warning(
@ -2334,35 +2294,6 @@ class AplicoopWebsiteSale(WebsiteSale):
) )
) )
# If draft exists and no action specified, return the existing draft info
if existing_drafts and not merge_action:
existing_draft = existing_drafts[0] # Get first draft
existing_items = [
{
"product_id": line.product_id.id,
"product_name": line.product_id.name,
"quantity": line.product_uom_qty,
"product_price": line.price_unit,
}
for line in existing_draft.order_line
]
return request.make_response(
json.dumps(
{
"success": False,
"existing_draft": True,
"existing_draft_id": existing_draft.id,
"existing_items": existing_items,
"current_items": items,
"message": request.env._(
"A draft already exists for this week."
),
}
),
[("Content-Type", "application/json")],
)
_logger.info( _logger.info(
"Creating draft sale.order with %d items for partner %d", "Creating draft sale.order with %d items for partner %d",
len(items), len(items),
@ -2371,7 +2302,9 @@ class AplicoopWebsiteSale(WebsiteSale):
# Create sales.order lines from items using shared helper # Create sales.order lines from items using shared helper
try: try:
sale_order_lines = self._process_cart_items(items, group_order) sale_order_lines = self._process_cart_items(
items, group_order, pricelist=self._resolve_pricelist()
)
except ValueError as e: except ValueError as e:
return request.make_response( return request.make_response(
json.dumps({"error": str(e)}), json.dumps({"error": str(e)}),
@ -2380,12 +2313,10 @@ class AplicoopWebsiteSale(WebsiteSale):
) )
# Delegate merge/replace/create logic to helper # Delegate merge/replace/create logic to helper
sale_order, merge_success = self._merge_or_replace_draft( sale_order = self._merge_or_replace_draft(
group_order, group_order,
current_user, current_user,
sale_order_lines, sale_order_lines,
merge_action,
existing_draft_id,
existing_drafts, existing_drafts,
order_id, order_id,
) )
@ -2404,13 +2335,8 @@ class AplicoopWebsiteSale(WebsiteSale):
json.dumps( json.dumps(
{ {
"success": True, "success": True,
"message": ( "message": request.env._("Order saved as draft"),
request.env._("Merged with existing draft")
if merge_success
else request.env._("Order saved as draft")
),
"sale_order_id": sale_order.id, "sale_order_id": sale_order.id,
"merged": merge_success,
} }
), ),
[("Content-Type", "application/json")], [("Content-Type", "application/json")],
@ -2481,7 +2407,9 @@ class AplicoopWebsiteSale(WebsiteSale):
# Process cart items using helper # Process cart items using helper
try: try:
sale_order_lines = self._process_cart_items(items, group_order) sale_order_lines = self._process_cart_items(
items, group_order, pricelist=self._resolve_pricelist()
)
except ValueError as e: except ValueError as e:
_logger.warning("confirm_eskaera: Cart processing error: %s", str(e)) _logger.warning("confirm_eskaera: Cart processing error: %s", str(e))
return request.make_response( return request.make_response(

View file

@ -1313,6 +1313,7 @@
var orderData = { var orderData = {
order_id: this.orderId, order_id: this.orderId,
items: items, items: items,
merge_action: "replace",
}; };
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
@ -1332,9 +1333,6 @@
data.sale_order_id + data.sale_order_id +
")"; ")";
self._showNotification("✓ " + successMsg, "success", 5000); self._showNotification("✓ " + successMsg, "success", 5000);
} else if (data.existing_draft) {
// A draft already exists - show modal with merge/replace options
self._showDraftConflictModal(data);
} else { } else {
self._showNotification( self._showNotification(
"Error: " + (data.error || labels.error_unknown || "Unknown error"), "Error: " + (data.error || labels.error_unknown || "Unknown error"),
@ -1536,9 +1534,6 @@
labels.draft_saved || labels.draft_saved ||
"Order saved as draft successfully"; "Order saved as draft successfully";
self._showNotification("\u2713 " + successMsg, "success", 5000); self._showNotification("\u2713 " + successMsg, "success", 5000);
} else if (data.existing_draft) {
// A draft already exists - show modal with merge/replace options
self._showDraftConflictModal(data);
} else { } else {
self._showNotification( self._showNotification(
"Error: " + (data.error || labels.error_unknown || "Unknown error"), "Error: " + (data.error || labels.error_unknown || "Unknown error"),
@ -1576,306 +1571,6 @@
xhr.send(JSON.stringify(orderData)); xhr.send(JSON.stringify(orderData));
}, },
_showDraftConflictModal: function (data) {
/**
* Show modal with merge/replace options for existing draft.
* Uses labels from window.groupOrderShop.labels or falls back to defaults.
*/
var self = this;
// Get labels - they should already be loaded by page init
var labels =
window.groupOrderShop && window.groupOrderShop.labels
? window.groupOrderShop.labels
: self._getDefaultLabels();
console.log(
"[_showDraftConflictModal] Using labels:",
Object.keys(labels).length,
"keys available"
);
var existing_items = data.existing_items || [];
var current_items = data.current_items || [];
var existing_draft_id = data.existing_draft_id;
// Create modal HTML with inline styles (no Bootstrap needed)
var modalHTML = `
<div id="draftConflictModal" style="
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
">
<div style="
background: white;
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
">
<!-- Header -->
<div style="
padding: 20px;
border-bottom: 1px solid #ddd;
display: flex;
justify-content: space-between;
align-items: center;
">
<h5 style="margin: 0; font-size: 1.25rem;">${
labels.draft_already_exists || "Draft Already Exists"
}</h5>
<button class="draft-modal-close" style="
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
">×</button>
</div>
<!-- Body -->
<div style="padding: 20px;">
<p><strong>${
labels.draft_exists_message || "A draft already exists"
}</strong></p>
<p style="margin-top: 15px;">${
labels.draft_two_options || "You have two options:"
}</p>
<!-- Option 1 -->
<div style="
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 15px;
overflow: hidden;
">
<div style="
background: #17a2b8;
color: white;
padding: 10px 15px;
font-weight: bold;
">
<i class="fa fa-code-fork"></i> ${
labels.draft_option1_title || "Option 1"
}
</div>
<div style="padding: 15px;">
<p>${labels.draft_option1_desc || "Merge with existing"}</p>
<ul style="margin: 10px 0; padding-left: 20px; font-size: 0.9rem;">
<li>${existing_items.length} ${
labels.draft_items_count || "items"
} - ${labels.draft_existing_items || "Existing"}</li>
<li>${current_items.length} ${
labels.draft_items_count || "items"
} - ${labels.draft_current_items || "Current"}</li>
</ul>
<p style="color: #666; font-size: 0.85rem; margin: 10px 0 0 0;">
${labels.draft_merge_note || "Products will be merged"}
</p>
</div>
</div>
<!-- Option 2 -->
<div style="
border: 1px solid #ddd;
border-radius: 4px;
overflow: hidden;
">
<div style="
background: #ffc107;
color: black;
padding: 10px 15px;
font-weight: bold;
">
<i class="fa fa-refresh"></i> ${
labels.draft_option2_title || "Option 2"
}
</div>
<div style="padding: 15px;">
<p>${labels.draft_option2_desc || "Replace with current"}</p>
<p style="color: #666; font-size: 0.85rem; margin: 0;">
<i class="fa fa-exclamation-triangle"></i> ${
labels.draft_replace_warning ||
"Old draft will be deleted"
}
</p>
</div>
</div>
</div>
<!-- Footer -->
<div style="
padding: 15px 20px;
border-top: 1px solid #ddd;
display: flex;
gap: 10px;
justify-content: flex-end;
">
<button class="draft-modal-close" style="
padding: 8px 16px;
background: #6c757d;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
">${labels.cancel || "Cancel"}</button>
<button id="mergeBtn" data-existing-id="${existing_draft_id}" style="
padding: 8px 16px;
background: #17a2b8;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
">
<i class="fa fa-code-fork"></i> ${labels.draft_merge_btn || "Merge"}
</button>
<button id="replaceBtn" data-existing-id="${existing_draft_id}" style="
padding: 8px 16px;
background: #ffc107;
color: black;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
">
<i class="fa fa-refresh"></i> ${
labels.draft_replace_btn || "Replace"
}
</button>
</div>
</div>
</div>
`;
// Remove existing modal if any
var existingModal = document.getElementById("draftConflictModal");
if (existingModal) {
existingModal.remove();
}
// Add modal to body
document.body.insertAdjacentHTML("beforeend", modalHTML);
var modalElement = document.getElementById("draftConflictModal");
// Handle close buttons
document.querySelectorAll(".draft-modal-close").forEach(function (btn) {
btn.addEventListener("click", function () {
modalElement.remove();
});
});
// Handle merge button
document.getElementById("mergeBtn").addEventListener("click", function () {
var existingId = this.getAttribute("data-existing-id");
modalElement.remove();
self._executeSaveDraftWithAction("merge", existingId);
});
// Handle replace button
document.getElementById("replaceBtn").addEventListener("click", function () {
var existingId = this.getAttribute("data-existing-id");
modalElement.remove();
self._executeSaveDraftWithAction("replace", existingId);
});
// Close modal when clicking outside
modalElement.addEventListener("click", function (e) {
if (e.target === modalElement) {
modalElement.remove();
}
});
},
_executeSaveDraftWithAction: function (action, existingDraftId) {
/**
* Execute save draft with merge or replace action.
*/
var self = this;
var items = [];
Object.keys(this.cart).forEach(function (productId) {
var item = self.cart[productId];
items.push({
product_id: productId,
product_name: item.name,
quantity: item.qty,
product_price: item.price,
});
});
var orderData = {
order_id: this.orderId,
items: items,
merge_action: action,
existing_draft_id: existingDraftId,
};
var xhr = new XMLHttpRequest();
xhr.open("POST", "/eskaera/save-order", true);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.onload = function () {
if (xhr.status === 200) {
try {
var data = JSON.parse(xhr.responseText);
console.log("Response:", data);
if (data.success) {
// Use server-provided labels instead of hardcoding
var labels = self._getLabels();
// Use the translated messages from server
var msg = data.merged
? "✓ " +
(labels.draft_merged_success || "Draft merged successfully")
: "✓ " +
(labels.draft_replaced_success || "Draft replaced successfully");
self._showNotification(msg, "success", 5000);
} else {
var labels = self._getLabels();
self._showNotification(
"Error: " + (data.error || labels.error_unknown || "Unknown error"),
"danger"
);
}
} catch (e) {
console.error("Error parsing response:", e);
self._showNotification("Error processing response", "danger");
}
} else {
try {
var errorData = JSON.parse(xhr.responseText);
self._showNotification(
"Error " + xhr.status + ": " + (errorData.error || "Request error"),
"danger"
);
} catch (e) {
self._showNotification(
"Error saving order (HTTP " + xhr.status + ")",
"danger"
);
}
}
};
xhr.onerror = function () {
self._showNotification("Connection error", "danger");
};
xhr.send(JSON.stringify(orderData));
},
_confirmOrder: function () { _confirmOrder: function () {
console.log("=== _confirmOrder started ==="); console.log("=== _confirmOrder started ===");
console.log("orderId:", this.orderId); console.log("orderId:", this.orderId);

View file

@ -16,6 +16,8 @@ Coverage:
from odoo.tests import tagged from odoo.tests import tagged
from odoo.tests.common import TransactionCase from odoo.tests.common import TransactionCase
from ..controllers.website_sale import AplicoopWebsiteSale
@tagged("post_install", "-at_install") @tagged("post_install", "-at_install")
class TestPricingWithPricelist(TransactionCase): class TestPricingWithPricelist(TransactionCase):
@ -49,6 +51,7 @@ class TestPricingWithPricelist(TransactionCase):
"company_ids": [(6, 0, [self.company.id])], "company_ids": [(6, 0, [self.company.id])],
} }
) )
self.partner = self.user.partner_id
# Get or create default tax group # Get or create default tax group
tax_group = self.env["account.tax.group"].search( tax_group = self.env["account.tax.group"].search(
@ -161,6 +164,9 @@ class TestPricingWithPricelist(TransactionCase):
} }
) )
# Controller helper
self.controller = AplicoopWebsiteSale()
# Create group order # Create group order
self.group_order = self.env["group.order"].create( self.group_order = self.env["group.order"].create(
{ {
@ -493,3 +499,52 @@ class TestPricingWithPricelist(TransactionCase):
except Exception: except Exception:
# If it raises, that's also acceptable behavior # If it raises, that's also acceptable behavior
self.assertTrue(True, "Negative quantity properly rejected") self.assertTrue(True, "Negative quantity properly rejected")
def test_pricing_helper_uses_config_pricelist_and_taxes(self):
"""Pricing helper must apply configured pricelist and include taxes for display."""
website = self.env.ref("website.default_website").sudo()
website.write(
{
"show_line_subtotals_tax_selection": "tax_included",
"company_id": self.company.id,
}
)
pricelist_discount = self.env["product.pricelist"].create(
{
"name": "Config Pricelist 10%",
"company_id": self.company.id,
"item_ids": [
(
0,
0,
{
"compute_price": "percentage",
"percent_price": 10.0,
"applied_on": "3_global",
},
)
],
}
)
self.env["ir.config_parameter"].sudo().set_param(
"website_sale_aplicoop.pricelist_id", pricelist_discount.id
)
product = self.product_with_tax # 100€ + 21%
pricing = self.controller._get_pricing_info(
product,
pricelist_discount,
quantity=1.0,
partner=self.partner,
)
# price_unit uses pricelist (10% discount)
self.assertAlmostEqual(pricing["price_unit"], 90.0, places=2)
# display price must include taxes (21% on discounted price)
self.assertAlmostEqual(pricing["price"], 108.9, places=2)
self.assertTrue(pricing.get("tax_included"))
self.assertTrue(pricing.get("has_discounted_price"))

View file

@ -16,6 +16,8 @@ from datetime import timedelta
from odoo.tests.common import TransactionCase from odoo.tests.common import TransactionCase
from ..controllers.website_sale import AplicoopWebsiteSale
class TestSaveOrderEndpoints(TransactionCase): class TestSaveOrderEndpoints(TransactionCase):
"""Test suite for order-saving endpoints.""" """Test suite for order-saving endpoints."""
@ -94,6 +96,51 @@ class TestSaveOrderEndpoints(TransactionCase):
# Associate product with group order # Associate product with group order
self.group_order.product_ids = [(4, self.product.id)] self.group_order.product_ids = [(4, self.product.id)]
# Helper: controller instance for pure helpers
self.controller = AplicoopWebsiteSale()
def _build_line(self, product, qty, price):
return (
0,
0,
{"product_id": product.id, "product_uom_qty": qty, "price_unit": price},
)
def test_merge_or_replace_replaces_existing_draft(self):
"""Existing draft must be replaced (not merged) with new lines."""
# Existing draft with one line
existing = self.env["sale.order"].create(
{
"partner_id": self.member_partner.id,
"group_order_id": self.group_order.id,
"pickup_day": self.group_order.pickup_day,
"pickup_date": self.group_order.pickup_date,
"home_delivery": self.group_order.home_delivery,
"order_line": [self._build_line(self.product, 1, 10.0)],
"state": "draft",
}
)
new_lines = [self._build_line(self.product, 3, 99.0)]
result = self.controller._merge_or_replace_draft(
self.group_order,
self.user,
new_lines,
existing,
self.group_order.id,
)
self.assertEqual(result.id, existing.id, "Should reuse existing draft")
self.assertEqual(len(result.order_line), 1, "Only one line should remain")
self.assertEqual(
result.order_line[0].product_uom_qty, 3, "Quantity must be replaced"
)
self.assertEqual(
result.order_line[0].price_unit, 99.0, "Price must be replaced"
)
def test_save_eskaera_draft_creates_order_with_group_order_id(self): def test_save_eskaera_draft_creates_order_with_group_order_id(self):
""" """
Test that save_eskaera_draft() creates a sale.order with group_order_id. Test that save_eskaera_draft() creates a sale.order with group_order_id.