diff --git a/sale_order_type/README.rst b/sale_order_type/README.rst index b6d4ed23729..e123f69c9dd 100644 --- a/sale_order_type/README.rst +++ b/sale_order_type/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - =============== Sale Order Type =============== @@ -17,7 +13,7 @@ Sale Order Type .. |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/license-AGPL--3-blue.png +.. |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%2Fsale--workflow-lightgray.png?logo=github @@ -42,6 +38,64 @@ You can see sale types as lines of business. You are able to select a sales order type by partner so that when you add a partner to a sales order it will get the related info to it. +Additionally, it adds a warning message to notify users when there is a +mismatch between the partner's default pricelist and the effective +pricelist set by the sales order type. The warning text adapts to the +type's configured precedence mode (see below) so that the user knows +which value will actually be applied on new sales orders. The warning is +only visible for companies without a parent and when there is a mismatch +between the two pricelists. + +|Pricelist Conflict Warning Note| + +Precedence modes (per sale.order.type) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Each ``sale.order.type`` declares how its propagated fields interact +with the partner's defaults. The setting is exposed in developer mode on +the type form (Sales > Configuration > Sales Order Types > *type* > +Precedence) and applies uniformly to the pricelist, payment term, +warehouse, shipping policy, incoterm, route and invoice journal. + +- **Sale type wins** (``type_first``, default): the type's value + overrides whatever the partner derives. This is the legacy behavior + that has existed in this module since it was first published. +- **Partner wins; type fills gaps** (``partner_first``): the partner's + value is used; the type's value only applies when the partner has no + value for that field. Use this for customers who maintain + authoritative partner-level defaults and treat sale order types + primarily as labeling / reporting / sequence-numbering devices. +- **Ignore type for propagation** (``partner_only``): the type's + pricelist / payment term / warehouse etc. are never pushed onto the + sales order. The type record remains useful for grouping, filtering, + sequences and the per-type invoice journal logic, but its other fields + are decorative. Use this when you want a sale order type purely as a + reporting axis. + +A company-wide default is configurable in *Settings → Sales* and is +applied when new types are created. Changing the default does not modify +existing types; their precedence is preserved. + +Manual edits on a sale order are preserved across recomputes by trigger +narrowing: once a user sets the pricelist (or payment term, etc.) +directly on a SO, the value will not be overwritten unless ``type_id`` +or ``partner_id`` actually changes. A ``type_id`` change *does* re-fire +the type's value in ``type_first`` mode — this is the documented legacy +behavior and matches user expectations for an explicit type change. + +For stricter dirty-state preservation (preserve user edits even on a +type change, with a Salesforce/SAP-style badge to surface "this value is +anchored by the user"), install the companion module +``web_field_provenance`` when it's available (see Huly OCA-23 / ledoent +initiative). This module's ``record._user_set(fname)`` API is consulted +automatically by ``sale.order._sot_resolve`` — when installed and the +user has manually set a field, the precedence rules are bypassed and the +manual value wins regardless of mode. The integration is a soft +dependency: this module works standalone and behaves identically when +``web_field_provenance`` is not installed. + +.. |Pricelist Conflict Warning Note| image:: https://raw.githubusercontent.com/OCA/sale-workflow/18.0/sale_order_type/static/description/pricelist_conflict_warning_note.png + **Table of contents** .. contents:: @@ -53,16 +107,39 @@ Configuration To configure Sale Order Types you need to: 1. Go to **Sales > Configuration > Sales Orders Types** -2. Create a new sale order type with all the settings you want + +2. Create a new sale order type with all the settings you want. + +3. *(Optional, developer mode only)* on each type, set the *Precedence* + field to control how its own pricelist / payment term / warehouse / + etc. interact with the partner's defaults when a sales order is + created: + + - ``Sale type wins`` — legacy behavior; the type overrides whatever + the partner derives. + - ``Partner wins; type fills gaps`` — the partner's value is used; + the type only fills fields the partner leaves empty. + - ``Ignore type for propagation`` — the type's fields are never + applied to new sales orders; the type acts as a label only. + + The default for newly-created types is set in *Settings > Sales > + Quotations & Orders > Default precedence for new Sale Order Types*. + Existing types keep their current value when this default changes — + upgrading the module preserves legacy behavior (``Sale type wins``) + for any type that hasn't been touched. Usage ===== 1. Go to **Sales > Sales Orders** and create a new sale order. Select the new type you have created before and all settings will be - propagated. + propagated. How they're propagated depends on the type's *Precedence* + setting (see CONFIGURE). 2. You can also define a type for a particular partner if you go to *Sales & Purchases* and set a sale order type. +3. When the type's precedence differs from the legacy ``Sale type wins`` + mode, a small caption appears under the SO's pricelist explaining + what behavior to expect. Bug Tracker =========== diff --git a/sale_order_type/__manifest__.py b/sale_order_type/__manifest__.py index 8bdabb3c21f..efee05ee233 100644 --- a/sale_order_type/__manifest__.py +++ b/sale_order_type/__manifest__.py @@ -8,7 +8,7 @@ { "name": "Sale Order Type", - "version": "18.0.1.2.2", + "version": "18.0.2.0.0", "category": "Sales Management", "author": "Grupo Vermon," "AvanzOSC," diff --git a/sale_order_type/models/res_company.py b/sale_order_type/models/res_company.py index 4a6f7130ebd..ea40408e2d3 100644 --- a/sale_order_type/models/res_company.py +++ b/sale_order_type/models/res_company.py @@ -9,3 +9,15 @@ class ResCompany(models.Model): sale_order_type_required = fields.Boolean( default=True, help="If checked, the sale orders will require a type." ) + sale_order_type_default_precedence = fields.Selection( + [ + ("type_first", "Sale type wins"), + ("partner_first", "Partner wins; type fills gaps"), + ("partner_only", "Ignore type for propagation"), + ], + default="type_first", + required=True, + help="Default precedence applied to newly-created sale order types. " + "The actual behavior is set per-type and visible only in developer mode " + "on the sale.order.type form.", + ) diff --git a/sale_order_type/models/res_config_settings.py b/sale_order_type/models/res_config_settings.py index 503c984f242..27bf8d081b0 100644 --- a/sale_order_type/models/res_config_settings.py +++ b/sale_order_type/models/res_config_settings.py @@ -10,3 +10,7 @@ class ResConfigSettings(models.TransientModel): related="company_id.sale_order_type_required", readonly=False, ) + sale_order_type_default_precedence = fields.Selection( + related="company_id.sale_order_type_default_precedence", + readonly=False, + ) diff --git a/sale_order_type/models/res_partner.py b/sale_order_type/models/res_partner.py index ee6360a70ba..70d38507cd5 100644 --- a/sale_order_type/models/res_partner.py +++ b/sale_order_type/models/res_partner.py @@ -13,6 +13,45 @@ class ResPartner(models.Model): copy=True, ) + effective_pricelist_id = fields.Many2one( + comodel_name="product.pricelist", + string="Sale Effective Pricelist", + related="sale_type.pricelist_id", + ) + effective_payment_term_id = fields.Many2one( + comodel_name="account.payment.term", + string="Sale Effective Payment Term", + related="sale_type.payment_term_id", + ) + effective_warehouse_id = fields.Many2one( + comodel_name="stock.warehouse", + string="Sale Effective Warehouse", + related="sale_type.warehouse_id", + ) + effective_incoterm_id = fields.Many2one( + comodel_name="account.incoterms", + string="Sale Effective Incoterm", + related="sale_type.incoterm_id", + ) + effective_route_id = fields.Many2one( + comodel_name="stock.route", + string="Sale Effective Route", + related="sale_type.route_id", + ) + effective_picking_policy = fields.Selection( + string="Sale Effective Shipping Policy", + related="sale_type.picking_policy", + ) + effective_journal_id = fields.Many2one( + comodel_name="account.journal", + string="Sale Effective Billing Journal", + related="sale_type.journal_id", + ) + effective_precedence = fields.Selection( + string="Sale Effective Precedence", + related="sale_type.precedence", + ) + def copy_data(self, default=None): result = super().copy_data(default=default) for idx, partner in enumerate(self): diff --git a/sale_order_type/models/sale.py b/sale_order_type/models/sale.py index 02e53041874..e08ba677e09 100644 --- a/sale_order_type/models/sale.py +++ b/sale_order_type/models/sale.py @@ -2,10 +2,13 @@ # Copyright 2023 Tecnativa - Sergio Teruel # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging from datetime import datetime, timedelta from odoo import _, api, fields, models +_logger = logging.getLogger(__name__) + class SaleOrder(models.Model): _inherit = "sale.order" @@ -22,6 +25,11 @@ class SaleOrder(models.Model): check_company=True, ) order_type_required = fields.Boolean(related="company_id.sale_order_type_required") + type_precedence = fields.Selection( + related="type_id.precedence", + help="The active precedence mode of this order's type — exposed for " + "view-level hints, not user-editable here.", + ) # Fields converted to computed writable picking_policy = fields.Selection( compute="_compute_picking_policy", store=True, readonly=False @@ -50,6 +58,68 @@ def _default_sequence_id(self): limit=1, ) + def _sot_resolve(self, type_value, current_value, fname=None): + """Apply the active precedence mode of this order's `type_id`. + + Returns the value that the caller (a `_compute_*` body) should write + to the order's field. `current_value` is whatever `super()` already + set (usually the partner-derived default). + + `fname` is accepted (and forwarded by callers) for parity with + `_sot_stamp_cascade_if_available`, which uses it for badge + attribution. The `_user_set` check is NOT done here — by the time + we run, `super()` has already overwritten `current_value` with the + partner default, so checking `_user_set` against the clobbered + value gives the wrong answer. The compute-level filter + `_sot_preserve_user_set` (called before `super()`) is the correct + place to short-circuit for user-anchored records. + """ + del fname # kept for caller-side parity / future extension + mode = self.type_id.precedence or "type_first" + if mode == "partner_only": + return current_value + if mode == "partner_first": + return current_value or type_value + # type_first (legacy) + return type_value or current_value + + def _sot_stamp_cascade_if_available(self, fname, value, type_value): + """Stamp `fname` as rule-derived when the resolved value came from + the type (not the partner default). + + Soft-dependency on the `web_field_provenance` module (Huly OCA-23): + if `_stamp_provenance` is present on the record, attribute the + cascade write so the OWL badge shows the green-cog icon and the + tooltip reads "Set by Sale Order Type cascade". When the module + isn't installed this is a silent no-op. + """ + if not value or value != type_value: + return + if not hasattr(self, "_stamp_provenance"): + return + try: + self._stamp_provenance( + [fname], + source="r", + by="sot.cascade", + rule="Sale Order Type cascade", + ) + except Exception as exc: + # Provenance stamping must never break the compute. + _logger.debug("_stamp_provenance failed for %s: %s", fname, exc) + + def _sot_preserve_user_set(self, fname): + """Filter `self` down to records whose `fname` has been + user-anchored via the `web_field_provenance` module. Compute + overrides should exclude these from `super()` so the partner + default doesn't overwrite the user's value before we get a + chance to short-circuit. Returns an empty recordset when + web_field_provenance is not installed — soft dependency. + """ + if not hasattr(self, "_user_set"): + return self.browse() + return self.filtered(lambda r: r._user_set(fname)) + @api.depends("partner_id", "company_id") @api.depends_context("partner_id", "company_id", "company") def _compute_sale_type_id(self): @@ -71,11 +141,16 @@ def _compute_sale_type_id(self): @api.depends("type_id") def _compute_warehouse_id(self): - res = super()._compute_warehouse_id() - for order in self.filtered("type_id"): - order_type = order.type_id - if order_type.warehouse_id: - order.warehouse_id = order_type.warehouse_id + preserved = self._sot_preserve_user_set("warehouse_id") + res = super(SaleOrder, self - preserved)._compute_warehouse_id() + for order in (self - preserved).filtered("type_id"): + type_value = order.type_id.warehouse_id + order.warehouse_id = order._sot_resolve( + type_value, order.warehouse_id, "warehouse_id" + ) + order._sot_stamp_cascade_if_available( + "warehouse_id", order.warehouse_id, type_value + ) return res def _depends_picking_policy(self): @@ -87,42 +162,64 @@ def _depends_picking_policy(self): @api.depends(lambda self: self._depends_picking_policy()) def _compute_picking_policy(self): + preserved = self._sot_preserve_user_set("picking_policy") + target = self - preserved res = None - if hasattr(super(), "_compute_picking_policy"): - res = super()._compute_picking_policy() - for order in self.filtered("type_id"): - order_type = order.type_id - if order_type.picking_policy: - order.picking_policy = order_type.picking_policy + if hasattr(super(SaleOrder, target), "_compute_picking_policy"): + res = super(SaleOrder, target)._compute_picking_policy() + for order in target.filtered("type_id"): + type_value = order.type_id.picking_policy + order.picking_policy = order._sot_resolve( + type_value, order.picking_policy, "picking_policy" + ) + order._sot_stamp_cascade_if_available( + "picking_policy", order.picking_policy, type_value + ) return res @api.depends("type_id") def _compute_payment_term_id(self): - res = super()._compute_payment_term_id() - for order in self.filtered("type_id"): - order_type = order.type_id - if order_type.payment_term_id: - order.payment_term_id = order_type.payment_term_id + preserved = self._sot_preserve_user_set("payment_term_id") + target = self - preserved + res = super(SaleOrder, target)._compute_payment_term_id() + for order in target.filtered("type_id"): + type_value = order.type_id.payment_term_id + order.payment_term_id = order._sot_resolve( + type_value, order.payment_term_id, "payment_term_id" + ) + order._sot_stamp_cascade_if_available( + "payment_term_id", order.payment_term_id, type_value + ) return res @api.depends("type_id") def _compute_pricelist_id(self): - res = super()._compute_pricelist_id() - for order in self.filtered("type_id"): - order_type = order.type_id - if order_type.pricelist_id: - order.pricelist_id = order_type.pricelist_id + preserved = self._sot_preserve_user_set("pricelist_id") + target = self - preserved + res = super(SaleOrder, target)._compute_pricelist_id() + for order in target.filtered("type_id"): + type_value = order.type_id.pricelist_id + order.pricelist_id = order._sot_resolve( + type_value, order.pricelist_id, "pricelist_id" + ) + order._sot_stamp_cascade_if_available( + "pricelist_id", order.pricelist_id, type_value + ) return res @api.depends("type_id") def _compute_incoterm(self): + preserved = self._sot_preserve_user_set("incoterm") + target = self - preserved res = None - if hasattr(super(), "_compute_incoterm"): - res = super()._compute_incoterm() - for order in self.filtered("type_id"): - order_type = order.type_id - if order_type.incoterm_id: - order.incoterm = order_type.incoterm_id + if hasattr(super(SaleOrder, target), "_compute_incoterm"): + res = super(SaleOrder, target)._compute_incoterm() + for order in target.filtered("type_id"): + type_value = order.type_id.incoterm_id + order.incoterm = order._sot_resolve(type_value, order.incoterm, "incoterm") + order._sot_stamp_cascade_if_available( + "incoterm", order.incoterm, type_value + ) return res @api.depends("type_id") @@ -179,7 +276,15 @@ def write(self, vals): def _prepare_invoice(self): res = super()._prepare_invoice() - if self.type_id.journal_id: + # Journal: + # - `type_first`: legacy — type's journal overrides super's choice. + # - `partner_first` / `partner_only`: leave whatever super chose. + # We don't use `_sot_resolve` here: super() may leave `journal_id` + # implicit (the account.move._get_default_journal logic fills it), + # so a three-way merge would spuriously fall back to the type's + # journal in non-type_first modes. + mode = self.type_id.precedence or "type_first" + if mode == "type_first" and self.type_id.journal_id: res["journal_id"] = self.type_id.journal_id.id if self.type_id: res["sale_type_id"] = self.type_id.id @@ -191,13 +296,42 @@ class SaleOrderLine(models.Model): route_id = fields.Many2one(compute="_compute_route_id", store=True, readonly=False) + def _sot_preserve_user_set(self, fname): + """Line-level mirror of `SaleOrder._sot_preserve_user_set`.""" + if not hasattr(self, "_user_set"): + return self.browse() + return self.filtered(lambda r: r._user_set(fname)) + + def _sot_stamp_cascade_if_available(self, fname, value, type_value): + """Mirror of `SaleOrder._sot_stamp_cascade_if_available` for line- + level fields (route_id). Same soft-dependency on + `web_field_provenance` (Huly OCA-23). + """ + if not value or value != type_value: + return + if not hasattr(self, "_stamp_provenance"): + return + try: + self._stamp_provenance( + [fname], + source="r", + by="sot.cascade", + rule="Sale Order Type cascade", + ) + except Exception as exc: + _logger.debug("_stamp_provenance failed for %s: %s", fname, exc) + @api.depends("order_id.type_id") def _compute_route_id(self): + preserved = self._sot_preserve_user_set("route_id") + target = self - preserved res = None - if hasattr(super(), "_compute_route_id"): - res = super()._compute_route_id() - for line in self.filtered("order_id.type_id"): - order_type = line.order_id.type_id - if order_type.route_id: - line.route_id = order_type.route_id + if hasattr(super(SaleOrderLine, target), "_compute_route_id"): + res = super(SaleOrderLine, target)._compute_route_id() + for line in target.filtered("order_id.type_id"): + type_value = line.order_id.type_id.route_id + line.route_id = line.order_id._sot_resolve( + type_value, line.route_id, "route_id" + ) + line._sot_stamp_cascade_if_available("route_id", line.route_id, type_value) return res diff --git a/sale_order_type/models/sale_order_type.py b/sale_order_type/models/sale_order_type.py index 4c4e86e6ee8..c2c33fe2ef7 100644 --- a/sale_order_type/models/sale_order_type.py +++ b/sale_order_type/models/sale_order_type.py @@ -59,6 +59,27 @@ class SaleOrderTypology(models.Model): ) active = fields.Boolean(default=True) quotation_validity_days = fields.Integer(string="Quotation Validity (Days)") + precedence = fields.Selection( + [ + ("type_first", "Sale type wins"), + ("partner_first", "Partner wins; type fills gaps"), + ("partner_only", "Ignore type for propagation"), + ], + required=True, + default=lambda self: ( + self.env.company.sale_order_type_default_precedence or "type_first" + ), + help="How this type resolves conflicts between its own pricelist / " + "payment_term / warehouse / etc. and the partner's defaults when a " + "sale order is created.\n" + "- Sale type wins: legacy behavior. The type's value overrides the " + "partner's whenever both are set.\n" + "- Partner wins; type fills gaps: the partner's default is used; the " + "type only fills fields the partner leaves empty.\n" + "- Ignore type for propagation: fields on this type are not pushed " + "onto sale orders at all; the type is then a labeling / reporting " + "construct only.", + ) @api.model def _get_domain_sequence_id(self): diff --git a/sale_order_type/readme/CONFIGURE.md b/sale_order_type/readme/CONFIGURE.md index ffd7a4bd0b2..28690469ba3 100644 --- a/sale_order_type/readme/CONFIGURE.md +++ b/sale_order_type/readme/CONFIGURE.md @@ -1,4 +1,21 @@ To configure Sale Order Types you need to: 1. Go to **Sales \> Configuration \> Sales Orders Types** -2. Create a new sale order type with all the settings you want +2. Create a new sale order type with all the settings you want. +3. *(Optional, developer mode only)* on each type, set the *Precedence* + field to control how its own pricelist / payment term / warehouse / + etc. interact with the partner's defaults when a sales order is + created: + + - `Sale type wins` — legacy behavior; the type overrides whatever + the partner derives. + - `Partner wins; type fills gaps` — the partner's value is used; + the type only fills fields the partner leaves empty. + - `Ignore type for propagation` — the type's fields are never + applied to new sales orders; the type acts as a label only. + + The default for newly-created types is set in *Settings \> Sales \> + Quotations & Orders \> Default precedence for new Sale Order + Types*. Existing types keep their current value when this default + changes — upgrading the module preserves legacy behavior + (`Sale type wins`) for any type that hasn't been touched. diff --git a/sale_order_type/readme/DESCRIPTION.md b/sale_order_type/readme/DESCRIPTION.md index a57daa01e42..23efc4f912e 100644 --- a/sale_order_type/readme/DESCRIPTION.md +++ b/sale_order_type/readme/DESCRIPTION.md @@ -7,3 +7,48 @@ You can see sale types as lines of business. You are able to select a sales order type by partner so that when you add a partner to a sales order it will get the related info to it. + +Additionally, it adds a warning message to notify users when there is a mismatch between the partner's default pricelist +and the effective pricelist set by the sales order type. The warning text adapts to the type's configured precedence mode +(see below) so that the user knows which value will actually be applied on new sales orders. +The warning is only visible for companies without a parent and when there is a mismatch between the two pricelists. + + + +### Precedence modes (per sale.order.type) + +Each `sale.order.type` declares how its propagated fields interact with the partner's defaults. The setting is exposed +in developer mode on the type form (Sales > Configuration > Sales Order Types > *type* > Precedence) and applies +uniformly to the pricelist, payment term, warehouse, shipping policy, incoterm, route and invoice journal. + +- **Sale type wins** (`type_first`, default): the type's value overrides whatever the partner derives. This is the + legacy behavior that has existed in this module since it was first published. +- **Partner wins; type fills gaps** (`partner_first`): the partner's value is used; the type's value only applies + when the partner has no value for that field. Use this for customers who maintain authoritative partner-level + defaults and treat sale order types primarily as labeling / reporting / sequence-numbering devices. +- **Ignore type for propagation** (`partner_only`): the type's pricelist / payment term / warehouse etc. are never + pushed onto the sales order. The type record remains useful for grouping, filtering, sequences and the per-type + invoice journal logic, but its other fields are decorative. Use this when you want a sale order type purely as a + reporting axis. + +A company-wide default is configurable in *Settings → Sales* and is applied when new types are created. Changing the +default does not modify existing types; their precedence is preserved. + +Manual edits on a sale order are preserved across recomputes by trigger +narrowing: once a user sets the pricelist (or payment term, etc.) +directly on a SO, the value will not be overwritten unless `type_id` or +`partner_id` actually changes. A `type_id` change *does* re-fire the +type's value in `type_first` mode — this is the documented legacy +behavior and matches user expectations for an explicit type change. + +For stricter dirty-state preservation (preserve user edits even on a +type change, with a Salesforce/SAP-style badge to surface "this value +is anchored by the user"), install the companion module +`web_field_provenance` when it's available +(see Huly OCA-23 / ledoent initiative). This module's +`record._user_set(fname)` API is consulted automatically by +`sale.order._sot_resolve` — when installed and the user has manually +set a field, the precedence rules are bypassed and the manual value +wins regardless of mode. The integration is a soft dependency: this +module works standalone and behaves identically when +`web_field_provenance` is not installed. diff --git a/sale_order_type/readme/USAGE.md b/sale_order_type/readme/USAGE.md index 94f740421e5..63f85d8be6b 100644 --- a/sale_order_type/readme/USAGE.md +++ b/sale_order_type/readme/USAGE.md @@ -1,5 +1,9 @@ 1. Go to **Sales \> Sales Orders** and create a new sale order. Select the new type you have created before and all settings will be - propagated. + propagated. How they're propagated depends on the type's *Precedence* + setting (see CONFIGURE). 2. You can also define a type for a particular partner if you go to *Sales & Purchases* and set a sale order type. +3. When the type's precedence differs from the legacy `Sale type wins` + mode, a small caption appears under the SO's pricelist explaining + what behavior to expect. diff --git a/sale_order_type/static/description/index.html b/sale_order_type/static/description/index.html index 69ddf450bff..6fbc58fdd0e 100644 --- a/sale_order_type/static/description/index.html +++ b/sale_order_type/static/description/index.html @@ -3,7 +3,7 @@
-This module adds a typology for the sales orders. In each different type, you can define, invoicing and refunding journal, a warehouse, a stock route, a sequence, the shipping policy, the invoicing policy, a @@ -382,6 +377,56 @@
You can see sale types as lines of business.
You are able to select a sales order type by partner so that when you add a partner to a sales order it will get the related info to it.
+Additionally, it adds a warning message to notify users when there is a +mismatch between the partner’s default pricelist and the effective +pricelist set by the sales order type. The warning text adapts to the +type’s configured precedence mode (see below) so that the user knows +which value will actually be applied on new sales orders. The warning is +only visible for companies without a parent and when there is a mismatch +between the two pricelists.
+
Each sale.order.type declares how its propagated fields interact +with the partner’s defaults. The setting is exposed in developer mode on +the type form (Sales > Configuration > Sales Order Types > type > +Precedence) and applies uniformly to the pricelist, payment term, +warehouse, shipping policy, incoterm, route and invoice journal.
+A company-wide default is configurable in Settings → Sales and is +applied when new types are created. Changing the default does not modify +existing types; their precedence is preserved.
+Manual edits on a sale order are preserved across recomputes by trigger +narrowing: once a user sets the pricelist (or payment term, etc.) +directly on a SO, the value will not be overwritten unless type_id +or partner_id actually changes. A type_id change does re-fire +the type’s value in type_first mode — this is the documented legacy +behavior and matches user expectations for an explicit type change.
+For stricter dirty-state preservation (preserve user edits even on a +type change, with a Salesforce/SAP-style badge to surface “this value is +anchored by the user”), install the companion module +web_field_provenance when it’s available (see Huly OCA-23 / ledoent +initiative). This module’s record._user_set(fname) API is consulted +automatically by sale.order._sot_resolve — when installed and the +user has manually set a field, the precedence rules are bypassed and the +manual value wins regardless of mode. The integration is a soft +dependency: this module works standalone and behaves identically when +web_field_provenance is not installed.
Table of contents
To configure Sale Order Types you need to:
-Go to Sales > Configuration > Sales Orders Types
+Create a new sale order type with all the settings you want.
+(Optional, developer mode only) on each type, set the Precedence +field to control how its own pricelist / payment term / warehouse / +etc. interact with the partner’s defaults when a sales order is +created:
+The default for newly-created types is set in Settings > Sales > +Quotations & Orders > Default precedence for new Sale Order Types. +Existing types keep their current value when this default changes — +upgrading the module preserves legacy behavior (Sale type wins) +for any type that hasn’t been touched.
+
+ When creating a Sales Order, above pricelist will be replaced by the one set on the sales order type:
+
+ The above partner pricelist will be used. The sale order type also defines a pricelist (
+ The sale order type for this partner is configured to ignore its own fields when creating sales orders. Its pricelist (