diff --git a/modules/purchase_trade/__init__.py b/modules/purchase_trade/__init__.py
index 82e1c04..3b12ae4 100755
--- a/modules/purchase_trade/__init__.py
+++ b/modules/purchase_trade/__init__.py
@@ -61,13 +61,15 @@ def register():
lc.CreateLCStart,
global_reporting.GRConfiguration,
ctrm_reporting.CTRMPhysicalPosition,
- ctrm_reporting.CTRMPhysicalPositionContext,
- ctrm_reporting.CTRMFinancialPosition,
- ctrm_reporting.CTRMFinancialPositionContext,
- ctrm_reporting.CTRMNetPosition,
- ctrm_reporting.CTRMNetPositionContext,
- ctrm_reporting.CTRMRealizedPnl,
- ctrm_reporting.CTRMRealizedPnlContext,
+ ctrm_reporting.CTRMPhysicalPositionContext,
+ ctrm_reporting.CTRMFinancialPosition,
+ ctrm_reporting.CTRMFinancialPositionContext,
+ ctrm_reporting.CTRMNetPosition,
+ ctrm_reporting.CTRMNetPositionContext,
+ ctrm_reporting.CTRMOpenPosition,
+ ctrm_reporting.CTRMOpenPositionContext,
+ ctrm_reporting.CTRMRealizedPnl,
+ ctrm_reporting.CTRMRealizedPnlContext,
ctrm_reporting.CTRMMtmPnl,
ctrm_reporting.CTRMMtmPnlContext,
ctrm_reporting.CTRMPnlExplain,
diff --git a/modules/purchase_trade/ctrm_reporting.py b/modules/purchase_trade/ctrm_reporting.py
index 1865bec..26d37cc 100644
--- a/modules/purchase_trade/ctrm_reporting.py
+++ b/modules/purchase_trade/ctrm_reporting.py
@@ -318,6 +318,283 @@ class CTRMNetPosition(ModelSQL, ModelView):
group_by=group_by)
+class CTRMOpenPositionContext(ModelView):
+ "CTRM Open Position Context"
+ __name__ = 'ctrm.reporting.risk.open_position.context'
+
+ valuation_date = fields.Date("Valuation Date")
+ commodity = fields.Many2One('product.category', "Commodity")
+ product = fields.Many2One('product.product', "Product")
+ pricing_status = fields.Selection([
+ (None, ''),
+ ('fixed', 'Fixed'),
+ ('unfixed', 'Unfixed'),
+ ('partly', 'Partly fix'),
+ ], "Pricing Status")
+ shipment_period = fields.Many2One('product.month', "Shipment Period")
+ strategy = fields.Many2One('mtm.strategy', "Strategy")
+ counterparty = fields.Many2One('party.party', "Counterparty")
+ direction = fields.Selection([
+ (None, ''),
+ ('long', 'LONG'),
+ ('short', 'SHORT'),
+ ('flat', 'Flat'),
+ ], "Direction")
+
+ @classmethod
+ def default_valuation_date(cls):
+ return Pool().get('ir.date').today()
+
+
+class CTRMOpenPosition(ModelSQL, ModelView):
+ "CTRM Open Position"
+ __name__ = 'ctrm.reporting.risk.open_position'
+
+ product = fields.Many2One(
+ 'product.product', "Product", readonly=True)
+ commodity = fields.Many2One(
+ 'product.category', "Commodity", readonly=True)
+ pricing = fields.Selection([
+ ('fixed', 'Fixed'),
+ ('unfixed', 'Unfixed'),
+ ('partly', 'Partly fix'),
+ ], "Pricing", readonly=True)
+ shipment_period = fields.Many2One(
+ 'product.month', "Shipment Pd", readonly=True)
+ strategy = fields.Many2One(
+ 'mtm.strategy', "Strategy", readonly=True)
+ bought = fields.Numeric("Bought", digits=(16, 3), readonly=True)
+ sold = fields.Numeric("Sold", digits=(16, 3), readonly=True)
+ net = fields.Numeric("Net", digits=(16, 3), readonly=True)
+ direction = fields.Selection([
+ ('long', 'LONG'),
+ ('short', 'SHORT'),
+ ('flat', 'Flat'),
+ ], "Direction", readonly=True)
+ unit = fields.Many2One('product.uom', "Unit", readonly=True)
+ open_contracts = fields.Integer("Open Contracts", readonly=True)
+ unfixed_qty = fields.Numeric(
+ "Unfixed Qty", digits=(16, 3), readonly=True)
+
+ @classmethod
+ def _physical_quantity_query(cls, lot, line_field):
+ return lot.select(
+ getattr(lot, line_field).as_('line'),
+ Sum(Coalesce(lot.lot_quantity, 0)).as_('quantity'),
+ where=((lot.lot_type == 'physic')
+ & (getattr(lot, line_field) != Null)),
+ group_by=[getattr(lot, line_field)])
+
+ @classmethod
+ def table_query(cls):
+ pool = Pool()
+ PurchaseLine = pool.get('purchase.line')
+ Purchase = pool.get('purchase.purchase')
+ SaleLine = pool.get('sale.line')
+ Sale = pool.get('sale.sale')
+ Lot = pool.get('lot.lot')
+ Product = pool.get('product.product')
+ Template = pool.get('product.template')
+ TemplateCategory = pool.get('product.template-product.category')
+ PurchaseStrategy = pool.get('purchase.strategy')
+ SaleStrategy = pool.get('sale.strategy')
+
+ purchase_line = PurchaseLine.__table__()
+ purchase = Purchase.__table__()
+ sale_line = SaleLine.__table__()
+ sale = Sale.__table__()
+ lot = Lot.__table__()
+ product = Product.__table__()
+ template = Template.__table__()
+ template_category = TemplateCategory.__table__()
+ purchase_strategy = PurchaseStrategy.__table__()
+ sale_strategy = SaleStrategy.__table__()
+
+ context = Transaction().context
+
+ purchase_physical = cls._physical_quantity_query(lot, 'line')
+ purchase_contract_qty = Coalesce(
+ purchase_line.quantity_theorical, purchase_line.quantity, 0)
+ purchase_physical_qty = Coalesce(Max(purchase_physical.quantity), 0)
+ purchase_residual = purchase_contract_qty - purchase_physical_qty
+ purchase_open_qty = Case(
+ (purchase_line.finished == True, 0),
+ (purchase_residual > 0, purchase_residual),
+ else_=0)
+ purchase_fixed = purchase_line.price_type.in_(['cash', 'priced'])
+ purchase_where = (
+ (purchase_line.product != Null)
+ & purchase.state.in_(['confirmed', 'processing'])
+ & (template.type != 'service'))
+ if context.get('product'):
+ purchase_where &= purchase_line.product == context['product']
+ if context.get('commodity'):
+ purchase_where &= (
+ template_category.category == context['commodity'])
+ if context.get('shipment_period'):
+ purchase_where &= (
+ purchase_line.del_period == context['shipment_period'])
+ if context.get('strategy'):
+ purchase_where &= (
+ purchase_strategy.strategy == context['strategy'])
+ if context.get('counterparty'):
+ purchase_where &= purchase.party == context['counterparty']
+
+ purchase_query = (
+ purchase_line
+ .join(purchase, condition=purchase_line.purchase == purchase.id)
+ .join(purchase_physical, 'LEFT',
+ condition=purchase_physical.line == purchase_line.id)
+ .join(product, condition=purchase_line.product == product.id)
+ .join(template, condition=product.template == template.id)
+ .join(template_category, 'LEFT',
+ condition=template_category.template == template.id)
+ .join(purchase_strategy, 'LEFT',
+ condition=purchase_strategy.line == purchase_line.id)
+ .select(
+ Literal('purchase').as_('side'),
+ purchase_line.id.as_('line_id'),
+ purchase_line.product.as_('product'),
+ Max(template_category.category).as_('commodity'),
+ purchase_line.del_period.as_('shipment_period'),
+ Max(purchase_strategy.strategy).as_('strategy'),
+ purchase.party.as_('counterparty'),
+ purchase_line.unit.as_('unit'),
+ purchase_open_qty.as_('bought'),
+ Literal(0).as_('sold'),
+ Case((purchase_fixed, purchase_open_qty), else_=0).as_(
+ 'fixed_qty'),
+ Case((purchase_fixed, 0), else_=purchase_open_qty).as_(
+ 'unfixed_qty'),
+ where=purchase_where,
+ group_by=[
+ purchase_line.id,
+ purchase_line.product,
+ purchase_line.del_period,
+ purchase.party,
+ purchase_line.unit,
+ purchase_line.finished,
+ purchase_line.quantity_theorical,
+ purchase_line.quantity,
+ purchase_line.price_type,
+ ],
+ having=purchase_open_qty > 0))
+
+ sale_physical = cls._physical_quantity_query(lot, 'sale_line')
+ sale_contract_qty = Coalesce(
+ sale_line.quantity_theorical, sale_line.quantity, 0)
+ sale_physical_qty = Coalesce(Max(sale_physical.quantity), 0)
+ sale_residual = sale_contract_qty - sale_physical_qty
+ sale_open_qty = Case(
+ (sale_line.finished == True, 0),
+ (sale_residual > 0, sale_residual),
+ else_=0)
+ sale_fixed = sale_line.price_type.in_(['cash', 'priced'])
+ sale_where = (
+ (sale_line.product != Null)
+ & sale.state.in_(['confirmed', 'processing'])
+ & (template.type != 'service'))
+ if context.get('product'):
+ sale_where &= sale_line.product == context['product']
+ if context.get('commodity'):
+ sale_where &= template_category.category == context['commodity']
+ if context.get('shipment_period'):
+ sale_where &= sale_line.del_period == context['shipment_period']
+ if context.get('strategy'):
+ sale_where &= sale_strategy.strategy == context['strategy']
+ if context.get('counterparty'):
+ sale_where &= sale.party == context['counterparty']
+
+ sale_query = (
+ sale_line
+ .join(sale, condition=sale_line.sale == sale.id)
+ .join(sale_physical, 'LEFT',
+ condition=sale_physical.line == sale_line.id)
+ .join(product, condition=sale_line.product == product.id)
+ .join(template, condition=product.template == template.id)
+ .join(template_category, 'LEFT',
+ condition=template_category.template == template.id)
+ .join(sale_strategy, 'LEFT',
+ condition=sale_strategy.sale_line == sale_line.id)
+ .select(
+ Literal('sale').as_('side'),
+ sale_line.id.as_('line_id'),
+ sale_line.product.as_('product'),
+ Max(template_category.category).as_('commodity'),
+ sale_line.del_period.as_('shipment_period'),
+ Max(sale_strategy.strategy).as_('strategy'),
+ sale.party.as_('counterparty'),
+ sale_line.unit.as_('unit'),
+ Literal(0).as_('bought'),
+ sale_open_qty.as_('sold'),
+ Case((sale_fixed, sale_open_qty), else_=0).as_(
+ 'fixed_qty'),
+ Case((sale_fixed, 0), else_=sale_open_qty).as_(
+ 'unfixed_qty'),
+ where=sale_where,
+ group_by=[
+ sale_line.id,
+ sale_line.product,
+ sale_line.del_period,
+ sale.party,
+ sale_line.unit,
+ sale_line.finished,
+ sale_line.quantity_theorical,
+ sale_line.quantity,
+ sale_line.price_type,
+ ],
+ having=sale_open_qty > 0))
+
+ lines = Union(purchase_query, sale_query, all_=True)
+ bought = Sum(Coalesce(lines.bought, 0))
+ sold = Sum(Coalesce(lines.sold, 0))
+ net = bought - sold
+ fixed_qty = Sum(Coalesce(lines.fixed_qty, 0))
+ unfixed_qty = Sum(Coalesce(lines.unfixed_qty, 0))
+ pricing = Case(
+ ((fixed_qty > 0) & (unfixed_qty > 0), 'partly'),
+ (unfixed_qty > 0, 'unfixed'),
+ else_='fixed')
+ direction = Case(
+ (net > 0, 'long'),
+ (net < 0, 'short'),
+ else_='flat')
+
+ having = (bought + sold) > 0
+ if context.get('pricing_status'):
+ having &= pricing == context['pricing_status']
+ if context.get('direction') == 'long':
+ having &= net > 0
+ elif context.get('direction') == 'short':
+ having &= net < 0
+ elif context.get('direction') == 'flat':
+ having &= net == 0
+
+ report_id = RowNumber(window=Window([],
+ order_by=[lines.product, lines.unit]))
+
+ return lines.select(
+ Literal(0).as_('create_uid'),
+ CurrentTimestamp().as_('create_date'),
+ Literal(None).as_('write_uid'),
+ Literal(None).as_('write_date'),
+ report_id.as_('id'),
+ lines.product.as_('product'),
+ Max(lines.commodity).as_('commodity'),
+ pricing.as_('pricing'),
+ Max(lines.shipment_period).as_('shipment_period'),
+ Max(lines.strategy).as_('strategy'),
+ bought.as_('bought'),
+ sold.as_('sold'),
+ net.as_('net'),
+ direction.as_('direction'),
+ lines.unit.as_('unit'),
+ Count(lines.line_id).as_('open_contracts'),
+ unfixed_qty.as_('unfixed_qty'),
+ group_by=[lines.product, lines.unit],
+ having=having)
+
+
class CTRMPnlContextMixin:
date = fields.Date("Valuation Date")
product = fields.Many2One('product.product', "Product")
diff --git a/modules/purchase_trade/docs/reporting/open_position_report_spec.md b/modules/purchase_trade/docs/reporting/open_position_report_spec.md
new file mode 100644
index 0000000..da94377
--- /dev/null
+++ b/modules/purchase_trade/docs/reporting/open_position_report_spec.md
@@ -0,0 +1,330 @@
+# Open Position — Physical Net Long/Short
+
+**Report ID:** RPT-02
+**Module:** `commodity_risk_reporting` (Global Reporting → Positions)
+**ERP:** Trad'On (Tryton 6.x)
+**Type:** Tabular list report (no charts)
+**Status:** Specification for development
+
+---
+
+## Table of Contents
+
+1. [Goal of the Report](#1-goal-of-the-report)
+2. [Scope and Definitions](#2-scope-and-definitions)
+3. [Source Data](#3-source-data)
+4. [Column Specification](#4-column-specification)
+5. [Aggregation & Grouping](#5-aggregation--grouping)
+6. [Filter Criteria](#6-filter-criteria)
+7. [KPI Header Cards](#7-kpi-header-cards)
+8. [Tryton Implementation](#8-tryton-implementation)
+9. [Edge Cases & Business Rules](#9-edge-cases--business-rules)
+10. [Acceptance Criteria](#10-acceptance-criteria)
+
+---
+
+## 1. Goal of the Report
+
+The **Open Position** report answers one question for the risk manager and the trader:
+
+> *For each product, have I bought more than I have sold, or the reverse — and by how much?*
+
+It computes, per product, the **net physical position** as the algebraic difference between open purchase quantities and open sale quantities:
+
+```
+Net Position = Σ Open Purchase Qty − Σ Open Sale Qty
+```
+
+- **Net > 0 → LONG** — more bought than sold. The desk is exposed to a **falling** market (holds/owes goods it must sell).
+- **Net < 0 → SHORT** — more sold than bought. The desk is exposed to a **rising** market (owes goods it must source).
+- **Net = 0 → FLAT** — purchases and sales balance; no directional physical exposure.
+
+This is the *physical* position only. It deliberately excludes derivatives — the hedge overlay is handled by RPT-03 (Net Position / Consolidated Risk), which consumes this report's output as its physical leg. Keeping the physical position isolated here lets the trader see the raw physical book before any hedge is applied.
+
+**Primary users:** physical traders (per book), risk manager (portfolio view).
+**Valuation basis:** quantity only — this report is not a P&L report. Values (price, MTM) are out of scope; see RPT-06 (Unrealized P&L).
+
+---
+
+## 2. Scope and Definitions
+
+| Term | Definition |
+|---|---|
+| **Open quantity** | Contract line quantity **not yet delivered/executed**, i.e. nominal contract quantity minus quantity already shipped and closed. A fully executed line contributes 0. |
+| **Purchase leg** | Contract lines where the company is the **buyer** (`P-xxxx` contracts). Add to net. |
+| **Sale leg** | Contract lines where the company is the **seller** (`S-xxxx` contracts). Subtract from net. |
+| **Product** | The tradeable product (e.g. `[FI-H2SO4] SULPHURIC ACID`). Grouping key. |
+| **Commodity** | The parent commodity family (Metals, Coffee, Cocoa, Cotton, Grains, Iron Ore). Used for filtering and sub-totalling. |
+| **Quantity UoM** | Unit in which the net is expressed: **MT** for most commodities, **lots** for those quoted in lots (e.g. Arabica/KC). Driven by the product's `risk_uom`, never mixed within a row. |
+| **Pricing status** | Whether the open quantity is price-fixed, unfixed, or partly fixed. Informational here (full detail in RPT-04). |
+
+**Position sign convention:** purchases positive, sales negative. A long book shows a positive net; a short book shows a negative net.
+
+---
+
+## 3. Source Data
+
+The report reads **contract lines** joined to their **linked shipments**. One source row per contract line, then aggregated by product.
+
+Primary source model: `commodity.contract.line` (or the Trad'On equivalent carrying targeted quantity, direction, product, and pricing state). The executed/shipped quantity is **not stored on the line** — it is derived by summing the linked shipment quantities.
+
+Fields required from source:
+
+- `contract.type` → buy / sell (leg direction)
+- `product` → grouping key
+- `product.commodity` → family + `risk_uom`
+- `targeted_quantity` → nominal/target contract quantity on the line
+- `finished` (boolean/state) → if the line is finished, open quantity is zeroed (see below)
+- linked **shipment** quantities → summed per line to obtain shipped quantity
+- `pricing_state` → fixed / unfixed / partly_fixed
+- `shipment_period` → for the period filter
+- `strategy` → for the strategy filter
+- `counterparty`
+
+**Open quantity derivation.**
+
+The line's execution progress is defined as:
+
+```
+quantity_executed = targeted_quantity − Σ(linked shipment quantities)
+```
+
+Here `quantity_executed` is the quantity **still remaining to execute** (target minus what has already shipped) — i.e. it *is* the open quantity. Therefore, subject to the finished-line rule below:
+
+```
+open_qty = targeted_quantity − Σ(linked shipment quantities) if line NOT finished
+open_qty = 0 if line IS finished
+```
+
+**Finished-line zeroing (balance zeroing).** A contract line can be **commercially closed before it is quantitatively complete**. When the line is marked *finished* — the option holder has declared the balance final under the tolerance framework — any remaining unshipped quantity ceases to be open exposure, even if `targeted_quantity − Σ(shipments) > 0`. A finished line therefore contributes **0** to the position regardless of the arithmetic. This guard takes precedence over the shipment sum: check the finished flag first, and only compute the residual for lines still open.
+
+Because there is no stored `quantity_executed` column, the report computes `Σ(shipment quantities)` directly from the shipment table (a `LEFT JOIN` + `SUM`, so lines with no shipment yet count their full target as open). Lines are excluded from the open position when **either**: the line is finished, **or** `open_qty <= 0` (fully executed, or over-delivered within tolerance — clamp to 0).
+
+---
+
+## 4. Column Specification
+
+The report has **one row per product** (default grouping). Columns:
+
+| # | Column name | Type | How to create it |
+|---|---|---|---|
+| 1 | **Product** | Char | Display name of the product (`product.rec_name`). Grouping key of the row. |
+| 2 | **Commodity** | Char | `product.commodity.name`. If the UoM is lots, append the quotation ref (e.g. "Coffee (lots)"). Used for sub-totals. |
+| 3 | **Pricing** | Selection badge | Aggregated pricing state of the open quantity for this product: `Fixed` if all open lines fixed; `Unfixed` if none fixed; `Partly fix` if mixed. Rendered as a status pill. |
+| 4 | **Shipment Pd** | Char | Earliest–latest shipment period spanning the product's open lines (e.g. "Q3 2026"). If a single period, show it; if several, show the range. |
+| 5 | **Strategy** | Char | Strategy/book code the product's open lines belong to (e.g. `ACID-EU`). If lines span strategies, show "Multiple" and expose detail on drill-down. |
+| 6 | **Bought (MT)** | Numeric, right-aligned | `Σ open_qty` over all **purchase** lines of this product, in `risk_uom`. 3 decimals for MT; integer for lots. |
+| 7 | **Sold (MT)** | Numeric, right-aligned | `Σ open_qty` over all **sale** lines of this product, in `risk_uom`. |
+| 8 | **Net (MT)** | Numeric, right-aligned | `Bought − Sold`. Colored: green if > 0, red if < 0, neutral if 0. This is the core figure. |
+| 9 | **Direction** | Selection badge | Derived from Net: `LONG` (Net > 0, green), `SHORT` (Net < 0, red), `Flat` (Net = 0, neutral). Rendered as a pill. |
+
+**Column header note:** columns 6–8 carry the unit label of the *row's* UoM. Because different rows may be in MT or lots, the header reads "Bought (MT / lots)" and each cell shows its own unit; totals are only summed **within** a UoM (see §5).
+
+**Optional drill-down columns** (hidden by default, shown on row expand — one sub-row per contract line): Contract ref, B/S, Counterparty, Open Qty, Pricing period, Shipment date.
+
+---
+
+## 5. Aggregation & Grouping
+
+- **Default grouping:** by Product (one row each).
+- **Optional grouping:** by Commodity (sub-totals per family), then Product. Tryton's list grouping handles this natively.
+- **Totals row (footer):** the "Net" column footer sums net positions **only across rows sharing the same UoM**. MT rows and lot rows are totalled separately and shown as two figures (e.g. `−7,880.000 MT eq` and `+120 lots`). Never sum MT and lots into one number.
+- **MT-equivalent option:** if the business wants one grand total, provide a `risk_uom` → MT conversion factor on the product (e.g. 1 KC lot = 37,500 lb ≈ 17.01 MT) and expose a "MT eq" toggle. Off by default to avoid misleading single totals.
+
+---
+
+## 6. Filter Criteria
+
+All filters are on the report's search bar (Tryton domain filters). Defaults in **bold**.
+
+| Filter | Values | Purpose |
+|---|---|---|
+| **Valuation Date** | date (default **today**) | Snapshot date; open quantity computed as of this date. |
+| **Commodity** | All / Metals / Coffee / Cocoa / Cotton / Grains / Iron Ore | Restrict to a commodity family. |
+| **Product** | All / specific product | Single-product focus. |
+| **Pricing Status** | All / Fixed / Unfixed / Partly fixed | Isolate unpriced (price-at-risk) quantity. |
+| **Shipment Period** | All / Q3 2026 / Q4 2026 / Q1 2027 … | Time-bucket the position. |
+| **Strategy** | All / strategy codes | Per-book view. |
+| **Counterparty** | All / specific | Concentration/limit checks. |
+| **Direction** *(optional)* | All / Long / Short / Flat | Show only exposed products. |
+
+Active filters appear as removable chips under the filter bar (Valuation date and Weight unit shown by default).
+
+---
+
+## 7. KPI Header Cards
+
+Five cards summarising the filtered result:
+
+| Card | Value | Computation |
+|---|---|---|
+| **Net Long** | Σ of positive nets (MT eq) | Sum of Net across products where Net > 0. |
+| **Net Short** | Σ of negative nets (MT eq) | Sum of Net across products where Net < 0. |
+| **Flat / Balanced** | count | Number of products with Net = 0. |
+| **Unfixed Qty** | MT | Σ open_qty where pricing_state = unfixed (price risk open). |
+| **Open Contracts** | count | Distinct open contract lines (buy + sell) in scope. |
+
+---
+
+## 8. Tryton Implementation
+
+### 8.1 Recommended pattern
+
+Build on the shared abstract base `commodity.risk.report` (proposed for the whole suite), which provides: valuation date, `risk_uom` resolution, and UoM/FX conversion helpers. Open Position needs only the quantity logic — no market pricing.
+
+### 8.2 Model
+
+A read-only, non-stored `ModelSQL` backed by a SQL view (`table_query`) is the right shape: it aggregates contract lines on the fly, so it is always live and stores nothing.
+
+```python
+# commodity_risk_reporting/open_position.py
+from decimal import Decimal
+from trytond.model import ModelSQL, ModelView, fields
+from trytond.pool import Pool
+from trytond.transaction import Transaction
+from sql import Literal
+from sql.aggregate import Sum
+from sql.conditionals import Case
+
+
+class OpenPosition(ModelSQL, ModelView):
+ "Open Position — Physical Net Long/Short"
+ __name__ = 'commodity.risk.open_position'
+
+ product = fields.Many2One('product.product', 'Product', readonly=True)
+ commodity = fields.Many2One(
+ 'commodity.commodity', 'Commodity', readonly=True)
+ pricing = fields.Selection([
+ ('fixed', 'Fixed'),
+ ('unfixed', 'Unfixed'),
+ ('partly', 'Partly fix'),
+ ], 'Pricing', readonly=True)
+ shipment_period = fields.Char('Shipment Pd', readonly=True)
+ strategy = fields.Many2One('commodity.strategy', 'Strategy', readonly=True)
+ bought = fields.Numeric('Bought', digits=(16, 3), readonly=True)
+ sold = fields.Numeric('Sold', digits=(16, 3), readonly=True)
+ net = fields.Numeric('Net', digits=(16, 3), readonly=True)
+ direction = fields.Selection([
+ ('long', 'LONG'),
+ ('short', 'SHORT'),
+ ('flat', 'Flat'),
+ ], 'Direction', readonly=True)
+ risk_uom = fields.Many2One('product.uom', 'Unit', readonly=True)
+
+ @classmethod
+ def table_query(cls):
+ pool = Pool()
+ Line = pool.get('commodity.contract.line')
+ Contract = pool.get('commodity.contract')
+ Shipment = pool.get('commodity.shipment') # linked shipments
+ line = Line.__table__()
+ contract = Contract.__table__()
+ shipment = Shipment.__table__()
+
+ # Step 1: shipped quantity per line = Σ of linked shipment quantities.
+ # Grouped subquery so we get one shipped total per contract line.
+ shipped = shipment.select(
+ shipment.contract_line.as_('line'),
+ Sum(shipment.quantity).as_('shipped_qty'),
+ group_by=[shipment.contract_line],
+ )
+
+ # Step 2: open_qty = targeted_quantity − shipped_qty.
+ # LEFT JOIN so lines with no shipment yet keep their full target open.
+ # Coalesce the (possibly NULL) shipped total to 0.
+ shipped_qty = Coalesce(shipped.shipped_qty, Literal(0))
+ residual = line.targeted_quantity - shipped_qty
+
+ # Finished-line zeroing: a line marked finished contributes 0 open qty,
+ # regardless of the residual (balance declared final by the option
+ # holder). This guard takes precedence over the shipment arithmetic.
+ # Combined with per-line over-delivery clamp (residual > 0).
+ open_qty = Case(
+ (line.finished, Literal(0)),
+ (residual > Literal(0), residual),
+ else_=Literal(0),
+ )
+
+ is_buy = contract.type == 'purchase'
+ bought = Sum(Case((is_buy, open_qty), else_=Literal(0)))
+ sold = Sum(Case((~is_buy, open_qty), else_=Literal(0)))
+
+ query = line.join(
+ contract, condition=line.contract == contract.id
+ ).join(
+ shipped, 'LEFT',
+ condition=shipped.line == line.id
+ ).select(
+ # a synthetic id per product group is required by Tryton
+ line.product.as_('id'),
+ line.product.as_('product'),
+ bought.as_('bought'),
+ sold.as_('sold'),
+ (bought - sold).as_('net'),
+ where=contract.state.in_(['confirmed', 'processing']),
+ group_by=[line.product],
+ # keep only products with a genuine open position;
+ # per-line clamping of over-delivery is handled in step 2 (see note)
+ having=(bought - sold) != Literal(0),
+ )
+ return query
+```
+
+> **Per-line guards folded into `open_qty`.** The `Case` above applies both rules before aggregation: a **finished** line yields 0 (balance zeroed), and a non-finished line yields its residual only when positive (over-delivery within tolerance clamps to 0). Doing this per line is essential — otherwise one finished or over-shipped line silently offsets a genuine open position elsewhere in the same product.
+
+> **Imports.** Add `from sql.functions import Coalesce` (or `from sql.conditionals import Coalesce` depending on the `python-sql` version).
+
+> **Other columns.** `pricing`, `shipment_period`, `strategy`, `commodity`, `direction`, and `risk_uom` are resolved either in SQL (extra joins with `MIN`/`MAX`/`CASE` aggregates) or as `fields.Function` computed in Python after the aggregate. `direction` is a pure function of `net` — `long` if `net > 0`, `short` if `net < 0`, else `flat` — best as a `fields.Function` with a searcher so the Direction filter works.
+
+### 8.3 View (list / tree)
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+Add colour via `tree` cell attributes / a `states` expression so Net is green when positive and red when negative, and render `pricing` and `direction` as badges.
+
+### 8.4 Menu & action
+
+Register under **Global Reporting → Positions → Open Position**, mirroring the existing Physical Position screen. Provide the search filters of §6 as `` search elements and a saved default filter on `valuation_date = today`.
+
+---
+
+## 9. Edge Cases & Business Rules
+
+1. **Over-delivery within tolerance.** If a line's shipments exceed its target (`Σ shipment qty > targeted_quantity`, so `open_qty < 0`), clamp that line to 0 — it is closed, not a negative open position. Clamp **per line before aggregating** (see the `CASE` note in §8.2), otherwise one over-shipped line silently offsets a genuine open position elsewhere in the same product.
+2. **Finished lines (balance zeroing).** A line marked *finished* contributes **0** open quantity even if `targeted_quantity − Σ(shipments) > 0`. The option holder has declared the balance final, so the residual is no longer exposure. This guard is checked first, ahead of the shipment arithmetic (see §8.2).
+3. **Unconfirmed / draft contracts.** Excluded (`state in ('confirmed', 'processing')` only). Draft quotes are not risk.
+4. **Mixed UoM within a product.** Should never happen — a product has exactly one `risk_uom`. Guard: if missing, flag the row rather than defaulting silently.
+5. **Lots vs MT totals.** Column footers must not add across UoM (see §5). This is the single most common reporting error for multi-commodity desks.
+6. **Multi-strategy products.** Show "Multiple" in the Strategy column; the per-line breakdown is available on drill-down.
+7. **Matched vs total quantity.** This report uses **open (undelivered) quantity**, consistent with the physical exposure definition. It does *not* use matched quantity — that distinction (flagged in tracker items TO-004/TO-023/TO-031) concerns P&L, not position, and does not affect this report.
+
+---
+
+## 10. Acceptance Criteria
+
+- [ ] One row per product with open contract lines; fully executed products absent.
+- [ ] `Net = Bought − Sold` holds for every row, in the product's `risk_uom`.
+- [ ] Direction badge matches the sign of Net (LONG/SHORT/Flat).
+- [ ] Footer totals separate MT and lots; no cross-UoM summation.
+- [ ] All eight filters (§6) apply correctly, Valuation Date defaults to today.
+- [ ] KPI cards recompute against the filtered set.
+- [ ] Draft/unconfirmed contracts excluded; over-delivered lines clamped to 0.
+- [ ] Finished lines contribute 0 open quantity even with a positive residual (balance zeroing).
+- [ ] Report is read-only and stores no records (live `table_query`).
+
+---
+
+*Spec version 1.0 — feeds RPT-03 (Net Position / Consolidated Risk) as its physical leg.*
diff --git a/modules/purchase_trade/global_reporting.xml b/modules/purchase_trade/global_reporting.xml
index b70cc6f..632b90b 100644
--- a/modules/purchase_trade/global_reporting.xml
+++ b/modules/purchase_trade/global_reporting.xml
@@ -39,16 +39,38 @@
Configuration
gr.configuration
-
-
-
-
-
-
-