# OLI and QLI Standard Line Item Design

Status: draft, round 2
Date: 2026-05-21
Scope: PingCAP downstream pack behavior for `OpportunityLineItem`,
`QuoteLineItem`, `OrderItem`, legacy `Opportunity_Product__c`, and legacy
`Quote_Product__c`.

## Executive Summary

PingCAP NexusCRM should make the standard Salesforce line item chain the only
live write path:

```text
Opportunity -> OpportunityLineItem -> QuoteLineItem -> OrderItem
```

The legacy custom product objects stay available only for migration,
reconciliation, and old-record history:

```text
Opportunity_Product__c -> migration/read-only compatibility
Quote_Product__c       -> migration/read-only compatibility
Order_Product__c       -> migration/read-only compatibility
```

The second design pass confirms four important updates from the issue review:

1. The OLI "two object" question is already resolved. PR #1586 collapsed the
   legacy dual-object shape into a single standard `OpportunityLineItem` with a
   twin-field Net Fees model.
2. The PM decision in #1608 is C1b: `Opportunity.Amount` must remain the ACV
   source of truth for forecasts, dashboards, and reports. The pack should roll
   `Amount` from `OpportunityLineItem.Net_fees__c`, not from standard gross
   `TotalPrice`.
3. Create-from-source behavior must follow NexusCRM #979: Flow-owned action
   orchestration, `_System_ObjectFieldMapping` for flat copies, explicit Flow
   assignments for dynamic values, and atomic child fan-out. Direct persistence
   should not grow object-specific clone branches.
4. The QLI and OI sides have not yet caught up to OLI. `QuoteLineItem` lacks
   PingCAP business fields, `Order` still rolls up from `Order_Product__c`, and
   Create Order currently has parent-action metadata but no QLI to OI fan-out.

## Source References

Primary live issues and stories:

- [#744](https://github.com/pingcap-inc/nexuscrm-pingcap-pack/issues/744) -
  Sales rep can manage Products on an Opportunity.
- [#772](https://github.com/pingcap-inc/nexuscrm-pingcap-pack/issues/772) -
  Sales rep can create a Quote from an Opportunity.
- [#773](https://github.com/pingcap-inc/nexuscrm-pingcap-pack/issues/773) -
  Sales rep can view Quote Line Items with full pricing and readable
  references.
- [#172](https://github.com/pingcap-inc/nexuscrm-pingcap-pack/issues/172) -
  Deal manager can create an Order from a Quote.
- [#273](https://github.com/pingcap-inc/nexuscrm-pingcap-pack/issues/273) -
  implicit CRUD child objects including OLI, QLI, OI.
- [#1244](https://github.com/pingcap-inc/nexuscrm-pingcap-pack/issues/1244) -
  OLI `TotalPrice` and Opportunity amount rollup.
- [#1604](https://github.com/pingcap-inc/nexuscrm-pingcap-pack/issues/1604) -
  QLI rows not cloned into OI rows.
- [#1605](https://github.com/pingcap-inc/nexuscrm-pingcap-pack/issues/1605) -
  QLI `Net_Fees__c` missing after Quote clone.
- [#1606](https://github.com/pingcap-inc/nexuscrm-pingcap-pack/issues/1606) -
  direct API Quote insert does not clone OLI rows.
- [#1608](https://github.com/pingcap-inc/nexuscrm-pingcap-pack/issues/1608) -
  OLI editing model RFC and C1b decision.
- [#1520](https://github.com/pingcap-inc/nexuscrm-pingcap-pack/issues/1520) -
  Salesforce import roadmap and migration loader status.

Design and implementation authorities:

- [NexusCRM #979](https://github.com/pingcap-inc/NexusCRM/issues/979) -
  Flow-owned copy and dynamic inherit on create.
- [NexusCRM #828](https://github.com/pingcap-inc/NexusCRM/issues/828) and
  [PR #856](https://github.com/pingcap-inc/NexusCRM/pull/856) - computed child
  rollup substrate for OLI total and Opportunity amount.
- [pingcap-pack PR #1411](https://github.com/pingcap-inc/nexuscrm-pingcap-pack/pull/1411) -
  preserve computed `OpportunityLineItem.TotalPrice` in downstream final pack.
- [pingcap-pack PR #1550](https://github.com/pingcap-inc/nexuscrm-pingcap-pack/pull/1550) -
  flow-backed Create Quote and OLI to QLI clone wiring.
- [pingcap-pack PR #1586](https://github.com/pingcap-inc/nexuscrm-pingcap-pack/pull/1586) -
  OLI Net Fees fields, autocalc flows, and Opportunity Net Fees rollups.
- [pingcap-pack PR #1599](https://github.com/pingcap-inc/nexuscrm-pingcap-pack/pull/1599) -
  currency-scoped PBE lookup plus Product2 and UnitPrice autofill.
- [nexuscrm-app-packs PR #31](https://github.com/pingcap-inc/nexuscrm-app-packs/pull/31) -
  complete Create Quote metadata.
- [nexuscrm-app-packs PR #33](https://github.com/pingcap-inc/nexuscrm-app-packs/pull/33) -
  flow-backed Create Order parent action.

Regression authority:

- [#1589](https://github.com/pingcap-inc/nexuscrm-pingcap-pack/issues/1589) -
  R13 cross-report rollup. This is the latest high-signal evidence for QLI
  and OI gaps.

Legacy mapping authorities:

- `docs/epics/0026-opportunity-bugs-and-uat.md` lists legacy OLI issues
  #1533, #1534, #1714, and #2035 through #2040.
- `docs/epics/0030-quote-bugs-and-uat.md` lists legacy QLI issues #1484
  through #1487, #1624, #1625, and #1985.

Migration authority:

- #1520 is the live migration roadmap. The first closed loader PR #1521 tried
  a supplemental 1:1 business-object load plan, but the standard-line-item
  architecture requires transform ETL for legacy product rows rather than
  permanently splitting historical rows across standard and custom line-item
  tables.

## Round 2 Issue Review

| Issue | Current read | Design impact |
| --- | --- | --- |
| #744 | Parent Opportunity product story remains open. It combines Add/Edit Products UX, OLI form/list metadata, catalog modal, and amount rollup. | Split into OLI financial contract, OLI layout/list, and catalog modal clusters. Do not treat all #744 ACs as one PR. |
| #1244 | Upstream runtime fix #828/#856 and downstream pack residue fix #1411 are merged. The live design still needs #1608 C1b ACV semantics for `Amount`. | `TotalPrice = Quantity * UnitPrice` remains standard gross price. `Amount` should roll from ACV `Net_fees__c`. |
| #1608 | RFC body started exploratory, but comments landed C1b and corrected that twin-field OLI is already shipped by #1586. #1599 later merged Product2/UnitPrice autofill. | Remaining OLI work is not dual-object architecture. It is amount rollup flip, layout/list/action polish, legacy flow rewrites, and report audit. |
| #869 | Requests Product first, PBE/Quantity not user-required, Net Fees in Information, and Opportunity Net Fees rollup. The rollup part is mostly shipped by #1586. | Keep field placement and create-form UX in the OLI layout cluster. Treat PBE/Quantity as "system-resolved or server-defaulted", not simply nullable. |
| #876 | OLI related list currently has too narrow a field set. | Add/update `opportunity-line-items.fields` and OLI listfields. |
| #879 | OLI compact layout/header shows scheduling flags instead of pricing. | Add OLI compact layout or equivalent header rule. |
| #882 | OLI create/edit exposes internal fields. Engine auto-number suppression is separate; pack still needs curated OLI layout. | Add OLI page layout and hide `OpportunityProduct__c`, `Recalculate__c`, and raw/internal owner display. |
| #885 | Pricing fields are buried in Additional Details. | Same layout PR as #869/#882. |
| #888 | Needs Salesforce-style catalog search, inline pricing, and batch save. | Separate product UX/action work. A curated layout does not close this. |
| #891 | OLI list shows auto-number/name instead of Product name. | Add product readable display and list columns; do not mutate auto-number metadata. |
| #772 | Parent Create Quote story is closed, but comments distinguish UI/action success from direct API divergence. | Use #979 standard action flow as source of truth. Direct SObject API either delegates through the action when source context is explicit or rejects unsupported clone semantics. |
| #1036 | Broad zero-QLI tracker is closed for the UI action path after #1550/#31. | Keep as historical context. Active residues are #1605 and #1606. |
| #1606 | Direct `POST /sobjects/Quote` creates no QLIs, while `actions/standard/createQuote` does. | API contract decision required. Avoid hidden object-specific insert hooks unless #979 action-backed create path says so. |
| #1605 | R13 found cloned QLI has no Net Fees field/value and no visible Net Fees label. | Add QLI business fields and mappings or a QLI calculation flow. |
| #773 | R13 made it testable. Pricing columns and Discount now pass; product/readable refs and Net Fees remain. | Narrow active #773 work to #1024 readable references and #1605 Net Fees. |
| #1024 | Canonical readable reference tracker. Prior attempt to remove auto-number was rejected; correct fix is display/layout resolution. | Do not change `Name` auto-number metadata as the primary fix. Prefer Product2 display, source-line display, and list/header configuration. |
| #1021 | Closed as superseded by #1605. | Duplicate Net Fees label is no longer the current symptom; absence/null value is. |
| #1022, #1023 | Closed after R13 evidence: pricing and Discount columns pass. | Do not re-open unless a current build regresses. Keep target column set in design. |
| #1026, #1027 | Earlier reference-display fixes for Quote/Product display. | Keep as evidence that generic lookup hydration exists, but QLI source-line and Name display still need verification. |
| #172 | Parent Order story remains open. app-packs #33 fixed parent Create Order action pending downstream consumption; #1604 tracks missing child fan-out. | Separate parent action from QLI to OI clone and Order rollup migration. |
| #1604 | Order is created but has zero OrderItems. jieni-droid's latest comment narrows the finance schema gap: `Order.Net_Fees__c` still rolls from `Order_Product__c`; `OrderItem` has no `Net_fees__c`; `Order.TCV_Amount__c` and `Order.ACV_Amount__c` depend on that parent rollup. | Implement QLI to OI fan-out, add a single `OrderItem.Net_fees__c`, and flip `Order.Net_Fees__c` to roll from `OrderItem`. Do not copy the OLI twin-field model onto OrderItem. |
| #273 | Original proposed new metadata columns were superseded. Correct fix derives implicit child CRUD from `SharingModel = ControlledByParent` plus master-detail parent. | Upstream permission substrate. Downstream pack should not invent child CRUD metadata. |

## Legacy Issue Mapping

| Legacy issue | Current issue | Cluster |
| --- | --- | --- |
| legacy #1533 | #862 closed, #744 parent | OLI catalog/add products |
| legacy #1534 | #863 closed, #1244 open/current validation | OLI computed total and rollup |
| legacy #1714 | #869 open | OLI form and Net Fees placement |
| legacy #2035 | #876 open | OLI related-list columns |
| legacy #2036 | #879 open | OLI compact layout/header |
| legacy #2037 | #882 open | OLI internal field hiding |
| legacy #2038 | #885 open | OLI pricing field placement |
| legacy #2039 | #888 open | OLI catalog modal and batch save |
| legacy #2040 | #891 open | OLI readable product column |
| legacy #1484 | #1021 closed, #1605 open residue | QLI Net Fees display/value |
| legacy #1485 | #1022 closed | QLI pricing columns |
| legacy #1486 | #1023 closed | QLI Discount column |
| legacy #1487 | #1024 open | QLI readable references |
| legacy #1624 | #1026 closed | QLI Quote reference display |
| legacy #1625 | #1027 closed, #1024 residue | QLI Product/source-line/name display |
| legacy #1985 | #1036 closed, #1605/#1606 residues | Create Quote OLI to QLI clone |

## Fix Clusters

### Cluster A: OLI Financial Contract

Issues: #1244, #863, finance part of #869, #1608, #744 AC7/AC8.

One fix should address:

- `OpportunityLineItem.TotalPrice` remains active formula
  `Quantity * UnitPrice`.
- `OpportunityLineItem.Net_Fees_Total__c` is **deprecated and not consumed on
  this object** (#4838). The Net Fees derivation reads `Unit_Net_Price__c`,
  `Quantity` and `Term_Mounths__c` directly; kept for schema parity only.
- `OpportunityLineItem.Net_fees__c` is read-only, stamped by the OLI flow, and
  holds the **whole-term** value — not an annualized ACV. #4838 DECISION 4
  removed line-level annualization; annualization happens once at the parent.
- `Opportunity.Net_Fees__c`, `Quantity__c`, and `Overall_Start_Date__c`
  roll from OLI. This is already shipped by #1586.
- `Opportunity.Amount` changes from gross `TotalPrice` rollup to ACV
  `Net_fees__c` rollup per #1608 C1b.

Concrete pack delta:

```json
{
  "object": "Opportunity",
  "field": "Amount",
  "rollupConfig": {
    "summaryObject": "OpportunityLineItem",
    "summaryField": "Net_fees__c",
    "relationshipField": "OpportunityId",
    "calcType": "SUM"
  }
}
```

Dependencies:

- #828/#856 and #1411 are merged for computed `TotalPrice` and authoritative
  child rollups.
- #1586 and #1599 are merged for OLI Net Fees and PBE/Product2/UnitPrice
  autofill.

Acceptance:

- Create OLI with Quantity, UnitPrice, Unit Net Price, Term, Service dates, and
  PBE. Readback has Product2Id, UnitPrice, TotalPrice, Net_Fees_Total__c, and
  Net_fees__c.
- Parent Opportunity has `Net_Fees__c == SUM(OLI.Net_fees__c)`.
- Parent Opportunity has `Amount == SUM(OLI.Net_fees__c)`.
- `TotalPrice` remains visible as standard gross price and is not redefined as
  PingCAP ACV.

### Cluster B: OLI Layout, List, and Readable Product UX

Issues: #869, #876, #879, #882, #885, #891, parts of #744. Legacy #1714 and
#2035 through #2040.

One pack/layout fix should address:

- Add a curated `OpportunityLineItem` page layout.
- Put Product/PBE, Quantity, UnitPrice, Unit Net Price, Discount, Term, Net
  Fees, Currency, Service Start, Service End, and Description in the main
  Information/Pricing sections.
- Hide `Name`, `OpportunityProduct__c`, `Recalculate__c`, and other internal
  fields from create/edit.
- Move Owner to a secondary section or render it as a readable user lookup.
- Expand Opportunity related-list fields from current
  `PricebookEntryId/ListPrice/Discount/Quantity` to the business field set.
- Add `OpportunityLineItem` listfields in `ux/standard_listfields.json`.
- Add/update compact layout/header pricing fields.
- Prefer Product display over auto-number/name in list and related-list
  surfaces.

Recommended OLI related/list field order:

```json
[
  "Product2Id",
  "PricebookEntryId",
  "UnitPrice",
  "ListPrice",
  "Discount",
  "Unit_Net_Price__c",
  "Quantity",
  "Term_Mounths__c",
  "Net_Fees_Total__c",
  "Net_fees__c",
  "Service_Start_Date__c",
  "Service_End_Date__c"
]
```

Acceptance:

- New/edit OLI form can be used by a sales rep without exposing legacy bridge
  fields or internal recalculation toggles.
- OLI list and Opportunity related list show product-readable columns and
  pricing, not scheduling flags.
- OLI detail header summarizes pricing and service window.

### Cluster C: OLI Catalog Add/Edit Modal

Issues: #888 and modal/product-picker parts of #744/#862.

This is not closed by Cluster B. It needs a product workflow:

- Search product catalog by product name, product code, and family.
- Resolve active `PricebookEntry` by Opportunity `Pricebook2Id`,
  Opportunity `CurrencyIsoCode`, and selected Product2.
- Allow multi-select with inline pricing edits.
- Save all lines atomically or fail the batch with row-level error detail.

Design constraint:

- The modal can be frontend-rich, but persistence still writes standard OLI
  rows and uses the same OLI before-save flows as direct create/edit.

Acceptance:

- Add Products opens a modal, not a generic single-record full-page form.
- Multiple selected products save in one operation.
- PricebookEntry resolution is deterministic and currency-scoped.

### Cluster D: Create Quote OLI to QLI Contract

Issues: #1605, #1606, #773 active AC2/AC3/AC6, #1024, historical #1036.
Legacy #1484 through #1487, #1624, #1625, #1985.

One combined fix should address:

- `Std_CreateQuoteFromOpportunity` clones all eligible OLIs to QLIs through
  Flow fan-out and `_System_ObjectFieldMapping`.
- The child mapping includes the complete business field set, not only standard
  price fields.
- Direct API behavior is explicitly defined:
  - preferred: action-backed create route/API uses `createQuote` for source
    Opportunity cloning;
  - acceptable alternative: raw `POST /sobjects/Quote` rejects clone-required
    source context with a clear error;
  - not acceptable: raw `POST /sobjects/Quote` silently creates a parent Quote
    with no QLIs when source context indicates cloning.

Current mapping in L2:

```json
[
  "PricebookEntryId",
  "Product2Id",
  "Quantity",
  "UnitPrice",
  "ListPrice",
  "Discount",
  "TotalPrice",
  "Description",
  "ServiceDate"
]
```

Target additional mapping after QLI schema expansion:

```json
[
  "Net_Fees_Total__c",
  "Net_fees__c",
  "Unit_Net_Price__c",
  "Term_Mounths__c -> Term_Months__c",
  "Service_Start_Date__c",
  "Service_End_Date__c",
  "SourceOpportunityLineItemId__c",
  "LegacyOpportunityProductId__c"
]
```

Term field rule:

> **Superseded 2026-08-13 (ACV overhaul, quote-side term derivation).** The
> explicit `Term_Mounths__c -> Term_Months__c` createQuote mapping described
> below has been REMOVED. `QuoteLineItem.Term_Months__c` is now derived, not
> copied: `flows/QuoteLineItem_Term_Derivation.flow.json` (triggerOrder 150)
> computes it from `Service_Start_Date__c` / `Service_End_Date__c` on every
> before-save and clears it when the window is incomplete or non-positive; the
> field is declared read-only and the flow is its single writer. The service
> dates are still copied by the mapping, so a cloned line derives its own term
> from the same window instead of inheriting the opportunity's value (which
> could be the fabricated default 12). See `Quote_Submit_Requires_Complete_Lines`
> for the submit gate that enforces per-line completeness.

- Keep the current OLI field `Term_Mounths__c` until the OLI flows and report
  consumers are renamed safely.
- Keep QLI on the correctly spelled `Term_Months__c`.

Acceptance:

- UI Create Quote action previews/creates the expected QLI count.
- QLIs have Product2Id, PricebookEntryId, Quantity, UnitPrice, ListPrice,
  Discount, TotalPrice, Net Fees, service dates, and term.
- QLI related list and detail display product/quote/source-line references as
  readable values.
- Direct API parity is either implemented or documented as unsupported with
  failing watchdog expectation updated accordingly.

### Cluster E: QLI Layout and Readable References

Issues: #773, #1024, closed #1021/#1022/#1023/#1026/#1027 residue.

One QLI metadata/display fix should address:

- Add missing business fields to `QuoteLineItem`.
- Add `QuoteLineItem` listfields and related-list field set.
- Keep `Name` auto-number semantics unless the platform has an explicit display
  label override. The rejected fix in #1024 is a warning: do not remove
  auto-number just to make lists readable.
- Show Product2 name and Quote name through normal relationship display
  hydration.
- For "Opportunity Product" display, prefer a standard source OLI reference
  with product-readable display. Do not surface legacy
  `Opportunity_Product__c.Name` auto-numbers as the primary identifier.

Recommended QLI related/list field order:

```json
[
  "Product2Id",
  "PricebookEntryId",
  "UnitPrice",
  "ListPrice",
  "Discount",
  "Unit_Net_Price__c",
  "Quantity",
  "Term_Months__c",
  "Net_Fees_Total__c",
  "Net_fees__c",
  "Service_Start_Date__c",
  "Service_End_Date__c"
]
```

Acceptance:

- #773 AC4/AC5 remain passing.
- #773 AC2/AC3 are no longer inconclusive: QLI has Product2Id and readable
  display values.
- #773 AC6 passes: Net Fees is present once and has a value.

### Cluster F: Create Order QLI to OI Contract

Issues: #1604, #172 AC2, order-line residue noted in #1589.

One flow/metadata fix should address:

- Consume app-packs #33 or equivalent flow-backed `createOrder` action.
- Extend `Std_CreateOrderFromQuote` to look up source QLIs, loop, and create
  OIs.
- Add `QuoteLineItem -> OrderItem` field mappings.
- Add `OrderItem.Net_fees__c` as a single currency field. OrderItem does not
  need the OLI raw-plus-ACV twin-field model because Order annualization
  happens on parent `Order.ACV_Amount__c` via `Term__c`.
- Rewire `Order.Net_Fees__c.rollupConfig.summaryObject` from
  `Order_Product__c` to `OrderItem`. `Order.TCV_Amount__c` and
  `Order.ACV_Amount__c` then continue to derive from the parent Net Fees
  rollup.
- Treat Order date rollups from `Order_Product__c` as a related cleanup item,
  not the minimum #1604 finance unblocker unless the Order story requires
  them in the same PR.

Acceptance:

- Accepted Quote with QLIs creates an Order and matching OrderItems.
- OrderItems preserve product, PBE, quantity, pricing, discount, Net Fees, and
  source QLI reference.
- `Order.Net_Fees__c` derives from `OrderItem.Net_fees__c` and no longer
  requires `Order_Product__c`.

Scope boundary:

- This cluster covers Quote to Order creation and QLI to OI line propagation.
- Stripe/self-service order creation, Renewal/Expansion-specific order paths,
  and the `Revenue__c` financial ledger/source-of-truth model are separate
  order-module investigations. They should not be assumed into the #1604 fix
  unless a follow-up issue explicitly brings them into scope.

### Cluster G: Implicit Child CRUD

Issues: #273; affects OLI, QLI, OI, OCR, CaseComment, PricebookEntry.

One upstream permission fix should address:

- Derive implicit CRUD parent from existing Salesforce-modeled facts:
  `SharingModel = ControlledByParent`, master-detail FK, and sharing parent.
- Delegate object CRUD to the parent object.
- Keep field-level security independent where Salesforce models it.
- Ensure permission seeders and OP/FP cleanup use the same helper.

Non-goal:

- Do not add `implicitCRUDParent`, `independentFLS`, or a downstream whitelist
  as new pack metadata.

## Canonical Object Model

### OpportunityLineItem

Current relevant fields:

| Field | Current role | Target role |
| --- | --- | --- |
| `OpportunityId` | Parent Opportunity | Canonical parent |
| `PricebookEntryId` | Required PBE reference | User may select PBE directly until Product picker is built; lookup must be scoped by pricebook and currency |
| `Product2Id` | Product reference added/read by flows | Autofilled from PBE by #1599, shown/readable in UX |
| `Quantity` | Required standard quantity | Required for valid line math; UI can default to 1 |
| `UnitPrice` | Sales price | Autofilled from PBE by #1599 unless user overrides |
| `ListPrice` | Catalog list price | Copied/displayed |
| `Discount` | Standard discount | Copied/displayed |
| `TotalPrice` | Formula `Quantity * UnitPrice` | Standard gross price only |
| `Unit_Net_Price__c` | PingCAP net unit price | Sales-entered business price for Net Fees |
| `Net_Fees_Total__c` | Raw line value | **Deprecated (#4838)** — read by nothing; the Expansion-flow write was removed |
| `Net_fees__c` | Read-only | Whole-term derived value: `Unit_Net_Price__c * Quantity`, `* Term` only for TiDB Enterprise Subscription |
| `Term_Mounths__c` | Typo field used by current flows | Preserve short term, add alias/rename plan |
| `Service_Start_Date__c` | Start date | Used for rollups and expansion prorating |
| `Service_End_Date__c` | End date | Used for expansion/pricing display |
| `OpportunityProduct__c` | Legacy bridge string | Hide from UX; consider replacing with explicit legacy id field |

### QuoteLineItem

Current relevant fields:

| Field | Current state | Target role |
| --- | --- | --- |
| `QuoteId` | Standard parent | Canonical parent |
| `PricebookEntryId` | Standard PBE | Copied from OLI |
| `Product2Id` | Exists, label currently poor | Copied from OLI and displayed as Product |
| `Quantity` | Exists | Copied from OLI |
| `UnitPrice` | Exists | Copied from OLI |
| `ListPrice` | Exists | Copied from OLI |
| `Discount` | Exists | Copied from OLI |
| `TotalPrice` | Exists | Formula/copy standard gross price |
| `Subtotal` | Exists | Derived/display if supported |
| `Term_Months__c` | Exists | Copy from OLI typo or normalized source |
| `Term_years__c` | Exists | Derive from months if retained |
| `Net_Fees_Total__c` | Added by PR-A (#3928), now **deprecated (#4838)** | Do not build on it; retired by the #4838 cleanup step |
| `Net_fees__c` | Missing/currently null in evidence | Add/copy or calculate |
| `Unit_Net_Price__c` | Missing | Add for pricing parity |
| `Service_Start_Date__c` | Missing | Add for quote/service period |
| `Service_End_Date__c` | Missing | Add for quote/service period |
| `SourceOpportunityLineItemId__c` | Missing | Add as standard source line bridge |
| `LegacyQuoteProductId__c` | Missing | Add only if migration needs explicit old id |

### OrderItem

Current relevant fields:

| Field | Current state | Target role |
| --- | --- | --- |
| `OrderId` | Standard parent | Canonical parent |
| `QuoteLineItemId` | Exists | Source QLI bridge |
| `Product2Id` | Exists | Copied from QLI |
| `PricebookEntryId` | Exists | Copied from QLI |
| `Quantity` | Exists | Copied from QLI |
| `UnitPrice` | Exists | Copied from QLI |
| `ListPrice` | Exists | Copied from QLI |
| `TotalPrice` | Exists | Formula/copy standard gross price |
| `Discount__c` | Read-only formula | Keep as formula or add writable clone target if business needs copied discount |
| `Net_fees__c` | Missing | Add as the single OrderItem Net Fees field for #1604 |
| `Net_Fees_Total__c` | Missing | Do not add for #1604 unless a separate reporting requirement needs raw per-line TCV |
| `Service_Start_Date__c` | Missing | Add or map to standard `ServiceDate` |
| `Service_End_Date__c` | Missing | Add or map to `EndDate` |

## Lifecycle Contracts

### Add or Edit Opportunity Product

1. User selects Product or PricebookEntry.
2. If Product is selected, resolve active PBE by Opportunity pricebook and
   currency.
3. If PBE is selected, autofill Product2Id, UnitPrice, and Currency from PBE.
   #1599 now covers Product2Id/UnitPrice and currency-scoped lookup.
4. User enters or confirms Quantity, Unit Net Price, term, discount, service
   dates, and description.
5. Before-save OLI flows run:
   - Product2 autofill, order 50.
   - UnitPrice autofill, order 75.
   - Expansion autostamp, order 100.
   - Net Fees autocalc, order 200.
6. Formula/read path computes `TotalPrice`.
7. Opportunity rollups recompute `Amount`, `Net_Fees__c`, `Quantity__c`, and
   `Overall_Start_Date__c`.

Fail-closed conditions:

- No unique active PBE can be resolved.
- Product and PBE conflict.
- Currency mismatch between Opportunity and PBE.
- Required pricing fields for Net Fees calculation are absent.

### Create Quote From Opportunity

1. User invokes `createQuote` standard action from Opportunity.
2. Flow resolves source Opportunity, matching Quote record type, primary line
   context, and source OLIs.
3. Flow creates parent Quote with `_System_ObjectFieldMapping` plus explicit
   defaults for required PingCAP quote fields.
4. Flow loops source OLIs and creates QLIs with the OLI to QLI mapping.
5. Flow runs atomically. If any required child create fails, parent Quote rolls
   back.
6. Preview path shows parent fields and child count before save.

Direct API contract:

- The supported source-clone API is the standard action endpoint unless #979
  action-backed create route explicitly routes raw creates through the action.
- Raw `POST /sobjects/Quote` should not silently clone sometimes and skip
  other times. The design must choose clone-or-reject and encode that in tests.

### Create Order From Quote

1. User invokes `createOrder` standard action from an Accepted Quote.
2. Flow resolves source Quote, target Order defaults, and eligible source QLIs.
3. Flow creates parent Order.
4. Flow loops QLIs and creates OIs with QLI to OI mapping.
5. Order rollups recompute from OrderItem.
6. Flow runs atomically for parent and line items.

## Appendix A: Legacy Behavior Mapping

### Opportunity_Product__c to OpportunityLineItem

| Legacy field | Target field | Rule |
| --- | --- | --- |
| `Id` | `LegacyOpportunityProductId__c` or current `OpportunityProduct__c` bridge | Preserve for reconciliation only. Do not use as live lookup. |
| `Opportunity__c` | `OpportunityId` | Required parent. |
| `Product__c` | `Product2Id` | Required product reference. |
| `Product_Code__c` | derive from `Product2.ProductCode` | Prefer derivation over stored duplicate. |
| `Product_Name__c` | derive from `Product2.Name` | Display only, not editable copy. |
| `Quantity__c` | `Quantity` | Direct copy. |
| `List_Price__c` | `ListPrice` | Direct copy when source has value. |
| `Unit_Net_Price__c` | `Unit_Net_Price__c` | Direct copy. |
| `Discount__c` | `Discount` | Direct copy. |
| `Net_fees__c` | `Net_fees__c` | **Resolved (#4838 DECISION 1)**: one field carries the whole-term value. `Net_Fees_Total__c` is not a source. |
| `NetFees__c` | no direct field | Formula alias; avoid duplicate label. |
| `Term_Months__c` | `Term_Mounths__c`, then normalized alias | Preserve current typo until flows/reports migrate. |
| `Service_Start_Date__c` | `Service_Start_Date__c` | Direct copy. |
| `Service_End_Date__c` | `Service_End_Date__c` | Direct copy. |
| `Description__c` | `Description` | Direct copy. |
| `Recalculate__c` | hidden/dropped | Internal, not user-facing. |
| `Order_Product__c` | legacy bridge only | Preserve only if historical chain requires it. |

`PricebookEntryId` is not present on the legacy object. Resolve it by
`Opportunity.Pricebook2Id`, line `Product2Id`, and `CurrencyIsoCode`. If more
than one active PBE matches, fail the row for manual reconciliation.

### Quote_Product__c to QuoteLineItem

| Legacy field | Target field | Rule |
| --- | --- | --- |
| `Id` | `LegacyQuoteProductId__c` | Add only if migration needs direct legacy id. |
| `Quote__c` | `QuoteId` | Required parent. |
| `Product__c` | `Product2Id` | Required product reference. |
| `OpportunityProduct__c` | `SourceOpportunityLineItemId__c` if resolvable | Prefer standard OLI source. Keep legacy id only as fallback. |
| `Product_Code__c` | derive from `Product2.ProductCode` | Prefer derivation. |
| `Product_Name__c` | derive from `Product2.Name` | Display only. |
| `Quantity__c` | `Quantity` | Direct copy. |
| `List_Price__c` | `ListPrice` | Direct copy. |
| `Unit_Net_Price__c` | `Unit_Net_Price__c` | Add to QLI and copy. |
| `Discount__c` | `Discount` | Direct copy. |
| `Net_fees__c` | `Net_fees__c` | **Resolved (#4838 DECISION 1)**: services do not multiply by term; only TiDB Enterprise Subscription does. |
| `NetFees__c` | no direct field | Formula alias; do not duplicate label. |
| `Term_Months__c` | (not mapped) | Superseded 2026-08-13: derived from the copied service dates by `QuoteLineItem_Term_Derivation`, never copied. |
| `Term_years__c` | `Term_years__c` if retained | Derive when possible. |
| `Service_Start_Date__c` | `Service_Start_Date__c` | Add to QLI and copy. |
| `Service_End_Date__c` | `Service_End_Date__c` | Add to QLI and copy. |
| `Description__c` | `Description` | Direct copy. |
| `Autoapprove__c` | approval rule/flow behavior | Preserve behavior, not necessarily field name. |
| `Recalculate__c` | hidden/dropped | Internal, not user-facing. |

### QuoteLineItem to OrderItem

| Source QLI field | Target OI field | Rule |
| --- | --- | --- |
| `Id` | `QuoteLineItemId` | Preserve source reference. |
| `QuoteId` | source context | Used to locate parent Quote and new Order. |
| `Product2Id` | `Product2Id` | Direct copy. |
| `PricebookEntryId` | `PricebookEntryId` | Direct copy. |
| `Quantity` | `Quantity` | Direct copy. |
| `UnitPrice` | `UnitPrice` | Direct copy. |
| `ListPrice` | `ListPrice` | Direct copy. |
| `Discount` | `Discount__c` or formula | If `Discount__c` stays formula, no copy is needed. |
| `TotalPrice` | `TotalPrice` | Formula/copy standard gross price. |
| `Net_fees__c` | `Net_fees__c` | Copy QLI ACV Net Fees to the single OrderItem Net Fees field. |
| `Net_Fees_Total__c` | no default target | Do not clone to OrderItem for #1604 unless a separate line-level TCV requirement appears. |
| `Service_Start_Date__c` | `Service_Start_Date__c` or `ServiceDate` | Pick one canonical field. |
| `Service_End_Date__c` | `Service_End_Date__c` or `EndDate` | Pick one canonical field. |

## Current Pack Gaps

Observed in `packs/Salesforce-PingCAP-L2` (status refreshed 2026-07-29; most of
the original gap list has since been closed by the #3873 product-chain epic):

- `Opportunity.Amount` still rolls from `OpportunityLineItem.TotalPrice`
  (placeholder-SKU pricing semantics — deferred P2 decision, unchanged).
- ~~QuoteLineItem missing PingCAP fields~~ **CLOSED**: `QuoteLineItem` now
  carries `Net_fees__c`, `Net_Fees_Total__c`, `Unit_Net_Price__c`,
  `Service_Start/End_Date__c`, `Term_Months__c` (PR-A, #3928).
- ~~`Quote` rollups on `Quote_Product__c`~~ **CLOSED**:
  `Overall_Start/End_Date__c` and `Discount__c` re-sourced to `QuoteLineItem`
  (#4384, PR-B1); `NetFees__c`, `TCV_Amount_c__c`, and `Total_List_Price__c`
  re-sourced to `QuoteLineItem` (#4392, PR-B2a) once its upstream gates
  landed on NexusCRM main (NX#3905 via NexusCRM#4118, merged 2026-07-28;
  NX#3908 via NexusCRM#4016, merged 2026-07-24). Every Quote product rollup now
  reads the standard chain.
- ~~Order rollups on `Order_Product__c`~~ **CLOSED**: `Order.Net_Fees__c` and
  `Count_Product_Lines__c` re-sourced to `OrderItem` (#4457);
  `Overall_Start/End_Date__c` re-sourced to `OrderItem.ServiceDate`/`EndDate`
  (#4469). A read-only prod parity sweep (2026-07-29, 5,479 orders) found zero
  regressions for the date re-source: 5,195 headers are NULL today and only
  improve on next touch, 220 match exactly, 64 have no dates on either side,
  0 mismatch. `Order_Product__c` itself is retired (#4434,
  frozen archive).
- ~~`Net_Fees_Total__c` as a second money field~~ **CLOSED for reads, cleanup
  in progress** (status 2026-08-06, #4838): the chain now carries one field.
  `QuoteLineItem_Net_Fees_Autocalc` and `OpportunityLineItem_Net_Fees_Autocalc`
  both derive `Net_fees__c` from `Unit_Net_Price__c` / `Quantity`, multiplying
  by term only for TiDB Enterprise Subscription (#4845 Step 1, #4862 Step 2);
  the two Quote header rollups sum `Net_fees__c` (#4845). This PR removes the
  last two writers — the Create-Quote field mapping and the dead
  `Net_Fees_Total__c` stamp in `OpportunityLineItem_Expansion_AutoStamp`
  (its gate has never fired: 117 Expansion opportunities in prod, 0 with
  `OriginalOrder__c`). Retiring the field itself is deferred until the Quote
  rollup re-source reaches prod — production still summarises
  `Net_Fees_Total__c` there — and until the 24 populated QuoteLineItem values
  are archived.
- ~~No QLI→OI mappings~~ **CLOSED**: `standard_field_mappings.json` carries
  QuoteLineItem→OrderItem mappings (incl. `Net_fees__c`).
- `standard_listfields.json` now has OLI and QLI entries; OrderItem columns are
  curated via the `order-line-items` related-list rule (#4489) rather than a
  listfields entry.
- Curated OLI/QLI page layouts exist (`opportunitylineitem_page_layout.json`,
  `quotelineitem_page_layout.json`); OrderItem uses the standard-pack layout.
- Current downstream stack lock still needs review whenever app-packs #31/#33
  are expected to be authoritative in this repo.
- ~~Four Opportunity flows query `Opportunity_Product__c`~~ **CLOSED** (#3874
  re-pointed them to OLI).
- ~~One Quote flow queries `Quote_Product__c`~~ **CLOSED**: the
  `Whether_include_man_day_or_Premium_Subscription` flow was re-sourced to
  `QuoteLineItem` (P1 PR-A, #3873; its description still narrates the legacy
  origin, which is prose, not a query). No flow queries the legacy line
  tables anymore.

## Implementation Plan

### Phase 0: Rebase Runtime and Official Pack Pins

Goal: consume already-merged authorities before adding more pack behavior.

Tasks:

- Confirm downstream stack lock includes NexusCRM #979 action-backed create
  route and app-packs #31/#33 if needed for this branch.
- Rebuild `packs/NOT-EDIT-final-merged-packs`.
- Run final pack audit and snapshot verification.
- Run quote/order watchdog smoke cases to establish current baseline.

Exit:

- Local stack installs and boots.
- `createQuote` standard action exists and is flow-backed.
- `createOrder` status is known: either present from app-packs #33 or
  intentionally deferred.

### Phase 1: OLI C1b Completion

Prerequisite before merging the `Amount` semantic flip:

- Finance/RevOps must audit downstream report, dashboard, forecast, formula,
  and flow consumers of `Opportunity.Amount`, `Opportunity.Net_Fees__c`, and
  legacy product objects. The current pack has report types and formulas that
  read `Amount`, so this is a merge gate rather than an optional cleanup task.

Tasks:

- Flip `Opportunity.Amount.rollupConfig.summaryField` to `Net_fees__c`.
- Add OLI page layout, compact layout/header, related-list fields, and
  listfields.
- Hide internal bridge/recalculate fields.
- Preserve `Term_Mounths__c` for existing OLI flows in the first PR, unless the
  same PR also updates every OLI flow/report consumer. Add explicit downstream
  mappings wherever the target field uses the corrected spelling.
- Rewrite Opportunity flows that still query `Opportunity_Product__c`.

Exit:

- #1244 can be closed or narrowed to deployment verification.
- #869/#876/#879/#882/#885/#891 have a single pack PR.
- #1608 can be updated from RFC to implementation complete for C1b.

### Phase 2: QLI Standardization

Tasks:

- Add QLI PingCAP fields.
- Add QLI layout/list/related-list metadata.
- Extend OLI to QLI field mapping.
- Add source OLI bridge.
- Rewire Quote rollups from `Quote_Product__c` to `QuoteLineItem` after field
  parity exists.
- Decide direct Quote API semantics for #1606 and update watchdogs.
- Rewrite Quote flow that queries `Quote_Product__c` if still relevant.

Exit:

- #1605 passes.
- #1024 is validated or narrowed to display-layer-only.
- #773 active ACs pass.
- Legacy quote product surfaces are hidden from normal navigation/create.

### Phase 3: OI Standardization

Tasks (status 2026-07-29: all shipped — QLI→OI fan-out and mappings via PR-A
work, `Order.Net_Fees__c`/`Count_Product_Lines__c` re-sourced in #4457, date
rollups re-sourced in #4469, legacy write surfaces retired in #4434; kept for
historical context):

- Consume or port app-packs #33 Create Order action.
- Add QLI to OI mappings.
- Extend `Std_CreateOrderFromQuote` with child fan-out.
- Add `OrderItem.Net_fees__c`.
- Rewire `Order.Net_Fees__c` from `Order_Product__c` to `OrderItem`.
- Decide separately whether Order start/end date rollups should move in the
  same PR.
- Hide legacy order product write surfaces.

Exit:

- #1604 passes.
- #172 AC2 is testable and passes.
- Order financial fields no longer depend on `Order_Product__c`.

### Phase 4: Migration Compatibility

Ownership and dependency:

- Phase 4 depends on the migration team picking up an architecture-aligned
  loader redesign under #1520. The pack maintainer should own the field-mapping
  and reconciliation specification; the migration loader owner should implement
  the transform batch.
- Do not treat legacy line-item import as a simple 1:1 load. #1520 shows that
  raw Salesforce snapshots include both standard and custom line objects, and
  #1521's closed supplemental loader path was not the final transform design.

Tasks:

- Write a migration reconciliation spec for legacy product rows:
  source object, standard target object, key matching, PBE resolution, and
  failure reporting.
- Define transform ETL for:
  - SF `Opportunity_Product__c` to NexusCRM `OpportunityLineItem`.
  - SF `Quote_Product__c` to NexusCRM `QuoteLineItem`.
  - SF/order line rows to the final `OrderItem` model after #1604.
- Add explicit legacy id fields only where reconciliation requires them.
- Mark legacy custom product objects hidden from navigation and create actions.
- Keep historical pages read-only if business needs access to old records.
- Add data checks that standard line counts match legacy line counts by parent
  after import.

Exit:

- Migration can load standard OLI/QLI/OI as canonical rows.
- Legacy custom rows no longer drive current quote/order/opportunity workflows.

## Verification Matrix

| Contract | Verification |
| --- | --- |
| OLI computed gross price | `opportunity-oli-rollup-contract-api` verifies `TotalPrice = Quantity * UnitPrice`. |
| Opportunity ACV amount | New or updated case verifies `Amount == SUM(OLI.Net_fees__c)`. |
| OLI UX | Manual and watchdog coverage for #744 AC1 through AC6, plus metadata assertions for layout/listfields. |
| OLI catalog modal | Browser E2E for product search, multi-select, inline pricing, atomic save. |
| Create Quote parent and child | `quote-create-clones-opportunity-line-items-api` and `quote-create-line-item-clone-count-contract-api` check created QLI count. Add mapped-field assertions for Net Fees and service fields after QLI schema expansion. |
| Direct Quote API | #1606 case verifies clone or explicit rejection, not silent parent-only create. Use or split from `quote-create-api` depending on the final raw-SObject API contract. |
| QLI pricing/readable display | `quote-line-items-api` and `quote-line-item-pricing-contract-api` cover #773 pricing/readable surfaces. Extend for Product2Id, source line display, and Net Fees value. |
| Create Order parent and child | `order-from-quote-contract-api` covers parent linkage. Add or tighten a #1604 case so action-created Orders prove QLI to OI clone, not only seeded OrderItem readiness. |
| Order rollups | `Order.Net_Fees__c` rolls from `OrderItem.Net_fees__c`; dependent ACV/TCV formulas remain nonzero without `Order_Product__c`. |
| Quote discount approval routing | `quote-approval-discount-step-routing-api` submits three quotes to the same NA Cloud chain: no discount, discount below the band threshold, discount above it. Only the last one enters `Sales_Leader_Approval`. See "Discount-gated Approval Routing" below. |
| Implicit child CRUD | Upstream permission tests prove parent-delegated object CRUD and independent FLS. |
| Migration compatibility | #1520 transform-loader run plus row-count and value reconciliation from legacy custom product objects into standard line-item objects. |

## Average Discount Input Revival (2026-07-30)

With the money re-source landed, the Cloud-branch `Average_Discount__c` still
blanked on natively created quotes because two of its inputs were never
populated outside the source org: `Opportunity.Rate__c` (stamped there by a
before-save Apex trigger from the dated conversion-rate table, anchored on
CloseDate's period) and `Quote.Total_Credit_Amount__c` (blank on 771/899 source
quotes, tolerated there by formula blank-as-zero semantics that this platform
does not implement — upstream gap filed). Shipped as a faithful port:

- `Dated_Conversion_Rate__c` (L2 object) + 142 seeded period rows
  (`pack_reference_data`, all non-production profiles; production needs the
  same one-time reference-data application at deploy).
- `Opportunity_Rate_From_Dated_Conversion` flow — before-save, USD→1 shortcut,
  otherwise the CloseDate period's rate; no match leaves the field untouched;
  re-stamps on every save (source-org parity).
- `Quote_Total_Credit_Amount_Default` flow — before-save, null→0.
- Watchdog case `quote-average-discount-cloud-native-inputs-api` asserts the
  full native chain: Rate stamped 1 → TCV_USD computes → Average computes →
  the ACV-band gate reads Y.

Both flows use before-save `$Record` assignments deliberately: an after-save
`recordUpdate` inside the record.created chain is silently lost on this
platform (audit logs success, zero rows affected — the same in-transaction
visibility family as the SubmitForApproval record-not-found bug). Existing
records revive on their next save; a one-time backfill for dormant rows
(native null-Rate opportunities, null-credit quotes) is a deploy-gated
follow-up.

## Discount-gated Approval Routing

Re-sourcing the Quote line-item rollups is not only a reporting fix: the
discount approval chain reads the same numbers. `Average_Discount__c` feeds
both the ~50 `Need_*_Approval__c` gate formulas and the approval step criteria
directly, and which band gate applies is selected by `ACV_Amount_USD__c`. While
the rollups pointed at empty legacy rows the whole discount ladder was
structurally inert.

Which re-source carries which half is worth being precise about, because the
two PRs are easy to conflate:

- `Discount__c` (`MAX(QuoteLineItem.Discount)`, #4384 / PR-B1) is what makes
  `Average_Discount__c` nonzero on the Cloud branch. With no referral fee and
  no credit the Cloud formula reduces to `Discount__c`, so PR-B1 alone revives
  the *magnitude*.
- `TCV_Amount_c__c` / `NetFees__c` (#4392 / PR-B2a) drive `ACV_Amount_USD__c`,
  which selects the *band* — a discount with no ACV lands in no band and every
  band gate stays `"N"`. On the non-Cloud branch the same rollups are the
  discount itself (`1 - NetFees__c / Total_List_Price__c`).

How the chain is wired:

- Quote approval definitions are runtime-seeded
  (`deployments/offline/runtime/seeds/exp-quote-approvals.seed.json`, 28
  definitions), applied by the Seed Dispatcher in the `test`, `watchdog`,
  `exp`, and `staging` profiles. Production keeps its own (ownerless) rows.
- Step criteria live in the definition's metadata XML as
  `approvalStep/entryCriteria`, not as `ProcessNode` columns — the seeded
  `ProcessNode` rows carry no criteria of their own. Criteria reference both
  the `Need_*` gate fields (text `"Y"`/`"N"`, so comparisons are string
  comparisons, not booleans) and `Average_Discount__c` / `TCV_Amount_USD__c`
  thresholds.
- Scale: threshold values in step criteria are in the storage/display domain
  (`>= 55` means 55%), while the formula bodies that produce the gates work in
  the fraction domain (`0.55`). Assertions and criteria use the display domain.
- `ifCriteriaNotMet` decides what a non-matching gated step does, and the seeded
  chains are not uniform: of 158 gated steps across the 28 definitions, 107 say
  `ApproveRecord`, 23 say `GotoNextStep`, and 28 omit the element (the runtime
  defaults an omitted value to `ApproveRecord`, matching Salesforce). On
  `NA_DBaaS_250502` — the chain this case exercises — `Sales_Leader_Approval`
  and `RGM_Approval` are `ApproveRecord`, so a criteria miss there finalizes the
  quote as Approved rather than skipping one step. On those steps a missing or
  unreadable discount does not fail loudly; it silently approves the whole
  quote.

That last point is why two independent regressions could hide each other: the
money chain produced no discount, and criteria evaluation could not read
virtual formula fields at all (NX#3913, fixed upstream; NexusCRM#3999 then
closed the follow-on NX#3997, where a missing authored step was silently
treated as a match; NexusCRM#4004 / NX#3945 narrowed the criteria evaluation
basis to a field-capped system read).

Verified on a local stack carrying those fixes plus the #4384 and #4392
rollups (2026-07-30, 28-chain seed applied, NA Cloud chain `NA_DBaaS_250502`,
`Sales_Leader_Approval` gated on the 25K–50K band gate among others):

| Quote line | `ACV_Amount_USD__c` | `Average_Discount__c` | `Need_Sales_Lead_Approval_Cloud_Over_25K__c` | After approving step 1 |
| --- | --- | --- | --- | --- |
| `Discount = 0` | 10,000 | 0 | `N` | instance Approved, no gated step entered |
| `Discount = 10` | 30,000 | 10 | `N` (below threshold) | instance Approved, no gated step entered |
| `Discount = 20` | 30,000 | 20 | `Y` | `Sales_Leader_Approval` pending, instance still Pending |

The middle row is the one that pins the threshold rather than just "nonzero
discount does something": that gate fires on
`Average_Discount__c - Additional_Discount_to_reduce_Sales__c > 0.12` in the
fraction domain, so 10% must not escalate and 20% must. A negative control
(gated branch rebuilt with `Discount = 0`) fails on the
`Sales_Leader_Approval` assertion, so the case cannot pass on an environment
where routing is dead again.

The case cleans up before it asserts, deliberately. The runner aborts a case at
its first failed assertion, so anything asserted before cleanup would strand
records exactly on the runs that fail — including the run where this case
catches the regression it exists for. Each branch therefore approves the first
step, recalls (skipped rather than failed when there is no next work item),
deletes the quote, and only then asserts the routing evidence from the approval
instance: the `ProcessInstance` and `ProcessInstanceNode` rows survive both the
recall and the quote deletion. Deleting the quote first is not an option — it
does not cascade the approval instance, so a pending work item would be left
pointing at a deleted record.

The escalated branch asserts the `Sales_Leader_Approval` node with
`NodeStatus = Removed` (proving it was the *pending* step the recall released,
not merely a step the instance passed through) and zero `DMT2_Approval` nodes
(proving the instance did not slide past the gate into the next unconditional
step). Measured on a failing run, the residue is zero: no quotes and no work
items left behind.

Deployment note: the moment these rollups reach an environment with real
quotes, discount approvals start firing on drafts that previously passed
straight through. That is the intended behavior, and it is a business-visible
change worth announcing with the release rather than discovering in the
approval queue.

## Open Decisions

1. Direct Quote API: should raw `POST /sobjects/Quote` ever infer source
   Opportunity clone behavior, or must all clone behavior go through
   `actions/standard/createQuote`?
2. QLI editability: after Quote creation, can users edit QLI business fields
   independently, or are QLIs snapshots of OLI until a dedicated Edit Products
   on Quote flow exists?
3. QLI Net Fees semantics: should QLI copy OLI ACV, copy raw TCV and recalc
   ACV, or preserve both exactly as OLI does?
4. Legacy id fields: do we add explicit `LegacyOpportunityProductId__c` and
   `LegacyQuoteProductId__c`, or reuse existing bridge fields for migration?
5. Term rename timing: after explicit OLI `Term_Mounths__c` to QLI
   `Term_Months__c` mapping lands, when do we rename or alias the OLI field
   itself?
6. Report consumer audit output: which dashboards, reports, approval rules,
   formulas, and forecasts must sign off before `Opportunity.Amount` flips to
   ACV?
7. ~~Order date rollups: should `Overall_Start_Date__c` and
   `Overall_End_Date__c` move from `Order_Product__c` to `OrderItem` in the
   same PR as #1604, or be tracked as a separate date-rollup cleanup?~~
   **RESOLVED (historical)**: tracked separately and shipped — money/count in
   #4457, date rollups in #4469.

## Recommended First PR Sequence

1. OLI C1b pack PR:
   `Opportunity.Amount` rollup flip, OLI layout/list/compact metadata, and
   legacy Opportunity flow rewrites.
2. QLI field/model PR:
   add QLI business fields, mappings, layout/list metadata, and Quote rollup
   rewires.
3. API contract PR or issue decision:
   close #1606 by implementation or explicit unsupported contract.
4. Order fan-out PR:
   `createOrder` QLI to OI loop, mappings, and Order rollup rewires.
5. Catalog modal PR:
   product picker, multi-add, and batch save UX.

This order keeps financial semantics stable before cloning them downstream,
then fixes quote and order line propagation, then tackles the richer modal UX.
