Compare commits
6 commits
a67181ab42
...
5a2f5d120f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a2f5d120f | ||
|
|
817ff31d39 | ||
|
|
3e4bd5e5db | ||
|
|
3eb79e2431 | ||
|
|
b3999e2283 | ||
|
|
6ba554c91b |
55 changed files with 4060 additions and 5225 deletions
22
.env.example
Normal file
22
.env.example
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Local environment for docker-compose. Copy to `.env` and adjust the paths:
|
||||
#
|
||||
# cp .env.example .env
|
||||
#
|
||||
# `.env` is gitignored: every developer points these at their own checkouts.
|
||||
# docker-compose reads it automatically. For manual `docker-compose run` calls
|
||||
# that need $ODOO_ADDONS_PATH in the shell, load it first:
|
||||
#
|
||||
# set -a; . ./.env; set +a
|
||||
|
||||
# Host directory holding the read-only Odoo/OCA sources (OCB + the OCA repos
|
||||
# listed in oca_dependencies.txt). Each repo is mounted at /mnt/oca/<repo>.
|
||||
ODOO_SRC=/media/snt/data/odoo
|
||||
|
||||
# Host ports published by the odoo service (web / longpolling).
|
||||
ODOO_HTTP_PORT=8069
|
||||
ODOO_LONGPOLLING_PORT=8072
|
||||
|
||||
# Addons path *inside* the container. Single source of truth: used by the
|
||||
# `command` in docker-compose.yml and by manual `docker-compose run`
|
||||
# invocations (`odoo --addons-path=$ODOO_ADDONS_PATH ...`).
|
||||
ODOO_ADDONS_PATH=/mnt/extra-addons,/mnt/oca/account-invoice-reporting,/mnt/oca/account-invoicing,/mnt/oca/bank-payment,/mnt/oca/community-data-files,/mnt/oca/l10n-spain,/mnt/oca/pos,/mnt/oca/product-attribute,/mnt/oca/purchase-workflow,/mnt/oca/sale-workflow,/mnt/oca/stock-logistics-workflow,/usr/lib/python3/dist-packages/odoo/addons
|
||||
|
|
@ -1,3 +1,14 @@
|
|||
# Host-specific values (paths, ports) live in `.env` — see `.env.example`:
|
||||
#
|
||||
# cp .env.example .env
|
||||
#
|
||||
# Note: `docker-compose run odoo odoo ...` replaces the `command` below, so the
|
||||
# addons path has to be repeated there. Load `.env` into the shell first:
|
||||
#
|
||||
# set -a; . ./.env; set +a
|
||||
# docker-compose run --rm odoo odoo -c /etc/odoo/odoo.conf \
|
||||
# --addons-path=$ODOO_ADDONS_PATH \
|
||||
# -d odoo -u <addon> --test-enable --stop-after-init
|
||||
services:
|
||||
db:
|
||||
image: postgres:15
|
||||
|
|
@ -21,8 +32,8 @@ services:
|
|||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8069:8069"
|
||||
- "8072:8072"
|
||||
- "${ODOO_HTTP_PORT:-8069}:8069"
|
||||
- "${ODOO_LONGPOLLING_PORT:-8072}:8072"
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "8069"
|
||||
|
|
@ -30,7 +41,22 @@ services:
|
|||
- ./:/mnt/extra-addons/
|
||||
- ./odoo.conf:/etc/odoo/odoo.conf:ro
|
||||
- odoo_data_addons_cm:/var/lib/odoo
|
||||
command: odoo -c /etc/odoo/odoo.conf
|
||||
# OCA sources living outside this repo (dependencies that are not
|
||||
# here: `pos_payment_method_cashdro`, `account_banking_mandate`, ...),
|
||||
# mounted read-only from $ODOO_SRC.
|
||||
- ${ODOO_SRC:?set ODOO_SRC in .env, see .env.example}/account-invoice-reporting:/mnt/oca/account-invoice-reporting:ro
|
||||
- ${ODOO_SRC}/account-invoicing:/mnt/oca/account-invoicing:ro
|
||||
- ${ODOO_SRC}/bank-payment:/mnt/oca/bank-payment:ro
|
||||
- ${ODOO_SRC}/community-data-files:/mnt/oca/community-data-files:ro
|
||||
- ${ODOO_SRC}/l10n-spain:/mnt/oca/l10n-spain:ro
|
||||
- ${ODOO_SRC}/pos:/mnt/oca/pos:ro
|
||||
- ${ODOO_SRC}/product-attribute:/mnt/oca/product-attribute:ro
|
||||
- ${ODOO_SRC}/purchase-workflow:/mnt/oca/purchase-workflow:ro
|
||||
- ${ODOO_SRC}/sale-workflow:/mnt/oca/sale-workflow:ro
|
||||
- ${ODOO_SRC}/stock-logistics-workflow:/mnt/oca/stock-logistics-workflow:ro
|
||||
command: >
|
||||
odoo -c /etc/odoo/odoo.conf
|
||||
--addons-path=${ODOO_ADDONS_PATH:?set ODOO_ADDONS_PATH in .env, see .env.example}
|
||||
|
||||
volumes:
|
||||
postgres_data_addons_cm:
|
||||
|
|
|
|||
|
|
@ -57,8 +57,8 @@ To check the three fixes on a PoS with a CashDro terminal:
|
|||
configured host.
|
||||
|
||||
The Python side is covered by the tests. The base module lives outside this
|
||||
repo, so the OCA sources have to be on the addons path (see
|
||||
``docker-compose.override.yml``, which is local and untracked)::
|
||||
repo, so the OCA sources have to be on the addons path (mounted by
|
||||
``docker-compose.yml`` from ``ODOO_SRC``, set in your local ``.env``)::
|
||||
|
||||
docker-compose run --rm odoo odoo -c /etc/odoo/odoo.conf \
|
||||
--addons-path=/mnt/extra-addons,/mnt/oca/pos,/usr/lib/python3/dist-packages/odoo/addons \
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ To check the three fixes on a PoS with a CashDro terminal:
|
|||
configured host.
|
||||
|
||||
The Python side is covered by the tests. The base module lives outside this
|
||||
repo, so the OCA sources have to be on the addons path (see
|
||||
``docker-compose.override.yml``, which is local and untracked)::
|
||||
repo, so the OCA sources have to be on the addons path (mounted by
|
||||
``docker-compose.yml`` from ``ODOO_SRC``, set in your local ``.env``)::
|
||||
|
||||
docker-compose run --rm odoo odoo -c /etc/odoo/odoo.conf \
|
||||
--addons-path=/mnt/extra-addons,/mnt/oca/pos,/usr/lib/python3/dist-packages/odoo/addons \
|
||||
|
|
|
|||
147
website_sale_aplicoop/COBERTURA_TESTS_PENDIENTE.md
Normal file
147
website_sale_aplicoop/COBERTURA_TESTS_PENDIENTE.md
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
# Cobertura de tests — website_sale_aplicoop
|
||||
|
||||
Estado tras recuperar y reescribir los tests que se habían perdido con la limpieza
|
||||
de código muerto (commit `b3999e2`).
|
||||
|
||||
La suite quedó en verde y creció en unos 90 tests respecto a los 270 que había
|
||||
tras la limpieza.
|
||||
|
||||
Los ficheros originales, si alguna vez hacen falta:
|
||||
|
||||
```bash
|
||||
git show b3999e2^:website_sale_aplicoop/tests/<fichero>.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recuperado y arreglado
|
||||
|
||||
### 1. `test_product_discovery.py` — blacklists y descubrimiento (46 tests)
|
||||
|
||||
Era el hueco grave: **ningún test cubría las tres blacklists**, que son lógica viva
|
||||
con "prioridad absoluta" sobre el descubrimiento
|
||||
([group_order.py:571-627](models/group_order.py#L571-L627)).
|
||||
|
||||
Dos bugs distintos en el fichero original:
|
||||
|
||||
- **`TestSupplierBlacklist` (6 fallos)**: el fixture escribía `main_seller_id` en el
|
||||
`create` del template, pero en el addon OCA `product_main_seller` ese campo es
|
||||
*computed + store* derivado de `variant_seller_ids` — el compute lo pisaba a `False`,
|
||||
los productos quedaban sin proveedor principal y la blacklist no tenía nada que
|
||||
excluir. Arreglado creando `product.supplierinfo` de verdad, más un test-guarda
|
||||
(`test_main_seller_is_computed_from_supplierinfo`) para que no vuelva a pasar en
|
||||
silencio.
|
||||
- **Cuatro clases enteras no probaban el descubrimiento**: hacían aserciones contra
|
||||
`group_order.product_ids`, que es el campo m2m de *entrada*, no el resultado. Es
|
||||
decir, asignaban `category_ids` y comprobaban `product_ids`, así que sólo podían
|
||||
fallar; y algunas "pasaban" por el motivo equivocado (el campo estaba vacío de
|
||||
todos modos). Tres tests no tenían ni una aserción. Todo reescrito contra
|
||||
`_get_products_for_group_order`.
|
||||
|
||||
Añadido de paso: productos archivados, pedido sin ninguna fuente, id inexistente, y
|
||||
el contrato de ordenación real (`is_out_of_stock`, `website_sequence`, nombre).
|
||||
|
||||
### 2. `test_date_edge_cases.py` — calendario (12 tests)
|
||||
|
||||
Sustituye al antiguo `test_edge_cases.py`. Los 4 fallos que había eran **del test**,
|
||||
no de producción: daban por hecho que `pickup_date` se calcula desde `start_date`,
|
||||
cuando en realidad la cadena es `cutoff_date` → `pickup_date` → `delivery_date`, y
|
||||
para un pedido recurrente con `start_date` pasada la referencia pasa a ser **hoy**
|
||||
(el ciclo avanza al actual). Un pedido de 2024 no tiene fecha de recogida en 2024.
|
||||
|
||||
Los nuevos anclan en fechas **futuras** para que el avance de ciclo no mueva el caso
|
||||
bajo los pies, y buscan el próximo 29-F dinámicamente (no hay fechas quemadas que
|
||||
caduquen). Cubren: recogida justo en 29-F, ciclo que cruza el 29-F, entrega el 1-M,
|
||||
cruce de mes y de año, último día de mes, rejilla mensual desde un ancla de 31-E y
|
||||
en febrero bisiesto. `TestRollForwardBehaviour` documenta el avance de ciclo, que es
|
||||
justo lo que el test viejo malinterpretaba.
|
||||
|
||||
### 3. `test_portal_routes.py` — humo HTTP con usuario portal (8 tests)
|
||||
|
||||
Une los tres ficheros portal borrados. Los fallos eran del test:
|
||||
|
||||
- **`/eskaera/labels` devolvía 400**: la ruta es `type="json"` y el test la llamaba
|
||||
con un GET plano. Odoo responde 400 correctamente. Ahora se llama por JSON-RPC
|
||||
(`make_jsonrpc_request`), y hay un test que fija ese 400 del GET para que nadie
|
||||
repita el error.
|
||||
- **`uom.uom.categ` no existe** en Odoo 18 (es `uom.category`), y `factor_inv` →
|
||||
`factor`.
|
||||
- **La afiliación al grupo no se aplicaba**: hay que poner `is_group=True` en el
|
||||
grupo y asignar `group_ids` desde el *miembro* (es el idioma que usa
|
||||
`test_record_rules.py`). Sin eso el guard rebotaba a `/eskaera`.
|
||||
|
||||
Ojo con esto último: el smoke test original sólo miraba `status_code == 200`, y el
|
||||
rebote a la lista **también** devuelve 200 — pasaba sin que el socio entrase nunca a
|
||||
la tienda. Añadido `test_shop_page_is_not_bounced_to_the_list`, que comprueba la URL
|
||||
final.
|
||||
|
||||
### 4. `test_price_with_taxes_included.py` — impuestos (14 tests)
|
||||
|
||||
Dos causas, ambas del test:
|
||||
|
||||
- **`price_include` es computed en Odoo 18**, derivado de `price_include_override`
|
||||
(Selection `tax_included`/`tax_excluded`). El fixture escribía `price_include=True`
|
||||
y se descartaba en silencio. Añadido `test_price_include_is_driven_by_the_override`
|
||||
como guarda.
|
||||
- **`_get_price` filtra los impuestos por compañía**, y su parámetro `company` cae
|
||||
por defecto en `self.env.company`. Como el fixture crea una compañía propia, la
|
||||
lista de impuestos salía **vacía** y `tax_included` era siempre `False`. Nótese que
|
||||
`test_oca_get_price_returns_base_without_tax` pasaba por este motivo equivocado.
|
||||
Ahora todas las llamadas van por un helper que pasa `company=self.company`.
|
||||
|
||||
---
|
||||
|
||||
## Sin recuperar (no había nada que rescatar)
|
||||
|
||||
Tres ficheros eran plantillas sin implementar — setUp seguido de métodos con
|
||||
`# Placeholder: will be implemented...` y cero aserciones, o aserciones sobre
|
||||
diccionarios simulados en vez de llamar al controlador:
|
||||
|
||||
- `test_endpoints.py`
|
||||
- `test_helper_methods_phase1.py`
|
||||
- `test_phase2_eskaera_shop.py`
|
||||
|
||||
---
|
||||
|
||||
## Pendiente
|
||||
|
||||
### `test_draft_persistence.py` — persistencia de borradores
|
||||
|
||||
Lo único de los 11 ficheros que aún no se ha rehecho. Cubría casos que hoy no cubre
|
||||
nadie (`test_save_order_endpoints.py` y `test_group_order_status_endpoint.py` cubren
|
||||
el guardado y el ciclo, no esto):
|
||||
|
||||
- **el precio del borrador es una foto fija**: cambiar `list_price` después no altera
|
||||
el borrador ya guardado. El más valioso — es una garantía de negocio real (al socio
|
||||
se le respeta el precio que vio) y ahora mismo nada la protege.
|
||||
- borrador con producto archivado después de guardarlo
|
||||
- borrador de un pedido ya cerrado / de hace 6 meses
|
||||
- un borrador no es visible para otro usuario
|
||||
- recuento de borradores por usuario
|
||||
|
||||
Sólo 2 de sus 14 tests fallaban, así que la recuperación debería ser barata.
|
||||
|
||||
### Huecos menores
|
||||
|
||||
- Transiciones de estado **ilegales** (draft→closed, cancelled→open). La suite cubre
|
||||
las legales en `test_group_order.py`.
|
||||
- `available_products_count` fuera del contexto de blacklists (dentro de ellas ya
|
||||
está cubierto).
|
||||
|
||||
Nota: las constraints `_check_company_groups` y `_check_dates` **sí** están cubiertas
|
||||
(`test_multi_company.py:147`, `test_group_order.py:66`) — no son hueco.
|
||||
|
||||
---
|
||||
|
||||
## Conclusión sobre los "posibles bugs de producción"
|
||||
|
||||
Los tres que quedaron marcados para investigar (fechas 29-F, `/eskaera/labels` 400,
|
||||
`tax_included`) resultaron ser **todos bugs de los tests**, en su mayoría restos de la
|
||||
migración 17→18: `is_supplier`, `main_seller_id`, `uom.uom.categ`, `factor_inv`,
|
||||
`price_include`. No apareció ningún bug de producción.
|
||||
|
||||
Lo que sí apareció, y es más incómodo, son **tests que pasaban por el motivo
|
||||
equivocado** — el smoke test del portal que se conformaba con el rebote, y
|
||||
`test_oca_get_price_returns_base_without_tax` que verificaba `tax_included == False`
|
||||
sobre una lista de impuestos vacía. Por eso cada arreglo lleva ahora un test-guarda
|
||||
que falla si el fixture deja de construir lo que dice construir.
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
{ # noqa: B018
|
||||
"name": "Website Sale - Aplicoop",
|
||||
"version": "18.0.1.13.0",
|
||||
"version": "18.0.1.15.0",
|
||||
"category": "Website/Sale",
|
||||
"summary": "Modern replacement of legacy Aplicoop - Collaborative consumption group orders",
|
||||
"author": "Odoo Community Association (OCA), Criptomart",
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
"depends": [
|
||||
"website_sale",
|
||||
"website_sale_stock",
|
||||
"payment",
|
||||
"product",
|
||||
"sale",
|
||||
"stock",
|
||||
|
|
@ -59,10 +60,6 @@
|
|||
# Demo: Sale Orders
|
||||
"demo/sale_order_demo.xml",
|
||||
],
|
||||
"i18n": [
|
||||
"i18n/es.po",
|
||||
"i18n/eu_ES.po",
|
||||
],
|
||||
"external_dependencies": {
|
||||
"python": [],
|
||||
},
|
||||
|
|
@ -76,7 +73,7 @@
|
|||
"website_sale_aplicoop/static/src/js/website_sale.js",
|
||||
"website_sale_aplicoop/static/src/js/checkout_labels.js",
|
||||
"website_sale_aplicoop/static/src/js/home_delivery.js",
|
||||
"website_sale_aplicoop/static/src/js/checkout_summary.js",
|
||||
"website_sale_aplicoop/static/src/js/eskaera_payment.js",
|
||||
# Search and pagination
|
||||
"website_sale_aplicoop/static/src/js/infinite_scroll.js",
|
||||
"website_sale_aplicoop/static/src/js/realtime_search.js",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -91,6 +91,13 @@ def _get_translated_labels(self, lang=None, request_obj=None):
|
|||
"save_draft": tr("Save Draft"),
|
||||
"save_order_as_draft": tr("Save order as draft"),
|
||||
"order_saved_as_draft": tr("Order saved as draft"),
|
||||
"confirm_and_pay": tr("Confirm and pay"),
|
||||
"confirm_and_pay_hint": tr("Confirm the order and go to payment"),
|
||||
"order_ready_for_payment": tr("Order ready for payment"),
|
||||
"already_placed": tr("You already placed an order for this cycle."),
|
||||
"no_payment_method": tr(
|
||||
"Online payment is not available right now. Please contact your group."
|
||||
),
|
||||
"save_cart": tr("Save Cart"),
|
||||
"reload_cart": tr("Reload Cart"),
|
||||
"proceed_to_checkout": tr("Proceed to Checkout"),
|
||||
|
|
@ -142,18 +149,3 @@ def _get_translated_labels(self, lang=None, request_obj=None):
|
|||
}
|
||||
|
||||
return labels
|
||||
|
||||
|
||||
def _translate_labels(self, labels_dict, lang):
|
||||
# Minimal fallback translator kept here; prefers env translations
|
||||
translations = {
|
||||
"es_ES": {},
|
||||
}
|
||||
lang_translations = translations.get(lang, {})
|
||||
translated = {}
|
||||
for key, english_label in labels_dict.items():
|
||||
translated[key] = lang_translations.get(english_label, english_label)
|
||||
_logger.info(
|
||||
"[_translate_labels] Language: %s, Translated %d labels", lang, len(translated)
|
||||
)
|
||||
return translated
|
||||
|
|
|
|||
|
|
@ -6,42 +6,6 @@ from odoo.http import request
|
|||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_pricelist(self, request_obj=None):
|
||||
try:
|
||||
req = request_obj or request
|
||||
env = req.env
|
||||
website = req.website
|
||||
except RuntimeError:
|
||||
env = getattr(self, "env", None) or self.env
|
||||
website = env["website"].get_current_website()
|
||||
pricelist = None
|
||||
try:
|
||||
param_value = (
|
||||
env["ir.config_parameter"]
|
||||
.sudo()
|
||||
.get_param("website_sale_aplicoop.pricelist_id")
|
||||
)
|
||||
if param_value:
|
||||
pricelist = (
|
||||
env["product.pricelist"].browse(int(param_value)).exists() or None
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.warning("_resolve_pricelist: error reading config param: %s", e)
|
||||
|
||||
if not pricelist:
|
||||
try:
|
||||
pricelist = website._get_current_pricelist()
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"_resolve_pricelist: fallback to website pricelist failed: %s", e
|
||||
)
|
||||
|
||||
if not pricelist:
|
||||
pricelist = env["product.pricelist"].sudo().search([], limit=1)
|
||||
|
||||
return pricelist
|
||||
|
||||
|
||||
def _prepare_product_display_info(self, product, product_price_info, request_obj=None):
|
||||
price_data = product_price_info.get(product.id, {})
|
||||
price = (
|
||||
|
|
@ -119,132 +83,182 @@ def _prepare_product_display_info(self, product, product_price_info, request_obj
|
|||
}
|
||||
|
||||
|
||||
def _get_pricing_info(
|
||||
self,
|
||||
product,
|
||||
pricelist,
|
||||
quantity=1.0,
|
||||
partner=None,
|
||||
request_obj=None,
|
||||
):
|
||||
req = request_obj or request
|
||||
def _pricing_context(record, request_obj=None):
|
||||
"""Return (env, website), both under an HTTP request and from the cron."""
|
||||
try:
|
||||
env = req.env
|
||||
website = req.website
|
||||
req = request_obj or request
|
||||
return req.env, req.website
|
||||
except RuntimeError:
|
||||
env = product.env
|
||||
website = env["website"].get_current_website()
|
||||
env = record.env
|
||||
return env, env["website"].get_current_website()
|
||||
|
||||
partner = partner or env.user.partner_id
|
||||
currency = pricelist.currency_id
|
||||
|
||||
def _pricing_company(product, website, env):
|
||||
website_company = (
|
||||
website.company_id
|
||||
if website and getattr(website, "company_id", False)
|
||||
else False
|
||||
)
|
||||
company = website_company or product.company_id or env.company
|
||||
return website_company or product.company_id or env.company
|
||||
|
||||
price, rule_id = pricelist._get_product_price_rule(
|
||||
product=product, quantity=quantity, target_currency=currency
|
||||
|
||||
def _display_taxes(product, website, company):
|
||||
"""Product taxes, and those same taxes after the website fiscal position."""
|
||||
product_taxes = product.sudo().taxes_id._filter_taxes_by_company(company)
|
||||
if not product_taxes:
|
||||
return product_taxes, product_taxes
|
||||
fiscal_position = (
|
||||
website.fiscal_position_id.sudo()
|
||||
if website and getattr(website, "fiscal_position_id", False)
|
||||
else product.env["account.fiscal.position"].sudo()
|
||||
)
|
||||
return product_taxes, fiscal_position.map_tax(product_taxes)
|
||||
|
||||
|
||||
def _display_price(product, price, currency, product_taxes, taxes, website):
|
||||
"""Price as the shop displays it, computed by website_sale itself.
|
||||
|
||||
`product.template._apply_taxes_to_price` rebases the price onto the taxes
|
||||
the fiscal position mapped to (which matters for tax-included taxes) and
|
||||
then honours the website's `show_line_subtotals_tax_selection`.
|
||||
"""
|
||||
return product.product_tmpl_id._apply_taxes_to_price(
|
||||
price, currency, product_taxes, taxes, product, website=website
|
||||
)
|
||||
|
||||
|
||||
def _build_pricing_info(
|
||||
product, price, pricelist_item, currency, quantity, website, company
|
||||
):
|
||||
"""Assemble the pricing dict from an already resolved pricelist price."""
|
||||
price_before_discount = price
|
||||
pricelist_item = env["product.pricelist.item"].sudo().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),
|
||||
date=fields.Date.context_today(product),
|
||||
uom=product.uom_id,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
has_discounted_price = price_before_discount > price
|
||||
|
||||
fiscal_position = (
|
||||
website.fiscal_position_id.sudo()
|
||||
if website and getattr(website, "fiscal_position_id", False)
|
||||
else env["account.fiscal.position"].sudo()
|
||||
product_taxes, taxes = _display_taxes(product, website, company)
|
||||
display_price = _display_price(
|
||||
product, price, currency, product_taxes, taxes, website
|
||||
)
|
||||
display_list_price = _display_price(
|
||||
product, price_before_discount, currency, product_taxes, taxes, website
|
||||
)
|
||||
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,
|
||||
"has_discounted_price": price_before_discount > price,
|
||||
"discount": display_list_price - display_price,
|
||||
"tax_included": tax_display == "total_included",
|
||||
"tax_included": _tax_included(website),
|
||||
}
|
||||
|
||||
|
||||
def _tax_included(website):
|
||||
"""Whether displayed prices carry tax, per the website setting."""
|
||||
if not website:
|
||||
return True
|
||||
return website.show_line_subtotals_tax_selection != "tax_excluded"
|
||||
|
||||
|
||||
def _get_pricing_info(
|
||||
self,
|
||||
product,
|
||||
pricelist,
|
||||
quantity=1.0,
|
||||
request_obj=None,
|
||||
):
|
||||
"""Price one product.
|
||||
|
||||
No partner argument: website_sale bills the tax display to the current
|
||||
user's partner itself, which is the same partner every caller here passed.
|
||||
"""
|
||||
env, website = _pricing_context(product, request_obj)
|
||||
currency = pricelist.currency_id
|
||||
|
||||
# No currency kwarg: _compute_price_rule already defaults to the
|
||||
# pricelist's own currency, which is what we want here.
|
||||
price, rule_id = pricelist._get_product_price_rule(product, quantity)
|
||||
|
||||
return _build_pricing_info(
|
||||
product,
|
||||
price,
|
||||
env["product.pricelist.item"].sudo().browse(rule_id),
|
||||
currency,
|
||||
quantity,
|
||||
website,
|
||||
_pricing_company(product, website, env),
|
||||
)
|
||||
|
||||
|
||||
def _pricing_variant(product):
|
||||
"""The variant to price: the record itself when it already is one."""
|
||||
if product._name == "product.product":
|
||||
return product
|
||||
return product.product_variant_ids[:1]
|
||||
|
||||
|
||||
def _fallback_pricing(product, website):
|
||||
"""Plain list price, used when there is no pricelist or pricing failed."""
|
||||
price = product.list_price
|
||||
return {
|
||||
"price_unit": price,
|
||||
"price": price,
|
||||
"list_price": price,
|
||||
"has_discounted_price": False,
|
||||
"discount": 0.0,
|
||||
"tax_included": _tax_included(website),
|
||||
}
|
||||
|
||||
|
||||
def _compute_price_info(self, products, pricelist, request_obj=None):
|
||||
"""Price a whole page of products with a single pricelist resolution."""
|
||||
if not products:
|
||||
return {}
|
||||
|
||||
env, website = _pricing_context(products, request_obj)
|
||||
currency = pricelist.currency_id if pricelist else None
|
||||
|
||||
variants = {product.id: _pricing_variant(product) for product in products}
|
||||
priceable = env["product.product"].browse()
|
||||
for variant in variants.values():
|
||||
priceable |= variant
|
||||
|
||||
# One pricelist resolution for the whole page rather than one per product.
|
||||
rules = (
|
||||
pricelist._compute_price_rule(priceable, 1.0) if pricelist and priceable else {}
|
||||
)
|
||||
|
||||
product_price_info = {}
|
||||
|
||||
def _tax_included_default(product_record):
|
||||
try:
|
||||
req = request_obj or request
|
||||
return req.website.show_line_subtotals_tax_selection != "tax_excluded"
|
||||
except RuntimeError:
|
||||
website = product_record.env["website"].get_current_website()
|
||||
if not website:
|
||||
return True
|
||||
return website.show_line_subtotals_tax_selection != "tax_excluded"
|
||||
|
||||
for product in products:
|
||||
product_variant = (
|
||||
product.product_variant_ids[0] if product.product_variant_ids else False
|
||||
)
|
||||
if product_variant and pricelist:
|
||||
try:
|
||||
try:
|
||||
req = request_obj or request
|
||||
partner = req.env.user.partner_id
|
||||
except RuntimeError:
|
||||
partner = product_variant.env.user.partner_id
|
||||
|
||||
pricing = _get_pricing_info(
|
||||
self,
|
||||
product_variant,
|
||||
pricelist,
|
||||
quantity=1.0,
|
||||
partner=partner,
|
||||
request_obj=request_obj,
|
||||
)
|
||||
product_price_info[product.id] = pricing
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"_compute_price_info: Error getting price for product %s (id=%s): %s. Using list_price fallback.",
|
||||
product.name,
|
||||
product.id,
|
||||
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": _tax_included_default(product),
|
||||
}
|
||||
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": _tax_included_default(product),
|
||||
}
|
||||
variant = variants[product.id]
|
||||
if not variant or variant.id not in rules:
|
||||
product_price_info[product.id] = _fallback_pricing(product, website)
|
||||
continue
|
||||
try:
|
||||
price, rule_id = rules[variant.id]
|
||||
product_price_info[product.id] = _build_pricing_info(
|
||||
variant,
|
||||
price,
|
||||
env["product.pricelist.item"].sudo().browse(rule_id),
|
||||
currency,
|
||||
1.0,
|
||||
website,
|
||||
_pricing_company(variant, website, env),
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"_compute_price_info: Error getting price for product %s (id=%s): %s. Using list_price fallback.",
|
||||
product.name,
|
||||
product.id,
|
||||
str(e),
|
||||
)
|
||||
product_price_info[product.id] = _fallback_pricing(product, website)
|
||||
return product_price_info
|
||||
|
||||
|
||||
|
|
@ -265,46 +279,27 @@ def _get_delivery_product_display_price(
|
|||
self, delivery_product, pricelist=None, request_obj=None
|
||||
):
|
||||
if not delivery_product:
|
||||
return 5.74
|
||||
return 0.0
|
||||
try:
|
||||
base_price = float(delivery_product.list_price or 0.0)
|
||||
try:
|
||||
req = request_obj or request
|
||||
website = req.website
|
||||
partner = req.env.user.partner_id
|
||||
company = (
|
||||
website.company_id or delivery_product.company_id or req.env.company
|
||||
)
|
||||
except RuntimeError:
|
||||
env = delivery_product.env
|
||||
website = env["website"].get_current_website()
|
||||
partner = env.user.partner_id
|
||||
company = website.company_id or delivery_product.company_id or env.company
|
||||
|
||||
product_taxes = delivery_product.sudo().taxes_id._filter_taxes_by_company(
|
||||
company
|
||||
)
|
||||
fiscal_position = (
|
||||
website.fiscal_position_id.sudo()
|
||||
if website and getattr(website, "fiscal_position_id", False)
|
||||
else delivery_product.env["account.fiscal.position"]
|
||||
)
|
||||
taxes = (
|
||||
fiscal_position.map_tax(product_taxes) if product_taxes else product_taxes
|
||||
)
|
||||
env, website = _pricing_context(delivery_product, request_obj)
|
||||
company = _pricing_company(delivery_product, website, env)
|
||||
|
||||
product_taxes, taxes = _display_taxes(delivery_product, website, company)
|
||||
if not taxes:
|
||||
return base_price
|
||||
|
||||
currency = website.currency_id
|
||||
totals = taxes.compute_all(
|
||||
base_price,
|
||||
currency=currency,
|
||||
quantity=1.0,
|
||||
product=delivery_product,
|
||||
partner=partner,
|
||||
return float(
|
||||
_display_price(
|
||||
delivery_product,
|
||||
base_price,
|
||||
website.currency_id,
|
||||
product_taxes,
|
||||
taxes,
|
||||
website,
|
||||
)
|
||||
or 0.0
|
||||
)
|
||||
return float(totals.get("total_included", base_price) or 0.0)
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"_get_delivery_product_display_price: Error computing delivery display price for product %s (id=%s): %s. Using list_price fallback.",
|
||||
|
|
|
|||
|
|
@ -62,26 +62,32 @@ def _get_salesperson_for_order(self, partner):
|
|||
return False
|
||||
|
||||
|
||||
def _find_recent_draft_order(self, partner_id, group_order, request_obj=None):
|
||||
"""Return the active-cycle draft sale.order for the partner, or empty.
|
||||
def _find_cycle_sale_order(
|
||||
self, partner_id, group_order, states=("draft",), request_obj=None
|
||||
):
|
||||
"""Return the partner's sale.order for the active cycle, or empty.
|
||||
|
||||
A draft only counts as "current cycle" when it satisfies BOTH of these
|
||||
An order only counts as "current cycle" when it satisfies BOTH of these
|
||||
(neither is sufficient on its own — see the regression each one guards):
|
||||
|
||||
1) create_date falls within the active window — derived from
|
||||
group_order.cutoff_date and the order period (7 days weekly, 14
|
||||
biweekly, one month monthly; one-time orders use a single cycle
|
||||
starting at start_date). Without this, a draft whose pickup_date
|
||||
starting at start_date). Without this, an order whose pickup_date
|
||||
happens to match the current one only because pickup_date froze
|
||||
across cycles (observed in production) would be wrongly reused.
|
||||
2) When group_order.pickup_date is set, the draft's pickup_date matches
|
||||
it exactly. Without this, a draft created "now" for a stale/previous
|
||||
across cycles (observed in production) would be wrongly matched.
|
||||
2) When group_order.pickup_date is set, the order's pickup_date matches
|
||||
it exactly. Without this, an order created "now" for a stale/previous
|
||||
pickup_date — but still inside the current create_date window —
|
||||
would be wrongly reused instead of starting a fresh cart.
|
||||
would be wrongly matched instead of starting a fresh cart.
|
||||
|
||||
Drafts failing either check belong to a previous cycle and must not be
|
||||
reused — otherwise stale carts come back when the user re-enters the
|
||||
order page.
|
||||
Orders failing either check belong to a previous cycle.
|
||||
|
||||
The upper bound of the create_date window only applies to drafts. Nothing
|
||||
stops a member from ordering between the cutoff date and the cron run
|
||||
that closes the cycle, so a *placed* order created in that gap is still
|
||||
part of this cycle — dropping the bound is what keeps the duplicate-order
|
||||
guard from letting them order twice.
|
||||
"""
|
||||
req = request_obj or request
|
||||
|
||||
|
|
@ -102,12 +108,14 @@ def _find_recent_draft_order(self, partner_id, group_order, request_obj=None):
|
|||
else: # once: single cycle, bounded by start_date when set
|
||||
period_start = group_order.start_date
|
||||
|
||||
states = tuple(states)
|
||||
domain = [
|
||||
("partner_id", "=", partner_id),
|
||||
("group_order_id", "=", group_order.id),
|
||||
("state", "=", "draft"),
|
||||
("create_date", "<=", f"{period_end} 23:59:59"),
|
||||
("state", "in", list(states)),
|
||||
]
|
||||
if states == ("draft",):
|
||||
domain.append(("create_date", "<=", f"{period_end} 23:59:59"))
|
||||
if period_start:
|
||||
domain.append(("create_date", ">=", f"{period_start} 00:00:00"))
|
||||
if group_order.pickup_date:
|
||||
|
|
@ -118,6 +126,34 @@ def _find_recent_draft_order(self, partner_id, group_order, request_obj=None):
|
|||
)
|
||||
|
||||
|
||||
def _find_recent_draft_order(self, partner_id, group_order, request_obj=None):
|
||||
"""Return the active-cycle draft sale.order for the partner, or empty.
|
||||
|
||||
Draft-only wrapper over `_find_cycle_sale_order`. Callers rely on this
|
||||
never returning a placed order — `/eskaera/clear-cart` cancels whatever
|
||||
it gets back, so widening it would cancel paid orders.
|
||||
"""
|
||||
return _find_cycle_sale_order(
|
||||
self, partner_id, group_order, states=("draft",), request_obj=request_obj
|
||||
)
|
||||
|
||||
|
||||
def _find_placed_cycle_order(self, partner_id, group_order, request_obj=None):
|
||||
"""Return the partner's already-placed order for the active cycle.
|
||||
|
||||
Used by the duplicate-order guard: once a member has paid, their order is
|
||||
confirmed, so no draft remains and nothing else would stop them from
|
||||
building — and paying for — a second order in the same cycle.
|
||||
"""
|
||||
return _find_cycle_sale_order(
|
||||
self,
|
||||
partner_id,
|
||||
group_order,
|
||||
states=("sale", "done"),
|
||||
request_obj=request_obj,
|
||||
)
|
||||
|
||||
|
||||
def _validate_confirm_request(self, data, request_obj=None):
|
||||
req = request_obj or request
|
||||
order_id = data.get("order_id")
|
||||
|
|
|
|||
|
|
@ -2362,3 +2362,169 @@ msgid ""
|
|||
msgstr ""
|
||||
"<span class=\"badge text-bg-success\"><i class=\"fa fa-truck\" aria-"
|
||||
"hidden=\"true\"/> Lliurament a domicili</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_order_lines_summary
|
||||
msgid "<span class=\"total-label\">Total</span>:"
|
||||
msgstr "<span class=\"total-label\">Total</span>:"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
|
||||
msgid "<span>Back to Checkout</span>"
|
||||
msgstr "<span>Torna al checkout</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid "<span>Back to Orders</span>"
|
||||
msgstr "<span>Torna a les comandes</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid "<span>Order</span>"
|
||||
msgstr "<span>Comanda</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
|
||||
msgid "<span>Payment in progress</span>"
|
||||
msgstr "<span>Pagament en curs</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
|
||||
msgid "<span>Reference</span>:"
|
||||
msgstr "<span>Referència</span>:"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid "<span>Thank you, your order is confirmed</span>"
|
||||
msgstr "<span>Gràcies, la teva comanda està confirmada</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid "<span>View my order</span>"
|
||||
msgstr "<span>Veure la meva comanda</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid "<span>Your payment is being processed</span>"
|
||||
msgstr "<span>El teu pagament s'està processant</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
|
||||
msgid "<strong>You already placed an order for this cycle.</strong>"
|
||||
msgstr "<strong>Ja has fet una comanda per a aquest cicle.</strong>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#. odoo-python
|
||||
#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0
|
||||
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
|
||||
msgid "Confirm and pay"
|
||||
msgstr "Confirma i paga"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#. odoo-python
|
||||
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
|
||||
msgid "Confirm the order and go to payment"
|
||||
msgstr "Confirma la comanda i ves al pagament"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__online_payment
|
||||
msgid ""
|
||||
"Let members pay their order online when they place it. Payment providers are"
|
||||
" configured globally (Settings > Payment Providers); this only decides "
|
||||
"whether this group order offers them. When enabled, paying is the only way "
|
||||
"to place an order from the checkout page."
|
||||
msgstr ""
|
||||
"Permet que els socis paguin la seva comanda en línia en fer-la. Els "
|
||||
"proveïdors de pagament es configuren globalment (Configuració > Proveïdors "
|
||||
"de pagament); això només decideix si aquesta comanda de grup els ofereix. "
|
||||
"Amb l'opció activa, pagar és l'única manera de fer la comanda des de la "
|
||||
"pàgina de checkout."
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form
|
||||
msgid ""
|
||||
"Members must pay online to place their order in this cycle. The\n"
|
||||
" available methods come from the payment providers published on the\n"
|
||||
" website (Settings > Payment Providers); this order does not\n"
|
||||
" configure any of them."
|
||||
msgstr ""
|
||||
"Els socis han de pagar en línia per fer la seva comanda en aquest cicle. Les"
|
||||
" formes de pagament disponibles surten dels proveïdors publicats al lloc web"
|
||||
" (Configuració > Proveïdors de pagament); aquesta comanda no en configura "
|
||||
"cap."
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model:ir.model.fields,field_description:website_sale_aplicoop.field_group_order__online_payment
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form
|
||||
msgid "Online Payment"
|
||||
msgstr "Pagament en línia"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#. odoo-python
|
||||
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
|
||||
msgid "Online payment is not available right now. Please contact your group."
|
||||
msgstr ""
|
||||
"El pagament en línia no està disponible ara mateix. Contacta amb el teu "
|
||||
"grup."
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid "Order Confirmed:"
|
||||
msgstr "Comanda confirmada:"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#. odoo-python
|
||||
#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0
|
||||
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
|
||||
msgid "Order ready for payment"
|
||||
msgstr "Comanda a punt per pagar"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
|
||||
msgid "Pay Order:"
|
||||
msgstr "Paga la comanda:"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
|
||||
msgid "Payment Method"
|
||||
msgstr "Forma de pagament"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model:ir.model,name:website_sale_aplicoop.model_payment_transaction
|
||||
msgid "Payment Transaction"
|
||||
msgstr "Transacció de pagament"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid "Pickup"
|
||||
msgstr "Recollida"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
|
||||
msgid "View my order"
|
||||
msgstr "Veure la meva comanda"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
|
||||
msgid ""
|
||||
"We are still waiting for your payment to be confirmed. Please do not pay "
|
||||
"again."
|
||||
msgstr ""
|
||||
"Encara estem esperant la confirmació del teu pagament. No tornis a pagar."
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#. odoo-python
|
||||
#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0
|
||||
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
|
||||
msgid "You already placed an order for this cycle."
|
||||
msgstr "Ja has fet una comanda per a aquest cicle."
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid ""
|
||||
"Your order will be confirmed as soon as we receive the payment. You can "
|
||||
"follow it from your orders page."
|
||||
msgstr ""
|
||||
"La teva comanda es confirmarà tan bon punt rebem el pagament. La pots seguir"
|
||||
" des de la pàgina de comandes."
|
||||
|
|
|
|||
|
|
@ -2358,3 +2358,165 @@ msgid ""
|
|||
msgstr ""
|
||||
"<span class=\"badge text-bg-success\"><i class=\"fa fa-truck\" aria-"
|
||||
"hidden=\"true\"/> Entrega a Casa</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_order_lines_summary
|
||||
msgid "<span class=\"total-label\">Total</span>:"
|
||||
msgstr "<span class=\"total-label\">Total</span>:"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
|
||||
msgid "<span>Back to Checkout</span>"
|
||||
msgstr "<span>Volver al checkout</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid "<span>Back to Orders</span>"
|
||||
msgstr "<span>Volver a los pedidos</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid "<span>Order</span>"
|
||||
msgstr "<span>Pedido</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
|
||||
msgid "<span>Payment in progress</span>"
|
||||
msgstr "<span>Pago en curso</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
|
||||
msgid "<span>Reference</span>:"
|
||||
msgstr "<span>Referencia</span>:"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid "<span>Thank you, your order is confirmed</span>"
|
||||
msgstr "<span>Gracias, tu pedido está confirmado</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid "<span>View my order</span>"
|
||||
msgstr "<span>Ver mi pedido</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid "<span>Your payment is being processed</span>"
|
||||
msgstr "<span>Tu pago se está procesando</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
|
||||
msgid "<strong>You already placed an order for this cycle.</strong>"
|
||||
msgstr "<strong>Ya has hecho un pedido para este ciclo.</strong>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#. odoo-python
|
||||
#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0
|
||||
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
|
||||
msgid "Confirm and pay"
|
||||
msgstr "Confirmar y pagar"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#. odoo-python
|
||||
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
|
||||
msgid "Confirm the order and go to payment"
|
||||
msgstr "Confirmar el pedido e ir al pago"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__online_payment
|
||||
msgid ""
|
||||
"Let members pay their order online when they place it. Payment providers are"
|
||||
" configured globally (Settings > Payment Providers); this only decides "
|
||||
"whether this group order offers them. When enabled, paying is the only way "
|
||||
"to place an order from the checkout page."
|
||||
msgstr ""
|
||||
"Permite que los socios paguen su pedido online al hacerlo. Los proveedores "
|
||||
"de pago se configuran de forma global (Ajustes > Proveedores de pago); esto "
|
||||
"solo decide si este pedido de grupo los ofrece. Con la opción activa, pagar "
|
||||
"es la única forma de hacer el pedido desde la página de checkout."
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form
|
||||
msgid ""
|
||||
"Members must pay online to place their order in this cycle. The\n"
|
||||
" available methods come from the payment providers published on the\n"
|
||||
" website (Settings > Payment Providers); this order does not\n"
|
||||
" configure any of them."
|
||||
msgstr ""
|
||||
"Los socios deben pagar online para hacer su pedido en este ciclo. Las formas"
|
||||
" de pago disponibles salen de los proveedores publicados en el sitio web "
|
||||
"(Ajustes > Proveedores de pago); este pedido no configura ninguno."
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model:ir.model.fields,field_description:website_sale_aplicoop.field_group_order__online_payment
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form
|
||||
msgid "Online Payment"
|
||||
msgstr "Pago online"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#. odoo-python
|
||||
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
|
||||
msgid "Online payment is not available right now. Please contact your group."
|
||||
msgstr "El pago online no está disponible ahora mismo. Contacta con tu grupo."
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid "Order Confirmed:"
|
||||
msgstr "Pedido confirmado:"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#. odoo-python
|
||||
#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0
|
||||
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
|
||||
msgid "Order ready for payment"
|
||||
msgstr "Pedido listo para pagar"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
|
||||
msgid "Pay Order:"
|
||||
msgstr "Pagar pedido:"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
|
||||
msgid "Payment Method"
|
||||
msgstr "Forma de pago"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model:ir.model,name:website_sale_aplicoop.model_payment_transaction
|
||||
msgid "Payment Transaction"
|
||||
msgstr "Transacción de pago"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid "Pickup"
|
||||
msgstr "Recogida"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
|
||||
msgid "View my order"
|
||||
msgstr "Ver mi pedido"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
|
||||
msgid ""
|
||||
"We are still waiting for your payment to be confirmed. Please do not pay "
|
||||
"again."
|
||||
msgstr ""
|
||||
"Todavía estamos esperando la confirmación de tu pago. No vuelvas a pagar."
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#. odoo-python
|
||||
#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0
|
||||
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
|
||||
msgid "You already placed an order for this cycle."
|
||||
msgstr "Ya has hecho un pedido para este ciclo."
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid ""
|
||||
"Your order will be confirmed as soon as we receive the payment. You can "
|
||||
"follow it from your orders page."
|
||||
msgstr ""
|
||||
"Tu pedido se confirmará en cuanto recibamos el pago. Puedes seguirlo desde "
|
||||
"tu página de pedidos."
|
||||
|
|
|
|||
|
|
@ -2358,3 +2358,168 @@ msgid ""
|
|||
msgstr ""
|
||||
"<span class=\"badge text-bg-success\"><i class=\"fa fa-truck\" aria-"
|
||||
"hidden=\"true\"/> Etxerako Entrega</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_order_lines_summary
|
||||
msgid "<span class=\"total-label\">Total</span>:"
|
||||
msgstr "<span class=\"total-label\">Guztira</span>:"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
|
||||
msgid "<span>Back to Checkout</span>"
|
||||
msgstr "<span>Itzuli checkout-era</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid "<span>Back to Orders</span>"
|
||||
msgstr "<span>Itzuli eskaeretara</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid "<span>Order</span>"
|
||||
msgstr "<span>Eskaera</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
|
||||
msgid "<span>Payment in progress</span>"
|
||||
msgstr "<span>Ordainketa abian</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
|
||||
msgid "<span>Reference</span>:"
|
||||
msgstr "<span>Erreferentzia</span>:"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid "<span>Thank you, your order is confirmed</span>"
|
||||
msgstr "<span>Eskerrik asko, zure eskaera berretsita dago</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid "<span>View my order</span>"
|
||||
msgstr "<span>Ikusi nire eskaera</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid "<span>Your payment is being processed</span>"
|
||||
msgstr "<span>Zure ordainketa prozesatzen ari da</span>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
|
||||
msgid "<strong>You already placed an order for this cycle.</strong>"
|
||||
msgstr "<strong>Dagoeneko eskaera bat egin duzu ziklo honetarako.</strong>"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#. odoo-python
|
||||
#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0
|
||||
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
|
||||
msgid "Confirm and pay"
|
||||
msgstr "Berretsi eta ordaindu"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#. odoo-python
|
||||
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
|
||||
msgid "Confirm the order and go to payment"
|
||||
msgstr "Eskaera berretsi eta ordainketara joan"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model:ir.model.fields,help:website_sale_aplicoop.field_group_order__online_payment
|
||||
msgid ""
|
||||
"Let members pay their order online when they place it. Payment providers are"
|
||||
" configured globally (Settings > Payment Providers); this only decides "
|
||||
"whether this group order offers them. When enabled, paying is the only way "
|
||||
"to place an order from the checkout page."
|
||||
msgstr ""
|
||||
"Utzi bazkideei beren eskaera linean ordaintzen egiten dutenean. Ordainketa "
|
||||
"hornitzaileak orokorrean konfiguratzen dira (Ezarpenak > Ordainketa "
|
||||
"hornitzaileak); honek talde eskaera honek eskaintzen dituen ala ez "
|
||||
"erabakitzen du soilik. Aktibatuta dagoenean, ordaintzea da checkout orritik "
|
||||
"eskaera egiteko modu bakarra."
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form
|
||||
msgid ""
|
||||
"Members must pay online to place their order in this cycle. The\n"
|
||||
" available methods come from the payment providers published on the\n"
|
||||
" website (Settings > Payment Providers); this order does not\n"
|
||||
" configure any of them."
|
||||
msgstr ""
|
||||
"Bazkideek linean ordaindu behar dute ziklo honetan eskaera egiteko. "
|
||||
"Erabilgarri dauden moduak webgunean argitaratutako ordainketa "
|
||||
"hornitzaileetatik datoz (Ezarpenak > Ordainketa hornitzaileak); eskaera "
|
||||
"honek ez du bat ere konfiguratzen."
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model:ir.model.fields,field_description:website_sale_aplicoop.field_group_order__online_payment
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.view_group_order_form
|
||||
msgid "Online Payment"
|
||||
msgstr "Lineako ordainketa"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#. odoo-python
|
||||
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
|
||||
msgid "Online payment is not available right now. Please contact your group."
|
||||
msgstr ""
|
||||
"Lineako ordainketa ez dago erabilgarri orain. Jarri harremanetan zure "
|
||||
"taldearekin."
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid "Order Confirmed:"
|
||||
msgstr "Eskaera berretsita:"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#. odoo-python
|
||||
#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0
|
||||
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
|
||||
msgid "Order ready for payment"
|
||||
msgstr "Eskaera ordaintzeko prest"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
|
||||
msgid "Pay Order:"
|
||||
msgstr "Ordaindu eskaera:"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
|
||||
msgid "Payment Method"
|
||||
msgstr "Ordainketa modua"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model:ir.model,name:website_sale_aplicoop.model_payment_transaction
|
||||
msgid "Payment Transaction"
|
||||
msgstr "Ordainketa transakzioa"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid "Pickup"
|
||||
msgstr "Jasotzea"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_shop
|
||||
msgid "View my order"
|
||||
msgstr "Ikusi nire eskaera"
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment
|
||||
msgid ""
|
||||
"We are still waiting for your payment to be confirmed. Please do not pay "
|
||||
"again."
|
||||
msgstr "Zure ordainketa berresteko zain gaude oraindik. Ez ordaindu berriro."
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#. odoo-python
|
||||
#: code:addons/website_sale_aplicoop/controllers/website_sale.py:0
|
||||
#: code:addons/website_sale_aplicoop/models/js_translations.py:0
|
||||
msgid "You already placed an order for this cycle."
|
||||
msgstr "Dagoeneko eskaera bat egin duzu ziklo honetarako."
|
||||
|
||||
#. module: website_sale_aplicoop
|
||||
#: model_terms:ir.ui.view,arch_db:website_sale_aplicoop.eskaera_payment_confirmation
|
||||
msgid ""
|
||||
"Your order will be confirmed as soon as we receive the payment. You can "
|
||||
"follow it from your orders page."
|
||||
msgstr ""
|
||||
"Zure eskaera ordainketa jaso bezain laster berretsiko da. Zure eskaeren "
|
||||
"orritik jarrai dezakezu."
|
||||
|
|
|
|||
39
website_sale_aplicoop/migrations/18.0.1.15.0/post-migrate.py
Normal file
39
website_sale_aplicoop/migrations/18.0.1.15.0/post-migrate.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""Drop the Eskaera-specific pricelist setting.
|
||||
|
||||
Eskaera used to resolve its own pricelist from
|
||||
`website_sale_aplicoop.pricelist_id`, which let it quote prices the rest of the
|
||||
shop did not use. Pricing is now the standard website_sale one, so the setting
|
||||
is gone and its parameter would otherwise linger in the database, looking like
|
||||
live configuration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import SUPERUSER_ID
|
||||
from odoo import api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
OBSOLETE_PARAM = "website_sale_aplicoop.pricelist_id"
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
if not version:
|
||||
return
|
||||
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
param = (
|
||||
env["ir.config_parameter"]
|
||||
.sudo()
|
||||
.search([("key", "=", OBSOLETE_PARAM)], limit=1)
|
||||
)
|
||||
if not param:
|
||||
return
|
||||
|
||||
_logger.info(
|
||||
"Removing obsolete config parameter %s (was pointing at pricelist %s). "
|
||||
"Eskaera now prices with the website pricelist.",
|
||||
OBSOLETE_PARAM,
|
||||
param.value,
|
||||
)
|
||||
param.unlink()
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
from . import group_order # noqa: F401
|
||||
from . import group_order_slot # noqa: F401
|
||||
from . import payment_transaction # noqa: F401
|
||||
from . import product_category_extension # noqa: F401
|
||||
from . import product_extension # noqa: F401
|
||||
from . import res_config_settings # noqa: F401
|
||||
from . import res_partner_extension # noqa: F401
|
||||
from . import sale_order_extension # noqa: F401
|
||||
from . import stock_picking_extension # noqa: F401
|
||||
from . import website # noqa: F401
|
||||
from . import js_translations # noqa: F401
|
||||
|
|
|
|||
|
|
@ -188,6 +188,16 @@ class GroupOrder(models.Model):
|
|||
help="Calculated delivery date (pickup date + 1 day)",
|
||||
)
|
||||
|
||||
# === Online payment ===
|
||||
online_payment = fields.Boolean(
|
||||
tracking=True,
|
||||
help="Let members pay their order online when they place it. Payment "
|
||||
"providers are configured globally (Settings > Payment Providers); "
|
||||
"this only decides whether this group order offers them. When "
|
||||
"enabled, paying is the only way to place an order from the "
|
||||
"checkout page.",
|
||||
)
|
||||
|
||||
# === Computed date fields ===
|
||||
pickup_date = fields.Date(
|
||||
compute="_compute_pickup_date",
|
||||
|
|
@ -274,23 +284,6 @@ class GroupOrder(models.Model):
|
|||
help="Image displayed alongside the consumer group order name",
|
||||
attachment=True,
|
||||
)
|
||||
display_image = fields.Binary(
|
||||
compute="_compute_display_image",
|
||||
store=True,
|
||||
help="Image to display: uses consumer group order image if set, otherwise group image",
|
||||
attachment=True,
|
||||
)
|
||||
|
||||
@api.depends("image", "group_ids")
|
||||
def _compute_display_image(self):
|
||||
"""Use order image if set, otherwise use first group image."""
|
||||
for record in self:
|
||||
if record.image:
|
||||
record.display_image = record.image
|
||||
elif record.group_ids and record.group_ids[0].image_1920:
|
||||
record.display_image = record.group_ids[0].image_1920
|
||||
else:
|
||||
record.display_image = False
|
||||
|
||||
@api.depends("delivery_product_id")
|
||||
def _compute_home_delivery(self):
|
||||
|
|
@ -691,31 +684,6 @@ class GroupOrder(models.Model):
|
|||
)
|
||||
return demand
|
||||
|
||||
def _get_products_paginated(self, order_id, page=1, per_page=20):
|
||||
"""Get paginated products for a group order.
|
||||
|
||||
Args:
|
||||
order_id: ID of the group order
|
||||
page: Page number (1-indexed)
|
||||
per_page: Number of products per page
|
||||
|
||||
Returns:
|
||||
tuple: (products_page, total_count, has_next)
|
||||
- products_page: recordset of product.product for this page
|
||||
- total_count: total number of products in order
|
||||
- has_next: boolean indicating if there are more pages
|
||||
"""
|
||||
all_products = self._get_products_for_group_order(order_id)
|
||||
total_count = len(all_products)
|
||||
|
||||
# Calculate pagination
|
||||
offset = (page - 1) * per_page
|
||||
products_page = all_products[offset : offset + per_page]
|
||||
|
||||
has_next = offset + per_page < total_count
|
||||
|
||||
return products_page, total_count, has_next
|
||||
|
||||
# === Pickup slots helpers ===
|
||||
pickup_slot_ids = fields.One2many(
|
||||
"group.order.slot",
|
||||
|
|
@ -725,12 +693,6 @@ class GroupOrder(models.Model):
|
|||
tracking=True,
|
||||
)
|
||||
|
||||
pickup_slots_count = fields.Integer(
|
||||
compute="_compute_pickup_slots_count",
|
||||
store=False,
|
||||
help="Number of pickup slots configured for this order",
|
||||
)
|
||||
|
||||
next_pickup_slot_id = fields.Many2one(
|
||||
"group.order.slot",
|
||||
string="Next Pickup Slot",
|
||||
|
|
@ -746,12 +708,6 @@ class GroupOrder(models.Model):
|
|||
help="Datetime of the next pickup occurrence for the selected slot",
|
||||
)
|
||||
|
||||
@api.depends("pickup_slot_ids")
|
||||
def _compute_pickup_slots_count(self):
|
||||
"""Simple count of configured slots for quick UI badges."""
|
||||
for record in self:
|
||||
record.pickup_slots_count = len(record.pickup_slot_ids or [])
|
||||
|
||||
@api.depends(
|
||||
"pickup_slot_ids",
|
||||
"pickup_slot_ids.start_hour",
|
||||
|
|
@ -1106,6 +1062,69 @@ class GroupOrder(models.Model):
|
|||
failed_orders,
|
||||
)
|
||||
|
||||
self._cron_batch_paid_orders_of_closed_cycles()
|
||||
|
||||
@api.model
|
||||
def _cron_batch_paid_orders_of_closed_cycles(self):
|
||||
"""Batch paid orders of group orders that were closed by hand.
|
||||
|
||||
The loop above only walks draft/open group orders. Closing an order
|
||||
manually after a member has paid would otherwise leave that member's
|
||||
picking out of every batch, because the confirmation already happened
|
||||
at payment time and the cron never looks at closed cycles.
|
||||
"""
|
||||
closed_orders = self.search(
|
||||
[("state", "=", "closed"), ("online_payment", "=", True)]
|
||||
)
|
||||
for order in closed_orders:
|
||||
try:
|
||||
order._batch_paid_sale_orders()
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
"Cron: Error batching paid sale orders of closed group order "
|
||||
"%s (%s)",
|
||||
order.id,
|
||||
order.name,
|
||||
)
|
||||
|
||||
def _batch_paid_sale_orders(self):
|
||||
"""Create the picking batches of orders already confirmed by payment.
|
||||
|
||||
The same sweep `_confirm_linked_sale_orders` does, minus the
|
||||
confirmation step. Drafts are deliberately left alone: closing a group
|
||||
order by hand is how a co-op calls a cycle off, and this must not
|
||||
resurrect the orders it meant to drop.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
batches = self.env["stock.picking.batch"]
|
||||
if not self.pickup_date:
|
||||
return batches
|
||||
|
||||
paid_sale_orders = (
|
||||
self.env["sale.order"]
|
||||
.sudo()
|
||||
.search(
|
||||
[
|
||||
("group_order_id", "=", self.id),
|
||||
("state", "in", ["sale", "done"]),
|
||||
("pickup_date", "=", self.pickup_date),
|
||||
]
|
||||
)
|
||||
)
|
||||
if not paid_sale_orders:
|
||||
return batches
|
||||
|
||||
batches = self._create_picking_batches_for_sale_orders(paid_sale_orders)
|
||||
if batches:
|
||||
_logger.info(
|
||||
"Cron: Batched %d paid sale order(s) of closed group order %s (%s)",
|
||||
len(paid_sale_orders),
|
||||
self.id,
|
||||
self.name,
|
||||
)
|
||||
return batches
|
||||
|
||||
def _close_one_time_order_if_ended(self):
|
||||
"""Close one-time orders once their end_date has passed.
|
||||
|
||||
|
|
@ -1179,7 +1198,25 @@ class GroupOrder(models.Model):
|
|||
]
|
||||
)
|
||||
|
||||
if not sale_orders:
|
||||
# Orders paid online are confirmed the moment their transaction is
|
||||
# done, long before this runs, so they are not in the search above —
|
||||
# but their pickings still have to end up in this cycle's batch.
|
||||
#
|
||||
# pickup_date is what scopes them to this cycle: the same group.order
|
||||
# record is reused every cycle, and _cron_update_dates() calls this
|
||||
# method BEFORE recomputing the dates, so self.pickup_date is still
|
||||
# the closing cycle's, exactly the value stamped on the order when it
|
||||
# was saved. Backorders from previous cycles hang off orders with an
|
||||
# older pickup_date, so they are not swept in either.
|
||||
already_confirmed = SaleOrder.search(
|
||||
[
|
||||
("group_order_id", "=", self.id),
|
||||
("state", "in", ["sale", "done"]),
|
||||
("pickup_date", "=", self.pickup_date),
|
||||
]
|
||||
)
|
||||
|
||||
if not sale_orders and not already_confirmed:
|
||||
_logger.info(
|
||||
"Cron: No sale orders to confirm for group order %s (%s)",
|
||||
self.id,
|
||||
|
|
@ -1188,10 +1225,12 @@ class GroupOrder(models.Model):
|
|||
return
|
||||
|
||||
_logger.info(
|
||||
"Cron: Confirming %d sale orders for group order %s (%s)",
|
||||
"Cron: Confirming %d sale orders for group order %s (%s); "
|
||||
"%d already confirmed by online payment",
|
||||
len(sale_orders),
|
||||
self.id,
|
||||
self.name,
|
||||
len(already_confirmed),
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
@ -1264,13 +1303,33 @@ class GroupOrder(models.Model):
|
|||
)
|
||||
|
||||
batches = self.env["stock.picking.batch"]
|
||||
if confirmed_sale_orders:
|
||||
# Create picking batches only for confirmed sale orders
|
||||
# One call with both sets: _create_picking_batches_for_sale_orders
|
||||
# groups by picking type and skips pickings that already have a
|
||||
# batch, so this yields one batch per type for the whole cycle.
|
||||
batchable_sale_orders = confirmed_sale_orders | already_confirmed
|
||||
if batchable_sale_orders:
|
||||
batches = self._create_picking_batches_for_sale_orders(
|
||||
confirmed_sale_orders
|
||||
batchable_sale_orders
|
||||
)
|
||||
# Only the orders confirmed right now: re-reporting the ones
|
||||
# confirmed in an earlier run would repeat the same warnings
|
||||
# on every cron pass.
|
||||
self._log_missing_procurement_warnings(confirmed_sale_orders)
|
||||
|
||||
if already_confirmed and not batches:
|
||||
# Paid orders that never made it into a batch are an
|
||||
# operational hole, and everything here runs inside a
|
||||
# try/except that only logs. Say so loudly.
|
||||
_logger.warning(
|
||||
"Cron: %d already confirmed sale order(s) of group order %s (%s) "
|
||||
"produced no picking batch. Their pickings may already be "
|
||||
"batched, done or cancelled — check manually. ids=%s",
|
||||
len(already_confirmed),
|
||||
self.id,
|
||||
self.name,
|
||||
already_confirmed.ids,
|
||||
)
|
||||
|
||||
if failed_sale_orders:
|
||||
_logger.warning(
|
||||
"Cron: %d/%d sale orders failed during confirmation for group order %s (%s). "
|
||||
|
|
|
|||
|
|
@ -55,20 +55,3 @@ class GroupOrderSlot(models.Model):
|
|||
sequence = fields.Integer(string="Sequence", default=10)
|
||||
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
def _get_display_label(self):
|
||||
"""Return a fallback display label combining weekday and hours.
|
||||
|
||||
This is a small helper used by views or when a specific `label` is
|
||||
not provided.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.label:
|
||||
return self.label
|
||||
# Fallback: simple numeric representation
|
||||
sh = "%02d:%02d" % (
|
||||
int(self.start_hour or 0),
|
||||
int((self.start_hour or 0) % 1 * 60),
|
||||
)
|
||||
eh = "%02d:%02d" % (int(self.end_hour or 0), int((self.end_hour or 0) % 1 * 60))
|
||||
return f"{self.weekday} {sh}-{eh}"
|
||||
|
|
|
|||
|
|
@ -40,6 +40,15 @@ def _register_translations():
|
|||
_("Load Draft")
|
||||
_("Browse Product Categories")
|
||||
|
||||
# ========================
|
||||
# Online Payment Labels
|
||||
# ========================
|
||||
_("Confirm and pay")
|
||||
_("Confirm the order and go to payment")
|
||||
_("Order ready for payment")
|
||||
_("You already placed an order for this cycle.")
|
||||
_("Online payment is not available right now. Please contact your group.")
|
||||
|
||||
# ========================
|
||||
# Draft Modal Labels
|
||||
# ========================
|
||||
|
|
|
|||
50
website_sale_aplicoop/models/payment_transaction.py
Normal file
50
website_sale_aplicoop/models/payment_transaction.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentTransaction(models.Model):
|
||||
_inherit = "payment.transaction"
|
||||
|
||||
def _check_amount_and_confirm_order(self):
|
||||
"""Confirm group order sales the way the cutoff cron already does.
|
||||
|
||||
``group.order._confirm_linked_sale_orders`` confirms with
|
||||
``from_orderpoint=True`` on purpose: ``stock.move._action_confirm``
|
||||
forwards ``raise_user_error=not from_orderpoint`` to
|
||||
``procurement.group.run``, so a product with a broken replenishment
|
||||
route does not block the sale, and the missing moves are reported
|
||||
operationally instead.
|
||||
|
||||
The standard payment post-processing confirms without that context,
|
||||
and ``/payment/status/poll`` rolls back and re-raises anything
|
||||
``_post_process`` throws. Without this override, one misconfigured
|
||||
product turns a successful payment into an error page while the
|
||||
transaction is already ``done``, and the retry cron keeps failing on
|
||||
it. Orders born from a group order follow the cron's operational
|
||||
rules, so they get the cron's context.
|
||||
"""
|
||||
eskaera_txs = self.filtered(lambda tx: tx.sale_order_ids.group_order_id)
|
||||
if not eskaera_txs:
|
||||
return super()._check_amount_and_confirm_order()
|
||||
|
||||
confirmed_orders = super(
|
||||
PaymentTransaction, self - eskaera_txs
|
||||
)._check_amount_and_confirm_order()
|
||||
|
||||
_logger.info(
|
||||
"[PAYMENT] Confirming %d group order transaction(s) with "
|
||||
"from_orderpoint=True: %s",
|
||||
len(eskaera_txs),
|
||||
eskaera_txs.ids,
|
||||
)
|
||||
confirmed_orders |= super(
|
||||
PaymentTransaction, eskaera_txs.with_context(from_orderpoint=True)
|
||||
)._check_amount_and_confirm_order()
|
||||
|
||||
return confirmed_orders
|
||||
|
|
@ -7,10 +7,11 @@ from odoo import models
|
|||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = "res.config.settings"
|
||||
|
||||
aplicoop_pricelist_id = fields.Many2one(
|
||||
"product.pricelist",
|
||||
config_parameter="website_sale_aplicoop.pricelist_id",
|
||||
help="Pricelist to use for Aplicoop group orders. If not set, will use website default.",
|
||||
# Per website: a co-op can run the plain shop on one and Eskaera on
|
||||
# another. The settings below are still global to the database.
|
||||
eskaera_enabled = fields.Boolean(
|
||||
related="website_id.eskaera_enabled",
|
||||
readonly=False,
|
||||
)
|
||||
|
||||
eskaera_lazy_loading_enabled = fields.Boolean(
|
||||
|
|
@ -34,15 +35,3 @@ class ResConfigSettings(models.TransientModel):
|
|||
help="Products with stock below or equal to this value will show 'Low Stock' ribbon. "
|
||||
"Products with stock = 0 will show 'Out of Stock' ribbon and cannot be added to cart.",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_products_per_page_selection(records):
|
||||
"""Return default page sizes."""
|
||||
return [
|
||||
(5, "5"),
|
||||
(10, "10"),
|
||||
(15, "15"),
|
||||
(20, "20"),
|
||||
(30, "30"),
|
||||
(50, "50"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -62,6 +62,33 @@ class SaleOrder(models.Model):
|
|||
help="Whether this order includes home delivery",
|
||||
)
|
||||
|
||||
@api.depends("company_id", "group_order_id", "group_order_id.online_payment")
|
||||
def _compute_require_payment(self): # pylint: disable=missing-return
|
||||
"""Let the group order decide whether its members pay online.
|
||||
|
||||
Orders outside a group order keep the company default.
|
||||
|
||||
No return: compute methods assign fields, and pylint-odoo's
|
||||
`missing-return` does not know that about a `super()` call.
|
||||
"""
|
||||
super()._compute_require_payment()
|
||||
for order in self:
|
||||
if order.group_order_id:
|
||||
order.require_payment = order.group_order_id.online_payment
|
||||
|
||||
@api.depends("require_payment", "group_order_id")
|
||||
def _compute_prepayment_percent(self): # pylint: disable=missing-return
|
||||
"""Group orders are paid in full, never with a down payment.
|
||||
|
||||
Written together with `require_payment` on purpose: the core compute
|
||||
would otherwise pull `company_id.prepayment_percent`, which
|
||||
`_check_prepayment_percent` rejects unless it is in (0, 1].
|
||||
"""
|
||||
super()._compute_prepayment_percent()
|
||||
for order in self:
|
||||
if order.group_order_id and order.require_payment:
|
||||
order.prepayment_percent = 1.0
|
||||
|
||||
@api.depends(
|
||||
"group_order_id",
|
||||
"group_order_id.next_pickup_slot_id",
|
||||
|
|
|
|||
40
website_sale_aplicoop/models/website.py
Normal file
40
website_sale_aplicoop/models/website.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Copyright 2025-Today Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
from odoo import fields
|
||||
from odoo import models
|
||||
|
||||
ESKAERA_URL_PREFIX = "/eskaera"
|
||||
|
||||
|
||||
class Website(models.Model):
|
||||
_inherit = "website"
|
||||
|
||||
eskaera_enabled = fields.Boolean(
|
||||
string="Eskaera Group Orders",
|
||||
default=True,
|
||||
help="Serve the Eskaera collaborative purchasing pages on this website. "
|
||||
"Turn it off on a website that only runs the regular shop: its "
|
||||
"/eskaera pages answer 404 and its Eskaera menu is hidden.",
|
||||
)
|
||||
|
||||
|
||||
class WebsiteMenu(models.Model):
|
||||
_inherit = "website.menu"
|
||||
|
||||
def _compute_visible(self):
|
||||
"""Hide the Eskaera menu on websites that do not serve it.
|
||||
|
||||
Hiding rather than deleting the menu keeps the entry (and any manual
|
||||
rename or reordering) around, so switching the feature back on
|
||||
restores it as it was.
|
||||
"""
|
||||
res = super()._compute_visible()
|
||||
for menu in self:
|
||||
if not menu.is_visible or not menu.website_id:
|
||||
continue
|
||||
if menu.website_id.eskaera_enabled:
|
||||
continue
|
||||
if (menu.url or "").startswith(ESKAERA_URL_PREFIX):
|
||||
menu.is_visible = False
|
||||
return res
|
||||
|
|
@ -58,3 +58,24 @@ Odoo then serves the pages on the new prefix, rewrites the links in the
|
|||
templates and redirects the old URLs. The AJAX endpoints
|
||||
(``/eskaera/labels``, ``/eskaera/save-order``…) are never shown in the
|
||||
address bar and do not need a rule.
|
||||
|
||||
**Online payment (v18.0.1.14.0+):**
|
||||
|
||||
Payment is enabled per group order, and off by default: an order without it
|
||||
behaves exactly as before, with members saving a draft that the cutoff cron
|
||||
confirms in bulk.
|
||||
|
||||
#. Configure the providers first, where Odoo always keeps them:
|
||||
Settings → Payment Providers. Enable and publish at least one (bank
|
||||
transfer, Redsys, Stripe...). This module ships no provider of its own and
|
||||
configures none; it only shows whichever ones are compatible.
|
||||
#. On a multi-website database, leave a provider's *Website* field empty to
|
||||
offer it everywhere, or set it to restrict the provider to one site.
|
||||
#. Open the group order form → *Online Payment* tab → tick **Online payment**.
|
||||
|
||||
With the flag on, the checkout button becomes "Confirm and pay" and paying is
|
||||
the only way to place an order: the member goes through
|
||||
``/eskaera/<slug>/payment``, picks a method, and their sale order is confirmed
|
||||
as soon as the transaction completes. Members who have not paid by the cutoff
|
||||
date still get their draft confirmed by the cron, exactly as they do today —
|
||||
the flag decides how orders are placed, not who gets served.
|
||||
|
|
|
|||
|
|
@ -10,5 +10,6 @@ This module replaces the legacy Aplicoop application with a modern, scalable sol
|
|||
* **Multi-language Support**: Full internationalization with translations for 7 languages (ES, EU, CA, GL, PT, FR, IT)
|
||||
* **Email Notifications**: Automatic notifications on order state changes
|
||||
* **Financial Tracking**: Track orders and payments per group member
|
||||
* **Online Payment**: Optional per group order — members pay at checkout through the standard Odoo payment providers, and their order is confirmed as soon as the transaction completes (v18.0.1.14.0+)
|
||||
* **Product Integration**: Compatible with product ribbons, pricing, and margin modules
|
||||
* **OCA Compliant**: AGPL-3.0 licensed, follows OCA standards for documentation, testing, and code structure
|
||||
|
|
|
|||
|
|
@ -43,3 +43,21 @@ Order States
|
|||
* **Confirmed**: Order open for shopping
|
||||
* **Collected**: Orders received from supplier
|
||||
* **Completed**: All members have picked up their orders
|
||||
|
||||
Paying an order online
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
When the group order has online payment enabled:
|
||||
|
||||
#. Build the cart in ``/eskaera/<slug>`` as usual
|
||||
#. Go to the checkout, review the summary and choose home delivery if offered
|
||||
#. Press **Confirm and pay**: the order is saved and the payment step opens
|
||||
#. Pick a payment method and pay; you are sent to the provider and back
|
||||
#. The confirmation page shows the outcome and empties the local cart
|
||||
|
||||
The order is confirmed the moment the payment goes through, so it can no
|
||||
longer be edited. Coming back to the shop shows a notice with a link to the
|
||||
order instead of an empty cart, so nobody pays twice for the same cycle.
|
||||
|
||||
If the payment is still being processed when you come back, the page says so
|
||||
and the order is confirmed as soon as the provider settles it.
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
/** AGPL-3.0
|
||||
* NOTE: Checkout summary rendering is now handled by checkout_labels.js
|
||||
* This file is kept for backwards compatibility but is no longer needed.
|
||||
* The main renderSummary() logic is in checkout_labels.js
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
// Checkout rendering is handled by checkout_labels.js
|
||||
})();
|
||||
35
website_sale_aplicoop/static/src/js/eskaera_payment.js
Normal file
35
website_sale_aplicoop/static/src/js/eskaera_payment.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright 2026 Criptomart
|
||||
* License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
*
|
||||
* Frees the localStorage cart once the order has been placed and paid.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var page = document.querySelector("[data-clear-cart-order-id]");
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
|
||||
var orderId = page.getAttribute("data-clear-cart-order-id");
|
||||
if (!orderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Client side only. /eskaera/clear-cart would also cancel the sale
|
||||
// order, which is exactly the wrong thing to do to an order that was
|
||||
// just paid for.
|
||||
try {
|
||||
localStorage.removeItem("eskaera_" + orderId + "_cart");
|
||||
localStorage.removeItem("eskaera_" + orderId + "_cart_cycle");
|
||||
} catch (e) {
|
||||
// localStorage unavailable (private mode, quota). The cart is
|
||||
// server-side irrelevant at this point; the duplicate-order guard
|
||||
// is what actually protects the member.
|
||||
console.warn("[ESKAERA PAYMENT] Could not clear the local cart:", e);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
|
@ -226,10 +226,6 @@
|
|||
}, 50);
|
||||
},
|
||||
|
||||
renderCheckoutSummary: function () {
|
||||
// Stub - now handled by global window.renderCheckoutSummary
|
||||
},
|
||||
|
||||
addDeliveryProduct: function () {
|
||||
if (!this.deliveryProductId) {
|
||||
console.warn("[HomeDelivery] Delivery product ID not found");
|
||||
|
|
|
|||
|
|
@ -9,9 +9,6 @@
|
|||
* OLD: window.getCheckoutLabels()
|
||||
* NEW: i18nManager.getAll()
|
||||
*
|
||||
* OLD: window.formatCurrency(amount)
|
||||
* NEW: i18nManager.formatCurrency(amount)
|
||||
*
|
||||
* Copyright 2025 Criptomart
|
||||
* License AGPL-3.0 or later
|
||||
*/
|
||||
|
|
@ -35,32 +32,5 @@
|
|||
return key ? key : {};
|
||||
};
|
||||
|
||||
/**
|
||||
* DEPRECATED - Use i18nManager.getAll() instead
|
||||
*/
|
||||
window.getSearchLabels = function () {
|
||||
if (window.i18nManager && window.i18nManager.initialized) {
|
||||
return {
|
||||
searchPlaceholder: window.i18nManager.get("search_products"),
|
||||
noResults: window.i18nManager.get("no_results"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
searchPlaceholder: "Search products...",
|
||||
noResults: "No products found",
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* DEPRECATED - Use i18nManager.formatCurrency(amount) instead
|
||||
*/
|
||||
window.formatCurrency = function (amount) {
|
||||
if (window.i18nManager) {
|
||||
return window.i18nManager.formatCurrency(amount);
|
||||
}
|
||||
// Fallback
|
||||
return "€" + parseFloat(amount).toFixed(2);
|
||||
};
|
||||
|
||||
console.log("[i18n_helpers] DEPRECATED - Use i18n_manager.js instead");
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -103,39 +103,6 @@
|
|||
}
|
||||
return this.labels;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a specific label exists
|
||||
*/
|
||||
has: function (key) {
|
||||
if (!this.initialized) return false;
|
||||
return key in this.labels;
|
||||
},
|
||||
|
||||
/**
|
||||
* Format currency to Euro format
|
||||
*/
|
||||
formatCurrency: function (amount) {
|
||||
try {
|
||||
return new Intl.NumberFormat(document.documentElement.lang || "es_ES", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(amount);
|
||||
} catch (e) {
|
||||
// Fallback to simple Euro format
|
||||
return "€" + parseFloat(amount).toFixed(2);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
escapeHtml: function (text) {
|
||||
if (!text) return "";
|
||||
var div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
},
|
||||
};
|
||||
|
||||
// Auto-initialize on DOM ready
|
||||
|
|
|
|||
|
|
@ -592,6 +592,38 @@
|
|||
}
|
||||
},
|
||||
|
||||
// The member already has a placed order for this cycle (409). Their
|
||||
// local cart is stale, so drop it and send them to that order rather
|
||||
// than let them build — and pay for — a duplicate.
|
||||
_handleAlreadyPlacedResponse: function (xhr) {
|
||||
if (!xhr || xhr.status !== 409) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var data;
|
||||
try {
|
||||
data = JSON.parse(xhr.responseText || "{}");
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
if (!data.already_placed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var labels = this._getLabels();
|
||||
this._clearCurrentOrderCartSilently();
|
||||
this._updateCartDisplay();
|
||||
this._showNotification(
|
||||
data.error || labels.already_placed || "You already placed an order.",
|
||||
"warning",
|
||||
6000
|
||||
);
|
||||
if (data.redirect_url) {
|
||||
window.location.href = data.redirect_url;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
_checkGroupOrderStatus: function (callback) {
|
||||
var self = this;
|
||||
var done = function () {
|
||||
|
|
@ -783,8 +815,18 @@
|
|||
var tooltipText = null;
|
||||
var labelKey = null;
|
||||
|
||||
// An explicit key on the element wins over the static map: the
|
||||
// checkout button carries a different label depending on
|
||||
// whether the group order takes online payments, and the
|
||||
// server is the one that knows.
|
||||
var declaredKey = element.getAttribute("data-tooltip-key");
|
||||
if (declaredKey && labels[declaredKey]) {
|
||||
labelKey = declaredKey;
|
||||
tooltipText = labels[declaredKey];
|
||||
}
|
||||
|
||||
// Check ID-based mapping
|
||||
if (element.id && tooltipMap[element.id]) {
|
||||
if (!tooltipText && element.id && tooltipMap[element.id]) {
|
||||
labelKey = tooltipMap[element.id];
|
||||
tooltipText = labels[labelKey];
|
||||
}
|
||||
|
|
@ -1329,160 +1371,6 @@
|
|||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Update DOM elements with translated labels
|
||||
*/
|
||||
_updateDOMLabels: function (labels) {
|
||||
console.log("[UPDATE_LABELS] Starting DOM update with labels:", labels);
|
||||
|
||||
// Map of element ID to label key
|
||||
var elementLabelMap = {
|
||||
"label-home-delivery": "home_delivery",
|
||||
"label-delivery-information": "delivery_information",
|
||||
"label-important": "important",
|
||||
"label-confirm-warning": "confirm_order_warning",
|
||||
};
|
||||
|
||||
// Update each element
|
||||
for (var elementId in elementLabelMap) {
|
||||
var element = document.getElementById(elementId);
|
||||
var labelKey = elementLabelMap[elementId];
|
||||
var translatedText = labels[labelKey];
|
||||
|
||||
console.log(
|
||||
"[UPDATE_LABELS] Element:",
|
||||
elementId,
|
||||
"| Exists:",
|
||||
!!element,
|
||||
"| Label Key:",
|
||||
labelKey,
|
||||
"| Translated:",
|
||||
translatedText
|
||||
);
|
||||
|
||||
if (element && translatedText) {
|
||||
var oldText = element.textContent;
|
||||
element.textContent = translatedText;
|
||||
console.log(
|
||||
"[UPDATE_LABELS] ✅ Updated #" +
|
||||
elementId +
|
||||
': "' +
|
||||
oldText +
|
||||
'" → "' +
|
||||
translatedText +
|
||||
'"'
|
||||
);
|
||||
} else if (!element) {
|
||||
console.log("[UPDATE_LABELS] ❌ Element not found: #" + elementId);
|
||||
} else if (!translatedText) {
|
||||
console.log(
|
||||
"[UPDATE_LABELS] ❌ Label not found: " +
|
||||
labelKey +
|
||||
" (available keys: " +
|
||||
Object.keys(labels).join(", ") +
|
||||
")"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update delivery day text if available
|
||||
if (
|
||||
window.groupOrderShop &&
|
||||
window.groupOrderShop.labels &&
|
||||
window.groupOrderShop.labels.delivery_info_template
|
||||
) {
|
||||
var deliveryDayText = document.getElementById("delivery-day-text");
|
||||
console.log("[UPDATE_LABELS] Delivery day text element exists:", !!deliveryDayText);
|
||||
|
||||
if (deliveryDayText) {
|
||||
// Get delivery data from window.deliveryData first, then fallback to attributes
|
||||
var pickupDayIndex = "";
|
||||
var pickupDate = "";
|
||||
var deliveryNotice = "";
|
||||
|
||||
if (window.deliveryData) {
|
||||
console.log(
|
||||
"[UPDATE_LABELS] Using window.deliveryData:",
|
||||
window.deliveryData
|
||||
);
|
||||
pickupDayIndex = window.deliveryData.pickupDay || "";
|
||||
pickupDate = window.deliveryData.pickupDate || "";
|
||||
deliveryNotice = window.deliveryData.deliveryNotice || "";
|
||||
} else {
|
||||
console.log(
|
||||
"[UPDATE_LABELS] window.deliveryData not found, using data attributes"
|
||||
);
|
||||
var wrap = document.getElementById("wrap");
|
||||
pickupDayIndex = wrap ? wrap.getAttribute("data-pickup-day") : "";
|
||||
pickupDate = wrap ? wrap.getAttribute("data-pickup-date") : "";
|
||||
deliveryNotice = wrap ? wrap.getAttribute("data-delivery-notice") : "";
|
||||
}
|
||||
|
||||
// Normalize: convert "undefined" strings and null to empty for processing
|
||||
if (pickupDayIndex === "undefined" || pickupDayIndex === null)
|
||||
pickupDayIndex = "";
|
||||
if (pickupDate === "undefined" || pickupDate === null) pickupDate = "";
|
||||
if (deliveryNotice === "undefined" || deliveryNotice === null)
|
||||
deliveryNotice = "";
|
||||
|
||||
console.log("[UPDATE_LABELS] Delivery data (final):", {
|
||||
pickupDayIndex: pickupDayIndex,
|
||||
pickupDate: pickupDate,
|
||||
deliveryNotice: deliveryNotice,
|
||||
});
|
||||
|
||||
// Day names mapping
|
||||
var dayNames = {
|
||||
0: "Monday",
|
||||
1: "Tuesday",
|
||||
2: "Wednesday",
|
||||
3: "Thursday",
|
||||
4: "Friday",
|
||||
5: "Saturday",
|
||||
6: "Sunday",
|
||||
};
|
||||
|
||||
// Get translated day names if available
|
||||
if (window.groupOrderShop && window.groupOrderShop.day_names) {
|
||||
dayNames = window.groupOrderShop.day_names;
|
||||
}
|
||||
|
||||
// Get the day name from index
|
||||
var pickupDayName =
|
||||
pickupDayIndex && dayNames[pickupDayIndex]
|
||||
? dayNames[pickupDayIndex]
|
||||
: pickupDayIndex;
|
||||
|
||||
// Build message from template
|
||||
var msg = window.groupOrderShop.labels.delivery_info_template;
|
||||
msg = msg.replace("{pickup_day}", pickupDayName);
|
||||
msg = msg.replace("{pickup_date}", pickupDate);
|
||||
|
||||
console.log("[UPDATE_LABELS] Built delivery message:", msg);
|
||||
|
||||
// Build final HTML output
|
||||
var htmlOutput = msg;
|
||||
if (deliveryNotice) {
|
||||
// Replace newlines with <br> tags for HTML display
|
||||
htmlOutput =
|
||||
msg.replace(/\n/g, "<br>") +
|
||||
"<br><br>" +
|
||||
deliveryNotice.replace(/\n/g, "<br>");
|
||||
console.log("[UPDATE_LABELS] Final HTML with notice:", htmlOutput);
|
||||
} else {
|
||||
htmlOutput = msg.replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
deliveryDayText.innerHTML = htmlOutput;
|
||||
console.log(
|
||||
"[UPDATE_LABELS] ✅ Updated delivery day text with translated template"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log("[UPDATE_LABELS] ❌ delivery_info_template label not found");
|
||||
}
|
||||
},
|
||||
|
||||
_attachLoadMoreListener: function () {
|
||||
var self = this;
|
||||
var btn = document.getElementById("load-more-btn");
|
||||
|
|
@ -1790,6 +1678,9 @@
|
|||
self._updateCartDisplay();
|
||||
return;
|
||||
}
|
||||
if (self._handleAlreadyPlacedResponse(xhr)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var errorData = JSON.parse(xhr.responseText);
|
||||
self._showNotification(
|
||||
|
|
@ -1914,6 +1805,9 @@
|
|||
self._updateCartDisplay();
|
||||
return;
|
||||
}
|
||||
if (self._handleAlreadyPlacedResponse(xhr)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var errorData = JSON.parse(xhr.responseText);
|
||||
self._showNotification(
|
||||
|
|
@ -2036,10 +1930,17 @@
|
|||
|
||||
if (data.success) {
|
||||
var successMsg =
|
||||
data.message ||
|
||||
labels.draft_saved_success ||
|
||||
labels.draft_saved ||
|
||||
"Order saved as draft successfully";
|
||||
self._showNotification("\u2713 " + successMsg, "success", 5000);
|
||||
// With online payment on, the server answers with the
|
||||
// payment step URL: saving the cart is only half of
|
||||
// placing the order.
|
||||
if (data.redirect_url) {
|
||||
window.location.href = data.redirect_url;
|
||||
}
|
||||
} else {
|
||||
self._showNotification(
|
||||
"Error: " + (data.error || labels.error_unknown || "Unknown error"),
|
||||
|
|
@ -2056,6 +1957,9 @@
|
|||
self._updateCartDisplay();
|
||||
return;
|
||||
}
|
||||
if (self._handleAlreadyPlacedResponse(xhr)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var errorData = JSON.parse(xhr.responseText);
|
||||
console.error("HTTP error:", xhr.status, errorData);
|
||||
|
|
|
|||
|
|
@ -18,3 +18,10 @@ from . import test_cron_picking_batch # noqa: F401
|
|||
from . import test_group_order_status_endpoint # noqa: F401
|
||||
from . import test_home_delivery # noqa: F401
|
||||
from . import test_forecasted_stock # noqa: F401
|
||||
from . import test_online_payment # noqa: F401
|
||||
from . import test_product_discovery # noqa: F401
|
||||
from . import test_date_edge_cases # noqa: F401
|
||||
from . import test_portal_routes # noqa: F401
|
||||
from . import test_price_with_taxes_included # noqa: F401
|
||||
from . import test_pricing_delegation # noqa: F401
|
||||
from . import test_website_enabled # noqa: F401
|
||||
|
|
|
|||
280
website_sale_aplicoop/tests/test_date_edge_cases.py
Normal file
280
website_sale_aplicoop/tests/test_date_edge_cases.py
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
# Copyright 2025 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Calendar edge cases for the cutoff/pickup/delivery date chain.
|
||||
|
||||
The date chain is `cutoff_date` -> `pickup_date` -> `delivery_date`:
|
||||
|
||||
- `cutoff_date` is the next occurrence of `cutoff_day`, measured from
|
||||
`start_date` when it is in the future, or from **today** when `start_date`
|
||||
already passed. Recurring orders therefore roll forward to the current
|
||||
cycle instead of staying on the cycle they were created in.
|
||||
- `pickup_date` is the next occurrence of `pickup_day` *strictly after*
|
||||
`cutoff_date`.
|
||||
- `delivery_date` is `pickup_date` + 1 day.
|
||||
|
||||
Every fixture below anchors on a **future** start date so the roll-forward
|
||||
does not move the cycle under the test, which keeps the arithmetic exact and
|
||||
the assertions stable over time. `test_past_weekly_order_rolls_forward`
|
||||
covers the roll-forward itself.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from datetime import timedelta
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class DateEdgeCaseCommon:
|
||||
"""Helpers to build orders anchored on chosen calendar dates."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
}
|
||||
)
|
||||
|
||||
def _create_order(self, start_date, pickup_day, cutoff_day, **overrides):
|
||||
vals = {
|
||||
"name": "Date Edge Case Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": str(pickup_day),
|
||||
"cutoff_day": str(cutoff_day),
|
||||
}
|
||||
vals.update(overrides)
|
||||
return self.env["group.order"].create(vals)
|
||||
|
||||
@staticmethod
|
||||
def _next_leap_day(reference):
|
||||
"""First 29 February strictly after `reference`."""
|
||||
year = reference.year
|
||||
while True:
|
||||
year += 1
|
||||
try:
|
||||
candidate = date(year, 2, 29)
|
||||
except ValueError:
|
||||
continue
|
||||
if candidate > reference:
|
||||
return candidate
|
||||
|
||||
@staticmethod
|
||||
def _expected_cutoff(start_date, cutoff_day):
|
||||
"""Mirror of `_compute_cutoff_date` for a future weekly start."""
|
||||
days_ahead = cutoff_day - start_date.weekday()
|
||||
if days_ahead < 0:
|
||||
days_ahead += 7
|
||||
return start_date + timedelta(days=days_ahead)
|
||||
|
||||
@staticmethod
|
||||
def _expected_pickup(cutoff_date, pickup_day):
|
||||
"""Mirror of `_compute_pickup_date`: strictly after the cutoff."""
|
||||
days_ahead = pickup_day - cutoff_date.weekday()
|
||||
if days_ahead <= 0:
|
||||
days_ahead += 7
|
||||
return cutoff_date + timedelta(days=days_ahead)
|
||||
|
||||
|
||||
class TestLeapYearHandling(DateEdgeCaseCommon, TransactionCase):
|
||||
"""A 29 February inside the cycle must not shift the dates."""
|
||||
|
||||
def test_pickup_lands_on_leap_day(self):
|
||||
"""A pickup that falls exactly on 29 February is computed as such."""
|
||||
leap_day = self._next_leap_day(date.today())
|
||||
# Anchor the cycle so the cutoff is the day before the leap day; the
|
||||
# next pickup weekday is then the leap day itself.
|
||||
cutoff = leap_day - timedelta(days=1)
|
||||
start = cutoff - timedelta(days=1)
|
||||
|
||||
order = self._create_order(
|
||||
start_date=start,
|
||||
pickup_day=leap_day.weekday(),
|
||||
cutoff_day=cutoff.weekday(),
|
||||
)
|
||||
|
||||
self.assertEqual(order.cutoff_date, cutoff)
|
||||
self.assertEqual(order.pickup_date, leap_day)
|
||||
self.assertEqual(order.pickup_date.day, 29)
|
||||
self.assertEqual(order.pickup_date.month, 2)
|
||||
|
||||
def test_cycle_spans_the_leap_day(self):
|
||||
"""A cycle crossing 29 February keeps the 7-day pickup spacing."""
|
||||
leap_day = self._next_leap_day(date.today())
|
||||
start = leap_day - timedelta(days=3)
|
||||
|
||||
order = self._create_order(
|
||||
start_date=start,
|
||||
pickup_day=(start.weekday() + 4) % 7,
|
||||
cutoff_day=start.weekday(),
|
||||
)
|
||||
|
||||
# cutoff is the start day itself (days_ahead == 0 is allowed)
|
||||
self.assertEqual(order.cutoff_date, start)
|
||||
self.assertEqual(order.pickup_date, start + timedelta(days=4))
|
||||
# The leap day is inside the cycle, so it was counted as a real day.
|
||||
self.assertLess(order.cutoff_date, leap_day)
|
||||
self.assertGreater(order.pickup_date, leap_day)
|
||||
|
||||
def test_delivery_after_leap_day_pickup(self):
|
||||
"""Delivery is pickup + 1 day even when pickup is 29 February."""
|
||||
leap_day = self._next_leap_day(date.today())
|
||||
cutoff = leap_day - timedelta(days=1)
|
||||
start = cutoff - timedelta(days=1)
|
||||
|
||||
order = self._create_order(
|
||||
start_date=start,
|
||||
pickup_day=leap_day.weekday(),
|
||||
cutoff_day=cutoff.weekday(),
|
||||
)
|
||||
|
||||
self.assertEqual(order.pickup_date, leap_day)
|
||||
# 1 March in a leap year.
|
||||
self.assertEqual(order.delivery_date, date(leap_day.year, 3, 1))
|
||||
|
||||
|
||||
class TestMonthAndYearBoundaries(DateEdgeCaseCommon, TransactionCase):
|
||||
"""Cycles crossing a month or year boundary."""
|
||||
|
||||
def _future_day(self, target_day, month_offset=1):
|
||||
"""A future date landing on `target_day` of some upcoming month."""
|
||||
candidate = date.today().replace(day=1) + relativedelta(months=month_offset)
|
||||
return candidate.replace(day=target_day)
|
||||
|
||||
def test_pickup_crosses_month_boundary(self):
|
||||
"""A pickup in the month after the cutoff is computed correctly."""
|
||||
# Anchor on the 28th so a few days forward always lands next month.
|
||||
start = self._future_day(28)
|
||||
pickup_day = (start.weekday() + 5) % 7
|
||||
|
||||
order = self._create_order(
|
||||
start_date=start,
|
||||
pickup_day=pickup_day,
|
||||
cutoff_day=start.weekday(),
|
||||
)
|
||||
|
||||
expected_pickup = start + timedelta(days=5)
|
||||
self.assertEqual(order.cutoff_date, start)
|
||||
self.assertEqual(order.pickup_date, expected_pickup)
|
||||
self.assertNotEqual(order.pickup_date.month, order.cutoff_date.month)
|
||||
|
||||
def test_pickup_crosses_year_boundary(self):
|
||||
"""A cycle spanning 31 December rolls into the next year."""
|
||||
today = date.today()
|
||||
start = date(today.year + 1, 12, 29)
|
||||
pickup_day = (start.weekday() + 4) % 7
|
||||
|
||||
order = self._create_order(
|
||||
start_date=start,
|
||||
pickup_day=pickup_day,
|
||||
cutoff_day=start.weekday(),
|
||||
)
|
||||
|
||||
self.assertEqual(order.cutoff_date, start)
|
||||
self.assertEqual(order.pickup_date, start + timedelta(days=4))
|
||||
self.assertEqual(order.pickup_date.year, start.year + 1)
|
||||
self.assertEqual(order.pickup_date.month, 1)
|
||||
|
||||
def test_last_day_of_month_pickup(self):
|
||||
"""A pickup landing on the last day of the month is kept intact."""
|
||||
start = self._future_day(24)
|
||||
last_day = start + relativedelta(day=31)
|
||||
cutoff = last_day - timedelta(days=1)
|
||||
|
||||
order = self._create_order(
|
||||
start_date=start,
|
||||
pickup_day=last_day.weekday(),
|
||||
cutoff_day=cutoff.weekday(),
|
||||
)
|
||||
|
||||
self.assertEqual(order.cutoff_date, cutoff)
|
||||
self.assertEqual(order.pickup_date, last_day)
|
||||
# Next day already belongs to the following month.
|
||||
self.assertEqual(order.delivery_date.day, 1)
|
||||
|
||||
|
||||
class TestMonthlyRecurrenceBoundaries(DateEdgeCaseCommon, TransactionCase):
|
||||
"""The monthly grid must survive short months and leap years."""
|
||||
|
||||
def test_monthly_grid_from_january_31_anchor(self):
|
||||
"""A 31 January anchor advances into February without overflowing."""
|
||||
today = date.today()
|
||||
anchor = date(today.year + 1, 1, 31)
|
||||
|
||||
order = self._create_order(
|
||||
start_date=anchor,
|
||||
pickup_day=(anchor.weekday() + 2) % 7,
|
||||
cutoff_day=anchor.weekday(),
|
||||
period="monthly",
|
||||
end_date=anchor + relativedelta(months=6),
|
||||
)
|
||||
|
||||
# First grid point is the anchor itself (already on cutoff_day).
|
||||
self.assertEqual(order.cutoff_date, anchor)
|
||||
self.assertEqual(order.pickup_date, anchor + timedelta(days=2))
|
||||
|
||||
def test_monthly_grid_lands_in_february_of_a_leap_year(self):
|
||||
"""A February cycle in a leap year stays inside February."""
|
||||
leap_day = self._next_leap_day(date.today())
|
||||
anchor = date(leap_day.year, 2, 1)
|
||||
# Only meaningful while the anchor is still ahead of us.
|
||||
if anchor <= date.today():
|
||||
self.skipTest("The next leap February already started")
|
||||
|
||||
order = self._create_order(
|
||||
start_date=anchor,
|
||||
pickup_day=(anchor.weekday() + 1) % 7,
|
||||
cutoff_day=anchor.weekday(),
|
||||
period="monthly",
|
||||
end_date=anchor + relativedelta(months=6),
|
||||
)
|
||||
|
||||
self.assertEqual(order.cutoff_date, anchor)
|
||||
self.assertEqual(order.cutoff_date.month, 2)
|
||||
self.assertEqual(order.pickup_date, anchor + timedelta(days=1))
|
||||
|
||||
|
||||
class TestRollForwardBehaviour(DateEdgeCaseCommon, TransactionCase):
|
||||
"""Past-dated recurring orders move to the current cycle."""
|
||||
|
||||
def test_past_weekly_order_rolls_forward(self):
|
||||
"""A weekly order created long ago computes dates for today's cycle.
|
||||
|
||||
This is the documented behaviour of `_compute_cutoff_date`: once
|
||||
`start_date` is in the past the reference becomes today, so the order
|
||||
never keeps stale dates from the cycle it was created in.
|
||||
"""
|
||||
today = date.today()
|
||||
long_ago = today - relativedelta(years=2)
|
||||
|
||||
order = self._create_order(
|
||||
start_date=long_ago,
|
||||
pickup_day=3,
|
||||
cutoff_day=0,
|
||||
end_date=long_ago + timedelta(days=7),
|
||||
)
|
||||
|
||||
expected_cutoff = self._expected_cutoff(today, 0)
|
||||
self.assertEqual(order.cutoff_date, expected_cutoff)
|
||||
self.assertEqual(order.pickup_date, self._expected_pickup(expected_cutoff, 3))
|
||||
# The stale cycle is gone: nothing points back at the creation year.
|
||||
self.assertGreaterEqual(order.cutoff_date, today)
|
||||
self.assertGreater(order.pickup_date, today)
|
||||
|
||||
def test_future_weekly_order_keeps_its_own_cycle(self):
|
||||
"""A future order is anchored on its start date, not on today."""
|
||||
start = date.today() + timedelta(days=30)
|
||||
|
||||
order = self._create_order(start_date=start, pickup_day=3, cutoff_day=0)
|
||||
|
||||
expected_cutoff = self._expected_cutoff(start, 0)
|
||||
self.assertEqual(order.cutoff_date, expected_cutoff)
|
||||
self.assertEqual(order.pickup_date, self._expected_pickup(expected_cutoff, 3))
|
||||
self.assertGreaterEqual(order.cutoff_date, start)
|
||||
|
|
@ -1,667 +0,0 @@
|
|||
# Copyright 2025 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""
|
||||
Test suite for cart/draft persistence in website_sale_aplicoop.
|
||||
|
||||
Coverage:
|
||||
- Save draft order (empty, with items)
|
||||
- Load draft order
|
||||
- Draft consistency (prices don't change unexpectedly)
|
||||
- Product archived in draft (handling)
|
||||
- Merge inconsistent drafts
|
||||
- Draft timeline (very old draft, recent draft)
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestSaveDraftOrder(TransactionCase):
|
||||
"""Test saving draft orders."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
}
|
||||
)
|
||||
|
||||
self.member_partner = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Group Member",
|
||||
"email": "member@test.com",
|
||||
}
|
||||
)
|
||||
|
||||
self.group.member_ids = [(4, self.member_partner.id)]
|
||||
|
||||
self.user = self.env["res.users"].create(
|
||||
{
|
||||
"name": "Test User",
|
||||
"login": "testuser@test.com",
|
||||
"email": "testuser@test.com",
|
||||
"partner_id": self.member_partner.id,
|
||||
}
|
||||
)
|
||||
|
||||
self.category = self.env["product.category"].create(
|
||||
{
|
||||
"name": "Test Category",
|
||||
}
|
||||
)
|
||||
|
||||
self.product1 = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Product 1",
|
||||
"type": "consu",
|
||||
"list_price": 10.0,
|
||||
"categ_id": self.category.id,
|
||||
}
|
||||
)
|
||||
|
||||
self.product2 = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Product 2",
|
||||
"type": "consu",
|
||||
"list_price": 20.0,
|
||||
"categ_id": self.category.id,
|
||||
}
|
||||
)
|
||||
|
||||
start_date = datetime.now().date()
|
||||
self.group_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Test Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"pickup_date": start_date + timedelta(days=3),
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
self.group_order.action_open()
|
||||
self.group_order.product_ids = [(4, self.product1.id), (4, self.product2.id)]
|
||||
|
||||
def test_save_draft_with_items(self):
|
||||
"""Test saving draft order with products."""
|
||||
draft_order = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.member_partner.id,
|
||||
"group_order_id": self.group_order.id,
|
||||
"state": "draft",
|
||||
"order_line": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": self.product1.id,
|
||||
"product_qty": 2,
|
||||
"price_unit": self.product1.list_price,
|
||||
},
|
||||
),
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": self.product2.id,
|
||||
"product_qty": 1,
|
||||
"price_unit": self.product2.list_price,
|
||||
},
|
||||
),
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(draft_order.exists())
|
||||
self.assertEqual(draft_order.state, "draft")
|
||||
self.assertEqual(len(draft_order.order_line), 2)
|
||||
|
||||
def test_save_draft_empty_order(self):
|
||||
"""Test saving draft order without items."""
|
||||
# Edge case: empty draft
|
||||
empty_draft = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.member_partner.id,
|
||||
"group_order_id": self.group_order.id,
|
||||
"state": "draft",
|
||||
"order_line": [],
|
||||
}
|
||||
)
|
||||
|
||||
# Should be valid (user hasn't added products yet)
|
||||
self.assertTrue(empty_draft.exists())
|
||||
self.assertEqual(len(empty_draft.order_line), 0)
|
||||
|
||||
def test_save_draft_updates_existing(self):
|
||||
"""Test that saving draft updates existing draft, not creates new."""
|
||||
# Create initial draft
|
||||
draft = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.member_partner.id,
|
||||
"group_order_id": self.group_order.id,
|
||||
"state": "draft",
|
||||
"order_line": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": self.product1.id,
|
||||
"product_qty": 1,
|
||||
},
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
draft_id = draft.id
|
||||
|
||||
# Simulate "save" with different quantity
|
||||
draft.order_line[0].product_qty = 5
|
||||
|
||||
# Should be same draft, not new one
|
||||
updated_draft = self.env["sale.order"].browse(draft_id)
|
||||
self.assertTrue(updated_draft.exists())
|
||||
self.assertEqual(updated_draft.order_line[0].product_qty, 5)
|
||||
|
||||
def test_save_draft_preserves_group_order_reference(self):
|
||||
"""Test that group_order_id is preserved when saving."""
|
||||
draft = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.member_partner.id,
|
||||
"group_order_id": self.group_order.id,
|
||||
"state": "draft",
|
||||
}
|
||||
)
|
||||
|
||||
# Link must be preserved
|
||||
self.assertEqual(draft.group_order_id, self.group_order)
|
||||
|
||||
def test_save_draft_preserves_pickup_date(self):
|
||||
"""Test that pickup_date is preserved in draft."""
|
||||
draft = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.member_partner.id,
|
||||
"group_order_id": self.group_order.id,
|
||||
"pickup_date": self.group_order.pickup_date,
|
||||
"state": "draft",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(draft.pickup_date, self.group_order.pickup_date)
|
||||
|
||||
|
||||
class TestLoadDraftOrder(TransactionCase):
|
||||
"""Test loading (retrieving) draft orders."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
}
|
||||
)
|
||||
|
||||
self.member_partner = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Group Member",
|
||||
"email": "member@test.com",
|
||||
}
|
||||
)
|
||||
|
||||
self.group.member_ids = [(4, self.member_partner.id)]
|
||||
|
||||
self.user = self.env["res.users"].create(
|
||||
{
|
||||
"name": "Test User",
|
||||
"login": "testuser@test.com",
|
||||
"email": "testuser@test.com",
|
||||
"partner_id": self.member_partner.id,
|
||||
}
|
||||
)
|
||||
|
||||
self.product = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Test Product",
|
||||
"type": "consu",
|
||||
"list_price": 10.0,
|
||||
}
|
||||
)
|
||||
|
||||
start_date = datetime.now().date()
|
||||
self.group_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Test Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
self.group_order.action_open()
|
||||
|
||||
def test_load_existing_draft(self):
|
||||
"""Test loading an existing draft order."""
|
||||
# Create draft
|
||||
draft = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.member_partner.id,
|
||||
"group_order_id": self.group_order.id,
|
||||
"state": "draft",
|
||||
"order_line": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": self.product.id,
|
||||
"product_qty": 3,
|
||||
},
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# Load it
|
||||
loaded = self.env["sale.order"].search(
|
||||
[
|
||||
("id", "=", draft.id),
|
||||
("partner_id", "=", self.member_partner.id),
|
||||
("state", "=", "draft"),
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(len(loaded), 1)
|
||||
self.assertEqual(loaded[0].order_line[0].product_qty, 3)
|
||||
|
||||
def test_load_draft_not_visible_to_other_user(self):
|
||||
"""Test that draft from one user not accessible to another."""
|
||||
# Create draft for member_partner
|
||||
draft = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.member_partner.id,
|
||||
"group_order_id": self.group_order.id,
|
||||
"state": "draft",
|
||||
}
|
||||
)
|
||||
|
||||
# Create another user/partner
|
||||
other_partner = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Other Member",
|
||||
"email": "other@test.com",
|
||||
}
|
||||
)
|
||||
|
||||
self.env["res.users"].create(
|
||||
{
|
||||
"name": "Other User",
|
||||
"login": "other@test.com",
|
||||
"partner_id": other_partner.id,
|
||||
}
|
||||
)
|
||||
|
||||
# Other user should not see original draft
|
||||
other_drafts = self.env["sale.order"].search(
|
||||
[
|
||||
("id", "=", draft.id),
|
||||
("partner_id", "=", other_partner.id),
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(len(other_drafts), 0)
|
||||
|
||||
def test_load_draft_from_expired_order(self):
|
||||
"""Test loading draft from closed/expired group order."""
|
||||
# Close the group order
|
||||
self.group_order.action_close()
|
||||
|
||||
# Create draft before closure (simulated)
|
||||
draft = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.member_partner.id,
|
||||
"group_order_id": self.group_order.id,
|
||||
"state": "draft",
|
||||
}
|
||||
)
|
||||
|
||||
# Draft should still be loadable (but should warn)
|
||||
loaded = self.env["sale.order"].browse(draft.id)
|
||||
self.assertTrue(loaded.exists())
|
||||
# Controller should check: group_order.state and warn if closed
|
||||
|
||||
|
||||
class TestDraftConsistency(TransactionCase):
|
||||
"""Test that draft prices remain consistent across saves."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
}
|
||||
)
|
||||
|
||||
self.member_partner = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Group Member",
|
||||
"email": "member@test.com",
|
||||
}
|
||||
)
|
||||
|
||||
self.group.member_ids = [(4, self.member_partner.id)]
|
||||
|
||||
self.user = self.env["res.users"].create(
|
||||
{
|
||||
"name": "Test User",
|
||||
"login": "testuser@test.com",
|
||||
"partner_id": self.member_partner.id,
|
||||
}
|
||||
)
|
||||
|
||||
self.product = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Test Product",
|
||||
"type": "consu",
|
||||
"list_price": 100.0,
|
||||
}
|
||||
)
|
||||
|
||||
start_date = datetime.now().date()
|
||||
self.group_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Test Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
self.group_order.action_open()
|
||||
|
||||
def test_draft_price_snapshot(self):
|
||||
"""Test that draft captures price at time of save."""
|
||||
original_price = self.product.list_price
|
||||
|
||||
# Save draft with current price
|
||||
draft = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.member_partner.id,
|
||||
"group_order_id": self.group_order.id,
|
||||
"state": "draft",
|
||||
"order_line": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": self.product.id,
|
||||
"product_qty": 1,
|
||||
"price_unit": original_price,
|
||||
},
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
saved_price = draft.order_line[0].price_unit
|
||||
|
||||
# Change product price
|
||||
self.product.list_price = 150.0
|
||||
|
||||
# Draft should still have original price
|
||||
self.assertEqual(draft.order_line[0].price_unit, saved_price)
|
||||
self.assertNotEqual(draft.order_line[0].price_unit, self.product.list_price)
|
||||
|
||||
def test_draft_quantity_consistency(self):
|
||||
"""Test that quantities are preserved across saves."""
|
||||
# Save draft
|
||||
draft = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.member_partner.id,
|
||||
"group_order_id": self.group_order.id,
|
||||
"state": "draft",
|
||||
"order_line": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": self.product.id,
|
||||
"product_qty": 5,
|
||||
},
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# Re-load draft
|
||||
reloaded = self.env["sale.order"].browse(draft.id)
|
||||
self.assertEqual(reloaded.order_line[0].product_qty, 5)
|
||||
|
||||
|
||||
class TestProductArchivedInDraft(TransactionCase):
|
||||
"""Test handling when product in draft gets archived."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
}
|
||||
)
|
||||
|
||||
self.member_partner = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Group Member",
|
||||
"email": "member@test.com",
|
||||
}
|
||||
)
|
||||
|
||||
self.group.member_ids = [(4, self.member_partner.id)]
|
||||
|
||||
self.user = self.env["res.users"].create(
|
||||
{
|
||||
"name": "Test User",
|
||||
"login": "testuser@test.com",
|
||||
"partner_id": self.member_partner.id,
|
||||
}
|
||||
)
|
||||
|
||||
self.product = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Test Product",
|
||||
"type": "consu",
|
||||
"list_price": 10.0,
|
||||
"active": True,
|
||||
}
|
||||
)
|
||||
|
||||
start_date = datetime.now().date()
|
||||
self.group_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Test Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
self.group_order.action_open()
|
||||
|
||||
def test_load_draft_with_archived_product(self):
|
||||
"""Test loading draft when product has been archived."""
|
||||
# Create draft with active product
|
||||
draft = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.member_partner.id,
|
||||
"group_order_id": self.group_order.id,
|
||||
"state": "draft",
|
||||
"order_line": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": self.product.id,
|
||||
"product_qty": 2,
|
||||
},
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# Archive the product
|
||||
self.product.active = False
|
||||
|
||||
# Load draft - should still work (historical data)
|
||||
loaded = self.env["sale.order"].browse(draft.id)
|
||||
self.assertTrue(loaded.exists())
|
||||
# But product may not be editable/accessible
|
||||
|
||||
|
||||
class TestDraftTimeline(TransactionCase):
|
||||
"""Test very old vs recent drafts."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
}
|
||||
)
|
||||
|
||||
self.member_partner = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Group Member",
|
||||
"email": "member@test.com",
|
||||
}
|
||||
)
|
||||
|
||||
self.group.member_ids = [(4, self.member_partner.id)]
|
||||
|
||||
self.product = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Test Product",
|
||||
"type": "consu",
|
||||
"list_price": 10.0,
|
||||
}
|
||||
)
|
||||
|
||||
def test_draft_from_current_week(self):
|
||||
"""Test draft from current/open group order."""
|
||||
start_date = datetime.now().date()
|
||||
current_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Current Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
current_order.action_open()
|
||||
|
||||
draft = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.member_partner.id,
|
||||
"group_order_id": current_order.id,
|
||||
"state": "draft",
|
||||
}
|
||||
)
|
||||
|
||||
# Should be accessible and valid
|
||||
self.assertTrue(draft.exists())
|
||||
self.assertEqual(draft.group_order_id.state, "open")
|
||||
|
||||
def test_draft_from_old_order_6_months_ago(self):
|
||||
"""Test draft from order that was 6 months ago."""
|
||||
old_start = datetime.now().date() - timedelta(days=180)
|
||||
old_end = old_start + timedelta(days=7)
|
||||
|
||||
old_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Old Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": old_start,
|
||||
"end_date": old_end,
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
old_order.action_open()
|
||||
old_order.action_close()
|
||||
|
||||
old_draft = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.member_partner.id,
|
||||
"group_order_id": old_order.id,
|
||||
"state": "draft",
|
||||
}
|
||||
)
|
||||
|
||||
# Should still exist but be inaccessible (order closed)
|
||||
self.assertTrue(old_draft.exists())
|
||||
self.assertEqual(old_order.state, "closed")
|
||||
|
||||
def test_draft_order_count_for_user(self):
|
||||
"""Test counting total drafts for a user."""
|
||||
# Create multiple orders and drafts
|
||||
orders = []
|
||||
for i in range(3):
|
||||
start = datetime.now().date() + timedelta(days=i * 7)
|
||||
order = self.env["group.order"].create(
|
||||
{
|
||||
"name": f"Order {i}",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start,
|
||||
"end_date": start + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
order.action_open()
|
||||
orders.append(order)
|
||||
|
||||
# Create draft for each
|
||||
for order in orders:
|
||||
self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.member_partner.id,
|
||||
"group_order_id": order.id,
|
||||
"state": "draft",
|
||||
}
|
||||
)
|
||||
|
||||
# Count drafts for user
|
||||
user_drafts = self.env["sale.order"].search(
|
||||
[
|
||||
("partner_id", "=", self.member_partner.id),
|
||||
("state", "=", "draft"),
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(len(user_drafts), 3)
|
||||
|
|
@ -1,506 +0,0 @@
|
|||
# Copyright 2025 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""
|
||||
Test suite for edge cases involving dates, times, and calendar calculations.
|
||||
|
||||
Coverage:
|
||||
- Leap year (Feb 29) handling
|
||||
- Long-duration orders (entire year)
|
||||
- Pickup day boundary conditions
|
||||
- Orders with future start dates
|
||||
- Orders without end dates
|
||||
- Extreme dates (year 1900, year 2099)
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from datetime import timedelta
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo.exceptions import ValidationError # noqa: F401
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestLeapYearHandling(TransactionCase):
|
||||
"""Test date calculations with leap year (Feb 29)."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
}
|
||||
)
|
||||
|
||||
def test_order_spans_leap_day(self):
|
||||
"""Test order that includes Feb 29 (leap year)."""
|
||||
# 2024 is a leap year
|
||||
start = date(2024, 2, 25)
|
||||
end = date(2024, 3, 3) # Spans Feb 29
|
||||
|
||||
order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Leap Year Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start,
|
||||
"end_date": end,
|
||||
"period": "weekly",
|
||||
"pickup_day": "2", # Wednesday (Feb 28 or 29 depending on week)
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(order.exists())
|
||||
# Should correctly calculate pickup date
|
||||
self.assertTrue(order.pickup_date)
|
||||
|
||||
def test_pickup_day_on_feb_29(self):
|
||||
"""Test setting pickup_day to land on Feb 29."""
|
||||
# 2024 Feb 29 is a Thursday (day 3)
|
||||
start = date(2024, 2, 26) # Monday
|
||||
end = date(2024, 3, 3)
|
||||
|
||||
order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Feb 29 Pickup",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start,
|
||||
"end_date": end,
|
||||
"period": "weekly",
|
||||
"pickup_day": "3", # Thursday = Feb 29
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(order.pickup_date, date(2024, 2, 29))
|
||||
|
||||
def test_order_before_leap_day(self):
|
||||
"""Test order in non-leap year (no Feb 29)."""
|
||||
# 2023 is NOT a leap year
|
||||
start = date(2023, 2, 25)
|
||||
end = date(2023, 3, 3)
|
||||
|
||||
order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Non-Leap Year Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start,
|
||||
"end_date": end,
|
||||
"period": "weekly",
|
||||
"pickup_day": "2",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(order.exists())
|
||||
# Pickup should be Feb 28 (last day of Feb)
|
||||
self.assertIn(order.pickup_date.month, [2, 3])
|
||||
|
||||
|
||||
class TestLongDurationOrders(TransactionCase):
|
||||
"""Test orders spanning very long periods."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
}
|
||||
)
|
||||
|
||||
def test_order_spans_entire_year(self):
|
||||
"""Test order running for 365 days."""
|
||||
start = date(2024, 1, 1)
|
||||
end = date(2024, 12, 31)
|
||||
|
||||
order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Year-Long Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start,
|
||||
"end_date": end,
|
||||
"period": "weekly",
|
||||
"pickup_day": "3", # Same day each week
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(order.exists())
|
||||
# Should handle 52+ weeks correctly
|
||||
days_diff = (end - start).days
|
||||
self.assertEqual(days_diff, 365)
|
||||
|
||||
def test_order_multiple_years(self):
|
||||
"""Test order spanning multiple years (2+ years)."""
|
||||
start = date(2024, 1, 1)
|
||||
end = date(2026, 12, 31) # 3 years
|
||||
|
||||
order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Multi-Year Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start,
|
||||
"end_date": end,
|
||||
"period": "monthly",
|
||||
"pickup_day": "15",
|
||||
"cutoff_day": "10",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(order.exists())
|
||||
days_diff = (end - start).days
|
||||
self.assertGreater(days_diff, 700) # More than 2 years
|
||||
|
||||
def test_order_one_day_duration(self):
|
||||
"""Test order with start_date == end_date (single day)."""
|
||||
same_day = date(2024, 2, 15)
|
||||
|
||||
order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "One-Day Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "once",
|
||||
"start_date": same_day,
|
||||
"end_date": same_day,
|
||||
"period": "once",
|
||||
"pickup_day": "0",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(order.exists())
|
||||
|
||||
|
||||
class TestPickupDayBoundary(TransactionCase):
|
||||
"""Test pickup_day calculations at boundaries."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
}
|
||||
)
|
||||
|
||||
def test_pickup_day_same_as_start_date(self):
|
||||
"""Test when pickup_day equals start date (today)."""
|
||||
today = date.today()
|
||||
start = today
|
||||
end = today + timedelta(days=7)
|
||||
|
||||
order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Today Pickup",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start,
|
||||
"end_date": end,
|
||||
"period": "weekly",
|
||||
"pickup_day": str(start.weekday()), # Same as start
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(order.exists())
|
||||
# Pickup should be today
|
||||
self.assertEqual(order.pickup_date, start)
|
||||
|
||||
def test_pickup_day_last_day_of_month(self):
|
||||
"""Test pickup day on last day of month (Jan 31, Feb 28/29, etc)."""
|
||||
# Start on Jan 24, pickup on Jan 31
|
||||
start = date(2024, 1, 24)
|
||||
end = date(2024, 2, 1)
|
||||
|
||||
order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Month-End Pickup",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start,
|
||||
"end_date": end,
|
||||
"period": "once",
|
||||
"pickup_day": "2", # Wednesday = Jan 31
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(order.exists())
|
||||
|
||||
def test_pickup_day_month_boundary(self):
|
||||
"""Test when pickup crosses month boundary."""
|
||||
# Start Jan 28, pickup might be in February
|
||||
start = date(2024, 1, 28)
|
||||
end = date(2024, 2, 5)
|
||||
|
||||
order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Month Boundary Pickup",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start,
|
||||
"end_date": end,
|
||||
"period": "weekly",
|
||||
"pickup_day": "4", # Friday (Feb 2)
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(order.exists())
|
||||
# Pickup should be in Feb
|
||||
self.assertEqual(order.pickup_date.month, 2)
|
||||
|
||||
def test_all_seven_days_as_pickup(self):
|
||||
"""Test each day of week (0-6) as valid pickup_day."""
|
||||
start = date(2024, 1, 1) # Monday
|
||||
end = date(2024, 1, 8)
|
||||
|
||||
for day_num in range(7):
|
||||
order = self.env["group.order"].create(
|
||||
{
|
||||
"name": f"Pickup Day {day_num}",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start,
|
||||
"end_date": end,
|
||||
"period": "weekly",
|
||||
"pickup_day": str(day_num),
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(order.exists())
|
||||
# Each should have valid pickup_date
|
||||
self.assertTrue(order.pickup_date)
|
||||
|
||||
|
||||
class TestFutureStartDateOrders(TransactionCase):
|
||||
"""Test orders that start in the future."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
}
|
||||
)
|
||||
|
||||
def test_order_starts_tomorrow(self):
|
||||
"""Test order starting tomorrow."""
|
||||
today = date.today()
|
||||
start = today + timedelta(days=1)
|
||||
end = start + timedelta(days=7)
|
||||
|
||||
order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Future Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start,
|
||||
"end_date": end,
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(order.exists())
|
||||
self.assertGreater(order.start_date, today)
|
||||
|
||||
def test_order_starts_6_months_future(self):
|
||||
"""Test order starting 6 months from now."""
|
||||
today = date.today()
|
||||
start = today + relativedelta(months=6)
|
||||
end = start + timedelta(days=30)
|
||||
|
||||
order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Far Future Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start,
|
||||
"end_date": end,
|
||||
"period": "monthly",
|
||||
"pickup_day": "15",
|
||||
"cutoff_day": "10",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(order.exists())
|
||||
|
||||
|
||||
class TestExtremeDate(TransactionCase):
|
||||
"""Test edge cases with very old or very new dates."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
}
|
||||
)
|
||||
|
||||
def test_order_year_2000(self):
|
||||
"""Test order in year 2000 (Y2K edge case)."""
|
||||
start = date(2000, 1, 1)
|
||||
end = date(2000, 12, 31)
|
||||
|
||||
order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Y2K Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start,
|
||||
"end_date": end,
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(order.exists())
|
||||
|
||||
def test_order_far_future_2099(self):
|
||||
"""Test order in far future (year 2099)."""
|
||||
start = date(2099, 1, 1)
|
||||
end = date(2099, 12, 31)
|
||||
|
||||
order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Far Future Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start,
|
||||
"end_date": end,
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(order.exists())
|
||||
|
||||
def test_order_crossing_century(self):
|
||||
"""Test order spanning century boundary (Dec 1999 to Jan 2000)."""
|
||||
start = date(1999, 12, 26)
|
||||
end = date(2000, 1, 2)
|
||||
|
||||
order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Century Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start,
|
||||
"end_date": end,
|
||||
"period": "weekly",
|
||||
"pickup_day": "6", # Saturday
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(order.exists())
|
||||
# Should handle date arithmetic correctly across years
|
||||
self.assertEqual(order.start_date.year, 1999)
|
||||
self.assertEqual(order.end_date.year, 2000)
|
||||
|
||||
|
||||
class TestOrderWithoutEndDate(TransactionCase):
|
||||
"""Test orders without explicit end_date (permanent/ongoing)."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
}
|
||||
)
|
||||
|
||||
def test_permanent_order_with_null_end_date(self):
|
||||
"""Test order with end_date = NULL (ongoing order)."""
|
||||
start = date.today()
|
||||
|
||||
self.env["group.order"].create(
|
||||
{
|
||||
"name": "Permanent Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start,
|
||||
"end_date": False, # No end date
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
|
||||
# If supported, should handle gracefully
|
||||
# Otherwise, may be optional validation
|
||||
|
||||
|
||||
class TestPickupCalculationAccuracy(TransactionCase):
|
||||
"""Test accuracy of pickup_date calculations."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
}
|
||||
)
|
||||
|
||||
def test_pickup_date_calculation_multiple_weeks(self):
|
||||
"""Test pickup_date calculation over multiple weeks."""
|
||||
# Week 1: Jan 1-7 (Mon-Sun), pickup Thursday = Jan 4
|
||||
start = date(2024, 1, 1)
|
||||
end = date(2024, 1, 22)
|
||||
|
||||
order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Multi-Week Pickup",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start,
|
||||
"end_date": end,
|
||||
"period": "weekly",
|
||||
"pickup_day": "3", # Thursday
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(order.exists())
|
||||
# First pickup should be first Thursday on or after start
|
||||
self.assertEqual(order.pickup_date.weekday(), 3)
|
||||
|
||||
def test_monthly_order_pickup_date(self):
|
||||
"""Test pickup_date for monthly orders."""
|
||||
# Order runs Feb 1 - Mar 31, pickup on 15th
|
||||
start = date(2024, 2, 1)
|
||||
end = date(2024, 3, 31)
|
||||
|
||||
order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Monthly Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start,
|
||||
"end_date": end,
|
||||
"period": "monthly",
|
||||
"pickup_day": "15",
|
||||
"cutoff_day": "10",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertTrue(order.exists())
|
||||
# First pickup should be Feb 15
|
||||
self.assertGreaterEqual(order.pickup_date.day, 15)
|
||||
|
|
@ -1,613 +0,0 @@
|
|||
# Copyright 2025 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""
|
||||
Test suite for HTTP endpoints in website_sale_aplicoop controllers.
|
||||
|
||||
Coverage:
|
||||
- /eskaera (GET) - View all group orders
|
||||
- /eskaera/<id> (GET) - View specific group order
|
||||
- /eskaera/<id>/add-to-cart (POST) - Add product to cart
|
||||
- /eskaera/<id>/checkout (GET) - Checkout page
|
||||
- /eskaera/<id>/checkout (POST) - Save cart items
|
||||
- /eskaera/confirm (POST) - Confirm order
|
||||
- /eskaera/<id>/confirm/<sale_id> (POST) - Confirm order from portal
|
||||
- /eskaera/<id>/load-from-history/<sale_id> (POST) - Load draft order
|
||||
- /eskaera/labels (GET) - Get translated labels
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo.exceptions import AccessError # noqa: F401
|
||||
from odoo.exceptions import ValidationError # noqa: F401
|
||||
from odoo.tests.common import HttpCase # noqa: F401
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestEskaearaListEndpoint(TransactionCase):
|
||||
"""Test /eskaera endpoint (list all group orders)."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
"email": "group@test.com",
|
||||
}
|
||||
)
|
||||
|
||||
self.member_partner = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Group Member",
|
||||
"email": "member@test.com",
|
||||
}
|
||||
)
|
||||
|
||||
self.group.member_ids = [(4, self.member_partner.id)]
|
||||
|
||||
self.user = self.env["res.users"].create(
|
||||
{
|
||||
"name": "Test User",
|
||||
"login": "testuser@test.com",
|
||||
"email": "testuser@test.com",
|
||||
"partner_id": self.member_partner.id,
|
||||
}
|
||||
)
|
||||
|
||||
# Create multiple group orders (some open, some closed)
|
||||
start_date = datetime.now().date()
|
||||
|
||||
self.open_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Open Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
self.open_order.action_open()
|
||||
|
||||
self.draft_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Draft Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date - timedelta(days=14),
|
||||
"end_date": start_date - timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
# Stay in draft
|
||||
|
||||
self.closed_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Closed Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date - timedelta(days=21),
|
||||
"end_date": start_date - timedelta(days=14),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
self.closed_order.action_open()
|
||||
self.closed_order.action_close()
|
||||
|
||||
def test_eskaera_list_shows_only_open_and_draft_orders(self):
|
||||
"""Test that /eskaera shows only open/draft orders, not closed."""
|
||||
# In controller context, only open and draft should be visible to members
|
||||
# This is business logic: closed orders are historical
|
||||
visible_orders = self.env["group.order"].search(
|
||||
[
|
||||
("state", "in", ["open", "draft"]),
|
||||
("group_ids", "in", self.group.id),
|
||||
]
|
||||
)
|
||||
|
||||
self.assertIn(self.open_order, visible_orders)
|
||||
self.assertIn(self.draft_order, visible_orders)
|
||||
self.assertNotIn(self.closed_order, visible_orders)
|
||||
|
||||
def test_eskaera_list_filters_by_user_groups(self):
|
||||
"""Test that user only sees orders from their groups."""
|
||||
other_group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Other Group",
|
||||
"is_company": True,
|
||||
"email": "other@test.com",
|
||||
}
|
||||
)
|
||||
|
||||
other_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Other Group Order",
|
||||
"group_ids": [(6, 0, [other_group.id])],
|
||||
"type": "regular",
|
||||
"start_date": datetime.now().date(),
|
||||
"end_date": datetime.now().date() + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
other_order.action_open()
|
||||
|
||||
# User should not see orders from groups they're not in
|
||||
user_groups = self.member_partner.group_ids
|
||||
visible_orders = self.env["group.order"].search(
|
||||
[
|
||||
("state", "in", ["open", "draft"]),
|
||||
("group_ids", "in", user_groups.ids),
|
||||
]
|
||||
)
|
||||
|
||||
self.assertNotIn(other_order, visible_orders)
|
||||
|
||||
|
||||
class TestAddToCartEndpoint(TransactionCase):
|
||||
"""Test /eskaera/<id>/add-to-cart endpoint."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
"email": "group@test.com",
|
||||
}
|
||||
)
|
||||
|
||||
self.member_partner = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Group Member",
|
||||
"email": "member@test.com",
|
||||
}
|
||||
)
|
||||
|
||||
self.group.member_ids = [(4, self.member_partner.id)]
|
||||
|
||||
self.user = self.env["res.users"].create(
|
||||
{
|
||||
"name": "Test User",
|
||||
"login": "testuser@test.com",
|
||||
"email": "testuser@test.com",
|
||||
"partner_id": self.member_partner.id,
|
||||
}
|
||||
)
|
||||
|
||||
self.category = self.env["product.category"].create(
|
||||
{
|
||||
"name": "Test Category",
|
||||
}
|
||||
)
|
||||
|
||||
# Published product
|
||||
self.product = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Test Product",
|
||||
"type": "consu",
|
||||
"list_price": 10.0,
|
||||
"categ_id": self.category.id,
|
||||
"sale_ok": True,
|
||||
"is_published": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Unpublished product (should not be available)
|
||||
self.unpublished_product = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Unpublished Product",
|
||||
"type": "consu",
|
||||
"list_price": 15.0,
|
||||
"categ_id": self.category.id,
|
||||
"sale_ok": False,
|
||||
"is_published": False,
|
||||
}
|
||||
)
|
||||
|
||||
start_date = datetime.now().date()
|
||||
self.group_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Test Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
self.group_order.action_open()
|
||||
self.group_order.product_ids = [(4, self.product.id)]
|
||||
|
||||
def test_add_to_cart_published_product(self):
|
||||
"""Test adding published product to cart."""
|
||||
# Simulate controller logic
|
||||
cart_line = {
|
||||
"product_id": self.product.id,
|
||||
"quantity": 2,
|
||||
"group_order_id": self.group_order.id,
|
||||
"partner_id": self.member_partner.id,
|
||||
}
|
||||
# Should succeed
|
||||
self.assertTrue(cart_line["product_id"])
|
||||
|
||||
def test_add_to_cart_zero_quantity(self):
|
||||
"""Test that adding zero quantity is rejected."""
|
||||
# Edge case: quantity = 0
|
||||
quantity = 0
|
||||
# Controller should validate: quantity > 0
|
||||
self.assertFalse(quantity > 0)
|
||||
|
||||
def test_add_to_cart_negative_quantity(self):
|
||||
"""Test that negative quantity is rejected."""
|
||||
quantity = -5
|
||||
# Controller should validate: quantity > 0
|
||||
self.assertFalse(quantity > 0)
|
||||
|
||||
def test_add_to_cart_unpublished_product(self):
|
||||
"""Test that unpublished products cannot be added."""
|
||||
# Product must be published and sale_ok=True
|
||||
self.assertFalse(self.unpublished_product.is_published)
|
||||
self.assertFalse(self.unpublished_product.sale_ok)
|
||||
|
||||
def test_add_to_cart_product_not_in_order(self):
|
||||
"""Test that products not in the order cannot be added."""
|
||||
# Create a product NOT associated with group_order
|
||||
other_product = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Other Product",
|
||||
"type": "consu",
|
||||
"list_price": 25.0,
|
||||
}
|
||||
)
|
||||
|
||||
# Controller should check: product in group_order.product_ids
|
||||
self.assertNotIn(other_product, self.group_order.product_ids)
|
||||
|
||||
def test_add_to_cart_order_closed(self):
|
||||
"""Test that adding to closed order is rejected."""
|
||||
self.group_order.action_close()
|
||||
# Controller should check: order.state == 'open'
|
||||
self.assertEqual(self.group_order.state, "closed")
|
||||
|
||||
|
||||
class TestCheckoutEndpoint(TransactionCase):
|
||||
"""Test /eskaera/<id>/checkout endpoint."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
"email": "group@test.com",
|
||||
}
|
||||
)
|
||||
|
||||
self.member_partner = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Group Member",
|
||||
"email": "member@test.com",
|
||||
}
|
||||
)
|
||||
|
||||
self.group.member_ids = [(4, self.member_partner.id)]
|
||||
|
||||
self.user = self.env["res.users"].create(
|
||||
{
|
||||
"name": "Test User",
|
||||
"login": "testuser@test.com",
|
||||
"email": "testuser@test.com",
|
||||
"partner_id": self.member_partner.id,
|
||||
}
|
||||
)
|
||||
|
||||
start_date = datetime.now().date()
|
||||
self.group_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Test Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"pickup_date": start_date + timedelta(days=3),
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
self.group_order.action_open()
|
||||
|
||||
def test_checkout_page_loads(self):
|
||||
"""Test that checkout page renders correctly."""
|
||||
# Controller should render template with group_order context
|
||||
self.assertTrue(self.group_order.exists())
|
||||
|
||||
def test_checkout_displays_pickup_date(self):
|
||||
"""Test that checkout shows correct pickup date."""
|
||||
# Controller should calculate pickup_date from pickup_day
|
||||
self.assertTrue(self.group_order.pickup_date)
|
||||
|
||||
def test_checkout_displays_home_delivery_option(self):
|
||||
"""Test that checkout shows home delivery option."""
|
||||
# Controller should pass home_delivery flag to template
|
||||
self.assertIsNotNone(self.group_order.home_delivery)
|
||||
|
||||
def test_checkout_order_without_products(self):
|
||||
"""Test checkout when no products available."""
|
||||
# Order with empty product_ids
|
||||
empty_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Empty Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": datetime.now().date(),
|
||||
"end_date": datetime.now().date() + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
empty_order.action_open()
|
||||
|
||||
# Should handle gracefully
|
||||
self.assertEqual(len(empty_order.product_ids), 0)
|
||||
|
||||
|
||||
class TestConfirmOrderEndpoint(TransactionCase):
|
||||
"""Test /eskaera/confirm endpoint (confirm final order)."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
"email": "group@test.com",
|
||||
}
|
||||
)
|
||||
|
||||
self.member_partner = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Group Member",
|
||||
"email": "member@test.com",
|
||||
}
|
||||
)
|
||||
|
||||
self.group.member_ids = [(4, self.member_partner.id)]
|
||||
|
||||
self.user = self.env["res.users"].create(
|
||||
{
|
||||
"name": "Test User",
|
||||
"login": "testuser@test.com",
|
||||
"email": "testuser@test.com",
|
||||
"partner_id": self.member_partner.id,
|
||||
}
|
||||
)
|
||||
|
||||
self.category = self.env["product.category"].create(
|
||||
{
|
||||
"name": "Test Category",
|
||||
}
|
||||
)
|
||||
|
||||
self.product = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Test Product",
|
||||
"type": "consu",
|
||||
"list_price": 10.0,
|
||||
"categ_id": self.category.id,
|
||||
}
|
||||
)
|
||||
|
||||
start_date = datetime.now().date()
|
||||
self.group_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Test Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"pickup_date": start_date + timedelta(days=3),
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
self.group_order.action_open()
|
||||
self.group_order.product_ids = [(4, self.product.id)]
|
||||
|
||||
# Create a draft sale order
|
||||
self.draft_sale = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.member_partner.id,
|
||||
"group_order_id": self.group_order.id,
|
||||
"pickup_date": self.group_order.pickup_date,
|
||||
"state": "draft",
|
||||
}
|
||||
)
|
||||
|
||||
def test_confirm_order_creates_sale_order(self):
|
||||
"""Test that confirming creates a confirmed sale.order."""
|
||||
# Controller should change state from draft to sale
|
||||
self.draft_sale.action_confirm()
|
||||
self.assertEqual(self.draft_sale.state, "sale")
|
||||
|
||||
def test_confirm_empty_order(self):
|
||||
"""Test confirming order without items fails."""
|
||||
# Order with no order_lines should fail
|
||||
empty_sale = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.member_partner.id,
|
||||
"group_order_id": self.group_order.id,
|
||||
"state": "draft",
|
||||
}
|
||||
)
|
||||
|
||||
# Should validate: must have at least one line
|
||||
self.assertEqual(len(empty_sale.order_line), 0)
|
||||
|
||||
def test_confirm_order_wrong_group(self):
|
||||
"""Test that user cannot confirm order from different group."""
|
||||
other_group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Other Group",
|
||||
"is_company": True,
|
||||
}
|
||||
)
|
||||
|
||||
self.env["group.order"].create(
|
||||
{
|
||||
"name": "Other Order",
|
||||
"group_ids": [(6, 0, [other_group.id])],
|
||||
"type": "regular",
|
||||
"start_date": datetime.now().date(),
|
||||
"end_date": datetime.now().date() + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
|
||||
# User should not be in other_group
|
||||
self.assertNotIn(self.member_partner, other_group.member_ids)
|
||||
|
||||
|
||||
class TestLoadDraftEndpoint(TransactionCase):
|
||||
"""Test /eskaera/<id>/load-from-history/<sale_id> endpoint."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
"email": "group@test.com",
|
||||
}
|
||||
)
|
||||
|
||||
self.member_partner = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Group Member",
|
||||
"email": "member@test.com",
|
||||
}
|
||||
)
|
||||
|
||||
self.group.member_ids = [(4, self.member_partner.id)]
|
||||
|
||||
self.user = self.env["res.users"].create(
|
||||
{
|
||||
"name": "Test User",
|
||||
"login": "testuser@test.com",
|
||||
"email": "testuser@test.com",
|
||||
"partner_id": self.member_partner.id,
|
||||
}
|
||||
)
|
||||
|
||||
self.category = self.env["product.category"].create(
|
||||
{
|
||||
"name": "Test Category",
|
||||
}
|
||||
)
|
||||
|
||||
self.product = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Test Product",
|
||||
"type": "consu",
|
||||
"list_price": 10.0,
|
||||
"categ_id": self.category.id,
|
||||
}
|
||||
)
|
||||
|
||||
start_date = datetime.now().date()
|
||||
self.group_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Test Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"pickup_date": start_date + timedelta(days=3),
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
self.group_order.action_open()
|
||||
self.group_order.product_ids = [(4, self.product.id)]
|
||||
|
||||
def test_load_draft_from_history(self):
|
||||
"""Test loading a previous draft order."""
|
||||
# Create old draft sale
|
||||
old_sale = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.member_partner.id,
|
||||
"group_order_id": self.group_order.id,
|
||||
"state": "draft",
|
||||
}
|
||||
)
|
||||
|
||||
# Should be able to load
|
||||
self.assertTrue(old_sale.exists())
|
||||
|
||||
def test_load_draft_not_owned_by_user(self):
|
||||
"""Test that user cannot load draft from other user."""
|
||||
other_partner = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Other Member",
|
||||
"email": "other@test.com",
|
||||
}
|
||||
)
|
||||
|
||||
other_sale = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": other_partner.id,
|
||||
"group_order_id": self.group_order.id,
|
||||
"state": "draft",
|
||||
}
|
||||
)
|
||||
|
||||
# User should not be able to load other's draft
|
||||
self.assertNotEqual(other_sale.partner_id, self.member_partner)
|
||||
|
||||
def test_load_draft_expired_order(self):
|
||||
"""Test loading draft from expired group order."""
|
||||
old_start = datetime.now().date() - timedelta(days=30)
|
||||
old_end = datetime.now().date() - timedelta(days=23)
|
||||
|
||||
expired_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Expired Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": old_start,
|
||||
"end_date": old_end,
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
expired_order.action_open()
|
||||
expired_order.action_close()
|
||||
|
||||
self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.member_partner.id,
|
||||
"group_order_id": expired_order.id,
|
||||
"state": "draft",
|
||||
}
|
||||
)
|
||||
|
||||
# Should warn: order expired
|
||||
self.assertEqual(expired_order.state, "closed")
|
||||
|
|
@ -63,6 +63,8 @@ class TestGroupOrderStatusEndpoint(TransactionCase):
|
|||
data=json.dumps(payload).encode("utf-8"),
|
||||
),
|
||||
make_response=_make_response,
|
||||
# The Eskaera routes check that this website serves Eskaera.
|
||||
website=SimpleNamespace(eskaera_enabled=True),
|
||||
)
|
||||
|
||||
def test_check_group_order_status_open(self):
|
||||
|
|
|
|||
|
|
@ -1,353 +0,0 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""
|
||||
Test suite for Phase 1 refactoring helper methods.
|
||||
|
||||
Tests for extracted helper methods that reduce cyclomatic complexity:
|
||||
- _resolve_pricelist(): Consolidate pricelist resolution logic
|
||||
- _validate_confirm_request(): Validate confirm order request
|
||||
- _validate_draft_request(): Validate draft order request
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestResolvePricelist(TransactionCase):
|
||||
"""Test _resolve_pricelist() helper method."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.pricelist_aplicoop = self.env["product.pricelist"].create(
|
||||
{
|
||||
"name": "Aplicoop Pricelist",
|
||||
"currency_id": self.env.company.currency_id.id,
|
||||
}
|
||||
)
|
||||
|
||||
self.pricelist_website = self.env["product.pricelist"].create(
|
||||
{
|
||||
"name": "Website Pricelist",
|
||||
"currency_id": self.env.company.currency_id.id,
|
||||
}
|
||||
)
|
||||
|
||||
self.website = self.env["website"].get_current_website()
|
||||
self.website.pricelist_id = self.pricelist_website.id
|
||||
|
||||
def test_resolve_pricelist_aplicoop_configured(self):
|
||||
"""Test pricelist resolution when Aplicoop pricelist is configured."""
|
||||
# Set Aplicoop pricelist in config
|
||||
self.env["ir.config_parameter"].sudo().set_param(
|
||||
"website_sale_aplicoop.pricelist_id", str(self.pricelist_aplicoop.id)
|
||||
)
|
||||
|
||||
# When calling _resolve_pricelist, should return Aplicoop pricelist
|
||||
# Placeholder: will be implemented with actual controller call
|
||||
|
||||
def test_resolve_pricelist_fallback_to_website(self):
|
||||
"""Test fallback to website pricelist when Aplicoop not configured."""
|
||||
# Don't set Aplicoop pricelist in config (leave empty)
|
||||
self.env["ir.config_parameter"].sudo().set_param(
|
||||
"website_sale_aplicoop.pricelist_id", ""
|
||||
)
|
||||
|
||||
# When calling _resolve_pricelist, should return website pricelist
|
||||
# Placeholder: will be implemented with actual controller call
|
||||
|
||||
def test_resolve_pricelist_fallback_to_first_active(self):
|
||||
"""Test final fallback to first active pricelist."""
|
||||
# Remove both configured pricelists
|
||||
self.env["ir.config_parameter"].sudo().set_param(
|
||||
"website_sale_aplicoop.pricelist_id", ""
|
||||
)
|
||||
self.website.pricelist_id = False
|
||||
|
||||
# When calling _resolve_pricelist, should return first active pricelist
|
||||
# Placeholder: will be implemented with actual controller call
|
||||
|
||||
|
||||
class TestValidateConfirmRequest(TransactionCase):
|
||||
"""Test _validate_confirm_request() helper method."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
}
|
||||
)
|
||||
|
||||
self.member = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Group Member",
|
||||
"email": "member@test.com",
|
||||
}
|
||||
)
|
||||
self.group.member_ids = [(4, self.member.id)]
|
||||
|
||||
self.user = self.env["res.users"].create(
|
||||
{
|
||||
"name": "Test User",
|
||||
"login": "testuser@test.com",
|
||||
"email": "testuser@test.com",
|
||||
"partner_id": self.member.id,
|
||||
}
|
||||
)
|
||||
|
||||
self.product = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Test Product",
|
||||
"type": "product",
|
||||
"list_price": 100.0,
|
||||
}
|
||||
)
|
||||
|
||||
self.group_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Test Order",
|
||||
"group_ids": [(4, self.group.id)],
|
||||
"start_date": datetime.now().date(),
|
||||
"end_date": datetime.now().date() + timedelta(days=7),
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
"state": "open",
|
||||
}
|
||||
)
|
||||
|
||||
def test_validate_confirm_valid_request(self):
|
||||
"""Test validation passes for valid confirm request."""
|
||||
_ = {
|
||||
"order_id": str(self.group_order.id),
|
||||
"items": [
|
||||
{
|
||||
"product_id": str(self.product.id),
|
||||
"quantity": 1.0,
|
||||
"product_price": 100.0,
|
||||
}
|
||||
],
|
||||
"is_delivery": False,
|
||||
}
|
||||
|
||||
# Validation should pass without raising exception
|
||||
# Placeholder: will be implemented with actual controller call
|
||||
|
||||
def test_validate_confirm_missing_order_id(self):
|
||||
"""Test validation fails when order_id missing."""
|
||||
_ = {
|
||||
"items": [{"product_id": "1", "quantity": 1.0}],
|
||||
}
|
||||
|
||||
# Validation should raise ValueError: "order_id is required"
|
||||
# Placeholder: will be implemented with actual controller call
|
||||
|
||||
def test_validate_confirm_invalid_order_id(self):
|
||||
"""Test validation fails for invalid order_id format."""
|
||||
_ = {
|
||||
"order_id": "invalid",
|
||||
"items": [{"product_id": "1", "quantity": 1.0}],
|
||||
}
|
||||
|
||||
# Validation should raise ValueError with "Invalid order_id format"
|
||||
# Placeholder: will be implemented with actual controller call
|
||||
|
||||
def test_validate_confirm_nonexistent_order(self):
|
||||
"""Test validation fails when order doesn't exist."""
|
||||
_ = {
|
||||
"order_id": "99999",
|
||||
"items": [{"product_id": "1", "quantity": 1.0}],
|
||||
}
|
||||
|
||||
# Validation should raise ValueError with "not found"
|
||||
# Placeholder: will be implemented with actual controller call
|
||||
|
||||
def test_validate_confirm_closed_order(self):
|
||||
"""Test validation fails when order is closed."""
|
||||
self.group_order.state = "confirmed"
|
||||
|
||||
_ = {
|
||||
"order_id": str(self.group_order.id),
|
||||
"items": [{"product_id": "1", "quantity": 1.0}],
|
||||
}
|
||||
|
||||
# Validation should raise ValueError with "not available"
|
||||
# Placeholder: will be implemented with actual controller call
|
||||
|
||||
def test_validate_confirm_no_items(self):
|
||||
"""Test validation fails when no items provided."""
|
||||
_ = {
|
||||
"order_id": str(self.group_order.id),
|
||||
"items": [],
|
||||
}
|
||||
|
||||
# Validation should raise ValueError with "No items in cart"
|
||||
# Placeholder: will be implemented with actual controller call
|
||||
|
||||
def test_validate_confirm_user_no_partner(self):
|
||||
"""Test validation fails when user has no partner_id."""
|
||||
_ = self.env["res.users"].create(
|
||||
{
|
||||
"name": "User No Partner",
|
||||
"login": "nopartner@test.com",
|
||||
"email": "nopartner@test.com",
|
||||
}
|
||||
)
|
||||
|
||||
_ = {
|
||||
"order_id": str(self.group_order.id),
|
||||
"items": [{"product_id": "1", "quantity": 1.0}],
|
||||
}
|
||||
|
||||
# Validation should raise ValueError with "no associated partner"
|
||||
# Placeholder: will be implemented with actual controller call
|
||||
|
||||
|
||||
class TestValidateDraftRequest(TransactionCase):
|
||||
"""Test _validate_draft_request() helper method."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
}
|
||||
)
|
||||
|
||||
self.member = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Group Member",
|
||||
"email": "member@test.com",
|
||||
}
|
||||
)
|
||||
self.group.member_ids = [(4, self.member.id)]
|
||||
|
||||
self.user = self.env["res.users"].create(
|
||||
{
|
||||
"name": "Test User",
|
||||
"login": "testuser@test.com",
|
||||
"email": "testuser@test.com",
|
||||
"partner_id": self.member.id,
|
||||
}
|
||||
)
|
||||
|
||||
self.product = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Test Product",
|
||||
"type": "product",
|
||||
"list_price": 100.0,
|
||||
}
|
||||
)
|
||||
|
||||
self.group_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Test Order",
|
||||
"group_ids": [(4, self.group.id)],
|
||||
"start_date": datetime.now().date(),
|
||||
"end_date": datetime.now().date() + timedelta(days=7),
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
"state": "open",
|
||||
}
|
||||
)
|
||||
|
||||
def test_validate_draft_valid_request(self):
|
||||
"""Test validation passes for valid draft request."""
|
||||
_ = {
|
||||
"order_id": str(self.group_order.id),
|
||||
"items": [
|
||||
{
|
||||
"product_id": str(self.product.id),
|
||||
"quantity": 1.0,
|
||||
"product_price": 100.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
# Validation should pass without raising exception
|
||||
# Placeholder: will be implemented with actual controller call
|
||||
|
||||
def test_validate_draft_missing_order_id(self):
|
||||
"""Test validation fails when order_id missing."""
|
||||
_ = {
|
||||
"items": [{"product_id": "1", "quantity": 1.0}],
|
||||
}
|
||||
|
||||
# Validation should raise ValueError: "order_id is required"
|
||||
# Placeholder: will be implemented with actual controller call
|
||||
|
||||
def test_validate_draft_invalid_order_id(self):
|
||||
"""Test validation fails for invalid order_id."""
|
||||
_ = {
|
||||
"order_id": "invalid",
|
||||
"items": [{"product_id": "1", "quantity": 1.0}],
|
||||
}
|
||||
|
||||
# Validation should raise ValueError with "Invalid order_id format"
|
||||
# Placeholder: will be implemented with actual controller call
|
||||
|
||||
def test_validate_draft_nonexistent_order(self):
|
||||
"""Test validation fails when order doesn't exist."""
|
||||
_ = {
|
||||
"order_id": "99999",
|
||||
"items": [{"product_id": "1", "quantity": 1.0}],
|
||||
}
|
||||
|
||||
# Validation should raise ValueError with "not found"
|
||||
# Placeholder: will be implemented with actual controller call
|
||||
|
||||
def test_validate_draft_no_items(self):
|
||||
"""Test validation fails when no items."""
|
||||
_ = {
|
||||
"order_id": str(self.group_order.id),
|
||||
"items": [],
|
||||
}
|
||||
|
||||
# Validation should raise ValueError with "No items in cart"
|
||||
# Placeholder: will be implemented with actual controller call
|
||||
|
||||
def test_validate_draft_user_no_partner(self):
|
||||
"""Test validation fails when user has no partner."""
|
||||
_ = self.env["res.users"].create(
|
||||
{
|
||||
"name": "User No Partner",
|
||||
"login": "nopartner@test.com",
|
||||
"email": "nopartner@test.com",
|
||||
}
|
||||
)
|
||||
|
||||
_ = {
|
||||
"order_id": str(self.group_order.id),
|
||||
"items": [{"product_id": "1", "quantity": 1.0}],
|
||||
}
|
||||
|
||||
# Validation should raise ValueError with "no associated partner"
|
||||
# Placeholder: will be implemented with actual controller call
|
||||
|
||||
def test_validate_draft_with_merge_action(self):
|
||||
"""Test validation passes when merge_action is specified."""
|
||||
_ = {
|
||||
"order_id": str(self.group_order.id),
|
||||
"items": [{"product_id": "1", "quantity": 1.0}],
|
||||
"merge_action": "merge",
|
||||
"existing_draft_id": "123",
|
||||
}
|
||||
|
||||
# Validation should pass and return merge_action and existing_draft_id
|
||||
# Placeholder: will be implemented with actual controller call
|
||||
|
||||
def test_validate_draft_with_replace_action(self):
|
||||
"""Test validation passes when replace_action is specified."""
|
||||
_ = {
|
||||
"order_id": str(self.group_order.id),
|
||||
"items": [{"product_id": "1", "quantity": 1.0}],
|
||||
"merge_action": "replace",
|
||||
"existing_draft_id": "123",
|
||||
}
|
||||
|
||||
# Validation should pass and return merge_action and existing_draft_id
|
||||
# Placeholder: will be implemented with actual controller call
|
||||
568
website_sale_aplicoop/tests/test_online_payment.py
Normal file
568
website_sale_aplicoop/tests/test_online_payment.py
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from odoo import fields
|
||||
from odoo.tests.common import HttpCase
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests.common import tagged
|
||||
|
||||
from odoo.addons.website_sale_aplicoop.controllers import (
|
||||
website_sale_validators as validators,
|
||||
)
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install", "eskaera_online_payment")
|
||||
class TestOnlinePayment(TransactionCase):
|
||||
"""Online payment for group orders: policy, guards and batching."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.consumer_group = cls.env["res.partner"].create(
|
||||
{
|
||||
"name": "Payment Consumer Group",
|
||||
"is_company": True,
|
||||
"is_group": True,
|
||||
}
|
||||
)
|
||||
cls.member = cls.env["res.partner"].create(
|
||||
{
|
||||
"name": "Paying Member",
|
||||
"email": "paying.member@test.com",
|
||||
"parent_id": cls.consumer_group.id,
|
||||
}
|
||||
)
|
||||
cls.other_member = cls.env["res.partner"].create(
|
||||
{
|
||||
"name": "Other Member",
|
||||
"email": "other.member@test.com",
|
||||
"parent_id": cls.consumer_group.id,
|
||||
}
|
||||
)
|
||||
cls.product = cls.env["product.product"].create(
|
||||
{
|
||||
"name": "Payable Product",
|
||||
"is_storable": True,
|
||||
"list_price": 10.0,
|
||||
}
|
||||
)
|
||||
# The validator helpers only ever reach for `request.env`, so a
|
||||
# namespace stands in for the HTTP request outside a web context.
|
||||
cls.fake_request = SimpleNamespace(env=cls.env)
|
||||
|
||||
# === Helpers ===
|
||||
|
||||
def _create_group_order(self, online_payment=True, cutoff_in_past=False):
|
||||
"""One-time group order whose cycle ends in the past or the future.
|
||||
|
||||
One-time orders derive `cutoff_date` from `end_date`, so the cycle is
|
||||
steered here through `end_date`.
|
||||
"""
|
||||
today = fields.Date.today()
|
||||
end_date = (
|
||||
today - timedelta(days=1) if cutoff_in_past else today + timedelta(days=2)
|
||||
)
|
||||
return self.env["group.order"].create(
|
||||
{
|
||||
"name": "Payment Group Order",
|
||||
"group_ids": [(6, 0, [self.consumer_group.id])],
|
||||
"period": "once",
|
||||
"pickup_day": "2", # Wednesday
|
||||
"state": "open",
|
||||
"end_date": end_date,
|
||||
"online_payment": online_payment,
|
||||
}
|
||||
)
|
||||
|
||||
def _create_sale_order(self, group_order, partner=None, pickup_date=None):
|
||||
return self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": (partner or self.member).id,
|
||||
"group_order_id": group_order.id,
|
||||
"consumer_group_id": self.consumer_group.id,
|
||||
"pickup_date": pickup_date or group_order.pickup_date,
|
||||
"order_line": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": self.product.id,
|
||||
"product_uom_qty": 1,
|
||||
"price_unit": 10.0,
|
||||
},
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
def _create_done_transaction(self, sale_order):
|
||||
"""A `done` transaction covering the order's full amount.
|
||||
|
||||
`payment.method` records ship archived until a provider module is
|
||||
installed, so the lookup has to ignore the active flag: the test only
|
||||
needs a well-formed transaction, not a usable payment route.
|
||||
"""
|
||||
provider = self.env["payment.provider"].search([], limit=1)
|
||||
method = (
|
||||
self.env["payment.method"]
|
||||
.with_context(active_test=False)
|
||||
.search([("primary_payment_method_id", "=", False)], limit=1)
|
||||
)
|
||||
transaction = self.env["payment.transaction"].create(
|
||||
{
|
||||
"provider_id": provider.id,
|
||||
"payment_method_id": method.id,
|
||||
"reference": f"TEST-{sale_order.id}",
|
||||
"amount": sale_order.amount_total,
|
||||
"currency_id": sale_order.currency_id.id,
|
||||
"partner_id": sale_order.partner_id.id,
|
||||
"sale_order_ids": [(6, 0, sale_order.ids)],
|
||||
}
|
||||
)
|
||||
transaction.write({"state": "done"})
|
||||
return transaction
|
||||
|
||||
# === Payment policy on the sale order ===
|
||||
|
||||
def test_require_payment_follows_group_order(self):
|
||||
"""A group order with online payment makes its orders payable."""
|
||||
group_order = self._create_group_order(online_payment=True)
|
||||
sale_order = self._create_sale_order(group_order)
|
||||
|
||||
self.assertTrue(sale_order.require_payment)
|
||||
self.assertEqual(sale_order.prepayment_percent, 1.0)
|
||||
self.assertTrue(
|
||||
sale_order._has_to_be_paid(),
|
||||
"A draft order of a paying cycle must be payable",
|
||||
)
|
||||
|
||||
def test_require_payment_off_without_online_payment(self):
|
||||
"""Without the flag nothing changes: no payment is required."""
|
||||
group_order = self._create_group_order(online_payment=False)
|
||||
sale_order = self._create_sale_order(group_order)
|
||||
|
||||
self.assertFalse(sale_order.require_payment)
|
||||
self.assertFalse(sale_order._has_to_be_paid())
|
||||
|
||||
def test_toggling_group_order_clears_require_payment(self):
|
||||
"""Turning the flag off mid-cycle must free the existing drafts."""
|
||||
group_order = self._create_group_order(online_payment=True)
|
||||
sale_order = self._create_sale_order(group_order)
|
||||
self.assertTrue(sale_order.require_payment)
|
||||
|
||||
group_order.online_payment = False
|
||||
sale_order.invalidate_recordset()
|
||||
|
||||
self.assertFalse(
|
||||
sale_order.require_payment,
|
||||
"An existing draft must stop requiring payment when the group "
|
||||
"order stops offering it",
|
||||
)
|
||||
|
||||
def test_non_group_orders_keep_company_default(self):
|
||||
"""Orders outside a group order are left alone."""
|
||||
plain_order = self.env["sale.order"].create({"partner_id": self.member.id})
|
||||
self.assertEqual(
|
||||
plain_order.require_payment,
|
||||
plain_order.company_id.portal_confirmation_pay,
|
||||
)
|
||||
|
||||
# === Confirmation through payment ===
|
||||
|
||||
def test_payment_confirms_with_from_orderpoint(self):
|
||||
"""Paying must confirm the way the cutoff cron does.
|
||||
|
||||
Without `from_orderpoint`, a product with a broken replenishment route
|
||||
raises during post-processing, `/payment/status/poll` rolls the whole
|
||||
thing back and the member sees an error over a `done` transaction.
|
||||
"""
|
||||
group_order = self._create_group_order(online_payment=True)
|
||||
sale_order = self._create_sale_order(group_order)
|
||||
transaction = self._create_done_transaction(sale_order)
|
||||
|
||||
captured = {}
|
||||
|
||||
def _fake_confirm(order_self):
|
||||
captured["from_orderpoint"] = order_self.env.context.get("from_orderpoint")
|
||||
return True
|
||||
|
||||
with patch.object(
|
||||
type(self.env["sale.order"]), "action_confirm", _fake_confirm
|
||||
):
|
||||
transaction._check_amount_and_confirm_order()
|
||||
|
||||
self.assertTrue(
|
||||
captured.get("from_orderpoint"),
|
||||
"Group order confirmations triggered by payment must carry "
|
||||
"from_orderpoint=True",
|
||||
)
|
||||
|
||||
def test_payment_confirms_the_order(self):
|
||||
"""The standard machinery confirms the order once paid."""
|
||||
group_order = self._create_group_order(online_payment=True)
|
||||
sale_order = self._create_sale_order(group_order)
|
||||
transaction = self._create_done_transaction(sale_order)
|
||||
|
||||
transaction._check_amount_and_confirm_order()
|
||||
sale_order.invalidate_recordset()
|
||||
|
||||
self.assertEqual(sale_order.state, "sale")
|
||||
|
||||
def test_plain_orders_confirm_without_from_orderpoint(self):
|
||||
"""Orders unrelated to a group order keep the core behaviour."""
|
||||
plain_order = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.member.id,
|
||||
"order_line": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": self.product.id,
|
||||
"product_uom_qty": 1,
|
||||
"price_unit": 10.0,
|
||||
},
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
transaction = self._create_done_transaction(plain_order)
|
||||
|
||||
captured = {}
|
||||
|
||||
def _fake_confirm(order_self):
|
||||
captured["from_orderpoint"] = order_self.env.context.get("from_orderpoint")
|
||||
return True
|
||||
|
||||
with patch.object(
|
||||
type(self.env["sale.order"]), "action_confirm", _fake_confirm
|
||||
):
|
||||
transaction._check_amount_and_confirm_order()
|
||||
|
||||
self.assertFalse(captured.get("from_orderpoint"))
|
||||
|
||||
# === Cycle lookup helpers ===
|
||||
|
||||
def test_draft_lookup_ignores_placed_orders(self):
|
||||
"""`_find_recent_draft_order` must never return a placed order.
|
||||
|
||||
`/eskaera/clear-cart` cancels whatever this returns, so widening it
|
||||
would cancel orders that are already paid for.
|
||||
"""
|
||||
group_order = self._create_group_order(online_payment=True)
|
||||
sale_order = self._create_sale_order(group_order)
|
||||
sale_order.action_confirm()
|
||||
|
||||
found = validators._find_recent_draft_order(
|
||||
None, self.member.id, group_order, request_obj=self.fake_request
|
||||
)
|
||||
self.assertFalse(found)
|
||||
|
||||
def test_placed_lookup_finds_confirmed_order(self):
|
||||
"""The duplicate guard sees the member's confirmed order."""
|
||||
group_order = self._create_group_order(online_payment=True)
|
||||
sale_order = self._create_sale_order(group_order)
|
||||
sale_order.action_confirm()
|
||||
|
||||
found = validators._find_placed_cycle_order(
|
||||
None, self.member.id, group_order, request_obj=self.fake_request
|
||||
)
|
||||
self.assertEqual(found, sale_order)
|
||||
|
||||
def test_placed_lookup_survives_orders_created_after_cutoff(self):
|
||||
"""Nothing blocks ordering between the cutoff and the cron run.
|
||||
|
||||
The draft window caps `create_date` at the cutoff date; the placed
|
||||
lookup must not, or an order paid in that gap would slip past the
|
||||
guard and let the member order twice.
|
||||
"""
|
||||
group_order = self._create_group_order(online_payment=True, cutoff_in_past=True)
|
||||
sale_order = self._create_sale_order(group_order)
|
||||
sale_order.action_confirm()
|
||||
|
||||
found = validators._find_placed_cycle_order(
|
||||
None, self.member.id, group_order, request_obj=self.fake_request
|
||||
)
|
||||
self.assertEqual(found, sale_order)
|
||||
|
||||
def test_placed_lookup_ignores_other_cycles(self):
|
||||
"""An order frozen on another pickup date belongs to another cycle."""
|
||||
group_order = self._create_group_order(online_payment=True)
|
||||
sale_order = self._create_sale_order(
|
||||
group_order, pickup_date=group_order.pickup_date - timedelta(days=7)
|
||||
)
|
||||
sale_order.action_confirm()
|
||||
|
||||
found = validators._find_placed_cycle_order(
|
||||
None, self.member.id, group_order, request_obj=self.fake_request
|
||||
)
|
||||
self.assertFalse(found)
|
||||
|
||||
# === Batching at cutoff ===
|
||||
|
||||
def test_cron_batches_a_fully_prepaid_cycle(self):
|
||||
"""A cycle where everybody paid early still gets its batch.
|
||||
|
||||
The confirmation loop used to bail out when it found no draft, which
|
||||
with online payment is the normal case: every order is confirmed the
|
||||
moment its transaction completes.
|
||||
"""
|
||||
group_order = self._create_group_order(online_payment=True, cutoff_in_past=True)
|
||||
sale_order = self._create_sale_order(group_order)
|
||||
sale_order.action_confirm()
|
||||
|
||||
self.assertFalse(
|
||||
self.env["sale.order"].search(
|
||||
[("group_order_id", "=", group_order.id), ("state", "=", "draft")]
|
||||
),
|
||||
"This cycle must have no drafts left for the test to mean anything",
|
||||
)
|
||||
|
||||
group_order._confirm_linked_sale_orders()
|
||||
|
||||
self.assertTrue(
|
||||
sale_order.picking_ids.batch_id,
|
||||
"The picking of an order paid before the cutoff must still be "
|
||||
"batched by the cron",
|
||||
)
|
||||
|
||||
def test_cron_batches_paid_and_draft_orders_together(self):
|
||||
"""Paid and cron-confirmed orders share one batch per picking type."""
|
||||
group_order = self._create_group_order(online_payment=True, cutoff_in_past=True)
|
||||
paid_order = self._create_sale_order(group_order, partner=self.member)
|
||||
paid_order.action_confirm()
|
||||
draft_order = self._create_sale_order(group_order, partner=self.other_member)
|
||||
|
||||
group_order._confirm_linked_sale_orders()
|
||||
draft_order.invalidate_recordset()
|
||||
|
||||
self.assertEqual(draft_order.state, "sale")
|
||||
batches = paid_order.picking_ids.batch_id | draft_order.picking_ids.batch_id
|
||||
self.assertEqual(
|
||||
len(batches),
|
||||
1,
|
||||
"Both orders belong to the same cycle and picking type, so they "
|
||||
"must land in a single batch",
|
||||
)
|
||||
|
||||
def test_cron_ignores_paid_orders_of_previous_cycles(self):
|
||||
"""A previous cycle's order must not be swept into this batch."""
|
||||
group_order = self._create_group_order(online_payment=True, cutoff_in_past=True)
|
||||
stale_order = self._create_sale_order(
|
||||
group_order, pickup_date=group_order.pickup_date - timedelta(days=7)
|
||||
)
|
||||
stale_order.action_confirm()
|
||||
stale_order.picking_ids.batch_id = False
|
||||
|
||||
group_order._confirm_linked_sale_orders()
|
||||
|
||||
self.assertFalse(
|
||||
stale_order.picking_ids.batch_id,
|
||||
"An order frozen on a previous pickup date is not part of this "
|
||||
"cycle and must be left out of its batch",
|
||||
)
|
||||
|
||||
def test_closed_cycle_still_batches_paid_orders(self):
|
||||
"""Closing a group order by hand must not strand a paid order."""
|
||||
group_order = self._create_group_order(online_payment=True, cutoff_in_past=True)
|
||||
sale_order = self._create_sale_order(group_order)
|
||||
sale_order.action_confirm()
|
||||
group_order.action_close()
|
||||
|
||||
self.env["group.order"]._cron_batch_paid_orders_of_closed_cycles()
|
||||
|
||||
self.assertTrue(
|
||||
sale_order.picking_ids.batch_id,
|
||||
"A paid order of a manually closed cycle must still be batched",
|
||||
)
|
||||
|
||||
def test_closed_cycle_leaves_drafts_alone(self):
|
||||
"""Closing a cycle by hand is how a co-op calls it off."""
|
||||
group_order = self._create_group_order(online_payment=True, cutoff_in_past=True)
|
||||
draft_order = self._create_sale_order(group_order)
|
||||
group_order.action_close()
|
||||
|
||||
self.env["group.order"]._cron_batch_paid_orders_of_closed_cycles()
|
||||
draft_order.invalidate_recordset()
|
||||
|
||||
self.assertEqual(
|
||||
draft_order.state,
|
||||
"draft",
|
||||
"The closed-cycle sweep must only batch, never confirm",
|
||||
)
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install", "eskaera_online_payment")
|
||||
class TestOnlinePaymentRoutes(HttpCase):
|
||||
"""The payment step and its landing page, over HTTP."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Payment Routes Group",
|
||||
"is_company": True,
|
||||
"is_group": True,
|
||||
"email": "payment-routes-group@test.com",
|
||||
}
|
||||
)
|
||||
self.member_partner = self.env["res.partner"].create(
|
||||
{"name": "Payment Routes Member", "email": "payment-routes@test.com"}
|
||||
)
|
||||
self.group.member_ids = [(4, self.member_partner.id)]
|
||||
|
||||
login = "portal.payment@test.com"
|
||||
self.portal_user = self.env["res.users"].create(
|
||||
{
|
||||
"name": "Portal Payment User",
|
||||
"login": login,
|
||||
"password": login,
|
||||
"partner_id": self.member_partner.id,
|
||||
"groups_id": [(4, self.env.ref("base.group_portal").id)],
|
||||
}
|
||||
)
|
||||
|
||||
self.product = self.env["product.product"].create(
|
||||
{"name": "Route Product", "is_storable": True, "list_price": 10.0}
|
||||
)
|
||||
|
||||
start_date = fields.Date.today()
|
||||
self.group_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Payment Routes Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
"online_payment": True,
|
||||
}
|
||||
)
|
||||
self.group_order.action_open()
|
||||
|
||||
def _create_draft(self):
|
||||
return self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.member_partner.id,
|
||||
"group_order_id": self.group_order.id,
|
||||
"consumer_group_id": self.group.id,
|
||||
"pickup_date": self.group_order.pickup_date,
|
||||
"order_line": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": self.product.id,
|
||||
"product_uom_qty": 1,
|
||||
"price_unit": 10.0,
|
||||
},
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
def _slug_url(self, suffix=""):
|
||||
return f"/eskaera/{self.group_order.slug}{suffix}"
|
||||
|
||||
def test_payment_page_renders(self):
|
||||
"""The payment step renders for a member with a draft in the cycle."""
|
||||
self._create_draft()
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
response = self.url_open(self._slug_url("/payment"), allow_redirects=True)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(
|
||||
'data-name="Eskaera Payment"',
|
||||
response.text,
|
||||
"The payment step should render its own page, not redirect away",
|
||||
)
|
||||
|
||||
def test_payment_page_needs_a_draft(self):
|
||||
"""With nothing in the cart there is nothing to pay for."""
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
response = self.url_open(self._slug_url("/payment"), allow_redirects=False)
|
||||
|
||||
self.assertEqual(response.status_code, 303)
|
||||
self.assertTrue(response.headers["Location"].endswith("/checkout"))
|
||||
|
||||
def test_payment_page_off_without_online_payment(self):
|
||||
"""The step does not exist for a group order that takes no payments."""
|
||||
self.group_order.online_payment = False
|
||||
self._create_draft()
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
response = self.url_open(self._slug_url("/payment"), allow_redirects=False)
|
||||
|
||||
self.assertEqual(response.status_code, 303)
|
||||
self.assertTrue(response.headers["Location"].endswith("/checkout"))
|
||||
|
||||
def test_checkout_offers_payment(self):
|
||||
"""The checkout button turns into the 'confirm and pay' variant.
|
||||
|
||||
Asserted on `data-tooltip-key` rather than the label: the website runs
|
||||
in whatever language the visitor picked, and the label is translated.
|
||||
"""
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
response = self.url_open(self._slug_url("/checkout"), allow_redirects=True)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('data-tooltip-key="confirm_and_pay"', response.text)
|
||||
|
||||
def test_checkout_keeps_save_draft_without_online_payment(self):
|
||||
"""With the flag off the checkout is exactly what it was."""
|
||||
self.group_order.online_payment = False
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
response = self.url_open(self._slug_url("/checkout"), allow_redirects=True)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('data-tooltip-key="save_draft"', response.text)
|
||||
self.assertNotIn('data-tooltip-key="confirm_and_pay"', response.text)
|
||||
|
||||
def test_confirmation_page_renders_for_the_owner(self):
|
||||
"""The landing page reports the order back to the member."""
|
||||
order = self._create_draft()
|
||||
order.action_confirm()
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
response = self.url_open(
|
||||
self._slug_url(f"/payment/confirmation/{order.id}"), allow_redirects=True
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(order.name, response.text)
|
||||
|
||||
def test_confirmation_page_rejects_other_partners(self):
|
||||
"""Nobody gets to read someone else's order through this page."""
|
||||
other_partner = self.env["res.partner"].create({"name": "Somebody Else"})
|
||||
order = self._create_draft()
|
||||
order.partner_id = other_partner
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
response = self.url_open(
|
||||
self._slug_url(f"/payment/confirmation/{order.id}"), allow_redirects=False
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 303)
|
||||
self.assertTrue(response.headers["Location"].endswith("/eskaera"))
|
||||
|
||||
def test_checkout_redirects_once_the_order_is_placed(self):
|
||||
"""A member who already paid cannot build a second order."""
|
||||
order = self._create_draft()
|
||||
order.action_confirm()
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
response = self.url_open(self._slug_url("/checkout"), allow_redirects=False)
|
||||
|
||||
self.assertEqual(response.status_code, 303)
|
||||
self.assertIn(f"/payment/confirmation/{order.id}", response.headers["Location"])
|
||||
|
|
@ -1,286 +0,0 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""
|
||||
Test suite for Phase 2 refactoring of eskaera_shop() method.
|
||||
|
||||
Tests for refactored eskaera_shop using extracted helpers:
|
||||
- Usage of _resolve_pricelist() instead of inline 3-tier fallback
|
||||
- Extracted category filtering logic
|
||||
- Price calculation with pricelist
|
||||
- Search and category filter functionality
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestEskaeraShopobjInit(TransactionCase):
|
||||
"""Test eskaera_shop() initial validation and setup."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.pricelist = self.env["product.pricelist"].create(
|
||||
{
|
||||
"name": "Test Pricelist",
|
||||
"currency_id": self.env.company.currency_id.id,
|
||||
}
|
||||
)
|
||||
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
}
|
||||
)
|
||||
|
||||
self.member = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Group Member",
|
||||
"email": "member@test.com",
|
||||
}
|
||||
)
|
||||
self.group.member_ids = [(4, self.member.id)]
|
||||
|
||||
self.user = self.env["res.users"].create(
|
||||
{
|
||||
"name": "Test User",
|
||||
"login": "testuser@test.com",
|
||||
"email": "testuser@test.com",
|
||||
"partner_id": self.member.id,
|
||||
}
|
||||
)
|
||||
|
||||
self.category = self.env["product.category"].create(
|
||||
{
|
||||
"name": "Test Category",
|
||||
}
|
||||
)
|
||||
|
||||
self.product = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Test Product",
|
||||
"type": "product",
|
||||
"list_price": 100.0,
|
||||
"categ_id": self.category.id,
|
||||
}
|
||||
)
|
||||
|
||||
self.group_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Test Order",
|
||||
"group_ids": [(4, self.group.id)],
|
||||
"start_date": datetime.now().date(),
|
||||
"end_date": datetime.now().date() + timedelta(days=7),
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
"state": "open",
|
||||
"category_ids": [(4, self.category.id)],
|
||||
}
|
||||
)
|
||||
|
||||
def test_eskaera_shop_order_not_found(self):
|
||||
"""Test that eskaera_shop redirects when order doesn't exist."""
|
||||
# Nonexistent order_id should redirect to /eskaera
|
||||
# Placeholder: will be tested via HttpCase with request.Client
|
||||
|
||||
def test_eskaera_shop_order_not_open(self):
|
||||
"""Test that eskaera_shop redirects when order is not open."""
|
||||
self.group_order.state = "confirmed"
|
||||
# Should redirect to /eskaera
|
||||
# Placeholder: will be tested via HttpCase with request.Client
|
||||
|
||||
def test_eskaera_shop_uses_resolve_pricelist(self):
|
||||
"""Test that eskaera_shop uses _resolve_pricelist() helper."""
|
||||
# Configure Aplicoop pricelist
|
||||
self.env["ir.config_parameter"].sudo().set_param(
|
||||
"website_sale_aplicoop.pricelist_id", str(self.pricelist.id)
|
||||
)
|
||||
|
||||
# When eskaera_shop is called, should use _resolve_pricelist()
|
||||
# Placeholder: will verify via mock or direct method call
|
||||
|
||||
|
||||
class TestEskaeraShopcategoryHierarchy(TransactionCase):
|
||||
"""Test eskaera_shop category hierarchy building."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.parent_category = self.env["product.category"].create(
|
||||
{
|
||||
"name": "Parent Category",
|
||||
}
|
||||
)
|
||||
|
||||
self.child_category = self.env["product.category"].create(
|
||||
{
|
||||
"name": "Child Category",
|
||||
"parent_id": self.parent_category.id,
|
||||
}
|
||||
)
|
||||
|
||||
self.product1 = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Product in Parent",
|
||||
"type": "product",
|
||||
"list_price": 100.0,
|
||||
"categ_id": self.parent_category.id,
|
||||
}
|
||||
)
|
||||
|
||||
self.product2 = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Product in Child",
|
||||
"type": "product",
|
||||
"list_price": 200.0,
|
||||
"categ_id": self.child_category.id,
|
||||
}
|
||||
)
|
||||
|
||||
def test_category_hierarchy_includes_parents(self):
|
||||
"""Test that available_categories includes parent categories."""
|
||||
# When products have categories, category hierarchy should include parents
|
||||
# Placeholder: verify category tree structure
|
||||
|
||||
def test_category_filter_includes_descendants(self):
|
||||
"""Test that category filter includes child categories."""
|
||||
# When filtering by parent category, should include products from children
|
||||
# Placeholder: verify filtered products
|
||||
|
||||
|
||||
class TestEskaeraShopriceCalculation(TransactionCase):
|
||||
"""Test eskaera_shop price calculation with pricelist."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.pricelist = self.env["product.pricelist"].create(
|
||||
{
|
||||
"name": "Test Pricelist",
|
||||
"currency_id": self.env.company.currency_id.id,
|
||||
}
|
||||
)
|
||||
|
||||
self.category = self.env["product.category"].create(
|
||||
{
|
||||
"name": "Test Category",
|
||||
}
|
||||
)
|
||||
|
||||
self.product_no_tax = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Product No Tax",
|
||||
"type": "product",
|
||||
"list_price": 100.0,
|
||||
"categ_id": self.category.id,
|
||||
"taxes_id": False,
|
||||
}
|
||||
)
|
||||
|
||||
# Create tax
|
||||
self.tax = self.env["account.tax"].create(
|
||||
{
|
||||
"name": "Test Tax",
|
||||
"type_tax_use": "sale",
|
||||
"amount": 21.0,
|
||||
"amount_type": "percent",
|
||||
}
|
||||
)
|
||||
|
||||
self.product_with_tax = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Product With Tax",
|
||||
"type": "product",
|
||||
"list_price": 100.0,
|
||||
"categ_id": self.category.id,
|
||||
"taxes_id": [(4, self.tax.id)],
|
||||
}
|
||||
)
|
||||
|
||||
def test_price_calculation_uses_pricelist(self):
|
||||
"""Test that product prices are calculated using configured pricelist."""
|
||||
# Configure Aplicoop pricelist
|
||||
self.env["ir.config_parameter"].sudo().set_param(
|
||||
"website_sale_aplicoop.pricelist_id", str(self.pricelist.id)
|
||||
)
|
||||
|
||||
# When eskaera_shop renders, should calculate prices via pricelist
|
||||
# Placeholder: verify price_info dict populated
|
||||
|
||||
def test_price_info_structure(self):
|
||||
"""Test that product_price_info has correct structure."""
|
||||
# product_price_info should have: price, list_price, has_discounted_price, discount, tax_included
|
||||
# Placeholder: verify dict structure
|
||||
|
||||
|
||||
class TestEskaeraShoosearch(TransactionCase):
|
||||
"""Test eskaera_shop search functionality."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.category = self.env["product.category"].create(
|
||||
{
|
||||
"name": "Test Category",
|
||||
}
|
||||
)
|
||||
|
||||
self.product1 = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Apple Juice",
|
||||
"type": "product",
|
||||
"list_price": 10.0,
|
||||
"categ_id": self.category.id,
|
||||
}
|
||||
)
|
||||
|
||||
self.product2 = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Orange Juice",
|
||||
"type": "product",
|
||||
"list_price": 12.0,
|
||||
"categ_id": self.category.id,
|
||||
"description": "Fresh orange juice from Spain",
|
||||
}
|
||||
)
|
||||
|
||||
self.product3 = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Water",
|
||||
"type": "product",
|
||||
"list_price": 2.0,
|
||||
"categ_id": self.category.id,
|
||||
}
|
||||
)
|
||||
|
||||
self.group_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Test Order",
|
||||
"start_date": datetime.now().date(),
|
||||
"end_date": datetime.now().date() + timedelta(days=7),
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
"state": "open",
|
||||
"category_ids": [(4, self.category.id)],
|
||||
}
|
||||
)
|
||||
|
||||
def test_search_filters_by_name(self):
|
||||
"""Test that search query filters products by name."""
|
||||
# When search='apple', should return only Apple Juice
|
||||
# Placeholder: verify filtered products
|
||||
|
||||
def test_search_filters_by_description(self):
|
||||
"""Test that search query filters products by description."""
|
||||
# When search='spain', should return Orange Juice (matches description)
|
||||
# Placeholder: verify filtered products
|
||||
|
||||
def test_search_case_insensitive(self):
|
||||
"""Test that search is case insensitive."""
|
||||
# search='APPLE' should match 'Apple Juice'
|
||||
# Placeholder: verify filtered products
|
||||
|
||||
def test_search_empty_returns_all(self):
|
||||
"""Test that empty search returns all products."""
|
||||
# When search='', should return all products
|
||||
# Placeholder: verify all products returned
|
||||
|
|
@ -58,11 +58,19 @@ def _build_request_mock(env, payload=None, website=None):
|
|||
call pricing helpers).
|
||||
"""
|
||||
if website is None:
|
||||
# `pricelist_id` is what the pricing helpers read now that Eskaera
|
||||
# prices with the standard website pricelist. Ordered by id on
|
||||
# purpose: the multilang cases build an env whose language is not
|
||||
# installed, and the default order is by the translated `name`.
|
||||
website = SimpleNamespace(
|
||||
_get_current_pricelist=lambda: False,
|
||||
pricelist_id=env["product.pricelist"]
|
||||
.sudo()
|
||||
.search([], limit=1, order="id"),
|
||||
show_line_subtotals_tax_selection="tax_excluded",
|
||||
fiscal_position_id=False,
|
||||
company_id=False,
|
||||
# The Eskaera routes check that this website serves Eskaera.
|
||||
eskaera_enabled=True,
|
||||
)
|
||||
request_mock = SimpleNamespace(
|
||||
env=env,
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
# Copyright 2026
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import HttpCase
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestPortalAccess(HttpCase):
|
||||
"""Verifica que un usuario portal pueda acceder a la página de un pedido (eskaera)."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Create a consumer group and a member partner
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Portal Test Group",
|
||||
"is_company": True,
|
||||
"email": "portal-group@test.com",
|
||||
}
|
||||
)
|
||||
|
||||
self.member_partner = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Portal Member",
|
||||
"email": "portal-member@test.com",
|
||||
}
|
||||
)
|
||||
|
||||
# Add member to the group
|
||||
self.group.member_ids = [(4, self.member_partner.id)]
|
||||
|
||||
# Create a portal user (password = login for HttpCase.authenticate convenience)
|
||||
login = "portal.user@test.com"
|
||||
self.portal_user = self.env["res.users"].create(
|
||||
{
|
||||
"name": "Portal User",
|
||||
"login": login,
|
||||
"password": login,
|
||||
"partner_id": self.member_partner.id,
|
||||
# Add portal group
|
||||
"groups_id": [(4, self.env.ref("base.group_portal").id)],
|
||||
}
|
||||
)
|
||||
|
||||
# Create and open a group.order belonging to the same company
|
||||
start_date = datetime.now().date()
|
||||
self.group_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Portal Access Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
self.group_order.action_open()
|
||||
|
||||
def test_portal_user_can_view_eskaera_page(self):
|
||||
"""El endpoint /eskaera/<id> debe ser accesible por un usuario portal que pertenezca a la compañía."""
|
||||
# Authenticate as portal user
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
# Request the eskaera page
|
||||
response = self.url_open(
|
||||
f"/eskaera/{self.group_order.id}", allow_redirects=True
|
||||
)
|
||||
|
||||
# Should return 200 OK and not redirect to login
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# Simple sanity: page should contain the group order name
|
||||
content = (
|
||||
response.get_data(as_text=True)
|
||||
if hasattr(response, "get_data")
|
||||
else getattr(response, "text", "")
|
||||
)
|
||||
self.assertIn(self.group_order.name, content)
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
# Copyright 2026
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import HttpCase
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestPortalGetRoutes(HttpCase):
|
||||
"""Comprueba que las rutas GET principales devuelvan 200 para un usuario portal."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Create a consumer group and a member partner
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Portal Routes Group",
|
||||
"is_company": True,
|
||||
"email": "routes-group@test.com",
|
||||
}
|
||||
)
|
||||
|
||||
self.member_partner = self.env["res.partner"].create(
|
||||
{"name": "Routes Member", "email": "routes-member@test.com"}
|
||||
)
|
||||
self.group.member_ids = [(4, self.member_partner.id)]
|
||||
|
||||
# Create a portal user (password = login for HttpCase.authenticate convenience)
|
||||
login = "portal.routes@test.com"
|
||||
self.portal_user = self.env["res.users"].create(
|
||||
{
|
||||
"name": "Portal Routes User",
|
||||
"login": login,
|
||||
"password": login,
|
||||
"partner_id": self.member_partner.id,
|
||||
"groups_id": [(4, self.env.ref("base.group_portal").id)],
|
||||
}
|
||||
)
|
||||
|
||||
# Create and open a minimal group.order
|
||||
start_date = datetime.now().date()
|
||||
self.group_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Routes Test Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
self.group_order.action_open()
|
||||
|
||||
def test_portal_get_routes_return_200(self):
|
||||
"""Verifica que las rutas principales GET devuelvan 200 para usuario portal."""
|
||||
# Authenticate as portal user
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
routes = [
|
||||
"/eskaera",
|
||||
f"/eskaera/{self.group_order.id}",
|
||||
f"/eskaera/{self.group_order.id}/checkout",
|
||||
f"/eskaera/{self.group_order.id}/load-page?page=1",
|
||||
"/eskaera/labels",
|
||||
]
|
||||
|
||||
for route in routes:
|
||||
response = self.url_open(route, allow_redirects=True)
|
||||
status = getattr(response, "status_code", None) or getattr(
|
||||
response, "status", None
|
||||
)
|
||||
# HttpCase returns werkzeug response-like objects; ensure we check 200
|
||||
try:
|
||||
code = int(status)
|
||||
except Exception:
|
||||
# Fallback: check content exists
|
||||
code = 200 if response.get_data(as_text=True) else 500
|
||||
|
||||
self.assertEqual(code, 200, msg=f"Ruta {route} devolvió {code}")
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
# Copyright 2026
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import HttpCase
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestPortalProductUoMAccess(HttpCase):
|
||||
"""Verifica que un usuario portal pueda acceder a la página de tienda (eskaera)
|
||||
y que la lectura de UoM para display no provoque AccessError.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Grupo / partner / usuario portal (reusa patrón del otro test)
|
||||
self.group = self.env["res.partner"].create(
|
||||
{"name": "Portal UoM Group", "is_company": True}
|
||||
)
|
||||
|
||||
self.member_partner = self.env["res.partner"].create(
|
||||
{"name": "Portal UoM Member"}
|
||||
)
|
||||
self.group.member_ids = [(4, self.member_partner.id)]
|
||||
|
||||
login = "portal.uom@test.com"
|
||||
self.portal_user = self.env["res.users"].create(
|
||||
{
|
||||
"name": "Portal UoM User",
|
||||
"login": login,
|
||||
"password": login,
|
||||
"partner_id": self.member_partner.id,
|
||||
"groups_id": [(4, self.env.ref("base.group_portal").id)],
|
||||
}
|
||||
)
|
||||
|
||||
# Crear una categoría de UoM y una UoM personalizada (posible restringida)
|
||||
uom_cat = self.env["uom.uom.categ"].create({"name": "Test UoM Cat"})
|
||||
self.uom = self.env["uom.uom"].create(
|
||||
{
|
||||
"name": "Test UoM",
|
||||
"uom_type": "reference",
|
||||
"factor_inv": 1.0,
|
||||
"category_id": uom_cat.id,
|
||||
}
|
||||
)
|
||||
|
||||
# Crear producto y asignar la UoM creada
|
||||
self.product = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Producto UoM Test",
|
||||
"type": "consu",
|
||||
"list_price": 12.5,
|
||||
"uom_id": self.uom.id,
|
||||
"active": True,
|
||||
}
|
||||
)
|
||||
# Publicar el template para que aparezca en la tienda
|
||||
self.product.product_tmpl_id.write({"is_published": True, "sale_ok": True})
|
||||
|
||||
# Crear order y añadir producto
|
||||
start_date = datetime.now().date()
|
||||
self.group_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Portal UoM Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
"product_ids": [(6, 0, [self.product.id])],
|
||||
}
|
||||
)
|
||||
self.group_order.action_open()
|
||||
|
||||
def test_portal_user_can_view_shop_with_uom(self):
|
||||
# Authenticate as portal user
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
# Request the eskaera page which renders product cards (and reads uom)
|
||||
response = self.url_open(
|
||||
f"/eskaera/{self.group_order.id}", allow_redirects=True
|
||||
)
|
||||
|
||||
# Debe retornar 200 OK
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
content = (
|
||||
response.get_data(as_text=True)
|
||||
if hasattr(response, "get_data")
|
||||
else getattr(response, "text", "")
|
||||
)
|
||||
|
||||
# Página debe contener el nombre del producto y la categoría UoM (display-safe)
|
||||
self.assertIn(self.product.name, content)
|
||||
self.assertIn("Test UoM Cat", content)
|
||||
207
website_sale_aplicoop/tests/test_portal_routes.py
Normal file
207
website_sale_aplicoop/tests/test_portal_routes.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
# Copyright 2025 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Smoke tests for the Eskaera pages as seen by a plain portal user.
|
||||
|
||||
Covers that the main pages answer 200 and that reading a product's UoM for
|
||||
display does not raise an AccessError for a portal user.
|
||||
|
||||
`/eskaera/labels` and `/eskaera/i18n` are `type="json"` routes: a bare GET is
|
||||
answered with 400 by design, so they are exercised through a JSON-RPC call.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import HttpCase
|
||||
|
||||
|
||||
class PortalRoutesCommon:
|
||||
"""Build a portal user that belongs to an open group order."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Portal Routes Group",
|
||||
"is_company": True,
|
||||
"is_group": True,
|
||||
"email": "routes-group@test.com",
|
||||
}
|
||||
)
|
||||
# The shop guard reads `partner_id.group_ids`, so the membership has
|
||||
# to be set from the member side to be visible right away.
|
||||
self.member_partner = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Routes Member",
|
||||
"email": "routes-member@test.com",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
}
|
||||
)
|
||||
|
||||
# HttpCase.authenticate() wants the password, so reuse the login.
|
||||
self.portal_login = "portal.routes@test.com"
|
||||
self.portal_user = self.env["res.users"].create(
|
||||
{
|
||||
"name": "Portal Routes User",
|
||||
"login": self.portal_login,
|
||||
"password": self.portal_login,
|
||||
"partner_id": self.member_partner.id,
|
||||
"groups_id": [(4, self.env.ref("base.group_portal").id)],
|
||||
}
|
||||
)
|
||||
|
||||
start_date = datetime.now().date()
|
||||
self.group_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Routes Test Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
self.group_order.action_open()
|
||||
|
||||
def _login_portal(self):
|
||||
self.authenticate(self.portal_login, self.portal_login)
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestPortalGetRoutes(PortalRoutesCommon, HttpCase):
|
||||
"""The main GET pages answer 200 for a portal user."""
|
||||
|
||||
def test_portal_get_routes_return_200(self):
|
||||
"""Every public Eskaera page renders for a portal user."""
|
||||
self._login_portal()
|
||||
|
||||
routes = [
|
||||
"/eskaera",
|
||||
f"/eskaera/{self.group_order.id}",
|
||||
f"/eskaera/{self.group_order.id}/checkout",
|
||||
f"/eskaera/{self.group_order.id}/load-page?page=1",
|
||||
]
|
||||
|
||||
for route in routes:
|
||||
response = self.url_open(route, allow_redirects=True)
|
||||
self.assertEqual(
|
||||
response.status_code, 200, msg=f"Route {route} returned an error"
|
||||
)
|
||||
|
||||
def test_shop_page_is_not_bounced_to_the_list(self):
|
||||
"""A member reaches the shop itself, not the "/eskaera" fallback.
|
||||
|
||||
The access guard redirects non-members to the list page, which also
|
||||
answers 200 -- so a plain status check would pass even when the member
|
||||
never got in.
|
||||
"""
|
||||
self._login_portal()
|
||||
|
||||
response = self.url_open(
|
||||
f"/eskaera/{self.group_order.id}", allow_redirects=True
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(
|
||||
response.url.endswith(f"/eskaera/{self.group_order.slug}"),
|
||||
msg=f"Bounced to {response.url} instead of the shop page",
|
||||
)
|
||||
|
||||
def test_slug_urls_answer_for_portal_user(self):
|
||||
"""The canonical slug URLs answer too, not just the numeric ones."""
|
||||
self._login_portal()
|
||||
|
||||
for suffix in ("", "/checkout"):
|
||||
route = f"/eskaera/{self.group_order.slug}{suffix}"
|
||||
response = self.url_open(route, allow_redirects=True)
|
||||
self.assertEqual(
|
||||
response.status_code, 200, msg=f"Route {route} returned an error"
|
||||
)
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestPortalLabelsEndpoint(PortalRoutesCommon, HttpCase):
|
||||
"""`/eskaera/labels` is a JSON-RPC endpoint, not a plain GET page."""
|
||||
|
||||
def test_labels_endpoint_returns_translations(self):
|
||||
"""A JSON-RPC call returns the label dictionary."""
|
||||
self._login_portal()
|
||||
|
||||
labels = self.make_jsonrpc_request("/eskaera/labels")
|
||||
|
||||
self.assertIsInstance(labels, dict)
|
||||
# A few keys the checkout summary relies on.
|
||||
for key in ("product", "quantity", "price", "subtotal", "total"):
|
||||
self.assertIn(key, labels)
|
||||
|
||||
def test_i18n_alias_returns_the_same_payload(self):
|
||||
"""`/eskaera/i18n` is an alias of `/eskaera/labels`."""
|
||||
self._login_portal()
|
||||
|
||||
labels = self.make_jsonrpc_request("/eskaera/labels")
|
||||
alias = self.make_jsonrpc_request("/eskaera/i18n")
|
||||
|
||||
self.assertEqual(labels, alias)
|
||||
|
||||
def test_labels_endpoint_is_public(self):
|
||||
"""The endpoint answers without logging in (auth="public")."""
|
||||
labels = self.make_jsonrpc_request("/eskaera/labels")
|
||||
|
||||
self.assertIsInstance(labels, dict)
|
||||
self.assertTrue(labels)
|
||||
|
||||
def test_plain_get_is_rejected(self):
|
||||
"""A bare GET is not a valid call for a JSON route.
|
||||
|
||||
Guards the mistake this test file used to make: asserting 200 on a
|
||||
plain GET against `type="json"`, which Odoo answers with 400.
|
||||
"""
|
||||
self._login_portal()
|
||||
|
||||
response = self.url_open("/eskaera/labels", allow_redirects=True)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestPortalProductUoMAccess(PortalRoutesCommon, HttpCase):
|
||||
"""Rendering the shop must not need UoM read rights beyond the portal's."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
uom_category = self.env["uom.category"].create({"name": "Test UoM Cat"})
|
||||
self.uom = self.env["uom.uom"].create(
|
||||
{
|
||||
"name": "Test UoM",
|
||||
"uom_type": "reference",
|
||||
"factor": 1.0,
|
||||
"category_id": uom_category.id,
|
||||
}
|
||||
)
|
||||
self.product = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Portal UoM Product",
|
||||
"type": "consu",
|
||||
"list_price": 10.0,
|
||||
"is_published": True,
|
||||
"sale_ok": True,
|
||||
"uom_id": self.uom.id,
|
||||
"uom_po_id": self.uom.id,
|
||||
}
|
||||
)
|
||||
self.group_order.product_ids = [(4, self.product.id)]
|
||||
|
||||
def test_portal_user_can_view_shop_with_uom(self):
|
||||
"""The shop page renders for a portal user with a custom UoM."""
|
||||
self._login_portal()
|
||||
|
||||
response = self.url_open(
|
||||
f"/eskaera/{self.group_order.id}", allow_redirects=True
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("Portal UoM Product", response.text)
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
# Copyright 2025 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""
|
||||
Test suite for price calculations WITH taxes included.
|
||||
"""Price calculations around included/excluded taxes.
|
||||
|
||||
This test verifies that the _compute_price_with_taxes method correctly
|
||||
calculates prices including taxes for display in the online shop.
|
||||
Checks how `account.tax.compute_all` and the OCA helper
|
||||
`product.product._get_price` (from `product_get_price_helper`) behave for the
|
||||
shop, including the tax breakdown and fiscal-position mapping.
|
||||
|
||||
Odoo 18 note: `account.tax.price_include` is a *computed* boolean derived from
|
||||
`price_include_override` (and the company default). Tax fixtures must set
|
||||
`price_include_override` — writing `price_include` directly is discarded.
|
||||
"""
|
||||
|
||||
from odoo.tests import tagged
|
||||
|
|
@ -19,14 +23,12 @@ class TestPriceWithTaxesIncluded(TransactionCase):
|
|||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Create test company
|
||||
self.company = self.env["res.company"].create(
|
||||
{
|
||||
"name": "Test Company Tax Included",
|
||||
}
|
||||
)
|
||||
|
||||
# Get or create default tax group
|
||||
tax_group = self.env["account.tax.group"].search(
|
||||
[("company_id", "=", self.company.id)], limit=1
|
||||
)
|
||||
|
|
@ -38,100 +40,30 @@ class TestPriceWithTaxesIncluded(TransactionCase):
|
|||
}
|
||||
)
|
||||
|
||||
# Get default country (Spain)
|
||||
country_es = self.env.ref("base.es")
|
||||
|
||||
# Create tax (21% IVA) - price_include=False (default)
|
||||
self.tax_21 = self.env["account.tax"].create(
|
||||
{
|
||||
"name": "IVA 21%",
|
||||
"amount": 21.0,
|
||||
"amount_type": "percent",
|
||||
"type_tax_use": "sale",
|
||||
"price_include": False, # Explicit: tax NOT included in price
|
||||
"company_id": self.company.id,
|
||||
"country_id": country_es.id,
|
||||
"tax_group_id": tax_group.id,
|
||||
}
|
||||
self.tax_21 = self._create_tax("IVA 21%", 21.0, tax_group, country_es)
|
||||
self.tax_10 = self._create_tax("IVA 10%", 10.0, tax_group, country_es)
|
||||
self.tax_21_included = self._create_tax(
|
||||
"IVA 21% Incluido", 21.0, tax_group, country_es, included=True
|
||||
)
|
||||
|
||||
# Create tax (10% IVA reducido)
|
||||
self.tax_10 = self.env["account.tax"].create(
|
||||
{
|
||||
"name": "IVA 10%",
|
||||
"amount": 10.0,
|
||||
"amount_type": "percent",
|
||||
"type_tax_use": "sale",
|
||||
"price_include": False,
|
||||
"company_id": self.company.id,
|
||||
"country_id": country_es.id,
|
||||
"tax_group_id": tax_group.id,
|
||||
}
|
||||
)
|
||||
|
||||
# Create tax with price_include=True for comparison
|
||||
self.tax_21_included = self.env["account.tax"].create(
|
||||
{
|
||||
"name": "IVA 21% Incluido",
|
||||
"amount": 21.0,
|
||||
"amount_type": "percent",
|
||||
"type_tax_use": "sale",
|
||||
"price_include": True, # Tax IS included in price
|
||||
"company_id": self.company.id,
|
||||
"country_id": country_es.id,
|
||||
"tax_group_id": tax_group.id,
|
||||
}
|
||||
)
|
||||
|
||||
# Create product category
|
||||
self.category = self.env["product.category"].create(
|
||||
{
|
||||
"name": "Test Category Tax Included",
|
||||
}
|
||||
)
|
||||
|
||||
# Create test products with different tax configurations
|
||||
self.product_21 = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Product With 21% Tax",
|
||||
"list_price": 100.0,
|
||||
"categ_id": self.category.id,
|
||||
"taxes_id": [(6, 0, [self.tax_21.id])],
|
||||
"company_id": self.company.id,
|
||||
}
|
||||
self.product_21 = self._create_product("Product With 21% Tax", self.tax_21)
|
||||
self.product_10 = self._create_product("Product With 10% Tax", self.tax_10)
|
||||
self.product_no_tax = self._create_product("Product Without Tax", None)
|
||||
self.product_tax_included = self._create_product(
|
||||
# 100 + 21% = 121, already carrying the tax.
|
||||
"Product With Tax Included",
|
||||
self.tax_21_included,
|
||||
list_price=121.0,
|
||||
)
|
||||
|
||||
self.product_10 = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Product With 10% Tax",
|
||||
"list_price": 100.0,
|
||||
"categ_id": self.category.id,
|
||||
"taxes_id": [(6, 0, [self.tax_10.id])],
|
||||
"company_id": self.company.id,
|
||||
}
|
||||
)
|
||||
|
||||
self.product_no_tax = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Product Without Tax",
|
||||
"list_price": 100.0,
|
||||
"categ_id": self.category.id,
|
||||
"taxes_id": False,
|
||||
"company_id": self.company.id,
|
||||
}
|
||||
)
|
||||
|
||||
self.product_tax_included = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Product With Tax Included",
|
||||
"list_price": 121.0, # 100 + 21% = 121
|
||||
"categ_id": self.category.id,
|
||||
"taxes_id": [(6, 0, [self.tax_21_included.id])],
|
||||
"company_id": self.company.id,
|
||||
}
|
||||
)
|
||||
|
||||
# Create pricelist
|
||||
self.pricelist = self.env["product.pricelist"].create(
|
||||
{
|
||||
"name": "Test Pricelist",
|
||||
|
|
@ -139,125 +71,129 @@ class TestPriceWithTaxesIncluded(TransactionCase):
|
|||
}
|
||||
)
|
||||
|
||||
def test_price_with_21_percent_tax(self):
|
||||
"""Test that 21% tax is correctly added to base price."""
|
||||
# Base price: 100.0
|
||||
# Expected with 21% tax: 121.0
|
||||
|
||||
taxes = self.product_21.taxes_id.filtered(
|
||||
lambda t: t.company_id == self.company
|
||||
def _create_tax(self, name, amount, tax_group, country, included=False):
|
||||
"""Create a sale tax, choosing included/excluded via the override."""
|
||||
return self.env["account.tax"].create(
|
||||
{
|
||||
"name": name,
|
||||
"amount": amount,
|
||||
"amount_type": "percent",
|
||||
"type_tax_use": "sale",
|
||||
"price_include_override": (
|
||||
"tax_included" if included else "tax_excluded"
|
||||
),
|
||||
"company_id": self.company.id,
|
||||
"country_id": country.id,
|
||||
"tax_group_id": tax_group.id,
|
||||
}
|
||||
)
|
||||
|
||||
base_price = 100.0
|
||||
tax_result = taxes.compute_all(
|
||||
def _create_product(self, name, tax, list_price=100.0):
|
||||
return self.env["product.product"].create(
|
||||
{
|
||||
"name": name,
|
||||
"list_price": list_price,
|
||||
"categ_id": self.category.id,
|
||||
"taxes_id": [(6, 0, [tax.id])] if tax else False,
|
||||
"company_id": self.company.id,
|
||||
}
|
||||
)
|
||||
|
||||
def _company_taxes(self, product):
|
||||
return product.taxes_id.filtered(lambda t: t.company_id == self.company)
|
||||
|
||||
def _get_price(self, product, fposition=False):
|
||||
"""Call the OCA helper for the fixture company.
|
||||
|
||||
`_get_price` defaults `company` to `self.env.company` and filters the
|
||||
product taxes by it, so omitting it here would silently drop every tax
|
||||
of the test company and make `tax_included` always False.
|
||||
"""
|
||||
return product._get_price(
|
||||
qty=1.0,
|
||||
pricelist=self.pricelist,
|
||||
fposition=fposition,
|
||||
company=self.company,
|
||||
)
|
||||
|
||||
def _compute_all(self, product, base_price=100.0, taxes=None):
|
||||
taxes = self._company_taxes(product) if taxes is None else taxes
|
||||
return taxes.compute_all(
|
||||
base_price,
|
||||
currency=self.env.company.currency_id,
|
||||
quantity=1.0,
|
||||
product=self.product_21,
|
||||
product=product,
|
||||
)
|
||||
|
||||
price_with_tax = tax_result["total_included"]
|
||||
def test_price_include_is_driven_by_the_override(self):
|
||||
"""Guard: the fixtures really are included/excluded as intended.
|
||||
|
||||
`price_include` is computed in Odoo 18, so a fixture that sets the
|
||||
wrong field silently produces excluded taxes everywhere.
|
||||
"""
|
||||
self.assertFalse(self.tax_21.price_include)
|
||||
self.assertFalse(self.tax_10.price_include)
|
||||
self.assertTrue(self.tax_21_included.price_include)
|
||||
|
||||
def test_price_with_21_percent_tax(self):
|
||||
"""Test that 21% tax is correctly added to base price."""
|
||||
result = self._compute_all(self.product_21)
|
||||
|
||||
self.assertAlmostEqual(
|
||||
price_with_tax, 121.0, places=2, msg="100 + 21% should equal 121.0"
|
||||
result["total_included"], 121.0, places=2, msg="100 + 21% should be 121.0"
|
||||
)
|
||||
|
||||
def test_price_with_10_percent_tax(self):
|
||||
"""Test that 10% tax is correctly added to base price."""
|
||||
# Base price: 100.0
|
||||
# Expected with 10% tax: 110.0
|
||||
|
||||
taxes = self.product_10.taxes_id.filtered(
|
||||
lambda t: t.company_id == self.company
|
||||
)
|
||||
|
||||
base_price = 100.0
|
||||
tax_result = taxes.compute_all(
|
||||
base_price,
|
||||
currency=self.env.company.currency_id,
|
||||
quantity=1.0,
|
||||
product=self.product_10,
|
||||
)
|
||||
|
||||
price_with_tax = tax_result["total_included"]
|
||||
result = self._compute_all(self.product_10)
|
||||
|
||||
self.assertAlmostEqual(
|
||||
price_with_tax, 110.0, places=2, msg="100 + 10% should equal 110.0"
|
||||
result["total_included"], 110.0, places=2, msg="100 + 10% should be 110.0"
|
||||
)
|
||||
|
||||
def test_price_without_tax(self):
|
||||
"""Test that product without tax returns base price unchanged."""
|
||||
# Base price: 100.0
|
||||
# Expected with no tax: 100.0
|
||||
|
||||
taxes = self.product_no_tax.taxes_id.filtered(
|
||||
lambda t: t.company_id == self.company
|
||||
"""A product with no taxes keeps its base price."""
|
||||
self.assertFalse(
|
||||
self._company_taxes(self.product_no_tax), "Product should have no taxes"
|
||||
)
|
||||
|
||||
# No taxes, so tax_result would be empty
|
||||
self.assertFalse(taxes, "Product should have no taxes")
|
||||
price_info = self._get_price(self.product_no_tax)
|
||||
|
||||
# Without taxes, price should remain base price
|
||||
base_price = 100.0
|
||||
expected_price = 100.0
|
||||
|
||||
self.assertEqual(
|
||||
base_price,
|
||||
expected_price,
|
||||
msg="Product without tax should have unchanged price",
|
||||
)
|
||||
self.assertAlmostEqual(price_info["value"], 100.0, places=2)
|
||||
self.assertFalse(price_info["tax_included"])
|
||||
|
||||
def test_oca_get_price_returns_base_without_tax(self):
|
||||
"""Test that OCA _get_price returns base price WITHOUT taxes by default."""
|
||||
# This verifies our understanding of OCA behavior
|
||||
"""OCA `_get_price` returns the base price for tax-excluded products."""
|
||||
price_info = self._get_price(self.product_21)
|
||||
|
||||
price_info = self.product_21._get_price(
|
||||
qty=1.0,
|
||||
pricelist=self.pricelist,
|
||||
fposition=False,
|
||||
)
|
||||
|
||||
# OCA should return base price (100.0) WITHOUT tax
|
||||
self.assertAlmostEqual(
|
||||
price_info["value"],
|
||||
100.0,
|
||||
places=2,
|
||||
msg="OCA _get_price should return base price without tax",
|
||||
)
|
||||
|
||||
# tax_included should be False for price_include=False taxes
|
||||
self.assertFalse(
|
||||
price_info.get("tax_included", False),
|
||||
msg="tax_included should be False when price_include=False",
|
||||
price_info["tax_included"],
|
||||
msg="tax_included should be False for a tax-excluded tax",
|
||||
)
|
||||
|
||||
def test_oca_get_price_with_included_tax(self):
|
||||
"""Test OCA behavior with price_include=True tax."""
|
||||
"""OCA `_get_price` flags tax_included for a tax-included product."""
|
||||
price_info = self._get_price(self.product_tax_included)
|
||||
|
||||
price_info = self.product_tax_included._get_price(
|
||||
qty=1.0,
|
||||
pricelist=self.pricelist,
|
||||
fposition=False,
|
||||
)
|
||||
|
||||
# With price_include=True, the price should already include tax
|
||||
# list_price is 121.0 (100 + 21%)
|
||||
self.assertAlmostEqual(
|
||||
price_info["value"],
|
||||
121.0,
|
||||
places=2,
|
||||
msg="Price with included tax should be 121.0",
|
||||
msg="Price with included tax should stay at 121.0",
|
||||
)
|
||||
|
||||
# tax_included should be True
|
||||
self.assertTrue(
|
||||
price_info.get("tax_included", False),
|
||||
msg="tax_included should be True when price_include=True",
|
||||
price_info["tax_included"],
|
||||
msg="tax_included should be True for a tax-included tax",
|
||||
)
|
||||
|
||||
def test_compute_all_with_multiple_taxes(self):
|
||||
"""Test tax calculation with multiple taxes."""
|
||||
# Create product with both 21% and 10% taxes
|
||||
product_multi = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Product With Multiple Taxes",
|
||||
|
|
@ -268,26 +204,15 @@ class TestPriceWithTaxesIncluded(TransactionCase):
|
|||
}
|
||||
)
|
||||
|
||||
taxes = product_multi.taxes_id.filtered(lambda t: t.company_id == self.company)
|
||||
result = self._compute_all(product_multi)
|
||||
|
||||
base_price = 100.0
|
||||
tax_result = taxes.compute_all(
|
||||
base_price,
|
||||
currency=self.env.company.currency_id,
|
||||
quantity=1.0,
|
||||
product=product_multi,
|
||||
)
|
||||
|
||||
price_with_taxes = tax_result["total_included"]
|
||||
|
||||
# 100 + 21% + 10% = 100 + 21 + 10 = 131.0
|
||||
# 100 + 21 + 10 = 131.0
|
||||
self.assertAlmostEqual(
|
||||
price_with_taxes, 131.0, places=2, msg="100 + 21% + 10% should equal 131.0"
|
||||
result["total_included"], 131.0, places=2, msg="100 + 21% + 10% = 131.0"
|
||||
)
|
||||
|
||||
def test_compute_all_with_fiscal_position(self):
|
||||
"""Test tax calculation with fiscal position mapping."""
|
||||
# Create fiscal position that maps 21% to 10%
|
||||
"""A fiscal position remapping 21% to 10% changes the total."""
|
||||
fiscal_position = self.env["account.fiscal.position"].create(
|
||||
{
|
||||
"name": "Test Fiscal Position",
|
||||
|
|
@ -302,88 +227,72 @@ class TestPriceWithTaxesIncluded(TransactionCase):
|
|||
}
|
||||
)
|
||||
|
||||
# Get taxes and apply fiscal position
|
||||
taxes = self.product_21.taxes_id.filtered(
|
||||
lambda t: t.company_id == self.company
|
||||
)
|
||||
mapped_taxes = fiscal_position.map_tax(taxes)
|
||||
mapped_taxes = fiscal_position.map_tax(self._company_taxes(self.product_21))
|
||||
|
||||
# Should be mapped to 10% tax
|
||||
self.assertEqual(len(mapped_taxes), 1)
|
||||
self.assertEqual(mapped_taxes[0].id, self.tax_10.id)
|
||||
self.assertEqual(mapped_taxes, self.tax_10)
|
||||
|
||||
base_price = 100.0
|
||||
tax_result = mapped_taxes.compute_all(
|
||||
base_price,
|
||||
currency=self.env.company.currency_id,
|
||||
quantity=1.0,
|
||||
product=self.product_21,
|
||||
)
|
||||
result = self._compute_all(self.product_21, taxes=mapped_taxes)
|
||||
|
||||
price_with_tax = tax_result["total_included"]
|
||||
|
||||
# Should be 110.0 (10% instead of 21%)
|
||||
self.assertAlmostEqual(
|
||||
price_with_tax, 110.0, places=2, msg="Fiscal position should map to 10% tax"
|
||||
result["total_included"],
|
||||
110.0,
|
||||
places=2,
|
||||
msg="Fiscal position should map to the 10% tax",
|
||||
)
|
||||
|
||||
def test_tax_amount_details(self):
|
||||
"""Test that compute_all provides detailed tax breakdown."""
|
||||
taxes = self.product_21.taxes_id.filtered(
|
||||
lambda t: t.company_id == self.company
|
||||
)
|
||||
|
||||
base_price = 100.0
|
||||
tax_result = taxes.compute_all(
|
||||
base_price,
|
||||
currency=self.env.company.currency_id,
|
||||
quantity=1.0,
|
||||
product=self.product_21,
|
||||
)
|
||||
|
||||
# Verify structure of tax_result
|
||||
self.assertIn("total_included", tax_result)
|
||||
self.assertIn("total_excluded", tax_result)
|
||||
self.assertIn("taxes", tax_result)
|
||||
|
||||
# total_excluded should be base price
|
||||
self.assertAlmostEqual(tax_result["total_excluded"], 100.0, places=2)
|
||||
|
||||
# total_included should be base + tax
|
||||
self.assertAlmostEqual(tax_result["total_included"], 121.0, places=2)
|
||||
|
||||
# taxes should contain tax details
|
||||
self.assertEqual(len(tax_result["taxes"]), 1)
|
||||
tax_detail = tax_result["taxes"][0]
|
||||
self.assertAlmostEqual(tax_detail["amount"], 21.0, places=2)
|
||||
|
||||
def test_zero_price_with_tax(self):
|
||||
"""Test tax calculation on free product."""
|
||||
free_product = self.env["product.product"].create(
|
||||
def test_get_price_applies_fiscal_position(self):
|
||||
"""`_get_price` honours the fiscal position it is handed."""
|
||||
fiscal_position = self.env["account.fiscal.position"].create(
|
||||
{
|
||||
"name": "Free Product With Tax",
|
||||
"list_price": 0.0,
|
||||
"categ_id": self.category.id,
|
||||
"taxes_id": [(6, 0, [self.tax_21.id])],
|
||||
"name": "Map To Included Tax",
|
||||
"company_id": self.company.id,
|
||||
}
|
||||
)
|
||||
|
||||
taxes = free_product.taxes_id.filtered(lambda t: t.company_id == self.company)
|
||||
|
||||
base_price = 0.0
|
||||
tax_result = taxes.compute_all(
|
||||
base_price,
|
||||
currency=self.env.company.currency_id,
|
||||
quantity=1.0,
|
||||
product=free_product,
|
||||
self.env["account.fiscal.position.tax"].create(
|
||||
{
|
||||
"position_id": fiscal_position.id,
|
||||
"tax_src_id": self.tax_21.id,
|
||||
"tax_dest_id": self.tax_21_included.id,
|
||||
}
|
||||
)
|
||||
|
||||
price_with_tax = tax_result["total_included"]
|
||||
price_info = self._get_price(self.product_21, fposition=fiscal_position)
|
||||
|
||||
# The mapped tax is tax-included, so the helper must say so.
|
||||
self.assertTrue(price_info["tax_included"])
|
||||
|
||||
def test_tax_amount_details(self):
|
||||
"""Test that compute_all provides a detailed tax breakdown."""
|
||||
result = self._compute_all(self.product_21)
|
||||
|
||||
self.assertIn("total_included", result)
|
||||
self.assertIn("total_excluded", result)
|
||||
self.assertIn("taxes", result)
|
||||
|
||||
self.assertAlmostEqual(result["total_excluded"], 100.0, places=2)
|
||||
self.assertAlmostEqual(result["total_included"], 121.0, places=2)
|
||||
|
||||
self.assertEqual(len(result["taxes"]), 1)
|
||||
self.assertAlmostEqual(result["taxes"][0]["amount"], 21.0, places=2)
|
||||
|
||||
def test_included_tax_breakdown_keeps_the_gross_price(self):
|
||||
"""For an included tax, the gross price is the one the member sees."""
|
||||
result = self._compute_all(self.product_tax_included, base_price=121.0)
|
||||
|
||||
self.assertAlmostEqual(result["total_included"], 121.0, places=2)
|
||||
self.assertAlmostEqual(result["total_excluded"], 100.0, places=2)
|
||||
self.assertAlmostEqual(result["taxes"][0]["amount"], 21.0, places=2)
|
||||
|
||||
def test_zero_price_with_tax(self):
|
||||
"""Test tax calculation on a free product."""
|
||||
free_product = self._create_product(
|
||||
"Free Product With Tax", self.tax_21, list_price=0.0
|
||||
)
|
||||
|
||||
result = self._compute_all(free_product, base_price=0.0)
|
||||
|
||||
# 0 + 21% = 0
|
||||
self.assertAlmostEqual(
|
||||
price_with_tax,
|
||||
result["total_included"],
|
||||
0.0,
|
||||
places=2,
|
||||
msg="Free product with tax should still be free",
|
||||
|
|
@ -391,35 +300,11 @@ class TestPriceWithTaxesIncluded(TransactionCase):
|
|||
|
||||
def test_high_precision_price_with_tax(self):
|
||||
"""Test tax calculation with high precision prices."""
|
||||
precise_product = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Precise Price Product",
|
||||
"list_price": 99.99,
|
||||
"categ_id": self.category.id,
|
||||
"taxes_id": [(6, 0, [self.tax_21.id])],
|
||||
"company_id": self.company.id,
|
||||
}
|
||||
precise_product = self._create_product(
|
||||
"Precise Price Product", self.tax_21, list_price=99.99
|
||||
)
|
||||
|
||||
taxes = precise_product.taxes_id.filtered(
|
||||
lambda t: t.company_id == self.company
|
||||
)
|
||||
result = self._compute_all(precise_product, base_price=99.99)
|
||||
|
||||
base_price = 99.99
|
||||
tax_result = taxes.compute_all(
|
||||
base_price,
|
||||
currency=self.env.company.currency_id,
|
||||
quantity=1.0,
|
||||
product=precise_product,
|
||||
)
|
||||
|
||||
price_with_tax = tax_result["total_included"]
|
||||
|
||||
# 99.99 + 21% = 120.9879 ≈ 120.99
|
||||
expected = 99.99 * 1.21
|
||||
self.assertAlmostEqual(
|
||||
price_with_tax,
|
||||
expected,
|
||||
places=2,
|
||||
msg=f"Expected {expected}, got {price_with_tax}",
|
||||
)
|
||||
# 99.99 + 21% = 120.9879 -> 120.99
|
||||
self.assertAlmostEqual(result["total_included"], 99.99 * 1.21, places=2)
|
||||
|
|
|
|||
280
website_sale_aplicoop/tests/test_pricing_delegation.py
Normal file
280
website_sale_aplicoop/tests/test_pricing_delegation.py
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
# Copyright 2025 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Pricing that is delegated to website_sale instead of reimplemented.
|
||||
|
||||
Three things this pins down:
|
||||
|
||||
- Eskaera prices with the standard website pricelist. The addon no longer
|
||||
carries a pricelist setting of its own, nor overrides how the website
|
||||
resolves one, so `/shop` and Eskaera cannot quote different prices.
|
||||
- taxes are applied by `product.template._apply_taxes_to_price`, so a fiscal
|
||||
position that remaps a tax-included tax rebases the price -- the step this
|
||||
addon used to skip.
|
||||
- a page of products costs one pricelist resolution, not one per product.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
from ..controllers.website_sale import AplicoopWebsiteSale
|
||||
|
||||
REQUEST_PATCH_TARGET = (
|
||||
"odoo.addons.website_sale_aplicoop.controllers.website_sale.request"
|
||||
)
|
||||
|
||||
|
||||
class PricingDelegationCommon:
|
||||
"""A company, a website and a tax-included product to price."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.controller = AplicoopWebsiteSale()
|
||||
self.company = self.env["res.company"].create({"name": "Pricing Deleg Co"})
|
||||
self.website = self.env.ref("website.default_website").sudo()
|
||||
self.website.write(
|
||||
{
|
||||
"company_id": self.company.id,
|
||||
"show_line_subtotals_tax_selection": "tax_included",
|
||||
}
|
||||
)
|
||||
self.tax_group = self.env["account.tax.group"].create(
|
||||
{"name": "IVA Deleg", "company_id": self.company.id}
|
||||
)
|
||||
self.country_es = self.env.ref("base.es")
|
||||
|
||||
def _create_pricelist(self, name, percent=None):
|
||||
vals = {"name": name, "company_id": self.company.id}
|
||||
if percent is not None:
|
||||
vals["item_ids"] = [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"compute_price": "percentage",
|
||||
"percent_price": percent,
|
||||
"applied_on": "3_global",
|
||||
},
|
||||
)
|
||||
]
|
||||
return self.env["product.pricelist"].create(vals)
|
||||
|
||||
def _create_tax(self, name, amount, included=False):
|
||||
return self.env["account.tax"].create(
|
||||
{
|
||||
"name": name,
|
||||
"amount": amount,
|
||||
"amount_type": "percent",
|
||||
"type_tax_use": "sale",
|
||||
"price_include_override": (
|
||||
"tax_included" if included else "tax_excluded"
|
||||
),
|
||||
"company_id": self.company.id,
|
||||
"country_id": self.country_es.id,
|
||||
"tax_group_id": self.tax_group.id,
|
||||
}
|
||||
)
|
||||
|
||||
def _create_product(self, name, list_price, tax=None):
|
||||
return self.env["product.product"].create(
|
||||
{
|
||||
"name": name,
|
||||
"type": "consu",
|
||||
"list_price": list_price,
|
||||
"is_published": True,
|
||||
"sale_ok": True,
|
||||
"company_id": self.company.id,
|
||||
"taxes_id": [(6, 0, [tax.id])] if tax else False,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestStandardPricelistResolution(PricingDelegationCommon, TransactionCase):
|
||||
"""Eskaera prices with the website pricelist, like the rest of the shop."""
|
||||
|
||||
def test_no_aplicoop_pricelist_setting_is_left(self):
|
||||
"""The old per-addon pricelist setting is gone for good.
|
||||
|
||||
It used to let Eskaera quote a different pricelist from `/shop`, which
|
||||
is exactly the drift this addon should not introduce.
|
||||
"""
|
||||
self.assertNotIn(
|
||||
"aplicoop_pricelist_id", self.env["res.config.settings"]._fields
|
||||
)
|
||||
|
||||
def test_website_resolution_is_left_untouched(self):
|
||||
"""`_get_current_pricelist` is core's; the addon does not override it."""
|
||||
website_cls = type(self.env["website"])
|
||||
|
||||
self.assertNotIn("_get_current_pricelist", website_cls.__dict__)
|
||||
self.assertEqual(
|
||||
self.website.pricelist_id, self.website._get_current_pricelist()
|
||||
)
|
||||
|
||||
def test_the_controller_quotes_the_website_pricelist(self):
|
||||
"""The Eskaera helper hands back exactly what the website resolved."""
|
||||
expected = self._create_pricelist("Website PL", percent=15.0)
|
||||
request_mock = SimpleNamespace(
|
||||
env=self.env,
|
||||
website=SimpleNamespace(pricelist_id=expected),
|
||||
)
|
||||
|
||||
with patch(REQUEST_PATCH_TARGET, request_mock):
|
||||
resolved = self.controller._resolve_pricelist()
|
||||
|
||||
self.assertEqual(resolved, expected)
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestTaxIncludedFiscalPosition(PricingDelegationCommon, TransactionCase):
|
||||
"""A remapped tax-included tax must rebase the displayed price."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.pricelist = self._create_pricelist("Plain PL")
|
||||
self.tax_21_incl = self._create_tax("IVA 21% incl", 21.0, included=True)
|
||||
self.tax_10_incl = self._create_tax("IVA 10% incl", 10.0, included=True)
|
||||
# 100 net + 21% already inside the price.
|
||||
self.product = self._create_product("Tax Incl Product", 121.0, self.tax_21_incl)
|
||||
|
||||
def _fiscal_position(self):
|
||||
position = self.env["account.fiscal.position"].create(
|
||||
{"name": "Remap 21->10", "company_id": self.company.id}
|
||||
)
|
||||
self.env["account.fiscal.position.tax"].create(
|
||||
{
|
||||
"position_id": position.id,
|
||||
"tax_src_id": self.tax_21_incl.id,
|
||||
"tax_dest_id": self.tax_10_incl.id,
|
||||
}
|
||||
)
|
||||
return position
|
||||
|
||||
def test_price_is_rebased_onto_the_mapped_tax(self):
|
||||
"""121 gross at 21% becomes 110 gross at 10%, not 121.
|
||||
|
||||
Without `_get_tax_included_unit_price_from_price` the 121 was handed
|
||||
straight to the mapped tax, which read it as already being the 10%
|
||||
gross price and displayed 121.
|
||||
"""
|
||||
self.website.fiscal_position_id = self._fiscal_position()
|
||||
|
||||
pricing = self.controller._get_pricing_info(
|
||||
self.product, self.pricelist, quantity=1.0
|
||||
)
|
||||
|
||||
self.assertAlmostEqual(pricing["price"], 110.0, places=2)
|
||||
|
||||
def test_price_is_untouched_without_a_fiscal_position(self):
|
||||
"""The rebasing is a no-op in the ordinary case."""
|
||||
self.website.fiscal_position_id = False
|
||||
|
||||
pricing = self.controller._get_pricing_info(
|
||||
self.product, self.pricelist, quantity=1.0
|
||||
)
|
||||
|
||||
self.assertAlmostEqual(pricing["price"], 121.0, places=2)
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestPricingBatching(PricingDelegationCommon, TransactionCase):
|
||||
"""A product listing resolves the pricelist once for the whole page."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.pricelist = self._create_pricelist("Batch PL", percent=10.0)
|
||||
self.tax = self._create_tax("IVA 21%", 21.0)
|
||||
self.products = self.env["product.product"]
|
||||
for index in range(5):
|
||||
self.products |= self._create_product(
|
||||
f"Batch Product {index}", 100.0 + index, self.tax
|
||||
)
|
||||
|
||||
def test_pricelist_is_resolved_once_for_the_page(self):
|
||||
"""One `_compute_price_rule` call, not one per product."""
|
||||
calls = []
|
||||
original = type(self.pricelist)._compute_price_rule
|
||||
|
||||
def counting_compute_price_rule(pricelist_self, products, *args, **kwargs):
|
||||
calls.append(len(products))
|
||||
return original(pricelist_self, products, *args, **kwargs)
|
||||
|
||||
self.patch(
|
||||
type(self.pricelist), "_compute_price_rule", counting_compute_price_rule
|
||||
)
|
||||
|
||||
self.controller._compute_price_info(self.products, self.pricelist)
|
||||
|
||||
self.assertEqual(
|
||||
calls,
|
||||
[len(self.products)],
|
||||
msg=f"Expected a single batched call, got {calls}",
|
||||
)
|
||||
|
||||
def test_batched_prices_match_the_single_product_helper(self):
|
||||
"""Batching must not change any number it produces."""
|
||||
batched = self.controller._compute_price_info(self.products, self.pricelist)
|
||||
|
||||
for product in self.products:
|
||||
single = self.controller._get_pricing_info(
|
||||
product, self.pricelist, quantity=1.0
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
batched[product.id]["price"], single["price"], places=2
|
||||
)
|
||||
self.assertAlmostEqual(
|
||||
batched[product.id]["price_unit"], single["price_unit"], places=2
|
||||
)
|
||||
|
||||
def test_prices_the_given_variant(self):
|
||||
"""A variant is priced as itself, not as its template's first one."""
|
||||
attribute = self.env["product.attribute"].create(
|
||||
{
|
||||
"name": "Size Deleg",
|
||||
"value_ids": [
|
||||
(0, 0, {"name": "Small"}),
|
||||
(0, 0, {"name": "Large"}),
|
||||
],
|
||||
}
|
||||
)
|
||||
template = self.env["product.template"].create(
|
||||
{
|
||||
"name": "Multi Variant Product",
|
||||
"type": "consu",
|
||||
"list_price": 100.0,
|
||||
"is_published": True,
|
||||
"sale_ok": True,
|
||||
"company_id": self.company.id,
|
||||
"attribute_line_ids": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"attribute_id": attribute.id,
|
||||
"value_ids": [(6, 0, attribute.value_ids.ids)],
|
||||
},
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
small, large = template.product_variant_ids[0], template.product_variant_ids[1]
|
||||
# Make the two variants cost visibly different amounts.
|
||||
large.write({"list_price": 100.0})
|
||||
self.env["product.pricelist.item"].create(
|
||||
{
|
||||
"pricelist_id": self.pricelist.id,
|
||||
"applied_on": "0_product_variant",
|
||||
"product_id": large.id,
|
||||
"compute_price": "fixed",
|
||||
"fixed_price": 500.0,
|
||||
}
|
||||
)
|
||||
|
||||
priced = self.controller._compute_price_info(large, self.pricelist)
|
||||
|
||||
self.assertAlmostEqual(priced[large.id]["price_unit"], 500.0, places=2)
|
||||
self.assertNotEqual(large.id, small.id)
|
||||
|
|
@ -500,8 +500,8 @@ class TestPricingWithPricelist(TransactionCase):
|
|||
# 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."""
|
||||
def test_pricing_helper_applies_pricelist_and_taxes(self):
|
||||
"""Pricing helper must apply the given pricelist and include taxes."""
|
||||
|
||||
website = self.env.ref("website.default_website").sudo()
|
||||
website.write(
|
||||
|
|
@ -529,17 +529,12 @@ class TestPricingWithPricelist(TransactionCase):
|
|||
}
|
||||
)
|
||||
|
||||
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)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,367 +0,0 @@
|
|||
# Copyright 2025 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""
|
||||
Test suite for validations and constraints in website_sale_aplicoop.
|
||||
|
||||
Coverage:
|
||||
- group.order constraint: same company for all groups
|
||||
- group.order constraint: start_date < end_date
|
||||
- group.order computed field: image_1920 fallback logic
|
||||
- group.order computed field: product count
|
||||
- res.partner validation: user without partner_id
|
||||
- group.order state transitions: illegal transitions
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestGroupOrderValidations(TransactionCase):
|
||||
"""Test constraints and validations for group.order model."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.company1 = self.env.company
|
||||
self.company2 = self.env["res.company"].create(
|
||||
{
|
||||
"name": "Company 2",
|
||||
}
|
||||
)
|
||||
|
||||
self.group_c1 = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Group Company 1",
|
||||
"is_company": True,
|
||||
"company_id": self.company1.id,
|
||||
}
|
||||
)
|
||||
|
||||
self.group_c2 = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Group Company 2",
|
||||
"is_company": True,
|
||||
"company_id": self.company2.id,
|
||||
}
|
||||
)
|
||||
|
||||
def test_group_order_same_company_constraint(self):
|
||||
"""Test that all groups in an order must be from same company."""
|
||||
start_date = datetime.now().date()
|
||||
|
||||
# Creating order with groups from different companies should fail
|
||||
with self.assertRaises(ValidationError):
|
||||
self.env["group.order"].create(
|
||||
{
|
||||
"name": "Multi-Company Order",
|
||||
"group_ids": [(6, 0, [self.group_c1.id, self.group_c2.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
|
||||
def test_group_order_same_company_mixed_single(self):
|
||||
"""Test that single company group is valid."""
|
||||
start_date = datetime.now().date()
|
||||
|
||||
# Single company should pass
|
||||
order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Single Company Order",
|
||||
"group_ids": [(6, 0, [self.group_c1.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
self.assertTrue(order.exists())
|
||||
|
||||
def test_group_order_date_validation_start_after_end(self):
|
||||
"""Test that start_date must be before end_date."""
|
||||
start_date = datetime.now().date()
|
||||
end_date = start_date - timedelta(days=1) # End before start
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
self.env["group.order"].create(
|
||||
{
|
||||
"name": "Bad Dates Order",
|
||||
"group_ids": [(6, 0, [self.group_c1.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
|
||||
def test_group_order_date_validation_same_date(self):
|
||||
"""Test that start_date == end_date is allowed (single-day order)."""
|
||||
same_date = datetime.now().date()
|
||||
|
||||
order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Same Day Order",
|
||||
"group_ids": [(6, 0, [self.group_c1.id])],
|
||||
"type": "regular",
|
||||
"start_date": same_date,
|
||||
"end_date": same_date,
|
||||
"period": "once",
|
||||
"pickup_day": "0",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
self.assertTrue(order.exists())
|
||||
|
||||
|
||||
class TestGroupOrderImageFallback(TransactionCase):
|
||||
"""Test image_1920 computed field fallback logic."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
}
|
||||
)
|
||||
|
||||
start_date = datetime.now().date()
|
||||
self.group_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Test Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
|
||||
def test_image_fallback_order_image_first(self):
|
||||
"""Test that order image takes priority over group image."""
|
||||
# Set both order and group image
|
||||
test_image = b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
|
||||
self.group_order.image_1920 = test_image
|
||||
self.group.image_1920 = test_image
|
||||
|
||||
# Order image should be returned
|
||||
computed_image = self.group_order.image_1920
|
||||
self.assertEqual(computed_image, test_image)
|
||||
|
||||
def test_image_fallback_group_image_when_no_order_image(self):
|
||||
"""Test fallback to group image when order has no image."""
|
||||
test_image = b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
|
||||
# Only set group image
|
||||
self.group_order.image_1920 = False
|
||||
self.group.image_1920 = test_image
|
||||
|
||||
# Group image should be returned as fallback
|
||||
# Note: This requires the computed field logic to be tested
|
||||
# after field recalculation
|
||||
|
||||
def test_image_fallback_none_when_no_images(self):
|
||||
"""Test that None is returned when no image available."""
|
||||
# No images set
|
||||
self.group_order.image_1920 = False
|
||||
self.group.image_1920 = False
|
||||
|
||||
# Should be empty/False
|
||||
computed_image = self.group_order.image_1920
|
||||
self.assertFalse(computed_image)
|
||||
|
||||
|
||||
class TestGroupOrderProductCount(TransactionCase):
|
||||
"""Test product_count computed field."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
}
|
||||
)
|
||||
|
||||
start_date = datetime.now().date()
|
||||
self.group_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Test Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
|
||||
self.product1 = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Product 1",
|
||||
"type": "consu",
|
||||
"list_price": 10.0,
|
||||
}
|
||||
)
|
||||
|
||||
self.product2 = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Product 2",
|
||||
"type": "consu",
|
||||
"list_price": 20.0,
|
||||
}
|
||||
)
|
||||
|
||||
def test_product_count_initial_zero(self):
|
||||
"""Test that new order has zero products."""
|
||||
self.assertEqual(self.group_order.product_count, 0)
|
||||
|
||||
def test_product_count_increments_on_add(self):
|
||||
"""Test that product_count increases when adding products."""
|
||||
self.group_order.product_ids = [(4, self.product1.id)]
|
||||
self.assertEqual(self.group_order.product_count, 1)
|
||||
|
||||
self.group_order.product_ids = [(4, self.product2.id)]
|
||||
self.assertEqual(self.group_order.product_count, 2)
|
||||
|
||||
def test_product_count_decrements_on_remove(self):
|
||||
"""Test that product_count decreases when removing products."""
|
||||
self.group_order.product_ids = [(6, 0, [self.product1.id, self.product2.id])]
|
||||
self.assertEqual(self.group_order.product_count, 2)
|
||||
|
||||
self.group_order.product_ids = [(3, self.product1.id)]
|
||||
self.assertEqual(self.group_order.product_count, 1)
|
||||
|
||||
def test_product_count_all_removed(self):
|
||||
"""Test that product_count is zero when all removed."""
|
||||
self.group_order.product_ids = [(6, 0, [self.product1.id, self.product2.id])]
|
||||
self.group_order.product_ids = [(6, 0, [])]
|
||||
self.assertEqual(self.group_order.product_count, 0)
|
||||
|
||||
|
||||
class TestStateTransitions(TransactionCase):
|
||||
"""Test group.order state transition validation."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
}
|
||||
)
|
||||
|
||||
start_date = datetime.now().date()
|
||||
self.order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Test Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
|
||||
def test_illegal_transition_draft_to_closed(self):
|
||||
"""Test that Draft -> Closed transition is not allowed."""
|
||||
# Should not allow skipping Open state
|
||||
self.assertEqual(self.order.state, "draft")
|
||||
|
||||
# Calling action_close() without action_open() should fail
|
||||
with self.assertRaises((ValidationError, UserError)):
|
||||
self.order.action_close()
|
||||
|
||||
def test_illegal_transition_cancelled_to_open(self):
|
||||
"""Test that Cancelled -> Open transition is not allowed."""
|
||||
self.order.action_cancel()
|
||||
self.assertEqual(self.order.state, "cancelled")
|
||||
|
||||
# Should not allow re-opening cancelled order
|
||||
with self.assertRaises((ValidationError, UserError)):
|
||||
self.order.action_open()
|
||||
|
||||
def test_legal_transition_draft_open_closed(self):
|
||||
"""Test that Draft -> Open -> Closed is allowed."""
|
||||
self.assertEqual(self.order.state, "draft")
|
||||
|
||||
self.order.action_open()
|
||||
self.assertEqual(self.order.state, "open")
|
||||
|
||||
self.order.action_close()
|
||||
self.assertEqual(self.order.state, "closed")
|
||||
|
||||
def test_transition_draft_to_cancelled(self):
|
||||
"""Test that Draft -> Cancelled is allowed."""
|
||||
self.assertEqual(self.order.state, "draft")
|
||||
|
||||
self.order.action_cancel()
|
||||
self.assertEqual(self.order.state, "cancelled")
|
||||
|
||||
def test_transition_open_to_cancelled(self):
|
||||
"""Test that Open -> Cancelled is allowed (emergency stop)."""
|
||||
self.order.action_open()
|
||||
self.assertEqual(self.order.state, "open")
|
||||
|
||||
self.order.action_cancel()
|
||||
self.assertEqual(self.order.state, "cancelled")
|
||||
|
||||
|
||||
class TestUserPartnerValidation(TransactionCase):
|
||||
"""Test validation when user has no partner_id."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Group",
|
||||
"is_company": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Create user without partner (edge case)
|
||||
self.user_no_partner = self.env["res.users"].create(
|
||||
{
|
||||
"name": "User No Partner",
|
||||
"login": "noparnter@test.com",
|
||||
"partner_id": False, # Explicitly no partner
|
||||
}
|
||||
)
|
||||
|
||||
def test_user_without_partner_cannot_access_order(self):
|
||||
"""Test that user without partner_id has no access to orders."""
|
||||
start_date = datetime.now().date()
|
||||
self.env["group.order"].create(
|
||||
{
|
||||
"name": "Test Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
|
||||
# User without partner should not have access
|
||||
# This should be validated in controller
|
||||
self.assertFalse(self.user_no_partner.partner_id)
|
||||
208
website_sale_aplicoop/tests/test_website_enabled.py
Normal file
208
website_sale_aplicoop/tests/test_website_enabled.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
# Copyright 2025 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Eskaera is served per website, not database-wide.
|
||||
|
||||
A co-op can run the plain shop on one website and the group orders on another.
|
||||
`website.eskaera_enabled` decides which is which: where it is off the /eskaera
|
||||
routes answer 404 and the Eskaera menu is hidden.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import HttpCase
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class EskaeraWebsiteCommon:
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.website = self.env.ref("website.default_website")
|
||||
|
||||
def _eskaera_menus(self, website):
|
||||
return self.env["website.menu"].search(
|
||||
[("url", "=like", "/eskaera%"), ("website_id", "=", website.id)]
|
||||
)
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestEskaeraEnabledField(EskaeraWebsiteCommon, TransactionCase):
|
||||
"""The switch itself, and what it does to the menu."""
|
||||
|
||||
def test_enabled_by_default(self):
|
||||
"""Installing the addon leaves every website serving Eskaera."""
|
||||
self.assertTrue(self.website.eskaera_enabled)
|
||||
self.assertTrue(
|
||||
all(website.eskaera_enabled for website in self.env["website"].search([]))
|
||||
)
|
||||
|
||||
def test_new_website_serves_eskaera(self):
|
||||
"""A website created later also starts with Eskaera on."""
|
||||
new_website = self.env["website"].create({"name": "Brand New Site"})
|
||||
|
||||
self.assertTrue(new_website.eskaera_enabled)
|
||||
|
||||
def test_menu_is_visible_while_enabled(self):
|
||||
"""The Eskaera menu shows on a website that serves it."""
|
||||
menus = self._eskaera_menus(self.website)
|
||||
|
||||
self.assertTrue(menus, "the website should have an Eskaera menu")
|
||||
self.assertTrue(all(menu.is_visible for menu in menus))
|
||||
|
||||
def test_menu_is_hidden_when_disabled(self):
|
||||
"""Switching Eskaera off hides its menu on that website only."""
|
||||
other_website = self.env["website"].create({"name": "Shop Only Site"})
|
||||
self.website.eskaera_enabled = False
|
||||
|
||||
self.env["website.menu"].invalidate_model(["is_visible"])
|
||||
|
||||
self.assertFalse(any(m.is_visible for m in self._eskaera_menus(self.website)))
|
||||
# The other website is untouched.
|
||||
other_menus = self._eskaera_menus(other_website)
|
||||
if other_menus:
|
||||
self.assertTrue(all(menu.is_visible for menu in other_menus))
|
||||
|
||||
def test_menu_is_kept_not_deleted(self):
|
||||
"""Turning the feature back on restores the menu as it was."""
|
||||
menus = self._eskaera_menus(self.website)
|
||||
menu_ids = menus.ids
|
||||
|
||||
self.website.eskaera_enabled = False
|
||||
self.website.eskaera_enabled = True
|
||||
self.env["website.menu"].invalidate_model(["is_visible"])
|
||||
|
||||
restored = self._eskaera_menus(self.website)
|
||||
self.assertEqual(restored.ids, menu_ids)
|
||||
self.assertTrue(all(menu.is_visible for menu in restored))
|
||||
|
||||
def test_other_menus_are_left_alone(self):
|
||||
"""The override must not touch menus that are not Eskaera's."""
|
||||
self.website.eskaera_enabled = False
|
||||
self.env["website.menu"].invalidate_model(["is_visible"])
|
||||
|
||||
home = self.env["website.menu"].search(
|
||||
[("url", "=", "/"), ("website_id", "=", self.website.id)], limit=1
|
||||
)
|
||||
|
||||
if home:
|
||||
self.assertTrue(home.is_visible)
|
||||
|
||||
def test_setting_is_exposed_per_website(self):
|
||||
"""The switch reaches the settings screen through website_id."""
|
||||
settings = self.env["res.config.settings"].create(
|
||||
{"website_id": self.website.id}
|
||||
)
|
||||
|
||||
self.assertTrue(settings.eskaera_enabled)
|
||||
|
||||
settings.eskaera_enabled = False
|
||||
settings.execute()
|
||||
|
||||
self.assertFalse(self.website.eskaera_enabled)
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestEskaeraRoutesPerWebsite(EskaeraWebsiteCommon, HttpCase):
|
||||
"""The /eskaera routes answer only where the feature is on."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.group = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Enabled Test Group",
|
||||
"is_company": True,
|
||||
"is_group": True,
|
||||
}
|
||||
)
|
||||
self.member_partner = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Enabled Test Member",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
}
|
||||
)
|
||||
self.login = "enabled.member@test.com"
|
||||
self.env["res.users"].create(
|
||||
{
|
||||
"name": "Enabled Test Member",
|
||||
"login": self.login,
|
||||
"password": self.login,
|
||||
"partner_id": self.member_partner.id,
|
||||
"groups_id": [(4, self.env.ref("base.group_portal").id)],
|
||||
}
|
||||
)
|
||||
|
||||
start_date = datetime.now().date()
|
||||
self.group_order = self.env["group.order"].create(
|
||||
{
|
||||
"name": "Enabled Test Order",
|
||||
"group_ids": [(6, 0, [self.group.id])],
|
||||
"type": "regular",
|
||||
"start_date": start_date,
|
||||
"end_date": start_date + timedelta(days=7),
|
||||
"period": "weekly",
|
||||
"pickup_day": "3",
|
||||
"cutoff_day": "0",
|
||||
}
|
||||
)
|
||||
self.group_order.action_open()
|
||||
|
||||
def _routes(self):
|
||||
return [
|
||||
"/eskaera",
|
||||
f"/eskaera/{self.group_order.slug}",
|
||||
f"/eskaera/{self.group_order.slug}/checkout",
|
||||
]
|
||||
|
||||
def test_routes_answer_when_enabled(self):
|
||||
"""With the feature on, the pages render."""
|
||||
self.authenticate(self.login, self.login)
|
||||
|
||||
for route in self._routes():
|
||||
response = self.url_open(route, allow_redirects=True)
|
||||
self.assertEqual(response.status_code, 200, msg=f"{route} should be served")
|
||||
|
||||
def test_routes_are_404_when_disabled(self):
|
||||
"""With the feature off, the pages are gone from that website."""
|
||||
self.website.eskaera_enabled = False
|
||||
self.authenticate(self.login, self.login)
|
||||
|
||||
for route in self._routes():
|
||||
response = self.url_open(route, allow_redirects=True)
|
||||
self.assertEqual(
|
||||
response.status_code, 404, msg=f"{route} should not be served"
|
||||
)
|
||||
|
||||
def _call_labels(self):
|
||||
return self.url_open(
|
||||
"/eskaera/labels",
|
||||
data=b'{"jsonrpc": "2.0", "method": "call", "params": {}}',
|
||||
headers={"Content-Type": "application/json"},
|
||||
).json()
|
||||
|
||||
def test_json_endpoint_answers_when_enabled(self):
|
||||
"""With the feature on, the labels endpoint returns its dictionary."""
|
||||
self.authenticate(self.login, self.login)
|
||||
|
||||
payload = self._call_labels()
|
||||
|
||||
self.assertIn("result", payload)
|
||||
self.assertIn("total", payload["result"])
|
||||
|
||||
def test_json_endpoint_is_gated_when_disabled(self):
|
||||
"""The JSON endpoints are gated too, not just the pages.
|
||||
|
||||
A JSON route reports the 404 inside the payload and still answers
|
||||
HTTP 200: that is how Odoo's JSON-RPC transport surfaces exceptions.
|
||||
"""
|
||||
self.website.eskaera_enabled = False
|
||||
self.authenticate(self.login, self.login)
|
||||
|
||||
payload = self._call_labels()
|
||||
|
||||
self.assertNotIn("result", payload)
|
||||
self.assertEqual(payload["error"]["code"], 404)
|
||||
self.assertEqual(
|
||||
payload["error"]["data"]["name"], "werkzeug.exceptions.NotFound"
|
||||
)
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
<field name="end_date" optional="show"/>
|
||||
<field name="home_delivery" optional="hide"/>
|
||||
<field name="delivery_product_id" optional="hide"/>
|
||||
<field name="online_payment" optional="hide"/>
|
||||
<field name="state" optional="show"/>
|
||||
</list>
|
||||
</field>
|
||||
|
|
@ -84,6 +85,15 @@
|
|||
<field name="delivery_product_id" help="Product to use for home delivery. Setting this enables home delivery."/>
|
||||
<field name="delivery_notice" placeholder="Information about home delivery..." nolabel="1"/>
|
||||
</page>
|
||||
<page string="Online Payment" name="online_payment">
|
||||
<field name="online_payment"/>
|
||||
<div class="text-muted" invisible="not online_payment">
|
||||
Members must pay online to place their order in this cycle. The
|
||||
available methods come from the payment providers published on the
|
||||
website (Settings > Payment Providers); this order does not
|
||||
configure any of them.
|
||||
</div>
|
||||
</page>
|
||||
<page string="Product Catalog">
|
||||
<group string="Included Products" col="2">
|
||||
<field name="supplier_ids" widget="many2many_tags" help="All products from these suppliers will be included"/>
|
||||
|
|
|
|||
|
|
@ -6,19 +6,18 @@
|
|||
<field name="inherit_id" ref="website.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//block[@id='website_info_settings']" position="after">
|
||||
<h2>Aplicoop Settings</h2>
|
||||
<div class="row mt16 o_settings_container" id="aplicoop_settings">
|
||||
<h2>Eskaera</h2>
|
||||
<div class="row mt16 o_settings_container" id="eskaera_website_settings">
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_left_pane"/>
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="eskaera_enabled"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="aplicoop_pricelist_id" string="Aplicoop Pricelist"/>
|
||||
<label for="eskaera_enabled" string="Eskaera Group Orders"/>
|
||||
<div class="text-muted">
|
||||
Pricelist used for Aplicoop group orders
|
||||
</div>
|
||||
<div class="content-group">
|
||||
<div class="mt16">
|
||||
<field name="aplicoop_pricelist_id" class="oe_inline"/>
|
||||
</div>
|
||||
Serve the collaborative purchasing pages on this
|
||||
website. Turn it off on a website that only runs
|
||||
the regular shop.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -107,12 +107,6 @@
|
|||
</script>
|
||||
</t>
|
||||
</template>
|
||||
<template id="confirm_message_snippet" name="Confirm Message Snippet">
|
||||
<t>Thank you! Your order has been confirmed.</t>
|
||||
</template>
|
||||
<template id="confirm_pickup_label_snippet" name="Confirm Pickup Label Snippet">
|
||||
<t>Pickup Day</t>
|
||||
</template>
|
||||
<template id="eskaera_order_card_meta" name="Order Card Metadata">
|
||||
<div class="card-meta-compact mt-3">
|
||||
<div class="meta-grid">
|
||||
|
|
@ -214,6 +208,18 @@
|
|||
<t t-call="website_sale_aplicoop.order_header">
|
||||
<t t-set="header_class" t-value="'eskaera-order-header'" />
|
||||
</t>
|
||||
<!-- Paying confirms the order, so no draft is left to
|
||||
reuse and nothing else would stop a second, also
|
||||
payable, order for the same cycle. -->
|
||||
<t t-if="placed_order">
|
||||
<div class="alert alert-info d-flex flex-column flex-md-row gap-2 align-items-md-center" role="alert">
|
||||
<div class="flex-grow-1">
|
||||
<strong>You already placed an order for this cycle.</strong>
|
||||
<span t-esc="placed_order.name" />
|
||||
</div>
|
||||
<a t-att-href="placed_order_url" class="btn btn-sm btn-outline-primary">View my order</a>
|
||||
</div>
|
||||
</t>
|
||||
<div class="eskaera-order-header">
|
||||
<!-- Name/value pairs: a description list, so assistive tech
|
||||
announces each value with its label. The 4 → 2 → 1 column
|
||||
|
|
@ -560,10 +566,13 @@
|
|||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="online_payment and not payment_available">
|
||||
<div class="alert alert-warning" role="alert" t-esc="no_payment_method_message" />
|
||||
</t>
|
||||
<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 }}" t-att-data-confirmed-label="labels.get('order_saved_as_draft', 'Order saved as draft')" t-att-data-pickup-label="labels.get('pickup_day_label', 'Pickup Day')" t-att-aria-label="labels.get('save_order_as_draft', 'Save order as draft')" t-att-data-bs-title="labels.get('save_draft', 'Save Draft')" data-bs-toggle="tooltip">
|
||||
<i class="fa fa-save" aria-hidden="true" t-translation="off" />
|
||||
<span t-esc="labels.get('save_draft', 'Save Draft')" />
|
||||
<button class="btn btn-success btn-lg" id="confirm-order-btn" t-attf-data-order-id="{{ group_order.id }}" t-att-data-confirmed-label="checkout_button['done_label']" t-att-data-pickup-label="labels.get('pickup_day_label', 'Pickup Day')" t-att-aria-label="checkout_button['hint']" t-att-data-bs-title="checkout_button['label']" t-att-data-tooltip-key="checkout_button['tooltip_key']" t-att-disabled="online_payment and not payment_available" data-bs-toggle="tooltip">
|
||||
<i t-attf-class="fa {{ checkout_button['icon'] }}" aria-hidden="true" t-translation="off" />
|
||||
<span t-esc="checkout_button['label']" />
|
||||
</button>
|
||||
<a t-attf-href="/eskaera/{{ group_order.slug }}" class="btn btn-outline-secondary btn-lg" aria-label="Back to cart page" title="Back to Cart" data-bs-toggle="tooltip">
|
||||
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
|
||||
|
|
@ -618,6 +627,172 @@
|
|||
</div>
|
||||
</t>
|
||||
</template>
|
||||
<template id="eskaera_order_lines_summary" name="Placed Order Summary">
|
||||
<!-- Server-side twin of eskaera_checkout_summary: from the payment
|
||||
step on, the amounts shown must be the ones that will be
|
||||
charged, so they come from the sale.order and not from the
|
||||
localStorage cart. -->
|
||||
<div class="checkout-summary-container" role="region" tabindex="0" aria-label="Order summary">
|
||||
<table class="table table-hover checkout-summary-table">
|
||||
<caption class="visually-hidden">Products in this order, with quantity, price and subtotal</caption>
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th scope="col" class="col-name">Product</th>
|
||||
<th scope="col" class="col-qty text-center">Quantity</th>
|
||||
<th scope="col" class="col-subtotal text-end">Subtotal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="sale_order.order_line" t-as="line">
|
||||
<td class="col-name" t-esc="line.name" />
|
||||
<td class="col-qty text-center" t-esc="line.product_uom_qty" />
|
||||
<td class="col-subtotal text-end">
|
||||
<span t-field="line.price_total" t-options="{'widget': 'monetary', 'display_currency': sale_order.currency_id}" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="checkout-total-section">
|
||||
<div class="total-row">
|
||||
<span class="total-label">Total</span>:
|
||||
<span class="total-amount">
|
||||
<span t-field="sale_order.amount_total" t-options="{'widget': 'monetary', 'display_currency': sale_order.currency_id}" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template id="eskaera_payment" name="Eskaera Payment">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="eskaera-checkout-page oe_structure oe_empty" data-name="Eskaera Payment">
|
||||
<div class="container mt-5">
|
||||
<div class="row">
|
||||
<div class="col-lg-10 offset-lg-1">
|
||||
<div class="mb-4">
|
||||
<t t-call="website_sale_aplicoop.order_header">
|
||||
<t t-set="header_class" t-value="'checkout-header'" />
|
||||
<t t-set="header_title">Pay Order: <t t-esc="group_order.name" />
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
<h4 class="summary-heading mb-3">Order Summary</h4>
|
||||
<div class="mb-5">
|
||||
<t t-call="website_sale_aplicoop.eskaera_order_lines_summary" />
|
||||
</div>
|
||||
<t t-if="pending_transaction">
|
||||
<div class="alert alert-info" role="alert">
|
||||
<h5 class="alert-heading">
|
||||
<i class="fa fa-clock-o me-2" aria-hidden="true" t-translation="off" />
|
||||
<span>Payment in progress</span>
|
||||
</h5>
|
||||
<p class="mb-1">We are still waiting for your payment to be confirmed. Please do not pay again.</p>
|
||||
<p class="mb-0">
|
||||
<span>Reference</span>:
|
||||
<span t-esc="pending_transaction.reference" />
|
||||
</p>
|
||||
</div>
|
||||
<div class="checkout-actions d-grid gap-3">
|
||||
<a t-att-href="sale_order.get_portal_url()" class="btn btn-outline-secondary btn-lg">
|
||||
<i class="fa fa-file-text-o" aria-hidden="true" t-translation="off" />
|
||||
<span>View my order</span>
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<h4 class="summary-heading mb-3">Payment Method</h4>
|
||||
<!-- The whole provider UI is payment.form; this addon
|
||||
configures no provider of its own. -->
|
||||
<div id="payment_method" class="o_not_editable mb-4">
|
||||
<t t-call="payment.form" />
|
||||
</div>
|
||||
<!-- Deliberately outside #o_payment_form: website_sale's
|
||||
payment_form.js binds every
|
||||
[name="o_payment_submit_button"] in the document, on
|
||||
top of payment_form.js's own delegated handler inside
|
||||
the form. A button inside would get both listeners and
|
||||
one click would open two transactions. -->
|
||||
<div class="checkout-actions d-grid gap-3">
|
||||
<t t-call="payment.submit_button" />
|
||||
<a t-attf-href="/eskaera/{{ group_order.slug }}/checkout" class="btn btn-outline-secondary btn-lg">
|
||||
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
|
||||
<span>Back to Checkout</span>
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
<template id="eskaera_payment_confirmation" name="Eskaera Payment Confirmation">
|
||||
<t t-call="website.layout">
|
||||
<div id="wrap" class="eskaera-checkout-page oe_structure oe_empty" data-name="Eskaera Payment Confirmation" t-attf-data-clear-cart-order-id="{{ group_order.id }}">
|
||||
<div class="container mt-5">
|
||||
<div class="row">
|
||||
<div class="col-lg-10 offset-lg-1">
|
||||
<div class="mb-4">
|
||||
<t t-call="website_sale_aplicoop.order_header">
|
||||
<t t-set="header_class" t-value="'checkout-header'" />
|
||||
<t t-set="header_title">Order Confirmed: <t t-esc="group_order.name" />
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
<!-- The browser lands here from /payment/status once
|
||||
post-processing has run, but that is asynchronous, so
|
||||
the page reports the order's actual state instead of
|
||||
assuming it was confirmed. -->
|
||||
<t t-if="is_paid">
|
||||
<div class="alert alert-success" role="alert">
|
||||
<h5 class="alert-heading">
|
||||
<i class="fa fa-check-circle me-2" aria-hidden="true" t-translation="off" />
|
||||
<span>Thank you, your order is confirmed</span>
|
||||
</h5>
|
||||
<p class="mb-0">
|
||||
<span>Order</span>
|
||||
<span t-esc="sale_order.name" />
|
||||
</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="alert alert-info" role="alert">
|
||||
<h5 class="alert-heading">
|
||||
<i class="fa fa-clock-o me-2" aria-hidden="true" t-translation="off" />
|
||||
<span>Your payment is being processed</span>
|
||||
</h5>
|
||||
<p class="mb-0">Your order will be confirmed as soon as we receive the payment. You can follow it from your orders page.</p>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="sale_order.pickup_slot_label">
|
||||
<div class="order-info-card card border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<dl class="info-pair mb-0">
|
||||
<dt class="info-label">Pickup</dt>
|
||||
<dd class="info-value" t-esc="sale_order.pickup_slot_label" />
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<h4 class="summary-heading mb-3">Order Summary</h4>
|
||||
<div class="mb-5">
|
||||
<t t-call="website_sale_aplicoop.eskaera_order_lines_summary" />
|
||||
</div>
|
||||
<div class="checkout-actions d-grid gap-3">
|
||||
<a t-att-href="sale_order.get_portal_url()" class="btn btn-primary btn-lg">
|
||||
<i class="fa fa-file-text-o" aria-hidden="true" t-translation="off" />
|
||||
<span>View my order</span>
|
||||
</a>
|
||||
<a href="/eskaera" class="btn btn-outline-secondary btn-lg">
|
||||
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
|
||||
<span>Back to Orders</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
<template id="category_hierarchy_options" name="Category Hierarchy Options">
|
||||
<t t-foreach="categories" t-as="cat">
|
||||
<t t-set="padding_px" t-value="depth * 20" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue