From 6a8e31f8787b7c2d2ce4d45042d68250044c8f3b Mon Sep 17 00:00:00 2001 From: Virginia Date: Thu, 29 Jan 2026 16:42:11 -0300 Subject: [PATCH 01/15] Update project.toml from template --- .copier-answers.yml | 2 +- .github/workflows/pre-commit.yml | 7 ++++++- .pre-commit-config.yaml | 2 ++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.copier-answers.yml b/.copier-answers.yml index 67ad86e4..7490032c 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,5 +1,5 @@ # Do NOT update manually; changes here will be overwritten by Copier -_commit: 2f2f7c4 +_commit: a740779 _src_path: https://github.com/ingadhoc/addons-repo-template.git description: ADHOC Odoo purchase Modules is_private: false diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 349c52d8..baa05dbf 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -6,8 +6,13 @@ name: pre-commit on: push: - branches: "[0-9][0-9].0" + branches: + - "1[8-9].0" + - "[2-9][0-9].0" pull_request_target: + branches: + - "1[8-9].0*" + - "[2-9][0-9].0*" jobs: pre-commit: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fc269814..c4be55ff 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,6 +29,8 @@ repos: - id: check-docstring-first - id: check-executables-have-shebangs - id: check-merge-conflict + args: ['--assume-in-merge'] + exclude: '\.rst$' - id: check-symlinks - id: check-xml - id: check-yaml From c857ca26e6cd49d3c3e50d27a4100b72a83fc848 Mon Sep 17 00:00:00 2001 From: adhoc-cicd-bot <116299102+adhoc-cicd-bot@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:44:05 -0300 Subject: [PATCH 02/15] [UPD] Copilot instructions --- .github/copilot-instructions.md | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a30ad3cf..783d6e4d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -38,8 +38,6 @@ * Confirmar que todos los archivos usados (vistas, seguridad, datos, reportes, wizards) estén referenciados en el manifest. * Verificar dependencias declaradas: que no falten módulos requeridos ni se declaren innecesarios. -* **Regla de versión (obligatoria):** - Solo sugerir bump de versión si el `__manifest__.py` no incrementa `version` y se modificó la estructura de un modelo, una vista, o algún record .xml (ej. cambios en definición de campos, vistas XML, datos XML, seguridad). * Solo hacerlo una vez por revisión, aunque haya múltiples archivos afectados. --- @@ -61,7 +59,6 @@ * Verificar los archivos `ir.model.access.csv` para nuevos modelos: deben tener permisos mínimos necesarios. * No proponer abrir acceso global sin justificación. -* Si se agregan nuevos modelos o campos de control de acceso, **recordar el bump de versión** (ver sección de manifest). * Si se cambian `record rules`, revisar especialmente combinaciones multi-compañía y multi-website. ### Seguridad y rendimiento del ORM @@ -86,7 +83,7 @@ ## Cambios estructurales y scripts de migración – **cuestiones generales** -Cuando el diff sugiera **cambios de estructura de datos**, **siempre evaluar** si corresponde proponer un **script de migración** en `migrations/` (pre/post/end) **y recordar el bump de versión**. +Cuando el diff sugiera **cambios de estructura de datos**, **siempre evaluar** si corresponde proponer un **script de migración** en `migrations/` (pre/post/end). ### Reglas generales de estructura de `migrations/` @@ -283,7 +280,6 @@ def migrate(cr, registry): | ------------------ | -------------------------------------------------------------------------------------------------------- | | Modelos | Relaciones válidas; constraints; uso adecuado de `@api.depends`; `super()` correcto | | Vistas XML | Herencias correctas; campos válidos; adaptación a cambios de versión (p.ej. `` vs ``) | -| Manifest | **Bump de versión obligatorio** si hay cambios estructurales en modelos/vistas/records .xml; archivos referenciados | | Seguridad | Accesos mínimos necesarios; reglas revisadas | | Migraciones | **Si hay cambios estructurales, sugerir script en `migrations/` (pre/post/end)** y describir qué hace | | Rendimiento / ORM | Evitar loops costosos; no SQL innecesario; aprovechar las optimizaciones del ORM de la versión | @@ -291,15 +287,6 @@ def migrate(cr, registry): --- -## Heurística práctica para el bump de versión (general) - -* **SI** el diff modifica la estructura de un modelo, una vista, o algún record .xml (ej. cambios en definición de campos, vistas XML, datos XML, seguridad) - **Y** `__manifest__.py` no cambia `version` → **Sugerir bump**. -* **SI** hay scripts `migrations/pre_*.py` o `migrations/post_*.py` nuevos → **Sugerir al menos minor bump**. -* **SI** hay cambios que rompen compatibilidad (renombres, cambios de tipo con impacto, limpieza masiva de datos) → **Sugerir minor/major** según impacto. - ---- - ## Estilo del feedback (general) * Ser breve, claro y útil. Ejemplos: @@ -307,7 +294,7 @@ def migrate(cr, registry): * “El campo `partner_id` no se encuentra referenciado en la vista.” * “Este método redefine `write()` sin usar `super()`.” * “Tip: hay un error ortográfico en el nombre del parámetro.” - * **Bump + migración:** “Se renombra `old_ref` → `new_ref`: falta **bump de versión** y **pre-script** en `migrations/` para copiar valores antes del upgrade; añadir **post-script** para recompute del stored.” + * **Migración:** “Se renombra `old_ref` → `new_ref`: falta **pre-script** en `migrations/` para copiar valores antes del upgrade; añadir **post-script** para recompute del stored.” * Evitar explicaciones largas o reescrituras completas salvo que el cambio sea claro y necesario. * Priorizar comentarios en forma de **lista corta de puntos** (3–7 ítems) y frases breves en lugar de bloques de texto extensos. @@ -316,10 +303,10 @@ def migrate(cr, registry): ## Resumen operativo para Copilot -1. **Detecta cambios estructurales en modelos, vistas o records .xml → exige bump de `version` en `__manifest__.py` si no está incrementada.** -2. **Si hay cambio estructural (según la lista actualizada) → propone y describe script(s) de migración en `migrations/` (pre/post/end)**, con enfoque idempotente y en lotes. -3. Distingue entre: +1. **Si hay cambio estructural (según la lista actualizada) → propone y describe script(s) de migración en `migrations/` (pre/post/end)**, con enfoque idempotente y en lotes. +2. Distingue entre: * **cuestiones generales** (válidas para cualquier versión), * y **matices específicos de Odoo 18** (por ejemplo, uso de ``, passkeys, tours y comportamiento del framework). -4. Mantén el feedback **concreto, breve y accionable**. \ No newline at end of file + +3. Mantén el feedback **concreto, breve y accionable**. \ No newline at end of file From 43234e1ffbe27060fc536a9e8fc6c02c45c15b0d Mon Sep 17 00:00:00 2001 From: mav-adhoc Date: Fri, 13 Feb 2026 17:07:43 -0300 Subject: [PATCH 03/15] [IMP]purchase_ux: improve purchase bill line match view closes ingadhoc/purchase#300 Signed-off-by: Juan Carreras --- purchase_ux/__manifest__.py | 3 +- purchase_ux/i18n/es.po | 5 +++ purchase_ux/models/__init__.py | 1 + purchase_ux/models/account_move.py | 12 ++++++ .../models/purchase_bill_line_match.py | 39 +++++++++++++++++++ purchase_ux/models/purchase_order.py | 12 ++++++ .../views/purchase_bill_line_match_views.xml | 29 ++++++++++++++ 7 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 purchase_ux/models/purchase_bill_line_match.py create mode 100644 purchase_ux/views/purchase_bill_line_match_views.xml diff --git a/purchase_ux/__manifest__.py b/purchase_ux/__manifest__.py index 26512b00..4ff796ca 100644 --- a/purchase_ux/__manifest__.py +++ b/purchase_ux/__manifest__.py @@ -19,7 +19,7 @@ ############################################################################## { "name": "Purchase UX", - "version": "18.0.1.1.0", + "version": "18.0.1.2.0", "category": "Purchases", "sequence": 14, "summary": "", @@ -41,6 +41,7 @@ "views/purchase_line_views.xml", "views/product_template_views.xml", "views/product_supplierinfo_views.xml", + "views/purchase_bill_line_match_views.xml", ], "demo": [ "demo/purchase_order_demo.xml", diff --git a/purchase_ux/i18n/es.po b/purchase_ux/i18n/es.po index b748c789..68013208 100644 --- a/purchase_ux/i18n/es.po +++ b/purchase_ux/i18n/es.po @@ -380,3 +380,8 @@ msgstr "o" #: model:ir.model,name:purchase_ux.model_purchase_order_line_add_to_invoice msgid "purchase.order.line.add_to_invoice" msgstr "" + +#. module: purchase_ux +#: model:ir.model,name:purchase_ux.model_purchase_bill_line_match +msgid "Received" +msgstr "Recibido" diff --git a/purchase_ux/models/__init__.py b/purchase_ux/models/__init__.py index 58cc98e1..2717a6d4 100644 --- a/purchase_ux/models/__init__.py +++ b/purchase_ux/models/__init__.py @@ -6,3 +6,4 @@ from . import purchase_order from . import purchase_order_line from . import product_template +from . import purchase_bill_line_match diff --git a/purchase_ux/models/account_move.py b/purchase_ux/models/account_move.py index 90d95d76..f949196a 100644 --- a/purchase_ux/models/account_move.py +++ b/purchase_ux/models/account_move.py @@ -82,3 +82,15 @@ def update_prices_with_supplier_cost(self): def get_product_lines_to_update(self): return self.with_company(self.company_id.id).invoice_line_ids.filtered(lambda x: x.product_id and x.price_unit) + + def action_purchase_matching(self): + result = super().action_purchase_matching() + result["context"] = result.get("context", {}) + result["context"].update( + { + "search_default_pol_id": 1, + "search_default_not_invoiced": 1, + "search_default_current_invoice": 1, + } + ) + return result diff --git a/purchase_ux/models/purchase_bill_line_match.py b/purchase_ux/models/purchase_bill_line_match.py new file mode 100644 index 00000000..ab14d400 --- /dev/null +++ b/purchase_ux/models/purchase_bill_line_match.py @@ -0,0 +1,39 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +# directory +############################################################################## +from odoo import api, fields, models + + +class PurchaseBillLineMatch(models.Model): + _inherit = "purchase.bill.line.match" + + reference_description = fields.Char( + string="Description", + compute="_compute_reference_description", + ) + + qty_received = fields.Float( + string="Received", + related="pol_id.qty_received", + readonly=True, + ) + + @api.depends("pol_id", "product_id", "display_name") + def _compute_reference_description(self): + for rec in self: + if rec.pol_id and rec.product_id: + pol_name = rec.pol_id.name or "" + product_name = rec.product_id.display_name or "" + if pol_name.startswith(product_name): + remaining = pol_name[len(product_name) :].strip() + if remaining: + rec.reference_description = f"{product_name} - {remaining}" + else: + rec.reference_description = product_name + else: + rec.reference_description = pol_name + elif rec.pol_id: + rec.reference_description = rec.pol_id.name + else: + rec.reference_description = rec.display_name diff --git a/purchase_ux/models/purchase_order.py b/purchase_ux/models/purchase_order.py index 569bb419..c5a6056f 100644 --- a/purchase_ux/models/purchase_order.py +++ b/purchase_ux/models/purchase_order.py @@ -129,3 +129,15 @@ def _prepare_invoice(self): if self.internal_notes: result["internal_notes"] = self.internal_notes return result + + def action_bill_matching(self): + result = super().action_bill_matching() + result["context"] = result.get("context", {}) + result["context"].update( + { + "search_default_pol_id": 1, + "search_default_not_invoiced": 1, + "search_default_current_invoice": 1, + } + ) + return result diff --git a/purchase_ux/views/purchase_bill_line_match_views.xml b/purchase_ux/views/purchase_bill_line_match_views.xml new file mode 100644 index 00000000..6ccf14cb --- /dev/null +++ b/purchase_ux/views/purchase_bill_line_match_views.xml @@ -0,0 +1,29 @@ + + + + purchase.bill.line.match.list + purchase.bill.line.match + + + + + + + + + + + + + purchase.bill.line.match.search + purchase.bill.line.match + + + + + + + + + + From 6287c18dd979fdecad5dca662176dca41b42f7f7 Mon Sep 17 00:00:00 2001 From: mav-adhoc Date: Fri, 27 Feb 2026 10:00:12 -0300 Subject: [PATCH 04/15] [FIX]purchase_ux: remove filters in purchase bill line match closes ingadhoc/purchase#305 Signed-off-by: Juan Carreras --- purchase_ux/models/account_move.py | 12 ------------ purchase_ux/models/purchase_order.py | 12 ------------ 2 files changed, 24 deletions(-) diff --git a/purchase_ux/models/account_move.py b/purchase_ux/models/account_move.py index f949196a..90d95d76 100644 --- a/purchase_ux/models/account_move.py +++ b/purchase_ux/models/account_move.py @@ -82,15 +82,3 @@ def update_prices_with_supplier_cost(self): def get_product_lines_to_update(self): return self.with_company(self.company_id.id).invoice_line_ids.filtered(lambda x: x.product_id and x.price_unit) - - def action_purchase_matching(self): - result = super().action_purchase_matching() - result["context"] = result.get("context", {}) - result["context"].update( - { - "search_default_pol_id": 1, - "search_default_not_invoiced": 1, - "search_default_current_invoice": 1, - } - ) - return result diff --git a/purchase_ux/models/purchase_order.py b/purchase_ux/models/purchase_order.py index c5a6056f..569bb419 100644 --- a/purchase_ux/models/purchase_order.py +++ b/purchase_ux/models/purchase_order.py @@ -129,15 +129,3 @@ def _prepare_invoice(self): if self.internal_notes: result["internal_notes"] = self.internal_notes return result - - def action_bill_matching(self): - result = super().action_bill_matching() - result["context"] = result.get("context", {}) - result["context"].update( - { - "search_default_pol_id": 1, - "search_default_not_invoiced": 1, - "search_default_current_invoice": 1, - } - ) - return result From 76c4297263a49a572ac570d5ea75d38a2b20921b Mon Sep 17 00:00:00 2001 From: mav-adhoc Date: Tue, 10 Mar 2026 15:54:16 -0300 Subject: [PATCH 05/15] [FIX]purchase_stock_ux: incorporate picking log after cancel remaining qties closes ingadhoc/purchase#307 Signed-off-by: Juan Carreras --- purchase_stock_ux/models/purchase_order_line.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/purchase_stock_ux/models/purchase_order_line.py b/purchase_stock_ux/models/purchase_order_line.py index 735dbde5..98651ff9 100644 --- a/purchase_stock_ux/models/purchase_order_line.py +++ b/purchase_stock_ux/models/purchase_order_line.py @@ -99,6 +99,8 @@ def button_cancel_remaining(self): # la realidad es que probablemente esto de acá no sea necesario. modificar product_qty ya hace que odoo, # apartir de 16 al menos, baje las cantidades de los moves. Justamente por esta razon es que ahora # pasamos contexto arriba de "cancel_from_order", porque ahora es odoo quien cancela los pickings + if rec.product_qty < old_product_qty: + rec.order_id._log_decrease_ordered_quantity({rec: (rec.product_qty, old_product_qty)}) rec.order_id.message_post( body=_('Cancel remaining call for line "%s" (id %s), line qty updated from %s to %s') % (rec.name, rec.id, old_product_qty, rec.product_qty) @@ -174,10 +176,12 @@ def _compute_qty_returned(self): for line in self: qty = 0.0 for move in line.move_ids.filtered( - lambda m: m.state == "done" - and m.location_id.usage != "supplier" - and m.to_refund - and not m._is_exchange_move_helper() + lambda m: ( + m.state == "done" + and m.location_id.usage != "supplier" + and m.to_refund + and not m._is_exchange_move_helper() + ) ): qty += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom) line.qty_returned = qty From cc641530b6898ee85b809c4882d1e314c12f9834 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Carreras Date: Tue, 10 Feb 2026 15:46:32 +0000 Subject: [PATCH 06/15] [IMP]purchase_stock_ux:admit cancel remaining in blocked orders closes ingadhoc/purchase#299 Signed-off-by: Franco Leyes --- .../models/purchase_order_line.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/purchase_stock_ux/models/purchase_order_line.py b/purchase_stock_ux/models/purchase_order_line.py index 98651ff9..9fb77d98 100644 --- a/purchase_stock_ux/models/purchase_order_line.py +++ b/purchase_stock_ux/models/purchase_order_line.py @@ -63,13 +63,14 @@ def button_cancel_remaining(self): # la cancelación de kits no está bien resuelta ya que odoo # solo computa la cantidad entregada cuando todo el kit se entregó. # Cuestión que, por ahora, desactivamos la cancelación de kits. - if self.order_id.state == "done": - raise UserError( - _( - "Cancel remaining quantities can't be called for blocked purchase orders. " - "First unblock the purchase order" - ) - ) + + # Manejar órdenes bloqueadas (done): desbloquear temporalmente sin tracking + orders_to_relock = self.env["purchase.order"] + for order in self.mapped("order_id").filtered(lambda o: o.state == "done"): + orders_to_relock |= order + # Desbloquear sin generar mensaje en el chatter + order.with_context(tracking_disable=True).write({"state": "purchase"}) + bom_enable = "bom_ids" in self.env["product.template"]._fields for rec in self: old_product_qty = rec.product_qty @@ -106,6 +107,10 @@ def button_cancel_remaining(self): % (rec.name, rec.id, old_product_qty, rec.product_qty) ) + # Volver a bloquear las órdenes que estaban bloqueadas sin generar mensaje + if orders_to_relock: + orders_to_relock.with_context(tracking_disable=True).write({"state": "done"}) + def _compute_vouchers(self): # Cambiamos esta lógica ya que antes teníamos si o si voucher_ids por dependencias y ahora va a depender de que esté instalado stock_voucher for rec in self: From b9848b694ccbf61907257eab24c7714ede8e6dec Mon Sep 17 00:00:00 2001 From: Camila Vives Date: Tue, 7 Apr 2026 15:10:17 +0000 Subject: [PATCH 07/15] [IMP] purchase_ux: Add new price_subtotal field closes ingadhoc/purchase#314 Signed-off-by: Matias Velazquez --- purchase_ux/models/purchase_order_line.py | 9 +++++++++ purchase_ux/views/purchase_line_views.xml | 3 +++ 2 files changed, 12 insertions(+) diff --git a/purchase_ux/models/purchase_order_line.py b/purchase_ux/models/purchase_order_line.py index cfd73d9b..9310f35f 100644 --- a/purchase_ux/models/purchase_order_line.py +++ b/purchase_ux/models/purchase_order_line.py @@ -40,6 +40,15 @@ class PurchaseOrderLine(models.Model): default=0.0, ) + # Campo relacionado para poder usar aggregator en agrupaciones sin afectar el campo base + price_subtotal_aggregable = fields.Monetary( + related="price_subtotal", + string="Subtotal ", + aggregator="sum", + store=False, + readonly=True, + ) + @api.depends("order_id.state", "qty_invoiced", "product_qty", "qty_to_invoice", "order_id.force_invoiced_status") def _compute_invoice_status(self): precision = self.env["decimal.precision"].precision_get("Product Unit of Measure") diff --git a/purchase_ux/views/purchase_line_views.xml b/purchase_ux/views/purchase_line_views.xml index 6de1709f..a09cb4d5 100644 --- a/purchase_ux/views/purchase_line_views.xml +++ b/purchase_ux/views/purchase_line_views.xml @@ -25,6 +25,9 @@ + + + From bb84c5900115743f6cbbd1ca1ec7e2b9269bde65 Mon Sep 17 00:00:00 2001 From: roboadhoc Date: Tue, 7 Apr 2026 17:12:36 +0000 Subject: [PATCH 08/15] [BOT] Bump version: purchase_ux 18.0.1.3.0 Merged: ingadhoc/purchase#314 --- purchase_ux/__manifest__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/purchase_ux/__manifest__.py b/purchase_ux/__manifest__.py index 4ff796ca..5e30452a 100644 --- a/purchase_ux/__manifest__.py +++ b/purchase_ux/__manifest__.py @@ -19,7 +19,7 @@ ############################################################################## { "name": "Purchase UX", - "version": "18.0.1.2.0", + "version": "18.0.1.3.0", "category": "Purchases", "sequence": 14, "summary": "", From 4d032f2e850f9dc184ba3634b5f3384b538d1315 Mon Sep 17 00:00:00 2001 From: Camila Vives Date: Mon, 20 Apr 2026 17:55:43 +0000 Subject: [PATCH 09/15] [IMP] purchase_ux: ignore incompatible UoM lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `action_purchase_matching` on `account.move` to set the `purchase_matching_from_button` context flag - Modify `_compute_product_uom_qty` in `purchase_bill_line_match` to ignore lines with incompatible UoM categories when opened from the purchase matching action - Preserve previous behavior when context flag is not present Change note: Al usar la acción de 'purchase matching' desde la factura de proveedor, ahora se marca el contexto para aplicar un filtrado especial en la vista. Las líneas con unidades de medida incompatibles se ignoran durante la búsqueda de coincidencias, mostrando solo las líneas válidas y evitando el error de UoM. closes ingadhoc/purchase#317 Signed-off-by: rov-adhoc --- purchase_ux/models/account_move.py | 9 +++++++++ purchase_ux/models/purchase_bill_line_match.py | 13 +++++++++++++ 2 files changed, 22 insertions(+) diff --git a/purchase_ux/models/account_move.py b/purchase_ux/models/account_move.py index 90d95d76..40481fb4 100644 --- a/purchase_ux/models/account_move.py +++ b/purchase_ux/models/account_move.py @@ -82,3 +82,12 @@ def update_prices_with_supplier_cost(self): def get_product_lines_to_update(self): return self.with_company(self.company_id.id).invoice_line_ids.filtered(lambda x: x.product_id and x.price_unit) + + def action_purchase_matching(self): + res = super().action_purchase_matching() + # mark the action so compute method in the view can apply special filtering + if isinstance(res, dict): + ctx = dict(res.get("context") or {}) + ctx["purchase_matching_from_button"] = True + res["context"] = ctx + return res diff --git a/purchase_ux/models/purchase_bill_line_match.py b/purchase_ux/models/purchase_bill_line_match.py index ab14d400..ad08ca9f 100644 --- a/purchase_ux/models/purchase_bill_line_match.py +++ b/purchase_ux/models/purchase_bill_line_match.py @@ -37,3 +37,16 @@ def _compute_reference_description(self): rec.reference_description = rec.pol_id.name else: rec.reference_description = rec.display_name + + def _compute_product_uom_qty(self): + # Only apply the incompatibility filter when the view was opened + # via the purchase matching action (context flag set by the action). + if self.env.context.get("purchase_matching_from_button"): + for rec in self: + if rec.line_uom_id.category_id.id != rec.product_uom_id.category_id.id: + # incompatible categories: ignore this line for matching + rec.product_uom_qty = 0.0 + else: + rec.product_uom_qty = rec.line_uom_id._compute_quantity(rec.line_qty, rec.product_uom_id) + else: + return super()._compute_product_uom_qty() From 2efb26869a8fd3cbd167defcc2d8acab78153e8d Mon Sep 17 00:00:00 2001 From: les-adhoc Date: Wed, 20 May 2026 18:44:01 +0000 Subject: [PATCH 10/15] [FIX] purchase_stock_ux: select supplier by location company closes ingadhoc/purchase#323 Signed-off-by: Matias Velazquez --- purchase_stock_ux/models/stock_orderpoint.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/purchase_stock_ux/models/stock_orderpoint.py b/purchase_stock_ux/models/stock_orderpoint.py index 2d55c4a9..7bf1806c 100644 --- a/purchase_stock_ux/models/stock_orderpoint.py +++ b/purchase_stock_ux/models/stock_orderpoint.py @@ -12,6 +12,8 @@ class StockWarehouseOrderpoint(models.Model): def _get_orderpoint_values(self, product, location): values = super()._get_orderpoint_values(product, location) product = self.env["product.product"].browse(product) - if product.seller_ids: - values["supplier_id"] = product.seller_ids[0].id + location = self.env["stock.location"].browse(location) + seller = product.with_company(location.company_id)._select_seller() + if seller: + values["supplier_id"] = seller.id return values From 89ce1423244589b3cd71213336d8349dccea5625 Mon Sep 17 00:00:00 2001 From: les-adhoc Date: Mon, 8 Jun 2026 16:27:37 +0000 Subject: [PATCH 11/15] [FIX] purchase_ux: initialize date_planned for new lines in confirmed POs closes ingadhoc/purchase#330 Signed-off-by: Matias Velazquez --- purchase_ux/models/purchase_order_line.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/purchase_ux/models/purchase_order_line.py b/purchase_ux/models/purchase_order_line.py index 9310f35f..14ffc505 100644 --- a/purchase_ux/models/purchase_order_line.py +++ b/purchase_ux/models/purchase_order_line.py @@ -173,6 +173,17 @@ def _compute_price_unit_and_date_planned_and_name(self): price_update_lines = self.filtered(lambda x: x.state not in ["purchase", "done"]) res = super(PurchaseOrderLine, price_update_lines)._compute_price_unit_and_date_planned_and_name() + # Lines in confirmed/done POs without date_planned are new lines added after confirmation. + # Price and description must NOT be updated (rule a), but date_planned must be initialized. + for line in (self - price_update_lines).filtered(lambda x: not x.date_planned and x.product_id): + seller = line.product_id._select_seller( + partner_id=line.partner_id, + quantity=line.product_qty, + date=line.order_id.date_order and line.order_id.date_order.date() or fields.Date.context_today(line), + uom_id=line.product_uom, + ) + line.date_planned = line._get_date_planned(seller) + for line in price_update_lines.filtered(lambda x: x.product_id and not x.price_unit): price_unit = line.with_company(line.company_id.id).product_id.standard_price if price_unit and line.currency_id != line.company_id.currency_id: From 3611ad5fa720aed9a9464180c662fd39c74f4055 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Carreras Date: Tue, 9 Jun 2026 18:09:17 +0000 Subject: [PATCH 12/15] [FIX] purchase_stock_ux: don't cancel done downstream moves when deleting a PO line Standard purchase_stock PurchaseOrderLine.unlink() calls move_dest_ids._action_cancel() for propagate_cancel lines without filtering out moves already in 'done' state. When an MTO line's customer delivery was already shipped from on-hand stock, deleting the purchase line raised 'You cannot cancel a stock move that has been set to Done', blocking the edition of the RFQ. button_cancel() already guards against this (it filters state != 'done' before cancelling). Mirror that protection in unlink(): detach the already-done downstream moves before the standard unlink so they are no longer cancelled. The delivered move stays untouched. Adds a regression test reproducing the case. closes ingadhoc/purchase#333 Signed-off-by: Matias Velazquez --- purchase_stock_ux/__manifest__.py | 2 +- .../models/purchase_order_line.py | 21 +++++++++ .../tests/test_purchase_order.py | 46 +++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/purchase_stock_ux/__manifest__.py b/purchase_stock_ux/__manifest__.py index fa5c5dc7..cb6165ca 100644 --- a/purchase_stock_ux/__manifest__.py +++ b/purchase_stock_ux/__manifest__.py @@ -19,7 +19,7 @@ ############################################################################## { "name": "Purchase Stock UX", - "version": "18.0.1.1.0", + "version": "18.0.1.2.0", "category": "Purchases", "sequence": 14, "summary": "", diff --git a/purchase_stock_ux/models/purchase_order_line.py b/purchase_stock_ux/models/purchase_order_line.py index 9fb77d98..8eaa5c30 100644 --- a/purchase_stock_ux/models/purchase_order_line.py +++ b/purchase_stock_ux/models/purchase_order_line.py @@ -59,6 +59,27 @@ def _compute_qty_on_voucher(self): for line in lines: line.qty_on_voucher = sum(moves.filtered(lambda x: x.id in line.move_ids.ids).mapped("product_uom_qty")) + def unlink(self): + """Evitar el bloqueo al borrar líneas MTO cuya entrega ya se hizo. + + El unlink estándar de ``purchase_stock`` cancela los ``move_dest_ids`` de + las líneas con ``propagate_cancel`` sin filtrar los movimientos ya + realizados. Si el destino -la entrega al cliente de una línea MTO- ya + está en estado ``done`` (porque se entregó desde stock disponible), + ``stock.move._action_cancel()`` lanza "No puede cancelar un movimiento de + existencias que se haya configurado como 'Hecho'" y bloquea el borrado. + + ``purchase.order.button_cancel()`` ya contempla este caso filtrando los + movimientos ``state != 'done'`` antes de cancelar; replicamos esa misma + protección acá: desvinculamos los movimientos destino ya hechos para que + el borrado no intente cancelarlos. El movimiento entregado queda intacto. + """ + for line in self: + done_dest_moves = line.move_dest_ids.filtered(lambda m: m.state == "done" and not m.scrapped) + if done_dest_moves: + line.move_dest_ids = [fields.Command.unlink(move.id) for move in done_dest_moves] + return super().unlink() + def button_cancel_remaining(self): # la cancelación de kits no está bien resuelta ya que odoo # solo computa la cantidad entregada cuando todo el kit se entregó. diff --git a/purchase_stock_ux/tests/test_purchase_order.py b/purchase_stock_ux/tests/test_purchase_order.py index 6aa4543d..71556199 100644 --- a/purchase_stock_ux/tests/test_purchase_order.py +++ b/purchase_stock_ux/tests/test_purchase_order.py @@ -30,3 +30,49 @@ def setUpClass(cls): ) cls.purchase_order.button_confirm() + + def test_unlink_line_with_done_dest_move(self): + """Borrar una línea de OC cuya entrega destino ya está 'done' no debe fallar. + + Reproduce el caso MTO en el que la venta se entregó desde stock disponible + (movimiento destino en 'done') y luego se quiere eliminar la línea de la OC + que la abastecía. El unlink estándar de purchase_stock intentaría cancelar + ese movimiento ya hecho y lanzaría UserError; con el fix se desvincula en + lugar de cancelarlo. + """ + draft_po = self.env["purchase.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + Command.create( + { + "product_id": self.product.id, + "product_qty": 5, + "price_unit": 50, + } + ), + ], + } + ) + pol = draft_po.order_line + pol.propagate_cancel = True + + done_move = self.env["stock.move"].create( + { + "name": "Test delivery move", + "product_id": self.product.id, + "product_uom_qty": 5, + "product_uom": self.product.uom_id.id, + "location_id": self.env.ref("stock.stock_location_stock").id, + "location_dest_id": self.env.ref("stock.stock_location_customers").id, + } + ) + done_move.write({"state": "done"}) + pol.move_dest_ids = [Command.link(done_move.id)] + + # No debe lanzar UserError por el movimiento 'Hecho'. + pol.unlink() + + # El movimiento entregado debe quedar intacto. + self.assertEqual(done_move.state, "done") + self.assertFalse(draft_po.order_line) From aa030d2d0f7a7039b7833c7a3678522782e60c90 Mon Sep 17 00:00:00 2001 From: mav-adhoc Date: Wed, 22 Apr 2026 17:17:23 -0300 Subject: [PATCH 13/15] [IMP]pruchase_ux: new features in purchase.bill.line.match closes ingadhoc/purchase#320 Signed-off-by: Juan Carreras --- purchase_ux/__manifest__.py | 2 +- purchase_ux/models/account_move.py | 17 ++++++++++ .../models/purchase_bill_line_match.py | 34 ++++++++++++------- .../views/purchase_bill_line_match_views.xml | 8 +++++ 4 files changed, 48 insertions(+), 13 deletions(-) diff --git a/purchase_ux/__manifest__.py b/purchase_ux/__manifest__.py index 5e30452a..50896c1c 100644 --- a/purchase_ux/__manifest__.py +++ b/purchase_ux/__manifest__.py @@ -19,7 +19,7 @@ ############################################################################## { "name": "Purchase UX", - "version": "18.0.1.3.0", + "version": "18.0.1.4.0", "category": "Purchases", "sequence": 14, "summary": "", diff --git a/purchase_ux/models/account_move.py b/purchase_ux/models/account_move.py index 40481fb4..93775782 100644 --- a/purchase_ux/models/account_move.py +++ b/purchase_ux/models/account_move.py @@ -86,8 +86,25 @@ def get_product_lines_to_update(self): def action_purchase_matching(self): res = super().action_purchase_matching() # mark the action so compute method in the view can apply special filtering + # also pass the current move id so matching adds POLs to this bill instead of creating a new one if isinstance(res, dict): ctx = dict(res.get("context") or {}) ctx["purchase_matching_from_button"] = True + ctx["default_account_move_id"] = self.id res["context"] = ctx + # Show only POLs where ordered qty > invoiced qty (fully invoiced lines excluded). + all_pols = self.env["purchase.order.line"].search( + [ + ("partner_id", "in", (self.partner_id | self.partner_id.commercial_partner_id).ids), + ("state", "in", ["purchase", "done"]), + ] + ) + # exclude POLs already matched to a line in this bill (qty_invoiced ignores drafts) + already_matched = set(self.invoice_line_ids.filtered("purchase_line_id").mapped("purchase_line_id").ids) + pending_pol_ids = all_pols.filtered( + lambda p: p.product_qty > p.qty_invoiced and p.id not in already_matched + ).ids + domain = list(res.get("domain") or []) + domain += ["|", ("pol_id", "=", False), ("pol_id", "in", pending_pol_ids)] + res["domain"] = domain return res diff --git a/purchase_ux/models/purchase_bill_line_match.py b/purchase_ux/models/purchase_bill_line_match.py index ad08ca9f..9803ec0f 100644 --- a/purchase_ux/models/purchase_bill_line_match.py +++ b/purchase_ux/models/purchase_bill_line_match.py @@ -7,6 +7,7 @@ class PurchaseBillLineMatch(models.Model): _inherit = "purchase.bill.line.match" + _order = "state, product_id, aml_id, pol_id" reference_description = fields.Char( string="Description", @@ -19,25 +20,34 @@ class PurchaseBillLineMatch(models.Model): readonly=True, ) + date_order = fields.Datetime(related="pol_id.order_id.date_order", readonly=True) + @api.depends("pol_id", "product_id", "display_name") def _compute_reference_description(self): + lang = self.env.user.lang for rec in self: - if rec.pol_id and rec.product_id: + if rec.pol_id: pol_name = rec.pol_id.name or "" - product_name = rec.product_id.display_name or "" - if pol_name.startswith(product_name): - remaining = pol_name[len(product_name) :].strip() - if remaining: - rec.reference_description = f"{product_name} - {remaining}" - else: - rec.reference_description = product_name - else: - rec.reference_description = pol_name - elif rec.pol_id: - rec.reference_description = rec.pol_id.name + # pol.name format: "[ref] Product Name\nExtra description" + # Use only the extra description if present, otherwise translated product name + parts = pol_name.split("\n", 1) + extra = parts[1].strip() if len(parts) > 1 else "" + rec.reference_description = extra or rec.pol_id.product_id.with_context(lang=lang).display_name else: rec.reference_description = rec.display_name + def action_match_lines(self): + """When opened from an existing draft bill and only PO lines are selected + (no aml_id), add the POLs to the current bill instead of creating a new one. + """ + account_move_id = self.env.context.get("default_account_move_id") + if account_move_id and not self.aml_id and self.pol_id: + bill = self.env["account.move"].browse(account_move_id) + if bill.exists() and bill.state == "draft": + bill._add_purchase_order_lines(self.pol_id) + return bill._get_records_action() + return super().action_match_lines() + def _compute_product_uom_qty(self): # Only apply the incompatibility filter when the view was opened # via the purchase matching action (context flag set by the action). diff --git a/purchase_ux/views/purchase_bill_line_match_views.xml b/purchase_ux/views/purchase_bill_line_match_views.xml index 6ccf14cb..ecfee302 100644 --- a/purchase_ux/views/purchase_bill_line_match_views.xml +++ b/purchase_ux/views/purchase_bill_line_match_views.xml @@ -6,6 +6,7 @@ + @@ -19,10 +20,17 @@ purchase.bill.line.match + + + + + + + From 419f811224534def7b04e0bdae7bf90f6e9decb3 Mon Sep 17 00:00:00 2001 From: mav-adhoc Date: Mon, 6 Jul 2026 16:22:16 +0000 Subject: [PATCH 14/15] [ADD] purchase_order_type_ux: lock on confirmation per order type Add a per-type "Lock on Confirmation" boolean on purchase.order.type. When enabled, purchase orders of that type are automatically locked on final approval, by overriding button_approve so the lock respects the double-validation flow (it only applies once the order is actually approved, not while it is still "to approve"). The behavior is additive to the core "Lock Confirmed Orders" company setting: when that global setting is on, every confirmed order is locked regardless of the per-type flag, and the global setting always prevails. The flag defaults to False, so existing databases keep the current behavior on update. A note next to the global setting in Purchase Settings explains the interaction. In purchase the "locked" state is represented by state == 'done' (there is no dedicated `locked` field as in sale), so the per-type lock writes that state, mirroring the core global-lock branch in button_approve. Includes tests covering the per-type lock, the no-lock default, the global setting precedence and the double-validation flow. closes ingadhoc/purchase#345 Signed-off-by: Luciano Esperlazza --- purchase_order_type_ux/README.rst | 2 + purchase_order_type_ux/__manifest__.py | 3 +- purchase_order_type_ux/i18n/es.po | 5 + .../models/purchase_order.py | 11 +++ .../models/purchase_order_type.py | 20 +++- purchase_order_type_ux/tests/__init__.py | 1 + .../tests/test_purchase_order_type_ux.py | 94 +++++++++++++++++++ .../views/purchase_order_type_views.xml | 1 + .../views/res_config_settings_views.xml | 20 ++++ 9 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 purchase_order_type_ux/tests/__init__.py create mode 100644 purchase_order_type_ux/tests/test_purchase_order_type_ux.py create mode 100644 purchase_order_type_ux/views/res_config_settings_views.xml diff --git a/purchase_order_type_ux/README.rst b/purchase_order_type_ux/README.rst index 6a5c7524..3004092f 100644 --- a/purchase_order_type_ux/README.rst +++ b/purchase_order_type_ux/README.rst @@ -17,6 +17,8 @@ Purchase Order Type UX On purchase order type: #. Adds to the purchase order type the report partner, the project, the billing journal and the billing policy. +#. Adds a per-type "Lock on Confirmation" flag that locks orders of that type when they are approved. It is + additive to the global "Lock Confirmed Orders" setting, which always prevails. Installation ============ diff --git a/purchase_order_type_ux/__manifest__.py b/purchase_order_type_ux/__manifest__.py index 2d823314..20fa2208 100644 --- a/purchase_order_type_ux/__manifest__.py +++ b/purchase_order_type_ux/__manifest__.py @@ -20,7 +20,7 @@ { "name": "Purchase Order Type UX", - "version": "18.0.1.2.0", + "version": "18.0.1.3.0", "category": "Purchases", "sequence": 14, "summary": "", @@ -31,6 +31,7 @@ "depends": ["purchase_stock_ux", "l10n_ar_purchase", "purchase_order_type", "account_multicompany_ux"], "data": [ "views/purchase_order_type_views.xml", + "views/res_config_settings_views.xml", "report/purchase_quotation_templates.xml", ], "installable": True, diff --git a/purchase_order_type_ux/i18n/es.po b/purchase_order_type_ux/i18n/es.po index 19e9d967..fae19c7c 100644 --- a/purchase_order_type_ux/i18n/es.po +++ b/purchase_order_type_ux/i18n/es.po @@ -52,6 +52,11 @@ msgstr "" msgid "Journal Entry" msgstr "Asiento contable" +#. module: purchase_order_type_ux +#: model:ir.model.fields,field_description:purchase_order_type_ux.field_purchase_order_type__set_locked_on_confirmation +msgid "Lock on Confirmation" +msgstr "Bloquear al confirmar" + #. module: purchase_order_type_ux #: model:ir.model.fields.selection,name:purchase_order_type_ux.selection__purchase_order_type__purchase_method__purchase msgid "On ordered quantities" diff --git a/purchase_order_type_ux/models/purchase_order.py b/purchase_order_type_ux/models/purchase_order.py index 797c15bf..b05f173e 100644 --- a/purchase_order_type_ux/models/purchase_order.py +++ b/purchase_order_type_ux/models/purchase_order.py @@ -23,6 +23,17 @@ def onchange_order_type(self): if order.order_type.fiscal_position_id: order.fiscal_position_id = order.order_type.fiscal_position_id + def button_approve(self, force=False): + res = super().button_approve(force=force) + # En compras el "bloqueo" es state == 'done' (no hay campo locked como en ventas). El lock + # global del core ya lo hace acá para po_lock == 'lock'; lo replicamos por tipo de forma + # aditiva: solo afectamos órdenes recién aprobadas (state 'purchase'), nunca las que siguen + # 'to approve' por doble validación pendiente. + self.filtered(lambda o: o.order_type.set_locked_on_confirmation and o.state == "purchase").write( + {"state": "done"} + ) + return res + def _prepare_invoice(self): if not self.order_type.journal_id: return super()._prepare_invoice() diff --git a/purchase_order_type_ux/models/purchase_order_type.py b/purchase_order_type_ux/models/purchase_order_type.py index 5b2218cd..28a65de8 100644 --- a/purchase_order_type_ux/models/purchase_order_type.py +++ b/purchase_order_type_ux/models/purchase_order_type.py @@ -1,7 +1,7 @@ # Copyright (C) 2015 Camptocamp SA # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). -from odoo import fields, models +from odoo import api, fields, models class PurchaseOrderType(models.Model): @@ -43,3 +43,21 @@ class PurchaseOrderType(models.Model): help="If you choose a fiscal position then this fiscal positioon would be used as default instead of the " "automatically detected or setted on the partner", ) + set_locked_on_confirmation = fields.Boolean( + string="Lock on Confirmation", + help="If enabled, purchase orders of this type are automatically locked when they are approved," + " preventing further edits. This is additive to the global 'Lock Confirmed Orders' setting: when that" + " setting is on, every confirmed order is locked regardless of this flag, and the global setting prevails.", + ) + lock_confirmed_po_setting = fields.Boolean( + compute="_compute_lock_confirmed_po_setting", + help="Technical field: True when the global 'Lock Confirmed Orders' setting is enabled for this type's" + " company. Analogous to sale_order_type_automation's 'auto_done_setting', it hides the per-type" + " 'Lock on Confirmation' flag while the global setting prevails, since the flag would then be irrelevant.", + ) + + @api.depends("company_id") + def _compute_lock_confirmed_po_setting(self): + for rec in self: + company = rec.company_id or self.env.company + rec.lock_confirmed_po_setting = company.sudo().po_lock == "lock" diff --git a/purchase_order_type_ux/tests/__init__.py b/purchase_order_type_ux/tests/__init__.py new file mode 100644 index 00000000..56012125 --- /dev/null +++ b/purchase_order_type_ux/tests/__init__.py @@ -0,0 +1 @@ +from . import test_purchase_order_type_ux diff --git a/purchase_order_type_ux/tests/test_purchase_order_type_ux.py b/purchase_order_type_ux/tests/test_purchase_order_type_ux.py new file mode 100644 index 00000000..5bbb6c27 --- /dev/null +++ b/purchase_order_type_ux/tests/test_purchase_order_type_ux.py @@ -0,0 +1,94 @@ +# Copyright 2026 ADHOC SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import Command +from odoo.addons.purchase_stock.tests.common import PurchaseTestCommon +from odoo.tests import tagged + + +@tagged("post_install", "-at_install") +class TestPurchaseOrderTypeLock(PurchaseTestCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env.ref("base.main_company") + # El lock global arranca apagado para aislar el efecto del flag por tipo. + cls.company.po_lock = "edit" + cls.vendor = cls.env["res.partner"].create({"name": "Test Vendor"}) + cls.type_lock = cls.env["purchase.order.type"].create( + {"name": "Locking Type", "set_locked_on_confirmation": True} + ) + cls.type_no_lock = cls.env["purchase.order.type"].create( + {"name": "Non-locking Type", "set_locked_on_confirmation": False} + ) + + def _create_po(self, order_type, price_unit=100.0): + return self.env["purchase.order"].create( + { + "partner_id": self.vendor.id, + "order_type": order_type.id, + "order_line": [ + Command.create( + { + "product_id": self.product_1.id, + "product_qty": 1.0, + "price_unit": price_unit, + } + ), + ], + } + ) + + def test_01_lock_on_confirmation_type_flag(self): + """AC2: tipo con flag ON + global OFF -> la orden queda bloqueada (state 'done').""" + po = self._create_po(self.type_lock) + po.button_confirm() + self.assertEqual(po.state, "done", "La OC de un tipo con 'Lock on Confirmation' debe quedar bloqueada") + + def test_02_no_lock_without_flag(self): + """AC3: tipo con flag OFF + global OFF -> la orden queda editable (state 'purchase').""" + po = self._create_po(self.type_no_lock) + po.button_confirm() + self.assertEqual(po.state, "purchase", "Sin flag y sin lock global la OC debe quedar editable") + + def test_03_global_lock_dominates(self): + """AC4: lock global ON -> bloquea siempre, sin importar el flag (apagado) del tipo.""" + self.company.po_lock = "lock" + po = self._create_po(self.type_no_lock) + po.button_confirm() + self.assertEqual(po.state, "done", "Con el lock global prendido la OC debe quedar bloqueada igual") + + def test_04_double_validation_respected(self): + """AC5: con doble validación el bloqueo se aplica al aprobar, no al confirmar.""" + self.company.write( + { + "po_double_validation": "two_step", + "po_double_validation_amount": 100.0, + } + ) + # Monto por encima del umbral confirmado por un usuario sin permiso de aprobar. + po = self._create_po(self.type_lock, price_unit=1000.0) + po.with_user(self.res_users_purchase_user).button_confirm() + self.assertEqual(po.state, "to approve", "Pendiente de aprobación no debe bloquearse todavía") + # Aprobación final (usuario manager) -> recién ahora bloquea. + po.button_approve() + self.assertEqual(po.state, "done", "Tras la aprobación final la OC del tipo con flag debe bloquearse") + + def test_05_flag_hidden_setting_reflects_global(self): + """Análogo a Ventas: el check por tipo se oculta cuando el global manda. + + La visibilidad la gobierna `lock_confirmed_po_setting`, que refleja el lock global de la + compañía del tipo. Con el global apagado el check queda visible; al prenderlo, se oculta. + """ + self.type_lock.company_id = self.company + self.company.po_lock = "edit" + self.assertFalse( + self.type_lock.lock_confirmed_po_setting, + "Con el lock global apagado el check por tipo debe seguir visible", + ) + self.company.po_lock = "lock" + self.type_lock.invalidate_recordset(["lock_confirmed_po_setting"]) + self.assertTrue( + self.type_lock.lock_confirmed_po_setting, + "Con el lock global prendido el check por tipo debe ocultarse (como en Ventas)", + ) diff --git a/purchase_order_type_ux/views/purchase_order_type_views.xml b/purchase_order_type_ux/views/purchase_order_type_views.xml index 1584b5a5..a4c5444a 100644 --- a/purchase_order_type_ux/views/purchase_order_type_views.xml +++ b/purchase_order_type_ux/views/purchase_order_type_views.xml @@ -19,6 +19,7 @@ + diff --git a/purchase_order_type_ux/views/res_config_settings_views.xml b/purchase_order_type_ux/views/res_config_settings_views.xml new file mode 100644 index 00000000..66ce9801 --- /dev/null +++ b/purchase_order_type_ux/views/res_config_settings_views.xml @@ -0,0 +1,20 @@ + + + + res.config.settings.view.form.inherit.purchase.order.type.ux + res.config.settings + + + +
+ When enabled, every confirmed order is locked, regardless of the per-type + "Lock on Confirmation" flag. That flag (off by default on each Purchase Order Type) + only takes effect when this setting is off; this global setting always prevails. +
+
+
+
+
From 95122e2d9e520aad0de4de96bcd7bf1b950ecd9a Mon Sep 17 00:00:00 2001 From: Virginia Date: Mon, 20 Jul 2026 16:40:03 -0300 Subject: [PATCH 15/15] [IMP] Update repository from template --- .copier-answers.yml | 2 +- .github/copilot-instructions.md | 356 +++--------------- .github/instructions/i18n.instructions.md | 71 ++++ .github/instructions/manifest.instructions.md | 48 +++ .../instructions/migrations.instructions.md | 59 +++ .github/instructions/models.instructions.md | 68 ++++ .../instructions/performance.instructions.md | 82 ++++ .github/instructions/security.instructions.md | 62 +++ .github/instructions/tests.instructions.md | 64 ++++ .github/instructions/views.instructions.md | 60 +++ .github/workflows/pre-commit.yml | 26 +- .gitignore | 3 + .pre-commit-config.yaml | 2 +- 13 files changed, 591 insertions(+), 312 deletions(-) create mode 100644 .github/instructions/i18n.instructions.md create mode 100644 .github/instructions/manifest.instructions.md create mode 100644 .github/instructions/migrations.instructions.md create mode 100644 .github/instructions/models.instructions.md create mode 100644 .github/instructions/performance.instructions.md create mode 100644 .github/instructions/security.instructions.md create mode 100644 .github/instructions/tests.instructions.md create mode 100644 .github/instructions/views.instructions.md diff --git a/.copier-answers.yml b/.copier-answers.yml index 7490032c..41349916 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,5 +1,5 @@ # Do NOT update manually; changes here will be overwritten by Copier -_commit: a740779 +_commit: 8677dea _src_path: https://github.com/ingadhoc/addons-repo-template.git description: ADHOC Odoo purchase Modules is_private: false diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 783d6e4d..fc9d7d5f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,312 +1,56 @@ -# Instrucciones para Copilot – Revisión de código Odoo (v18.0) +# Instrucciones para Copilot – Revisión de código Odoo ## Contexto -* El repositorio contiene **módulos Odoo preparados para Odoo 18** (rama `18.0`). -* El objetivo es **revisar cambios de código** y **sugerir mejoras seguras y relevantes**, sin caer en micro-comentarios. +Este repositorio contiene módulos Odoo. La versión objetivo está declarada en `__manifest__.py` de cada módulo. Las reglas específicas por dominio viven en `.github/instructions/*.instructions.md`, cada una con `applyTo:` que delimita a qué archivos aplica. ---- - -## Reglas generales (aplican a todo el código) +## Reglas globales (aplican a todo cambio) 1. **Responder siempre en español.** -2. Detectar y corregir **errores de tipeo u ortografía evidentes** en nombres de variables, métodos o comentarios (cuando sean claros). -3. No sugerir traducciones de docstrings o comentarios entre idiomas (no proponer pasar del inglés al español o viceversa). -4. No proponer agregar docstrings si el método no tiene uno. - - * Si ya existe un docstring, puede sugerirse un estilo básico acorde a PEP8, pero **no será un error** si faltan `return`, tipos o parámetros documentados. -5. No proponer cambios puramente estéticos (espacios, comillas simples vs dobles, orden de imports, etc.). -6. Mantener el feedback **muy conciso** en los PRs: priorizar pocos puntos claros, evitar párrafos largos y no repetir el contexto que ya está explicado en la descripción del PR. -7. Sobre traducciones: usar `_()` o `self.env._()` es indistinto; solo marcar si hay mensajes de error o textos no traducidos que deban serlo. - ---- - -## Revisión de modelos (`models/*.py`) – cuestiones generales - -* Verificar que: - - * Los campos (`fields.*`) tengan nombres claros, consistentes y no entren en conflicto con otros módulos. - * Las relaciones (`Many2one`, `One2many`, `Many2many`) estén bien definidas y referencien modelos válidos, con `ondelete` apropiado. - * Las constraints declaradas con `_sql_constraints` o `@api.constrains` mantengan la integridad esperada y mensajes claros. -* Sugerir uso de `@api.depends` si un campo compute carece de dependencias explícitas. -* Si se redefine un método de Odoo, asegurar que se llama correctamente `super()`, manteniendo el contrato original. -* Si hay lógica nueva, evitar loops costosos con búsquedas dentro de iteraciones; sugerir `mapped`, `filtered`, dominios vectorizados u otras formas más eficientes. - ---- - -## 🧾 Revisión del manifest (`__manifest__.py`) – reglas generales - -* Confirmar que todos los archivos usados (vistas, seguridad, datos, reportes, wizards) estén referenciados en el manifest. -* Verificar dependencias declaradas: que no falten módulos requeridos ni se declaren innecesarios. -* Solo hacerlo una vez por revisión, aunque haya múltiples archivos afectados. - ---- - -## Revisión de vistas XML (`views/*.xml`) – reglas generales - -* Confirmar que se usen herencias (`inherit_id`, `xpath`) en lugar de redefinir vistas completas sin necesidad. -* Validar que los campos referenciados existan en los modelos correspondientes. -* Evitar duplicar gran parte del `arch`; prioriza `xpath` específicos y claros. - -### Notas específicas Odoo 18 (vistas / UI) - -* Las vistas de lista usan el nuevo elemento `` en lugar de ``; si se ve código nuevo en 18 que sigue usando `` para listas estándar, sugiere adaptarlo cuando sea coherente con el resto del módulo. -* Muchas condiciones en vistas pueden escribirse con atributos declarativos (`invisible`, `readonly`, `required`) más simples que combinaciones complejas de `attrs`; sugiere simplificar cuando el diff haga la vista más compleja sin necesidad. - ---- - -## Seguridad y acceso – reglas generales - -* Verificar los archivos `ir.model.access.csv` para nuevos modelos: deben tener permisos mínimos necesarios. -* No proponer abrir acceso global sin justificación. -* Si se cambian `record rules`, revisar especialmente combinaciones multi-compañía y multi-website. - -### Seguridad y rendimiento del ORM - -* Reforzar las advertencias sobre **SQL crudo**: si el diff muestra `self.env.cr.execute("...%s..." % var)` u otras interpolaciones inseguras, recomendar reemplazarlo por dominios ORM (`search`, `browse`) o, si es inevitable, parametrizar la query para heredar sanitización y reglas de acceso. - * Ejemplo inseguro que debe marcarse: `self.env.cr.execute("SELECT * FROM res_partner WHERE email = '%s'" % email)`. - * Variante segura aceptable: `self.env.cr.execute("SELECT * FROM res_partner WHERE email = %s", (email,))` o, mejor aún, `self.env['res.partner'].search([('email', '=', email)])`. -* Señalar cualquier uso de `eval` o construcción manual de domains a partir de input de usuario (`eval(domain_string)`), proponiendo dominios expresados como listas de tuplas o mediante objetos `Domain`. - * Ejemplo inseguro: `records = self.env['res.partner'].search(eval("[('name','ilike','%s')]" % user_input))`. - * Forma segura: `records = self.env['res.partner'].search([('name', 'ilike', user_input)])`. -* Vigilar patrones ineficientes comunes: bucles que ejecutan `search`/`write` por registro, filtrados manuales tras `search([])` o cómputos que podrían resolverse con `search_count`, `mapped`, `filtered` o `browse` masivo. - * Ejemplo a señalar: `for partner_id in partner_ids: partner = self.env['res.partner'].search([('id', '=', partner_id)])`. - * Proponer `partners = self.env['res.partner'].browse(partner_ids)` y operar sobre el recordset completo. -* Para lecturas planas o exportaciones, preferir `search_fetch(fields=...)` para limitar columnas y reducir memoria. - * Caso ilustrativo: reemplazar listas armadas a mano con `result = self.env['res.partner'].search_fetch(domain=[('is_company', '=', True)], fields=['name', 'email', 'vat'])`. -* Recordar que los writes vectorizados (`recordset.write`) y las operaciones en lotes evitan locks prolongados y mejoran la trazabilidad de auditoría del ORM. - * Ejemplo recomendado: `partners.write({'comment': 'Actualizado masivamente'})` en lugar de iterar y escribir registro por registro. -* Tener en cuenta la **navegación de campos relacionales** en Odoo: acceder a campos encadenados como `m.fiscal_position_id.l10n_ar_tax_ids` es seguro incluso cuando `fiscal_position_id` está vacío (devuelve un recordset vacío). Por eso, expresiones como `not m.fiscal_position_id.l10n_ar_tax_ids` ya cubren el caso en que no haya posición fiscal y **no hace falta** añadir un chequeo previo separado sobre `fiscal_position_id`. -* Revisar accesos directos por índice en listas o recordsets, por ejemplo `lines[0].id`: si el conjunto está vacío puede lanzar `IndexError`. Copilot debe sugerir patrones más seguros (por ejemplo `if lines: first = lines[0]`) o, cuando sea posible, reescribir la lógica para trabajar sobre el recordset completo en lugar de un único elemento. - ---- - -## Cambios estructurales y scripts de migración – **cuestiones generales** - -Cuando el diff sugiera **cambios de estructura de datos**, **siempre evaluar** si corresponde proponer un **script de migración** en `migrations/` (pre/post/end). - -### Reglas generales de estructura de `migrations/` - -* La carpeta dentro de `migrations/` debe corresponder con la versión declarada en el manifest (p. ej. `migrations/18.0.4.0/`). -* Los scripts deben ser idempotentes, trabajar en lotes y registrar logs claros. - -### Ejemplos de cambios estructurales (actualizado con tus criterios) - -En estos casos **normalmente corresponde** proponer migración (salvo notas en contra): - -1. **Renombrar campos o modelos** - - * **Campos:** proponer migración **solo si el campo es almacenado** en base de datos: - * campos normales (`Char`, `Many2one`, `Boolean`, etc.), - * campos `compute` con `store=True`. - * Campos `compute` **sin** `store=True` no requieren script por el renombre en sí (son virtuales). - * **Modelos:** renombrar modelos **siempre** implica revisar migración (`ir.model`, `ir.model.data`, tablas relacionales, vistas, acciones…). - -2. **Cambiar tipos de campo** - - * Se considera cambio estructural cuando **cambia la representación en la base de datos** (p.ej. `Char → Many2one`, `Selection → Many2one`, `Integer → Monetary`, `Many2one → Many2many`, etc.). - * Cambios “compatibles” a nivel de PostgreSQL **no suelen requerir script**, por ejemplo: - * `Char → Text` o ajustes de tamaño de `Char`; - * cambios de precisión en `Float` sin cambio de semántica. - * Aun así, si el cambio implica lógica nueva (p.ej. pasar de `Boolean` a `Selection` con múltiples estados) puede requerir mapeo de datos. - -3. **Quitar campos para reestructurar información** - - * Por ejemplo, dividir un campo en varios (split) o fusionar varios en uno (merge). - * Siempre revisar si hay datos que deban preservarse antes de eliminar el campo original. - -4. **Agregar campos `compute` almacenados (`store=True`) con backfill** - - * Si el campo nuevo es `compute` y `store=True`, y se espera que tenga valor para **registros históricos**, conviene: - * Proponer **script `post`** que haga el backfill **en lotes**. - * Añadir una **advertencia explícita** cuando el modelo tiene muchos registros (p.ej. millones) para que el cálculo no se haga en una sola transacción que bloquee la tabla. - -5. **Cambiar dominios o valores de campos `selection`** - - * **Añadir nuevos valores de `selection`**: - En general **no requiere migración** si solo se agregan opciones nuevas y no se tocan las existentes. - * **Eliminar o renombrar keys existentes de `selection`**: - * Puede dejar valores históricos huérfanos o inválidos → proponer script que mapee `old_value → new_value` o que normalice registros antiguos. - * Mencionar que hay que tener en cuenta el comportamiento de campos relacionados (p.ej. un `Many2one` con `ondelete` específico) si el `selection` influye en lógica que crea o elimina registros. - * **Cambios de dominio** en campos relacionales (`Many2one`, `Many2many`): - * Si el nuevo dominio excluye valores usados históricamente, puede ser necesario limpiar o remapear datos para que no queden registros en estados imposibles. - * Recordar que el `ondelete` del campo define qué ocurre al eliminar registros apuntados; hay que respetarlo al limpiar datos. - -6. **Cambiar o añadir `_sql_constraints` (unique / index)** - - * Cambios en constraints `UNIQUE` o adición de nuevas constraints/índices pueden **fallar con datos existentes** (duplicados, valores nulos, etc.). - * Al menos, Copilot debe: - * emitir una **advertencia** sobre el riesgo de fallo en el upgrade, - * sugerir revisar datos previos (y, cuando se vea necesario, un **pre-script** que limpie duplicados o normalice datos antes de aplicar la constraint). - -7. **Cambios en `ir.model.data` / XML IDs** - - * Renombres de XML IDs (`module.name → module2.name2`) o cambios en `module` / `name` suelen requerir: - * script para actualizar referencias dependientes (acciones, vistas, menús, records en otros módulos), - * o uso de utilidades de upgrade. - * Caso especial: registros con `no_update="1"`: - * Si cambia solo texto/etiquetas menores, puede no hacer falta migración. - * **Si cambia el contenido lógico** (ej. campo `domain`, configuración, secuencias) y el registro tiene `no_update="1"`, debes **sugerir forzar el cambio**: - * vía script que actualice explícitamente los registros por su `xml_id`, - * o mediante un proceso de “force update” apropiado. - -8. **Cambios de reglas de acceso / propiedad** - - * Cambios profundos en `record rules` o en campos que determinan propiedad (company, website, owner…) pueden necesitar scripts para: - * recomputar propiedad, - * asignar company/website por defecto, - * o migrar datos entre reglas. - -> **Nota:** No se incluye en esta lista el caso “Añadir `required=True` a campos existentes sin default” como condición automática de migración; Copilot no debe sugerir script de migración **solo** por ese motivo, salvo que en el diff se vea claro que hay datos históricos incompatibles. - ---- - -## Scripts de migración en `migrations/`: pre / post / end (reglas generales) - -> **Objetivo:** preservar datos y mantener instalabilidad/actualizabilidad segura. - -- **pre**: Se ejecutan antes de actualizar el módulo. Útiles para preparar datos o estructuras que eviten fallos durante el upgrade. -- **post**: Se ejecutan justo después de actualizar el módulo. Ideales para recalcular datos, limpiar residuos o ajustar referencias tras el cambio. -- **end**: Se ejecutan al final de la actualización de todos los módulos. Indicados para tareas globales que dependen de múltiples módulos o para ajustes finales. - -### Mapeo de cambio → acción recomendada (actualizado) - -* **Rename de campo almacenado (mismo modelo)** - - * **Pre-script**: crear columna/alias temporal o copiar datos del campo viejo al nuevo antes de que Odoo toque el esquema, si el cambio puede romper constraints. - * **Post-script**: limpieza de residuos, recomputes de campos derivados si aplica. - -* **Renombrar modelo** - - * **Pre-script**: preparar mapeos en `ir.model` y `ir.model.data`, y ajustar referencias técnicas si es necesario. - * **Post-script**: re-enlazar vistas, acciones, menús, reglas y volver a chequear accesos. - -* **Eliminar campo y mover datos a otros campos (split/merge)** - - * **Pre-script**: copiar datos a los nuevos campos (cuando sea posible) antes de que el schema elimine la columna original. - * **Post-script**: normalizar referencias, recalcular computes, limpiar helpers. - -* **Agregar campo `compute` con `store=True`** - - * **Pre-script (opcional y solo en modelos muy grandes)**: crear columna en DB o preparar estructura para evitar locks largos. - * **Post-script (recomendado)**: backfill **en lotes** para poblar el valor almacenado; es importante para modelos con muchos registros. - -* **Cambiar tipo de campo con cambio real de representación** - - * **Pre-script**: crear columna temporal con el nuevo tipo y migrar datos (con conversión). - * **Post-script**: intercambiar/renombrar columnas, borrar la vieja, disparar recomputes si hace falta. - -* **Cambios en `selection` (eliminar/renombrar keys existentes)** - - * **Pre-script**: mapear valores antiguos → nuevos (tabla de mapeo) usando helpers como `change_field_selection_values()` cuando aplique. - * **Post-script**: validar que no quedan valores huérfanos y que las reglas de negocio siguen cumpliéndose. - * **Añadir keys nuevas**: **no proponer script** salvo que el diff muestre una migración masiva explícita de valores. - -* **Nuevas constraints `_sql_constraints` (unique) / índices** - - * **Pre-script (recomendado cuando haya riesgo)**: detectar y resolver duplicados o datos inconsistentes antes de crear la constraint. - * **Post-script**: crear el índice/constraint y, si procede, validar que no haya fallos. - -* **Cambios en registros XML con `no_update="1"`** - - * **Post-script**: actualizar esos registros por API (respetando `xml_id`) cuando el contenido lógico haya cambiado y no vaya a ser reaplicado por el upgrade normal. - -* **Cambios de reglas de acceso / multi-company / multi-website** - - * **Pre- o post-script** según el caso, para rellenar campos obligatorios (company, website, owner) y evitar que registros queden inaccesibles. - -> **Regla general:** si el cambio puede **romper durante el upgrade**, usa **pre-script**; si requiere **recalcular o reaplicar** después del código nuevo, usa **post-script**. Si se necesita una acción global al final, usa **end-script**. - ---- - -## Cobertura de tests automatizados – reglas generales - -* Cuando el diff introduzca **funcionalidad nueva no trivial** (nuevos métodos con lógica compleja, nuevos flujos de negocio, refactors grandes, nuevas APIs, etc.), revisar si existe cobertura de tests razonable para esos cambios. -* Si no se ve una cobertura clara, sugerir de forma **concreta y breve** qué tipo de test añadir (unitarios de modelo, tests de wizards, tours, pruebas sobre reportes, etc.), sin exigir una suite completa para cada cambio. -* Para cambios pequeños o puramente cosméticos (ajustes en textos, vistas simples, pequeñas correcciones) **no hace falta** proponer la creación de tests nuevos. - ---- - -## Convenciones de scripts en `migrations/` (generales) - -* Ubicación: `migrations//`. -* Nombres sugeridos: - - * `pre_.py` - * `post_.py` -* Requisitos: - - * Idempotentes (seguros si se ejecutan más de una vez). - * En lotes (`batch_size` razonable) para datasets grandes. - * Logs claros (uso de `_logger.info`). - * Manejo de transacciones cuando aplique (evitar locks largos). - * Documentar al inicio **qué suponen** y **qué garantizan**. - -**Esqueleto mínimo (ejemplo):** - -```python -# migrations//pre_rename_partner_ref.py -from odoo import api, SUPERUSER_ID - -def migrate(cr, registry): - env = api.Environment(cr, SUPERUSER_ID, {}) - partners = env['res.partner'].with_context(active_test=False).search([('old_ref', '!=', False)]) - for batch in range(0, len(partners), 500): - sub = partners[batch:batch+500] - for p in sub: - if not p.new_ref: - p.new_ref = p.old_ref -``` - -```python -# migrations//post_backfill_stored_amount_total.py -from odoo import api, SUPERUSER_ID - -def migrate(cr, registry): - env = api.Environment(cr, SUPERUSER_ID, {}) - Orders = env['sale.order'].with_context(active_test=False) - ids = Orders.search([]).ids - for i in range(0, len(ids), 200): - batch = Orders.browse(ids[i:i+200]) - # Forzar recompute del stored - batch._compute_amount_total() -``` - ---- - -## Checklist rápida para el review (general) - -| Categoría | Qué comprobar Copilot | -| ------------------ | -------------------------------------------------------------------------------------------------------- | -| Modelos | Relaciones válidas; constraints; uso adecuado de `@api.depends`; `super()` correcto | -| Vistas XML | Herencias correctas; campos válidos; adaptación a cambios de versión (p.ej. `` vs ``) | -| Seguridad | Accesos mínimos necesarios; reglas revisadas | -| Migraciones | **Si hay cambios estructurales, sugerir script en `migrations/` (pre/post/end)** y describir qué hace | -| Rendimiento / ORM | Evitar loops costosos; no SQL innecesario; aprovechar las optimizaciones del ORM de la versión | -| Ortografía & typos | Errores evidentes corregibles sin modificar idioma ni estilo | - ---- - -## Estilo del feedback (general) - -* Ser breve, claro y útil. Ejemplos: - - * “El campo `partner_id` no se encuentra referenciado en la vista.” - * “Este método redefine `write()` sin usar `super()`.” - * “Tip: hay un error ortográfico en el nombre del parámetro.” - * **Migración:** “Se renombra `old_ref` → `new_ref`: falta **pre-script** en `migrations/` para copiar valores antes del upgrade; añadir **post-script** para recompute del stored.” - -* Evitar explicaciones largas o reescrituras completas salvo que el cambio sea claro y necesario. -* Priorizar comentarios en forma de **lista corta de puntos** (3–7 ítems) y frases breves en lugar de bloques de texto extensos. - ---- - -## Resumen operativo para Copilot - -1. **Si hay cambio estructural (según la lista actualizada) → propone y describe script(s) de migración en `migrations/` (pre/post/end)**, con enfoque idempotente y en lotes. -2. Distingue entre: - - * **cuestiones generales** (válidas para cualquier versión), - * y **matices específicos de Odoo 18** (por ejemplo, uso de ``, passkeys, tours y comportamiento del framework). - -3. Mantén el feedback **concreto, breve y accionable**. \ No newline at end of file +2. Feedback **breve, concreto y accionable**. Lista corta de 3–7 puntos. Evitar párrafos largos y no repetir lo que ya dice la descripción del PR. +3. Corregir errores de tipeo u ortografía evidentes en nombres y comentarios (cuando sean claros). +4. No proponer traducciones de docstrings/comentarios entre idiomas. +5. No exigir docstrings en métodos que no los tienen. Si ya existe uno, PEP8 alcanza; falta de tipos o `return` **no es un error**. +6. No proponer cambios puramente estéticos (espacios, comillas, orden de imports). +7. Traducciones: `_()` y `self.env._()` son indistintos; solo marcar mensajes/textos al usuario que no estén envueltos. + +## Resumen operativo + +- **Si hay cambio estructural** (rename de campos almacenados, cambio de tipo, split/merge, nuevos `compute` con `store=True` con backfill, cambio de keys de `selection`, nuevas `UNIQUE`, cambios en `ir.model.data`/XML IDs) → **proponer script de migración** en `migrations//` con enfoque idempotente y en lotes. Ver `migrations.instructions.md`. +- **Si hay cambio en modelos** → aplicar `models.instructions.md`. +- **Si hay cambio en vistas XML** → `views.instructions.md`. +- **Si hay cambio en seguridad / ACL / `cr.execute` / `eval`** → `security.instructions.md`. +- **Si cambia `__manifest__.py`** → `manifest.instructions.md`. +- **Si el diff es grande y sensible a performance** → `performance.instructions.md`. +- **Si introduce funcionalidad no trivial sin tests** → `tests.instructions.md`. +- **Si hay texto al usuario sin `_()`** → `i18n.instructions.md`. + +## Versionado Odoo + +Cada módulo declara versión en `__manifest__.py`. Cuando hay diferencias relevantes entre v18 y v19, los archivos en `instructions/` marcan la regla como "Odoo 19+" o "Odoo 18". + +Cambios clave de Odoo 19 a tener en cuenta (detalle en cada `instructions.md` específica): +- `_sql_constraints` → `models.Constraint`, `models.Index`, `models.UniqueIndex`. +- `@api.one`/`@api.multi` eliminados; `@api.ondelete` para validación de borrado. +- `` → ``; `attrs={...}` → atributos directos (`invisible=`, `readonly=`). +- `t-esc` deprecado → `t-out`. +- `cr.execute(...)` crudo desaconsejado → clase `SQL` con `execute_query_dict()`. +- Dominios con clase `Domain` y operadores `&`, `|`, `~` sobre instancias. +- Crons: `_commit_progress(remaining=, processed=)` en lugar de `notify_progress`. +- `category_id` de `res.groups` → `privilege_id` + `res.groups.privilege`. + +## Estilo del feedback + +- Formato recomendado: `**categoría** · descripción concreta · sugerencia`. +- Un comentario por issue; no duplicar la misma observación en varios archivos. +- Preferir mencionar la regla concreta (ej. "queries parametrizadas") antes que la teoría. +- **Checklist rápida**: + +| Categoría | Qué comprobar | +|---|---| +| Modelos | Relaciones con `comodel_name`/`ondelete`; `@api.depends` correcto; `super()` preservado | +| Vistas XML | Herencias con `xpath` acotado; campos existentes; nada de redefinir vistas enteras | +| Seguridad | ACL mínimo; sin `cr.execute` con interpolación; sin `eval()` sobre input externo | +| Migraciones | Cambios estructurales → script idempotente en lotes | +| Rendimiento | Sin `search`/`write`/`create` en loop; `mapped`/`filtered`/`search_count`/`_read_group` | +| i18n | Textos al usuario envueltos en `_()`; no marcar nombres técnicos ni claves de dict | diff --git a/.github/instructions/i18n.instructions.md b/.github/instructions/i18n.instructions.md new file mode 100644 index 00000000..ed7fc7b0 --- /dev/null +++ b/.github/instructions/i18n.instructions.md @@ -0,0 +1,71 @@ +--- +applyTo: + - "**/models/**/*.py" + - "**/wizards/**/*.py" + - "**/wizard/**/*.py" + - "**/controllers/**/*.py" + - "**/report/**/*.py" + - "**/i18n/**/*.po" + - "**/i18n/**/*.pot" +--- + +# Revisión de internacionalización (i18n) + +## Marcar texto traducible + +- Todo texto que se muestra al usuario debe estar envuelto en `_()` o `self.env._()` (indistinto): + ```python + raise UserError(_("No se puede eliminar un pedido confirmado.")) + return {'warning': {'title': _("Atención"), 'message': _("Stock insuficiente.")}} + ``` +- Import típico: `from odoo import _, _lt` (usar `_lt` cuando el texto se define a nivel módulo/clase y la traducción se resuelve en runtime). + +## Qué marcar como issue + +- `raise UserError("...")` o `ValidationError("...")` con string literal. +- `return {'warning': {'message': "texto"}}`. +- Mensajes de `raise`, `notifications`, toast, `display_name` calculado, labels en wizards, títulos de acciones construidas dinámicamente. +- Textos en `_message_post` que muestran al usuario. + +## Qué NO marcar + +- Nombres técnicos de campos (`'partner_id'`, `'name'`). +- Claves de diccionarios (`'state': 'draft'`). +- Logs técnicos (`_logger.info("...")`) — no se traducen. +- Nombres de xml_ids. +- Cadenas en tests, comentarios, docstrings. +- `fields.Char(string="Name")`: el `string=` se recoge para i18n automáticamente, no requiere `_()`. + +## Uso correcto de `_()` + +- `_()` resuelve traducción **en el momento de la llamada** (runtime del idioma del usuario). +- `_lt()` (lazy translate) para strings definidas a nivel módulo; la traducción se resuelve al serializar, útil en selecciones y listas de constantes. +- No concatenar fragmentos traducibles con `+`: usar `%` o f-string sobre la cadena ya traducida: + ```python + # MAL (rompe traducción) + raise UserError(_("Error en ") + record.name) + # BIEN + raise UserError(_("Error en %s") % record.name) + ``` +- Evitar format con claves traducibles múltiples; preferir placeholders con nombre: + ```python + raise UserError(_("Falta %(field)s en %(model)s") % {'field': name, 'model': model}) + ``` + +## Archivos `.po` / `.pot` + +- No editar manualmente traducciones generadas por `odoo i18n export` salvo correcciones puntuales. +- Commits que solo tocan `.po` / `.pot` de exportación suelen ser benignos; no requieren tests ni script de migración. +- Si se agrega un idioma nuevo, verificar que esté listado en `i18n/` y que las cadenas base existan en `.pot`. + +## Convención del equipo (ADHOC) + +- Idioma destino principal: **español latinoamericano formal**. Evitar tuteo en mensajes de sistema ("usted" vs "tú"). +- Mantener consistencia terminológica: "pedido" (no "orden"), "contacto" (no "partner" en user-facing), "factura", etc. +- Placeholders (`%s`, `%(name)s`) deben mantenerse idénticos entre el mensaje original y la traducción. + +## Criterio de severidad + +- **Medio**: texto al usuario sin `_()`, aislado. +- **Bajo**: patrón que podría mejorarse (concatenación con `+`, falta de `_lt` en constante de módulo). +- No-issue: archivo `.po` autogenerado con cambios de exportación rutinarios. diff --git a/.github/instructions/manifest.instructions.md b/.github/instructions/manifest.instructions.md new file mode 100644 index 00000000..4444016c --- /dev/null +++ b/.github/instructions/manifest.instructions.md @@ -0,0 +1,48 @@ +--- +applyTo: + - "**/__manifest__.py" +--- + +# Revisión de `__manifest__.py` + +## Archivos referenciados + +- Todo archivo usado por el módulo (vistas, seguridad, datos, reportes, wizards, demo) debe estar listado en alguna de las claves del manifest (`data`, `demo`, `assets`). +- Si un archivo XML/CSV se borra del módulo, debe removerse del manifest; si se agrega uno nuevo, debe incluirse. +- Orden relativo importa: datos de seguridad antes de datos que los referencian; vistas después de sus modelos. + +## Dependencias (`depends`) + +- Deben listarse todos los módulos cuyos modelos/vistas/xml_ids se usan directamente. +- **No** declarar dependencias innecesarias (infla el árbol de instalación). +- Módulos de localización (`l10n_*`) solo cuando el módulo depende funcionalmente; no por conveniencia. + +## Versión + +- Formato `...` (ej. `19.0.1.0.0`). La serie (`19.0`, `18.0`) debe coincidir con la rama y la versión de Odoo target. +- **Regla obligatoria de versión**: cualquier cambio estructural que requiera script en `migrations/` debe **bumpear la versión** del módulo, y la carpeta bajo `migrations/` debe coincidir. +- Solo comentar la versión **una vez por revisión**, aunque haya múltiples archivos afectados. + +## Metadatos + +- `name`, `summary`, `description` deben estar definidos y ser consistentes. +- `author`, `license` presentes. En Adhoc, típicamente `"ADHOC SA"` y licencia según convención del repo. +- `category` coherente con el tipo de módulo. +- `installable: True` salvo que explícitamente esté siendo discontinuado. +- `application: True` solo para módulos que deben aparecer como aplicación raíz (no para sub-módulos). + +## Assets (bundles) + +- `assets` debe listar bundles correctos (`web.assets_backend`, `web.assets_frontend`, `web.report_assets_common`, `web.assets_tests`, etc.). +- Extensiones coherentes: `.js`, `.scss`, `.css`, `.xml` (OWL templates). +- Archivos borrados deben quitarse también de `assets`. + +## Hooks + +- `pre_init_hook`, `post_init_hook`, `uninstall_hook`, `post_load`: si están declarados, verificar que apunten a funciones existentes en el módulo (`from . import hooks` o similar). +- Los hooks deben ser idempotentes y no dependientes de datos demo. + +## Demo data + +- Datos de demo en la key `demo`, **no** mezclados con `data`. +- Al introducir funcionalidad nueva que se beneficia de casos visibles, considerar agregar demo; al introducir módulo de configuración, no es necesario. diff --git a/.github/instructions/migrations.instructions.md b/.github/instructions/migrations.instructions.md new file mode 100644 index 00000000..9557bb29 --- /dev/null +++ b/.github/instructions/migrations.instructions.md @@ -0,0 +1,59 @@ +--- +applyTo: + - "**/migrations/**/*.py" + - "**/__manifest__.py" + - "**/models/**/*.py" +--- + +# Revisión de scripts de migración + +> Si el diff introduce cambio estructural en un modelo, **siempre** evaluar si corresponde proponer script en `migrations//`. + +## Cuándo proponer script + +1. **Rename de campo almacenado** (`Char`, `Many2one`, etc. o `compute` con `store=True`). **No** si es `compute` sin store. +2. **Rename de modelo**: siempre. Toca `ir.model`, `ir.model.data`, tablas relacionales, vistas, acciones. +3. **Cambio de tipo de campo** con cambio real en DB (`Char→Many2one`, `Selection→Many2one`, `Many2one→Many2many`). Cambios compatibles (`Char→Text`, ajustes de `Float`) no requieren script. +4. **Split/merge de campos**. +5. **Nuevo `compute` con `store=True`** que aplique a registros históricos → post-script de backfill en lotes. Advertir si el modelo tiene millones de registros. +6. **Cambio en keys de `selection`**: renombrar/eliminar existentes → script que mapee `old → new`. Agregar nuevas keys **no** requiere script. +7. **Cambio de dominio** en relacional que excluya valores usados históricamente → limpiar/remapear. +8. **Nueva `UNIQUE`/índice** (`_sql_constraints` o `models.Constraint`): pre-script que resuelva duplicados antes de crear la constraint. +9. **Cambios en `ir.model.data` / XML IDs** (rename `module.name → module2.name2`): script para actualizar referencias. +10. **Registros con `noupdate="1"`** cuyo contenido lógico cambia: forzar update por `xml_id`. +11. **Cambios en reglas de acceso / multi-company / multi-website**: rellenar campos obligatorios, recomputar ownership. + +> **No** proponer script solo por `required=True` nuevo sin default, salvo que el diff evidencie datos históricos incompatibles. + +## Pre / Post / End + +- **pre**: antes del update. Preparar datos/esquemas para evitar fallos. +- **post**: después. Recalcular, limpiar, ajustar referencias. +- **end**: al final del upgrade global. Tareas cross-módulo o finales. + +Regla: **rompe durante el upgrade → pre**; **recalcula después → post**; **global al final → end**. + +## Mapeo cambio → acción + +- **Rename campo almacenado** → pre: copiar datos viejo→nuevo. Post: cleanup + recomputes. +- **Rename modelo** → pre: mapear `ir.model`/`ir.model.data`. Post: re-enlazar vistas, acciones, menús, reglas. +- **Split/merge** → pre: copiar a nuevos campos antes de que el schema borre el viejo. Post: normalizar/recompute. +- **`compute` nuevo con `store=True`** → post: backfill en lotes (pre opcional en modelos grandes para preparar columna). +- **Cambio de tipo con conversión** → pre: columna temporal + conversión. Post: swap/rename/borrar vieja. +- **`selection` (remove/rename keys)** → pre: mapeo `old → new` (usar `change_field_selection_values` si aplica). Post: validar consistencia. +- **Nueva `UNIQUE`** → pre: resolver duplicados. Post: crear índice si aplica. +- **`noupdate="1"` con cambio lógico** → post: update por `xml_id`. + +## Convenciones + +- Ubicación: `migrations//` (ej. `migrations/19.0.1.0/`). Versión debe coincidir con `__manifest__.py`. +- Nombres: `pre_.py`, `post_.py`, `end_.py`. +- **Idempotentes**: seguros ante re-ejecución. +- **En lotes** (`batch_size` razonable) para datasets grandes. +- Logs claros (`_logger.info`); comentario al inicio documentando supuestos y garantías. +- Evitar transacciones muy largas; `env.cr.commit()` controlado o helpers de progreso. + +## Versión del manifest + +- Al introducir cambio estructural, **bumpear** versión en `__manifest__.py` para que el script corra (ej. `19.0.1.0 → 19.0.2.0`). +- La carpeta bajo `migrations/` debe coincidir con la nueva versión. diff --git a/.github/instructions/models.instructions.md b/.github/instructions/models.instructions.md new file mode 100644 index 00000000..af275930 --- /dev/null +++ b/.github/instructions/models.instructions.md @@ -0,0 +1,68 @@ +--- +applyTo: + - "**/models/**/*.py" + - "**/wizards/**/*.py" + - "**/wizard/**/*.py" + - "**/report/**/*.py" +--- + +# Revisión de modelos Python + +## Relaciones y campos + +- `Many2one`/`One2many`/`Many2many` deben declarar `comodel_name` y `ondelete` apropiado. Evitar `ondelete='cascade'` sin justificación. +- Nombres de campos claros, consistentes, sin conflictos con campos heredados. +- `required=True` sin `default` **solo** si no hay datos históricos que puedan romperse. Si los hay, proponer `default` o migración. +- Campos `compute` con `store=True` que dependen de datos históricos pueden necesitar backfill (ver `migrations.instructions.md`). + +## Decoradores `@api.*` + +- `@api.depends` debe listar **todas** las dependencias reales, incluidas las dotted (`@api.depends('partner_id.email')`). +- `@api.constrains` **no** acepta dotted paths, solo nombres simples. +- `@api.onchange` no debe escribir a BD ni modificar campos computados. +- Evitar decoradores obsoletos: `@api.one`, `@api.multi` (Odoo 13+ no los acepta). +- **Odoo 18+**: para prevenir borrado usar `@api.ondelete(at_uninstall=False)` en vez de sobreescribir `unlink`. +- `@api.model` solo cuando el método no depende de `self` como recordset. +- `@api.model_create_multi` para métodos `create` que aceptan lista de dicts (obligatorio en Odoo 17+). + +## Herencia y `super()` + +- Métodos redefinidos deben llamar `super()` salvo que el contrato diga lo contrario. Preservar el tipo/shape del retorno. +- `_name` + `_inherit` juntos solo cuando se busca crear modelo nuevo (multi-table inheritance); marcar si no hay razón clara. +- No sobrescribir `create`/`write`/`unlink` solo para side effects triviales; preferir `@api.depends`, `@api.constrains` o `@api.ondelete`. + +## Constraints e índices + +- **Odoo 19+**: usar `models.Constraint(...)`, `models.Index(...)`, `models.UniqueIndex(...)` como declarativas a nivel de clase, en vez de `_sql_constraints`. Si el diff ya toca constraints, sugerir migrar a la nueva API. +- Mensajes de constraint deben ser traducibles (`_("...")`). +- Añadir `UNIQUE` sobre tabla con datos existentes puede fallar; ver `migrations.instructions.md`. + +## ORM seguro y eficiente + +- Evitar `search` dentro de loops → usar dominio con `in` sobre ids o `_read_group`. +- Evitar `write`/`create`/`unlink` uno a uno en loops → vectorizar sobre recordset. +- `create` en Odoo 17+: preferir lista de dicts `create([{...}, {...}])`. +- `mapped`, `filtered`, `search_count`, `search_fetch` antes que recorrer en Python. +- Navegación relacional segura: `rec.partner_id.email` devuelve falso si `partner_id` vacío; no duplicar el check. +- Acceso por índice (`recordset[0]`) puede lanzar `IndexError`; guardar con `if rec: ...` o rediseñar para operar sobre el recordset completo. +- Evitar `sudo()` amplio/innecesario en métodos de negocio; justificar cada uso. +- En Odoo 19, `cr.execute` crudo desaconsejado → usar clase `SQL` con `execute_query_dict()`. Si hay `cr.execute` con interpolación (`%`, f-string, `.format`) → bloqueante, ver `security.instructions.md`. + +## Nombres y estilo + +- Métodos privados prefijo `_` (sigue siendo la convención estándar; ya bloquea RPC por sí solo). `@api.private` **no** es un reemplazo del prefijo: es para el caso de excepción de un método sin `_` (API pública existente, o método interno del ORM) que necesita bloquearse de RPC sin renombrarlo. Ver docstring de `private` en `odoo/orm/decorators.py`. +- Métodos muy largos (>50 líneas) → sugerir split. +- Comparaciones booleanas: `if x:` / `if not x:` (no `== True` / `== False`). +- `else` después de `return` innecesario. +- Imports no utilizados deben removerse. + +## Dominios + +- En Odoo 19 es válido `Domain('field', 'op', 'value')` y combinar con `&`, `|`, `~`. No marcar como error. +- `Domain` permite uso en `filtered`: no hace falta convertir a lista. +- Nunca construir dominios como strings y pasarlos por `eval` (ver `security.instructions.md`). + +## Selecciones + +- Agregar nuevos values a un `selection` **no** requiere migración. +- Renombrar/eliminar keys existentes → proponer script que mapee `old → new` (ver `migrations.instructions.md`). diff --git a/.github/instructions/performance.instructions.md b/.github/instructions/performance.instructions.md new file mode 100644 index 00000000..e2e66e4a --- /dev/null +++ b/.github/instructions/performance.instructions.md @@ -0,0 +1,82 @@ +--- +applyTo: + - "**/models/**/*.py" + - "**/wizards/**/*.py" + - "**/wizard/**/*.py" + - "**/controllers/**/*.py" + - "**/report/**/*.py" +--- + +# Revisión de rendimiento (ORM) + +## Anti-patrones que bloquean performance + +- **Search en loop** → N+1 queries. Reemplazar por una sola `search` con dominio `in` sobre ids, o `_read_group` / `search_fetch`. + ```python + # MAL + for order in orders: + payments = self.env['payment'].search([('order_id', '=', order.id)]) + # BIEN + payments = self.env['payment'].search([('order_id', 'in', orders.ids)]) + ``` +- **Create/write/unlink en loop** → múltiples roundtrips a DB. Vectorizar: + ```python + # MAL + for vals in data: + self.env['res.partner'].create(vals) + # BIEN (Odoo 17+) + self.env['res.partner'].create(data) # lista de dicts + ``` +- **`search([])` + filtrado en Python** → traer todos los records. Usar dominio preciso. +- **`mapped` en loop** sobre recordsets grandes → preferir una única `.mapped('field')` fuera del loop. + +## `@api.depends` afinado + +- Listar todas las dependencias **reales**, incluidas las dotted: `@api.depends('partner_id.email')` para evitar consultas extra. +- No listar campos ajenos al compute (dispara recomputes innecesarios). +- Evitar depender de campos no almacenados en cadenas largas. + +## Agregados + +- Para sumar/contar preferir `read_group` / `_read_group` / `formatted_read_group` (Odoo 17+) antes que iterar + `sum`/`len`. +- `search_count(domain)` en vez de `len(search(domain))`. +- `browse(ids)` en lugar de re-buscar cuando ya se tienen ids. + +## Relacionales + +- **N+1 por navegación**: si un `@api.depends` dispara muchas lecturas, ajustar dependencias o prefetch. +- `mapped('campo_relacional.subcampo')` agrupa lecturas y usa prefetch; preferir a loops manuales. +- `filtered_domain(domain)` para filtrados con mismo idioma que `search`. + +## Cron y jobs largos + +- **Odoo 19**: usar `self.env['ir.cron']._commit_progress(remaining=N)` / `_commit_progress(processed=M)` en crons en lugar de `notify_progress` / commits manuales ad hoc. +- Procesar en **lotes** (`batch_size` razonable, p. ej. 500–1000) y commitear por lote. +- Logs con `_logger.info` para observabilidad. + +## Computes y store + +- `store=True` sobre `compute` implica backfill en historia → ver `migrations.instructions.md`. +- `compute` sin store se reevalúa por read; si se accede repetidas veces en un loop, cachear localmente. +- `write` dentro de un compute → anti-patrón, genera recursión o recomputes encadenados. + +## Transacciones + +- `flush()` explícito solo cuando se requiere forzar la orden de escritura antes de leer. No usar en loops. +- `env.cr.commit()` en crons o scripts de migración, pero nunca dentro de lógica transaccional de usuario. +- `invalidate_cache` solo si hay razón concreta (modificación externa por SQL directo). + +## Vistas XML relacionadas (cross-reference) + +- Filtros en listas grandes sobre campos no indexados → sugerir `index=True` en el modelo. +- Columnas de lista que nunca se muestran: `column_invisible="1"` (evita cargar valores). Ver `views.instructions.md`. + +## Cuándo NO optimizar + +- Loops sobre recordsets pequeños y acotados (< ~20 elementos) donde la claridad gana a la micro-optimización. +- Código de setup/install que corre una única vez. +- Para diffs chicos y acotados, evitar proponer reescrituras masivas — preferir marcar la regla para futuras iteraciones. + +## Beneficios indirectos + +- Mantenerse dentro del ORM hereda controles de acceso, auditoría, reglas multi-compañía y prefetch automático. Queries crudas pierden todo eso. diff --git a/.github/instructions/security.instructions.md b/.github/instructions/security.instructions.md new file mode 100644 index 00000000..56f097e8 --- /dev/null +++ b/.github/instructions/security.instructions.md @@ -0,0 +1,62 @@ +--- +applyTo: + - "**/security/**" + - "**/controllers/**/*.py" + - "**/models/**/*.py" + - "**/wizards/**/*.py" + - "**/wizard/**/*.py" +--- + +# Revisión de seguridad + +## ACL y reglas de acceso + +- Modelo nuevo debe tener fila en `security/ir.model.access.csv` con permisos **mínimos necesarios**. No abrir `perm_unlink` o `perm_write` si no se justifica. +- Campos sensibles (datos personales, flags de configuración, credenciales) deben restringirse por `groups="..."`. +- `record rules` (`ir.rule`) nuevas deben cubrir multi-compañía cuando el modelo tiene `company_id`. Verificar reglas globales vs por grupo. +- **Odoo 19**: `res.groups.category_id` fue reemplazado por `privilege_id` + `res.groups.privilege`; al crear grupos usar la nueva estructura. + +## SQL injection + +- **Bloqueante**: `self.env.cr.execute("... '%s' ..." % var)` o con f-string/`.format`. Toda variable debe pasar como parámetro: + ```python + self.env.cr.execute("SELECT id FROM res_partner WHERE name = %s", (name,)) + ``` +- Preferir dominio ORM: `self.env['res.partner'].search([('name', '=', name)])`. +- **Odoo 19**: usar clase `SQL` con `execute_query_dict()` para consultas seguras; marcar si se ve `cr.execute` crudo. + +## Ejecución arbitraria y deserialización + +- `eval()`, `exec()`: nunca sobre input del usuario. +- Dominios construidos como string y pasados por `eval` → bloqueante. Usar lista de tuplas o `Domain(...)`. +- `safe_eval` permitido solo sobre contextos controlados; marcar si viene de parámetros de request. +- `pickle.loads`, `yaml.load` (sin `SafeLoader`), `marshal`: prohibidos con data no confiable. + +## Bypass de reglas + +- `sudo()` en controllers/wizards: cada uso requiere justificación explícita. Evitar `sudo()` amplio a nivel de método. +- `with_user(SUPERUSER_ID)` sólo para operaciones de sistema documentadas. +- Accesos multi-compañía sin `company_id` explícito: riesgo de leakage; exigir scoping. + +## Controllers HTTP + +- `auth='public'` con escritura o acceso a datos sensibles → riesgo. Evaluar si debería ser `auth='user'` o `auth='portal'`. +- `@http.route(..., csrf=False)` solo para endpoints no-UI (webhooks, APIs) y con autenticación alternativa; marcar si se desactiva sin justificación. +- `browse(int(request.params.get('id')))`: validar pertenencia del registro al usuario actual antes de operar. +- Input del usuario que llega a SQL, filesystem o shell → ver secciones específicas. + +## Filesystem y comandos + +- `subprocess.*` con `shell=True` → bloqueante. Pasar args como lista. +- Paths construidos con input del usuario sin validar → path traversal. Usar `werkzeug.utils.secure_filename` o equivalente. +- URLs descargadas con input del usuario → riesgo SSRF; validar esquema y host permitido. + +## Sensibles específicas Odoo 19 + +- Integraciones IA, VOIP, WhatsApp, Equity/ESG: cambios acá pueden requerir migración de tokens/ownership. Revisar con atención y sugerir script si aplica (ver `migrations.instructions.md`). + +## Criterio de severidad + +- **Bloqueante** (BLOCKER): SQL injection, eval sobre input, shell=True, deserialización insegura, `auth='public'` con efectos secundarios graves. +- **Alto** (HIGH): `sudo()` sin justificación, bypass de ACL, record rules faltantes. +- **Medio** (MEDIUM): falta `groups` en campos sensibles, `noupdate` sin considerar consecuencias. diff --git a/.github/instructions/tests.instructions.md b/.github/instructions/tests.instructions.md new file mode 100644 index 00000000..34d81281 --- /dev/null +++ b/.github/instructions/tests.instructions.md @@ -0,0 +1,64 @@ +--- +applyTo: + - "**/tests/**/*.py" + - "**/models/**/*.py" + - "**/wizards/**/*.py" + - "**/wizard/**/*.py" + - "**/controllers/**/*.py" +--- + +# Revisión de cobertura de tests + +## Cuándo sugerir tests + +Sugerir agregar tests cuando el diff introduce **funcionalidad no trivial**: + +- Métodos nuevos con lógica de negocio (cálculos, validaciones, transiciones de estado). +- Nuevos flujos/wizards completos. +- Refactors amplios de código existente (especialmente si cambia firma de métodos públicos). +- Nuevas APIs/endpoints de controladores. +- Cambios en reportes que alteran la salida. +- Overrides de `create`/`write`/`unlink` con side effects. + +## Cuándo NO sugerir + +- Cambios puramente cosméticos (textos, vistas simples, ajustes de estilo). +- Correcciones menores sin cambio de comportamiento. +- Solo traducciones / solo documentación. +- Renombres de variables. + +## Tipo de test apropiado + +- **Unitario de modelo** (`TransactionCase` / `TestCase`): validar métodos, constraints, computes, onchanges. +- **Wizard test**: instanciar wizard, setear campos, disparar acción, assert resultado. +- **HttpCase**: controladores, rutas, autenticación, respuesta. +- **Tour** (`odoo.tests.common.HttpCase` + tour JS): flujos de UI críticos, especialmente en OWL components. +- **Reporte**: generar reporte contra data conocida y comparar output. + +## Calidad del test + +- `setUp` preparando datos mínimos; preferir factory methods o datos de demo. +- Assertions concretas: no `assertTrue(result)` si se puede `assertEqual(result, expected)`. +- Decoradores apropiados: `@tagged('post_install', '-at_install')` para tests que dependen de módulos dependientes. +- Evitar dependencias del orden de ejecución entre tests; cada test debe ser independiente. +- Si el test crea registros con datos predecibles, usar ids/xml_ids estables para poder referenciarlos. + +## Patrones a marcar como issue + +- Test nuevo sin `assertEqual` / `assertRaises` / similar → no valida nada. +- `try: ... except: pass` en tests → oculta fallos. +- Tests que dependen de la hora del sistema sin `freeze_time` / `mute_logger` donde aplica. +- Tests que modifican `noupdate` records sin restaurar estado. + +## Criterio de suficiencia + +- No exigir una suite completa por cada cambio. +- Una sugerencia concreta y breve es suficiente: "Para este método de cálculo, podría agregarse un test unitario que cubra el caso X." (sin diseñar la suite entera). +- Si el módulo ya tiene una carpeta `tests/` con cobertura previa similar, sugerir seguir el mismo estilo. + +## En PRs que SÍ agregan tests + +- Verificar que el test realmente cubra el diff (no solo código alrededor). +- Que no haga mocks innecesarios del ORM (regla del equipo: preferir tests de integración sobre mocks de BD). +- Que se ejecute: nombre `test_*.py`, clase `Test*`, método `test_*`. +- `__init__.py` en `tests/` importa el nuevo archivo. diff --git a/.github/instructions/views.instructions.md b/.github/instructions/views.instructions.md new file mode 100644 index 00000000..304409d1 --- /dev/null +++ b/.github/instructions/views.instructions.md @@ -0,0 +1,60 @@ +--- +applyTo: + - "**/views/**/*.xml" + - "**/reports/**/*.xml" + - "**/data/**/*.xml" +--- + +# Revisión de vistas XML y QWeb + +## Herencia + +- Usar `inherit_id` + `xpath` específico en vez de redefinir la vista entera. +- `xpath` debe apuntar a un elemento único y estable: preferir `//field[@name='...']` o `//group[@name='...']` antes que índices de `child::`. +- Evitar `position="replace"` cuando `position="attributes"` o `position="after"/"before"/"inside"` alcanza. +- No duplicar grandes bloques de `arch`: heredar y sobreescribir lo mínimo necesario. + +## Campos referenciados + +- Todo `` debe existir en el modelo correspondiente (y ser accesible por el usuario). +- Campos usados en atributos como `invisible="..."`, `readonly="..."`, `required="..."` también deben estar declarados en la vista (si no, agregar con `invisible="1"`). + +## Atributos dinámicos (Odoo 17+) + +- `attrs="{'invisible': [...]}"` **deprecado**. Usar atributos directos: `invisible="field == 'done'"`, `readonly="state in ['done','cancel']"`, `required="type_id"`. +- Expresiones en atributos usan sintaxis Python sobre los campos disponibles del registro actual. +- En listas (``): para campos que nunca se muestran, usar `column_invisible="1"` en vez de `invisible="1"` (evita cargar valores innecesariamente). + +## `` vs `` (Odoo 19) + +- Odoo 19 usa `` en vez de `` como tag de lista. +- Atributos frecuentes: `editable="bottom"`, `multi_edit="1"`, `decoration-*`, `optional="show|hide"` en fields. +- Si el diff introduce `` en módulo v19 → marcar como cambio obligatorio a ``. + +## Kanban y QWeb + +- **Odoo 19+**: templates kanban usan `t-name="card"` (antes `t-name="kanban-box"`). +- `t-esc` deprecado → usar `t-out` para escribir valores (aplica a todas las versiones recientes). +- `t-options-widget` sólo sobre campos; no abusar. + +## Búsquedas y filtros + +- Filtros de búsqueda sobre campos no indexados en datasets grandes → sugerir `index=True` en el field o filtro alternativo. +- `` debe tener `name` único para poder heredarse. + +## Acciones y menús (cuando vengan en el mismo diff) + +- `ir.actions.act_window` debe declarar `res_model`; `view_mode` consistente con vistas existentes. +- Menús heredados con `parent_id` correcto; evitar duplicación de `sequence`. +- Nuevos menús deben tener permisos coherentes (grupo o reglas ACL). + +## Datos XML + +- `` nuevos deben tener `id` con convención `module__`. +- Usar `noupdate="1"` con cuidado: si más adelante cambia el contenido lógico, requiere script de migración forzando el update por `xml_id`. +- No mezclar datos de demo con datos funcionales (carpetas `data/` vs `demo/` y declaración en manifest). + +## Reportes QWeb + +- Templates deben heredar estilos base (`web.external_layout` o similar) en vez de duplicar CSS inline. +- `t-call` para layouts; `t-field` para renderizar valores con su widget; `t-out`/`t-esc` ya no es necesario si se usa `t-field`. diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index baa05dbf..a4f0356c 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -18,28 +18,46 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: + - + name: Block sensitive file changes from fork PRs + if: >- + github.event_name == 'pull_request_target' && + github.event.pull_request.head.repo.full_name != github.repository + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + changed=$(gh api --paginate \ + "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" \ + --jq '.[].filename') + if echo "$changed" | grep -qE '^(\.github/workflows/|\.pre-commit-config\.yaml$)'; then + echo "::error::Fork PRs may not modify workflows or the pre-commit config. Blocked for security." + exit 1 + fi - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.ref }} + allow-unsafe-pr-checkout: true - id: setup-python name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: "3.10" cache: "pip" - name: Pre-commit cache - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.cache/pre-commit key: pre-commit|${{ steps.setup-python.outputs.python-version }}|${{ hashFiles('.pre-commit-config.yaml') }} - id: precommit name: Pre-commit - uses: pre-commit/action@v3.0.1 + run: | + pip install pre-commit + pre-commit run --all-files --show-diff-on-failure --color=always - name: Create commit status if: github.event_name == 'pull_request_target' diff --git a/.gitignore b/.gitignore index 59c99089..a3c99031 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,9 @@ coverage.xml # Sphinx documentation docs/_build/ +# Vscode +.vscode/ + ### macOS ### # General .DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c4be55ff..4539e696 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: - id: check-executables-have-shebangs - id: check-merge-conflict args: ['--assume-in-merge'] - exclude: '\.rst$' + exclude: '\.(rst|md)$' - id: check-symlinks - id: check-xml - id: check-yaml