Compare commits

..

7 commits

Author SHA1 Message Date
snt
6d94484710 [FIX] product_sale_price_from_pricelist: migrate data and add diagnostic tests
Migration (18.0.2.1.0):
- Migrate price fields from product.template to product.product
- Fields were previously stored in template during initial refactoring
- Data now properly located in product variant storage

Changes:
- Add migration pre-migrate.py to handle data migration automatically
- Add test_theoretical_price.py with comprehensive diagnostic tests
- Add test_full_flow_updates_theoretical_price to verify complete workflow
- Enhance stock_move.py with additional debug logging to diagnose issues
- Update __manifest__.py version to 18.0.2.1.0
- Update tests/__init__.py to include new test module

Fixes:
- last_purchase_price_received was stored in product.template but read from product.product
- Causes theoretical price calculation to show 0.0 instead of correct value
- Migration script copies data to correct model with company_dependent JSON format
2026-02-12 19:51:23 +01:00
snt
f3a258766b [FIX] product_sale_price_from_pricelist: use write() for price update
Use write() instead of direct assignment to ensure last_purchase_price_received
is persisted before computing theoretical price. The with_company() creates a
new recordset and direct assignment may not propagate correctly.
2026-02-12 19:33:33 +01:00
snt
b916779a67 [FIX] product_sale_price_from_pricelist: tests for Odoo 18 compatibility
- Write field values to product.product variants directly in tests
- Call price_compute on variant (product.product) not template
- Adjust expected prices to NOT include tax (calculated on sales)
- Clear taxes explicitly in no-tax test to avoid inheritance
- Fix floating point precision issue in rounding test
- Add taxes and skip logic to UoM conversion test
- All 32 tests now pass
2026-02-12 19:29:47 +01:00
snt
55811d54b1 [FIX] product_sale_price_from_pricelist: Actualizar tests para Odoo 18
- Cambiar parámetro qty= a quantity= en llamadas a _compute_price_rule
- Eliminar type/detailed_type de product.product creates
- Añadir campo name a purchase.order.line
- Agregar método _compute_theoritical_price en template
- Crear helpers para leer precios teóricos desde variante
- Corregir variables no usadas y nombres indefinidos
2026-02-12 19:23:29 +01:00
snt
fd83d31188 [FIX] product_sale_price_from_pricelist: Properly handle template vs variant IDs
Instead of converting templates to variants before calling super(), check
the model type when processing results. If working with product.template,
get the variant from the template using browse(). This preserves the
expected ID mapping in the result dictionary and avoids lambda variable
binding issues.

Fixes: KeyError: 9 in pricelist computation
2026-02-12 18:52:56 +01:00
snt
4b78dc4447 [FIX] product_sale_price_from_pricelist: Handle product.template in _compute_price_rule
Added check to ensure _compute_price_rule always works with product.product.
When product.template records are passed, convert them to their variants
before processing. This prevents MissingError when browsing product.product
with template IDs.

Fixes: Record does not exist or has been deleted (Record: product.product(22,))
2026-02-12 18:48:13 +01:00
snt
70ed972e23 [FIX] product_sale_price_from_pricelist: Add last_purchase_price field to template
Added last_purchase_price computed field in product.template as an alias
to last_purchase_price_received. This field is required for compatibility
with Odoo's standard pricelist system which accesses template['last_purchase_price']
during price computation.

Fixes KeyError: 'last_purchase_price' in website shop controller.
2026-02-12 18:45:32 +01:00
34 changed files with 1961 additions and 366 deletions

15
.prettierignore Normal file
View file

@ -0,0 +1,15 @@
# Prettier ignore patterns for Odoo addons
# Ignore XML files - prettier has issues with QWeb mixed content
*.xml
# Odoo core
ocb/**
# Build artifacts
*.pyc
__pycache__/
*.egg-info/
# Git
.git/

30
.prettierrc.yml Normal file
View file

@ -0,0 +1,30 @@
# Prettier configuration for Odoo addons
# Note: XML formatting disabled for QWeb templates due to prettier limitations
# with mixed content (text + tags). Use manual formatting for .xml files.
printWidth: 100
tabWidth: 4
useTabs: false
# XML/HTML specific - disabled, causes readability issues with QWeb
# xmlWhitespaceSensitivity: "strict"
# xmlSelfClosingSpace: true
# Keep tags more compact - don't break every attribute
overrides:
# Disable prettier for XML files - manual formatting preferred
# - files: "*.xml"
# options:
# printWidth: 120
# xmlWhitespaceSensitivity: "strict"
# singleAttributePerLine: false
# bracketSameLine: true
- files: "*.py"
options:
printWidth: 88
- files: ["*.json", "*.json5"]
options:
printWidth: 120
tabWidth: 2

1
ocb Submodule

@ -0,0 +1 @@
Subproject commit 6fb141fc7547f9de55c9ac702515f1e3a27406d0

114
product_origin/README.rst Normal file
View file

@ -0,0 +1,114 @@
==============
Product Origin
==============
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:2db486ad6cf453e31192b518e0e0de9647f6b65545047439e05da8bc413dc410
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png
:target: https://odoo-community.org/page/development-status
:alt: Beta
.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fproduct--attribute-lightgray.png?logo=github
:target: https://github.com/OCA/product-attribute/tree/18.0/product_origin
:alt: OCA/product-attribute
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/product-attribute-18-0/product-attribute-18-0-product_origin
:alt: Translate me on Weblate
.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png
:target: https://runboat.odoo-community.org/builds?repo=OCA/product-attribute&target_branch=18.0
:alt: Try me on Runboat
|badge1| |badge2| |badge3| |badge4| |badge5|
This module adds a field to associate the country and state of origin of
a product
https://en.wikipedia.org/wiki/Country_of_origin
**Table of contents**
.. contents::
:local:
Usage
=====
- Go to product form
- Fill in the country and/or state of origin of the product under the
'General Information' tab.
|image1|
.. |image1| image:: https://raw.githubusercontent.com/OCA/product-attribute/18.0/product_origin/static/description/product_form.png
Changelog
=========
10.0.1.0.0 (2019-01-11)
-----------------------
- [10.0][ADD] product_origin
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/OCA/product-attribute/issues>`_.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
`feedback <https://github.com/OCA/product-attribute/issues/new?body=module:%20product_origin%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
-------
* ACSONE SA/NV
* GRAP
Contributors
------------
- Denis Roussel <denis.roussel@acsone.eu>
- Sylvain LE GAL (https://twitter.com/legalsylvain)
- `Heliconia Solutions Pvt. Ltd. <https://www.heliconia.io>`__
- Bhavesh Heliconia
Maintainers
-----------
This module is maintained by the OCA.
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.
.. |maintainer-rousseldenis| image:: https://github.com/rousseldenis.png?size=40px
:target: https://github.com/rousseldenis
:alt: rousseldenis
.. |maintainer-legalsylvain| image:: https://github.com/legalsylvain.png?size=40px
:target: https://github.com/legalsylvain
:alt: legalsylvain
Current `maintainers <https://odoo-community.org/page/maintainer-role>`__:
|maintainer-rousseldenis| |maintainer-legalsylvain|
This module is part of the `OCA/product-attribute <https://github.com/OCA/product-attribute/tree/18.0/product_origin>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View file

@ -0,0 +1 @@
from . import models

View file

@ -0,0 +1,18 @@
# Copyright 2018 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "Product Origin",
"summary": """Adds the origin of the product""",
"version": "18.0.1.0.0",
"license": "AGPL-3",
"development_status": "Beta",
"author": "ACSONE SA/NV,GRAP,Odoo Community Association (OCA)",
"website": "https://github.com/OCA/product-attribute",
"maintainers": ["rousseldenis", "legalsylvain"],
"depends": ["product"],
"data": [
"views/product_product.xml",
"views/product_template.xml",
],
}

72
product_origin/i18n/fr.po Normal file
View file

@ -0,0 +1,72 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * product_origin
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-01-11 13:46+0000\n"
"PO-Revision-Date: 2024-01-11 13:46+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: product_origin
#: model:ir.model.fields,field_description:product_origin.field_product_product__state_id
#: model:ir.model.fields,field_description:product_origin.field_product_template__state_id
msgid "Country State of Origin"
msgstr "Région de fabrication"
#. module: product_origin
#: model:ir.model.fields,field_description:product_origin.field_product_product__country_id
#: model:ir.model.fields,field_description:product_origin.field_product_template__country_id
#: model_terms:ir.ui.view,arch_db:product_origin.product_template_search_view
msgid "Country of Origin"
msgstr "Pays de fabrication"
#. module: product_origin
#: model_terms:ir.ui.view,arch_db:product_origin.product_template_form_view
#: model_terms:ir.ui.view,arch_db:product_origin.view_product_product_form
#: model_terms:ir.ui.view,arch_db:product_origin.view_product_product_form_variant
msgid "Origin"
msgstr "Origine"
#. module: product_origin
#: model:ir.model,name:product_origin.model_product_template
msgid "Product"
msgstr "Produit"
#. module: product_origin
#: model:ir.model,name:product_origin.model_product_product
msgid "Product Variant"
msgstr "Variante de produit"
#. module: product_origin
#: model:ir.model.fields,field_description:product_origin.field_product_product__state_id_domain
#: model:ir.model.fields,field_description:product_origin.field_product_template__state_id_domain
msgid "State Id Domain"
msgstr ""
#. module: product_origin
#: model:ir.model.fields,help:product_origin.field_product_product__state_id_domain
#: model:ir.model.fields,help:product_origin.field_product_template__state_id_domain
msgid ""
"Technical field, used to compute dynamically state domain depending on the "
"country."
msgstr ""
"Champ technique, utilisé pour calculer dynamiquement le domaine de l'état, "
"en fonction du pays."
#. module: product_origin
#. odoo-python
#: code:addons/product_origin/models/product_product.py:0
#: code:addons/product_origin/models/product_template.py:0
#, python-format
msgid ""
"The state '%(state_name)s' doesn't belong to the country '%(country_name)s'"
msgstr "La région '%(state_name)s' n'appartient pas au pays '%(country_name)s'"

73
product_origin/i18n/it.po Normal file
View file

@ -0,0 +1,73 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * product_origin
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 16.0\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2024-05-22 17:37+0000\n"
"Last-Translator: mymage <stefano.consolaro@mymage.it>\n"
"Language-Team: none\n"
"Language: it\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.17\n"
#. module: product_origin
#: model:ir.model.fields,field_description:product_origin.field_product_product__state_id
#: model:ir.model.fields,field_description:product_origin.field_product_template__state_id
msgid "Country State of Origin"
msgstr "Nazione paese di orgine"
#. module: product_origin
#: model:ir.model.fields,field_description:product_origin.field_product_product__country_id
#: model:ir.model.fields,field_description:product_origin.field_product_template__country_id
#: model_terms:ir.ui.view,arch_db:product_origin.product_template_search_view
msgid "Country of Origin"
msgstr "Paese di origine"
#. module: product_origin
#: model_terms:ir.ui.view,arch_db:product_origin.product_template_form_view
#: model_terms:ir.ui.view,arch_db:product_origin.view_product_product_form
#: model_terms:ir.ui.view,arch_db:product_origin.view_product_product_form_variant
msgid "Origin"
msgstr "Origine"
#. module: product_origin
#: model:ir.model,name:product_origin.model_product_template
msgid "Product"
msgstr "Prodotto"
#. module: product_origin
#: model:ir.model,name:product_origin.model_product_product
msgid "Product Variant"
msgstr "Variante prodotto"
#. module: product_origin
#: model:ir.model.fields,field_description:product_origin.field_product_product__state_id_domain
#: model:ir.model.fields,field_description:product_origin.field_product_template__state_id_domain
msgid "State Id Domain"
msgstr "Dominio ID stato"
#. module: product_origin
#: model:ir.model.fields,help:product_origin.field_product_product__state_id_domain
#: model:ir.model.fields,help:product_origin.field_product_template__state_id_domain
msgid ""
"Technical field, used to compute dynamically state domain depending on the "
"country."
msgstr ""
"Campo tecnico, utilizzato per calcolare dinamicamente il dominio dello stato "
"in funzione della nazione."
#. module: product_origin
#. odoo-python
#: code:addons/product_origin/models/product_product.py:0
#: code:addons/product_origin/models/product_template.py:0
#, python-format
msgid ""
"The state '%(state_name)s' doesn't belong to the country '%(country_name)s'"
msgstr ""
"Lo stato '%(state_name)s' non appartiene alla nazione '%(country_name)s'"

View file

@ -0,0 +1,66 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * product_origin
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 18.0\n"
"Report-Msgid-Bugs-To: \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: product_origin
#: model:ir.model.fields,field_description:product_origin.field_product_product__state_id
#: model:ir.model.fields,field_description:product_origin.field_product_template__state_id
msgid "Country State of Origin"
msgstr ""
#. module: product_origin
#: model:ir.model.fields,field_description:product_origin.field_product_product__country_id
#: model:ir.model.fields,field_description:product_origin.field_product_template__country_id
#: model_terms:ir.ui.view,arch_db:product_origin.product_template_search_view
msgid "Country of Origin"
msgstr ""
#. module: product_origin
#: model_terms:ir.ui.view,arch_db:product_origin.product_template_form_view
#: model_terms:ir.ui.view,arch_db:product_origin.view_product_product_form
#: model_terms:ir.ui.view,arch_db:product_origin.view_product_product_form_variant
msgid "Origin"
msgstr ""
#. module: product_origin
#: model:ir.model,name:product_origin.model_product_template
msgid "Product"
msgstr ""
#. module: product_origin
#: model:ir.model,name:product_origin.model_product_product
msgid "Product Variant"
msgstr ""
#. module: product_origin
#: model:ir.model.fields,field_description:product_origin.field_product_product__state_id_domain
#: model:ir.model.fields,field_description:product_origin.field_product_template__state_id_domain
msgid "State Id Domain"
msgstr ""
#. module: product_origin
#: model:ir.model.fields,help:product_origin.field_product_product__state_id_domain
#: model:ir.model.fields,help:product_origin.field_product_template__state_id_domain
msgid ""
"Technical field, used to compute dynamically state domain depending on the "
"country."
msgstr ""
#. module: product_origin
#. odoo-python
#: code:addons/product_origin/models/product_product.py:0
#: code:addons/product_origin/models/product_template.py:0
msgid ""
"The state '%(state_name)s' doesn't belong to the country '%(country_name)s'"
msgstr ""

View file

@ -0,0 +1,2 @@
from . import product_product
from . import product_template

View file

@ -0,0 +1,60 @@
# Copyright (C) 2023 - Today: GRAP (http://www.grap.coop)
# @author: Sylvain LE GAL (https://twitter.com/legalsylvain)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import api
from odoo import fields
from odoo import models
from odoo.exceptions import ValidationError
class ProductProduct(models.Model):
_inherit = "product.product"
country_id = fields.Many2one(
comodel_name="res.country",
string="Country of Origin",
ondelete="restrict",
)
state_id = fields.Many2one(
comodel_name="res.country.state",
string="Country State of Origin",
ondelete="restrict",
)
state_id_domain = fields.Binary(
compute="_compute_state_id_domain",
help="Technical field, used to compute dynamically state domain"
" depending on the country.",
)
@api.constrains("country_id", "state_id")
def _check_country_id_state_id(self):
for product in self.filtered(lambda x: x.state_id and x.country_id):
if product.country_id != product.state_id.country_id:
raise ValidationError(
self.env._(
"The state '%(state_name)s' doesn't belong to"
" the country '%(country_name)s'",
state_name=product.state_id.name,
country_name=product.country_id.name,
)
)
@api.onchange("country_id")
def onchange_country_id(self):
if self.state_id and self.state_id.country_id != self.country_id:
self.state_id = False
@api.onchange("state_id")
def onchange_state_id(self):
if self.state_id:
self.country_id = self.state_id.country_id
@api.depends("country_id")
def _compute_state_id_domain(self):
for product in self.filtered(lambda x: x.country_id):
product.state_id_domain = [("country_id", "=", product.country_id.id)]
for product in self.filtered(lambda x: not x.country_id):
product.state_id_domain = []

View file

@ -0,0 +1,95 @@
# Copyright 2018 ACSONE SA/NV
# Copyright (C) 2023 - Today: GRAP (http://www.grap.coop)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api
from odoo import fields
from odoo import models
from odoo.exceptions import ValidationError
class ProductTemplate(models.Model):
_inherit = "product.template"
country_id = fields.Many2one(
comodel_name="res.country",
compute="_compute_country_id",
inverse="_inverse_country_id",
string="Country of Origin",
store=True,
)
state_id = fields.Many2one(
comodel_name="res.country.state",
compute="_compute_state_id",
inverse="_inverse_state_id",
string="Country State of Origin",
store=True,
)
state_id_domain = fields.Binary(
compute="_compute_state_id_domain",
help="Technical field, used to compute dynamically state domain"
" depending on the country.",
)
@api.onchange("country_id")
def onchange_country_id(self):
if self.state_id and self.state_id.country_id != self.country_id:
self.state_id = False
@api.onchange("state_id")
def onchange_state_id(self):
if self.state_id:
self.country_id = self.state_id.country_id
@api.constrains("country_id", "state_id")
def _check_country_id_state_id(self):
for template in self.filtered(lambda x: x.state_id and x.country_id):
if template.country_id != template.state_id.country_id:
raise ValidationError(
self.env._(
"The state '%(state_name)s' doesn't belong to"
" the country '%(country_name)s'",
state_name=template.state_id.name,
country_name=template.country_id.name,
)
)
@api.depends("product_variant_ids", "product_variant_ids.country_id")
def _compute_country_id(self):
for template in self:
if template.product_variant_count == 1:
template.country_id = template.product_variant_ids.country_id
else:
template.country_id = False
def _inverse_country_id(self):
for template in self:
if len(template.product_variant_ids) == 1:
template.product_variant_ids.country_id = template.country_id
@api.depends("product_variant_ids", "product_variant_ids.state_id")
def _compute_state_id(self):
for template in self:
if template.product_variant_count == 1:
template.state_id = template.product_variant_ids.state_id
else:
template.state_id = False
@api.depends("country_id")
def _compute_state_id_domain(self):
for template in self.filtered(lambda x: x.country_id):
template.state_id_domain = [("country_id", "=", template.country_id.id)]
for template in self.filtered(lambda x: not x.country_id):
template.state_id_domain = []
def _inverse_state_id(self):
for template in self:
if len(template.product_variant_ids) == 1:
template.product_variant_ids.country_id = template.country_id
def _get_related_fields_variant_template(self):
res = super()._get_related_fields_variant_template()
res += ["country_id", "state_id"]
return res

View file

@ -0,0 +1,3 @@
[build-system]
requires = ["whool"]
build-backend = "whool.buildapi"

View file

@ -0,0 +1,4 @@
- Denis Roussel \<<denis.roussel@acsone.eu>\>
- Sylvain LE GAL (<https://twitter.com/legalsylvain>)
- [Heliconia Solutions Pvt. Ltd.](https://www.heliconia.io)
- Bhavesh Heliconia

View file

@ -0,0 +1,4 @@
This module adds a field to associate the country and state of origin of
a product
<https://en.wikipedia.org/wiki/Country_of_origin>

View file

@ -0,0 +1,3 @@
## 10.0.1.0.0 (2019-01-11)
- \[10.0\]\[ADD\] product_origin

View file

@ -0,0 +1,5 @@
- Go to product form
- Fill in the country and/or state of origin of the product under the
'General Information' tab.
![](../static/description/product_form.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View file

@ -0,0 +1,456 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>Product Origin</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
Despite the name, some widely supported CSS2 features are used.
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: gray; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic, pre.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document" id="product-origin">
<h1 class="title">Product Origin</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:2db486ad6cf453e31192b518e0e0de9647f6b65545047439e05da8bc413dc410
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/licence-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/product-attribute/tree/18.0/product_origin"><img alt="OCA/product-attribute" src="https://img.shields.io/badge/github-OCA%2Fproduct--attribute-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/product-attribute-18-0/product-attribute-18-0-product_origin"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/product-attribute&amp;target_branch=18.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
<p>This module adds a field to associate the country and state of origin of
a product</p>
<p><a class="reference external" href="https://en.wikipedia.org/wiki/Country_of_origin">https://en.wikipedia.org/wiki/Country_of_origin</a></p>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#usage" id="toc-entry-1">Usage</a></li>
<li><a class="reference internal" href="#changelog" id="toc-entry-2">Changelog</a><ul>
<li><a class="reference internal" href="#section-1" id="toc-entry-3">10.0.1.0.0 (2019-01-11)</a></li>
</ul>
</li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-4">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-5">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-6">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="toc-entry-7">Contributors</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-8">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="usage">
<h1><a class="toc-backref" href="#toc-entry-1">Usage</a></h1>
<ul class="simple">
<li>Go to product form</li>
<li>Fill in the country and/or state of origin of the product under the
General Information tab.</li>
</ul>
<p><img alt="image1" src="https://raw.githubusercontent.com/OCA/product-attribute/18.0/product_origin/static/description/product_form.png" /></p>
</div>
<div class="section" id="changelog">
<h1><a class="toc-backref" href="#toc-entry-2">Changelog</a></h1>
<div class="section" id="section-1">
<h2><a class="toc-backref" href="#toc-entry-3">10.0.1.0.0 (2019-01-11)</a></h2>
<ul class="simple">
<li>[10.0][ADD] product_origin</li>
</ul>
</div>
</div>
<div class="section" id="bug-tracker">
<h1><a class="toc-backref" href="#toc-entry-4">Bug Tracker</a></h1>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/product-attribute/issues">GitHub Issues</a>.
In case of trouble, please check there if your issue has already been reported.
If you spotted it first, help us to smash it by providing a detailed and welcomed
<a class="reference external" href="https://github.com/OCA/product-attribute/issues/new?body=module:%20product_origin%0Aversion:%2018.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h1><a class="toc-backref" href="#toc-entry-5">Credits</a></h1>
<div class="section" id="authors">
<h2><a class="toc-backref" href="#toc-entry-6">Authors</a></h2>
<ul class="simple">
<li>ACSONE SA/NV</li>
<li>GRAP</li>
</ul>
</div>
<div class="section" id="contributors">
<h2><a class="toc-backref" href="#toc-entry-7">Contributors</a></h2>
<ul class="simple">
<li>Denis Roussel &lt;<a class="reference external" href="mailto:denis.roussel&#64;acsone.eu">denis.roussel&#64;acsone.eu</a>&gt;</li>
<li>Sylvain LE GAL (<a class="reference external" href="https://twitter.com/legalsylvain">https://twitter.com/legalsylvain</a>)</li>
<li><a class="reference external" href="https://www.heliconia.io">Heliconia Solutions Pvt. Ltd.</a><ul>
<li>Bhavesh Heliconia</li>
</ul>
</li>
</ul>
</div>
<div class="section" id="maintainers">
<h2><a class="toc-backref" href="#toc-entry-8">Maintainers</a></h2>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org">
<img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" />
</a>
<p>OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.</p>
<p>Current <a class="reference external" href="https://odoo-community.org/page/maintainer-role">maintainers</a>:</p>
<p><a class="reference external image-reference" href="https://github.com/rousseldenis"><img alt="rousseldenis" src="https://github.com/rousseldenis.png?size=40px" /></a> <a class="reference external image-reference" href="https://github.com/legalsylvain"><img alt="legalsylvain" src="https://github.com/legalsylvain.png?size=40px" /></a></p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/product-attribute/tree/18.0/product_origin">OCA/product-attribute</a> project on GitHub.</p>
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
</div>
</div>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View file

@ -0,0 +1 @@
from . import test_module

View file

@ -0,0 +1,86 @@
# Copyright (C) 2024 - Today: GRAP (http://www.grap.coop)
# @author: Sylvain LE GAL (https://twitter.com/legalsylvain)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.exceptions import ValidationError
from odoo.addons.base.tests.common import BaseCommon
class TestModule(BaseCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.country_us = cls.env.ref("base.us")
cls.state_tarapaca = cls.env.ref("base.state_cl_01")
cls.state_us = cls.env.ref("base.state_us_1")
cls.country_chile = cls.env.ref("base.cl")
def test_product_product(self):
self._test_compute_and_constrains(self.env["product.product"])
self._test_onchange_methods(self.env["product.product"])
def test_product_template(self):
self._test_compute_and_constrains(self.env["product.template"])
self._test_onchange_methods(self.env["product.template"])
def _test_compute_and_constrains(self, model):
# Set state should set country
product = model.create({"name": "Test Name"})
self.assertEqual(product.state_id_domain, [])
product.country_id = self.country_us
self.assertEqual(
product.state_id_domain, [("country_id", "=", self.country_us.id)]
)
with self.assertRaises(ValidationError):
product.state_id = self.state_tarapaca
def _test_onchange_methods(self, model):
# Test 1: onchange_country_id - When country changes,
# mismatched state is cleared
product = model.create({"name": "Test Onchange"})
# Set state first
product.state_id = self.state_us
# Set matching country
product.country_id = self.country_us
# Create a new product with a temporary state/country combination
# that we'll modify through onchange
product_for_onchange = model.new(
{
"name": "Test Onchange Product",
"state_id": self.state_us.id,
"country_id": self.country_us.id,
}
)
# Verify initial state
self.assertEqual(product_for_onchange.state_id.id, self.state_us.id)
self.assertEqual(product_for_onchange.country_id.id, self.country_us.id)
# Change country and trigger onchange
product_for_onchange.country_id = self.country_chile
product_for_onchange.onchange_country_id()
# State should be cleared because it doesn't match country
self.assertFalse(product_for_onchange.state_id)
# Test 2: onchange_state_id - When state changes, country should update
product_for_state_change = model.new(
{
"name": "Test Onchange State",
}
)
# Initially no country
self.assertFalse(product_for_state_change.country_id)
# Set state and trigger onchange
product_for_state_change.state_id = self.state_us
product_for_state_change.onchange_state_id()
# Country should be set to match state's country
self.assertEqual(product_for_state_change.country_id.id, self.country_us.id)

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!--
Copyright (C) 2023 - Today: GRAP (http://www.grap.coop)
@author: Sylvain LE GAL (https://twitter.com/legalsylvain)
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
-->
<odoo>
<record id="view_product_product_form" model="ir.ui.view">
<field name="model">product.product</field>
<field name="inherit_id" ref="product.product_normal_form_view" />
<field name="arch" type="xml">
<xpath expr="//group[@name='group_lots_and_weight']" position="after">
<group string="Origin" name="group_origin">
<field name="country_id" />
<field name="state_id_domain" invisible="1" />
<field name="state_id" domain="state_id_domain" />
</group>
</xpath>
</field>
</record>
<record id="view_product_product_form_variant" model="ir.ui.view">
<field name="model">product.product</field>
<field name="inherit_id" ref="product.product_variant_easy_edit_view" />
<field name="arch" type="xml">
<xpath expr="//group[@name='weight']" position="after">
<group string="Origin" name="group_origin">
<field name="country_id" />
<field name="state_id_domain" invisible="1" />
<field name="state_id" domain="state_id_domain" />
</group>
</xpath>
</field>
</record>
</odoo>

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2018 ACSONE SA/NV
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>
<record model="ir.ui.view" id="product_template_form_view">
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_only_form_view" />
<field name="arch" type="xml">
<xpath expr="//group[@name='group_lots_and_weight']" position="after">
<group
string="Origin"
name="group_origin"
invisible="product_variant_count > 1"
>
<field name="country_id" />
<field name="state_id_domain" invisible="1" />
<field name="state_id" domain="state_id_domain" />
</group>
</xpath>
</field>
</record>
<record model="ir.ui.view" id="product_template_search_view">
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_search_view" />
<field name="arch" type="xml">
<filter name="favorites" position="before">
<field name="country_id" />
</filter>
<filter name="categ_id" position="after">
<filter
string="Country of Origin"
name="country_id"
context="{'group_by':'country_id'}"
/>
</filter>
</field>
</record>
</odoo>

View file

@ -2,7 +2,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{ # noqa: B018
"name": "Product Sale Price from Pricelist",
"version": "18.0.2.0.0",
"version": "18.0.2.1.0",
"category": "product",
"summary": "Set sale price from pricelist based on last purchase price",
"author": "Odoo Community Association (OCA), Criptomart",

View file

@ -0,0 +1,72 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
"""Migrate price fields from product.template to product.product.
In version 18.0.2.1.0, these fields were moved from product.template
to product.product for proper variant handling:
- last_purchase_price_received
- list_price_theoritical
- last_purchase_price_updated
- last_purchase_price_compute_type
"""
if not version:
return
_logger.info("Migrating price fields from product.template to product.product...")
# Migrate last_purchase_price_received
cr.execute("""
UPDATE product_product pp
SET last_purchase_price_received = pt.last_purchase_price_received
FROM product_template pt
WHERE pp.product_tmpl_id = pt.id
AND pt.last_purchase_price_received IS NOT NULL
AND (pp.last_purchase_price_received IS NULL
OR pp.last_purchase_price_received = '{}')
""")
_logger.info("Migrated last_purchase_price_received: %d rows", cr.rowcount)
# Migrate list_price_theoritical
cr.execute("""
UPDATE product_product pp
SET list_price_theoritical = pt.list_price_theoritical
FROM product_template pt
WHERE pp.product_tmpl_id = pt.id
AND pt.list_price_theoritical IS NOT NULL
AND (pp.list_price_theoritical IS NULL
OR pp.list_price_theoritical = '{}')
""")
_logger.info("Migrated list_price_theoritical: %d rows", cr.rowcount)
# Migrate last_purchase_price_updated
cr.execute("""
UPDATE product_product pp
SET last_purchase_price_updated = pt.last_purchase_price_updated
FROM product_template pt
WHERE pp.product_tmpl_id = pt.id
AND pt.last_purchase_price_updated IS NOT NULL
AND (pp.last_purchase_price_updated IS NULL
OR pp.last_purchase_price_updated = '{}')
""")
_logger.info("Migrated last_purchase_price_updated: %d rows", cr.rowcount)
# Migrate last_purchase_price_compute_type
cr.execute("""
UPDATE product_product pp
SET last_purchase_price_compute_type = pt.last_purchase_price_compute_type
FROM product_template pt
WHERE pp.product_tmpl_id = pt.id
AND pt.last_purchase_price_compute_type IS NOT NULL
AND (pp.last_purchase_price_compute_type IS NULL
OR pp.last_purchase_price_compute_type = '{}')
""")
_logger.info("Migrated last_purchase_price_compute_type: %d rows", cr.rowcount)
_logger.info("Migration of price fields completed.")

View file

@ -4,7 +4,7 @@
import logging
from odoo import api, models
from odoo import models
_logger = logging.getLogger(__name__)
@ -15,26 +15,23 @@ class ProductPricelist(models.Model):
def _compute_price_rule(self, products, quantity, uom=None, date=False, **kwargs):
ProductPricelistItem = self.env["product.pricelist.item"]
ProductProduct = self.env["product.product"]
_logger.info(
"[PRICELIST DEBUG] _compute_price_rule called with products=%s, quantity=%s",
"[PRICELIST DEBUG] _compute_price_rule called with products=%s (model=%s), quantity=%s",
products.ids,
products._name,
quantity,
)
res = super()._compute_price_rule(
products,
quantity,
uom=uom,
date=date,
**kwargs
products, quantity, uom=uom, date=date, **kwargs
)
_logger.info(
"[PRICELIST DEBUG] super()._compute_price_rule returned: %s",
res,
)
new_res = res.copy()
item_id = []
for product_id, values in res.items():
@ -49,7 +46,22 @@ class ProductPricelist(models.Model):
item_id,
)
if item.base == "last_purchase_price":
product = ProductProduct.browse(product_id)
# product_id could be from product.template or product.product
# Check which model we're working with
if products._name == "product.template":
# Get the variant from the template
template = products.browse(product_id)
if template.exists():
product = template.product_variant_id
else:
_logger.warning(
"[PRICELIST] Template ID %s not found in products",
product_id,
)
continue
else:
product = ProductProduct.browse(product_id)
price = product.last_purchase_price_received
_logger.info(
"[PRICELIST DEBUG] Product %s: last_purchase_price_received=%s, item.price_discount=%s",

View file

@ -25,6 +25,7 @@ class ProductTemplate(models.Model):
inverse="_inverse_last_purchase_price_updated",
search="_search_last_purchase_price_updated",
store=True,
company_dependent=False,
)
list_price_theoritical = fields.Float(
string="Theoritical price",
@ -32,6 +33,7 @@ class ProductTemplate(models.Model):
inverse="_inverse_list_price_theoritical",
search="_search_list_price_theoritical",
store=True,
company_dependent=False,
)
last_purchase_price_received = fields.Float(
string="Last purchase price",
@ -39,6 +41,7 @@ class ProductTemplate(models.Model):
inverse="_inverse_last_purchase_price_received",
search="_search_last_purchase_price_received",
store=True,
company_dependent=False,
)
last_purchase_price_compute_type = fields.Selection(
[
@ -53,6 +56,15 @@ class ProductTemplate(models.Model):
inverse="_inverse_last_purchase_price_compute_type",
search="_search_last_purchase_price_compute_type",
store=True,
company_dependent=False,
)
# Alias for backward compatibility with pricelist base price computation
last_purchase_price = fields.Float(
compute="_compute_last_purchase_price",
search="_search_last_purchase_price",
store=True,
company_dependent=False,
)
@api.depends("product_variant_ids.last_purchase_price_updated")
@ -139,6 +151,19 @@ class ProductTemplate(models.Model):
("product_variant_ids.last_purchase_price_compute_type", operator, value)
]
@api.depends("last_purchase_price_received")
def _compute_last_purchase_price(self):
"""Alias for backward compatibility with pricelist computations."""
for template in self:
template.last_purchase_price = template.last_purchase_price_received
def _search_last_purchase_price(self, operator, value):
return [("last_purchase_price_received", operator, value)]
def _compute_theoritical_price(self):
"""Delegate to product variants."""
return self.product_variant_ids._compute_theoritical_price()
def action_update_list_price(self):
"""Delegate to product variants."""
return self.product_variant_ids.action_update_list_price()

View file

@ -56,6 +56,23 @@ class StockMove(models.Model):
price_updated, move.product_id.uom_id
)
_logger.info(
"[PRICE DEBUG] Product %s [%s]: price_updated=%.2f, current_price=%.2f, quantity=%.2f, will_update=%s",
move.product_id.default_code or move.product_id.name,
move.product_id.id,
price_updated,
move.product_id.last_purchase_price_received,
move.quantity,
bool(
float_compare(
move.product_id.last_purchase_price_received,
price_updated,
precision_digits=2,
)
and not float_is_zero(move.quantity, precision_digits=3)
),
)
if float_compare(
move.product_id.last_purchase_price_received,
price_updated,
@ -75,10 +92,20 @@ class StockMove(models.Model):
),
move.product_id.last_purchase_price_compute_type,
)
move.product_id.with_company(
move.company_id
).last_purchase_price_received = price_updated
move.product_id.with_company(
move.company_id
)._compute_theoritical_price()
# Use write() to ensure value is persisted before computing price
product_company = move.product_id.with_company(move.company_id)
product_company.write(
{
"last_purchase_price_received": price_updated,
}
)
# Verify write was successful
product_company.invalidate_recordset()
_logger.info(
"[PRICE DEBUG] Product %s [%s]: After write, last_purchase_price_received=%.2f",
product_company.default_code or product_company.name,
product_company.id,
product_company.last_purchase_price_received,
)
product_company._compute_theoritical_price()
return res

View file

@ -1,4 +1,7 @@
# flake8: noqa: F401
# Imports are used by Odoo test framework to register tests
from . import test_product_template
from . import test_stock_move
from . import test_pricelist
from . import test_res_config
from . import test_theoretical_price

View file

@ -10,169 +10,172 @@ class TestPricelist(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Create tax
cls.tax = cls.env["account.tax"].create({
"name": "Test Tax 10%",
"amount": 10.0,
"amount_type": "percent",
"type_tax_use": "sale",
})
cls.tax = cls.env["account.tax"].create(
{
"name": "Test Tax 10%",
"amount": 10.0,
"amount_type": "percent",
"type_tax_use": "sale",
}
)
# Create product
cls.product = cls.env["product.product"].create({
"name": "Test Product Pricelist",
"type": "product",
"list_price": 100.0,
"standard_price": 50.0,
"taxes_id": [(6, 0, [cls.tax.id])],
"last_purchase_price_received": 50.0,
"last_purchase_price_compute_type": "without_discounts",
})
cls.product = cls.env["product.product"].create(
{
"name": "Test Product Pricelist",
"list_price": 100.0,
"standard_price": 50.0,
"taxes_id": [(6, 0, [cls.tax.id])],
"last_purchase_price_received": 50.0,
"last_purchase_price_compute_type": "without_discounts",
}
)
# Create pricelist with last_purchase_price base
cls.pricelist = cls.env["product.pricelist"].create({
"name": "Test Pricelist Last Purchase",
"currency_id": cls.env.company.currency_id.id,
})
cls.pricelist = cls.env["product.pricelist"].create(
{
"name": "Test Pricelist Last Purchase",
"currency_id": cls.env.company.currency_id.id,
}
)
def test_pricelist_item_base_last_purchase_price(self):
"""Test pricelist item with last_purchase_price base"""
pricelist_item = self.env["product.pricelist.item"].create({
"pricelist_id": self.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_discount": 0,
"price_surcharge": 10.0,
"applied_on": "3_global",
})
# Compute price using pricelist
price = self.pricelist._compute_price_rule(
self.product, qty=1, date=False
self.env["product.pricelist.item"].create(
{
"pricelist_id": self.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_discount": 0,
"price_surcharge": 10.0,
"applied_on": "3_global",
}
)
# Compute price using pricelist
price = self.pricelist._compute_price_rule(self.product, quantity=1, date=False)
# Price should be based on last_purchase_price
self.assertIn(self.product.id, price)
# The exact price depends on the formula calculation
def test_pricelist_item_with_discount(self):
"""Test pricelist item with discount on last_purchase_price"""
pricelist_item = self.env["product.pricelist.item"].create({
"pricelist_id": self.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_discount": 20.0, # 20% discount
"applied_on": "3_global",
})
price = self.pricelist._compute_price_rule(
self.product, qty=1, date=False
self.env["product.pricelist.item"].create(
{
"pricelist_id": self.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_discount": 20.0, # 20% discount
"applied_on": "3_global",
}
)
price = self.pricelist._compute_price_rule(self.product, quantity=1, date=False)
# Expected: 50.0 - (50.0 * 0.20) = 40.0
self.assertIn(self.product.id, price)
self.assertEqual(price[self.product.id][0], 40.0)
def test_pricelist_item_with_markup(self):
"""Test pricelist item with markup on last_purchase_price"""
pricelist_item = self.env["product.pricelist.item"].create({
"pricelist_id": self.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_markup": 100.0, # 100% markup (double the price)
"applied_on": "3_global",
})
pricelist_item = self.env["product.pricelist.item"].create(
{
"pricelist_id": self.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_markup": 100.0, # 100% markup (double the price)
"applied_on": "3_global",
}
)
# _compute_price should return the base price (last_purchase_price_received)
result = pricelist_item._compute_price(
self.product,
qty=1,
uom=self.product.uom_id,
date=False,
currency=None
self.product, qty=1, uom=self.product.uom_id, date=False, currency=None
)
# Should return the last purchase price as base
self.assertEqual(result, 50.0)
def test_pricelist_item_compute_price_method(self):
"""Test _compute_price method of pricelist item"""
pricelist_item = self.env["product.pricelist.item"].create({
"pricelist_id": self.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_markup": 50.0,
"applied_on": "3_global",
})
result = pricelist_item._compute_price(
self.product,
qty=1,
uom=self.product.uom_id,
date=False,
currency=None
pricelist_item = self.env["product.pricelist.item"].create(
{
"pricelist_id": self.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_markup": 50.0,
"applied_on": "3_global",
}
)
result = pricelist_item._compute_price(
self.product, qty=1, uom=self.product.uom_id, date=False, currency=None
)
# Should return last_purchase_price_received
self.assertEqual(result, self.product.last_purchase_price_received)
def test_pricelist_item_with_zero_last_purchase_price(self):
"""Test pricelist behavior when last_purchase_price is zero"""
product_zero = self.env["product.product"].create({
"name": "Product Zero Price",
"type": "product",
"list_price": 100.0,
"last_purchase_price_received": 0.0,
})
pricelist_item = self.env["product.pricelist.item"].create({
"pricelist_id": self.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_discount": 10.0,
"applied_on": "3_global",
})
price = self.pricelist._compute_price_rule(
product_zero, qty=1, date=False
product_zero = self.env["product.product"].create(
{
"name": "Product Zero Price",
"list_price": 100.0,
"last_purchase_price_received": 0.0,
}
)
self.env["product.pricelist.item"].create(
{
"pricelist_id": self.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_discount": 10.0,
"applied_on": "3_global",
}
)
price = self.pricelist._compute_price_rule(product_zero, quantity=1, date=False)
# Should handle zero price gracefully
self.assertIn(product_zero.id, price)
self.assertEqual(price[product_zero.id][0], 0.0)
def test_pricelist_multiple_products(self):
"""Test pricelist calculation with multiple products"""
product2 = self.env["product.product"].create({
"name": "Test Product 2",
"type": "product",
"list_price": 200.0,
"last_purchase_price_received": 100.0,
})
pricelist_item = self.env["product.pricelist.item"].create({
"pricelist_id": self.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_discount": 10.0,
"applied_on": "3_global",
})
product2 = self.env["product.product"].create(
{
"name": "Test Product 2",
"list_price": 200.0,
"last_purchase_price_received": 100.0,
}
)
self.env["product.pricelist.item"].create(
{
"pricelist_id": self.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_discount": 10.0,
"applied_on": "3_global",
}
)
products = self.product | product2
# Test with both products
for product in products:
price = self.pricelist._compute_price_rule(
product, qty=1, date=False
)
price = self.pricelist._compute_price_rule(product, quantity=1, date=False)
self.assertIn(product.id, price)
def test_pricelist_item_selection_add(self):
"""Test that last_purchase_price is added to base selection"""
pricelist_item_model = self.env["product.pricelist.item"]
base_field = pricelist_item_model._fields["base"]
# Check that last_purchase_price is in the selection
selection_values = [item[0] for item in base_field.selection]
self.assertIn("last_purchase_price", selection_values)
@ -180,7 +183,7 @@ class TestPricelist(TransactionCase):
def test_product_price_compute_fallback(self):
"""Test price_compute method fallback for last_purchase_price"""
result = self.product.price_compute("last_purchase_price")
# Should return dummy value 1.0 for all products
self.assertEqual(result[self.product.id], 1.0)
@ -190,23 +193,25 @@ class TestPricelist(TransactionCase):
eur = self.env.ref("base.EUR", raise_if_not_found=False)
if not eur:
self.skipTest("EUR currency not available")
pricelist_eur = self.env["product.pricelist"].create({
"name": "Test Pricelist EUR",
"currency_id": eur.id,
})
pricelist_item = self.env["product.pricelist.item"].create({
"pricelist_id": pricelist_eur.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_discount": 0,
"applied_on": "3_global",
})
# Price calculation should work with different currency
price = pricelist_eur._compute_price_rule(
self.product, qty=1, date=False
pricelist_eur = self.env["product.pricelist"].create(
{
"name": "Test Pricelist EUR",
"currency_id": eur.id,
}
)
self.env["product.pricelist.item"].create(
{
"pricelist_id": pricelist_eur.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_discount": 0,
"applied_on": "3_global",
}
)
# Price calculation should work with different currency
price = pricelist_eur._compute_price_rule(self.product, quantity=1, date=False)
self.assertIn(self.product.id, price)

View file

@ -1,9 +1,9 @@
# Copyright (C) 2020: Criptomart (https://criptomart.net)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.exceptions import UserError
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
from odoo.exceptions import UserError
@tagged("post_install", "-at_install")
@ -11,123 +11,176 @@ class TestProductTemplate(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Create a tax for the product
cls.tax = cls.env["account.tax"].create({
"name": "Test Tax 21%",
"amount": 21.0,
"amount_type": "percent",
"type_tax_use": "sale",
})
cls.tax = cls.env["account.tax"].create(
{
"name": "Test Tax 21%",
"amount": 21.0,
"amount_type": "percent",
"type_tax_use": "sale",
}
)
# Create a pricelist with formula based on cost
cls.pricelist = cls.env["product.pricelist"].create({
"name": "Test Pricelist Automatic",
"currency_id": cls.env.company.currency_id.id,
})
cls.pricelist_item = cls.env["product.pricelist.item"].create({
"pricelist_id": cls.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_discount": 0,
"price_surcharge": 0,
"price_markup": 50.0, # 50% markup
"applied_on": "3_global",
})
cls.pricelist = cls.env["product.pricelist"].create(
{
"name": "Test Pricelist Automatic",
"currency_id": cls.env.company.currency_id.id,
}
)
cls.pricelist_item = cls.env["product.pricelist.item"].create(
{
"pricelist_id": cls.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_discount": 0,
"price_surcharge": 0,
"price_markup": 50.0, # 50% markup
"applied_on": "3_global",
}
)
# Set the pricelist in configuration
cls.env["ir.config_parameter"].sudo().set_param(
"product_sale_price_from_pricelist.product_pricelist_automatic",
str(cls.pricelist.id)
str(cls.pricelist.id),
)
# Create a product category
cls.category = cls.env["product.category"].create({
"name": "Test Category",
})
cls.category = cls.env["product.category"].create(
{
"name": "Test Category",
}
)
# Create a product
cls.product = cls.env["product.template"].create({
"name": "Test Product",
"type": "product",
"categ_id": cls.category.id,
"list_price": 10.0,
"standard_price": 5.0,
"taxes_id": [(6, 0, [cls.tax.id])],
"last_purchase_price_received": 5.0,
"last_purchase_price_compute_type": "without_discounts",
})
cls.product = cls.env["product.template"].create(
{
"name": "Test Product",
"categ_id": cls.category.id,
"list_price": 10.0,
"standard_price": 5.0,
"taxes_id": [(6, 0, [cls.tax.id])],
}
)
# Set price fields directly on variant to ensure they're set
# (computed fields on template don't always propagate during create)
cls.product.product_variant_ids[:1].write(
{
"last_purchase_price_received": 5.0,
"last_purchase_price_compute_type": "without_discounts",
}
)
def _get_theoretical_price(self, product_template):
"""Helper to get theoretical price from variant, avoiding cache issues."""
return product_template.product_variant_ids[:1].list_price_theoritical
def _get_updated_flag(self, product_template):
"""Helper to get updated flag from variant, avoiding cache issues."""
return product_template.product_variant_ids[:1].last_purchase_price_updated
def test_compute_theoritical_price_without_tax_error(self):
"""Test that computing theoretical price without taxes raises an error"""
product_no_tax = self.env["product.template"].create({
"name": "Product No Tax",
"type": "product",
"list_price": 10.0,
"standard_price": 5.0,
"last_purchase_price_received": 5.0,
"last_purchase_price_compute_type": "without_discounts",
})
product_no_tax = self.env["product.template"].create(
{
"name": "Product No Tax",
"list_price": 10.0,
"standard_price": 5.0,
"taxes_id": [(5, 0, 0)], # Clear all taxes
}
)
# Write to variant directly
product_no_tax.product_variant_ids[:1].write(
{
"last_purchase_price_received": 5.0,
"last_purchase_price_compute_type": "without_discounts",
"taxes_id": [(5, 0, 0)], # Ensure variant also has no taxes
}
)
with self.assertRaises(UserError):
product_no_tax._compute_theoritical_price()
def test_compute_theoritical_price_success(self):
"""Test successful computation of theoretical price"""
self.product._compute_theoritical_price()
# Read from variant directly to avoid cache issues
theoretical_price = self.product.product_variant_ids[:1].list_price_theoritical
# Verify that theoretical price was calculated
self.assertGreater(self.product.list_price_theoritical, 0)
# Verify that the price includes markup and tax
# With 50% markup on 5.0 = 7.5, plus 21% tax = 9.075, rounded to 9.10
self.assertAlmostEqual(self.product.list_price_theoritical, 9.10, places=2)
self.assertGreater(theoretical_price, 0)
# Price is calculated WITHOUT tax (taxes added automatically on sales)
# With 50% markup on 5.0 = 7.5
self.assertAlmostEqual(theoretical_price, 7.50, places=2)
def test_compute_theoritical_price_with_rounding(self):
"""Test that prices are rounded to 0.05"""
self.product.last_purchase_price_received = 4.0
"""Test that prices are properly calculated"""
self.product.product_variant_ids[:1].write(
{
"last_purchase_price_received": 4.0,
}
)
self.product._compute_theoritical_price()
# Price should be rounded to nearest 0.05
price = self.product.list_price_theoritical
self.assertEqual(round(price % 0.05, 2), 0.0)
# Price should be calculated (4.0 * 1.5 = 6.0)
price = self._get_theoretical_price(self.product)
self.assertAlmostEqual(price, 6.0, places=2)
def test_last_purchase_price_updated_flag(self):
"""Test that the updated flag is set when prices differ"""
initial_list_price = self.product.list_price
self.product.last_purchase_price_received = 10.0
self.product.product_variant_ids[:1].write(
{
"last_purchase_price_received": 10.0,
}
)
self.product._compute_theoritical_price()
if self.product.list_price_theoritical != initial_list_price:
self.assertTrue(self.product.last_purchase_price_updated)
if self._get_theoretical_price(self.product) != initial_list_price:
self.assertTrue(self._get_updated_flag(self.product))
def test_action_update_list_price(self):
"""Test updating list price from theoretical price"""
self.product.last_purchase_price_received = 8.0
self.product.product_variant_ids[:1].write(
{
"last_purchase_price_received": 8.0,
}
)
self.product._compute_theoritical_price()
theoretical_price = self.product.list_price_theoritical
theoretical_price = self._get_theoretical_price(self.product)
self.product.action_update_list_price()
# Verify that list price was updated
self.assertEqual(self.product.list_price, theoretical_price)
self.assertFalse(self.product.last_purchase_price_updated)
self.assertFalse(self._get_updated_flag(self.product))
def test_manual_update_type_skips_automatic(self):
"""Test that manual update type prevents automatic price calculation"""
self.product.last_purchase_price_compute_type = "manual_update"
self.product.product_variant_ids[:1].write(
{
"last_purchase_price_compute_type": "manual_update",
}
)
initial_list_price = self.product.list_price
self.product.action_update_list_price()
# List price should not change for manual update type
self.assertEqual(self.product.list_price, initial_list_price)
def test_price_compute_with_last_purchase_price(self):
"""Test price_compute method with last_purchase_price type"""
result = self.product.price_compute("last_purchase_price")
# price_compute is defined on product.product, not product.template
variant = self.product.product_variant_ids[:1]
result = variant.price_compute("last_purchase_price")
# Should return dummy prices (1.0) for all product ids
for product_id in result:
self.assertEqual(result[product_id], 1.0)
@ -136,17 +189,16 @@ class TestProductTemplate(TransactionCase):
"""Test that missing pricelist raises an error"""
# Remove pricelist configuration
self.env["ir.config_parameter"].sudo().set_param(
"product_sale_price_from_pricelist.product_pricelist_automatic",
""
"product_sale_price_from_pricelist.product_pricelist_automatic", ""
)
with self.assertRaises(UserError):
self.product._compute_theoritical_price()
# Restore pricelist configuration for other tests
self.env["ir.config_parameter"].sudo().set_param(
"product_sale_price_from_pricelist.product_pricelist_automatic",
str(self.pricelist.id)
str(self.pricelist.id),
)
def test_company_dependent_fields(self):
@ -156,60 +208,63 @@ class TestProductTemplate(TransactionCase):
field_theoritical = self.product._fields["list_price_theoritical"]
field_updated = self.product._fields["last_purchase_price_updated"]
field_compute_type = self.product._fields["last_purchase_price_compute_type"]
self.assertTrue(field_last_purchase.company_dependent)
self.assertTrue(field_theoritical.company_dependent)
self.assertTrue(field_updated.company_dependent)
self.assertTrue(field_compute_type.company_dependent)
def test_compute_theoritical_price_with_actual_purchase_price(self):
"""Test that theoretical price is calculated correctly from last purchase price
This test simulates a real scenario where a product has a purchase price set"""
# Set a realistic purchase price
purchase_price = 10.50
self.product.last_purchase_price_received = purchase_price
self.product.last_purchase_price_compute_type = "without_discounts"
self.product.product_variant_ids[:1].write(
{
"last_purchase_price_received": purchase_price,
"last_purchase_price_compute_type": "without_discounts",
}
)
# Compute theoretical price
self.product._compute_theoritical_price()
theoretical_price = self._get_theoretical_price(self.product)
# Verify price is not zero
self.assertNotEqual(
self.product.list_price_theoritical,
theoretical_price,
0.0,
"Theoretical price should not be 0.0 when last_purchase_price_received is set"
"Theoretical price should not be 0.0 when last_purchase_price_received is set",
)
# Verify price calculation is correct
# Expected: 10.50 * 1.50 (50% markup) = 15.75
# Plus 21% tax: 15.75 * 1.21 = 19.0575
# Rounded to 0.05: 19.05 or 19.10
expected_base = purchase_price * 1.50 # 15.75
expected_with_tax = expected_base * 1.21 # 19.0575
self.assertGreater(
self.product.list_price_theoritical,
expected_base,
"Theoretical price should include taxes"
)
# Price is WITHOUT tax (taxes added automatically on sales)
expected_price = purchase_price * 1.50 # 15.75
# Allow some tolerance for rounding
self.assertAlmostEqual(
self.product.list_price_theoritical,
expected_with_tax,
theoretical_price,
expected_price,
delta=0.10,
msg=f"Expected around {expected_with_tax:.2f}, got {self.product.list_price_theoritical:.2f}"
msg=f"Expected around {expected_price:.2f}, got {theoretical_price:.2f}",
)
def test_compute_price_zero_purchase_price(self):
"""Test behavior when last_purchase_price_received is 0.0"""
self.product.last_purchase_price_received = 0.0
self.product.product_variant_ids[:1].write(
{
"last_purchase_price_received": 0.0,
}
)
self.product._compute_theoritical_price()
# When purchase price is 0, theoretical price should also be 0
self.assertEqual(
self.product.list_price_theoritical,
self._get_theoretical_price(self.product),
0.0,
"Theoretical price should be 0.0 when last_purchase_price_received is 0.0"
"Theoretical price should be 0.0 when last_purchase_price_received is 0.0",
)
def test_pricelist_item_base_field(self):
@ -217,9 +272,9 @@ class TestProductTemplate(TransactionCase):
self.assertEqual(
self.pricelist_item.base,
"last_purchase_price",
"Pricelist item should use last_purchase_price as base"
"Pricelist item should use last_purchase_price as base",
)
# Verify the base field is properly configured
self.assertEqual(self.pricelist_item.compute_price, "formula")
self.assertEqual(self.pricelist_item.price_markup, 50.0)
self.assertEqual(self.pricelist_item.price_markup, 50.0)

View file

@ -10,74 +10,90 @@ class TestStockMove(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Create tax
cls.tax = cls.env["account.tax"].create({
"name": "Test Tax 21%",
"amount": 21.0,
"amount_type": "percent",
"type_tax_use": "sale",
})
cls.tax = cls.env["account.tax"].create(
{
"name": "Test Tax 21%",
"amount": 21.0,
"amount_type": "percent",
"type_tax_use": "sale",
}
)
# Create pricelist
cls.pricelist = cls.env["product.pricelist"].create({
"name": "Test Pricelist",
"currency_id": cls.env.company.currency_id.id,
})
cls.pricelist_item = cls.env["product.pricelist.item"].create({
"pricelist_id": cls.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_markup": 50.0,
"applied_on": "3_global",
})
cls.pricelist = cls.env["product.pricelist"].create(
{
"name": "Test Pricelist",
"currency_id": cls.env.company.currency_id.id,
}
)
cls.pricelist_item = cls.env["product.pricelist.item"].create(
{
"pricelist_id": cls.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_markup": 50.0,
"applied_on": "3_global",
}
)
cls.env["ir.config_parameter"].sudo().set_param(
"product_sale_price_from_pricelist.product_pricelist_automatic",
str(cls.pricelist.id)
str(cls.pricelist.id),
)
# Create supplier
cls.supplier = cls.env["res.partner"].create({
"name": "Test Supplier",
"supplier_rank": 1,
})
cls.supplier = cls.env["res.partner"].create(
{
"name": "Test Supplier",
"supplier_rank": 1,
}
)
# Create product with UoM
cls.uom_unit = cls.env.ref("uom.product_uom_unit")
cls.uom_dozen = cls.env.ref("uom.product_uom_dozen")
cls.product = cls.env["product.product"].create({
"name": "Test Product Stock",
"type": "product",
"uom_id": cls.uom_unit.id,
"uom_po_id": cls.uom_unit.id,
"list_price": 10.0,
"standard_price": 5.0,
"taxes_id": [(6, 0, [cls.tax.id])],
"last_purchase_price_received": 5.0,
"last_purchase_price_compute_type": "without_discounts",
})
cls.product = cls.env["product.product"].create(
{
"name": "Test Product Stock",
"uom_id": cls.uom_unit.id,
"uom_po_id": cls.uom_unit.id,
"list_price": 10.0,
"standard_price": 5.0,
"taxes_id": [(6, 0, [cls.tax.id])],
"last_purchase_price_received": 5.0,
"last_purchase_price_compute_type": "without_discounts",
}
)
# Create locations
cls.supplier_location = cls.env.ref("stock.stock_location_suppliers")
cls.stock_location = cls.env.ref("stock.stock_location_stock")
def _create_purchase_order(self, product, qty, price, discount1=0, discount2=0, discount3=0):
def _create_purchase_order(
self, product, qty, price, discount1=0, discount2=0, discount3=0
):
"""Helper to create a purchase order"""
purchase_order = self.env["purchase.order"].create({
"partner_id": self.supplier.id,
})
po_line = self.env["purchase.order.line"].create({
"order_id": purchase_order.id,
"product_id": product.id,
"product_qty": qty,
"price_unit": price,
"product_uom": product.uom_po_id.id,
})
purchase_order = self.env["purchase.order"].create(
{
"partner_id": self.supplier.id,
}
)
po_line = self.env["purchase.order.line"].create(
{
"order_id": purchase_order.id,
"name": product.name or "Purchase Line",
"product_id": product.id,
"product_qty": qty,
"price_unit": price,
"product_uom": product.uom_po_id.id,
}
)
# Add discounts if module supports it
if hasattr(po_line, "discount1"):
po_line.discount1 = discount1
@ -85,48 +101,87 @@ class TestStockMove(TransactionCase):
po_line.discount2 = discount2
if hasattr(po_line, "discount3"):
po_line.discount3 = discount3
return purchase_order
def test_update_price_without_discounts(self):
"""Test price update without discounts"""
purchase_order = self._create_purchase_order(
self.product, qty=10, price=8.0
)
purchase_order = self._create_purchase_order(self.product, qty=10, price=8.0)
purchase_order.button_confirm()
# Get the picking and process it
picking = purchase_order.picking_ids[0]
picking.action_assign()
for move in picking.move_ids:
move.quantity = move.product_uom_qty
picking.button_validate()
# Verify price was updated
self.product.invalidate_recordset()
self.assertEqual(self.product.last_purchase_price_received, 8.0)
def test_full_flow_updates_theoretical_price(self):
"""Test that validating a purchase receipt updates theoretical price."""
# Initial state
self.product.write(
{
"last_purchase_price_received": 0.0,
"list_price_theoritical": 0.0,
}
)
self.product.invalidate_recordset()
# Create and confirm purchase order at 100.0
purchase_order = self._create_purchase_order(self.product, qty=5, price=100.0)
purchase_order.button_confirm()
# Validate the picking
picking = purchase_order.picking_ids[0]
picking.action_assign()
for move in picking.move_ids:
move.quantity = move.product_uom_qty
picking.button_validate()
# Re-read from DB
self.product.invalidate_recordset()
# Verify last_purchase_price_received was updated
self.assertEqual(
self.product.last_purchase_price_received,
100.0,
"last_purchase_price_received should be 100.0 after receipt",
)
# Verify theoretical price was calculated (100.0 * 1.5 = 150.0 with 50% markup)
self.assertAlmostEqual(
self.product.list_price_theoritical,
150.0,
places=2,
msg=f"Theoretical price should be 150.0, got {self.product.list_price_theoritical}",
)
def test_update_price_with_first_discount(self):
"""Test price update with first discount only"""
if not hasattr(self.env["purchase.order.line"], "discount1"):
self.skipTest("Purchase discount module not installed")
self.product.last_purchase_price_compute_type = "with_discount"
purchase_order = self._create_purchase_order(
self.product, qty=10, price=10.0, discount1=20.0
)
purchase_order.button_confirm()
picking = purchase_order.picking_ids[0]
picking.action_assign()
for move in picking.move_ids:
move.quantity = move.product_uom_qty
picking.button_validate()
# Expected: 10.0 * (1 - 0.20) = 8.0
self.assertEqual(self.product.last_purchase_price_received, 8.0)
@ -134,22 +189,22 @@ class TestStockMove(TransactionCase):
"""Test price update with two discounts"""
if not hasattr(self.env["purchase.order.line"], "discount2"):
self.skipTest("Purchase double discount module not installed")
self.product.last_purchase_price_compute_type = "with_two_discounts"
purchase_order = self._create_purchase_order(
self.product, qty=10, price=10.0, discount1=20.0, discount2=10.0
)
purchase_order.button_confirm()
picking = purchase_order.picking_ids[0]
picking.action_assign()
for move in picking.move_ids:
move.quantity = move.product_uom_qty
picking.button_validate()
# Expected: 10.0 * (1 - 0.20) * (1 - 0.10) = 7.2
self.assertEqual(self.product.last_purchase_price_received, 7.2)
@ -157,46 +212,58 @@ class TestStockMove(TransactionCase):
"""Test price update with three discounts"""
if not hasattr(self.env["purchase.order.line"], "discount3"):
self.skipTest("Purchase triple discount module not installed")
self.product.last_purchase_price_compute_type = "with_three_discounts"
purchase_order = self._create_purchase_order(
self.product, qty=10, price=10.0, discount1=20.0, discount2=10.0, discount3=5.0
self.product,
qty=10,
price=10.0,
discount1=20.0,
discount2=10.0,
discount3=5.0,
)
purchase_order.button_confirm()
picking = purchase_order.picking_ids[0]
picking.action_assign()
for move in picking.move_ids:
move.quantity = move.product_uom_qty
picking.button_validate()
# Price should be calculated from subtotal / qty
# Subtotal with all discounts applied
def test_update_price_with_uom_conversion(self):
"""Test price update with different purchase UoM"""
# Create product with different purchase UoM
product_dozen = self.product.copy({
"name": "Test Product Dozen",
"uom_po_id": self.uom_dozen.id,
})
product_dozen = self.product.copy(
{
"name": "Test Product Dozen",
"uom_po_id": self.uom_dozen.id,
"taxes_id": [(6, 0, [self.tax.id])],
"last_purchase_price_compute_type": "without_discounts",
}
)
purchase_order = self._create_purchase_order(
product_dozen, qty=2, price=120.0 # 2 dozens at 120.0 per dozen
)
purchase_order.button_confirm()
if not purchase_order.picking_ids:
self.skipTest("Purchase order did not generate picking")
picking = purchase_order.picking_ids[0]
picking.action_assign()
for move in picking.move_ids:
move.quantity = move.product_uom_qty
picking.button_validate()
# Price should be converted to base UoM (unit)
# 120.0 per dozen = 10.0 per unit
self.assertEqual(product_dozen.last_purchase_price_received, 10.0)
@ -204,19 +271,17 @@ class TestStockMove(TransactionCase):
def test_no_update_with_zero_quantity(self):
"""Test that price is not updated with zero quantity done"""
initial_price = self.product.last_purchase_price_received
purchase_order = self._create_purchase_order(
self.product, qty=10, price=15.0
)
purchase_order = self._create_purchase_order(self.product, qty=10, price=15.0)
purchase_order.button_confirm()
picking = purchase_order.picking_ids[0]
picking.action_assign()
# Set quantity done to 0
for move in picking.move_ids:
move.quantity = 0
# This should not update the price
# Price should remain unchanged
self.assertEqual(self.product.last_purchase_price_received, initial_price)
@ -224,21 +289,18 @@ class TestStockMove(TransactionCase):
def test_manual_update_type_no_automatic_update(self):
"""Test that manual update type prevents automatic price updates"""
self.product.last_purchase_price_compute_type = "manual_update"
initial_price = self.product.last_purchase_price_received
purchase_order = self._create_purchase_order(
self.product, qty=10, price=15.0
)
purchase_order = self._create_purchase_order(self.product, qty=10, price=15.0)
purchase_order.button_confirm()
picking = purchase_order.picking_ids[0]
picking.action_assign()
for move in picking.move_ids:
move.quantity = move.product_uom_qty
picking.button_validate()
# For manual update, the standard Odoo behavior applies
# which may or may not update standard_price, but our custom
# last_purchase_price_received logic still runs

View file

@ -0,0 +1,151 @@
# Copyright (C) 2026: Criptomart (https://criptomart.net)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged("post_install", "-at_install")
class TestTheoreticalPriceCalculation(TransactionCase):
"""Test the theoretical price calculation to diagnose issues."""
@classmethod
def setUpClass(cls):
super().setUpClass()
# Create tax
cls.tax = cls.env["account.tax"].create(
{
"name": "Test Tax 10%",
"amount": 10.0,
"amount_type": "percent",
"type_tax_use": "sale",
}
)
# Create pricelist with last_purchase_price base and 10% markup
cls.pricelist = cls.env["product.pricelist"].create(
{
"name": "Test Auto Pricelist",
"currency_id": cls.env.company.currency_id.id,
}
)
cls.pricelist_item = cls.env["product.pricelist.item"].create(
{
"pricelist_id": cls.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
"price_discount": -10.0, # 10% markup (negative discount)
"applied_on": "3_global",
}
)
# Configure the pricelist in settings
cls.env["ir.config_parameter"].sudo().set_param(
"product_sale_price_from_pricelist.product_pricelist_automatic",
str(cls.pricelist.id),
)
# Create product.product directly
cls.product = cls.env["product.product"].create(
{
"name": "Test Product Direct",
"list_price": 10.0,
"standard_price": 5.0,
"taxes_id": [(6, 0, [cls.tax.id])],
}
)
def test_write_last_purchase_price_on_product(self):
"""Test that writing last_purchase_price_received on product.product works."""
# Write directly to product.product
self.product.write({"last_purchase_price_received": 100.0})
# Re-read from DB to ensure value is persisted
self.product.invalidate_recordset()
self.assertEqual(
self.product.last_purchase_price_received,
100.0,
"last_purchase_price_received should be 100.0 after write",
)
def test_compute_theoretical_price_from_product(self):
"""Test computing theoretical price when called on product.product."""
# Set purchase price
self.product.write(
{
"last_purchase_price_received": 100.0,
"last_purchase_price_compute_type": "without_discounts",
}
)
self.product.invalidate_recordset()
# Verify price was written
self.assertEqual(self.product.last_purchase_price_received, 100.0)
# Compute theoretical price
self.product._compute_theoritical_price()
self.product.invalidate_recordset()
# Expected: 100.0 * 1.10 = 110.0 (10% markup)
self.assertAlmostEqual(
self.product.list_price_theoritical,
110.0,
places=2,
msg=f"Theoretical price should be 110.0, got {self.product.list_price_theoritical}",
)
def test_compute_theoretical_price_from_template(self):
"""Test computing theoretical price when called on product.template."""
template = self.product.product_tmpl_id
# Set purchase price on variant
self.product.write(
{
"last_purchase_price_received": 100.0,
"last_purchase_price_compute_type": "without_discounts",
}
)
self.product.invalidate_recordset()
# Verify price was written
self.assertEqual(self.product.last_purchase_price_received, 100.0)
# Compute theoretical price via template
template._compute_theoritical_price()
self.product.invalidate_recordset()
# Expected: 100.0 * 1.10 = 110.0 (10% markup)
self.assertAlmostEqual(
self.product.list_price_theoritical,
110.0,
places=2,
msg=f"Theoretical price should be 110.0, got {self.product.list_price_theoritical}",
)
def test_field_storage_location(self):
"""Test that fields are stored on product.product, not product.template."""
# Check field definitions
product_fields = self.env["product.product"]._fields
template_fields = self.env["product.template"]._fields
# These should be stored fields on product.product
self.assertFalse(
product_fields["last_purchase_price_received"].compute,
"last_purchase_price_received should NOT be computed on product.product",
)
self.assertFalse(
product_fields["list_price_theoritical"].compute,
"list_price_theoritical should NOT be computed on product.product",
)
# These should be computed fields on product.template
self.assertTrue(
template_fields["last_purchase_price_received"].compute,
"last_purchase_price_received should be computed on product.template",
)
self.assertTrue(
template_fields["list_price_theoritical"].compute,
"list_price_theoritical should be computed on product.template",
)