Compare commits
8 commits
6f593c6240
...
a9c1f1f609
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9c1f1f609 | ||
|
|
aef57a3de4 | ||
|
|
d294843241 | ||
|
|
55497327e8 | ||
|
|
ced21cc489 | ||
|
|
97dc41d212 | ||
|
|
2f57a5d14e | ||
|
|
6935d8fc83 |
31 changed files with 760 additions and 560 deletions
|
|
@ -5,6 +5,15 @@ 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/),
|
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).
|
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
|
## [18.0.2.6.0] - 2026-02-14
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ This module automatically calculates and updates product sale prices based on th
|
||||||
* **Batch Updates**: Update theoretical prices for multiple products
|
* **Batch Updates**: Update theoretical prices for multiple products
|
||||||
* **Product Flags**: Mark products for price updates and track status
|
* **Product Flags**: Mark products for price updates and track status
|
||||||
* **Variant Architecture**: All business logic in product.product for proper pricelist handling
|
* **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**
|
**Table of contents**
|
||||||
|
|
||||||
|
|
@ -95,6 +96,12 @@ Usage
|
||||||
#. System recalculates theoretical prices based on current settings
|
#. System recalculates theoretical prices based on current settings
|
||||||
#. Review and apply changes
|
#. 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:**
|
**Price Calculation Flow:**
|
||||||
|
|
||||||
#. Purchase order is received
|
#. Purchase order is received
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ Automatically calculate and update product sale prices based on the last purchas
|
||||||
- **UoM Conversion**: Handles different purchase and sale units of measure
|
- **UoM Conversion**: Handles different purchase and sale units of measure
|
||||||
- **Batch Updates**: Update theoretical prices for multiple products
|
- **Batch Updates**: Update theoretical prices for multiple products
|
||||||
- **Product Flags**: Mark products for price updates and track status
|
- **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
|
## Configuration
|
||||||
|
|
||||||
|
|
@ -39,6 +40,7 @@ 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
|
3. Sale price is calculated from the configured pricelist using the cost price
|
||||||
4. Taxes are automatically applied based on product tax settings
|
4. Taxes are automatically applied based on product tax settings
|
||||||
5. Go to **Products > Update Theoretical Prices** to batch update prices
|
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
|
## Technical Architecture
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||||
{ # noqa: B018
|
{ # noqa: B018
|
||||||
"name": "Product Sale Price from Pricelist",
|
"name": "Product Sale Price from Pricelist",
|
||||||
"version": "18.0.2.6.0",
|
"version": "18.0.2.7.0",
|
||||||
"category": "product",
|
"category": "product",
|
||||||
"summary": "Set sale price from pricelist based on last purchase price",
|
"summary": "Set sale price from pricelist based on last purchase price",
|
||||||
"author": "Odoo Community Association (OCA), Criptomart",
|
"author": "Odoo Community Association (OCA), Criptomart",
|
||||||
|
|
@ -20,5 +20,6 @@
|
||||||
"data": [
|
"data": [
|
||||||
"views/actions.xml",
|
"views/actions.xml",
|
||||||
"views/product_view.xml",
|
"views/product_view.xml",
|
||||||
|
"views/sale_order_view.xml",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,32 @@ msgstr "Triple descuento"
|
||||||
msgid "Update Sales Price"
|
msgid "Update Sales Price"
|
||||||
msgstr "Actualizar Precio de Venta"
|
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
|
#. 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
|
#: model:ir.model.fields.selection,name:product_sale_price_from_pricelist.selection__product_template__last_purchase_price_compute_type__without_discounts
|
||||||
msgid "Without discounts"
|
msgid "Without discounts"
|
||||||
|
|
|
||||||
43
product_sale_price_from_pricelist/i18n/eu.po
Normal file
43
product_sale_price_from_pricelist/i18n/eu.po
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# 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."
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from . import product_pricelist
|
from . import product_pricelist # noqa: F401
|
||||||
from . import product_pricelist_item
|
from . import product_pricelist_item # noqa: F401
|
||||||
from . import product_product
|
from . import product_product # noqa: F401
|
||||||
from . import product_template
|
from . import product_template # noqa: F401
|
||||||
from . import res_config
|
from . import res_config # noqa: F401
|
||||||
from . import stock_move
|
from . import stock_move # noqa: F401
|
||||||
|
from . import sale_order # noqa: F401
|
||||||
|
|
|
||||||
72
product_sale_price_from_pricelist/models/sale_order.py
Normal file
72
product_sale_price_from_pricelist/models/sale_order.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,12 @@
|
||||||
#. System recalculates theoretical prices based on current settings
|
#. System recalculates theoretical prices based on current settings
|
||||||
#. Review and apply changes
|
#. 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:**
|
**Price Calculation Flow:**
|
||||||
|
|
||||||
#. Purchase order is received
|
#. Purchase order is received
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
# 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)
|
||||||
47
product_sale_price_from_pricelist/views/sale_order_view.xml
Normal file
47
product_sale_price_from_pricelist/views/sale_order_view.xml
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?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>
|
||||||
13
stock_picking_batch_custom/README.rst
Normal file
13
stock_picking_batch_custom/README.rst
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
===============================
|
||||||
|
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
|
||||||
1
stock_picking_batch_custom/__init__.py
Normal file
1
stock_picking_batch_custom/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from . import models # noqa: F401
|
||||||
18
stock_picking_batch_custom/__manifest__.py
Normal file
18
stock_picking_batch_custom/__manifest__.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# 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",
|
||||||
|
],
|
||||||
|
}
|
||||||
1
stock_picking_batch_custom/models/__init__.py
Normal file
1
stock_picking_batch_custom/models/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from . import stock_move_line # noqa: F401
|
||||||
17
stock_picking_batch_custom/models/stock_move_line.py
Normal file
17
stock_picking_batch_custom/models/stock_move_line.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# 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,
|
||||||
|
)
|
||||||
8
stock_picking_batch_custom/readme/CONFIGURE.rst
Normal file
8
stock_picking_batch_custom/readme/CONFIGURE.rst
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
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.
|
||||||
4
stock_picking_batch_custom/readme/CONTRIBUTORS.rst
Normal file
4
stock_picking_batch_custom/readme/CONTRIBUTORS.rst
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
Contribuidores
|
||||||
|
==============
|
||||||
|
|
||||||
|
* Criptomart
|
||||||
12
stock_picking_batch_custom/readme/CREDITS.rst
Normal file
12
stock_picking_batch_custom/readme/CREDITS.rst
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
Créditos
|
||||||
|
========
|
||||||
|
|
||||||
|
Autor
|
||||||
|
-----
|
||||||
|
|
||||||
|
* Criptomart
|
||||||
|
|
||||||
|
Financiador
|
||||||
|
-----------
|
||||||
|
|
||||||
|
* Elika Bilbo
|
||||||
11
stock_picking_batch_custom/readme/DESCRIPTION.rst
Normal file
11
stock_picking_batch_custom/readme/DESCRIPTION.rst
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
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.
|
||||||
8
stock_picking_batch_custom/readme/INSTALL.rst
Normal file
8
stock_picking_batch_custom/readme/INSTALL.rst
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
Instalación
|
||||||
|
===========
|
||||||
|
|
||||||
|
Actualizar o instalar el módulo:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
docker-compose run --rm odoo odoo -d odoo --stop-after-init -u stock_picking_batch_custom
|
||||||
10
stock_picking_batch_custom/readme/USAGE.rst
Normal file
10
stock_picking_batch_custom/readme/USAGE.rst
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
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.
|
||||||
BIN
stock_picking_batch_custom/static/description/icon.png
Normal file
BIN
stock_picking_batch_custom/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
16
stock_picking_batch_custom/views/stock_move_line_views.xml
Normal file
16
stock_picking_batch_custom/views/stock_move_line_views.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?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>
|
||||||
|
|
@ -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():
|
|
||||||
_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)
|
|
||||||
)
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning("_resolve_pricelist: error reading config param: %s", e)
|
||||||
|
|
||||||
# Fallback to website pricelist
|
# 2) Website current pricelist
|
||||||
|
if not pricelist:
|
||||||
try:
|
try:
|
||||||
pricelist = request.website._get_current_pricelist()
|
pricelist = website._get_current_pricelist()
|
||||||
if pricelist:
|
except Exception as e:
|
||||||
_logger.info(
|
|
||||||
"_resolve_pricelist: Using website pricelist: %s (id=%s)",
|
|
||||||
pricelist.name,
|
|
||||||
pricelist.id,
|
|
||||||
)
|
|
||||||
return pricelist
|
|
||||||
except Exception as err:
|
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
"_resolve_pricelist: Error getting website pricelist: %s", str(err)
|
"_resolve_pricelist: fallback to website pricelist failed: %s", e
|
||||||
)
|
)
|
||||||
|
|
||||||
# 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
|
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):
|
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,68 +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
|
_logger.info(
|
||||||
if merge_action == "merge" and existing_draft_id:
|
"Replacing existing draft order %s for partner %s",
|
||||||
existing_draft = (
|
draft.id,
|
||||||
request.env["sale.order"].sudo().browse(int(existing_draft_id))
|
current_user.partner_id.id,
|
||||||
)
|
)
|
||||||
if existing_draft.exists():
|
draft.write(
|
||||||
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
|
"order_line": [(5, 0, 0)] + sale_order_lines,
|
||||||
+ new_quantity
|
"group_order_id": order_id,
|
||||||
|
"pickup_day": group_order.pickup_day,
|
||||||
|
"pickup_date": group_order.pickup_date,
|
||||||
|
"home_delivery": group_order.home_delivery,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
_logger.info(
|
return draft
|
||||||
"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(
|
|
||||||
"Deleted existing draft(s) for replace: %s",
|
|
||||||
existing_drafts.mapped("id"),
|
|
||||||
)
|
|
||||||
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,
|
||||||
|
|
@ -1297,25 +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
|
||||||
|
|
||||||
# Default: create new 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
|
|
||||||
|
|
||||||
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(
|
||||||
|
|
|
||||||
|
|
@ -680,4 +680,44 @@ class GroupOrder(models.Model):
|
||||||
order._compute_cutoff_date()
|
order._compute_cutoff_date()
|
||||||
order._compute_pickup_date()
|
order._compute_pickup_date()
|
||||||
order._compute_delivery_date()
|
order._compute_delivery_date()
|
||||||
|
order._confirm_linked_sale_orders()
|
||||||
_logger.info("Cron: Date update completed")
|
_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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,14 @@
|
||||||
var cartKey = "eskaera_" + this.orderId + "_cart";
|
var cartKey = "eskaera_" + this.orderId + "_cart";
|
||||||
localStorage.setItem(cartKey, JSON.stringify(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
|
// Re-render checkout summary without reloading
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
// Use the global function from checkout_labels.js
|
// Use the global function from checkout_labels.js
|
||||||
|
|
|
||||||
|
|
@ -449,7 +449,7 @@
|
||||||
var tooltipMap = {
|
var tooltipMap = {
|
||||||
"save-cart-btn": "save_cart",
|
"save-cart-btn": "save_cart",
|
||||||
"reload-cart-btn": "reload_cart",
|
"reload-cart-btn": "reload_cart",
|
||||||
"confirm-order-btn": "confirm_order",
|
"confirm-order-btn": "save_cart",
|
||||||
"remove-from-cart": "remove_item",
|
"remove-from-cart": "remove_item",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -634,9 +634,9 @@
|
||||||
|
|
||||||
if (confirmBtn) {
|
if (confirmBtn) {
|
||||||
confirmBtn.addEventListener("click", function (e) {
|
confirmBtn.addEventListener("click", function (e) {
|
||||||
console.log("[CLICK] confirm-order-btn clicked");
|
console.log("[CLICK] confirm-order-btn clicked (save draft)");
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
self._confirmOrder();
|
self._saveOrderDraft();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
@ -1499,10 +1497,14 @@
|
||||||
console.log("[_saveOrderDraft] Starting - this.orderId:", this.orderId);
|
console.log("[_saveOrderDraft] Starting - this.orderId:", this.orderId);
|
||||||
|
|
||||||
var self = this;
|
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 = [];
|
var items = [];
|
||||||
|
|
||||||
Object.keys(this.cart).forEach(function (productId) {
|
Object.keys(cart).forEach(function (productId) {
|
||||||
var item = self.cart[productId];
|
var item = cart[productId];
|
||||||
items.push({
|
items.push({
|
||||||
product_id: productId,
|
product_id: productId,
|
||||||
product_name: item.name,
|
product_name: item.name,
|
||||||
|
|
@ -1527,14 +1529,11 @@
|
||||||
console.log("Response:", data);
|
console.log("Response:", data);
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
self._showNotification(
|
var successMsg =
|
||||||
"✓ Order saved as draft successfully",
|
labels.draft_saved_success ||
|
||||||
"success",
|
labels.draft_saved ||
|
||||||
5000
|
"Order saved as draft successfully";
|
||||||
);
|
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"),
|
||||||
|
|
@ -1572,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);
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -349,6 +349,11 @@
|
||||||
<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">
|
<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" />
|
<i class="fa fa-save cart-icon-size" />
|
||||||
</button>
|
</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">
|
<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" />
|
<i class="fa fa-check cart-icon-size" aria-hidden="true" />
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -518,16 +523,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<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">
|
<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" />
|
<i class="fa fa-save" aria-hidden="true" t-translation="off" />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue