diff --git a/.copier-answers.yml b/.copier-answers.yml index f9d76247d..ce29cd73c 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: d46567f +_commit: 8677dea _src_path: https://github.com/ingadhoc/addons-repo-template.git description: ADHOC Odoo Stock & Warehouse Management Addons is_private: false diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 83d1263a6..fc9d7d5f4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,320 +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.). - ---- - -## 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. -* **Regla de versión (obligatoria):** - Siempre que el diff incluya **modificaciones en**: - - * definición de campos o modelos (`models/*.py`, `wizards/*.py`), - * vistas o datos XML (`views/*.xml`, `data/*.xml`, `report/*.xml`, `wizards/*.xml`), - * seguridad (`security/*.csv`, `security/*.xml`), - - **y el `__manifest__.py` no incrementa `version`, sugerir el bump de versión** (por ejemplo, `1.0.0 → 1.0.1`). -* 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 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 - -* 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. - ---- - -## 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**. - -### 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:** hemos eliminado explícitamente de 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**. - ---- - -## 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 ``) | -| Manifest | **Bump de versión obligatorio** si hay cambios en modelos/vistas/seguridad/datos; 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 | -| Ortografía & typos | Errores evidentes corregibles sin modificar idioma ni estilo | - ---- - -## Heurística práctica para el bump de versión (general) - -* **SI** el diff toca cualquiera de: `models/`, `views/`, `data/`, `report/`, `security/`, `wizards/` - **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: - - * “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.” - -* Evitar explicaciones largas o reescrituras completas salvo que el cambio sea claro y necesario. - ---- - -## Resumen operativo para Copilot (v18) - -1. **Detecta cambios en modelos/vistas/seguridad/datos → exige bump de `version` en `__manifest__.py`.** -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: - - * **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**. - -[^odoo18]: Resumen basado en la documentación oficial de Odoo 18 Release Notes y artículos técnicos que analizan sus mejoras de rendimiento y UX. \ 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 000000000..ed7fc7b0d --- /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 000000000..4444016cd --- /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 000000000..9557bb29f --- /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 000000000..af2759307 --- /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 000000000..e2e66e4af --- /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 000000000..56f097e80 --- /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 000000000..34d81281d --- /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 000000000..304409d1d --- /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 349c52d82..a4f0356c3 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -6,35 +6,58 @@ 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: 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 59c990894..a3c990315 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 fc269814a..4539e6968 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|md)$' - id: check-symlinks - id: check-xml - id: check-yaml diff --git a/location_security/models/stock_move.py b/location_security/models/stock_move.py index 94a62880f..523240e97 100644 --- a/location_security/models/stock_move.py +++ b/location_security/models/stock_move.py @@ -12,9 +12,20 @@ class StockMove(models.Model): @api.constrains("state", "location_id", "location_dest_id") def check_user_location_rights(self): - moves = self.filtered(lambda x: x.state in ["done", "cancel"]) + # (b) Cancelar un movimiento no procesa mercadería: solo validamos los + # movimientos que pasan a "done". Los movimientos encadenados que quedan + # en "cancel" al modificar/confirmar una OC con ruta MTO no deben + # disparar la constraint. Ver ticket 109676. + moves = self.filtered(lambda x: x.state == "done") if not moves or not self.env.user.restrict_locations: return True + # (a) La verificación de ubicaciones permitidas pertenece a la validación + # explícita del picking (botón "Validar"). Fuera de ese flujo la + # constraint se dispara por efectos colaterales (ej. recreación de + # movimientos encadenados MTO) generando falsos positivos de + # "Invalid Location". Ver ticket 109676. + if not self.env.context.get("button_validate_picking_ids"): + return True user_locations = self.env.user.stock_location_ids for user_location in user_locations: location = user_locations.search([("id", "child_of", user_location.id)]) diff --git a/pyproject.toml b/pyproject.toml index 9f837a8cb..9b15bb049 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,9 +27,9 @@ ignore = [ [tool.ruff.lint.pycodestyle] # line-length is set in [tool.ruff], and it's used by the formatter # in case the formatted can't autofix the line length, it will be reported as an error -# only if it exceeds the max-line-length set here. We use 999 to effectively disable +# only if it exceeds the max-line-length set here. We use 320 (max available value) to disable # this check. -max-line-length = 999 +max-line-length = 320 [tool.ruff.lint.isort] combine-as-imports = true @@ -46,6 +46,7 @@ known-third-party = [ "urllib2", "yaml", ] +section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] [tool.ruff.lint.mccabe] max-complexity = 20 diff --git a/sale_order_type_invoice_policy_invoice_link/models/stock_move.py b/sale_order_type_invoice_policy_invoice_link/models/stock_move.py index 14472ba32..6304e4409 100644 --- a/sale_order_type_invoice_policy_invoice_link/models/stock_move.py +++ b/sale_order_type_invoice_policy_invoice_link/models/stock_move.py @@ -12,14 +12,13 @@ def new_write(self, vals): res = super(StockMove, self).write(vals) if vals.get("state", "") == "done": stock_moves = self.get_moves_delivery_link_invoice() - for stock_move in stock_moves.filtered( - lambda sm: sm.sale_line_id - and ( - sm.sale_line_id.order_id.type_id.invoice_policy == "order" - or sm.sale_line_id.order_id.type_id.invoice_policy == "by_product" - and sm.product_id.invoice_policy == "order" - ) - ): + for stock_move in stock_moves.filtered(lambda sm: sm.sale_line_id): + invoice_policy = stock_move.sudo().sale_line_id.order_id.type_id.invoice_policy + if not ( + invoice_policy == "order" + or (invoice_policy == "by_product" and stock_move.product_id.invoice_policy == "order") + ): + continue inv_type = stock_move.to_refund and "out_refund" or "out_invoice" inv_line = ( self.env["account.move.line"] diff --git a/stock_batch_picking_ux/__manifest__.py b/stock_batch_picking_ux/__manifest__.py index 480c7bd19..c610b197a 100644 --- a/stock_batch_picking_ux/__manifest__.py +++ b/stock_batch_picking_ux/__manifest__.py @@ -19,7 +19,7 @@ ############################################################################## { "name": "Stock Usability with Batch Picking and stock vouchers", - "version": "18.0.1.1.0", + "version": "18.0.1.3.1", "category": "Warehouse Management", "sequence": 14, "summary": "", diff --git a/stock_batch_picking_ux/i18n/es.po b/stock_batch_picking_ux/i18n/es.po index c6a42327e..d798f9734 100644 --- a/stock_batch_picking_ux/i18n/es.po +++ b/stock_batch_picking_ux/i18n/es.po @@ -121,6 +121,12 @@ msgstr "Origen" msgid "Stock Voucher Book" msgstr "Talonario de Remitos" +#. module: stock_batch_picking_ux +#. odoo-python +#: code:addons/stock_batch_picking_ux/models/stock_batch_picking.py:0 +msgid "Please complete the vouchers book for the following pickings: %s" +msgstr "Por favor completá los talonarios de remito en los siguientes traslados: %s" + #. module: stock_batch_picking_ux #. odoo-python #: code:addons/stock_batch_picking_ux/models/stock_batch_picking.py:0 diff --git a/stock_batch_picking_ux/models/stock_batch_picking.py b/stock_batch_picking_ux/models/stock_batch_picking.py index 6ffa43939..9b7eacbfb 100644 --- a/stock_batch_picking_ux/models/stock_batch_picking.py +++ b/stock_batch_picking_ux/models/stock_batch_picking.py @@ -16,7 +16,7 @@ class StockPickingBatch(models.Model): # maneje en la vista para que si esta seteado pase dominio # y si no esta seteado no # required=True, - help="If you choose a partner then only pickings of this partner will" "be sellectable", + help="If you choose a partner then only pickings of this partner will be sellectable", ) voucher_number = fields.Char() voucher_required = fields.Boolean( @@ -93,17 +93,33 @@ def write(self, vals): vals["voucher_number"] = voucher_number return super().write(vals) + def action_confirm(self): + batches_in_draft = self.filtered(lambda batch: batch.state == "draft") + res = super().action_confirm() + # When the batch is confirmed for the first time, Odoo already created + # the operation lines from the selected pickings. For receptions we reset + # them to zero so the operator can input only the quantities physically + # received (partial reception). This must NOT touch deliveries/waves, + # where zeroing the quantity wrongly removes product availability. + batches_in_draft.move_line_ids.filtered( + lambda line: line.state not in ("done", "cancel") and line.picking_id.picking_type_id.code == "incoming" + ).write({"quantity": 0}) + return res + def add_picking_operation(self): self.ensure_one() - view_id = self.env.ref("stock_ux.view_move_line_tree").id - search_view_id = self.env.ref("stock_ux.stock_move_line_view_search").id + view_id = self.env.ref("stock_batch_picking_ux.view_move_line_tree_smart_button").id + search_view_id = self.env.ref("stock_batch_picking_ux.stock_move_line_view_search").id return { "type": "ir.actions.act_window", "res_model": "stock.move.line", "search_view_id": search_view_id, "views": [[view_id, "list"], [False, "form"]], "domain": [["id", "in", self.move_line_ids.ids]], - "context": {"create": False, "from_batch": True}, + "context": { + "create": False, + "from_batch": True, + }, } def action_done(self): @@ -112,10 +128,23 @@ def action_done(self): # al agregar la restriccion de que al menos una tenga que tener # cantidad entonces nunca se manda el force_qty al picking if all(operation.quantity == 0 for operation in rec.move_line_ids): - raise UserError(_("Debe definir Cantidad Realizada en al menos una " "operación.")) + raise UserError(_("Debe definir Cantidad Realizada en al menos una operación.")) if rec.restrict_number_package and not rec.number_of_packages > 0: raise UserError(_("The number of packages can not be 0")) + + if rec.picking_type_id.book_required: + if rec.picking_type_id.book_id: + pickings_without_book = rec.picking_ids.filtered(lambda p: not p.book_id) + pickings_without_book.book_id = rec.picking_type_id.book_id + else: + pickings_without_book = rec.picking_ids.filtered(lambda p: not p.book_id) + if pickings_without_book: + raise UserError( + _("Please complete the vouchers book for the following pickings: %s") + % ", ".join(pickings_without_book.mapped("name")) + ) + if rec.number_of_packages: rec.picking_ids.write({"number_of_packages": rec.number_of_packages}) @@ -133,6 +162,20 @@ def action_done(self): "name": rec.voucher_number, } ) + else: + batch_voucher_installed = "stock_batch_picking_voucher" in self.env["ir.module.module"].search( + [("name", "=", "stock_batch_picking_voucher"), ("state", "=", "installed")] + ).mapped("name") + if not batch_voucher_installed: + for picking in rec.picking_ids: + if not picking.picking_type_id.auto_print_delivery_slip: + continue + book = picking.book_id or picking.picking_type_id.book_id + if not book: + continue + if all(operation.quantity == 0 for operation in picking.move_line_ids): + continue + picking.assign_numbers(picking.get_estimated_number_of_pages(), book) return super(StockPickingBatch, self.with_context(do_not_assign_numbers=True)).action_done() def action_view_stock_picking(self): diff --git a/stock_batch_picking_ux/views/stock_move_line_views.xml b/stock_batch_picking_ux/views/stock_move_line_views.xml index 6d7b57b5f..56bf473e6 100644 --- a/stock_batch_picking_ux/views/stock_move_line_views.xml +++ b/stock_batch_picking_ux/views/stock_move_line_views.xml @@ -43,4 +43,22 @@ + + stock.move.line.list.smart.button + stock.move.line + + + + + 0 + + + + + + [('product_id', '=', product_id), '|', ('company_id', '=', False), ('company_id', '=', company_id)] + + + + diff --git a/stock_currency_valuation/models/__init__.py b/stock_currency_valuation/models/__init__.py index ebf7df320..0de9f08d3 100644 --- a/stock_currency_valuation/models/__init__.py +++ b/stock_currency_valuation/models/__init__.py @@ -5,3 +5,4 @@ from . import stock_move from . import stock_landed_cost from . import stock_picking +from . import account_move_line diff --git a/stock_currency_valuation/models/account_move_line.py b/stock_currency_valuation/models/account_move_line.py new file mode 100644 index 000000000..33b4ac961 --- /dev/null +++ b/stock_currency_valuation/models/account_move_line.py @@ -0,0 +1,19 @@ +from odoo import models + + +class AccountMoveLine(models.Model): + _inherit = "account.move.line" + + def _prepare_pdiff_vals(self, layer, aml, layer_price_unit, out_qty_to_invoice, qty_to_correct): + svl_vals_list, aml_vals_list = super()._prepare_pdiff_vals( + layer, aml, layer_price_unit, out_qty_to_invoice, qty_to_correct + ) + valuation_currency_id = self.product_id.with_company(self.company_id.id).categ_id.valuation_currency_id + use_valuation_currency = valuation_currency_id == self.currency_id == self.purchase_line_id.currency_id + if use_valuation_currency and svl_vals_list: + # TODO pueden ser diferentes unidades de media + svl_vals_list[0]["bypass_currency_valuation"] = True + svl_vals_list[0]["value_in_currency"] = ( + self.price_total - self.purchase_line_id.price_total / self.purchase_line_id.product_qty * self.quantity + ) + return svl_vals_list, aml_vals_list diff --git a/stock_currency_valuation/models/stock_landed_cost.py b/stock_currency_valuation/models/stock_landed_cost.py index ac1b1ec6b..3b3659b56 100644 --- a/stock_currency_valuation/models/stock_landed_cost.py +++ b/stock_currency_valuation/models/stock_landed_cost.py @@ -45,22 +45,23 @@ class AdjustmentLines(models.Model): def _create_accounting_entries(self, move, qty_out): AccountMoveLine = super()._create_accounting_entries(move, qty_out) amount = AccountMoveLine[0][2].get("debit", 0) or AccountMoveLine[0][2].get("credit", 0) * -1 - if self.product_id.categ_id.valuation_currency_id and amount: + valuation_currency_id = self.product_id.with_company(self.cost_id.company_id.id).categ_id.valuation_currency_id + if valuation_currency_id and amount: if self.cost_id.currency_rate: value_in_currency = amount * self.cost_id.currency_rate else: value_in_currency = self.cost_id.currency_id._convert( from_amount=amount, - to_currency=self.product_id.categ_id.valuation_currency_id, + to_currency=valuation_currency_id, company=self.cost_id.company_id, date=self.create_date, ) AccountMoveLine[0][2].update( - {"currency_id": self.product_id.categ_id.valuation_currency_id.id, "amount_currency": value_in_currency} + {"currency_id": valuation_currency_id.id, "amount_currency": value_in_currency} ) AccountMoveLine[1][2].update( { - "currency_id": self.product_id.categ_id.valuation_currency_id.id, + "currency_id": valuation_currency_id.id, "amount_currency": value_in_currency * -1, } ) diff --git a/stock_currency_valuation/models/stock_move.py b/stock_currency_valuation/models/stock_move.py index 5fe09c929..b0235c048 100644 --- a/stock_currency_valuation/models/stock_move.py +++ b/stock_currency_valuation/models/stock_move.py @@ -18,7 +18,10 @@ def _get_price_unit(self): self.picking_id.currency_rate and self.purchase_line_id.order_id.currency_id == self.picking_id.valuation_currency_id ): - price_units[index[0]] = self.purchase_line_id.price_unit / self.picking_id.currency_rate + # Use _get_gross_price_unit() so that UoM conversion (e.g. Box→Unit) + # and discounts are already applied; then divide by currency_rate + # to get the price in company currency per reference UoM. + price_units[index[0]] = self.purchase_line_id._get_gross_price_unit() / self.picking_id.currency_rate return price_units def _account_entry_move(self, qty, description, svl_id, cost): @@ -47,34 +50,33 @@ def product_price_update_before_done(self, forced_qty=None): and move.with_company(move.company_id).product_id.categ_id.valuation_currency_id and move.with_company(move.company_id).product_id.cost_method == "average" ): - product_tot_qty_available = ( - move.product_id.sudo().with_company(move.company_id).quantity_svl + tmpl_dict[move.product_id.id] - ) - rounding = move.product_id.uom_id.rounding + product_with_company = move.product_id.with_company(move.company_id) + product_tot_qty_available = product_with_company.sudo().quantity_svl + tmpl_dict[move.product_id.id] + rounding = product_with_company.uom_id.rounding valued_move_lines = move._get_in_move_lines() qty_done = 0 for valued_move_line in valued_move_lines: qty_done += valued_move_line.product_uom_id._compute_quantity( - valued_move_line.qty_done, move.product_id.uom_id + valued_move_line.qty_done, product_with_company.uom_id ) qty = forced_qty or qty_done if float_is_zero(product_tot_qty_available, precision_rounding=rounding): new_std_price_in_currency = move._get_currency_price_unit( - default=move.product_id.standard_price_in_currency + default=product_with_company.standard_price_in_currency ) elif float_is_zero( product_tot_qty_available + move.product_qty, precision_rounding=rounding ) or float_is_zero(product_tot_qty_available + qty, precision_rounding=rounding): new_std_price_in_currency = move._get_currency_price_unit( - default=move.product_id.standard_price_in_currency + default=product_with_company.standard_price_in_currency ) else: # Get the standard price amount_unit = ( std_price_update.get((move.company_id.id, move.product_id.id)) - or move.product_id.with_company(move.company_id).standard_price_in_currency + or product_with_company.standard_price_in_currency ) new_std_price_in_currency = ( (amount_unit * product_tot_qty_available) + (move._get_currency_price_unit() * qty) @@ -82,7 +84,7 @@ def product_price_update_before_done(self, forced_qty=None): tmpl_dict[move.product_id.id] += qty_done # Write the standard price, as SUPERUSER_ID because a warehouse manager may not have the right to write on products - move.product_id.with_company(move.company_id.id).with_context(disable_auto_svl=True).sudo().write( + product_with_company.with_context(disable_auto_svl=True).sudo().write( {"standard_price_in_currency": new_std_price_in_currency} ) @@ -105,12 +107,22 @@ def _get_currency_price_unit(self, default=0.0): if hasattr(self, "sale_line_id") and self.sale_line_id: currency_id = self.sale_line_id.currency_id - price_unit = currency_id._convert( - from_amount=self.price_unit, - to_currency=self.product_id.categ_id.valuation_currency_id, - company=self.company_id, - date=fields.date.today(), - ) + if ( + self.picking_id.currency_rate + and self.purchase_line_id + and self.purchase_line_id.order_id.currency_id == self.picking_id.valuation_currency_id + ): + # When a custom currency_rate is set on the picking, use the PO line + # price directly in secondary currency (already UoM-converted by + # _get_gross_price_unit), so the AVCO update is consistent with the SVL. + price_unit = self.purchase_line_id._get_gross_price_unit() + else: + price_unit = currency_id._convert( + from_amount=self.price_unit, + to_currency=self.product_id.with_company(self.company_id.id).categ_id.valuation_currency_id, + company=self.company_id, + date=fields.date.today(), + ) precision = self.env["decimal.precision"].precision_get("Product Price") # If the move is a return, use the original move's price unit. if self.origin_returned_move_id and self.origin_returned_move_id.sudo().stock_valuation_layer_ids: diff --git a/stock_currency_valuation/models/stock_picking.py b/stock_currency_valuation/models/stock_picking.py index 0ccc1a25c..9a52a1239 100644 --- a/stock_currency_valuation/models/stock_picking.py +++ b/stock_currency_valuation/models/stock_picking.py @@ -1,4 +1,5 @@ from odoo import api, fields, models +from odoo.exceptions import UserError class StockPicking(models.Model): @@ -16,11 +17,31 @@ class StockPicking(models.Model): help="If no rate is defined, the rate of the confirmation date is used.", ) currency_rate = fields.Float( - digits=0, + default=0, + compute="_compute_currency_rate", copy=False, + store=True, help="If no rate is defined, the rate of the confirmation date is used.", ) + def button_validate(self): + for rec in self: + if ( + rec.valuation_currency_id + and rec.mapped("move_ids.purchase_line_id") + and rec.valuation_currency_id in rec.mapped("move_ids.purchase_line_id.order_id.currency_id") + and rec.currency_rate == 0 + and rec.move_ids.purchase_line_id.order_id.invoice_ids.filtered( + lambda inv: inv.state == "posted" and inv.currency_id == rec.valuation_currency_id + ) + ): + raise UserError( + """You cannot validate a picking with a zero currency rate. + The purchase already has an invoice with a determined rate; + we suggest reviewing it and applying the corresponding rate.""" + ) + return super().button_validate() + @api.depends("currency_rate") def _compute_inverse_currency_rate(self): for rec in self: @@ -30,6 +51,21 @@ def _inverse_currency_rate(self): for rec in self: rec.currency_rate = 1 / rec.inverse_currency_rate if rec.inverse_currency_rate else 0 + @api.depends("valuation_currency_id", "move_ids.purchase_line_id.invoice_lines.parent_state") + def _compute_currency_rate(self): + for rec in self: + if ( + not rec.currency_rate + and rec.state not in ["cancel", "done"] + and rec.valuation_currency_id in rec.mapped("move_ids.purchase_line_id.order_id.currency_id") + and rec.mapped("move_ids.purchase_line_id.invoice_lines") + ): + invoice_lines = rec.mapped("move_ids.purchase_line_id.invoice_lines").filtered( + lambda line: line.parent_state == "posted" + ) + if invoice_lines: + rec.currency_rate = invoice_lines[-1].move_id.invoice_currency_rate + def _compute_valuation_currency_id(self): for rec in self.filtered(lambda x: x.purchase_id and x.picking_type_id.code == "incoming"): valuation_currency_id = rec.move_ids.with_company(rec.company_id.id).mapped( diff --git a/stock_currency_valuation/models/stock_valuation_layer.py b/stock_currency_valuation/models/stock_valuation_layer.py index d0d9e9900..20db6193f 100644 --- a/stock_currency_valuation/models/stock_valuation_layer.py +++ b/stock_currency_valuation/models/stock_valuation_layer.py @@ -20,7 +20,7 @@ class StockValuationLayer(models.Model): ) product_tmpl_id = fields.Many2one(store=True) bypass_currency_valuation = fields.Boolean() - manual_currency_rate = fields.Float(store=True, digits=0, compute="_compute_manual_currency_rate") + manual_currency_rate = fields.Float(store=True, compute="_compute_manual_currency_rate") def move_is_return(self): return bool( diff --git a/stock_picking_returned_qty/__init__.py b/stock_delivery_zone/__init__.py similarity index 99% rename from stock_picking_returned_qty/__init__.py rename to stock_delivery_zone/__init__.py index d03377692..83bb583dc 100644 --- a/stock_picking_returned_qty/__init__.py +++ b/stock_delivery_zone/__init__.py @@ -2,4 +2,5 @@ # For copyright and license notices, see __manifest__.py file in module root # directory ############################################################################## + from . import models diff --git a/stock_picking_returned_qty/__manifest__.py b/stock_delivery_zone/__manifest__.py similarity index 73% rename from stock_picking_returned_qty/__manifest__.py rename to stock_delivery_zone/__manifest__.py index 9e893aa12..5771da4a3 100644 --- a/stock_picking_returned_qty/__manifest__.py +++ b/stock_delivery_zone/__manifest__.py @@ -1,6 +1,6 @@ ############################################################################## # -# Copyright (C) 2015 ADHOC SA (http://www.adhoc.com.ar) +# Copyright (C) 2026 ADHOC SA (http://www.adhoc.com.ar) # All Rights Reserved. # # This program is free software: you can redistribute it and/or modify @@ -18,19 +18,24 @@ # ############################################################################## { - "name": "Stock Picking Returned Quantity", + "name": "Stock Delivery Zone", "version": "18.0.1.0.0", "category": "Warehouse Management", - "sequence": 14, - "summary": "", + "summary": "Assign delivery zones to contacts and show them on transfers", "author": "ADHOC SA", "website": "www.adhoc.com.ar", "license": "AGPL-3", - "images": [], "depends": [ + "contacts", "stock_ux", ], - "data": [], + "data": [ + "security/ir.model.access.csv", + "views/stock_delivery_zone_views.xml", + "views/res_partner_views.xml", + "views/stock_picking_views.xml", + "report/stock_picking_reports.xml", + ], "demo": [], "installable": True, "auto_install": False, diff --git a/stock_delivery_zone/i18n/es.po b/stock_delivery_zone/i18n/es.po new file mode 100644 index 000000000..f5ef86e8b --- /dev/null +++ b/stock_delivery_zone/i18n/es.po @@ -0,0 +1,101 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_delivery_zone +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-06-02 13:00+0000\n" +"PO-Revision-Date: 2026-06-09 00:00+0000\n" +"Last-Translator: \n" +"Language-Team: Spanish (https://app.transifex.com/adhoc/teams/46451/es/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: es\n" +"Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n" + +#. module: stock_delivery_zone +#: model_terms:ir.ui.view,arch_db:stock_delivery_zone.stock_delivery_zone_report_picking_ux +msgid "Zone:" +msgstr "Zona:" + +#. module: stock_delivery_zone +#: model_terms:ir.ui.view,arch_db:stock_delivery_zone.stock_delivery_zone_report_delivery_document +msgid "Zone" +msgstr "Zona" + +#. module: stock_delivery_zone +#: model:ir.model.fields,field_description:stock_delivery_zone.field_stock_delivery_zone__active +msgid "Active" +msgstr "Activo" + +#. module: stock_delivery_zone +#: model:ir.model,name:stock_delivery_zone.model_res_partner +msgid "Contact" +msgstr "Contacto" + +#. module: stock_delivery_zone +#: model:ir.model.fields,field_description:stock_delivery_zone.field_stock_delivery_zone__create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: stock_delivery_zone +#: model:ir.model.fields,field_description:stock_delivery_zone.field_stock_delivery_zone__create_date +msgid "Created on" +msgstr "Fecha de creación" + +#. module: stock_delivery_zone +#: model:ir.model,name:stock_delivery_zone.model_stock_delivery_zone +msgid "Delivery Zone" +msgstr "Zona de entrega" + +#. module: stock_delivery_zone +#: model:ir.model.fields,field_description:stock_delivery_zone.field_res_partner__display_name +#: model:ir.model.fields,field_description:stock_delivery_zone.field_stock_delivery_zone__display_name +#: model:ir.model.fields,field_description:stock_delivery_zone.field_stock_picking__display_name +msgid "Display Name" +msgstr "Nombre mostrado" + +#. module: stock_delivery_zone +#: model:ir.model.fields,field_description:stock_delivery_zone.field_res_partner__id +#: model:ir.model.fields,field_description:stock_delivery_zone.field_stock_delivery_zone__id +#: model:ir.model.fields,field_description:stock_delivery_zone.field_stock_picking__id +msgid "ID" +msgstr "ID" + +#. module: stock_delivery_zone +#: model:ir.model.fields,field_description:stock_delivery_zone.field_stock_delivery_zone__write_uid +msgid "Last Updated by" +msgstr "Última actualización por" + +#. module: stock_delivery_zone +#: model:ir.model.fields,field_description:stock_delivery_zone.field_stock_delivery_zone__write_date +msgid "Last Updated on" +msgstr "Última actualización" + +#. module: stock_delivery_zone +#: model:ir.model.fields,field_description:stock_delivery_zone.field_stock_delivery_zone__name +msgid "Name" +msgstr "Nombre" + +#. module: stock_delivery_zone +#: model:ir.model,name:stock_delivery_zone.model_stock_picking +msgid "Transfer" +msgstr "Transferencia" + +#. module: stock_delivery_zone +#: model:ir.model.fields,field_description:stock_delivery_zone.field_res_partner__delivery_zone_id +#: model:ir.model.fields,field_description:stock_delivery_zone.field_res_users__delivery_zone_id +#: model:ir.model.fields,field_description:stock_delivery_zone.field_stock_picking__delivery_zone_id +#: model_terms:ir.ui.view,arch_db:stock_delivery_zone.stock_delivery_zone_view_stock_delivery_zone_form +msgid "Zone" +msgstr "Zona" + +#. module: stock_delivery_zone +#: model:ir.actions.act_window,name:stock_delivery_zone.stock_delivery_zone_action_stock_delivery_zone +#: model:ir.ui.menu,name:stock_delivery_zone.stock_delivery_zone_menu_stock_delivery_zone +#: model_terms:ir.ui.view,arch_db:stock_delivery_zone.stock_delivery_zone_view_stock_delivery_zone_list +msgid "Zones" +msgstr "Zonas" diff --git a/stock_delivery_zone/i18n/stock_delivery_zone.pot b/stock_delivery_zone/i18n/stock_delivery_zone.pot new file mode 100644 index 000000000..ea2c17743 --- /dev/null +++ b/stock_delivery_zone/i18n/stock_delivery_zone.pot @@ -0,0 +1,100 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_delivery_zone +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-06-02 13:00+0000\n" +"PO-Revision-Date: 2026-06-02 13:00+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: stock_delivery_zone +#: model_terms:ir.ui.view,arch_db:stock_delivery_zone.stock_delivery_zone_report_picking_ux +msgid "Zone:" +msgstr "" + +#. module: stock_delivery_zone +#: model_terms:ir.ui.view,arch_db:stock_delivery_zone.stock_delivery_zone_report_delivery_document +msgid "Zone" +msgstr "" + +#. module: stock_delivery_zone +#: model:ir.model.fields,field_description:stock_delivery_zone.field_stock_delivery_zone__active +msgid "Active" +msgstr "" + +#. module: stock_delivery_zone +#: model:ir.model,name:stock_delivery_zone.model_res_partner +msgid "Contact" +msgstr "" + +#. module: stock_delivery_zone +#: model:ir.model.fields,field_description:stock_delivery_zone.field_stock_delivery_zone__create_uid +msgid "Created by" +msgstr "" + +#. module: stock_delivery_zone +#: model:ir.model.fields,field_description:stock_delivery_zone.field_stock_delivery_zone__create_date +msgid "Created on" +msgstr "" + +#. module: stock_delivery_zone +#: model:ir.model,name:stock_delivery_zone.model_stock_delivery_zone +msgid "Delivery Zone" +msgstr "" + +#. module: stock_delivery_zone +#: model:ir.model.fields,field_description:stock_delivery_zone.field_res_partner__display_name +#: model:ir.model.fields,field_description:stock_delivery_zone.field_stock_delivery_zone__display_name +#: model:ir.model.fields,field_description:stock_delivery_zone.field_stock_picking__display_name +msgid "Display Name" +msgstr "" + +#. module: stock_delivery_zone +#: model:ir.model.fields,field_description:stock_delivery_zone.field_res_partner__id +#: model:ir.model.fields,field_description:stock_delivery_zone.field_stock_delivery_zone__id +#: model:ir.model.fields,field_description:stock_delivery_zone.field_stock_picking__id +msgid "ID" +msgstr "" + +#. module: stock_delivery_zone +#: model:ir.model.fields,field_description:stock_delivery_zone.field_stock_delivery_zone__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: stock_delivery_zone +#: model:ir.model.fields,field_description:stock_delivery_zone.field_stock_delivery_zone__write_date +msgid "Last Updated on" +msgstr "" + +#. module: stock_delivery_zone +#: model:ir.model.fields,field_description:stock_delivery_zone.field_stock_delivery_zone__name +msgid "Name" +msgstr "" + +#. module: stock_delivery_zone +#: model:ir.model,name:stock_delivery_zone.model_stock_picking +msgid "Transfer" +msgstr "" + +#. module: stock_delivery_zone +#: model:ir.model.fields,field_description:stock_delivery_zone.field_res_partner__delivery_zone_id +#: model:ir.model.fields,field_description:stock_delivery_zone.field_res_users__delivery_zone_id +#: model:ir.model.fields,field_description:stock_delivery_zone.field_stock_picking__delivery_zone_id +#: model_terms:ir.ui.view,arch_db:stock_delivery_zone.stock_delivery_zone_view_stock_delivery_zone_form +msgid "Zone" +msgstr "" + +#. module: stock_delivery_zone +#: model:ir.actions.act_window,name:stock_delivery_zone.stock_delivery_zone_action_stock_delivery_zone +#: model:ir.ui.menu,name:stock_delivery_zone.stock_delivery_zone_menu_stock_delivery_zone +#: model_terms:ir.ui.view,arch_db:stock_delivery_zone.stock_delivery_zone_view_stock_delivery_zone_list +msgid "Zones" +msgstr "" diff --git a/stock_picking_returned_qty/models/__init__.py b/stock_delivery_zone/models/__init__.py similarity index 82% rename from stock_picking_returned_qty/models/__init__.py rename to stock_delivery_zone/models/__init__.py index 0c1dc32ba..8714e0ae5 100644 --- a/stock_picking_returned_qty/models/__init__.py +++ b/stock_delivery_zone/models/__init__.py @@ -2,4 +2,7 @@ # For copyright and license notices, see __manifest__.py file in module root # directory ############################################################################## -from . import stock_move + +from . import delivery_zone +from . import res_partner +from . import stock_picking diff --git a/stock_delivery_zone/models/delivery_zone.py b/stock_delivery_zone/models/delivery_zone.py new file mode 100644 index 000000000..0b925fccb --- /dev/null +++ b/stock_delivery_zone/models/delivery_zone.py @@ -0,0 +1,15 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +# directory +############################################################################## + +from odoo import fields, models + + +class StockDeliveryZone(models.Model): + _name = "stock.delivery.zone" + _description = "Delivery Zone" + _order = "name" + + name = fields.Char(required=True) + active = fields.Boolean(default=True) diff --git a/stock_delivery_zone/models/res_partner.py b/stock_delivery_zone/models/res_partner.py new file mode 100644 index 000000000..4ffaf334c --- /dev/null +++ b/stock_delivery_zone/models/res_partner.py @@ -0,0 +1,16 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +# directory +############################################################################## + +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + delivery_zone_id = fields.Many2one( + comodel_name="stock.delivery.zone", + string="Zone", + ondelete="set null", + ) diff --git a/stock_delivery_zone/models/stock_picking.py b/stock_delivery_zone/models/stock_picking.py new file mode 100644 index 000000000..71e85148b --- /dev/null +++ b/stock_delivery_zone/models/stock_picking.py @@ -0,0 +1,18 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +# directory +############################################################################## + +from odoo import fields, models + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + delivery_zone_id = fields.Many2one( + comodel_name="stock.delivery.zone", + related="partner_id.delivery_zone_id", + string="Zone", + readonly=True, + ondelete="set null", + ) diff --git a/stock_delivery_zone/report/stock_picking_reports.xml b/stock_delivery_zone/report/stock_picking_reports.xml new file mode 100644 index 000000000..7c0c1d596 --- /dev/null +++ b/stock_delivery_zone/report/stock_picking_reports.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/stock_delivery_zone/security/ir.model.access.csv b/stock_delivery_zone/security/ir.model.access.csv new file mode 100644 index 000000000..b625b4c04 --- /dev/null +++ b/stock_delivery_zone/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_stock_delivery_zone_user,stock.delivery.zone user,model_stock_delivery_zone,base.group_user,1,0,0,0 +access_stock_delivery_zone_system,stock.delivery.zone system,model_stock_delivery_zone,base.group_system,1,1,1,1 diff --git a/stock_delivery_zone/views/res_partner_views.xml b/stock_delivery_zone/views/res_partner_views.xml new file mode 100644 index 000000000..23685f9d9 --- /dev/null +++ b/stock_delivery_zone/views/res_partner_views.xml @@ -0,0 +1,13 @@ + + + + res.partner.form.stock.delivery.zone + res.partner + + + + + + + + diff --git a/stock_delivery_zone/views/stock_delivery_zone_views.xml b/stock_delivery_zone/views/stock_delivery_zone_views.xml new file mode 100644 index 000000000..be67c1734 --- /dev/null +++ b/stock_delivery_zone/views/stock_delivery_zone_views.xml @@ -0,0 +1,42 @@ + + + + stock.delivery.zone.list + stock.delivery.zone + + + + + + + + + + stock.delivery.zone.form + stock.delivery.zone + +
+ + + + + + +
+
+
+ + + Zones + stock.delivery.zone + list,form + + + +
diff --git a/stock_delivery_zone/views/stock_picking_views.xml b/stock_delivery_zone/views/stock_picking_views.xml new file mode 100644 index 000000000..cd396124f --- /dev/null +++ b/stock_delivery_zone/views/stock_picking_views.xml @@ -0,0 +1,13 @@ + + + + stock.picking.form.stock.delivery.zone + stock.picking + + + + + + + + diff --git a/stock_orderpoint_manual_update/__manifest__.py b/stock_orderpoint_manual_update/__manifest__.py index f65396a51..1a9e17fe2 100644 --- a/stock_orderpoint_manual_update/__manifest__.py +++ b/stock_orderpoint_manual_update/__manifest__.py @@ -19,7 +19,7 @@ ############################################################################## { "name": "Stock Orderpoint Manual Update", - "version": "18.0.1.1.0", + "version": "18.0.1.2.0", "category": "Warehouse Management", "sequence": 14, "summary": "", diff --git a/stock_orderpoint_manual_update/models/stock_orderpoint.py b/stock_orderpoint_manual_update/models/stock_orderpoint.py index 82b14c3b5..a4f3e209a 100644 --- a/stock_orderpoint_manual_update/models/stock_orderpoint.py +++ b/stock_orderpoint_manual_update/models/stock_orderpoint.py @@ -25,7 +25,7 @@ def update_qty_forecast(self): rec.qty_forecast_stored = rec.qty_forecast def _get_orderpoint_products(self): - domain = [("type", "=", "product"), ("stock_move_ids", "!=", False)] + domain = [("is_storable", "=", True), ("stock_move_ids", "!=", False)] # Filter by suppliers suppliers_ids = self._context.get("filter_suppliers") @@ -52,8 +52,8 @@ def _get_orderpoint_locations(self): domain.append(("id", "in", location_ids)) return self.env["stock.location"].search(domain) - def action_replenish(self): - super().action_replenish() + def action_replenish(self, force_to_max=False): + result = super().action_replenish(force_to_max=force_to_max) action = self.with_context()._get_orderpoint_action() orderpoint_domain = self.with_context().env["stock.warehouse.orderpoint.wizard"].get_orderpoint_domain() action["domain"] = expression.AND( @@ -62,6 +62,9 @@ def action_replenish(self): orderpoint_domain, ] ) + if result and result.get("tag") == "display_notification": + result.setdefault("params", {})["next"] = action + return result return action def update_qty_to_order_orderpoint(self): diff --git a/stock_orderpoint_manual_update/views/stock_warehouse_orderpoint_views.xml b/stock_orderpoint_manual_update/views/stock_warehouse_orderpoint_views.xml index 76b4ba3f3..442b9bf2c 100644 --- a/stock_orderpoint_manual_update/views/stock_warehouse_orderpoint_views.xml +++ b/stock_orderpoint_manual_update/views/stock_warehouse_orderpoint_views.xml @@ -6,7 +6,10 @@ stock.warehouse.orderpoint - + + 1 + + diff --git a/stock_picking_returned_qty/README.rst b/stock_picking_returned_qty/README.rst deleted file mode 100644 index 22318843a..000000000 --- a/stock_picking_returned_qty/README.rst +++ /dev/null @@ -1,70 +0,0 @@ -.. |company| replace:: ADHOC SA - -.. |company_logo| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-logo.png - :alt: ADHOC SA - :target: https://www.adhoc.com.ar - -.. |icon| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-icon.png - -.. image:: https://img.shields.io/badge/license-AGPL--3-blue.png - :target: https://www.gnu.org/licenses/agpl - :alt: License: AGPL-3 - -=============================== -Stock Picking Returned Quantity -=============================== - -Calculates the quantity to deliver in the sale order taking into account the returned quantity - -Installation -============ - -To install this module, you need to: - -#. Just install this module. - -Configuration -============= - -To configure this module, you need to: - -#. No configuration nedeed. - -Usage -===== - -To use this module, you need to: - -#. Go to ... - -.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas - :alt: Try me on Runbot - :target: http://runbot.adhoc.com.ar/ - -Bug Tracker -=========== - -Bugs are tracked on `GitHub Issues -`_. In case of trouble, please -check there if your issue has already been reported. If you spotted it first, -help us smashing it by providing a detailed and welcomed feedback. - -Credits -======= - -Images ------- - -* |company| |icon| - -Contributors ------------- - -Maintainer ----------- - -|company_logo| - -This module is maintained by the |company|. - -To contribute to this module, please visit https://www.adhoc.com.ar. diff --git a/stock_picking_returned_qty/i18n/es.po b/stock_picking_returned_qty/i18n/es.po deleted file mode 100644 index 7f4db194c..000000000 --- a/stock_picking_returned_qty/i18n/es.po +++ /dev/null @@ -1,25 +0,0 @@ -# Translation of Odoo Server. -# This file contains the translation of the following modules: -# * stock_picking_returned_qty -# -# Translators: -# Juan José Scarafía , 2025 -# -msgid "" -msgstr "" -"Project-Id-Version: Odoo Server 18.0+e\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-02-05 23:11+0000\n" -"PO-Revision-Date: 2025-02-05 12:31+0000\n" -"Last-Translator: Juan José Scarafía , 2025\n" -"Language-Team: Spanish (https://app.transifex.com/adhoc/teams/46451/es/)\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: \n" -"Language: es\n" -"Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n" - -#. module: stock_picking_returned_qty -#: model:ir.model,name:stock_picking_returned_qty.model_stock_move -msgid "Stock Move" -msgstr "Movimiento de stock" diff --git a/stock_picking_returned_qty/models/stock_move.py b/stock_picking_returned_qty/models/stock_move.py deleted file mode 100644 index b19af00a9..000000000 --- a/stock_picking_returned_qty/models/stock_move.py +++ /dev/null @@ -1,22 +0,0 @@ -############################################################################## -# For copyright and license notices, see __manifest__.py file in module root -# directory -############################################################################## -from odoo import api, models - - -class StockMove(models.Model): - _inherit = "stock.move" - - @api.model_create_multi - def create(self, vals_list): - if ( - vals_list - and vals_list[0].get("picking_type_id") - and vals_list[0].get("sale_line_id") - and self.env["stock.picking.type"].browse(vals_list[0]["picking_type_id"]).code == "outgoing" - ): - sale_line_qty_ret = self.env["sale.order.line"].browse(vals_list[0]["sale_line_id"]).quantity_returned - vals_list[0]["product_uom_qty"] -= sale_line_qty_ret - res = super().create(vals_list) - return res diff --git a/stock_picking_state/__manifest__.py b/stock_picking_state/__manifest__.py index 036c8e03c..4427b3307 100644 --- a/stock_picking_state/__manifest__.py +++ b/stock_picking_state/__manifest__.py @@ -19,7 +19,7 @@ ############################################################################## { "name": "Stock Picking State", - "version": "18.0.1.1.0", + "version": "18.0.1.2.0", "category": "Warehouse Management", "sequence": 14, "summary": "", diff --git a/stock_picking_state/models/stock_picking_state_detail.py b/stock_picking_state/models/stock_picking_state_detail.py index 49a20a09d..92edf0d0c 100644 --- a/stock_picking_state/models/stock_picking_state_detail.py +++ b/stock_picking_state/models/stock_picking_state_detail.py @@ -21,6 +21,7 @@ class StockPickingStateDetail(models.Model): ("internal", "Internal"), ("outgoing", "Outgoing"), ("incoming", "Incoming"), + ("dropship", "Dropship"), ], ) state = fields.Selection( diff --git a/stock_ux/__manifest__.py b/stock_ux/__manifest__.py index 93cfa265a..d99705f82 100644 --- a/stock_ux/__manifest__.py +++ b/stock_ux/__manifest__.py @@ -19,7 +19,7 @@ ############################################################################## { "name": "Stock UX", - "version": "18.0.1.5.0", + "version": "18.0.1.11.0", "category": "Warehouse Management", "sequence": 14, "summary": "", @@ -45,6 +45,7 @@ "views/report_deliveryslip.xml", "views/res_config_settings_views.xml", "wizards/stock_operation_wizard_views.xml", + "wizards/stock_product_zpl_views.xml", "report/ir.action.reports.xml", "report/picking_templates.xml", "views/res_company_views.xml", diff --git a/stock_ux/migrations/18.0.1.11.0/pre-migration.py b/stock_ux/migrations/18.0.1.11.0/pre-migration.py new file mode 100644 index 000000000..3265028e1 --- /dev/null +++ b/stock_ux/migrations/18.0.1.11.0/pre-migration.py @@ -0,0 +1,33 @@ +# Copyright 2026 ADHOC SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + """Create stock_orderpoint_allow_multiple_over_max and qty_multiple_over_max + columns to support multiple-over-max feature in stock orderpoints. + """ + _logger.info("Starting migration: creating multiple-over-max columns") + + cr.execute(""" + ALTER TABLE res_company + ADD COLUMN IF NOT EXISTS stock_orderpoint_allow_multiple_over_max boolean + """) + cr.execute(""" + UPDATE res_company + SET stock_orderpoint_allow_multiple_over_max = TRUE + WHERE stock_orderpoint_allow_multiple_over_max IS NULL + """) + + cr.execute(""" + ALTER TABLE stock_warehouse_orderpoint + ADD COLUMN IF NOT EXISTS qty_multiple_over_max varchar + """) + cr.execute(""" + UPDATE stock_warehouse_orderpoint + SET qty_multiple_over_max = 'company' + WHERE qty_multiple_over_max IS NULL + """) diff --git a/stock_ux/migrations/18.0.1.5.0/pre-migration.py b/stock_ux/migrations/18.0.1.5.0/pre-migration.py index efc905b99..0732dfd4d 100644 --- a/stock_ux/migrations/18.0.1.5.0/pre-migration.py +++ b/stock_ux/migrations/18.0.1.5.0/pre-migration.py @@ -1,15 +1,25 @@ # Copyright 2025 ADHOC SA # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + +_logger = logging.getLogger(__name__) + def migrate(cr, version): - """Create index on warehouse_id for stock_warehouse_orderpoint table. + """Create qty_to_order_computed column and warehouse_id index. - Backport from v19: This index improves query performance when filtering - orderpoints by warehouse, which is a common operation. + Backport from v19: Adds stored qty_to_order_computed column and + warehouse_id index to improve query performance. """ - # Check if index already exists + # Create qty_to_order_computed column if it doesn't exist cr.execute(""" ALTER TABLE stock_warehouse_orderpoint - ADD COLUMN qty_to_order_computed numeric + ADD COLUMN IF NOT EXISTS qty_to_order_computed numeric + """) + + # Create index on warehouse_id if it doesn't exist + cr.execute(""" + CREATE INDEX IF NOT EXISTS stock_warehouse_orderpoint_warehouse_id_index + ON stock_warehouse_orderpoint (warehouse_id) """) diff --git a/stock_ux/models/__init__.py b/stock_ux/models/__init__.py index d58254965..4803ba021 100644 --- a/stock_ux/models/__init__.py +++ b/stock_ux/models/__init__.py @@ -10,6 +10,7 @@ from . import stock_warehouse_orderpoint from . import stock_move_line from . import stock_picking_type +from . import res_company from . import res_config_settings from . import stock_rule from . import stock_scrap diff --git a/stock_ux/models/res_company.py b/stock_ux/models/res_company.py new file mode 100644 index 000000000..9ce81142a --- /dev/null +++ b/stock_ux/models/res_company.py @@ -0,0 +1,16 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +# directory +############################################################################## + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + stock_orderpoint_allow_multiple_over_max = fields.Boolean( + string="Allow Reordering Rule Multiples Above Max", + default=True, + help="If enabled, replenishment rules can round up to the next multiple even when that exceeds the maximum quantity.", + ) diff --git a/stock_ux/models/res_config_settings.py b/stock_ux/models/res_config_settings.py index 29d14cc79..eb6445e5a 100644 --- a/stock_ux/models/res_config_settings.py +++ b/stock_ux/models/res_config_settings.py @@ -9,6 +9,12 @@ class ResConfigSettings(models.TransientModel): _inherit = "res.config.settings" + stock_orderpoint_allow_multiple_over_max = fields.Boolean( + string="Allow Reordering Rule Multiples Above Max", + related="company_id.stock_orderpoint_allow_multiple_over_max", + readonly=False, + ) + group_operation_used_lots = fields.Boolean( "Show Used Lots on Picking Operations", implied_group="stock_ux.group_operation_used_lots", diff --git a/stock_ux/models/stock_move.py b/stock_ux/models/stock_move.py index c405bc280..61a02e0ae 100644 --- a/stock_ux/models/stock_move.py +++ b/stock_ux/models/stock_move.py @@ -4,7 +4,6 @@ ############################################################################## from odoo import _, api, fields, models from odoo.exceptions import UserError, ValidationError -from odoo.tools import float_compare class StockMove(models.Model): @@ -48,36 +47,10 @@ def _compute_origin_description(self): for rec in self: if rec.sale_line_id: rec.origin_description = rec.sale_line_id.name - else: + elif rec.picking_id.origin: rec.origin_description = rec.product_id.name - - @api.constrains("quantity") - def _check_quantity(self): - precision = self.env["decimal.precision"].precision_get("Product Unit of Measure") - if any(self.filtered(lambda x: x.scrapped)): - return - moves = self.filtered( - lambda x: x.picking_id.picking_type_id.block_additional_quantity - and float_compare(x.product_uom_qty, x.quantity, precision_digits=precision) == -1 - ) - if not moves: - return - - # Si lo ejecuta el superusuario (scheduler), revertir el cambio y loguear - if self.env.is_superuser(): - for move in moves: - # Revertir el cambio de quantity - move.quantity = move.product_uom_qty - move.picking_id.message_post( - body=_( - "Se intentó transferir una cantidad mayor a la demanda inicial en el movimiento %s durante la ejecución automática (scheduler). El sistema ignoró el cambio y mantuvo la cantidad original." - ) - % move.display_name - ) - return - - # Comportamiento normal: raise si corresponde - raise ValidationError(_("You can not transfer more than the initial demand!")) + else: + rec.origin_description = rec.description_picking def action_view_linked_record(self): """This function returns an action that display existing sales order @@ -111,15 +84,25 @@ def check_cancel(self): if self._context.get("cancel_from_order") or self.env.is_superuser(): return if self.filtered( - lambda x: x.picking_id - and x.state == "cancel" - and not self.env.user.has_group("stock_ux.allow_picking_cancellation") + lambda x: ( + x.picking_id + and x.state == "cancel" + and not self.env.user.has_group("stock_ux.allow_picking_cancellation") + ) ): raise ValidationError("Only User with 'Picking cancelation allow' rights can cancel pickings") def _merge_moves(self, merge_into=False): # 22/04/2024: Agregamos esto porque sino al intentar confirmar compras con usuarios sin permisos, podia pasar que salga la constrain de arriba (check_cancel) - return super(StockMove, self.with_context(cancel_from_order=True))._merge_moves(merge_into=merge_into) + # Agregamos can_delete=True para permitir el unlink de moves duplicados durante el merge + return super(StockMove, self.with_context(cancel_from_order=True, can_delete=True))._merge_moves( + merge_into=merge_into + ) + + def action_explode(self): + # Cuando se explota un kit, MRP cancela y elimina el move original del producto kit, + # aunque tenga sale_line_id. Permitimos ese unlink con can_delete=True. + return super(StockMove, self.with_context(can_delete=True)).action_explode() @api.model_create_multi def create(self, vals_list): @@ -132,7 +115,7 @@ def create(self, vals_list): and sp.sale_id and (sp.sale_id.state == "sale" or sp.sale_id.state == "done") ): - if vals.get("additional", False): + if vals.get("additional", False) and not vals.get("origin_returned_move_id"): raise UserError( "No se puede agregar productos adicionales ni modificar las cantidades demandadas:\n" "- El pedido de venta se encuentra bloqueado.\n" @@ -155,3 +138,71 @@ def _trigger_assign(self): if not self.env.context.get("trigger_assign"): return super().with_context(trigger_assign=True)._trigger_assign() return super()._trigger_assign() + + def _action_assign(self, force_qty=False): + """Reservar / Comprobar disponibilidad crea líneas de reserva, no líneas + cargadas a mano, por lo que no debe dispararse el chequeo de + _check_manual_lines. El _trigger_assign automático ya lo evitaba, pero el + action_assign manual del picking no pasaba por ahí; marcamos el contexto + para saltear _check_quantity_available al crear las stock.move.line. + """ + return super(StockMove, self.with_context(trigger_assign=True))._action_assign(force_qty=force_qty) + + def _prepare_procurement_values(self): + values = super()._prepare_procurement_values() + physical_warehouse = self.location_id.warehouse_id + propagated_warehouse = values.get("warehouse_id") + is_subcontracting_move = ( + "raw_material_production_id" in self._fields + and "subcontractor_id" in self.raw_material_production_id._fields + and bool(self.raw_material_production_id.subcontractor_id) + ) + + # In some multi-warehouse MTO chains the move keeps the commercial + # warehouse in `warehouse_id` even when the real source location belongs + # to another warehouse. If we propagate that stale warehouse to the next + # procurement, Odoo may reuse a draft RFQ from the wrong warehouse and + # end up mixing destinations across warehouses in the same PO. + # Scope the correction to MTO moves only so other procurement flows can + # keep their intentional warehouse propagation. + if ( + self.procure_method == "make_to_order" + and not is_subcontracting_move + and physical_warehouse + and propagated_warehouse + and propagated_warehouse != physical_warehouse + ): + values["warehouse_id"] = physical_warehouse + + return values + + @api.ondelete(at_uninstall=False) + def _unlink_if_not_from_order(self): + """ + Prevent deletion of moves linked to sale or purchase orders. + Only manual moves (not from orders) can be deleted. + Allow deletion when coming from internal Odoo processes (like merge_moves). + """ + # Allow deletion when coming from internal processes + if self.env.context.get("can_delete"): + return + + protected_moves = self.env["stock.move"] + + # Check moves from sales (if sale_stock is installed) + if "sale_line_id" in self._fields: + protected_moves |= self.filtered(lambda m: m.sale_line_id) + + # Check moves from purchases (if purchase_stock is installed) + if "purchase_line_id" in self._fields: + protected_moves |= self.filtered(lambda m: m.purchase_line_id) + + if protected_moves: + raise UserError( + _( + "Cannot delete stock moves linked to sale or purchase orders.\n" + "Please modify quantities from the source order instead.\n\n" + "Affected moves: %s" + ) + % ", ".join(protected_moves.mapped("display_name")) + ) diff --git a/stock_ux/models/stock_move_line.py b/stock_ux/models/stock_move_line.py index bf74a5c19..34caee8a2 100644 --- a/stock_ux/models/stock_move_line.py +++ b/stock_ux/models/stock_move_line.py @@ -54,14 +54,18 @@ def _compute_product_uom_qty_location(self): product_uom_qty_location = 0.0 if rec.location_dest_id in locations else -rec.quantity rec.product_uom_qty_location = product_uom_qty_location - @api.constrains("quantity") def _check_manual_lines(self): + # Si tenemos este contexto es porque si o si viene de una compra + if "previous_product_qty" in self.env.context: + return if self._context.get("put_in_pack", False): return invalid_lines = self.filtered( - lambda x: not x.location_id.should_bypass_reservation() - and x.picking_id.picking_type_id.block_manual_lines - and x._check_quantity_available() < 0 + lambda x: ( + not x.location_id.should_bypass_reservation() + and x.picking_id.picking_type_id.block_manual_lines + and x._check_quantity_available() < 0 + ) ) if not invalid_lines: return @@ -101,7 +105,7 @@ def _check_quantity_available(self): quants = self.env["stock.quant"].search( [("product_id", "=", self.product_id.id), ("location_id", "in", locations.ids)] ) - total_available = sum(quants.mapped("available_quantity")) - self.quantity + total_available = sum(quants.mapped("available_quantity")) return total_available @api.model_create_multi @@ -113,6 +117,7 @@ def create(self, vals_list): if rec.picking_id and not rec.description_picking: product = rec.product_id.with_context(lang=rec.picking_id.partner_id.lang or rec.env.user.lang) rec.description_picking = product._get_description(rec.picking_id.picking_type_id) + recs._check_manual_lines() return recs def _get_aggregated_product_quantities(self, **kwargs): @@ -124,7 +129,7 @@ def _get_aggregated_product_quantities(self, **kwargs): move_line_by_move = {} for sml in self: move = sml.move_id - if move and move.origin_description: + if move and move.origin_description and sml.picking_id.origin: move_line_by_move.setdefault( move.id, {"description": move.origin_description, "product_id": sml.product_id.id} ) @@ -152,7 +157,8 @@ def _get_aggregated_properties(self, move_line=False, move=False): use_origin = ( self.env["ir.config_parameter"].sudo().get_param("stock_ux.delivery_slip_use_origin", "False") == "True" ) - if use_origin: + picking = move_line.picking_id if move_line else (move.picking_id if move else False) + if use_origin and picking and picking.origin: move = move or move_line.move_id uom = move.product_uom or move_line.product_uom_id name = move.product_id.display_name diff --git a/stock_ux/models/stock_picking.py b/stock_ux/models/stock_picking.py index 0694694c0..d6428ca26 100644 --- a/stock_ux/models/stock_picking.py +++ b/stock_ux/models/stock_picking.py @@ -5,6 +5,7 @@ ############################################################################## from odoo import models, fields, api, _ from odoo.exceptions import ValidationError, UserError +from odoo.tools.float_utils import float_compare class StockPicking(models.Model): @@ -39,7 +40,10 @@ def unlink(self): "or the state of the picking is not draft or cancel.\n" "Picking Ids: %s" ) - % (",".join(not_del_pickings.mapped("picking_type_id.name")), not_del_pickings.ids) + % ( + ",".join(not_del_pickings.mapped("picking_type_id.name")), + not_del_pickings.ids, + ) ) return super().unlink() @@ -68,6 +72,10 @@ def change_location_dest(self): def _send_confirmation_email(self): for rec in self: + # If stock_voucher is installed, skip email sending when validating the picking + if "book_required" in rec._fields and not rec._context.get("from_assign_numbers"): + continue + if rec.picking_type_id.mail_template_id: try: rec.with_context( @@ -89,27 +97,6 @@ def _send_confirmation_email(self): else: super(StockPicking, self)._send_confirmation_email() - def _action_done(self): - for rec in self.with_context( - mail_notify_force_send=False, - email_notification_force_header=True, - email_notification_force_footer=True, - ).filtered("picking_type_id.mail_template_id"): - try: - rec.message_post_with_template(rec.picking_type_id.mail_template_id.id) - except Exception as error: - title = _("ERROR: Picking was not sent via email") - rec.message_post( - body="

".join( - [ - "" + title + "", - _("Please check the email template associated with the picking type."), - "" + str(error) + "", - ] - ), - ) - return super()._action_done() - def new_force_availability(self): self.action_assign() for rec in self.mapped("move_ids").filtered(lambda m: m.state not in ["cancel", "done"]): @@ -166,3 +153,24 @@ def write(self, vals): ) ) return super().write(vals) + + def button_validate(self): + """Valida que no se transfiera más de la demanda inicial.""" + for picking in self: + if picking.picking_type_id.block_additional_quantity: + precision = self.env["decimal.precision"].precision_get("Product Unit of Measure") + for move in picking.move_ids.filtered(lambda m: m.state not in ("draft", "cancel")): + if float_compare(move.quantity, move.product_uom_qty, precision_digits=precision) == 1: + raise UserError( + _( + "Cannot transfer more than initial demand!\n\n" + "Product: %(product)s\n" + "Initial Demand: %(demand)s\n" + "Attempted Transfer: %(quantity)s\n\n" + "Please update the source document (Purchase/Sales Order) to increase quantities.", + product=move.product_id.display_name, + demand=move.product_uom_qty, + quantity=move.quantity, + ) + ) + return super().button_validate() diff --git a/stock_ux/models/stock_warehouse_orderpoint.py b/stock_ux/models/stock_warehouse_orderpoint.py index cb1654fcd..6e87a3153 100644 --- a/stock_ux/models/stock_warehouse_orderpoint.py +++ b/stock_ux/models/stock_warehouse_orderpoint.py @@ -8,6 +8,7 @@ from odoo import api, fields, models from odoo.osv import expression +from odoo.tools import float_compare, float_is_zero _logger = logging.getLogger(__name__) @@ -53,6 +54,17 @@ class StockWarehouseOrderpoint(models.Model): product_min_qty = fields.Float(tracking=True) product_max_qty = fields.Float(tracking=True) qty_multiple = fields.Float(tracking=True) + qty_multiple_over_max = fields.Selection( + selection=[ + ("company", "Use Company Setting"), + ("allow", "Allow Exceeding Max"), + ("restrict", "Respect Max"), + ], + string="Multiple Above Max", + default="company", + required=True, + tracking=True, + ) location_id = fields.Many2one(tracking=True) product_id = fields.Many2one(tracking=True) reviewed = fields.Boolean() @@ -173,6 +185,46 @@ def action_replenish(self, force_to_max=False): self._change_review_toggle_negative() return super(StockWarehouseOrderpoint, self).action_replenish(force_to_max) + def _is_qty_multiple_over_max_allowed(self): + self.ensure_one() + if self.qty_multiple_over_max == "allow": + return True + if self.qty_multiple_over_max == "restrict": + return False + return self.company_id.stock_orderpoint_allow_multiple_over_max + + def _get_qty_to_order(self, force_visibility_days=False, qty_in_progress_by_orderpoint=None): + self.ensure_one() + visibility_days = self.visibility_days + if force_visibility_days is not False: + visibility_days = force_visibility_days + qty_to_order = 0.0 + qty_in_progress_by_orderpoint = qty_in_progress_by_orderpoint or {} + qty_in_progress = qty_in_progress_by_orderpoint.get(self.id) + if qty_in_progress is None: + qty_in_progress = self._quantity_in_progress()[self.id] + rounding = self.product_uom.rounding + if float_compare(self.qty_forecast, self.product_min_qty, precision_rounding=rounding) < 0: + product_context = self._get_product_context(visibility_days=visibility_days) + qty_forecast_with_visibility = ( + self.product_id.with_context(**product_context).read(["virtual_available"])[0]["virtual_available"] + + qty_in_progress + ) + qty_to_order = max(self.product_min_qty, self.product_max_qty) - qty_forecast_with_visibility + remainder = (self.qty_multiple > 0.0 and qty_to_order % self.qty_multiple) or 0.0 + if ( + float_compare(remainder, 0.0, precision_rounding=rounding) > 0 + and float_compare(self.qty_multiple - remainder, 0.0, precision_rounding=rounding) > 0 + ): + if ( + float_is_zero(self.product_max_qty, precision_rounding=rounding) + or self._is_qty_multiple_over_max_allowed() + ): + qty_to_order += self.qty_multiple - remainder + else: + qty_to_order -= remainder + return qty_to_order + def update_qty_to_order(self): # Redefinimos ya que el metodo _compute_qty_to_order es privado valid_orderpoints = self.exists() diff --git a/stock_ux/report/ir.action.reports.xml b/stock_ux/report/ir.action.reports.xml index 02de1b39d..0d0184f46 100644 --- a/stock_ux/report/ir.action.reports.xml +++ b/stock_ux/report/ir.action.reports.xml @@ -10,6 +10,15 @@ report + + Etiquetas de Productos (ZPL) + product.label.layout + qweb-text + stock_ux.custom_product_barcode_zpl + stock_ux.custom_product_barcode_zpl + report + + Picking Operations stock.picking diff --git a/stock_ux/report/picking_templates.xml b/stock_ux/report/picking_templates.xml index d7ba61c5f..ff9d894c6 100644 --- a/stock_ux/report/picking_templates.xml +++ b/stock_ux/report/picking_templates.xml @@ -12,15 +12,15 @@ ^LH0,0 ^FO20,10,0 -^FO260,10 +^FO250,10 ^A0N,20,25^FD^FS -^FO20,40 +^FO10,40 ^A0N,40,30 ^TBN,360,40 ^FD^FS -^FO20,90 +^FO10,90 ^BY3 ^BCN,60,Y,N,N,A ^FD^FS @@ -29,13 +29,13 @@ ^FX Nueva etiqueta ^LH445,0 ^FO20,10,0 -^FO260,10 +^FO250,10 ^A0N,20,25^FD^FS -^FO20,40 +^FO10,40 ^A0N,40,30 ^TBN,360,40 ^FD^FS -^FO20,90 +^FO10,90 ^BY3 ^BCN,60,Y,N,N,A ^FD^FS @@ -46,4 +46,51 @@ ^PQ1,0,1,Y^XZ + + diff --git a/stock_ux/security/ir.model.access.csv b/stock_ux/security/ir.model.access.csv index a2456871d..a8981ea20 100644 --- a/stock_ux/security/ir.model.access.csv +++ b/stock_ux/security/ir.model.access.csv @@ -1,3 +1,4 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_stock_operation_wizard,access_stock_operation_wizard,model_stock_operation_wizard,base.group_user,1,1,1,1 stock_ux.access_stock_picking_zpl_lines,access_stock_picking_zpl_lines,stock_ux.model_stock_picking_zpl_lines,base.group_user,1,1,1,1 +stock_ux.access_stock_product_zpl_lines,access_stock_product_zpl_lines,stock_ux.model_stock_product_zpl_lines,base.group_user,1,1,1,1 diff --git a/stock_ux/tests/__init__.py b/stock_ux/tests/__init__.py new file mode 100644 index 000000000..82baed8bc --- /dev/null +++ b/stock_ux/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_mto_warehouse_propagation +from . import test_stock_orderpoint_multiple_over_max diff --git a/stock_ux/tests/test_mto_warehouse_propagation.py b/stock_ux/tests/test_mto_warehouse_propagation.py new file mode 100644 index 000000000..103d185ae --- /dev/null +++ b/stock_ux/tests/test_mto_warehouse_propagation.py @@ -0,0 +1,58 @@ +from odoo.addons.stock.tests.common import TestStockCommon +from odoo.tests import tagged + + +@tagged("stock_ux_mto") +class TestMtoWarehousePropagation(TestStockCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.warehouse_2 = cls.env["stock.warehouse"].create( + { + "name": "Secondary Warehouse", + "code": "SWH", + "company_id": cls.env.company.id, + "partner_id": cls.env.company.partner_id.id, + "reception_steps": "one_step", + "delivery_steps": "ship_only", + } + ) + cls.customer_location_rec = cls.env["stock.location"].browse(cls.customer_location) + + def test_prepare_procurement_values_uses_physical_warehouse_for_mto(self): + move = self._create_move( + self.productA, + self.warehouse_2.lot_stock_id, + self.customer_location_rec, + name="MTO stale warehouse", + picking_type_id=self.warehouse_1.out_type_id.id, + procure_method="make_to_order", + warehouse_id=self.warehouse_1.id, + ) + + values = move._prepare_procurement_values() + + self.assertEqual( + values["warehouse_id"], + self.warehouse_2, + "MTO procurements must use the physical warehouse of the source location.", + ) + + def test_prepare_procurement_values_keeps_non_mto_warehouse(self): + move = self._create_move( + self.productA, + self.warehouse_2.lot_stock_id, + self.customer_location_rec, + name="MTS stale warehouse", + picking_type_id=self.warehouse_1.out_type_id.id, + procure_method="make_to_stock", + warehouse_id=self.warehouse_1.id, + ) + + values = move._prepare_procurement_values() + + self.assertEqual( + values["warehouse_id"], + self.warehouse_1, + "Non-MTO procurements should keep their propagated warehouse untouched.", + ) diff --git a/stock_ux/tests/test_stock_orderpoint_multiple_over_max.py b/stock_ux/tests/test_stock_orderpoint_multiple_over_max.py new file mode 100644 index 000000000..1a4f8e81d --- /dev/null +++ b/stock_ux/tests/test_stock_orderpoint_multiple_over_max.py @@ -0,0 +1,48 @@ +from odoo.tests.common import TransactionCase + + +class TestStockOrderpointMultipleOverMax(TransactionCase): + def setUp(self): + super().setUp() + self.warehouse = self.env["stock.warehouse"].search([("company_id", "=", self.env.company.id)], limit=1) + self.product = self.env["product.product"].create( + { + "name": "Reordering Rule Multiple Product", + "is_storable": True, + } + ) + self.env["stock.quant"]._update_available_quantity(self.product, self.warehouse.lot_stock_id, 4) + + def _create_orderpoint(self, qty_multiple_over_max="company"): + orderpoint = self.env["stock.warehouse.orderpoint"].create( + { + "name": f"Orderpoint {qty_multiple_over_max}", + "product_id": self.product.id, + "location_id": self.warehouse.lot_stock_id.id, + "product_min_qty": 5, + "product_max_qty": 10, + "qty_multiple": 20, + "qty_multiple_over_max": qty_multiple_over_max, + } + ) + orderpoint._compute_qty() + orderpoint._compute_qty_to_order_computed() + return orderpoint + + def test_company_setting_allows_rounding_over_max(self): + self.env.company.stock_orderpoint_allow_multiple_over_max = True + orderpoint = self._create_orderpoint() + + self.assertEqual(orderpoint.qty_to_order_computed, 20.0) + + def test_orderpoint_can_override_company_setting(self): + self.env.company.stock_orderpoint_allow_multiple_over_max = True + orderpoint = self._create_orderpoint(qty_multiple_over_max="restrict") + + self.assertEqual(orderpoint.qty_to_order_computed, 0.0) + + def test_orderpoint_can_force_legacy_behavior(self): + self.env.company.stock_orderpoint_allow_multiple_over_max = False + orderpoint = self._create_orderpoint(qty_multiple_over_max="allow") + + self.assertEqual(orderpoint.qty_to_order_computed, 20.0) diff --git a/stock_ux/views/res_config_settings_views.xml b/stock_ux/views/res_config_settings_views.xml index 1837d1e45..5ae32d2e2 100644 --- a/stock_ux/views/res_config_settings_views.xml +++ b/stock_ux/views/res_config_settings_views.xml @@ -5,6 +5,17 @@ +
+
+ +
+
+
+
diff --git a/stock_ux/views/stock_picking_type_views.xml b/stock_ux/views/stock_picking_type_views.xml index 87571e8ef..efc96c0b3 100644 --- a/stock_ux/views/stock_picking_type_views.xml +++ b/stock_ux/views/stock_picking_type_views.xml @@ -1,6 +1,26 @@ + + stock.picking.type.kanban + stock.picking.type + + + + col-12 pe-0 text-truncate + + + col-12 pe-0 text-truncate + + + col-12 pe-0 text-truncate + + + col-12 pe-0 text-truncate + + + + stock.picking.type.form stock.picking.type diff --git a/stock_ux/views/stock_picking_views.xml b/stock_ux/views/stock_picking_views.xml index b430295fa..7121ecf6a 100644 --- a/stock_ux/views/stock_picking_views.xml +++ b/stock_ux/views/stock_picking_views.xml @@ -43,6 +43,9 @@ 1 + + state != 'done' + diff --git a/stock_ux/views/stock_warehouse_orderpoint_views.xml b/stock_ux/views/stock_warehouse_orderpoint_views.xml index f7263bbab..fe64e47f8 100644 --- a/stock_ux/views/stock_warehouse_orderpoint_views.xml +++ b/stock_ux/views/stock_warehouse_orderpoint_views.xml @@ -19,12 +19,26 @@ + + + + + stock.warehouse.orderpoint.form.multiple.over.max + stock.warehouse.orderpoint + + + + + + + + stock.warehouse.orderpoint.chatter stock.warehouse.orderpoint diff --git a/stock_ux/wizards/stock_label_type.py b/stock_ux/wizards/stock_label_type.py index 08cc0cb60..932aeccab 100644 --- a/stock_ux/wizards/stock_label_type.py +++ b/stock_ux/wizards/stock_label_type.py @@ -7,6 +7,7 @@ class ProductLabelLayout(models.TransientModel): _inherit = "product.label.layout" picking_id = fields.Many2one("stock.picking", string="picking") line_ids = fields.One2many("stock.picking.zpl.lines", "picking_zpl_id", string="Moves") + product_line_ids = fields.One2many("stock.product.zpl.lines", "wizard_id", string="Products") @api.model def default_get(self, default_fields): @@ -19,6 +20,17 @@ def default_get(self, default_fields): Command.create({"move_id": x.id, "move_quantity": x.quantity, "move_uom_id": x.product_uom.id}) for x in move_ids ] + return rec + # Support opening from product views (via Print Labels button). + # product_ids / product_tmpl_ids come from the action context as plain ID lists. + product_ids = self._context.get("default_product_ids", []) + product_tmpl_ids = self._context.get("default_product_tmpl_ids", []) + if product_ids: + products = self.env["product.product"].browse(product_ids) + rec["product_line_ids"] = [Command.create({"product_id": p.id, "quantity": 1}) for p in products] + elif product_tmpl_ids: + products = self.env["product.template"].browse(product_tmpl_ids).product_variant_ids + rec["product_line_ids"] = [Command.create({"product_id": p.id, "quantity": 1}) for p in products] return rec def action_print(self): @@ -35,6 +47,13 @@ def action_print_pdf(self): report_action["close_on_report_download"] = True return report_action + def action_print_product_zpl(self): + self.ensure_one() + report_id = self.env.ref("stock_ux.action_product_barcode_zpl") + report_action = report_id.report_action(self.ids) + report_action["close_on_report_download"] = True + return report_action + class StockPickingZplLines(models.TransientModel): _name = "stock.picking.zpl.lines" @@ -55,3 +74,13 @@ def _check_move_quantity(self): for line in self: if line.move_quantity > line.move_id.quantity: raise exceptions.ValidationError("La cantidad a imprimir no puede ser mayor que la cantidad original.") + + +class StockProductZplLines(models.TransientModel): + _name = "stock.product.zpl.lines" + _description = "Product ZPL Label lines" + + wizard_id = fields.Many2one("product.label.layout", required=True, ondelete="cascade") + product_id = fields.Many2one("product.product", required=True) + product_name = fields.Char(related="product_id.display_name", string="Producto") + quantity = fields.Integer(default=1, required=True) diff --git a/stock_ux/wizards/stock_product_zpl_views.xml b/stock_ux/wizards/stock_product_zpl_views.xml new file mode 100644 index 000000000..626a702f7 --- /dev/null +++ b/stock_ux/wizards/stock_product_zpl_views.xml @@ -0,0 +1,26 @@ + + + + product.label.layout + + product.label.layout.product.zpl + + + + + + + + + + + + +
+
+
+
+
+
+
diff --git a/stock_voucher/__manifest__.py b/stock_voucher/__manifest__.py index 30e9f0432..93d675a84 100644 --- a/stock_voucher/__manifest__.py +++ b/stock_voucher/__manifest__.py @@ -19,7 +19,7 @@ ############################################################################## { "name": "Stock Voucher", - "version": "18.0.1.5.0", + "version": "18.0.1.7.0", "category": "Warehouse Management", "sequence": 14, "summary": "", diff --git a/stock_voucher/models/stock_book.py b/stock_voucher/models/stock_book.py index 3c10243d3..2c90ca339 100644 --- a/stock_voucher/models/stock_book.py +++ b/stock_voucher/models/stock_book.py @@ -37,3 +37,4 @@ class StockBook(models.Model): default=lambda self: self.env.company, ) next_number = fields.Integer(related="sequence_id.number_next_actual", readonly=False) + active = fields.Boolean(default=True) diff --git a/stock_voucher/models/stock_picking.py b/stock_voucher/models/stock_picking.py index 72b6a49b2..0b697f4db 100644 --- a/stock_voucher/models/stock_picking.py +++ b/stock_voucher/models/stock_picking.py @@ -9,7 +9,13 @@ class StockPicking(models.Model): _inherit = "stock.picking" - book_id = fields.Many2one("stock.book", "Voucher Book", copy=False, ondelete="restrict", check_company=True) + book_id = fields.Many2one( + "stock.book", + "Voucher Book", + copy=False, + ondelete="restrict", + check_company=True, + ) vouchers = fields.Char( compute="_compute_vouchers", string="Vouchers (string)", @@ -52,7 +58,7 @@ def get_estimated_number_of_pages(self): if lines_per_voucher == 0: return res - operations = len(self.move_ids) + operations = len(self.move_line_ids) res = int(-(-float(operations) // float(lines_per_voucher))) return res @@ -81,6 +87,9 @@ def assign_numbers(self, estimated_number_of_pages, book): self.message_post(body=_("Números de remitos asignados: %s") % (self.vouchers)) self.write({"book_id": book.id}) + # Send confirmation email with voucher numbers already assigned + self.with_context(from_assign_numbers=True)._send_confirmation_email() + def clean_voucher_data(self): self.voucher_ids.unlink() self.book_id = False @@ -101,6 +110,9 @@ def do_stock_voucher_transfer_check(self): """ We separe to use it in other modules """ + if self.picking_type_id.number_of_packages: + packages = self.move_line_ids.mapped("result_package_id").filtered(lambda p: p) + self.number_of_packages = len(packages) for picking in self: if picking.picking_type_id.code == "outgoing": if picking.picking_type_id.restrict_number_package and not picking.number_of_packages > 0: @@ -112,15 +124,6 @@ def do_stock_voucher_transfer_check(self): raise UserError(_("You must set stock voucher numbers")) return True - def action_put_in_pack(self, move_lines_to_pack=False): - """ - We override to compute number of packages - """ - res = super().action_put_in_pack(move_lines_to_pack=move_lines_to_pack) - if self.picking_type_id.number_of_packages: - self.number_of_packages = len(self.package_level_ids) - return res - def button_validate(self): """ We make checks before calling transfer @@ -174,7 +177,9 @@ def _compute_declared_value(self): elif rec.picking_type_id.pricelist_id: pricelist = rec.picking_type_id.pricelist_id price = rec.picking_type_id.pricelist_id.with_context(uom=move_line.product_uom.id)._price_get( - move_line.product_id, move_line.quantity or 1.0, partner=rec.partner_id.id + move_line.product_id, + move_line.quantity or 1.0, + partner=rec.partner_id.id, )[rec.picking_type_id.pricelist_id.id] picking_value += price * move_line.product_uom_qty done_value += price * move_line.quantity @@ -190,8 +195,11 @@ def _compute_declared_value(self): bom_moves = so_bom_line.move_ids & stock_bom_lines._origin done_avg = [] picking_avg = [] + # Explode for 1 kit to get base quantities per component boms, lines = bom.sudo().explode( - so_bom_line.product_id, so_bom_line.product_uom_qty, picking_type=bom.picking_type_id + so_bom_line.product_id, + 1.0, + picking_type=bom.picking_type_id, ) for move in bom_moves: bom_quantity = 0.0 @@ -201,10 +209,14 @@ def _compute_declared_value(self): if not bom_quantity: continue rec_move = rec.move_ids.filtered(lambda m: m._origin.id == move.id) + if not rec_move: + continue picking_avg.append(move.product_uom_qty / bom_quantity) done_avg.append(rec_move.quantity / bom_quantity) - picking_value += so_bom_line.price_reduce_taxexcl * (sum(picking_avg) / len(picking_avg)) - done_value += so_bom_line.price_reduce_taxexcl * (sum(done_avg) / len(done_avg)) + if picking_avg and done_avg: + # Average represents how many kits, multiply by unit price + picking_value += so_bom_line.price_reduce_taxexcl * (sum(picking_avg) / len(picking_avg)) + done_value += so_bom_line.price_reduce_taxexcl * (sum(done_avg) / len(done_avg)) declared_value = picking_value if inmediate_transfer else done_value if pricelist: diff --git a/stock_voucher/views/stock_book_views.xml b/stock_voucher/views/stock_book_views.xml index 4ebc1a079..ab341f8d2 100644 --- a/stock_voucher/views/stock_book_views.xml +++ b/stock_voucher/views/stock_book_views.xml @@ -22,6 +22,7 @@ + diff --git a/stock_voucher/wizards/stock_backorder_confirmation.py b/stock_voucher/wizards/stock_backorder_confirmation.py index d80d46693..7f4c33c70 100644 --- a/stock_voucher/wizards/stock_backorder_confirmation.py +++ b/stock_voucher/wizards/stock_backorder_confirmation.py @@ -34,7 +34,7 @@ def process(self): return res def process_cancel_backorder(self): - super().process_cancel_backorder() + res = super().process_cancel_backorder() pickings = ( self.env["stock.picking"] .browse( @@ -47,4 +47,7 @@ def process_cancel_backorder(self): .filtered("book_required") ) if pickings: + if isinstance(res, dict): + return res, pickings.do_print_voucher() return pickings.do_print_voucher() + return res diff --git a/stock_voucher_ux/__manifest__.py b/stock_voucher_ux/__manifest__.py index b41897f39..395f5f4dd 100644 --- a/stock_voucher_ux/__manifest__.py +++ b/stock_voucher_ux/__manifest__.py @@ -19,7 +19,7 @@ ############################################################################## { "name": "Stock Voucher UX", - "version": "18.0.1.1.0", + "version": "18.0.1.4.0", "category": "Warehouse Management", "sequence": 14, "summary": "", diff --git a/stock_voucher_ux/controllers/main.py b/stock_voucher_ux/controllers/main.py index f2b013bcc..e9130ede0 100644 --- a/stock_voucher_ux/controllers/main.py +++ b/stock_voucher_ux/controllers/main.py @@ -12,7 +12,9 @@ class ReportController(report.ReportController): def _count_pages_with_products(self, pdf_reader, picking_id): """ Cuenta las páginas que realmente contienen productos - analizando el contenido de texto de cada página + analizando el contenido de texto de cada página. + Usa identificadores de producto (código interno, código de barras) + para la detección, de forma independiente del idioma. """ picking = request.env["stock.picking"].browse(picking_id) move_lines = picking.move_line_ids @@ -21,7 +23,15 @@ def _count_pages_with_products(self, pdf_reader, picking_id): if not move_lines: move_lines = picking.move_ids - product_codes = [line.product_id.default_code or line.product_id.name for line in move_lines if line.product_id] + # Recopilar identificadores de producto + product_identifiers = set() + for line in move_lines: + product = getattr(line, "product_id", None) + if product: + if product.default_code: + product_identifiers.add(product.default_code.lower().strip()) + if product.barcode: + product_identifiers.add(product.barcode.lower().strip()) pages_with_products = 0 @@ -29,11 +39,14 @@ def _count_pages_with_products(self, pdf_reader, picking_id): try: page = pdf_reader.pages[page_num] text = page.extract_text() - - # Verificar si algún código/nombre de producto aparece en esta página - has_products = any( - product_code and product_code in text for product_code in product_codes if product_code - ) + if not text: + continue + text_lower = text.lower() + if product_identifiers and any(pid in text_lower for pid in product_identifiers): + has_products = True + else: + # Fallback: patrón numérico genérico (independiente del idioma) + has_products = bool(re.search(r"\b\d+[.,]\d+\b", text_lower)) if has_products: pages_with_products += 1 @@ -64,10 +77,8 @@ def report_download(self, data, context=None, token=None, **kwargs): assign = context_dict.get("assign") book_id = request.env["stock.picking"].browse(picking_id).book_id if assign and book_id and picking_id: - copies_result = request.env["ir.actions.report"].search_read( - [("report_name", "ilike", "remito")], ["copies"], limit=1 - ) - copies = copies_result[0]["copies"] if copies_result else None + # Copias del reporte que se imprime, resuelto por su report_name en la URL. + copies = request.env["ir.actions.report"]._get_voucher_copies_from_url(url) # Check if response is PDF, if not (like .doc), assign 1 voucher try: pdf_response = response.response[0] @@ -85,45 +96,62 @@ def report_download(self, data, context=None, token=None, **kwargs): # If not PDF or can't process (like .doc), assign only 1 voucher number_pages = 1 - # See if there are vouchers already assigned. If not, then it assigns the vouchers - if not request.env["stock.picking"].browse(picking_id).voucher_ids and book_id: - request.env["stock.picking"].browse(picking_id).assign_numbers(number_pages, book_id) + # See if there are vouchers already assigned. If not, assign them + # based on the real page count, then re-render so the numbers show. + picking = request.env["stock.picking"].browse(picking_id) + if not picking.voucher_ids and book_id: + picking.assign_numbers(number_pages, book_id) + picking.env.flush_all() + # Re-render: the first PDF had no numbers yet; this second + # render includes the just-assigned voucher numbers. + try: + response = super().report_download(data, context=context, token=token, **kwargs) + except Exception: + pass elif "report_deliveryslip" in url: - # If the report is not an aeroo, the assign method should only assign one voucher + # Fallback: if numbers weren't pre-assigned (e.g. printed outside + # do_print_and_assign), assign them post-render based on actual page count. + # Note: in this path the first PDF won't show the numbers; use + # do_print_and_assign to guarantee numbers on the first print. match = re.search(r"(\d+)$", json.loads(data)[0]) if match: picking_id = int(match.group(1)) - book_id = request.env["stock.picking"].browse(picking_id).book_id + picking = request.env["stock.picking"].browse(picking_id) + book_id = picking.book_id if book_id and book_id.autoprinted == False and picking_id: - try: - pdf_response = response.response[0] - reader = PdfFileReader(io.BytesIO(pdf_response)) - - # Usar el nuevo método para contar páginas con productos - copies_result = request.env["ir.actions.report"].search_read( - [("report_name", "=", "stock.report_deliveryslip")], ["l10n_ar_copies"], limit=1 - ) - copies = copies_result[0]["l10n_ar_copies"] if copies_result else None - - if copies == "triplicado": - total_pages = int(len(reader.pages) / 3) - elif copies == "duplicado": - total_pages = int(len(reader.pages) / 2) - else: - total_pages = len(reader.pages) - - number_pages = self._count_pages_with_products(reader, picking_id) - number_pages = min(number_pages, total_pages) - except Exception: - # If not PDF or can't process, assign only 1 voucher - number_pages = 1 - - if not request.env["stock.picking"].browse(picking_id).voucher_ids and book_id: - request.env["stock.picking"].browse(picking_id).assign_numbers(number_pages, book_id) + if not picking.voucher_ids and book_id: + try: + pdf_response = response.response[0] + reader = PdfFileReader(io.BytesIO(pdf_response)) + + copies_result = request.env["ir.actions.report"].search_read( + [("report_name", "=", "stock.report_deliveryslip")], ["l10n_ar_copies"], limit=1 + ) + copies = copies_result[0]["l10n_ar_copies"] if copies_result else None + + if copies == "triplicado": + total_pages = int(len(reader.pages) / 3) + elif copies == "duplicado": + total_pages = int(len(reader.pages) / 2) + else: + total_pages = len(reader.pages) + + number_pages = self._count_pages_with_products(reader, picking_id) + number_pages = min(number_pages, total_pages) + except Exception: + number_pages = 1 + + picking.assign_numbers(number_pages, book_id) + picking.env.flush_all() + # Re-render so the assigned numbers appear on the first PDF. + try: + response = super().report_download(data, context=context, token=token, **kwargs) + except Exception: + pass elif book_id and picking_id: - if not request.env["stock.picking"].browse(picking_id).voucher_ids and book_id: - request.env["stock.picking"].browse(picking_id).assign_numbers(1, book_id) + if not picking.voucher_ids and book_id: + picking.assign_numbers(1, book_id) return response diff --git a/stock_voucher_ux/i18n/es.po b/stock_voucher_ux/i18n/es.po index dd5d040cc..63bd60509 100644 --- a/stock_voucher_ux/i18n/es.po +++ b/stock_voucher_ux/i18n/es.po @@ -42,6 +42,12 @@ msgstr "CAI:" msgid "Clean Voucher Data" msgstr "Limpiar Remitos" +#. module: stock_voucher_ux +#. odoo-python +#: code:addons/stock_voucher_ux/models/stock_picking.py:0 +msgid "The voucher number %s exceeds the range specified in the CAI. Please update the range or use a different CAI with a different range." +msgstr "El número de remito %s excede el rango especificado en el CAI. Actualice el rango o utilice otro CAI con un rango diferente." + #. module: stock_voucher_ux #: model:ir.model.fields,help:stock_voucher_ux.field_stock_book__autoprinted #: model:ir.model.fields,help:stock_voucher_ux.field_stock_picking__autoprinted @@ -90,6 +96,11 @@ msgstr "Imprimir Remitos" msgid "Printed" msgstr "Impreso" +#. module: stock_voucher_ux +#: model:ir.model.fields,field_description:stock_voucher_ux.field_stock_book__sequence_to +msgid "Sequence To" +msgstr "Secuencia Hasta" + #. module: stock_voucher_ux #: model:ir.model,name:stock_voucher_ux.model_stock_book msgid "Stock Voucher Book" diff --git a/stock_voucher_ux/models/__init__.py b/stock_voucher_ux/models/__init__.py index e00b401a5..7715689e7 100644 --- a/stock_voucher_ux/models/__init__.py +++ b/stock_voucher_ux/models/__init__.py @@ -2,5 +2,6 @@ # For copyright and license notices, see __manifest__.py file in module root # directory ############################################################################## +from . import ir_actions_report from . import stock_book from . import stock_picking diff --git a/stock_voucher_ux/models/ir_actions_report.py b/stock_voucher_ux/models/ir_actions_report.py new file mode 100644 index 000000000..16d172eab --- /dev/null +++ b/stock_voucher_ux/models/ir_actions_report.py @@ -0,0 +1,19 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +# directory +############################################################################## +from odoo import models + + +class IrActionsReport(models.Model): + _inherit = "ir.actions.report" + + def _get_voucher_copies_from_url(self, url): + """Copias (campo aeroo ``copies``) del reporte que se imprime, + resuelto por su ``report_name`` en la URL.""" + marker = "/report/aeroo/" + if marker not in url: + return None + report_name = url.split(marker)[1].split("?")[0].split("/")[0] + report = self.search([("report_name", "=", report_name)], limit=1) + return report.copies if report else None diff --git a/stock_voucher_ux/models/stock_book.py b/stock_voucher_ux/models/stock_book.py index 2ae4b89fb..caf361782 100644 --- a/stock_voucher_ux/models/stock_book.py +++ b/stock_voucher_ux/models/stock_book.py @@ -2,7 +2,7 @@ # For copyright and license notices, see __manifest__.py file in module root # directory ############################################################################## -from odoo import fields, models +from odoo import api, fields, models class StockBook(models.Model): @@ -12,3 +12,13 @@ class StockBook(models.Model): help="If voucher is not an autoprinted, it will assign as many vouchers as pages the report has. " "Otherwise, it will assign only one voucher", ) + sequence_to = fields.Char( + help="Número límite superior hasta el cual se puede usar este libro de stock. " + "Deje el campo vacío para indicar sin límite. Si ingresa un valor, se completará automáticamente con ceros a la izquierda hasta 8 dígitos.", + required=False, + ) + + @api.onchange("sequence_to") + def _add_padding_to_sequence_to(self): + if self.sequence_to: + self.sequence_to = self.sequence_to.zfill(8) diff --git a/stock_voucher_ux/models/stock_picking.py b/stock_voucher_ux/models/stock_picking.py index acabc8845..4e6d44854 100644 --- a/stock_voucher_ux/models/stock_picking.py +++ b/stock_voucher_ux/models/stock_picking.py @@ -2,7 +2,7 @@ # For copyright and license notices, see __manifest__.py file in module root # directory ############################################################################## -from odoo import api, fields, models +from odoo import _, api, fields, models from odoo.exceptions import UserError @@ -39,11 +39,36 @@ def do_print_and_assign(self): if not self.book_id and self.picking_type_code != "incoming": raise UserError("Primero debe seleccionar un talonario") if self.autoprinted == False: + # Talonario preimpreso: la cantidad de remitos debe coincidir con las + # páginas REALES del reporte. No pre-asignamos por la estimación + # ``lines_per_voucher`` (subnumera: p. ej. asigna 3 cuando el remito + # tiene 5 páginas). Imprimimos con ``assign=True`` para que el + # controller cuente las páginas renderizadas, asigne los números y + # re-renderice el PDF ya con los números puestos. self.printed = True return self.with_context(assign=True).do_print_voucher() else: + if self.book_id.sequence_to and int(self.next_voucher_number) > int(self.book_id.sequence_to): + raise UserError( + _( + "The voucher number %s exceeds the range specified in the CAI. Please update the range or use a different CAI with a different range.", + self.next_voucher_number, + ) + ) self.assign_numbers(1, self.book_id) return self.do_print_voucher() + def _action_done(self): + # Los talonarios preimpresos (``autoprinted=False``) se numeran al + # IMPRIMIR según las páginas reales del reporte, no en la validación por + # la estimación ``lines_per_voucher``. Evitamos que la base los + # pre-asigne acá; los autoimpresos siguen numerándose como antes. + res = super(StockPicking, self.with_context(do_not_assign_numbers=True))._action_done() + if self._context.get("do_not_assign_numbers"): + return res + for picking in self.filtered(lambda p: p.book_required and p.book_id and p.book_id.autoprinted): + picking.assign_numbers(picking.get_estimated_number_of_pages(), picking.book_id) + return res + def clean_voucher_data(self): return super(StockPicking, self).clean_voucher_data() diff --git a/stock_voucher_ux/tests/__init__.py b/stock_voucher_ux/tests/__init__.py new file mode 100644 index 000000000..e1d091c1c --- /dev/null +++ b/stock_voucher_ux/tests/__init__.py @@ -0,0 +1 @@ +from . import test_remito_preimpreso diff --git a/stock_voucher_ux/tests/test_remito_preimpreso.py b/stock_voucher_ux/tests/test_remito_preimpreso.py new file mode 100644 index 000000000..7e22bc807 --- /dev/null +++ b/stock_voucher_ux/tests/test_remito_preimpreso.py @@ -0,0 +1,104 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +# directory +############################################################################## +from odoo.tests.common import TransactionCase + + +class TestRemitoPreimpresoNumbering(TransactionCase): + """Numeración de remitos según el tipo de talonario. + + Preimpreso (``autoprinted=False``): NO se numera en la validación por la + estimación ``lines_per_voucher`` (subnumera). La cantidad se determina al + imprimir, según las páginas reales del reporte (controller). + + Autoimpreso (``autoprinted=True``): conserva el comportamiento previo + (asigna en la validación). + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.sequence = cls.env["ir.sequence"].create( + { + "name": "Test stock voucher", + "code": "stock.voucher", + "prefix": "0001-", + "padding": 8, + "implementation": "no_gap", + } + ) + cls.book_pre = cls.env["stock.book"].create( + { + "name": "Preimpreso test", + "sequence_id": cls.sequence.id, + "lines_per_voucher": 25, + "autoprinted": False, + } + ) + cls.book_auto = cls.env["stock.book"].create( + { + "name": "Autoimpreso test", + "sequence_id": cls.sequence.id, + "lines_per_voucher": 0, + "autoprinted": True, + } + ) + # Consumible no almacenable: la validación no requiere stock disponible. + cls.product = cls.env["product.product"].create( + { + "name": "Producto remito test", + "type": "consu", + } + ) + cls.src = cls.env.ref("stock.stock_location_stock") + cls.dest = cls.env.ref("stock.stock_location_customers") + + def _make_done_picking(self, book): + picking_type = self.env.ref("stock.picking_type_out") + picking_type.write({"book_required": True, "book_id": book.id, "voucher_required": False}) + picking = self.env["stock.picking"].create( + { + "picking_type_id": picking_type.id, + "location_id": self.src.id, + "location_dest_id": self.dest.id, + "book_id": book.id, + "move_ids": [ + ( + 0, + 0, + { + "name": self.product.name, + "product_id": self.product.id, + "product_uom_qty": 1.0, + "product_uom": self.product.uom_id.id, + "location_id": self.src.id, + "location_dest_id": self.dest.id, + }, + ) + ], + } + ) + picking.action_confirm() + for move in picking.move_ids: + move.quantity = move.product_uom_qty + picking.with_context(skip_sms=True).button_validate() + return picking + + def test_preprinted_not_preassigned_on_validation(self): + picking = self._make_done_picking(self.book_pre) + self.assertEqual(picking.state, "done") + self.assertFalse( + picking.voucher_ids, + "Un talonario preimpreso no debe pre-numerarse por estimación en _action_done; " + "la numeración se hace al imprimir según páginas reales.", + ) + + def test_autoprinted_assigned_on_validation(self): + picking = self._make_done_picking(self.book_auto) + self.assertEqual(picking.state, "done") + self.assertEqual( + len(picking.voucher_ids), + 1, + "Un talonario autoimpreso debe asignar un único remito en la validación.", + ) diff --git a/stock_voucher_ux/views/stock_book_views.xml b/stock_voucher_ux/views/stock_book_views.xml index e5a4de8c9..4eb158f6f 100644 --- a/stock_voucher_ux/views/stock_book_views.xml +++ b/stock_voucher_ux/views/stock_book_views.xml @@ -16,6 +16,9 @@ autoprinted == False + + +