[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
This commit is contained in:
snt 2026-02-12 19:23:29 +01:00
parent fd83d31188
commit 55811d54b1
28 changed files with 1569 additions and 327 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

@ -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,14 +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(
string="Last Purchase Price",
compute="_compute_last_purchase_price",
search="_search_last_purchase_price",
store=True,
company_dependent=False,
)
@api.depends("product_variant_ids.last_purchase_price_updated")
@ -156,6 +160,10 @@ class ProductTemplate(models.Model):
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

@ -12,45 +12,50 @@ class TestPricelist(TransactionCase):
super().setUpClass()
# Create tax
cls.tax = cls.env["account.tax"].create({
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({
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",
})
}
)
# 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",
"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({
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
)
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)
@ -58,39 +63,37 @@ class TestPricelist(TransactionCase):
def test_pricelist_item_with_discount(self):
"""Test pricelist item with discount on last_purchase_price"""
pricelist_item = self.env["product.pricelist.item"].create({
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
}
)
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_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
@ -98,20 +101,18 @@ class TestPricelist(TransactionCase):
def test_pricelist_item_compute_price_method(self):
"""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,
"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
self.product, qty=1, uom=self.product.uom_id, date=False, currency=None
)
# Should return last_purchase_price_received
@ -119,53 +120,55 @@ class TestPricelist(TransactionCase):
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({
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({
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
}
)
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({
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({
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):
@ -191,22 +194,24 @@ class TestPricelist(TransactionCase):
if not eur:
self.skipTest("EUR currency not available")
pricelist_eur = self.env["product.pricelist"].create({
pricelist_eur = self.env["product.pricelist"].create(
{
"name": "Test Pricelist EUR",
"currency_id": eur.id,
})
}
)
pricelist_item = self.env["product.pricelist.item"].create({
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
}
)
# 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")
@ -13,20 +13,25 @@ class TestProductTemplate(TransactionCase):
super().setUpClass()
# Create a tax for the product
cls.tax = cls.env["account.tax"].create({
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({
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({
cls.pricelist_item = cls.env["product.pricelist.item"].create(
{
"pricelist_id": cls.pricelist.id,
"compute_price": "formula",
"base": "last_purchase_price",
@ -34,41 +39,61 @@ class TestProductTemplate(TransactionCase):
"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({
cls.category = cls.env["product.category"].create(
{
"name": "Test Category",
})
}
)
# Create a product
cls.product = cls.env["product.template"].create({
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])],
}
)
# 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({
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",
})
}
)
with self.assertRaises(UserError):
product_no_tax._compute_theoritical_price()
@ -77,12 +102,15 @@ class TestProductTemplate(TransactionCase):
"""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)
self.assertGreater(theoretical_price, 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.assertAlmostEqual(theoretical_price, 9.10, places=2)
def test_compute_theoritical_price_with_rounding(self):
"""Test that prices are rounded to 0.05"""
@ -90,7 +118,7 @@ class TestProductTemplate(TransactionCase):
self.product._compute_theoritical_price()
# Price should be rounded to nearest 0.05
price = self.product.list_price_theoritical
price = self._get_theoretical_price(self.product)
self.assertEqual(round(price % 0.05, 2), 0.0)
def test_last_purchase_price_updated_flag(self):
@ -99,20 +127,20 @@ class TestProductTemplate(TransactionCase):
self.product.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._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"""
@ -136,8 +164,7 @@ 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):
@ -146,7 +173,7 @@ class TestProductTemplate(TransactionCase):
# 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):
@ -161,6 +188,7 @@ class TestProductTemplate(TransactionCase):
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"""
@ -172,11 +200,13 @@ class TestProductTemplate(TransactionCase):
# 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
@ -187,17 +217,17 @@ class TestProductTemplate(TransactionCase):
expected_with_tax = expected_base * 1.21 # 19.0575
self.assertGreater(
self.product.list_price_theoritical,
theoretical_price,
expected_base,
"Theoretical price should include taxes"
"Theoretical price should include taxes",
)
# Allow some tolerance for rounding
self.assertAlmostEqual(
self.product.list_price_theoritical,
theoretical_price,
expected_with_tax,
delta=0.10,
msg=f"Expected around {expected_with_tax:.2f}, got {self.product.list_price_theoritical:.2f}"
msg=f"Expected around {expected_with_tax:.2f}, got {theoretical_price:.2f}",
)
def test_compute_price_zero_purchase_price(self):
@ -207,9 +237,9 @@ class TestProductTemplate(TransactionCase):
# 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,7 +247,7 @@ 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

View file

@ -12,45 +12,53 @@ class TestStockMove(TransactionCase):
super().setUpClass()
# Create tax
cls.tax = cls.env["account.tax"].create({
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({
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({
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({
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({
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,
@ -58,25 +66,33 @@ class TestStockMove(TransactionCase):
"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({
purchase_order = self.env["purchase.order"].create(
{
"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,
"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"):
@ -90,9 +106,7 @@ class TestStockMove(TransactionCase):
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
@ -161,7 +175,12 @@ class TestStockMove(TransactionCase):
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()
@ -179,10 +198,12 @@ class TestStockMove(TransactionCase):
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({
product_dozen = self.product.copy(
{
"name": "Test Product Dozen",
"uom_po_id": self.uom_dozen.id,
})
}
)
purchase_order = self._create_purchase_order(
product_dozen, qty=2, price=120.0 # 2 dozens at 120.0 per dozen
@ -205,9 +226,7 @@ class TestStockMove(TransactionCase):
"""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]
@ -224,11 +243,8 @@ 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]