Compare commits

..

No commits in common. "a9c1f1f609b679750bb1f71473bbf3d74a6a6228" and "6f593c62408288fc13c2e0d28e17d9bfdca08e01" have entirely different histories.

31 changed files with 560 additions and 760 deletions

View file

@ -5,15 +5,6 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [18.0.2.7.0] - 2026-02-27
### Added
- New action/button on sales orders to update non-invoiced orders from current product sale price (skips notes/downpayments, keeps discounts)
- Server action binding for mass update from list view
- Tests covering sales order update flow and invoiced order exclusion
- Translations updated (es, eu) for the new UI strings
## [18.0.2.6.0] - 2026-02-14
### Fixed

View file

@ -29,7 +29,6 @@ This module automatically calculates and updates product sale prices based on th
* **Batch Updates**: Update theoretical prices for multiple products
* **Product Flags**: Mark products for price updates and track status
* **Variant Architecture**: All business logic in product.product for proper pricelist handling
* **Sales Orders Update**: Action/button to sync sale order lines (not invoiced) with current product sale price
**Table of contents**
@ -96,12 +95,6 @@ Usage
#. System recalculates theoretical prices based on current settings
#. Review and apply changes
**Update Sales Orders from List Price:**
#. Go to Sales → Orders (form or list) and use **Update with List Price**
#. Only orders not invoiced/canceled are processed
#. The action copies current product sale price to normal lines (skipping notes and downpayments) and keeps discounts
**Price Calculation Flow:**
#. Purchase order is received

View file

@ -15,7 +15,6 @@ Automatically calculate and update product sale prices based on the last purchas
- **UoM Conversion**: Handles different purchase and sale units of measure
- **Batch Updates**: Update theoretical prices for multiple products
- **Product Flags**: Mark products for price updates and track status
- **Sales Orders Update**: Action/button to sync non-invoiced sale order lines with current product sale price
## Configuration
@ -40,7 +39,6 @@ Automatically calculate and update product sale prices based on the last purchas
3. Sale price is calculated from the configured pricelist using the cost price
4. Taxes are automatically applied based on product tax settings
5. Go to **Products > Update Theoretical Prices** to batch update prices
6. Go to **Sales > Orders** and use **Update with List Price** (form or list). Applies only to non-invoiced/non-canceled orders, skips notes and downpayments, preserves discounts.
## Technical Architecture

View file

@ -2,7 +2,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{ # noqa: B018
"name": "Product Sale Price from Pricelist",
"version": "18.0.2.7.0",
"version": "18.0.2.6.0",
"category": "product",
"summary": "Set sale price from pricelist based on last purchase price",
"author": "Odoo Community Association (OCA), Criptomart",
@ -20,6 +20,5 @@
"data": [
"views/actions.xml",
"views/product_view.xml",
"views/sale_order_view.xml",
],
}

View file

@ -187,32 +187,6 @@ msgstr "Triple descuento"
msgid "Update Sales Price"
msgstr "Actualizar Precio de Venta"
#. module: product_sale_price_from_pricelist
#: model:ir.actions.server,name:product_sale_price_from_pricelist.action_sale_update_from_list_price
#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
msgid "Update with List Price"
msgstr "Actualizar con precio de venta"
#. module: product_sale_price_from_pricelist
#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
msgid "Update skipped"
msgstr "Actualización omitida"
#. module: product_sale_price_from_pricelist
#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
msgid "No sales orders to update. Only non-invoiced and non-canceled orders are processed."
msgstr "No hay pedidos de venta que actualizar. Solo se procesan los no facturados ni cancelados."
#. module: product_sale_price_from_pricelist
#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
msgid "Sales price update"
msgstr "Actualización de precios de venta"
#. module: product_sale_price_from_pricelist
#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
msgid "Updated %(lines)s lines across %(orders)s sales orders."
msgstr "Se actualizaron %(lines)s líneas en %(orders)s pedidos de venta."
#. module: product_sale_price_from_pricelist
#: model:ir.model.fields.selection,name:product_sale_price_from_pricelist.selection__product_template__last_purchase_price_compute_type__without_discounts
msgid "Without discounts"

View file

@ -1,43 +0,0 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# \t* product_sale_price_from_pricelist
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-27 00:00+0000\n"
"PO-Revision-Date: 2026-02-27 00:00+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: eu\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. module: product_sale_price_from_pricelist
#: model:ir.actions.server,name:product_sale_price_from_pricelist.action_sale_update_from_list_price
#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
msgid "Update with List Price"
msgstr "Eguneratu tarifa-prezioarekin"
#. module: product_sale_price_from_pricelist
#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
msgid "Update skipped"
msgstr "Eguneratzea saltatu da"
#. module: product_sale_price_from_pricelist
#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
msgid "No sales orders to update. Only non-invoiced and non-canceled orders are processed."
msgstr "Ez dago eguneratzeko salmenta-agindurik. Soilik fakturatu gabe eta bertan behera utzi gabe daudenak prozesatzen dira."
#. module: product_sale_price_from_pricelist
#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
msgid "Sales price update"
msgstr "Salmenta-prezioaren eguneraketa"
#. module: product_sale_price_from_pricelist
#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
msgid "Updated %(lines)s lines across %(orders)s sales orders."
msgstr "Eguneratu dira %(orders)s salmenta-agindutako %(lines)s lerro."

View file

@ -1,7 +1,6 @@
from . import product_pricelist # noqa: F401
from . import product_pricelist_item # noqa: F401
from . import product_product # noqa: F401
from . import product_template # noqa: F401
from . import res_config # noqa: F401
from . import stock_move # noqa: F401
from . import sale_order # noqa: F401
from . import product_pricelist
from . import product_pricelist_item
from . import product_product
from . import product_template
from . import res_config
from . import stock_move

View file

@ -1,72 +0,0 @@
import logging
from odoo import models
_logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_inherit = "sale.order"
def action_update_lines_from_list_price(self):
"""Copy current product list price into sale order lines.
Applies to orders that are not fully invoiced. Skips display and
downpayment lines, keeps discounts. Totals/taxes are recomputed by
field dependencies when the lines change.
"""
eligible_orders = self.filtered(
lambda so: so.invoice_status not in ("invoiced", "up_to_date")
and so.state != "cancel"
)
if not eligible_orders:
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": self.env._("Update skipped"),
"message": self.env._(
"No sales orders to update. Only non-invoiced and non-canceled orders are processed."
),
"type": "warning",
"sticky": False,
},
}
total_lines = 0
for order in eligible_orders:
lines = order.order_line.filtered(
lambda line: not line.display_type
and not line.is_downpayment
and line.product_id
)
for line in lines:
price_unit = line.product_id.uom_id._compute_price(
line.product_id.lst_price, line.product_uom
)
line.price_unit = price_unit
total_lines += len(lines)
_logger.info(
"[SALE PRICE UPDATE] Order %s (%s): updated %s lines",
order.name,
order.id,
len(lines),
)
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": self.env._("Sales price update"),
"message": self.env._(
"Updated %(lines)s lines across %(orders)s sales orders.",
lines=total_lines,
orders=len(eligible_orders),
),
"type": "success" if total_lines else "warning",
"sticky": False,
},
}

View file

@ -12,12 +12,6 @@
#. System recalculates theoretical prices based on current settings
#. Review and apply changes
**Update Sales Orders from List Price:**
#. Go to Sales → Orders (form or list) and click **Update with List Price**
#. Only orders not invoiced or canceled are processed
#. Copies current product sale price to normal lines (skips notes/sections and downpayments) and preserves discounts
**Price Calculation Flow:**
#. Purchase order is received

View file

@ -1,95 +0,0 @@
# Copyright (C) 2026: Criptomart (https://criptomart.net)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged("post_install", "-at_install")
class TestSaleOrderUpdate(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env.ref("base.res_partner_1")
cls.product = cls.env["product.product"].create(
{
"name": "Test Product SO",
"list_price": 120.0,
"standard_price": 80.0,
}
)
cls.downpayment_product = cls.env["product.product"].create(
{
"name": "Downpayment Product",
"type": "service",
"list_price": 10.0,
}
)
def _create_sale_order(self, values=None):
vals = {
"partner_id": self.partner.id,
"order_line": [
(
0,
0,
{
"product_id": self.product.id,
"product_uom": self.product.uom_id.id,
"product_uom_qty": 2.0,
"price_unit": 50.0,
"tax_id": [(6, 0, self.product.taxes_id.ids)],
},
),
(0, 0, {"display_type": "line_note", "name": "Note"}),
(
0,
0,
{
"product_id": self.downpayment_product.id,
"product_uom": self.downpayment_product.uom_id.id,
"product_uom_qty": 1.0,
"price_unit": 10.0,
"is_downpayment": True,
},
),
],
}
if values:
vals.update(values)
return self.env["sale.order"].create(vals)
def test_update_lines_copies_list_price(self):
order = self._create_sale_order()
order.action_update_lines_from_list_price()
line = order.order_line.filtered(lambda line: line.product_id == self.product)
self.assertTrue(line)
self.assertEqual(line.price_unit, self.product.list_price)
# Downpayment and display lines remain unchanged
down_line = order.order_line.filtered("is_downpayment")
self.assertTrue(down_line)
self.assertEqual(down_line.price_unit, 10.0)
def test_invoiced_orders_are_skipped(self):
order = self._create_sale_order()
order.action_confirm()
invoice = order._create_invoices()
invoice.action_post()
order._compute_invoice_status()
self.assertEqual(order.invoice_status, "invoiced")
# Change product price to ensure potential change
order.order_line.filtered(
lambda line: line.product_id == self.product
).price_unit = 5.0
order.action_update_lines_from_list_price()
# Price should remain untouched because order is invoiced
line = order.order_line.filtered(lambda line: line.product_id == self.product)
self.assertEqual(line.price_unit, 5.0)

View file

@ -1,47 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<!-- Server action for mass update from list view -->
<record id="action_sale_update_from_list_price" model="ir.actions.server">
<field name="name">Update with List Price</field>
<field name="model_id" ref="sale.model_sale_order" />
<field name="binding_model_id" ref="sale.model_sale_order" />
<field name="binding_view_types">list,form</field>
<field name="state">code</field>
<field name="code">records.action_update_lines_from_list_price()</field>
</record>
<!-- Button in sale order form -->
<record id="view_order_form_update_list_price" model="ir.ui.view">
<field name="name">sale.order.form.update.list.price</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form" />
<field name="arch" type="xml">
<xpath expr="//button[@name='action_update_prices']" position="after">
<button
name="action_update_lines_from_list_price"
type="object"
string="Update with List Price"
class="btn-link mb-1 px-0"
icon="fa-refresh"
invisible="invoice_status in ('invoiced','up_to_date') or state == 'cancel'"
/>
</xpath>
</field>
</record>
<!-- Action menu entry in tree/list view -->
<record id="view_order_tree_update_list_price" model="ir.ui.view">
<field name="name">sale.order.tree.update.list.price</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_quotation_tree" />
<field name="arch" type="xml">
<xpath expr="//list" position="attributes">
<attribute name="decoration-muted">state == 'cancel'</attribute>
</xpath>
</field>
</record>
</data>
</odoo>

View file

@ -1,13 +0,0 @@
===============================
Stock Picking Batch Custom
===============================
.. contents::
:local:
.. include:: readme/DESCRIPTION.rst
.. include:: readme/INSTALL.rst
.. include:: readme/CONFIGURE.rst
.. include:: readme/USAGE.rst
.. include:: readme/CONTRIBUTORS.rst
.. include:: readme/CREDITS.rst

View file

@ -1 +0,0 @@
from . import models # noqa: F401

View file

@ -1,18 +0,0 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{ # noqa: B018
"name": "Stock Picking Batch Custom",
"version": "18.0.1.0.0",
"category": "Warehouse",
"summary": "Extra columns for batch detailed operations",
"author": "Odoo Community Association (OCA), Criptomart",
"maintainers": ["Criptomart"],
"website": "https://github.com/Criptomart",
"license": "AGPL-3",
"depends": [
"stock_picking_batch",
],
"data": [
"views/stock_move_line_views.xml",
],
}

View file

@ -1 +0,0 @@
from . import stock_move_line # noqa: F401

View file

@ -1,17 +0,0 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields
from odoo import models
class StockMoveLine(models.Model):
_inherit = "stock.move.line"
product_categ_id = fields.Many2one(
comodel_name="product.category",
string="Product Category",
related="product_id.categ_id",
store=True,
readonly=True,
)

View file

@ -1,8 +0,0 @@
Configuración
=============
No requiere configuración adicional. Para usar las columnas:
- Abrir un **Lote de picking**.
- Ir a la pestaña **Detailed Operations**.
- Abrir el **selector de columnas** y activar *Partner* y *Product Category* según necesidad.

View file

@ -1,4 +0,0 @@
Contribuidores
==============
* Criptomart

View file

@ -1,12 +0,0 @@
Créditos
========
Autor
-----
* Criptomart
Financiador
-----------
* Elika Bilbo

View file

@ -1,11 +0,0 @@
Este módulo añade dos columnas opcionales en las operaciones detalladas de los
lotes de picking:
- ``picking_partner_id`` (Partner del albarán) para que el personal de almacén
pueda identificar rápidamente el cliente/proveedor asociado.
- ``product_categ_id`` (Categoría de producto) para permitir ordenación y
agrupación por categoría.
Ambas columnas se añaden como ``optional="hide"`` en la vista de líneas del
lote, de modo que el usuario puede activarlas desde el selector de columnas sin
cargar la vista por defecto.

View file

@ -1,8 +0,0 @@
Instalación
===========
Actualizar o instalar el módulo:
::
docker-compose run --rm odoo odoo -d odoo --stop-after-init -u stock_picking_batch_custom

View file

@ -1,10 +0,0 @@
Uso
===
1. Accede a **Inventory > Operations > Batch Transfers** y abre un lote.
2. Pestaña **Detailed Operations**: usa el selector de columnas para activar:
- **Partner** (``picking_partner_id``) para ver el cliente/proveedor.
- **Product Category** (``product_categ_id``) para ordenar/agrupación por categoría.
3. Ordena o agrupa por la columna de categoría según convenga.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

View file

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_move_line_tree_inherit_batch_custom" model="ir.ui.view">
<field name="name">stock.move.line.list.batch.custom</field>
<field name="model">stock.move.line</field>
<field name="inherit_id" ref="stock_picking_batch.view_move_line_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='product_id']" position="after">
<field name="product_categ_id" optional="hide"/>
</xpath>
<xpath expr="//field[@name='picking_id']" position="after">
<field name="picking_partner_id" optional="hide"/>
</xpath>
</field>
</record>
</odoo>

View file

@ -6,7 +6,6 @@ import logging
from datetime import datetime
from datetime import timedelta
from odoo import fields
from odoo import http
from odoo.http import request
@ -303,6 +302,7 @@ class AplicoopWebsiteSale(WebsiteSale):
# Agregar a los hijos de su padre
category_map[parent_id]["children"].append(cat_info)
# Ordenar raíces y sus hijos por nombre
def sort_hierarchy(items):
items.sort(key=lambda x: x["name"])
for item in items:
@ -325,49 +325,87 @@ class AplicoopWebsiteSale(WebsiteSale):
Returns:
product.pricelist record or False if none found
"""
env = request.env
website = request.website
pricelist = None
# 1) Configured pricelist from settings
# Try to get configured Aplicoop pricelist first
try:
param_value = (
env["ir.config_parameter"]
aplicoop_pricelist_id = (
request.env["ir.config_parameter"]
.sudo()
.get_param("website_sale_aplicoop.pricelist_id")
)
if param_value:
if aplicoop_pricelist_id:
pricelist = (
env["product.pricelist"].browse(int(param_value)).exists() or None
request.env["product.pricelist"]
.sudo()
.browse(int(aplicoop_pricelist_id))
)
except Exception as e:
_logger.warning("_resolve_pricelist: error reading config param: %s", e)
if pricelist.exists():
_logger.info(
"_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)
)
# 2) Website current pricelist
if not pricelist:
try:
pricelist = website._get_current_pricelist()
except Exception as e:
_logger.warning(
"_resolve_pricelist: fallback to website pricelist failed: %s", e
# Fallback to website pricelist
try:
pricelist = request.website._get_current_pricelist()
if pricelist:
_logger.info(
"_resolve_pricelist: Using website pricelist: %s (id=%s)",
pricelist.name,
pricelist.id,
)
return pricelist
except Exception as err:
_logger.warning(
"_resolve_pricelist: Error getting website pricelist: %s", str(err)
)
# 3) First active pricelist as fallback
if not pricelist:
pricelist = env["product.pricelist"].sudo().search([], limit=1)
# Final fallback to first active pricelist
pricelist = (
request.env["product.pricelist"]
.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
return pricelist
_logger.error(
"_resolve_pricelist: ERROR - No pricelist found! Pricing may fail."
)
return False
def _prepare_product_display_info(self, product, product_price_info):
"""Build display info for a product using precomputed prices.
"""Prepare all display information for a product in a QWeb-safe way.
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:
product (product.product): Product variant.
product_price_info: dict with price data keyed by product.id.
product: product.template record
product_price_info: dict with 'price', 'list_price', etc.
Returns:
dict with display_price, safe_uom_category, quantity_step
dict with all pre-processed display values ready for template
"""
# Safety: Get price, ensure it's a float
price_data = product_price_info.get(product.id, {})
price = (
price_data.get("price", product.list_price)
@ -376,14 +414,19 @@ class AplicoopWebsiteSale(WebsiteSale):
)
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 = ""
quantity_step = 1
quantity_step = 1 # Default step for integer quantities (Units)
if product.uom_id:
uom = product.uom_id.sudo()
if uom.category_id:
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:
# Get external ID for the UoM category
ir_model_data = request.env["ir.model.data"].sudo()
external_id = ir_model_data.search(
[
@ -394,16 +437,18 @@ class AplicoopWebsiteSale(WebsiteSale):
)
if external_id:
# Standard Odoo UoM categories requiring fractional step
fractional_categories = [
"uom.product_uom_categ_kgm",
"uom.product_uom_categ_vol",
"uom.uom_categ_length",
"uom.uom_categ_surface",
"uom.product_uom_categ_kgm", # Weight (kg, g, ton, etc.)
"uom.product_uom_categ_vol", # Volume (L, m³, etc.)
"uom.uom_categ_length", # Length/Distance (m, km, etc.)
"uom.uom_categ_surface", # Surface (m², ha, etc.)
]
full_xmlid = f"{external_id.module}.{external_id.name}"
if full_xmlid in fractional_categories:
quantity_step = 0.1
except Exception as e:
# Fallback to integer step on error
_logger.warning(
"_prepare_product_display_info: Error detecting UoM category XML ID for product %s: %s",
product.id,
@ -416,70 +461,6 @@ class AplicoopWebsiteSale(WebsiteSale):
"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):
"""Compute price info dict for a list of products using the given pricelist.
@ -492,13 +473,23 @@ class AplicoopWebsiteSale(WebsiteSale):
)
if product_variant and pricelist:
try:
pricing = self._get_pricing_info(
product_variant,
pricelist,
quantity=1.0,
partner=request.env.user.partner_id,
price_info = product_variant._get_price(
qty=1.0,
pricelist=pricelist,
fposition=request.website.fiscal_position_id,
)
product_price_info[product.id] = pricing
price = price_info.get("value", 0.0)
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:
_logger.warning(
"_compute_price_info: Error getting price for product %s (id=%s): %s. Using list_price fallback.",
@ -507,23 +498,19 @@ class AplicoopWebsiteSale(WebsiteSale):
str(e),
)
product_price_info[product.id] = {
"price_unit": product.list_price,
"price": product.list_price,
"list_price": product.list_price,
"has_discounted_price": False,
"discount": 0.0,
"tax_included": request.website.show_line_subtotals_tax_selection
!= "tax_excluded",
"tax_included": False,
}
else:
product_price_info[product.id] = {
"price_unit": product.list_price,
"price": product.list_price,
"list_price": product.list_price,
"has_discounted_price": False,
"discount": 0.0,
"tax_included": request.website.show_line_subtotals_tax_selection
!= "tax_excluded",
"tax_included": False,
}
return product_price_info
@ -827,7 +814,7 @@ class AplicoopWebsiteSale(WebsiteSale):
return order_id, group_order, current_user, items, is_delivery
def _process_cart_items(self, items, group_order, pricelist=None):
def _process_cart_items(self, items, group_order):
"""Process cart items and build sale.order line data.
Args:
@ -841,13 +828,12 @@ class AplicoopWebsiteSale(WebsiteSale):
ValueError: if no valid items after processing
"""
sale_order_lines = []
pricelist = pricelist or self._resolve_pricelist()
partner = request.env.user.partner_id
for item in items:
try:
product_id = int(item.get("product_id"))
quantity = float(item.get("quantity", 1))
price = float(item.get("product_price", 0))
product = request.env["product.product"].sudo().browse(product_id)
if not product.exists():
@ -860,17 +846,10 @@ class AplicoopWebsiteSale(WebsiteSale):
product_in_lang = product.with_context(lang=request.env.lang)
product_name = product_in_lang.name
pricing = self._get_pricing_info(
product,
pricelist,
quantity=quantity,
partner=partner,
)
line_data = {
"product_id": product_id,
"product_uom_qty": quantity,
"price_unit": pricing.get("price_unit", product.list_price),
"price_unit": price or product.list_price,
"name": product_name, # Force the translated product name
}
_logger.info("_process_cart_items: Adding line: %s", line_data)
@ -1039,7 +1018,19 @@ class AplicoopWebsiteSale(WebsiteSale):
return sale_order
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
pickup_day_name, pickup_date_str, pickup_day_index = self._format_pickup_info(
group_order, is_delivery
@ -1229,29 +1220,86 @@ class AplicoopWebsiteSale(WebsiteSale):
group_order,
current_user,
sale_order_lines,
merge_action,
existing_draft_id,
existing_drafts,
order_id,
):
"""Replace existing draft (if any) with new lines, else create it."""
"""Handle merge/replace logic for drafts and return (sale_order, merge_success).
if existing_drafts:
draft = existing_drafts[0].sudo()
existing_drafts: recordset of existing draft orders (may be empty)
"""
# 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(
"Replacing existing draft order %s for partner %s",
draft.id,
current_user.partner_id.id,
"Deleted existing draft(s) for replace: %s",
existing_drafts.mapped("id"),
)
draft.write(
{
"order_line": [(5, 0, 0)] + sale_order_lines,
"group_order_id": order_id,
"pickup_day": group_order.pickup_day,
"pickup_date": group_order.pickup_date,
"home_delivery": group_order.home_delivery,
}
)
return draft
order_vals = {
"partner_id": current_user.partner_id.id,
"order_line": sale_order_lines,
"state": "draft",
"group_order_id": order_id,
"pickup_day": group_order.pickup_day,
"pickup_date": group_order.pickup_date,
"home_delivery": group_order.home_delivery,
}
# 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)
return sale_order, False
# Default: create new draft
order_vals = {
"partner_id": current_user.partner_id.id,
"order_line": sale_order_lines,
@ -1267,7 +1315,7 @@ class AplicoopWebsiteSale(WebsiteSale):
order_vals["user_id"] = salesperson.id
sale_order = request.env["sale.order"].sudo().create(order_vals)
return sale_order
return sale_order, False
def _decode_json_body(self):
"""Safely decode JSON body from request. Returns dict or raises ValueError."""
@ -1997,9 +2045,7 @@ class AplicoopWebsiteSale(WebsiteSale):
# Build sale.order lines and create draft using helpers
try:
sale_order_lines = self._process_cart_items(
items, group_order, pricelist=self._resolve_pricelist()
)
sale_order_lines = self._process_cart_items(items, group_order)
except ValueError as e:
return request.make_response(
json.dumps({"error": str(e)}),
@ -2125,21 +2171,13 @@ class AplicoopWebsiteSale(WebsiteSale):
# Extract items from the draft order
items = []
pricelist = self._resolve_pricelist()
partner = current_user.partner_id
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(
{
"product_id": line.product_id.id,
"product_name": line.product_id.name,
"quantity": line.product_uom_qty,
"product_price": pricing.get("price", line.price_unit),
"product_price": line.price_unit,
}
)
@ -2268,6 +2306,8 @@ class AplicoopWebsiteSale(WebsiteSale):
# Get cart 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:
_logger.warning(
@ -2294,6 +2334,35 @@ 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(
"Creating draft sale.order with %d items for partner %d",
len(items),
@ -2302,9 +2371,7 @@ class AplicoopWebsiteSale(WebsiteSale):
# Create sales.order lines from items using shared helper
try:
sale_order_lines = self._process_cart_items(
items, group_order, pricelist=self._resolve_pricelist()
)
sale_order_lines = self._process_cart_items(items, group_order)
except ValueError as e:
return request.make_response(
json.dumps({"error": str(e)}),
@ -2313,10 +2380,12 @@ class AplicoopWebsiteSale(WebsiteSale):
)
# Delegate merge/replace/create logic to helper
sale_order = self._merge_or_replace_draft(
sale_order, merge_success = self._merge_or_replace_draft(
group_order,
current_user,
sale_order_lines,
merge_action,
existing_draft_id,
existing_drafts,
order_id,
)
@ -2335,8 +2404,13 @@ class AplicoopWebsiteSale(WebsiteSale):
json.dumps(
{
"success": True,
"message": request.env._("Order saved as draft"),
"message": (
request.env._("Merged with existing draft")
if merge_success
else request.env._("Order saved as draft")
),
"sale_order_id": sale_order.id,
"merged": merge_success,
}
),
[("Content-Type", "application/json")],
@ -2407,9 +2481,7 @@ class AplicoopWebsiteSale(WebsiteSale):
# Process cart items using helper
try:
sale_order_lines = self._process_cart_items(
items, group_order, pricelist=self._resolve_pricelist()
)
sale_order_lines = self._process_cart_items(items, group_order)
except ValueError as e:
_logger.warning("confirm_eskaera: Cart processing error: %s", str(e))
return request.make_response(

View file

@ -680,44 +680,4 @@ class GroupOrder(models.Model):
order._compute_cutoff_date()
order._compute_pickup_date()
order._compute_delivery_date()
order._confirm_linked_sale_orders()
_logger.info("Cron: Date update completed")
def _confirm_linked_sale_orders(self):
"""Confirm draft/sent sale orders linked to this group order.
This is triggered by the daily cron so that weekly orders generated
from the website are confirmed automatically once dates are refreshed.
"""
self.ensure_one()
SaleOrder = self.env["sale.order"].sudo()
sale_orders = SaleOrder.search(
[
("group_order_id", "=", self.id),
("state", "in", ["draft", "sent"]),
]
)
if not sale_orders:
_logger.info(
"Cron: No sale orders to confirm for group order %s (%s)",
self.id,
self.name,
)
return
_logger.info(
"Cron: Confirming %d sale orders for group order %s (%s)",
len(sale_orders),
self.id,
self.name,
)
try:
sale_orders.action_confirm()
except Exception:
_logger.exception(
"Cron: Error confirming sale orders for group order %s (%s)",
self.id,
self.name,
)

View file

@ -213,14 +213,6 @@
var cartKey = "eskaera_" + this.orderId + "_cart";
localStorage.setItem(cartKey, JSON.stringify(cart));
// Mantener sincronizado el carrito en memoria (sidebar de tienda)
if (window.groupOrderShop && window.groupOrderShop.cart !== undefined) {
window.groupOrderShop.cart = cart;
if (typeof window.groupOrderShop._updateCartDisplay === "function") {
window.groupOrderShop._updateCartDisplay();
}
}
// Re-render checkout summary without reloading
setTimeout(function () {
// Use the global function from checkout_labels.js

View file

@ -449,7 +449,7 @@
var tooltipMap = {
"save-cart-btn": "save_cart",
"reload-cart-btn": "reload_cart",
"confirm-order-btn": "save_cart",
"confirm-order-btn": "confirm_order",
"remove-from-cart": "remove_item",
};
@ -634,9 +634,9 @@
if (confirmBtn) {
confirmBtn.addEventListener("click", function (e) {
console.log("[CLICK] confirm-order-btn clicked (save draft)");
console.log("[CLICK] confirm-order-btn clicked");
e.preventDefault();
self._saveOrderDraft();
self._confirmOrder();
});
}
@ -1313,7 +1313,6 @@
var orderData = {
order_id: this.orderId,
items: items,
merge_action: "replace",
};
var xhr = new XMLHttpRequest();
@ -1333,6 +1332,9 @@
data.sale_order_id +
")";
self._showNotification("✓ " + successMsg, "success", 5000);
} else if (data.existing_draft) {
// A draft already exists - show modal with merge/replace options
self._showDraftConflictModal(data);
} else {
self._showNotification(
"Error: " + (data.error || labels.error_unknown || "Unknown error"),
@ -1497,14 +1499,10 @@
console.log("[_saveOrderDraft] Starting - this.orderId:", this.orderId);
var self = this;
var labels = this._getLabels();
var cartKey = "eskaera_" + this.orderId + "_cart";
var storedCart = localStorage.getItem(cartKey);
var cart = storedCart ? JSON.parse(storedCart) : this.cart;
var items = [];
Object.keys(cart).forEach(function (productId) {
var item = cart[productId];
Object.keys(this.cart).forEach(function (productId) {
var item = self.cart[productId];
items.push({
product_id: productId,
product_name: item.name,
@ -1529,11 +1527,14 @@
console.log("Response:", data);
if (data.success) {
var successMsg =
labels.draft_saved_success ||
labels.draft_saved ||
"Order saved as draft successfully";
self._showNotification("\u2713 " + successMsg, "success", 5000);
self._showNotification(
"✓ Order saved as draft successfully",
"success",
5000
);
} else if (data.existing_draft) {
// A draft already exists - show modal with merge/replace options
self._showDraftConflictModal(data);
} else {
self._showNotification(
"Error: " + (data.error || labels.error_unknown || "Unknown error"),
@ -1571,6 +1572,306 @@
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 () {
console.log("=== _confirmOrder started ===");
console.log("orderId:", this.orderId);

View file

@ -16,8 +16,6 @@ Coverage:
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
from ..controllers.website_sale import AplicoopWebsiteSale
@tagged("post_install", "-at_install")
class TestPricingWithPricelist(TransactionCase):
@ -51,7 +49,6 @@ class TestPricingWithPricelist(TransactionCase):
"company_ids": [(6, 0, [self.company.id])],
}
)
self.partner = self.user.partner_id
# Get or create default tax group
tax_group = self.env["account.tax.group"].search(
@ -164,9 +161,6 @@ class TestPricingWithPricelist(TransactionCase):
}
)
# Controller helper
self.controller = AplicoopWebsiteSale()
# Create group order
self.group_order = self.env["group.order"].create(
{
@ -499,52 +493,3 @@ class TestPricingWithPricelist(TransactionCase):
except Exception:
# If it raises, that's also acceptable behavior
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,8 +16,6 @@ from datetime import timedelta
from odoo.tests.common import TransactionCase
from ..controllers.website_sale import AplicoopWebsiteSale
class TestSaveOrderEndpoints(TransactionCase):
"""Test suite for order-saving endpoints."""
@ -96,51 +94,6 @@ class TestSaveOrderEndpoints(TransactionCase):
# Associate product with group order
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):
"""
Test that save_eskaera_draft() creates a sale.order with group_order_id.

View file

@ -349,11 +349,6 @@
<button type="button" class="btn btn-primary cart-btn-compact" id="save-cart-btn" t-attf-data-order-id="{{ group_order.id }}" data-bs-title="Save Cart" data-bs-toggle="tooltip">
<i class="fa fa-save cart-icon-size" />
</button>
<t t-if="group_order.home_delivery and delivery_product_id">
<button type="button" class="btn btn-outline-warning cart-btn-compact" id="home-delivery-btn" aria-label="Toggle home delivery" data-bs-title="Home Delivery" data-bs-toggle="tooltip">
<i class="fa fa-truck cart-icon-size" aria-hidden="true" />
</button>
</t>
<a t-attf-href="/eskaera/{{ group_order.id }}/checkout" class="btn btn-success cart-btn-compact" aria-label="Proceed to checkout" data-bs-title="Proceed to Checkout" data-bs-toggle="tooltip">
<i class="fa fa-check cart-icon-size" aria-hidden="true" />
</a>
@ -523,6 +518,16 @@
</div>
</div>
</div>
<div class="alert alert-warning alert-dismissible fade show" role="alert">
<div>
<i class="fa fa-exclamation-triangle" aria-hidden="true" t-translation="off" />
<span class="fw-bold">Important</span>:
</div>
<p>
Once you confirm this order, you will not be able to modify it. Please review carefully before confirming.
</p>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close" />
</div>
<div class="checkout-actions d-grid gap-3" id="checkout-form-labels">
<button class="btn btn-success btn-lg" id="confirm-order-btn" t-attf-data-order-id="{{ group_order.id }}" data-confirmed-label="Order saved as draft" data-pickup-label="Pickup Day" aria-label="Save order as draft" data-bs-title="Save Draft" data-bs-toggle="tooltip">
<i class="fa fa-save" aria-hidden="true" t-translation="off" />