diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b1da0c3..6495dec 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -83,7 +83,7 @@ Este repositorio contiene addons personalizados y modificados de Odoo 18.0. El p Pedir al usuario generar a través de UI, no sabemos el método correcto para exportar SÓLO las cadenas del addon sin incluir todo el sistema. ``` -Usar sólo polib y apend cadenas en los archivos .po, msmerge corrompe los archivos. +Usar sólo polib para trataer los archivos .po, msmerge corrompe los archivos. ``` diff --git a/product_price_category_supplier/__manifest__.py b/product_price_category_supplier/__manifest__.py index 08e9bfd..9734b59 100644 --- a/product_price_category_supplier/__manifest__.py +++ b/product_price_category_supplier/__manifest__.py @@ -8,7 +8,6 @@ "summary": "Add default price category to suppliers and bulk update products", "author": "Odoo Community Association (OCA), Criptomart", "license": "AGPL-3", - "website": "https://git.criptomart.net/criptomart/addons-cm", "depends": ["product_price_category", "sales_team", "product_main_seller"], "data": [ "security/ir.model.access.csv", diff --git a/product_sale_price_from_pricelist/CHANGELOG.md b/product_sale_price_from_pricelist/CHANGELOG.md deleted file mode 100644 index f6554fb..0000000 --- a/product_sale_price_from_pricelist/CHANGELOG.md +++ /dev/null @@ -1,62 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [18.0.2.0.0] - 2026-02-12 - -### Changed - -**BREAKING CHANGE**: Architecture refactoring to fix pricelist report issues - -- **Moved all main fields from `product.template` to `product.product`** - - `last_purchase_price_updated` - - `list_price_theoritical` - - `last_purchase_price_received` - - `last_purchase_price_compute_type` - -- **Created related fields in `product.template`** - - All template fields now use `related="product_variant_id.FIELD_NAME"` - - Fields are stored and writable for performance - - This maintains backward compatibility with existing views - -- **Moved business logic to `product.product`** - - `_compute_theoritical_price()` method - - `action_update_list_price()` method - - `price_compute()` override - -### Fixed - -- Resolved issues with pricelist report generation -- Proper handling of product variants in price calculations -- Eliminated confusion between template and variant pricing - -### Migration Notes - -**This is a database schema change**. After updating: - -1. Update the module: `odoo -u product_sale_price_from_pricelist` -2. Odoo will automatically migrate data from template to variant fields -3. No manual data migration needed thanks to related fields -4. All existing views continue working without modification - -### Technical Details - -This architecture follows Odoo best practices: -- Product-specific data belongs in `product.product` (variants) -- Template fields are related/computed from variants -- Prevents issues with pricelist reports that work at variant level -- Maintains compatibility with standard Odoo reports - -## [18.0.1.0.0] - 2025-01-XX - -### Added - -- Initial release -- Automatic price calculation from purchase prices -- Multiple discount handling options -- Tax-aware pricing -- UoM conversion support -- Batch price updates diff --git a/product_sale_price_from_pricelist/README.md b/product_sale_price_from_pricelist/README.md index 6fd86ff..4fbebb0 100644 --- a/product_sale_price_from_pricelist/README.md +++ b/product_sale_price_from_pricelist/README.md @@ -40,26 +40,6 @@ Automatically calculate and update product sale prices based on the last purchas 4. Taxes are automatically applied based on product tax settings 5. Go to **Products > Update Theoretical Prices** to batch update prices -## Technical Architecture - -### Models - -All main fields and business logic are stored in `product.product` for proper variant handling: - -**product.product (Main model)** -- `last_purchase_price_updated` (Boolean): Flag for price update needed -- `list_price_theoritical` (Float): Calculated theoretical price -- `last_purchase_price_received` (Float): Last purchase price received -- `last_purchase_price_compute_type` (Selection): How to calculate price -- `_compute_theoritical_price()`: Main price calculation method -- `action_update_list_price()`: Update sale price from theoretical - -**product.template (Related fields)** -- All fields are `related` to `product_variant_id` fields -- Allows views on template to continue working -- Delegates actions to product variants - -This architecture prevents issues with pricelist reports and ensures proper handling of product variants. ## License diff --git a/product_sale_price_from_pricelist/__manifest__.py b/product_sale_price_from_pricelist/__manifest__.py index 3303455..dfedd94 100644 --- a/product_sale_price_from_pricelist/__manifest__.py +++ b/product_sale_price_from_pricelist/__manifest__.py @@ -2,7 +2,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). { # noqa: B018 "name": "Product Sale Price from Pricelist", - "version": "18.0.2.0.0", + "version": "18.0.1.0.0", "category": "product", "summary": "Set sale price from pricelist based on last purchase price", "author": "Odoo Community Association (OCA), Criptomart", diff --git a/product_sale_price_from_pricelist/migrations/18.0.2.0.0/post-migration.py b/product_sale_price_from_pricelist/migrations/18.0.2.0.0/post-migration.py deleted file mode 100644 index c11a1ea..0000000 --- a/product_sale_price_from_pricelist/migrations/18.0.2.0.0/post-migration.py +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright 2026 Criptomart -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). - -import logging - -_logger = logging.getLogger(__name__) - - -def migrate(cr, version): - """Migrate fields from product.template to product.product. - - This migration handles the architecture change where main fields - moved from template to variant level. - - Note: Most of the work is handled automatically by Odoo's related fields, - but we ensure proper data integrity here. - """ - if not version: - return - - _logger.info("Starting migration to 18.0.2.0.0 - Moving fields to product.product") - - # The fields are now stored at variant level with related fields in template - # Odoo will handle the data migration automatically through related fields - # We just need to ensure the fields exist and are properly populated - - # Force recompute of related fields to ensure data consistency - cr.execute(""" - SELECT id FROM product_product WHERE active = true - """) - product_ids = [row[0] for row in cr.fetchall()] - - if product_ids: - _logger.info(f"Migration: Recomputing fields for {len(product_ids)} products") - # The ORM will handle the rest through related fields - - _logger.info("Migration to 18.0.2.0.0 completed successfully") diff --git a/product_sale_price_from_pricelist/models/product_product.py b/product_sale_price_from_pricelist/models/product_product.py index a797d42..425a8a2 100644 --- a/product_sale_price_from_pricelist/models/product_product.py +++ b/product_sale_price_from_pricelist/models/product_product.py @@ -1,210 +1,16 @@ -import logging - -from odoo import fields -from odoo import models -from odoo.exceptions import UserError - -_logger = logging.getLogger(__name__) +from odoo import fields, models class ProductProduct(models.Model): _inherit = "product.product" - last_purchase_price_updated = fields.Boolean( - string="Last purchase price updated", - help="The last purchase price has been updated and you need to update the selling price in the database, shelves and scales.", - default=False, - company_dependent=True, - ) - list_price_theoritical = fields.Float( - "Theoritical price", - company_dependent=True, - ) - last_purchase_price_received = fields.Float( - string="Last purchase price", - help="The last price at which the product was purchased. It is used as the base price field for calculating the product sale price.", - digits="Product Price", - company_dependent=True, - ) - last_purchase_price_compute_type = fields.Selection( - [ - ("without_discounts", "Without discounts"), - ("with_discount", "First discount"), - ("with_two_discounts", "Double discount"), - ("with_three_discounts", "Triple discount"), - ("manual_update", "Manual update"), - ], - string="Last purchase price calculation type", - help="Choose whether discounts should influence the calculation of the last purchase price. Select Never update for manual configuration of cost and sale prices.\n" - "\n* Without discounts: does not take into account discounts when updating the last purchase price.\n" - "* First discount: take into account only first discount when updating the last purchase price.\n" - '* Triple discount: take into account all discounts when updating the last purchase price. Needs "Purchase Triple Discount" OCA module.\n' - "* Manual update: Select this for manual configuration of cost and sale price. The sales price will not be calculated automatically.", - default="without_discounts", - company_dependent=True, - ) - - # Keep this for backward compatibility with pricelist computations + # Related field for pricelist base computation last_purchase_price = fields.Float( - related="last_purchase_price_received", + related="product_tmpl_id.last_purchase_price_received", string="Last Purchase Price", readonly=True, ) - def _compute_theoritical_price(self): - pricelist_obj = self.env["product.pricelist"] - pricelist_id = ( - self.env["ir.config_parameter"] - .sudo() - .get_param("product_sale_price_from_pricelist.product_pricelist_automatic") - or False - ) - pricelist = pricelist_obj.browse(int(pricelist_id)) - if pricelist: - for product in self: - _logger.info( - "[PRICE DEBUG] Product %s [%s]: Checking conditions - name=%s, id=%s, compute_type=%s", - product.default_code or product.name, - product.id, - bool(product.name), - bool(product.id), - product.last_purchase_price_compute_type, - ) - if ( - product.name - and product.id - and product.last_purchase_price_compute_type != "manual_update" - ): - partial_price = product._get_price(qty=1, pricelist=pricelist) - - _logger.info( - "[PRICE DEBUG] Product %s [%s]: partial_price result = %s", - product.default_code or product.name, - product.id, - partial_price, - ) - - # Compute taxes to add - if not product.taxes_id: - raise UserError( - self.env._( - "No taxes defined for product %(product)s. Please define taxes in the product form.", - product=product.name, - ) - ) - - base_price = partial_price.get("value", 0.0) or 0.0 - _logger.info( - "[PRICE DEBUG] Product %s [%s]: base_price from pricelist = %.2f, last_purchase_price = %.2f", - product.default_code or product.name, - product.id, - base_price, - product.last_purchase_price_received, - ) - - # Use base price without taxes (taxes will be calculated automatically on sales) - theoretical_price = base_price - - _logger.info( - "[PRICE] Product %s [%s]: Computed theoretical price %.2f (previous: %.2f, current list_price: %.2f)", - product.default_code or product.name, - product.id, - theoretical_price, - product.list_price_theoritical, - product.lst_price, - ) - - product.write( - { - "list_price_theoritical": theoretical_price, - "last_purchase_price_updated": ( - theoretical_price != product.lst_price - ), - } - ) - else: - raise UserError( - self.env._( - "Not found a valid pricelist to compute sale price. Check configuration in General Settings." - ) - ) - - def action_update_list_price(self): - updated_products = [] - skipped_products = [] - - for product in self: - if product.last_purchase_price_compute_type != "manual_update": - # First compute the theoretical price - product._compute_theoritical_price() - - old_price = product.lst_price - product.lst_price = product.list_price_theoritical - product.last_purchase_price_updated = False - _logger.info( - "[PRICE] Product %s [%s]: List price updated from %.2f to %.2f", - product.default_code or product.name, - product.id, - old_price, - product.lst_price, - ) - updated_products.append( - ( - product.name or product.default_code, - old_price, - product.lst_price, - ) - ) - else: - skipped_products.append(product.name or product.default_code) - - # Invalidate cache to refresh UI - self.invalidate_recordset( - ["lst_price", "list_price_theoritical", "last_purchase_price_updated"] - ) - - # Build notification message - message = "" - if updated_products: - message += self.env._( - "✓ Products updated: %(count)s", count=len(updated_products) - ) - if len(updated_products) <= 5: - message += "\n\n" - for name, old, new in updated_products: - message += self.env._( - "• %(name)s: %(old).2f → %(new).2f\n", - name=name, - old=old, - new=new, - ) - - if skipped_products: - if message: - message += "\n\n" - message += self.env._( - "⚠ Skipped (manual update): %(count)s", count=len(skipped_products) - ) - if len(skipped_products) <= 5: - message += "\n" - for name in skipped_products: - message += self.env._("• %(name)s\n", name=name) - - if not updated_products and not skipped_products: - message = self.env._("No products to update.") - - return { - "type": "ir.actions.client", - "tag": "display_notification", - "params": { - "title": self.env._("Price Update"), - "message": message, - "type": "success" if updated_products else "warning", - "sticky": False, - "next": {"type": "ir.actions.act_window_close"}, - }, - } - def price_compute( self, price_type, uom=None, currency=None, company=None, date=False ): diff --git a/product_sale_price_from_pricelist/models/product_template.py b/product_sale_price_from_pricelist/models/product_template.py index 09ef270..2397270 100644 --- a/product_sale_price_from_pricelist/models/product_template.py +++ b/product_sale_price_from_pricelist/models/product_template.py @@ -2,43 +2,35 @@ # @author Santi Noreña () # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo import api + +import logging + +from odoo import _ from odoo import fields from odoo import models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) class ProductTemplate(models.Model): - """Product template with computed fields to product.product. - - All main fields and logic are now in product.product. - These computed fields allow views on product.template to continue working. - For templates with a single variant, reads/writes directly from/to the variant. - For templates with multiple variants, uses the first variant. - """ - _inherit = "product.template" - # Computed fields that delegate to product.product variants last_purchase_price_updated = fields.Boolean( string="Last purchase price updated", - compute="_compute_last_purchase_price_updated", - inverse="_inverse_last_purchase_price_updated", - search="_search_last_purchase_price_updated", - store=True, + help="The last purchase price has been updated and you need to update the selling price in the database, shelves and scales.", + default=False, + company_dependent=True, ) list_price_theoritical = fields.Float( - string="Theoritical price", - compute="_compute_list_price_theoritical", - inverse="_inverse_list_price_theoritical", - search="_search_list_price_theoritical", - store=True, + "Theoritical price", + company_dependent=True, ) last_purchase_price_received = fields.Float( string="Last purchase price", - compute="_compute_last_purchase_price_received", - inverse="_inverse_last_purchase_price_received", - search="_search_last_purchase_price_received", - store=True, + help="The last price at which the product was purchased. It is used as the base price field for calculating the product sale price.", + digits="Product Price", + company_dependent=True, ) last_purchase_price_compute_type = fields.Selection( [ @@ -49,96 +41,173 @@ class ProductTemplate(models.Model): ("manual_update", "Manual update"), ], string="Last purchase price calculation type", - compute="_compute_last_purchase_price_compute_type", - inverse="_inverse_last_purchase_price_compute_type", - search="_search_last_purchase_price_compute_type", - store=True, + help="Choose whether discounts should influence the calculation of the last purchase price. Select Never update for manual configuration of cost and sale prices.\n" + "\n* Without discounts: does not take into account discounts when updating the last purchase price.\n" + "* First discount: take into account only first discount when updating the last purchase price.\n" + '* Triple discount: take into account all discounts when updating the last purchase price. Needs "Purchase Triple Discount" OCA module.\n' + "* Manual update: Select this for manual configuration of cost and sale price. The sales price will not be calculated automatically.", + default="without_discounts", + company_dependent=True, ) - @api.depends("product_variant_ids.last_purchase_price_updated") - def _compute_last_purchase_price_updated(self): - for template in self: - template.last_purchase_price_updated = ( - template.product_variant_ids[:1].last_purchase_price_updated - if template.product_variant_ids - else False - ) - - def _inverse_last_purchase_price_updated(self): - for template in self: - if template.product_variant_ids: - template.product_variant_ids.write( - { - "last_purchase_price_updated": template.last_purchase_price_updated - } + def _compute_theoritical_price(self): + pricelist_obj = self.env["product.pricelist"] + pricelist_id = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("product_sale_price_from_pricelist.product_pricelist_automatic") + or False + ) + pricelist = pricelist_obj.browse(int(pricelist_id)) + if pricelist: + for template in self: + _logger.info( + "[PRICE DEBUG] Product %s [%s]: Checking conditions - name=%s, id=%s, variant=%s, compute_type=%s", + template.default_code or template.name, + template.id, + bool(template.name), + bool(template.id), + bool(template.product_variant_id), + template.last_purchase_price_compute_type, ) + if ( + template.name + and template.id + and template.product_variant_id + and template.last_purchase_price_compute_type != "manual_update" + ): + partial_price = template.product_variant_id._get_price( + qty=1, pricelist=pricelist + ) - def _search_last_purchase_price_updated(self, operator, value): - return [("product_variant_ids.last_purchase_price_updated", operator, value)] + _logger.info( + "[PRICE DEBUG] Product %s [%s]: partial_price result = %s", + template.default_code or template.name, + template.id, + partial_price, + ) - @api.depends("product_variant_ids.list_price_theoritical") - def _compute_list_price_theoritical(self): - for template in self: - template.list_price_theoritical = ( - template.product_variant_ids[:1].list_price_theoritical - if template.product_variant_ids - else 0.0 - ) + # Compute taxes to add + if not template.taxes_id: + raise UserError( + _( + "No taxes defined for product %s. Please define taxes in the product form." + ) + % template.name + ) - def _inverse_list_price_theoritical(self): - for template in self: - if template.product_variant_ids: - template.product_variant_ids.write( - {"list_price_theoritical": template.list_price_theoritical} + base_price = partial_price.get("value", 0.0) or 0.0 + _logger.info( + "[PRICE DEBUG] Product %s [%s]: base_price from pricelist = %.2f, last_purchase_price = %.2f", + template.default_code or template.name, + template.id, + base_price, + template.last_purchase_price_received, + ) + + # Use base price without taxes (taxes will be calculated automatically on sales) + theoretical_price = base_price + + _logger.info( + "[PRICE] Product %s [%s]: Computed theoretical price %.2f (previous: %.2f, current list_price: %.2f)", + template.default_code or template.name, + template.id, + theoretical_price, + template.list_price_theoritical, + template.list_price, + ) + + template.write( + { + "list_price_theoritical": theoretical_price, + "last_purchase_price_updated": ( + theoretical_price != template.list_price + ), + } + ) + else: + raise UserError( + _( + "Not found a valid pricelist to compute sale price. Check configuration in General Settings." ) - - def _search_list_price_theoritical(self, operator, value): - return [("product_variant_ids.list_price_theoritical", operator, value)] - - @api.depends("product_variant_ids.last_purchase_price_received") - def _compute_last_purchase_price_received(self): - for template in self: - template.last_purchase_price_received = ( - template.product_variant_ids[:1].last_purchase_price_received - if template.product_variant_ids - else 0.0 ) - def _inverse_last_purchase_price_received(self): - for template in self: - if template.product_variant_ids: - template.product_variant_ids.write( - { - "last_purchase_price_received": template.last_purchase_price_received - } - ) - - def _search_last_purchase_price_received(self, operator, value): - return [("product_variant_ids.last_purchase_price_received", operator, value)] - - @api.depends("product_variant_ids.last_purchase_price_compute_type") - def _compute_last_purchase_price_compute_type(self): - for template in self: - template.last_purchase_price_compute_type = ( - template.product_variant_ids[:1].last_purchase_price_compute_type - if template.product_variant_ids - else "without_discounts" - ) - - def _inverse_last_purchase_price_compute_type(self): - for template in self: - if template.product_variant_ids: - template.product_variant_ids.write( - { - "last_purchase_price_compute_type": template.last_purchase_price_compute_type - } - ) - - def _search_last_purchase_price_compute_type(self, operator, value): - return [ - ("product_variant_ids.last_purchase_price_compute_type", operator, value) - ] - def action_update_list_price(self): - """Delegate to product variants.""" - return self.product_variant_ids.action_update_list_price() + updated_products = [] + skipped_products = [] + + for template in self: + if template.last_purchase_price_compute_type != "manual_update": + # First compute the theoretical price + template._compute_theoritical_price() + + old_price = template.list_price + template.list_price = template.list_price_theoritical + template.last_purchase_price_updated = False + _logger.info( + "[PRICE] Product %s [%s]: List price updated from %.2f to %.2f", + template.default_code or template.name, + template.id, + old_price, + template.list_price, + ) + updated_products.append( + ( + template.name or template.default_code, + old_price, + template.list_price, + ) + ) + else: + skipped_products.append(template.name or template.default_code) + + # Invalidate cache to refresh UI + self.invalidate_recordset( + ["list_price", "list_price_theoritical", "last_purchase_price_updated"] + ) + + # Build notification message + message = "" + if updated_products: + message += _("✓ Products updated: %s") % len(updated_products) + if len(updated_products) <= 5: + message += "\n\n" + for name, old, new in updated_products: + message += _("• %s: %.2f → %.2f\n") % (name, old, new) + + if skipped_products: + if message: + message += "\n\n" + message += _("⚠ Skipped (manual update): %s") % len(skipped_products) + if len(skipped_products) <= 5: + message += "\n" + for name in skipped_products: + message += _("• %s\n") % name + + if not updated_products and not skipped_products: + message = _("No products to update.") + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Price Update"), + "message": message, + "type": "success" if updated_products else "warning", + "sticky": False, + "next": {"type": "ir.actions.act_window_close"}, + }, + } + + def price_compute( + self, price_type, uom=None, currency=None, company=False, date=False + ): + """Return dummy not falsy prices when computation is done from supplier + info for avoiding error on super method. We will later fill these with + correct values. + """ + if price_type == "last_purchase_price": + return dict.fromkeys(self.ids, 1.0) + return super().price_compute( + price_type, uom=uom, currency=currency, company=company, date=date + ) diff --git a/product_sale_price_from_pricelist/models/stock_move.py b/product_sale_price_from_pricelist/models/stock_move.py index 26521be..abbaaf5 100644 --- a/product_sale_price_from_pricelist/models/stock_move.py +++ b/product_sale_price_from_pricelist/models/stock_move.py @@ -5,11 +5,10 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import logging +import math -from odoo import models -from odoo.tools import float_compare -from odoo.tools import float_is_zero -from odoo.tools import float_round +from odoo import exceptions, models, fields, api, _ +from odoo.tools import float_is_zero, float_round, float_compare _logger = logging.getLogger(__name__) @@ -18,7 +17,7 @@ class StockMove(models.Model): _inherit = "stock.move" def product_price_update_before_done(self): - res = super().product_price_update_before_done() + super(StockMove, self).product_price_update_before_done() for move in self.filtered(lambda move: move.location_id.usage == "supplier"): if ( move.product_id.last_purchase_price_compute_type @@ -68,11 +67,7 @@ class StockMove(models.Model): move.product_id.last_purchase_price_received, price_updated, move.name, - ( - move.purchase_line_id.order_id.name - if move.purchase_line_id - else "N/A" - ), + move.purchase_line_id.order_id.name if move.purchase_line_id else 'N/A', move.product_id.last_purchase_price_compute_type, ) move.product_id.with_company( @@ -80,5 +75,4 @@ class StockMove(models.Model): ).last_purchase_price_received = price_updated move.product_id.with_company( move.company_id - )._compute_theoritical_price() - return res + ).product_tmpl_id._compute_theoritical_price() diff --git a/website_sale_aplicoop/views/website_templates.xml b/website_sale_aplicoop/views/website_templates.xml index 2236a3b..1fa6dc7 100644 --- a/website_sale_aplicoop/views/website_templates.xml +++ b/website_sale_aplicoop/views/website_templates.xml @@ -2,22 +2,32 @@ - -