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. + +![Pricelist Conflict Warning Note](../static/description/pricelist_conflict_warning_note.png) + +### 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 @@ -README.rst +Sale Order Type -
+
+

Sale Order Type

- - -Odoo Community Association - -
-

Sale Order Type

-

Beta License: AGPL-3 OCA/sale-workflow Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/sale-workflow Translate me on Weblate Try me on Runboat

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 @@

Sale Order Type

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.

Table of contents

    @@ -399,9 +444,29 @@

    Sale Order Type

    Configuration

    To configure Sale Order Types you need to:

    -
      -
    1. Go to Sales > Configuration > Sales Orders Types
    2. -
    3. Create a new sale order type with all the settings you want
    4. +
        +
      1. Go to Sales > Configuration > Sales Orders Types

        +
      2. +
      3. Create a new sale order type with all the settings you want.

        +
      4. +
      5. (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.

        +
    @@ -409,9 +474,13 @@

    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.
    2. +propagated. How they’re propagated depends on the type’s Precedence +setting (see CONFIGURE).
    3. You can also define a type for a particular partner if you go to Sales & Purchases and set a sale order type.
    4. +
    5. 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/pricelist_conflict_warning_note.png b/sale_order_type/static/description/pricelist_conflict_warning_note.png new file mode 100644 index 00000000000..57dfd271e55 Binary files /dev/null and b/sale_order_type/static/description/pricelist_conflict_warning_note.png differ diff --git a/sale_order_type/tests/test_sale_order_type.py b/sale_order_type/tests/test_sale_order_type.py index 9a5a9584959..ababa899ff8 100644 --- a/sale_order_type/tests/test_sale_order_type.py +++ b/sale_order_type/tests/test_sale_order_type.py @@ -5,7 +5,7 @@ from freezegun import freeze_time from odoo import fields -from odoo.tests import Form +from odoo.tests import Form, tagged from odoo.addons.base.tests.common import BaseCommon @@ -273,6 +273,26 @@ def test_res_partner_copy_data(self): new_partner = self.partner.copy() self.assertEqual(self.partner.sale_type, new_partner.sale_type) + def test_effective_pricelist_id_with_pricelist(self): + """effective_pricelist_id resolves to sale_type.pricelist_id when set.""" + self.partner.sale_type = self.sale_type + self.assertEqual( + self.partner.effective_pricelist_id, self.sale_type.pricelist_id + ) + + def test_effective_pricelist_id_without_pricelist(self): + """effective_pricelist_id is empty when sale_type has no pricelist set.""" + sale_type_no_pricelist = self.sale_type_model.create( + {"name": "Type without pricelist"} + ) + self.partner.sale_type = sale_type_no_pricelist + self.assertFalse(self.partner.effective_pricelist_id) + + def test_effective_pricelist_id_without_sale_type(self): + """effective_pricelist_id is empty when the partner has no sale_type.""" + self.partner.sale_type = False + self.assertFalse(self.partner.effective_pricelist_id) + def test_sale_order_type_required(self): sale_form = Form(self.env["sale.order"]) sale_form.partner_id = self.partner @@ -341,3 +361,528 @@ def test_credit_note_preserves_sale_type_from_sale_order(self): "not use partner's default sale type " f"(partner has: {sale_order.partner_id.sale_type.name})", ) + + +@tagged("post_install", "-at_install") +class TestPrecedence(BaseCommon): + """Cover the three precedence modes on `sale.order.type`. + + Strategy: unit-test the resolver helper directly with synthetic + inputs, then a handful of full-flow integration tests for the + cases we can drive without fighting Odoo's per-company property + field plumbing. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create( + { + "name": "Precedence Test Partner", + "is_company": True, + "country_id": cls.env.ref("base.us").id, + } + ) + cls.pl_partner = cls.env["product.pricelist"].create( + {"name": "Partner Pricelist", "company_id": False} + ) + cls.pl_type = cls.env["product.pricelist"].create( + {"name": "Type Pricelist", "company_id": False} + ) + cls.pt_type = cls.env["account.payment.term"].create( + {"name": "Type Payment Term"} + ) + cls.wh_type = cls.env["stock.warehouse"].create( + {"name": "Type Warehouse", "code": "TYP"} + ) + cls.inc_type = cls.env.ref("account.incoterm_EXW") + cls.route_type = cls.env["stock.route"].create( + {"name": "Type Route", "sale_selectable": True} + ) + cls.journal_type = cls.env["account.journal"].create( + {"name": "Type Journal", "type": "sale", "code": "TYPJ"} + ) + + cls.type_full = cls.env["sale.order.type"].create( + { + "name": "Full Type", + "pricelist_id": cls.pl_type.id, + "payment_term_id": cls.pt_type.id, + "warehouse_id": cls.wh_type.id, + "picking_policy": "one", + "incoterm_id": cls.inc_type.id, + "route_id": cls.route_type.id, + "journal_id": cls.journal_type.id, + } + ) + cls.product = cls.env["product.product"].create( + {"type": "service", "invoice_policy": "order", "name": "P"} + ) + + def _set_mode(self, mode): + self.type_full.precedence = mode + + def _new_so(self): + f = Form(self.env["sale.order"]) + f.partner_id = self.partner + f.type_id = self.type_full + with f.order_line.new() as line: + line.product_id = self.product + line.product_uom_qty = 1.0 + return f.save() + + # ----- _sot_resolve helper (unit) ---------------------------------- + + def test_sot_resolve_type_first_picks_type(self): + self._set_mode("type_first") + order = self.env["sale.order"].new({"type_id": self.type_full.id}) + self.assertEqual(order._sot_resolve("type-val", "current-val"), "type-val") + + def test_sot_resolve_type_first_falls_back_to_current(self): + """type_first: when type is empty, keep super's value.""" + self._set_mode("type_first") + order = self.env["sale.order"].new({"type_id": self.type_full.id}) + self.assertEqual(order._sot_resolve(False, "current-val"), "current-val") + + def test_sot_resolve_partner_first_picks_current(self): + self._set_mode("partner_first") + order = self.env["sale.order"].new({"type_id": self.type_full.id}) + self.assertEqual(order._sot_resolve("type-val", "current-val"), "current-val") + + def test_sot_resolve_partner_first_fills_gap_with_type(self): + self._set_mode("partner_first") + order = self.env["sale.order"].new({"type_id": self.type_full.id}) + self.assertEqual(order._sot_resolve("type-val", False), "type-val") + + def test_sot_resolve_partner_only_ignores_type(self): + self._set_mode("partner_only") + order = self.env["sale.order"].new({"type_id": self.type_full.id}) + self.assertEqual(order._sot_resolve("type-val", "current-val"), "current-val") + + def test_sot_resolve_partner_only_leaves_empty(self): + self._set_mode("partner_only") + order = self.env["sale.order"].new({"type_id": self.type_full.id}) + self.assertFalse(order._sot_resolve("type-val", False)) + + # ----- integration: type_first end-to-end --------------------------- + + def test_type_first_pricelist(self): + self._set_mode("type_first") + order = self._new_so() + self.assertEqual(order.pricelist_id, self.pl_type) + + def test_type_first_payment_term(self): + self._set_mode("type_first") + order = self._new_so() + self.assertEqual(order.payment_term_id, self.pt_type) + + def test_type_first_warehouse(self): + self._set_mode("type_first") + order = self._new_so() + self.assertEqual(order.warehouse_id, self.wh_type) + + def test_type_first_picking_policy(self): + self._set_mode("type_first") + order = self._new_so() + self.assertEqual(order.picking_policy, "one") + + def test_type_first_incoterm(self): + self._set_mode("type_first") + order = self._new_so() + self.assertEqual(order.incoterm, self.inc_type) + + def test_type_first_route(self): + self._set_mode("type_first") + order = self._new_so() + self.assertEqual(order.order_line.route_id, self.route_type) + + def test_type_first_invoice_journal(self): + self._set_mode("type_first") + order = self._new_so() + self.assertEqual(order._prepare_invoice()["journal_id"], self.journal_type.id) + + # ----- integration: partner_only end-to-end ------------------------- + # (No need for partner.property_product_pricelist setup — the test + # asserts that the type's value is NOT propagated regardless of + # partner state.) + + def test_partner_only_no_invoice_journal_override(self): + self._set_mode("partner_only") + order = self._new_so() + self.assertNotEqual( + order._prepare_invoice().get("journal_id"), self.journal_type.id + ) + + def test_partner_only_warehouse_not_propagated(self): + self._set_mode("partner_only") + order = self._new_so() + # Whatever warehouse super() chose, it should not be the type's. + self.assertNotEqual(order.warehouse_id, self.wh_type) + + def test_partner_only_picking_policy_not_propagated(self): + self._set_mode("partner_only") + order = self._new_so() + # Default picking_policy is 'direct'; type carries 'one'. + self.assertEqual(order.picking_policy, "direct") + + def test_partner_only_incoterm_not_propagated(self): + self._set_mode("partner_only") + order = self._new_so() + self.assertFalse(order.incoterm) + + def test_partner_only_route_not_propagated(self): + self._set_mode("partner_only") + order = self._new_so() + self.assertFalse(order.order_line.route_id) + + # ----- cross-cutting ------------------------------------------------ + + def test_per_type_precedence_independent(self): + """Two types with different precedence yield different SO results. + Asserts on warehouse_id which is reliably propagated without + needing partner-property setup. + """ + other_type = self.type_full.copy( + {"name": "Partner-only Twin", "precedence": "partner_only"} + ) + self.type_full.precedence = "type_first" + + f1 = Form(self.env["sale.order"]) + f1.partner_id = self.partner + f1.type_id = self.type_full + with f1.order_line.new() as line: + line.product_id = self.product + so1 = f1.save() + self.assertEqual(so1.warehouse_id, self.wh_type) + + f2 = Form(self.env["sale.order"]) + f2.partner_id = self.partner + f2.type_id = other_type + with f2.order_line.new() as line: + line.product_id = self.product + so2 = f2.save() + self.assertNotEqual(so2.warehouse_id, self.wh_type) + + def test_new_type_inherits_company_default(self): + self.env.company.sale_order_type_default_precedence = "partner_first" + new_type = self.env["sale.order.type"].create({"name": "Inheritor"}) + self.assertEqual(new_type.precedence, "partner_first") + # Reset for downstream tests. + self.env.company.sale_order_type_default_precedence = "type_first" + + def test_existing_default_type_is_type_first(self): + """The seed `default_type` ships with type_first to preserve behavior.""" + default_type = self.env.ref( + "sale_order_type.default_type", raise_if_not_found=False + ) + if default_type: + self.assertEqual(default_type.precedence, "type_first") + + def test_manual_pricelist_edit_preserved_after_unrelated_write(self): + self._set_mode("type_first") + order = self._new_so() + order.pricelist_id = self.pl_partner + order.note = "Some unrelated edit" + self.assertEqual(order.pricelist_id, self.pl_partner) + + def test_manual_pricelist_edit_overridden_on_type_change(self): + self._set_mode("type_first") + order = self._new_so() + order.pricelist_id = self.pl_partner + other_type = self.type_full.copy( + {"name": "Trigger", "precedence": "type_first"} + ) + order.type_id = other_type + self.assertEqual(order.pricelist_id, self.pl_type) + + # ----- effective_* related fields on res.partner ------------------- + + def test_effective_pricelist_id_related(self): + self.partner.sale_type = self.type_full + self.assertEqual(self.partner.effective_pricelist_id, self.pl_type) + + def test_effective_payment_term_id_related(self): + self.partner.sale_type = self.type_full + self.assertEqual(self.partner.effective_payment_term_id, self.pt_type) + + def test_effective_warehouse_id_related(self): + self.partner.sale_type = self.type_full + self.assertEqual(self.partner.effective_warehouse_id, self.wh_type) + + def test_effective_incoterm_id_related(self): + self.partner.sale_type = self.type_full + self.assertEqual(self.partner.effective_incoterm_id, self.inc_type) + + def test_effective_route_id_related(self): + self.partner.sale_type = self.type_full + self.assertEqual(self.partner.effective_route_id, self.route_type) + + def test_effective_journal_id_related(self): + self.partner.sale_type = self.type_full + self.assertEqual(self.partner.effective_journal_id, self.journal_type) + + def test_effective_precedence_related(self): + self.partner.sale_type = self.type_full + self.type_full.precedence = "partner_first" + self.assertEqual(self.partner.effective_precedence, "partner_first") + + +@tagged("post_install", "-at_install") +class TestPrecedenceWithProvenance(BaseCommon): + """Integration tests for the soft dependency on `web_field_provenance` + (Huly OCA-23). Skipped automatically when that module is not + installed in the current registry — proves the soft-dep guard works. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.has_provenance = hasattr(cls.env["sale.order"], "_stamp_provenance") + if not cls.has_provenance: + # Not skipping the whole class — the no-op assertion below + # is itself a useful test (proves the wiring doesn't crash + # when the dep is absent). + return + # Opt-in the propagated fields. Base-field protection blocks + # `ir.model.fields.write()` on Python-declared fields, so use + # the documented SQL bypass (same as web_field_provenance's + # own tests). + for fname in ("pricelist_id", "payment_term_id", "warehouse_id"): + cls.env.cr.execute( + "UPDATE ir_model_fields SET track_provenance = TRUE " + "WHERE model = %s AND name = %s", + ("sale.order", fname), + ) + cls.env.registry.clear_cache() + + cls.partner = cls.env["res.partner"].create( + { + "name": "Provenance Cascade Probe", + "is_company": True, + "country_id": cls.env.ref("base.us").id, + } + ) + cls.pl_type = cls.env["product.pricelist"].create( + {"name": "Cascade Pricelist", "company_id": False} + ) + cls.pt_type = cls.env["account.payment.term"].create( + {"name": "Cascade Payment Term"} + ) + cls.wh_type = cls.env["stock.warehouse"].create( + {"name": "Cascade Warehouse", "code": "CWH"} + ) + cls.type_full = cls.env["sale.order.type"].create( + { + "name": "Cascade Type", + "pricelist_id": cls.pl_type.id, + "payment_term_id": cls.pt_type.id, + "warehouse_id": cls.wh_type.id, + "precedence": "type_first", + } + ) + cls.product = cls.env["product.product"].create( + {"type": "service", "invoice_policy": "order", "name": "P-cascade"} + ) + # Anchor the partner's sale_type so _compute_sale_type_id resolves + # to our test type on create — its precompute fires unconditionally + # and would otherwise overwrite an explicit type_id in vals. + cls.partner.sale_type = cls.type_full + + def _new_so(self, type_id=None): + """Create a persistent SO without going through Form(). + + Form() uses NewId records, and `_provenance` modifications + inside the resulting onchange cycle leak into the diff and + break form views that don't declare the field (most don't — + the badge consumes _provenance via web_read only). Bypassing + Form() lets the cascade computes run on a persistent record + where the SQL-bypass stamping behaves correctly. + """ + order = self.env["sale.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": self.product.id, + "product_uom_qty": 1.0, + }, + ) + ], + } + ) + # `_compute_sale_type_id` runs on create and resolves from + # partner.sale_type. If the caller passed a different type, set + # it explicitly afterwards — that write re-fires the cascade + # computes on the persistent record. + if type_id is not None and type_id != self.type_full: + order.type_id = type_id + self.env.flush_all() + order.invalidate_recordset(["_provenance"]) + return order + + def test_wiring_safe_without_provenance_module(self): + """The cascade-stamp helper must be a silent no-op when + web_field_provenance is not in the registry. This test runs + in both flavors of CI (with and without the module).""" + if self.has_provenance: + self.skipTest("web_field_provenance is installed; covered elsewhere") + order = self._new_so() if False else None # noqa + # If we got here without web_field_provenance, the model has no + # `_stamp_provenance` attribute and `_sot_stamp_cascade_if_available` + # short-circuits on the `hasattr` check. Verify the attribute + # really isn't there. + self.assertFalse( + hasattr(self.env["sale.order"], "_stamp_provenance"), + "Test sanity: provenance module should NOT be present here", + ) + + def test_cascade_stamps_pricelist_provenance(self): + """When `type_first` propagates the type's pricelist via a + write on a persistent record, the OWL badge should attribute + it to sot.cascade. + + Note: cascade stamping during `create()` precomputes is + skipped at the base layer (NewId records can't be SQL-updated; + Form-onchange paths trip view-spec KeyErrors). Cascade + attribution becomes accurate once the user first touches the + record on a persistent path — which is the common case for + propagated-field UX. + """ + if not self.has_provenance: + self.skipTest("requires web_field_provenance") + order = self._new_so() + # Force a persistent-record cascade by re-writing type_id — + # this re-fires `_compute_pricelist_id` with `self.id` as int, + # so `_stamp_provenance_keys` can persist the entry. + other_type = self.type_full.copy( + {"name": "Persistent Cascade", "precedence": "type_first"} + ) + order.type_id = other_type + self.env.flush_all() + order.invalidate_recordset(["_provenance"]) + entry = (order._provenance or {}).get("pricelist_id") + self.assertIsNotNone( + entry, "Cascade-set pricelist must be stamped in _provenance" + ) + self.assertEqual(entry["s"], "r") + self.assertEqual(entry["b"], "sot.cascade") + self.assertEqual(entry["r"], "Sale Order Type cascade") + + def test_cascade_stamps_all_propagated_fields(self): + """payment_term_id and warehouse_id should be attributed too — + on persistent-record writes (see note on the pricelist test).""" + if not self.has_provenance: + self.skipTest("requires web_field_provenance") + order = self._new_so() + other_type = self.type_full.copy( + {"name": "Persistent All", "precedence": "type_first"} + ) + order.type_id = other_type + self.env.flush_all() + order.invalidate_recordset(["_provenance"]) + prov = order._provenance or {} + for fname in ("pricelist_id", "payment_term_id", "warehouse_id"): + self.assertIn(fname, prov, f"{fname} not stamped by cascade") + self.assertEqual(prov[fname]["s"], "r") + self.assertEqual(prov[fname]["b"], "sot.cascade") + + def test_user_anchor_preserves_across_type_change(self): + """The dirty-bit integration: once user anchors a field, a + subsequent type change does NOT overwrite it.""" + if not self.has_provenance: + self.skipTest("requires web_field_provenance") + order = self._new_so() + # User manually sets a new pricelist — overrides the cascade. + user_pl = self.env["product.pricelist"].create( + {"name": "User Choice", "company_id": False} + ) + order.pricelist_id = user_pl + # Verify the user stamping took effect. + self.assertTrue(order._user_set("pricelist_id")) + # Now flip to a different type that would normally cascade + # a different pricelist. + other_type = self.type_full.copy( + { + "name": "Other Type", + "pricelist_id": self.pl_type.id, + "precedence": "type_first", + } + ) + order.type_id = other_type + # The user's pricelist must be preserved. + self.assertEqual( + order.pricelist_id, + user_pl, + "User-anchored pricelist must survive a type change when " + "web_field_provenance is installed", + ) + + +class TestPrecedenceMultiCompany(BaseCommon): + """Multi-company semantics for the precedence feature: per-company + default for new types, per-type independence across companies.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company_a = cls.env.ref("base.main_company") + cls.company_b = cls.env["res.company"].create({"name": "Precedence Test Co B"}) + + def test_company_default_applied_to_new_type(self): + """Setting `sale_order_type_default_precedence` on a company + flows into newly-created types as their default.""" + # Set differing defaults per company. + self.company_a.sale_order_type_default_precedence = "type_first" + self.company_b.sale_order_type_default_precedence = "partner_first" + + type_a = ( + self.env["sale.order.type"] + .with_company(self.company_a) + .create({"name": "A-default-type"}) + ) + type_b = ( + self.env["sale.order.type"] + .with_company(self.company_b) + .create({"name": "B-default-type"}) + ) + + self.assertEqual(type_a.precedence, "type_first") + self.assertEqual(type_b.precedence, "partner_first") + + def test_existing_types_unchanged_when_company_default_flips(self): + """Toggling the company default must NOT mutate existing types.""" + original = self.env["sale.order.type"].create( + {"name": "Pre-existing", "precedence": "partner_only"} + ) + self.env.company.sale_order_type_default_precedence = "type_first" + original.invalidate_recordset(["precedence"]) + self.assertEqual( + original.precedence, + "partner_only", + "Existing types must keep their own precedence regardless " + "of company default changes", + ) + + def test_per_type_precedence_independent_across_companies(self): + """A 'Wholesale-strict' (type_first) in company A and a + 'Standard' (partner_first) in company B should resolve their + own modes regardless of which company owns them.""" + type_a = ( + self.env["sale.order.type"] + .with_company(self.company_a) + .create({"name": "Wholesale-strict", "precedence": "type_first"}) + ) + type_b = ( + self.env["sale.order.type"] + .with_company(self.company_b) + .create({"name": "Standard", "precedence": "partner_first"}) + ) + + self.assertEqual(type_a.precedence, "type_first") + self.assertEqual(type_b.precedence, "partner_first") + # And the orthogonal axis — `company_id` is per-type: + self.assertEqual(type_a.company_id, self.company_a) + self.assertEqual(type_b.company_id, self.company_b) diff --git a/sale_order_type/views/res_config_settings.xml b/sale_order_type/views/res_config_settings.xml index 76734bdbd4e..26da693c481 100644 --- a/sale_order_type/views/res_config_settings.xml +++ b/sale_order_type/views/res_config_settings.xml @@ -14,6 +14,14 @@ > + + + diff --git a/sale_order_type/views/res_partner_view.xml b/sale_order_type/views/res_partner_view.xml index ead6d8fa6d6..30c4cb93e4d 100644 --- a/sale_order_type/views/res_partner_view.xml +++ b/sale_order_type/views/res_partner_view.xml @@ -3,10 +3,67 @@ res.partner.sale_type.form res.partner + + + + + + + + + + + diff --git a/sale_order_type/views/sale_order_type_view.xml b/sale_order_type/views/sale_order_type_view.xml index 7b494a1afe8..fce8a6a5b33 100644 --- a/sale_order_type/views/sale_order_type_view.xml +++ b/sale_order_type/views/sale_order_type_view.xml @@ -49,6 +49,7 @@ groups="product.group_product_pricelist" /> + diff --git a/sale_order_type/views/sale_order_view.xml b/sale_order_type/views/sale_order_view.xml index f8f6835918a..d709546f475 100644 --- a/sale_order_type/views/sale_order_view.xml +++ b/sale_order_type/views/sale_order_view.xml @@ -11,6 +11,20 @@ required="order_type_required" readonly="state in ['sale', 'cancel'] or locked" /> + +
    + + Pricelist / payment term / warehouse from the partner take precedence over this type's values; the type only fills gaps. + + + This type's pricelist / payment term / warehouse are ignored when computing sales-order fields — the type is a labeling construct only. + +