Compare commits

..

No commits in common. "6d944847109c5ad2dd1cf80f9b498b49777f04e2" and "c308d538a3d9346bfa00618d0951fb46d3d36e5e" have entirely different histories.

34 changed files with 365 additions and 1960 deletions

View file

@ -1,15 +0,0 @@
# 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/

View file

@ -1,30 +0,0 @@
# 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

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

View file

@ -1,114 +0,0 @@
==============
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

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

View file

@ -1,18 +0,0 @@
# 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",
],
}

View file

@ -1,72 +0,0 @@
# 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'"

View file

@ -1,73 +0,0 @@
# 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

@ -1,66 +0,0 @@
# 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

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

View file

@ -1,60 +0,0 @@
# 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

@ -1,95 +0,0 @@
# 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

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

View file

@ -1,4 +0,0 @@
- 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

@ -1,4 +0,0 @@
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

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

View file

@ -1,5 +0,0 @@
- 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.

Before

Width:  |  Height:  |  Size: 9.2 KiB

View file

@ -1,456 +0,0 @@
<!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.

Before

Width:  |  Height:  |  Size: 45 KiB

View file

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

View file

@ -1,86 +0,0 @@
# 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

@ -1,35 +0,0 @@
<?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

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

View file

@ -1,72 +0,0 @@
# 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 import logging
from odoo import models from odoo import api, models
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@ -17,14 +17,17 @@ class ProductPricelist(models.Model):
ProductProduct = self.env["product.product"] ProductProduct = self.env["product.product"]
_logger.info( _logger.info(
"[PRICELIST DEBUG] _compute_price_rule called with products=%s (model=%s), quantity=%s", "[PRICELIST DEBUG] _compute_price_rule called with products=%s, quantity=%s",
products.ids, products.ids,
products._name,
quantity, quantity,
) )
res = super()._compute_price_rule( res = super()._compute_price_rule(
products, quantity, uom=uom, date=date, **kwargs products,
quantity,
uom=uom,
date=date,
**kwargs
) )
_logger.info( _logger.info(
@ -46,22 +49,7 @@ class ProductPricelist(models.Model):
item_id, item_id,
) )
if item.base == "last_purchase_price": if item.base == "last_purchase_price":
# product_id could be from product.template or product.product product = ProductProduct.browse(product_id)
# 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 price = product.last_purchase_price_received
_logger.info( _logger.info(
"[PRICELIST DEBUG] Product %s: last_purchase_price_received=%s, item.price_discount=%s", "[PRICELIST DEBUG] Product %s: last_purchase_price_received=%s, item.price_discount=%s",

View file

@ -25,7 +25,6 @@ class ProductTemplate(models.Model):
inverse="_inverse_last_purchase_price_updated", inverse="_inverse_last_purchase_price_updated",
search="_search_last_purchase_price_updated", search="_search_last_purchase_price_updated",
store=True, store=True,
company_dependent=False,
) )
list_price_theoritical = fields.Float( list_price_theoritical = fields.Float(
string="Theoritical price", string="Theoritical price",
@ -33,7 +32,6 @@ class ProductTemplate(models.Model):
inverse="_inverse_list_price_theoritical", inverse="_inverse_list_price_theoritical",
search="_search_list_price_theoritical", search="_search_list_price_theoritical",
store=True, store=True,
company_dependent=False,
) )
last_purchase_price_received = fields.Float( last_purchase_price_received = fields.Float(
string="Last purchase price", string="Last purchase price",
@ -41,7 +39,6 @@ class ProductTemplate(models.Model):
inverse="_inverse_last_purchase_price_received", inverse="_inverse_last_purchase_price_received",
search="_search_last_purchase_price_received", search="_search_last_purchase_price_received",
store=True, store=True,
company_dependent=False,
) )
last_purchase_price_compute_type = fields.Selection( last_purchase_price_compute_type = fields.Selection(
[ [
@ -56,15 +53,6 @@ class ProductTemplate(models.Model):
inverse="_inverse_last_purchase_price_compute_type", inverse="_inverse_last_purchase_price_compute_type",
search="_search_last_purchase_price_compute_type", search="_search_last_purchase_price_compute_type",
store=True, 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") @api.depends("product_variant_ids.last_purchase_price_updated")
@ -151,19 +139,6 @@ class ProductTemplate(models.Model):
("product_variant_ids.last_purchase_price_compute_type", operator, value) ("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): def action_update_list_price(self):
"""Delegate to product variants.""" """Delegate to product variants."""
return self.product_variant_ids.action_update_list_price() return self.product_variant_ids.action_update_list_price()

View file

@ -56,23 +56,6 @@ class StockMove(models.Model):
price_updated, move.product_id.uom_id 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( if float_compare(
move.product_id.last_purchase_price_received, move.product_id.last_purchase_price_received,
price_updated, price_updated,
@ -92,20 +75,10 @@ class StockMove(models.Model):
), ),
move.product_id.last_purchase_price_compute_type, move.product_id.last_purchase_price_compute_type,
) )
# Use write() to ensure value is persisted before computing price move.product_id.with_company(
product_company = move.product_id.with_company(move.company_id) move.company_id
product_company.write( ).last_purchase_price_received = price_updated
{ move.product_id.with_company(
"last_purchase_price_received": price_updated, move.company_id
} )._compute_theoritical_price()
)
# 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 return res

View file

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

View file

@ -12,50 +12,45 @@ class TestPricelist(TransactionCase):
super().setUpClass() super().setUpClass()
# Create tax # Create tax
cls.tax = cls.env["account.tax"].create( cls.tax = cls.env["account.tax"].create({
{ "name": "Test Tax 10%",
"name": "Test Tax 10%", "amount": 10.0,
"amount": 10.0, "amount_type": "percent",
"amount_type": "percent", "type_tax_use": "sale",
"type_tax_use": "sale", })
}
)
# Create product # Create product
cls.product = cls.env["product.product"].create( cls.product = cls.env["product.product"].create({
{ "name": "Test Product Pricelist",
"name": "Test Product Pricelist", "type": "product",
"list_price": 100.0, "list_price": 100.0,
"standard_price": 50.0, "standard_price": 50.0,
"taxes_id": [(6, 0, [cls.tax.id])], "taxes_id": [(6, 0, [cls.tax.id])],
"last_purchase_price_received": 50.0, "last_purchase_price_received": 50.0,
"last_purchase_price_compute_type": "without_discounts", "last_purchase_price_compute_type": "without_discounts",
} })
)
# Create pricelist with last_purchase_price base # Create pricelist with last_purchase_price base
cls.pricelist = cls.env["product.pricelist"].create( cls.pricelist = cls.env["product.pricelist"].create({
{ "name": "Test Pricelist Last Purchase",
"name": "Test Pricelist Last Purchase", "currency_id": cls.env.company.currency_id.id,
"currency_id": cls.env.company.currency_id.id, })
}
)
def test_pricelist_item_base_last_purchase_price(self): def test_pricelist_item_base_last_purchase_price(self):
"""Test pricelist item with last_purchase_price base""" """Test pricelist item with last_purchase_price base"""
self.env["product.pricelist.item"].create( pricelist_item = self.env["product.pricelist.item"].create({
{ "pricelist_id": self.pricelist.id,
"pricelist_id": self.pricelist.id, "compute_price": "formula",
"compute_price": "formula", "base": "last_purchase_price",
"base": "last_purchase_price", "price_discount": 0,
"price_discount": 0, "price_surcharge": 10.0,
"price_surcharge": 10.0, "applied_on": "3_global",
"applied_on": "3_global", })
}
)
# Compute price using pricelist # Compute price using pricelist
price = self.pricelist._compute_price_rule(self.product, quantity=1, date=False) price = self.pricelist._compute_price_rule(
self.product, qty=1, date=False
)
# Price should be based on last_purchase_price # Price should be based on last_purchase_price
self.assertIn(self.product.id, price) self.assertIn(self.product.id, price)
@ -63,17 +58,17 @@ class TestPricelist(TransactionCase):
def test_pricelist_item_with_discount(self): def test_pricelist_item_with_discount(self):
"""Test pricelist item with discount on last_purchase_price""" """Test pricelist item with discount on last_purchase_price"""
self.env["product.pricelist.item"].create( pricelist_item = self.env["product.pricelist.item"].create({
{ "pricelist_id": self.pricelist.id,
"pricelist_id": self.pricelist.id, "compute_price": "formula",
"compute_price": "formula", "base": "last_purchase_price",
"base": "last_purchase_price", "price_discount": 20.0, # 20% discount
"price_discount": 20.0, # 20% discount "applied_on": "3_global",
"applied_on": "3_global", })
}
)
price = self.pricelist._compute_price_rule(self.product, quantity=1, date=False) price = self.pricelist._compute_price_rule(
self.product, qty=1, date=False
)
# Expected: 50.0 - (50.0 * 0.20) = 40.0 # Expected: 50.0 - (50.0 * 0.20) = 40.0
self.assertIn(self.product.id, price) self.assertIn(self.product.id, price)
@ -81,19 +76,21 @@ class TestPricelist(TransactionCase):
def test_pricelist_item_with_markup(self): def test_pricelist_item_with_markup(self):
"""Test pricelist item with markup on last_purchase_price""" """Test pricelist item with markup on last_purchase_price"""
pricelist_item = self.env["product.pricelist.item"].create( pricelist_item = self.env["product.pricelist.item"].create({
{ "pricelist_id": self.pricelist.id,
"pricelist_id": self.pricelist.id, "compute_price": "formula",
"compute_price": "formula", "base": "last_purchase_price",
"base": "last_purchase_price", "price_markup": 100.0, # 100% markup (double the price)
"price_markup": 100.0, # 100% markup (double the price) "applied_on": "3_global",
"applied_on": "3_global", })
}
)
# _compute_price should return the base price (last_purchase_price_received) # _compute_price should return the base price (last_purchase_price_received)
result = pricelist_item._compute_price( 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 # Should return the last purchase price as base
@ -101,18 +98,20 @@ class TestPricelist(TransactionCase):
def test_pricelist_item_compute_price_method(self): def test_pricelist_item_compute_price_method(self):
"""Test _compute_price method of pricelist item""" """Test _compute_price method of pricelist item"""
pricelist_item = self.env["product.pricelist.item"].create( pricelist_item = self.env["product.pricelist.item"].create({
{ "pricelist_id": self.pricelist.id,
"pricelist_id": self.pricelist.id, "compute_price": "formula",
"compute_price": "formula", "base": "last_purchase_price",
"base": "last_purchase_price", "price_markup": 50.0,
"price_markup": 50.0, "applied_on": "3_global",
"applied_on": "3_global", })
}
)
result = pricelist_item._compute_price( 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 last_purchase_price_received # Should return last_purchase_price_received
@ -120,25 +119,24 @@ class TestPricelist(TransactionCase):
def test_pricelist_item_with_zero_last_purchase_price(self): def test_pricelist_item_with_zero_last_purchase_price(self):
"""Test pricelist behavior when last_purchase_price is zero""" """Test pricelist behavior when last_purchase_price is zero"""
product_zero = self.env["product.product"].create( product_zero = self.env["product.product"].create({
{ "name": "Product Zero Price",
"name": "Product Zero Price", "type": "product",
"list_price": 100.0, "list_price": 100.0,
"last_purchase_price_received": 0.0, "last_purchase_price_received": 0.0,
} })
)
self.env["product.pricelist.item"].create( pricelist_item = self.env["product.pricelist.item"].create({
{ "pricelist_id": self.pricelist.id,
"pricelist_id": self.pricelist.id, "compute_price": "formula",
"compute_price": "formula", "base": "last_purchase_price",
"base": "last_purchase_price", "price_discount": 10.0,
"price_discount": 10.0, "applied_on": "3_global",
"applied_on": "3_global", })
}
)
price = self.pricelist._compute_price_rule(product_zero, quantity=1, date=False) price = self.pricelist._compute_price_rule(
product_zero, qty=1, date=False
)
# Should handle zero price gracefully # Should handle zero price gracefully
self.assertIn(product_zero.id, price) self.assertIn(product_zero.id, price)
@ -146,29 +144,28 @@ class TestPricelist(TransactionCase):
def test_pricelist_multiple_products(self): def test_pricelist_multiple_products(self):
"""Test pricelist calculation with multiple products""" """Test pricelist calculation with multiple products"""
product2 = self.env["product.product"].create( product2 = self.env["product.product"].create({
{ "name": "Test Product 2",
"name": "Test Product 2", "type": "product",
"list_price": 200.0, "list_price": 200.0,
"last_purchase_price_received": 100.0, "last_purchase_price_received": 100.0,
} })
)
self.env["product.pricelist.item"].create( pricelist_item = self.env["product.pricelist.item"].create({
{ "pricelist_id": self.pricelist.id,
"pricelist_id": self.pricelist.id, "compute_price": "formula",
"compute_price": "formula", "base": "last_purchase_price",
"base": "last_purchase_price", "price_discount": 10.0,
"price_discount": 10.0, "applied_on": "3_global",
"applied_on": "3_global", })
}
)
products = self.product | product2 products = self.product | product2
# Test with both products # Test with both products
for product in products: for product in products:
price = self.pricelist._compute_price_rule(product, quantity=1, date=False) price = self.pricelist._compute_price_rule(
product, qty=1, date=False
)
self.assertIn(product.id, price) self.assertIn(product.id, price)
def test_pricelist_item_selection_add(self): def test_pricelist_item_selection_add(self):
@ -194,24 +191,22 @@ class TestPricelist(TransactionCase):
if not eur: if not eur:
self.skipTest("EUR currency not available") self.skipTest("EUR currency not available")
pricelist_eur = self.env["product.pricelist"].create( pricelist_eur = self.env["product.pricelist"].create({
{ "name": "Test Pricelist EUR",
"name": "Test Pricelist EUR", "currency_id": eur.id,
"currency_id": eur.id, })
}
)
self.env["product.pricelist.item"].create( pricelist_item = self.env["product.pricelist.item"].create({
{ "pricelist_id": pricelist_eur.id,
"pricelist_id": pricelist_eur.id, "compute_price": "formula",
"compute_price": "formula", "base": "last_purchase_price",
"base": "last_purchase_price", "price_discount": 0,
"price_discount": 0, "applied_on": "3_global",
"applied_on": "3_global", })
}
)
# Price calculation should work with different currency # Price calculation should work with different currency
price = pricelist_eur._compute_price_rule(self.product, quantity=1, date=False) price = pricelist_eur._compute_price_rule(
self.product, qty=1, date=False
)
self.assertIn(self.product.id, price) self.assertIn(self.product.id, price)

View file

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

View file

@ -12,87 +12,71 @@ class TestStockMove(TransactionCase):
super().setUpClass() super().setUpClass()
# Create tax # Create tax
cls.tax = cls.env["account.tax"].create( cls.tax = cls.env["account.tax"].create({
{ "name": "Test Tax 21%",
"name": "Test Tax 21%", "amount": 21.0,
"amount": 21.0, "amount_type": "percent",
"amount_type": "percent", "type_tax_use": "sale",
"type_tax_use": "sale", })
}
)
# Create pricelist # Create pricelist
cls.pricelist = cls.env["product.pricelist"].create( cls.pricelist = cls.env["product.pricelist"].create({
{ "name": "Test Pricelist",
"name": "Test Pricelist", "currency_id": cls.env.company.currency_id.id,
"currency_id": cls.env.company.currency_id.id, })
}
)
cls.pricelist_item = cls.env["product.pricelist.item"].create( cls.pricelist_item = cls.env["product.pricelist.item"].create({
{ "pricelist_id": cls.pricelist.id,
"pricelist_id": cls.pricelist.id, "compute_price": "formula",
"compute_price": "formula", "base": "last_purchase_price",
"base": "last_purchase_price", "price_markup": 50.0,
"price_markup": 50.0, "applied_on": "3_global",
"applied_on": "3_global", })
}
)
cls.env["ir.config_parameter"].sudo().set_param( cls.env["ir.config_parameter"].sudo().set_param(
"product_sale_price_from_pricelist.product_pricelist_automatic", "product_sale_price_from_pricelist.product_pricelist_automatic",
str(cls.pricelist.id), str(cls.pricelist.id)
) )
# Create supplier # Create supplier
cls.supplier = cls.env["res.partner"].create( cls.supplier = cls.env["res.partner"].create({
{ "name": "Test Supplier",
"name": "Test Supplier", "supplier_rank": 1,
"supplier_rank": 1, })
}
)
# Create product with UoM # Create product with UoM
cls.uom_unit = cls.env.ref("uom.product_uom_unit") cls.uom_unit = cls.env.ref("uom.product_uom_unit")
cls.uom_dozen = cls.env.ref("uom.product_uom_dozen") cls.uom_dozen = cls.env.ref("uom.product_uom_dozen")
cls.product = cls.env["product.product"].create( cls.product = cls.env["product.product"].create({
{ "name": "Test Product Stock",
"name": "Test Product Stock", "type": "product",
"uom_id": cls.uom_unit.id, "uom_id": cls.uom_unit.id,
"uom_po_id": cls.uom_unit.id, "uom_po_id": cls.uom_unit.id,
"list_price": 10.0, "list_price": 10.0,
"standard_price": 5.0, "standard_price": 5.0,
"taxes_id": [(6, 0, [cls.tax.id])], "taxes_id": [(6, 0, [cls.tax.id])],
"last_purchase_price_received": 5.0, "last_purchase_price_received": 5.0,
"last_purchase_price_compute_type": "without_discounts", "last_purchase_price_compute_type": "without_discounts",
} })
)
# Create locations # Create locations
cls.supplier_location = cls.env.ref("stock.stock_location_suppliers") cls.supplier_location = cls.env.ref("stock.stock_location_suppliers")
cls.stock_location = cls.env.ref("stock.stock_location_stock") cls.stock_location = cls.env.ref("stock.stock_location_stock")
def _create_purchase_order( def _create_purchase_order(self, product, qty, price, discount1=0, discount2=0, discount3=0):
self, product, qty, price, discount1=0, discount2=0, discount3=0
):
"""Helper to create a purchase order""" """Helper to create a purchase order"""
purchase_order = self.env["purchase.order"].create( purchase_order = self.env["purchase.order"].create({
{ "partner_id": self.supplier.id,
"partner_id": self.supplier.id, })
}
)
po_line = self.env["purchase.order.line"].create( po_line = self.env["purchase.order.line"].create({
{ "order_id": purchase_order.id,
"order_id": purchase_order.id, "product_id": product.id,
"name": product.name or "Purchase Line", "product_qty": qty,
"product_id": product.id, "price_unit": price,
"product_qty": qty, "product_uom": product.uom_po_id.id,
"price_unit": price, })
"product_uom": product.uom_po_id.id,
}
)
# Add discounts if module supports it # Add discounts if module supports it
if hasattr(po_line, "discount1"): if hasattr(po_line, "discount1"):
@ -106,7 +90,9 @@ class TestStockMove(TransactionCase):
def test_update_price_without_discounts(self): def test_update_price_without_discounts(self):
"""Test price update without discounts""" """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() purchase_order.button_confirm()
# Get the picking and process it # Get the picking and process it
@ -119,49 +105,8 @@ class TestStockMove(TransactionCase):
picking.button_validate() picking.button_validate()
# Verify price was updated # Verify price was updated
self.product.invalidate_recordset()
self.assertEqual(self.product.last_purchase_price_received, 8.0) 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): def test_update_price_with_first_discount(self):
"""Test price update with first discount only""" """Test price update with first discount only"""
if not hasattr(self.env["purchase.order.line"], "discount1"): if not hasattr(self.env["purchase.order.line"], "discount1"):
@ -216,12 +161,7 @@ class TestStockMove(TransactionCase):
self.product.last_purchase_price_compute_type = "with_three_discounts" self.product.last_purchase_price_compute_type = "with_three_discounts"
purchase_order = self._create_purchase_order( purchase_order = self._create_purchase_order(
self.product, self.product, qty=10, price=10.0, discount1=20.0, discount2=10.0, discount3=5.0
qty=10,
price=10.0,
discount1=20.0,
discount2=10.0,
discount3=5.0,
) )
purchase_order.button_confirm() purchase_order.button_confirm()
@ -239,23 +179,16 @@ class TestStockMove(TransactionCase):
def test_update_price_with_uom_conversion(self): def test_update_price_with_uom_conversion(self):
"""Test price update with different purchase UoM""" """Test price update with different purchase UoM"""
# Create product with different purchase UoM # Create product with different purchase UoM
product_dozen = self.product.copy( product_dozen = self.product.copy({
{ "name": "Test Product Dozen",
"name": "Test Product Dozen", "uom_po_id": self.uom_dozen.id,
"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( purchase_order = self._create_purchase_order(
product_dozen, qty=2, price=120.0 # 2 dozens at 120.0 per dozen product_dozen, qty=2, price=120.0 # 2 dozens at 120.0 per dozen
) )
purchase_order.button_confirm() 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 = purchase_order.picking_ids[0]
picking.action_assign() picking.action_assign()
@ -272,7 +205,9 @@ class TestStockMove(TransactionCase):
"""Test that price is not updated with zero quantity done""" """Test that price is not updated with zero quantity done"""
initial_price = self.product.last_purchase_price_received 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() purchase_order.button_confirm()
picking = purchase_order.picking_ids[0] picking = purchase_order.picking_ids[0]
@ -289,8 +224,11 @@ class TestStockMove(TransactionCase):
def test_manual_update_type_no_automatic_update(self): def test_manual_update_type_no_automatic_update(self):
"""Test that manual update type prevents automatic price updates""" """Test that manual update type prevents automatic price updates"""
self.product.last_purchase_price_compute_type = "manual_update" 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() purchase_order.button_confirm()
picking = purchase_order.picking_ids[0] picking = purchase_order.picking_ids[0]

View file

@ -1,151 +0,0 @@
# 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",
)