You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ofbiz.apache.org by di...@apache.org on 2026/08/21 11:32:20 UTC

(ofbiz-framework) branch trunk updated (2dd1519560 -> 8312aec452)

This is an automated email from the ASF dual-hosted git repository.

diveshdut pushed a change to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git


    from 2dd1519560 Fixed: JWT refresh tokens no longer accepted as access tokens (OFBIZ-13493) (#1664)
     new d34901e830 OFBIZ-13460 Add MRP run lifecycle logging
     new fc1b597661 OFBIZ-13502 Add MRP event plan read model
     new cf23ae2e01 OFBIZ-13502 Add MRP run history read model
     new 8312aec452 OFBIZ-13502 Expose MRP planning REST API

The 4 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../manufacturing/api/mrp-planning.rest.xml        |   44 +
 .../manufacturing/config/ManufacturingUiLabels.xml |   69 ++
 .../manufacturing/servicedef/services_mrp.xml      |  115 +++
 .../manufacturing/mrp/MrpEventPlanBuilder.groovy   |  705 ++++++++++++++
 .../manufacturing/mrp/MrpEventPlanQuery.groovy     |  420 ++++++++
 .../manufacturing/mrp/MrpEventPlanServices.groovy  |   98 ++
 .../manufacturing/mrp/MrpRunLogServices.groovy     |  145 +++
 .../ofbiz/manufacturing/mrp/MrpServices.java       |  380 +++++++-
 .../api/MrpPlanningServicesTests.groovy            | 1026 ++++++++++++++++++++
 .../webapp/manufacturing/WEB-INF/controller.xml    |    2 +-
 10 files changed, 2961 insertions(+), 43 deletions(-)
 create mode 100644 applications/manufacturing/api/mrp-planning.rest.xml
 create mode 100644 applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/mrp/MrpEventPlanBuilder.groovy
 create mode 100644 applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/mrp/MrpEventPlanQuery.groovy
 create mode 100644 applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/mrp/MrpEventPlanServices.groovy
 create mode 100644 applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/mrp/MrpRunLogServices.groovy
 create mode 100644 applications/manufacturing/src/test/groovy/org/apache/ofbiz/manufacturing/api/MrpPlanningServicesTests.groovy


(ofbiz-framework) 02/04: OFBIZ-13502 Add MRP event plan read model

Posted by di...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

diveshdut pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git

commit fc1b597661dc4bb210125080eeb48e88f001667a
Author: diveshdut <di...@hotwax.co>
AuthorDate: Fri Aug 21 12:21:44 2026 +0530

    OFBIZ-13502 Add MRP event plan read model
    
    Add the read-side event-plan pipeline for grouped MRP event planning.
    
    Introduce MrpEventPlanServices.groovy as the service entry point for findMrpEventPlans and findMrpPlanningFacilities. It reads framework REST query options, delegates event loading and row preparation, and returns paged REST-compatible responses.
    
    Introduce MrpEventPlanQuery.groovy as the query/read helper. It builds MrpEventView conditions for facility, product, event type, date, search, latest-run fallback, and candidate row limits, then loads the raw events and enrichment data used by the planner rows.
    
    Introduce MrpEventPlanBuilder.groovy as the planner row builder. It groups raw MRP events by product and facility, builds visible timelines with running balances, and derives attention metadata such as actionNeeded, attentionTypes, primaryAttentionType, and attentionSummary.
    
    Register the event-plan and planning-facility services in services_mrp.xml and add the planner-facing labels used by the event-plan read model.
---
 .../manufacturing/config/ManufacturingUiLabels.xml |  57 ++
 .../manufacturing/servicedef/services_mrp.xml      |  49 ++
 .../manufacturing/mrp/MrpEventPlanBuilder.groovy   | 705 +++++++++++++++++++++
 .../manufacturing/mrp/MrpEventPlanQuery.groovy     | 420 ++++++++++++
 .../manufacturing/mrp/MrpEventPlanServices.groovy  |  98 +++
 5 files changed, 1329 insertions(+)

diff --git a/applications/manufacturing/config/ManufacturingUiLabels.xml b/applications/manufacturing/config/ManufacturingUiLabels.xml
index a2fc697327..5fa7973478 100644
--- a/applications/manufacturing/config/ManufacturingUiLabels.xml
+++ b/applications/manufacturing/config/ManufacturingUiLabels.xml
@@ -3004,6 +3004,18 @@
         <value xml:lang="zh">创建或更新生产资源计划事件(MrpEvent)时出错,参数:${parameters}</value>
         <value xml:lang="zh-TW">新建或更新生產資源計劃事件(MrpEvent)時出錯,參數:${parameters}</value>
     </property>
+    <property key="ManufacturingMrpCurrentBelowSafetyStock">
+        <value xml:lang="en">Current below safety stock</value>
+    </property>
+    <property key="ManufacturingMrpCurrentBelowSafetyStockGuidance">
+        <value xml:lang="en">Review proposed supply because current inventory is below the minimum stock policy.</value>
+    </property>
+    <property key="ManufacturingMrpEngineError">
+        <value xml:lang="en">MRP engine error</value>
+    </property>
+    <property key="ManufacturingMrpEngineErrorGuidance">
+        <value xml:lang="en">Review the MRP event ledger error before relying on this plan.</value>
+    </property>
     <property key="ManufacturingMrpErrorExplodingProduct">
         <value xml:lang="en">An error occurred exploding the product ${productId}</value>
         <value xml:lang="it">E' successo un'errore durante l'espolosizione del prodotto ${productId}</value>
@@ -3027,6 +3039,9 @@
         <value xml:lang="zh">运行初始化生产资源计划事件(initMrpEvents)服务时出错:${errorString}</value>
         <value xml:lang="zh-TW">執行初始化生產資源計劃事件(initMrpEvents)服務時出錯:${errorString}</value>
     </property>
+    <property key="ManufacturingMrpEventDateFilterInvalid">
+        <value xml:lang="en">Invalid MRP event date filter: ${value}</value>
+    </property>
     <property key="ManufacturingMrpEventFindError">
         <value xml:lang="en">Problem, we can not find all the items of MrpEvent, for more detail look at the log</value>
         <value xml:lang="it">Problema, non possaimo trovare tutte le righe di MrpEvent, per più dettagli controllare il log</value>
@@ -3043,6 +3058,9 @@
         <value xml:lang="zh">初始化生产资源计划事件(MrpEvent)实体(${mrpEventTypeId})时出问题了</value>
         <value xml:lang="zh-TW">初始化生產資源計劃事件(MrpEvent)資料實體(${mrpEventTypeId})時出問題了</value>
     </property>
+    <property key="ManufacturingMrpEventLedgerSource">
+        <value xml:lang="en">OFBiz MRP event ledger</value>
+    </property>
     <property key="ManufacturingMrpEventRemoveError">
         <value xml:lang="en">Problem, we can not remove the MrpEvent items, for more detail look at the log</value>
         <value xml:lang="it">Problema, non possaimo rimuovere le righe di MrpEvent, per più dettagli controllare il log</value>
@@ -3091,6 +3109,12 @@
         <value xml:lang="zh">场所标识(facilityId)和生产场所标识(manufacturingFacilityId)不能为空</value>
         <value xml:lang="zh-TW">場所標識(facilityId)和生產場所標識(manufacturingFacilityId)不能為空</value>
     </property>
+    <property key="ManufacturingMrpForecastAllocation">
+        <value xml:lang="en">Forecast allocation</value>
+    </property>
+    <property key="ManufacturingMrpForecastAllocationGuidance">
+        <value xml:lang="en">Allocate this organization-level forecast to a warehouse or review forecast setup.</value>
+    </property>
     <property key="ManufacturingMrpInitialisation">
         <value xml:lang="de">MRP-Initialisierung</value>
         <value xml:lang="en">IEP init.</value>
@@ -3160,6 +3184,12 @@
         <value xml:lang="zh">生产资源计划日志</value>
         <value xml:lang="zh-TW">生產資源計劃日誌</value>
     </property>
+    <property key="ManufacturingMrpLateProposedSupply">
+        <value xml:lang="en">Proposed supply is late</value>
+    </property>
+    <property key="ManufacturingMrpLateProposedSupplyGuidance">
+        <value xml:lang="en">Review the late event date and source before relying on the plan.</value>
+    </property>
     <property key="ManufacturingMrpName">
         <value xml:lang="de">MRP-Name</value>
         <value xml:lang="en">Mrp Name</value>
@@ -3174,6 +3204,21 @@
         <value xml:lang="zh">生产资源计划名称</value>
         <value xml:lang="zh-TW">生產資源計劃名稱</value>
     </property>
+    <property key="ManufacturingMrpNeedsAttention">
+        <value xml:lang="en">Needs attention</value>
+    </property>
+    <property key="ManufacturingMrpOnTrack">
+        <value xml:lang="en">On track</value>
+    </property>
+    <property key="ManufacturingMrpOpen">
+        <value xml:lang="en">Open</value>
+    </property>
+    <property key="ManufacturingMrpProjectedBelowSafetyStock">
+        <value xml:lang="en">Projected below safety stock</value>
+    </property>
+    <property key="ManufacturingMrpProjectedBelowSafetyStockGuidance">
+        <value xml:lang="en">Review projected inventory because the running balance falls below minimum stock.</value>
+    </property>
     <property key="ManufacturingMrpRunDefaultName">
         <value xml:lang="en">MRP run</value>
     </property>
@@ -3187,6 +3232,18 @@
         <value xml:lang="en">Mrp run scheduled successfully</value>
         <value xml:lang="fr">Exécution de Mrp programmée avec succès</value>
     </property>
+    <property key="ManufacturingMrpSetupIssue">
+        <value xml:lang="en">Setup issue</value>
+    </property>
+    <property key="ManufacturingMrpSetupIssueGuidance">
+        <value xml:lang="en">Review product-facility, BOM, routing, supplier, or inventory setup.</value>
+    </property>
+    <property key="ManufacturingMrpStockPolicyTrigger">
+        <value xml:lang="en">Stock policy trigger</value>
+    </property>
+    <property key="ManufacturingMrpStockPolicyTriggerDescription">
+        <value xml:lang="en">Current inventory is below minimum stock; MRP created this marker to trigger replenishment.</value>
+    </property>
     <property key="ManufacturingProductionRunTaskIssuedSuccessfully">
         <value xml:lang="en">Issued Inventory for Production Run Task [${workEffortId}]</value>
     </property>
diff --git a/applications/manufacturing/servicedef/services_mrp.xml b/applications/manufacturing/servicedef/services_mrp.xml
index ec0be0f5ae..3e22af4d1c 100644
--- a/applications/manufacturing/servicedef/services_mrp.xml
+++ b/applications/manufacturing/servicedef/services_mrp.xml
@@ -135,4 +135,53 @@ under the License.
         <description>Delete a MrpEventType record</description>
         <auto-attributes include="pk" mode="IN"/>
     </service>
+    <!-- MRP planning read services for event plans, run history, and planning facilities. -->
+    <service name="findMrpEventPlans" engine="groovy" auth="true"
+            location="component://manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/mrp/MrpEventPlanServices.groovy" invoke="findMrpEventPlans">
+        <description>Find product-centric MRP event plans with grouped facility timelines and attention metadata.</description>
+        <permission-service service-name="manufacturingPermissionService" main-action="VIEW"/>
+        <attribute mode="IN" name="facilityId" optional="true" type="String"/>
+        <attribute mode="IN" name="facilityGroupId" optional="true" type="String"/>
+        <attribute mode="IN" name="mrpId" optional="true" type="String"/>
+        <attribute mode="IN" name="productId" optional="true" type="String"/>
+        <attribute mode="IN" name="eventDate" optional="true" type="String"/>
+        <attribute mode="IN" name="dateFrom" optional="true" type="String"/>
+        <attribute mode="IN" name="dateTo" optional="true" type="String"/>
+        <attribute mode="IN" name="mrpEventTypeId" optional="true" type="String"/>
+        <attribute mode="IN" name="query" optional="true" type="String"/>
+        <attribute mode="IN" name="attentionOnly" optional="true" type="String"/>
+        <attribute mode="IN" name="attentionType" optional="true" type="String"/>
+        <attribute mode="IN" name="search" optional="true" type="String"/>
+        <attribute mode="IN" name="pageIndex" optional="true" type="Integer"/>
+        <attribute mode="IN" name="pageSize" optional="true" type="Integer"/>
+        <attribute mode="IN" name="sort" optional="true" type="String"/>
+        <attribute mode="IN" name="orderBy" optional="true" type="String"/>
+        <attribute mode="OUT" name="pageIndex" optional="false" type="Integer"/>
+        <attribute mode="OUT" name="pageSize" optional="false" type="Integer"/>
+        <attribute mode="OUT" name="totalCount" optional="false" type="Long"/>
+        <attribute mode="OUT" name="totalPages" optional="false" type="Long"/>
+        <attribute mode="OUT" name="hasNext" optional="false" type="Boolean"/>
+        <attribute mode="OUT" name="previousPageCount" optional="true" type="Long"/>
+        <attribute mode="OUT" name="nextPageCount" optional="true" type="Long"/>
+        <attribute mode="OUT" name="links" optional="true" type="Map"/>
+        <attribute mode="OUT" name="eventPlans" optional="false" type="java.util.List"/>
+    </service>
+    <service name="findPlanningFacilities" engine="groovy" auth="true"
+            location="component://manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/api/ManufacturingLookupServices.groovy" invoke="findManufacturingFacilities">
+        <description>Find warehouse and plant facilities available for MRP planning.</description>
+        <permission-service service-name="manufacturingPermissionService" main-action="VIEW"/>
+        <attribute mode="IN" name="pageIndex" optional="true" type="Integer"/>
+        <attribute mode="IN" name="pageSize" optional="true" type="Integer"/>
+        <attribute mode="IN" name="sort" optional="true" type="String"/>
+        <attribute mode="IN" name="orderBy" optional="true" type="String"/>
+        <attribute mode="OUT" name="pageIndex" optional="false" type="Integer"/>
+        <attribute mode="OUT" name="pageSize" optional="false" type="Integer"/>
+        <attribute mode="OUT" name="totalCount" optional="false" type="Long"/>
+        <attribute mode="OUT" name="totalPages" optional="false" type="Long"/>
+        <attribute mode="OUT" name="hasNext" optional="false" type="Boolean"/>
+        <attribute mode="OUT" name="previousPageCount" optional="true" type="Long"/>
+        <attribute mode="OUT" name="nextPageCount" optional="true" type="Long"/>
+        <attribute mode="OUT" name="links" optional="true" type="Map"/>
+        <attribute mode="OUT" name="facilities" optional="false" type="java.util.List"/>
+    </service>
 </services>
diff --git a/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/mrp/MrpEventPlanBuilder.groovy b/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/mrp/MrpEventPlanBuilder.groovy
new file mode 100644
index 0000000000..28288ea3ee
--- /dev/null
+++ b/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/mrp/MrpEventPlanBuilder.groovy
@@ -0,0 +1,705 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.ofbiz.manufacturing.mrp
+
+import java.nio.charset.StandardCharsets
+import java.sql.Timestamp
+
+import org.apache.ofbiz.base.util.UtilProperties
+import org.apache.ofbiz.base.util.UtilGenerics
+import org.apache.ofbiz.base.util.UtilValidate
+import org.apache.ofbiz.base.util.collections.MapComparator
+import org.apache.ofbiz.entity.GenericValue
+import org.apache.ofbiz.manufacturing.api.ManufacturingServiceUtil
+
+/*
+ * Converts raw MrpEventView ledger rows into grouped product/warehouse plan rows.
+ *
+ * Pipeline:
+ * 1. buildWarehouseEventPlanContexts groups raw rows by MRP run, product, and facility.
+ * 2. buildWarehouseEventPlanContext builds the working context for one product/warehouse group.
+ * 3. buildRunningBalanceTimeline calculates running inventory balances and timeline metadata.
+ * 4. buildEventPlanResponseRow shapes the context into the service response row.
+ * 5. buildAttentionSignals and buildInventoryContext add planner action and inventory facts.
+ */
+final class MrpEventPlanBuilder {
+
+    static final String MRP_OPEN_STATUS_ID = 'MRP_OPEN'
+    private static final String RESOURCE = 'ManufacturingUiLabels'
+    static final Set EVENT_PLAN_SORT_FIELDS = [
+            'productId',
+            'productName',
+            'facilityId',
+            'facilityName',
+            'statusId',
+            'startingQoh',
+            'lowestRunningBalance',
+            'minimumStock'
+    ] as Set
+
+    private static final Map SEVERITY_PRIORITY = [HIGH: 0, MEDIUM: 1].asImmutable()
+    private static final List SETUP_SIGNAL_KEYWORDS = ['SETUP', 'BOM', 'ROUTING', 'SUPPLIER', 'ERROR'].asImmutable()
+
+    private MrpEventPlanBuilder() { }
+
+    // Event Plan Pipeline
+
+    /*
+     * Raw MRP events are ledger rows, so group them before presenting planner-ready balances. The same product can have
+     * demand in WH1 and PLANT1, each with its own ProductFacility minimum stock. Grouping by product/facility prevents
+     * those balances from being mixed before attention metadata is calculated.
+     *
+     * Input shape:
+     * [
+     *   [mrpId: '10022', productId: 'GZ-8544', facilityId: 'WebStoreWarehouse', quantity: 18],
+     *   [mrpId: '10022', productId: 'GZ-8544', facilityId: 'WebStoreWarehouse', quantity: -20],
+     *   [mrpId: '10022', productId: 'GZ-8544', facilityId: 'WebStoreWarehouse', quantity: 50]
+     * ]
+     *
+     * Output shape:
+     * [
+     *   '10022|GZ-8544|WebStoreWarehouse': [
+     *     mrpId: '10022',
+     *     productId: 'GZ-8544',
+     *     facilityId: 'WebStoreWarehouse',
+     *     startingQoh: 18,
+     *     timeline: [...],
+     *     hasInitialQoh: true
+     *   ]
+     * ]
+     *
+     * Example grouped plan:
+     * GZ-8544 at WebStoreWarehouse
+     *   starting QOH: 18
+     *   demand: -20
+     *   proposed supply: +50
+     *   final balance: 48
+     *   attention: on track / needs attention
+     */
+    static Map buildWarehouseEventPlanContexts(List events, Map eventPlanReferenceData, Locale locale = Locale.ENGLISH) {
+        return events.groupBy { GenericValue event -> [event.getString('mrpId'), event.getString('productId')].join('|') }
+                .collectEntries { String productKey, List productEvents ->
+                    List productLevelEvents = productEvents.findAll { GenericValue event ->
+                        UtilValidate.isEmpty(event.getString('facilityId'))
+                    }
+                    productEvents.findAll { GenericValue event -> UtilValidate.isNotEmpty(event.getString('facilityId')) }
+                            .groupBy { GenericValue event -> eventPlanGroupKey(event) }
+                            .collectEntries { String key, List warehouseEvents ->
+                                [(key): buildWarehouseEventPlanContext(key, warehouseEvents + productLevelEvents,
+                                        eventPlanReferenceData, locale)]
+                            }
+                }
+    }
+
+    /*
+     * Prepares one product/warehouse plan context from a grouped MRP event stream. The context centralizes ids, display
+     * names, stock policy, timeline balances, and setup flags so the final service row can be assembled without
+     * re-querying or recalculating those values.
+     *
+     * Input example:
+     * key = '10022|GZ-8544|WebStoreWarehouse'
+     * events = [
+     *   [mrpEventTypeId: 'INITIAL_QOH', quantity: 18],
+     *   [mrpEventTypeId: 'SALES_ORDER', quantity: -20],
+     *   [mrpEventTypeId: 'PROP_PUR_O_RECP', quantity: 50]
+     * ]
+     *
+     * Output example:
+     * [
+     *   key: '10022|GZ-8544|WebStoreWarehouse',
+     *   mrpId: '10022',
+     *   productId: 'GZ-8544',
+     *   productName: 'Demo Gizmo',
+     *   facilityId: 'WebStoreWarehouse',
+     *   facilityName: 'Web Store Warehouse',
+     *   minimumStock: 20,
+     *   startingQoh: 18,
+     *   timeline: [[quantity: 18, runningBalance: 18], [quantity: -20, runningBalance: -2]],
+     *   hasInitialQoh: true,
+     *   hasSetupSignal: false
+     * ]
+     */
+    static Map buildWarehouseEventPlanContext(String key, List events, Map eventPlanReferenceData, Locale locale = Locale.ENGLISH) {
+        List sortedEvents = events.toSorted({ GenericValue left, GenericValue right ->
+            compareMrpTimelineEvents(left, right)
+        } as Comparator)
+        GenericValue firstEvent = UtilValidate.isNotEmpty(sortedEvents) ? sortedEvents.first() : null
+        /*
+         * Product-level forecast rows can have no facility; preserve the grouped key parts as a fallback for context
+         * ids.
+         */
+        String[] keyParts = key.split('\\|', -1)
+        String productIdFromKey = keyParts.length > 1 && UtilValidate.isNotEmpty(keyParts[1]) ? keyParts[1] : null
+        String facilityIdFromKey = keyParts.length > 2 && UtilValidate.isNotEmpty(keyParts[2]) ? keyParts[2] : null
+        String mrpId = firstEvent?.getString('mrpId')
+        String productId = firstEvent?.getString('productId') ?: productIdFromKey
+        String facilityId = firstEvent?.getString('facilityId') ?: facilityIdFromKey
+        GenericValue product = eventPlanReferenceData.products[productId]
+        GenericValue facility = eventPlanReferenceData.facilities[facilityId]
+        GenericValue productFacility = eventPlanReferenceData.productFacilities[productFacilityLookupKey(productId, facilityId)]
+        BigDecimal minimumStock = productFacility?.getBigDecimal('minimumStock')
+        BigDecimal reorderQuantity = productFacility?.getBigDecimal('reorderQuantity')
+        Long productFacilityDaysToShip = productFacility?.getLong('daysToShip')
+        Long daysToShip = productFacilityDaysToShip != null ? productFacilityDaysToShip :
+                facility?.getLong('defaultDaysToShip')
+        List timeline = buildRunningBalanceTimeline(sortedEvents, eventPlanReferenceData, productFacility, locale)
+        return [
+                key: key,
+                mrpId: mrpId,
+                productId: productId,
+                productName: ManufacturingServiceUtil.displayProductName(product),
+                facilityId: facilityId,
+                facilityName: ManufacturingServiceUtil.displayFacilityName(facility),
+                minimumStock: minimumStock,
+                reorderQuantity: reorderQuantity,
+                daysToShip: daysToShip,
+                startingQoh: sortedEvents.find { GenericValue event ->
+                    event.getString('mrpEventTypeId') == 'INITIAL_QOH'
+                }?.getBigDecimal('quantity'),
+                timeline: timeline,
+                events: plannerVisibleTimelineRows(sortedEvents),
+                hasInitialQoh: sortedEvents.any { GenericValue event -> event.getString('mrpEventTypeId') == 'INITIAL_QOH' },
+                hasSetupSignal: sortedEvents.any { GenericValue event ->
+                    String eventDisplayDescription = event.getString('eventName')?.toUpperCase()
+                    event.getString('mrpEventTypeId') == 'ERROR' ||
+                            (UtilValidate.isNotEmpty(eventDisplayDescription) && SETUP_SIGNAL_KEYWORDS.any {
+                                eventDisplayDescription.contains(it)
+                            })
+                }
+        ]
+    }
+
+    // Timeline Preparation
+
+    // Hides internal planning markers from planner-facing evidence while keeping them in balance calculations.
+    static List plannerVisibleTimelineRows(List events) {
+        return events.findAll { event ->
+            String mrpEventTypeId = event instanceof GenericValue ? event.getString('mrpEventTypeId') :
+                    event.mrpEventTypeId
+            mrpEventTypeId != 'REQUIRED_MRP'
+        }
+    }
+
+    /*
+     * Calculates running balances and timeline metadata for one grouped plan. REQUIRED_MRP markers stay in the
+     * arithmetic even though plannerVisibleTimelineRows hides them from planner-facing evidence.
+     *
+     * Input example:
+     * events = [
+     *   [mrpEventTypeId: 'INITIAL_QOH', quantity: 18],
+     *   [mrpEventTypeId: 'SALES_ORDER', quantity: -20],
+     *   [mrpEventTypeId: 'PROP_PUR_O_RECP', quantity: 50]
+     * ]
+     *
+     * Output example:
+     * [
+     *   [mrpEventTypeId: 'INITIAL_QOH', quantity: 18, runningBalance: 18, belowMinimumStock: true],
+     *   [mrpEventTypeId: 'SALES_ORDER', quantity: -20, runningBalance: -2, belowMinimumStock: true],
+     *   [mrpEventTypeId: 'PROP_PUR_O_RECP', quantity: 50, runningBalance: 48, belowMinimumStock: false]
+     * ]
+     */
+    static List buildRunningBalanceTimeline(List events, Map eventPlanReferenceData, GenericValue productFacility,
+                                             Locale locale = Locale.ENGLISH) {
+        BigDecimal runningBalance = BigDecimal.ZERO
+        BigDecimal minimumStock = productFacility?.getBigDecimal('minimumStock')
+        return events.collect { GenericValue event ->
+            String mrpId = event.getString('mrpId')
+            String mrpEventTypeId = event.getString('mrpEventTypeId')
+            String productId = event.getString('productId')
+            String facilityId = event.getString('facilityId')
+            String eventName = event.getString('eventName')
+            Timestamp eventDate = event.getTimestamp('eventDate')
+            BigDecimal quantity = event.getBigDecimal('quantity') ?: BigDecimal.ZERO
+            runningBalance = runningBalance.add(quantity)
+            GenericValue eventType = eventPlanReferenceData.eventTypes[mrpEventTypeId]
+            GenericValue product = eventPlanReferenceData.products[productId]
+            GenericValue facility = eventPlanReferenceData.facilities[facilityId]
+            String facilityName = ManufacturingServiceUtil.displayFacilityName(facility)
+            if (UtilValidate.isEmpty(facilityName) && mrpEventTypeId == 'SALES_FORECAST' &&
+                    UtilValidate.isEmpty(facilityId)) {
+                facilityName = 'Organization-level'
+            }
+            GenericValue rowProductFacility = eventPlanReferenceData.productFacilities[productFacilityLookupKey(productId,
+                    facilityId)]
+            BigDecimal rowMinimumStock = rowProductFacility?.getBigDecimal('minimumStock') ?: minimumStock
+            Long rowProductFacilityDaysToShip = rowProductFacility?.getLong('daysToShip')
+            Map requirementReference = extractProposedSupplyRequirementReference(event)
+            [
+                    eventId: buildTimelineEventId(event),
+                    mrpId: mrpId,
+                    mrpEventTypeId: mrpEventTypeId,
+                    eventTypeLabel: eventTypeDisplayLabel(mrpEventTypeId, eventType, quantity, locale),
+                    eventDescription: eventDisplayDescription(event, eventType, quantity, locale),
+                    eventDate: eventDate,
+                    quantity: quantity,
+                    runningBalance: runningBalance,
+                    isLate: event.getString('isLate') == 'Y',
+                    sourceId: eventName,
+                    requirementId: requirementReference.requirementId,
+                    requirementDate: requirementReference.requirementDate,
+                    statusId: MRP_OPEN_STATUS_ID,
+                    statusDescription: ManufacturingServiceUtil.displayStatusDescription(
+                            eventPlanReferenceData.statuses[MRP_OPEN_STATUS_ID]) ?: label('ManufacturingMrpOpen', locale),
+                    productId: productId,
+                    productName: ManufacturingServiceUtil.displayProductName(product),
+                    facilityId: facilityId,
+                    facilityName: facilityName,
+                    minimumStock: rowMinimumStock,
+                    reorderQuantity: rowProductFacility?.getBigDecimal('reorderQuantity'),
+                    daysToShip: rowProductFacilityDaysToShip != null ? rowProductFacilityDaysToShip :
+                            facility?.getLong('defaultDaysToShip'),
+                    startingQoh: null,
+                    belowMinimumStock: rowMinimumStock != null && runningBalance < rowMinimumStock
+            ]
+        }.with { List rows ->
+            BigDecimal firstQoh = rows.find { Map row -> row.mrpEventTypeId == 'INITIAL_QOH' }?.quantity
+            rows.each { Map row -> row.startingQoh = firstQoh }
+            rows
+        }
+    }
+
+    // Response Row Shaping
+
+    /*
+     * Converts one prepared product/warehouse context into the Event Plans response row consumed by clients.
+     *
+     * Input example:
+     * groupContext = [
+     *   productId: 'GZ-8544',
+     *   facilityId: 'WebStoreWarehouse',
+     *   startingQoh: 18,
+     *   minimumStock: 20,
+     *   timeline: [[quantity: 18, runningBalance: 18], [quantity: -20, runningBalance: -2]]
+     * ]
+     *
+     * Output example:
+     * [
+     *   planId: 'mrp_...',
+     *   productId: 'GZ-8544',
+     *   facilityId: 'WebStoreWarehouse',
+     *   statusDescription: 'Needs attention',
+     *   startingQoh: 18,
+     *   lowestRunningBalance: -2,
+     *   actionNeeded: true,
+     *   attentionTypes: ['BELOW_SAFETY_STOCK'],
+     *   inventoryContext: [projectedBalance: 48, lowestRunningBalance: -2],
+     *   evidence: [...],
+     *   timeline: [...]
+     * ]
+     */
+    static Map buildEventPlanResponseRow(Map groupContext, Locale locale = Locale.ENGLISH) {
+        Map inventory = buildInventoryContext(groupContext.timeline.first() ?: [:], groupContext)
+        List planSignals = buildAttentionSignals(groupContext, locale)
+        List actionSignals = planSignals.findAll { Map signal -> signal.actionNeeded != false }
+        Map primarySignal = actionSignals.min { Map signal -> signal.severityPriority } ?: [:]
+        return [
+                planId: buildEventPlanId(groupContext),
+                mrpId: groupContext.mrpId,
+                productId: groupContext.productId,
+                productName: groupContext.productName,
+                facilityId: groupContext.facilityId,
+                facilityName: groupContext.facilityName,
+                statusId: MRP_OPEN_STATUS_ID,
+                statusDescription: UtilValidate.isNotEmpty(actionSignals) ? label('ManufacturingMrpNeedsAttention', locale) :
+                        label('ManufacturingMrpOnTrack', locale),
+                minimumStock: groupContext.minimumStock,
+                reorderQuantity: groupContext.reorderQuantity,
+                daysToShip: groupContext.daysToShip,
+                startingQoh: groupContext.startingQoh,
+                lowestRunningBalance: inventory.lowestRunningBalance,
+                actionNeeded: UtilValidate.isNotEmpty(actionSignals),
+                attentionSeverity: primarySignal.severity,
+                attentionTypes: actionSignals*.type,
+                primaryAttentionType: primarySignal.type,
+                attentionSummary: actionSignals*.summary.findAll { it }.join('; '),
+                inlineGuidance: primarySignal.inlineGuidance,
+                inventoryContext: inventory,
+                evidence: plannerVisibleTimelineRows(groupContext.timeline),
+                timeline: groupContext.timeline
+        ]
+    }
+
+    // Planner Attention
+
+    /*
+     * Produces planner-action chips and guidance from the prepared timeline context.
+     *
+     * Input example:
+     * groupContext = [
+     *   minimumStock: 20,
+     *   timeline: [[mrpEventTypeId: 'SALES_ORDER', runningBalance: -2, belowMinimumStock: true]]
+     * ]
+     *
+     * Output example:
+     * [
+     *   [
+     *     type: 'BELOW_SAFETY_STOCK',
+     *     severity: 'HIGH',
+     *     actionNeeded: true,
+     *     summary: 'Projected below safety stock',
+     *     inlineGuidance: [
+     *       recommendation: 'Review projected inventory because the running balance falls below minimum stock.'
+     *     ]
+     *   ]
+     * ]
+     */
+    static List buildAttentionSignals(Map groupContext, Locale locale = Locale.ENGLISH) {
+        List signals = []
+        if (groupContext.timeline.any { Map row -> row.isLate }) {
+            signals.add(buildAttentionSignal('LATE_EVENT', 'HIGH',
+                    label('ManufacturingMrpLateProposedSupply', locale),
+                    label('ManufacturingMrpLateProposedSupplyGuidance', locale), locale))
+        }
+        if (groupContext.timeline.any { Map row -> row.mrpEventTypeId == 'ERROR' }) {
+            signals.add(buildAttentionSignal('ENGINE_ERROR', 'HIGH',
+                    label('ManufacturingMrpEngineError', locale),
+                    label('ManufacturingMrpEngineErrorGuidance', locale), locale))
+        }
+        Map safetyStockEvent = findUnresolvedSafetyStockEvent(groupContext)
+        if (UtilValidate.isNotEmpty(safetyStockEvent)) {
+            boolean currentBelowSafetyStock = safetyStockEvent.mrpEventTypeId == 'INITIAL_QOH'
+            signals.add(buildAttentionSignal('BELOW_SAFETY_STOCK', attentionSeverityFor('BELOW_SAFETY_STOCK', safetyStockEvent),
+                    currentBelowSafetyStock ? label('ManufacturingMrpCurrentBelowSafetyStock', locale) :
+                            label('ManufacturingMrpProjectedBelowSafetyStock', locale),
+                    currentBelowSafetyStock
+                            ? label('ManufacturingMrpCurrentBelowSafetyStockGuidance', locale)
+                            : label('ManufacturingMrpProjectedBelowSafetyStockGuidance', locale), locale))
+        } else {
+            Map recoveredSafetyStockEvent = groupContext.timeline.findAll { Map row -> row.belowMinimumStock }
+                    .min { Map row -> row.runningBalance ?: BigDecimal.ZERO }
+            if (UtilValidate.isNotEmpty(recoveredSafetyStockEvent)) {
+                signals.add(buildAttentionSignal('BELOW_SAFETY_STOCK', attentionSeverityFor('BELOW_SAFETY_STOCK',
+                        recoveredSafetyStockEvent), null, null, locale, [actionNeeded: false]))
+            }
+        }
+        // Missing initial QOH usually points to setup data, not a normal inventory movement.
+        if ((!groupContext.hasInitialQoh && UtilValidate.isNotEmpty(groupContext.facilityId)) ||
+                groupContext.hasSetupSignal) {
+            signals.add(buildAttentionSignal('SETUP_ISSUE', 'HIGH',
+                    label('ManufacturingMrpSetupIssue', locale),
+                    label('ManufacturingMrpSetupIssueGuidance', locale), locale))
+        }
+        if (groupContext.timeline.any { Map row -> isOrganizationLevelForecast(row) }) {
+            signals.add(buildAttentionSignal('FORECAST_ALLOCATION', 'MEDIUM',
+                    label('ManufacturingMrpForecastAllocation', locale),
+                    label('ManufacturingMrpForecastAllocationGuidance', locale), locale))
+        }
+        return signals.unique { Map signal -> signal.type }
+    }
+
+    // Filtering And Sorting
+
+    // Applies MRP-specific filters after framework paging/sort parameters have been parsed.
+    static boolean matchesEventPlanFilters(Map plan, Map filters) {
+        if (Boolean.valueOf(filters.attentionOnly?.toString()) && !plan.actionNeeded) {
+            return false
+        }
+        if (UtilValidate.isNotEmpty(filters.attentionType) && !(filters.attentionType in (plan.attentionTypes ?: []))) {
+            return false
+        }
+        String queryText = UtilValidate.isNotEmpty(filters.query) ? filters.query.toString().trim() :
+                (UtilValidate.isNotEmpty(filters.search) ? filters.search.toString().trim() : null)
+        return matchesEventPlanSearch(plan, queryText)
+    }
+
+    // Provides deterministic fallback ordering when no framework sort is requested.
+    static Comparator<Map<String, Object>> buildEventPlanFallbackComparator() {
+        return { Map left, Map right ->
+            new MapComparator(['productId', 'facilityId', 'mrpId']).compare(UtilGenerics.cast(left),
+                    UtilGenerics.cast(right))
+        } as Comparator
+    }
+
+    // Applies the same free-text query to already-prepared rows after grouping and computed fields are available.
+    static boolean matchesEventPlanSearch(Map row, String queryText) {
+        if (UtilValidate.isEmpty(queryText)) {
+            return true
+        }
+        String needle = queryText.toUpperCase()
+        return [row.productId, row.productName, row.facilityId, row.facilityName, row.exceptionType,
+                row.eventDescription, row.mrpId].find { Object value ->
+            value?.toString()?.toUpperCase()?.contains(needle)
+        } != null
+    }
+
+    // Timeline Ordering And Stable IDs
+
+    // Ensures running balances start with initial QOH before same-run demand/supply events are applied.
+    static int compareMrpTimelineEvents(GenericValue left, GenericValue right) {
+        Map leftFields = [
+                eventSortBucket: left.getString('mrpEventTypeId') == 'INITIAL_QOH' ? 0 : 1,
+                eventDate: left.getTimestamp('eventDate'),
+                mrpEventTypeId: left.getString('mrpEventTypeId'),
+                eventName: left.getString('eventName')
+        ]
+        Map rightFields = [
+                eventSortBucket: right.getString('mrpEventTypeId') == 'INITIAL_QOH' ? 0 : 1,
+                eventDate: right.getTimestamp('eventDate'),
+                mrpEventTypeId: right.getString('mrpEventTypeId'),
+                eventName: right.getString('eventName')
+        ]
+        return new MapComparator(['eventSortBucket', 'eventDate', 'mrpEventTypeId', 'eventName']).compare(
+                UtilGenerics.cast(leftFields), UtilGenerics.cast(rightFields))
+    }
+
+    // Builds a stable id for computed timeline rows, which do not have a single DB primary key.
+    static String buildTimelineEventId(Object event) {
+        Object eventDate = event instanceof GenericValue ? event.getTimestamp('eventDate') : event.eventDate
+        String eventDateKey = eventDate instanceof Timestamp ? Long.toString(eventDate.time) : eventDate?.toString()
+        return encodeCompositeId(['event',
+                event instanceof GenericValue ? event.getString('mrpId') : event.mrpId,
+                event instanceof GenericValue ? event.getString('productId') : event.productId,
+                event instanceof GenericValue ? event.getString('facilityId') : event.facilityId,
+                eventDateKey,
+                event instanceof GenericValue ? event.getString('mrpEventTypeId') : event.mrpEventTypeId])
+    }
+
+    // Builds a stable id for computed product/warehouse plan rows.
+    static String buildEventPlanId(Map groupContext) {
+        return encodeCompositeId(['plan', groupContext.mrpId, groupContext.productId, groupContext.facilityId])
+    }
+
+    // Creates an in-memory grouping key for mrpId/productId/facilityId.
+    static String eventPlanGroupKey(Object value) {
+        if (value instanceof GenericValue) {
+            return [value.getString('mrpId'), value.getString('productId'), value.getString('facilityId') ?: ''].join('|')
+        }
+        return [value.mrpId, value.productId, value.facilityId ?: ''].join('|')
+    }
+
+    // Creates a lookup key for batched ProductFacility reference rows.
+    static String productFacilityLookupKey(Object productId, Object facilityId) {
+        return [productId ?: '', facilityId ?: ''].join('|')
+    }
+
+    // Internal Attention Helpers
+
+    // Organization-level forecasts need planner allocation before warehouse plans can be trusted.
+    private static boolean isOrganizationLevelForecast(Map event) {
+        return event?.mrpEventTypeId == 'SALES_FORECAST' && UtilValidate.isEmpty(event.facilityId)
+    }
+
+    // Finds the first unresolved balance problem that still needs planner attention.
+    private static Object findUnresolvedSafetyStockEvent(Map groupContext) {
+        if (UtilValidate.isEmpty(groupContext?.timeline)) {
+            return null
+        }
+
+        Map negativeBalanceEvent = groupContext.timeline
+                .findAll { Map event -> event.runningBalance != null && event.runningBalance < BigDecimal.ZERO }
+                .min { Map event -> event.runningBalance ?: BigDecimal.ZERO }
+        if (UtilValidate.isNotEmpty(negativeBalanceEvent)) {
+            return negativeBalanceEvent
+        }
+
+        if (groupContext.minimumStock == null) {
+            return null
+        }
+
+        Map finalEvent = finalPlannerVisibleTimelineRow(groupContext.timeline)
+        if (finalEvent?.runningBalance != null && finalEvent.runningBalance < groupContext.minimumStock) {
+            return finalEvent
+        }
+
+        Map firstBelowMinimum = groupContext.timeline.find { Map event -> event.belowMinimumStock }
+        if (UtilValidate.isEmpty(firstBelowMinimum)) {
+            return null
+        }
+
+        if (firstBelowMinimum.mrpEventTypeId != 'INITIAL_QOH') {
+            return firstBelowMinimum
+        }
+
+        return hasOnTimeProposedSupplyRecovery(groupContext) ? null : firstBelowMinimum
+    }
+
+    // Uses the last user-visible event when deciding whether the plan recovers.
+    private static Map finalPlannerVisibleTimelineRow(List timeline) {
+        List rows = plannerVisibleTimelineRows(timeline)
+        return UtilValidate.isNotEmpty(rows) ? rows.last() : timeline.last()
+    }
+
+    // A below-minimum starting balance is acceptable if on-time proposed supply recovers the plan.
+    private static boolean hasOnTimeProposedSupplyRecovery(Map groupContext) {
+        Map finalEvent = finalPlannerVisibleTimelineRow(groupContext.timeline)
+        if (finalEvent?.runningBalance == null || finalEvent.runningBalance < groupContext.minimumStock) {
+            return false
+        }
+        return groupContext.timeline.any { Map event ->
+            event.mrpEventTypeId in ['PROP_PUR_O_RECP', 'PROP_MANUF_O_RECP'] &&
+                    event.quantity > BigDecimal.ZERO && !event.isLate
+        }
+    }
+
+    /*
+     * Creates the normalized attention-chip payload used by filters, summaries, and inline planner guidance.
+     *
+     * Input example:
+     * type = 'BELOW_SAFETY_STOCK'
+     * severity = 'HIGH'
+     * summary = 'Projected below safety stock'
+     * recommendation = 'Review projected inventory because the running balance falls below minimum stock.'
+     * fields = [actionNeeded: true]
+     *
+     * Output example:
+     * [
+     *   type: 'BELOW_SAFETY_STOCK',
+     *   severity: 'HIGH',
+     *   severityPriority: 0,
+     *   summary: 'Projected below safety stock',
+     *   actionNeeded: true,
+     *   inlineGuidance: [
+     *     recommendation: 'Review projected inventory because the running balance falls below minimum stock.',
+     *     source: 'OFBiz MRP event ledger'
+     *   ]
+     * ]
+     */
+    private static Map buildAttentionSignal(String type, String severity, String summary, String recommendation,
+                                            Locale locale = Locale.ENGLISH, Map fields = [:]) {
+        Integer severityPriority = SEVERITY_PRIORITY.containsKey(severity) ? SEVERITY_PRIORITY[severity] : 2
+        Map signal = [
+                type: type,
+                severity: severity,
+                severityPriority: severityPriority,
+                summary: summary,
+                actionNeeded: fields.actionNeeded != false
+        ]
+        if (UtilValidate.isNotEmpty(recommendation)) {
+            signal.inlineGuidance = [
+                    recommendation: recommendation,
+                    source: label('ManufacturingMrpEventLedgerSource', locale)
+            ]
+        }
+        return signal
+    }
+
+    // Internal Row And Label Helpers
+
+    /*
+     * Collects inventory balance facts separately from display-oriented attention summaries.
+     *
+     * Input example:
+     * summary = [runningBalance: 48]
+     * groupContext = [
+     *   productId: 'GZ-8544',
+     *   facilityId: 'WebStoreWarehouse',
+     *   startingQoh: 18,
+     *   minimumStock: 20,
+     *   reorderQuantity: 50,
+     *   timeline: [[runningBalance: 18], [runningBalance: -2, belowMinimumStock: true], [runningBalance: 48]]
+     * ]
+     *
+     * Output example:
+     * [
+     *   productId: 'GZ-8544',
+     *   facilityId: 'WebStoreWarehouse',
+     *   startingQoh: 18,
+     *   minimumStock: 20,
+     *   reorderQuantity: 50,
+     *   projectedBreachAmount: 22,
+     *   projectedBalance: 48,
+     *   lowestRunningBalance: -2,
+     *   breachDate: null
+     * ]
+     */
+    private static Map buildInventoryContext(Map summary, Map groupContext) {
+        BigDecimal lowestBalance = groupContext.timeline*.runningBalance.findAll { it != null }.min()
+        Map breach = groupContext.timeline.find { Map row -> row.belowMinimumStock }
+        BigDecimal projectedBreachAmount = null
+        if (breach?.runningBalance != null && groupContext.minimumStock != null) {
+            projectedBreachAmount = groupContext.minimumStock.subtract(breach.runningBalance)
+        }
+        return [
+                productId: groupContext.productId,
+                productName: groupContext.productName,
+                facilityId: groupContext.facilityId,
+                facilityName: groupContext.facilityName,
+                startingQoh: groupContext.startingQoh,
+                minimumStock: groupContext.minimumStock,
+                reorderQuantity: groupContext.reorderQuantity,
+                daysToShip: groupContext.daysToShip,
+                projectedBreachAmount: projectedBreachAmount,
+                projectedBalance: summary.runningBalance,
+                lowestRunningBalance: lowestBalance,
+                breachDate: breach?.eventDate
+        ]
+    }
+
+    // Uses MrpEventType.description from seed data, except for the zero-quantity stock policy marker.
+    private static String eventTypeDisplayLabel(Object mrpEventTypeId, GenericValue eventType, BigDecimal quantity = null,
+                                                Locale locale = Locale.ENGLISH) {
+        if (isStockPolicyTrigger(mrpEventTypeId, quantity)) {
+            return label('ManufacturingMrpStockPolicyTrigger', locale)
+        }
+        String description = eventType?.getString('description')
+        return UtilValidate.isNotEmpty(description) ? description : mrpEventTypeId
+    }
+
+    // Explains synthetic stock-policy markers differently from ordinary event descriptions.
+    private static String eventDisplayDescription(GenericValue event, GenericValue eventType, BigDecimal quantity,
+                                                  Locale locale = Locale.ENGLISH) {
+        if (isStockPolicyTrigger(event.getString('mrpEventTypeId'), quantity)) {
+            return label('ManufacturingMrpStockPolicyTriggerDescription', locale)
+        }
+        String eventName = event.getString('eventName')
+        return UtilValidate.isNotEmpty(eventName) ? eventName : eventType?.getString('description')
+    }
+
+    // Identifies the internal marker created when current inventory is below minimum stock.
+    private static boolean isStockPolicyTrigger(Object mrpEventTypeId, BigDecimal quantity) {
+        return mrpEventTypeId?.toString() == 'REQUIRED_MRP' && quantity != null && quantity == BigDecimal.ZERO
+    }
+
+    // Extracts the covered requirement id/date from proposed-supply event names generated by the MRP engine.
+    private static Map extractProposedSupplyRequirementReference(GenericValue event) {
+        if (!(event?.getString('mrpEventTypeId') in ['PROP_PUR_O_RECP', 'PROP_MANUF_O_RECP'])) {
+            return [:]
+        }
+        String eventName = event.getString('eventName')
+        if (UtilValidate.isEmpty(eventName)) {
+            return [:]
+        }
+        return (eventName =~ /^\*(\S+)\s+\((.+)\)\*$/).with { matcher ->
+            matcher.matches() ? [
+                    requirementId: matcher.group(1),
+                    requirementDate: Timestamp.valueOf(matcher.group(2))
+            ] : [:]
+        }
+    }
+
+    // Converts attention types into a severity used by the planner chips.
+    private static String attentionSeverityFor(String exceptionType, Map event) {
+        if (exceptionType in ['ENGINE_ERROR', 'SETUP_ISSUE', 'LATE_EVENT']) {
+            return 'HIGH'
+        }
+        return exceptionType == 'BELOW_SAFETY_STOCK' && event.runningBalance != null &&
+                event.runningBalance < BigDecimal.ZERO ? 'HIGH' : 'MEDIUM'
+    }
+
+    // Encodes composite ids so delimiters inside product/facility ids cannot make ambiguous service ids.
+    private static String encodeCompositeId(List parts) {
+        String payload = parts.collect { it?.toString() ?: '' }.join('\u001F')
+        return 'mrp_' + Base64.urlEncoder.withoutPadding().encodeToString(payload.getBytes(StandardCharsets.UTF_8))
+    }
+
+    private static String label(String key, Locale locale, Map parameters = [:]) {
+        return UtilProperties.getMessage(RESOURCE, key, parameters, locale ?: Locale.ENGLISH)
+    }
+
+}
diff --git a/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/mrp/MrpEventPlanQuery.groovy b/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/mrp/MrpEventPlanQuery.groovy
new file mode 100644
index 0000000000..fb4884731d
--- /dev/null
+++ b/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/mrp/MrpEventPlanQuery.groovy
@@ -0,0 +1,420 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.ofbiz.manufacturing.mrp
+
+import java.sql.Timestamp
+import java.time.LocalDate
+import java.util.regex.Pattern
+
+import org.apache.ofbiz.base.util.ObjectType
+import org.apache.ofbiz.base.util.UtilProperties
+import org.apache.ofbiz.base.util.UtilValidate
+import org.apache.ofbiz.entity.Delegator
+import org.apache.ofbiz.entity.GenericValue
+import org.apache.ofbiz.entity.condition.EntityCondition
+import org.apache.ofbiz.entity.condition.EntityOperator
+import org.apache.ofbiz.entity.util.EntityQuery
+import org.apache.ofbiz.entity.util.EntityUtil
+
+final class MrpEventPlanQuery {
+
+    private static final Pattern DATE_ONLY_PATTERN = ~/\d{4}-\d{2}-\d{2}/
+    private static final String RESOURCE = 'ManufacturingUiLabels'
+    private static final int MAX_MRP_EVENT_VIEW_ROWS = 5000
+
+    private MrpEventPlanQuery() { }
+
+    /*
+     * Calculates how many raw MrpEventView rows can be loaded before grouping them into product/warehouse plans.
+     * The service may need more raw rows than the requested page size because multiple ledger rows collapse into one
+     * planner-facing row.
+     *
+     * Example: a single plan for GZ-8544 at WebStoreWarehouse may need INITIAL_QOH, SALES_ORDER, and PROP_PUR_O_RECP
+     * rows to calculate one final running balance. Loading only pageSize raw rows could therefore return too few plans
+     * after grouping. This method over-fetches enough raw rows for the requested page window, but caps the query so one
+     * request does not load too many MRP event rows.
+     *
+     * The 500-row floor protects very small page sizes. For example, pageSize 1 would otherwise load only 100 raw rows,
+     * which may not be enough after raw event rows collapse into grouped plan rows.
+     *
+     * Input example:
+     * pageIndex = 2
+     * pageSize = 10
+     *
+     * Output example:
+     * 3000
+     */
+    static int candidateRowLimitForPlanGrouping(int pageIndex, int pageSize) {
+        int pageWindow = Math.max(1, pageIndex + 1)
+        return Math.max(500, Math.min(pageWindow * pageSize * 100, MAX_MRP_EVENT_VIEW_ROWS)) as int
+    }
+
+    /*
+     * Detail loader for one selected product/facility plan. Unlike the list loader, this narrows the raw event stream
+     * to the selected warehouse plus product-level context rows so the detail timeline can explain that one plan.
+     *
+     * Product-level rows, such as organization-level forecasts, are retained so the detail view can explain why
+     * warehouse planning changed.
+     *
+     * Input example:
+     * filters = [mrpId: '10022', productId: 'GZ-8544', facilityId: 'WebStoreWarehouse']
+     *
+     * Output example:
+     * [
+     *   MrpEventView[mrpId: '10022', productId: 'GZ-8544', facilityId: 'WebStoreWarehouse'],
+     *   MrpEventView[mrpId: '10022', productId: 'GZ-8544', facilityId: null]
+     * ]
+     */
+    static List loadSelectedPlanEvents(Delegator delegator, Map filters, int candidateLimit, Locale locale = Locale.ENGLISH) {
+        Map eventFilters = filters.findAll { String key, Object value ->
+            UtilValidate.isNotEmpty(value)
+        }
+        List events = loadMrpEventViewRows(delegator, eventFilters, candidateLimit, true, locale)
+        return events.findAll { GenericValue event ->
+            String facilityId = event.getString('facilityId')
+            facilityId == filters.facilityId || UtilValidate.isEmpty(facilityId)
+        }
+    }
+
+    /*
+     * List loader for many product/facility plans. Unlike the selected-plan loader, this loads a bounded candidate set
+     * that can be grouped into multiple planner-facing warehouse rows.
+     *
+     * It applies optional facility or facility-group filters before the builder groups rows into product/warehouse
+     * plans.
+     *
+     * Input example:
+     * filters = [mrpId: '10022', facilityGroupId: 'WH_GROUP']
+     *
+     * Output example:
+     * [
+     *   MrpEventView[mrpId: '10022', productId: 'GZ-8544', facilityId: 'WebStoreWarehouse'],
+     *   MrpEventView[mrpId: '10022', productId: 'WG-1111', facilityId: 'PlantOne']
+     * ]
+     */
+    static List loadEventPlanListEvents(Delegator delegator, Map filters, int candidateLimit, Locale locale = Locale.ENGLISH) {
+        Map eventFilters = filters.findAll { String key, Object value ->
+            UtilValidate.isNotEmpty(value)
+        }
+        List events = loadMrpEventViewRows(delegator, eventFilters, candidateLimit, true, locale)
+        if (UtilValidate.isNotEmpty(filters.facilityId)) {
+            return events.findAll { GenericValue event ->
+                String facilityId = event.getString('facilityId')
+                facilityId == filters.facilityId || UtilValidate.isEmpty(facilityId)
+            }
+        }
+        if (UtilValidate.isNotEmpty(filters.facilityGroupId)) {
+            Set facilityIds = activeFacilityIdsForGroup(delegator, filters.facilityGroupId)
+            if (UtilValidate.isEmpty(facilityIds)) {
+                return []
+            }
+            return events.findAll { GenericValue event ->
+                String facilityId = event.getString('facilityId')
+                facilityIds.contains(facilityId) || UtilValidate.isEmpty(facilityId)
+            }
+        }
+        return events
+    }
+
+    /*
+     * Executes the bounded MrpEventView query before any product/facility grouping or planner-facing shaping is applied.
+     *
+     * Input example:
+     * filters = [mrpId: '10022', productId: 'GZ-8544']
+     * candidateLimit = 1000
+     *
+     * Output example:
+     * [
+     *   MrpEventView[mrpEventTypeId: 'INITIAL_QOH', quantity: 18],
+     *   MrpEventView[mrpEventTypeId: 'SALES_ORDER', quantity: -20],
+     *   MrpEventView[mrpEventTypeId: 'PROP_PUR_O_RECP', quantity: 50]
+     * ]
+     */
+    static List loadMrpEventViewRows(Delegator delegator, Map filters, int candidateLimit, boolean includeProductLevelEvents,
+                                     Locale locale = Locale.ENGLISH) {
+        List conditions = buildMrpEventViewConditions(delegator, filters, includeProductLevelEvents, locale)
+        /*
+         * Keep the DB read bounded here as well as at the service layer. Grouping and timeline calculation happen in
+         * memory, so direct callers must not be able to accidentally load a very large MrpEventView result set.
+         */
+        int queryLimit = Math.max(1, Math.min(candidateLimit, MAX_MRP_EVENT_VIEW_ROWS))
+        return EntityQuery.use(delegator).from('MrpEventView')
+                .select('mrpId', 'productId', 'eventDate', 'mrpEventTypeId', 'facilityId', 'facilityIdTo', 'quantity',
+                        'eventName', 'isLate', 'billOfMaterialLevel')
+                .where(EntityCondition.makeCondition(conditions, EntityOperator.AND))
+                .orderBy('productId', 'facilityId', 'mrpId', 'eventDate', 'mrpEventTypeId')
+                .maxRows(queryLimit)
+                .queryList()
+    }
+
+    /*
+     * Loads reference rows needed to turn raw MrpEventView rows into planner-facing rows. Product, Facility,
+     * ProductFacility, MrpEventType, and StatusItem records are batched here to avoid N+1 queries while preparing each
+     * grouped plan timeline.
+     *
+     * Input example:
+     * events = [MrpEventView[productId: 'GZ-8544', facilityId: 'WebStoreWarehouse', mrpEventTypeId: 'SALES_ORDER']]
+     *
+     * Output example:
+     * [
+     *   products: ['GZ-8544': Product[productId: 'GZ-8544']],
+     *   facilities: ['WebStoreWarehouse': Facility[facilityId: 'WebStoreWarehouse']],
+     *   productFacilities: ['GZ-8544|WebStoreWarehouse': ProductFacility[minimumStock: 20]],
+     *   eventTypes: ['SALES_ORDER': MrpEventType[mrpEventTypeId: 'SALES_ORDER']],
+     *   statuses: ['MRP_OPEN': StatusItem[statusId: 'MRP_OPEN']]
+     * ]
+     */
+    static Map loadEventPlanReferenceData(Delegator delegator, List events, Collection extraFacilityIds = []) {
+        Set productIds = events*.getString('productId').findAll { UtilValidate.isNotEmpty(it) } as Set
+        Set facilityIds = (events*.getString('facilityId').findAll { UtilValidate.isNotEmpty(it) } +
+                extraFacilityIds.findAll { UtilValidate.isNotEmpty(it) }) as Set
+        Set eventTypeIds = events*.getString('mrpEventTypeId').findAll { UtilValidate.isNotEmpty(it) } as Set
+        return [
+                products: UtilValidate.isNotEmpty(productIds) ?
+                        EntityUtil.lookupById(delegator, 'Product', 'productId', productIds, false) : [:],
+                facilities: UtilValidate.isNotEmpty(facilityIds) ?
+                        EntityUtil.lookupById(delegator, 'Facility', 'facilityId', facilityIds, false) : [:],
+                productFacilities: loadProductFacilitiesByKey(delegator, productIds, facilityIds),
+                eventTypes: UtilValidate.isNotEmpty(eventTypeIds) ?
+                        EntityUtil.lookupById(delegator, 'MrpEventType', 'mrpEventTypeId', eventTypeIds) : [:],
+                statuses: UtilValidate.isNotEmpty(events) ?
+                        EntityUtil.lookupById(delegator, 'StatusItem', 'statusId',
+                                [MrpEventPlanBuilder.MRP_OPEN_STATUS_ID]) : [:]
+        ]
+    }
+
+    /*
+     * Converts service filters into MrpEventView conditions. When no mrpId is supplied, it defaults to the latest MRP
+     * run with a durable run log so Event Plans opens on the newest available ledger.
+     *
+     * Input example:
+     * filters = [productId: 'GZ-8544', facilityId: 'WebStoreWarehouse', dateFrom: '2026-08-01']
+     *
+     * Output example:
+     * [
+     *   mrpId == latestMrpId,
+     *   productId == 'GZ-8544',
+     *   facilityId == 'WebStoreWarehouse' OR facilityId is product-level,
+     *   eventDate >= Timestamp('2026-08-01 00:00:00')
+     * ]
+     */
+    static List buildMrpEventViewConditions(Delegator delegator, Map filters, boolean includeProductLevelEvents,
+                                            Locale locale = Locale.ENGLISH) {
+        List conditions = []
+        if (UtilValidate.isNotEmpty(filters.mrpId)) {
+            conditions.add(EntityCondition.makeCondition('mrpId', filters.mrpId))
+        } else {
+            String latestMrpId = latestMrpIdFromRunLog(delegator)
+            conditions.add(EntityCondition.makeCondition('mrpId', latestMrpId ?: '__NO_MATCHING_MRP__'))
+        }
+        if (UtilValidate.isNotEmpty(filters.productId)) {
+            conditions.add(EntityCondition.makeCondition('productId', filters.productId))
+        }
+        if (UtilValidate.isNotEmpty(filters.facilityId)) {
+            conditions.add(buildFacilityEventCondition(filters.facilityId, includeProductLevelEvents))
+        }
+        if (UtilValidate.isNotEmpty(filters.facilityGroupId)) {
+            Set facilityIds = activeFacilityIdsForGroup(delegator, filters.facilityGroupId)
+            conditions.add(UtilValidate.isNotEmpty(facilityIds) ? buildFacilityEventCondition(facilityIds,
+                    includeProductLevelEvents) : EntityCondition.makeCondition('facilityId', '__NO_MATCHING_FACILITY__'))
+        }
+        if (UtilValidate.isNotEmpty(filters.mrpEventTypeId)) {
+            conditions.add(EntityCondition.makeCondition('mrpEventTypeId', filters.mrpEventTypeId))
+        }
+        if (UtilValidate.isNotEmpty(filters.eventDate)) {
+            if (DATE_ONLY_PATTERN.matcher(filters.eventDate.toString()).matches()) {
+                conditions.add(EntityCondition.makeCondition('eventDate', EntityOperator.GREATER_THAN_EQUAL_TO,
+                        parseEventDateBoundary(filters.eventDate, false, locale)))
+                conditions.add(EntityCondition.makeCondition('eventDate', EntityOperator.LESS_THAN,
+                        parseEventDateBoundary(filters.eventDate, true, locale)))
+            } else {
+                conditions.add(EntityCondition.makeCondition('eventDate', parseEventDateBoundary(filters.eventDate, false, locale)))
+            }
+        }
+        Timestamp dateFrom = parseEventDateBoundary(filters.dateFrom, false, locale)
+        if (dateFrom != null) {
+            conditions.add(EntityCondition.makeCondition('eventDate', EntityOperator.GREATER_THAN_EQUAL_TO, dateFrom))
+        }
+        Timestamp dateTo = parseEventDateBoundary(filters.dateTo, true, locale)
+        if (dateTo != null) {
+            conditions.add(EntityCondition.makeCondition('eventDate', EntityOperator.LESS_THAN, dateTo))
+        }
+        String queryText = UtilValidate.isNotEmpty(filters.query) ? filters.query.toString().trim() :
+                (UtilValidate.isNotEmpty(filters.search) ? filters.search.toString().trim() : null)
+        if (UtilValidate.isNotEmpty(queryText)) {
+            conditions.add(EntityCondition.makeCondition(buildMrpEventSearchConditions(delegator, queryText),
+                    EntityOperator.OR))
+        }
+        return conditions
+    }
+
+    /*
+     * Expands Event Plans free-text search across raw MRP event fields plus related product/facility display fields.
+     * Product and Facility lookups are explicit because MrpEventView stores ids, not display names.
+     *
+     * Input example:
+     * queryText = 'gizmo'
+     *
+     * Output example:
+     * [
+     *   upperLikeAny(['mrpId', 'productId', 'facilityId', 'mrpEventTypeId', 'eventName'], 'gizmo'),
+     *   productId IN ['GZ-8544'],
+     *   facilityId IN ['WebStoreWarehouse']
+     * ]
+     */
+    static List buildMrpEventSearchConditions(Delegator delegator, String queryText) {
+        List conditions = [
+                EntityUtil.upperLikeAny(['mrpId', 'productId', 'facilityId', 'mrpEventTypeId', 'eventName'], queryText)
+        ]
+        Set matchingProductIds = EntityUtil.searchIds(delegator, 'Product', 'productId',
+                ['productId', 'productName', 'internalName'], queryText, 1000)
+        if (UtilValidate.isNotEmpty(matchingProductIds)) {
+            conditions.add(EntityCondition.makeCondition('productId', EntityOperator.IN, matchingProductIds as List))
+        }
+        Set matchingFacilityIds = EntityUtil.searchIds(delegator, 'Facility', 'facilityId',
+                ['facilityId', 'facilityName'], queryText,
+                [EntityCondition.makeCondition('facilityTypeId', EntityOperator.IN, ['WAREHOUSE', 'PLANT'])], 1000)
+        if (UtilValidate.isNotEmpty(matchingFacilityIds)) {
+            conditions.add(EntityCondition.makeCondition('facilityId', EntityOperator.IN, matchingFacilityIds as List))
+        }
+        return conditions
+    }
+
+    /*
+     * Finds the latest MRP id from durable run history so the list endpoint has a useful default when no mrpId filter is
+     * supplied.
+     *
+     * Output example:
+     * '10022'
+     */
+    static String latestMrpIdFromRunLog(Delegator delegator) {
+        return EntityQuery.use(delegator).from('MrpRunLog')
+                .select('mrpId')
+                .where(EntityCondition.makeCondition('mrpId', EntityOperator.NOT_EQUAL, null))
+                .orderBy('-startedAt', '-createdStamp')
+                .maxRows(25)
+                .queryList()
+                .find { GenericValue runLog -> UtilValidate.isNotEmpty(runLog.getString('mrpId')) }
+                ?.getString('mrpId')
+    }
+
+    /*
+     * Builds the facility condition used by list/detail queries. Detail views include product-level rows, such as
+     * organization-level forecasts, alongside warehouse-specific rows.
+     *
+     * Input example:
+     * facilityIdOrIds = 'WebStoreWarehouse'
+     * includeProductLevelEvents = true
+     *
+     * Output example:
+     * facilityId == 'WebStoreWarehouse' OR facilityId is null OR facilityId == ''
+     */
+    static EntityCondition buildFacilityEventCondition(Object facilityIdOrIds, boolean includeProductLevelEvents) {
+        EntityCondition facilityCondition = facilityIdOrIds instanceof Collection ?
+                EntityCondition.makeCondition('facilityId', EntityOperator.IN, facilityIdOrIds as List) :
+                EntityCondition.makeCondition('facilityId', facilityIdOrIds)
+        if (!includeProductLevelEvents) {
+            return facilityCondition
+        }
+        return EntityCondition.makeCondition([
+                facilityCondition,
+                EntityCondition.makeCondition('facilityId', null),
+                EntityCondition.makeCondition('facilityId', '')
+        ], EntityOperator.OR)
+    }
+
+    /*
+     * Expands a facility group filter into active member facilities before the main MrpEventView query is built.
+     *
+     * Input example:
+     * facilityGroupId = 'WH_GROUP'
+     *
+     * Output example:
+     * ['WebStoreWarehouse', 'PlantOne'] as Set
+     */
+    static Set activeFacilityIdsForGroup(Delegator delegator, Object facilityGroupId) {
+        if (UtilValidate.isEmpty(facilityGroupId)) {
+            return [] as Set
+        }
+        return EntityQuery.use(delegator).from('FacilityGroupMember')
+                .select('facilityId')
+                .where(facilityGroupId: facilityGroupId)
+                .filterByDate()
+                .queryList()
+                *.getString('facilityId')
+                .findAll { UtilValidate.isNotEmpty(it) } as Set
+    }
+
+    /*
+     * Reads product-facility stock policy rows keyed by product and warehouse so the builder can calculate safety-stock
+     * attention without querying inside each grouped plan.
+     *
+     * Input example:
+     * productIds = ['GZ-8544']
+     * facilityIds = ['WebStoreWarehouse']
+     *
+     * Output example:
+     * ['GZ-8544|WebStoreWarehouse': ProductFacility[minimumStock: 20, reorderQuantity: 50]]
+     */
+    private static Map loadProductFacilitiesByKey(Delegator delegator, Collection productIds, Collection facilityIds) {
+        if (UtilValidate.isEmpty(productIds) || UtilValidate.isEmpty(facilityIds)) {
+            return [:]
+        }
+        return EntityQuery.use(delegator).from('ProductFacility')
+                .select('productId', 'facilityId', 'minimumStock', 'reorderQuantity', 'daysToShip')
+                .where(EntityCondition.makeCondition([
+                        EntityCondition.makeCondition('productId', EntityOperator.IN, productIds as List),
+                        EntityCondition.makeCondition('facilityId', EntityOperator.IN, facilityIds as List)
+                ], EntityOperator.AND))
+                .queryList()
+                .collectEntries { GenericValue productFacility ->
+                    String key = MrpEventPlanBuilder.productFacilityLookupKey(productFacility.getString('productId'),
+                            productFacility.getString('facilityId'))
+                    [(key): productFacility]
+                }
+    }
+
+    /*
+     * Parses date/timestamp filters for EntityQuery conditions. Date-only end filters use an exclusive next-day boundary
+     * so the whole selected date is included.
+     *
+     * Input example:
+     * value = '2026-08-01'
+     * exclusiveEnd = true
+     *
+     * Output example:
+     * Timestamp('2026-08-02 00:00:00')
+     */
+    private static Timestamp parseEventDateBoundary(Object value, boolean exclusiveEnd, Locale locale = Locale.ENGLISH) {
+        if (UtilValidate.isEmpty(value)) {
+            return null
+        }
+        try {
+            if (DATE_ONLY_PATTERN.matcher(value.toString()).matches()) {
+                LocalDate date = LocalDate.parse(value.toString())
+                return Timestamp.valueOf((exclusiveEnd ? date.plusDays(1) : date).atStartOfDay())
+            }
+            return (Timestamp) ObjectType.simpleTypeOrObjectConvert(value, 'Timestamp', null, null)
+        } catch (Exception e) {
+            throw new IllegalArgumentException(UtilProperties.getMessage(RESOURCE, 'ManufacturingMrpEventDateFilterInvalid',
+                    [value: value], locale ?: Locale.ENGLISH))
+        }
+    }
+
+}
diff --git a/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/mrp/MrpEventPlanServices.groovy b/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/mrp/MrpEventPlanServices.groovy
new file mode 100644
index 0000000000..f833fa337d
--- /dev/null
+++ b/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/mrp/MrpEventPlanServices.groovy
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.ofbiz.manufacturing.mrp
+
+import org.apache.ofbiz.base.util.UtilValidate
+import org.apache.ofbiz.entity.GenericValue
+import org.apache.ofbiz.service.ServiceUtil
+import org.apache.ofbiz.ws.rs.util.RestApiUtil
+import org.apache.ofbiz.ws.rs.util.RestQueryOptions
+
+/*
+ * Builds reusable event-plan data from MrpEventView instead of the FindInventoryEventPlan screen query.
+ *
+ * Flow:
+ * 1. Parse framework REST query options and identify list/detail filters.
+ * 2. Query/load raw MRP event rows and reference data through MrpEventPlanQuery.
+ * 3. Build planner-facing product/warehouse plan rows through MrpEventPlanBuilder.
+ * 4. Apply framework-compatible sorting and paging before returning the REST response.
+ */
+Map findMrpEventPlans() {
+    Locale serviceLocale = binding.hasVariable('locale') ? locale : Locale.ENGLISH
+    // 1. Parse REST query options and identify list/detail mode.
+    RestQueryOptions queryOptions
+    try {
+        queryOptions = RestQueryOptions.fromParameters(parameters)
+    } catch (IllegalArgumentException e) {
+        return ServiceUtil.returnError(e.message)
+    }
+    Map filters = queryOptions.filters
+    String relativeRequestPath = RestApiUtil.getRelativeRequestPath(binding)
+    boolean detailRequest = UtilValidate.isNotEmpty(filters.productId) && UtilValidate.isNotEmpty(filters.facilityId)
+    List events
+    try {
+        /*
+         * 2. Query/load raw MrpEventView rows. The detail flow loads one selected plan; the list flow loads candidates
+         * for grouping and planner filters.
+         */
+        int candidateRowLimit = MrpEventPlanQuery.candidateRowLimitForPlanGrouping(queryOptions.pageIndex,
+                queryOptions.pageSize)
+        events = detailRequest ? MrpEventPlanQuery.loadSelectedPlanEvents(delegator, filters, candidateRowLimit, serviceLocale) :
+                MrpEventPlanQuery.loadEventPlanListEvents(delegator, filters, candidateRowLimit, serviceLocale)
+    } catch (IllegalArgumentException e) {
+        return ServiceUtil.returnError(e.message)
+    }
+    if (detailRequest) {
+        if (UtilValidate.isEmpty(events)) {
+            return ServiceUtil.returnSuccess() + RestApiUtil.getPagedResult('eventPlans', [], queryOptions, 0L,
+                    relativeRequestPath)
+        }
+        // 3a. Build one planner-facing detail row.
+        Map eventPlanReferenceData = MrpEventPlanQuery.loadEventPlanReferenceData(delegator, events,
+                [filters.facilityId])
+        GenericValue firstEvent = events.first()
+        Map groupContext = MrpEventPlanBuilder.buildWarehouseEventPlanContext(MrpEventPlanBuilder.eventPlanGroupKey([
+                mrpId: firstEvent?.getString('mrpId') ?: filters.mrpId,
+                productId: filters.productId,
+                facilityId: filters.facilityId
+        ]), events, eventPlanReferenceData, serviceLocale)
+        Map plan = MrpEventPlanBuilder.buildEventPlanResponseRow(groupContext, serviceLocale)
+        List detailPage = queryOptions.pageIndex == 0 ? [plan] : []
+        // 4a. Return detail through the same paged response contract as list.
+        return ServiceUtil.returnSuccess() + RestApiUtil.getPagedResult('eventPlans', detailPage, queryOptions, 1L,
+                relativeRequestPath)
+    }
+    // 3b. Build planner-facing product/warehouse rows before computed filters and sorting.
+    Map eventPlanReferenceData = MrpEventPlanQuery.loadEventPlanReferenceData(delegator, events)
+    Map groupedContexts = MrpEventPlanBuilder.buildWarehouseEventPlanContexts(events, eventPlanReferenceData, serviceLocale)
+    List eventPlans = groupedContexts.values()
+            .findAll { Map groupContext -> UtilValidate.isNotEmpty(groupContext.facilityId) }
+            .collect { Map groupContext -> MrpEventPlanBuilder.buildEventPlanResponseRow(groupContext, serviceLocale) }
+            .findAll { Map plan -> MrpEventPlanBuilder.matchesEventPlanFilters(plan, filters) }
+    try {
+        eventPlans = RestApiUtil.sortMapRows(eventPlans, queryOptions.sort, MrpEventPlanBuilder.EVENT_PLAN_SORT_FIELDS,
+                MrpEventPlanBuilder.buildEventPlanFallbackComparator())
+    } catch (IllegalArgumentException e) {
+        return ServiceUtil.returnError(e.message)
+    }
+    // 4b. Page the built rows using framework REST metadata and links.
+    List pagedPlans = RestApiUtil.pageList(eventPlans, queryOptions.pageIndex, queryOptions.pageSize)
+    return ServiceUtil.returnSuccess() + RestApiUtil.getPagedResult('eventPlans', pagedPlans, queryOptions,
+            eventPlans.size() as long, relativeRequestPath)
+}


(ofbiz-framework) 04/04: OFBIZ-13502 Expose MRP planning REST API

Posted by di...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

diveshdut pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git

commit 8312aec4522319400131f29b1ee6a914af06dc37
Author: diveshdut <di...@hotwax.co>
AuthorDate: Fri Aug 21 12:22:17 2026 +0530

    OFBIZ-13502 Expose MRP planning REST API
    
    Add the MRP planning REST resource for facilities, launching MRP runs, run history, and event plans.
    
    Add focused tests covering service wiring, REST contract expectations, framework query behavior, event-plan attention metadata, and run-log response shaping.
---
 .../manufacturing/api/mrp-planning.rest.xml        |   44 +
 .../api/MrpPlanningServicesTests.groovy            | 1026 ++++++++++++++++++++
 2 files changed, 1070 insertions(+)

diff --git a/applications/manufacturing/api/mrp-planning.rest.xml b/applications/manufacturing/api/mrp-planning.rest.xml
new file mode 100644
index 0000000000..cb29159eb6
--- /dev/null
+++ b/applications/manufacturing/api/mrp-planning.rest.xml
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+-->
+
+<api name="Manufacturing MRP Planning API"
+        path="mrp-planning"
+        displayName="MRP and Planning"
+        description="REST API for manufacturing MRP event plans and MRP runs"
+        publish="true">
+    <resource name="MrpPlanning" path="" auth="true" publish="true">
+        <operation verb="get" path="/event-plans" produces="application/json" auth="true"
+                description="Find product-centric MRP event plans">
+            <service name="findMrpEventPlans"/>
+        </operation>
+        <operation verb="get" path="/runs" produces="application/json" auth="true"
+                description="Find MRP runs">
+            <service name="findMrpRuns"/>
+        </operation>
+        <operation verb="get" path="/planning-facilities" produces="application/json" auth="true"
+                description="Find warehouse and plant facilities available for MRP planning">
+            <service name="findPlanningFacilities"/>
+        </operation>
+        <operation verb="post" path="/runs" consumes="application/json" produces="application/json" auth="true"
+                description="Execute an MRP run">
+            <service name="launchMrpRun"/>
+        </operation>
+    </resource>
+</api>
diff --git a/applications/manufacturing/src/test/groovy/org/apache/ofbiz/manufacturing/api/MrpPlanningServicesTests.groovy b/applications/manufacturing/src/test/groovy/org/apache/ofbiz/manufacturing/api/MrpPlanningServicesTests.groovy
new file mode 100644
index 0000000000..a5b0c9e5e9
--- /dev/null
+++ b/applications/manufacturing/src/test/groovy/org/apache/ofbiz/manufacturing/api/MrpPlanningServicesTests.groovy
@@ -0,0 +1,1026 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.ofbiz.manufacturing.api
+
+import static org.junit.jupiter.api.Assertions.assertThrows
+
+import org.apache.ofbiz.entity.GenericValue
+import org.apache.ofbiz.entity.util.EntityQuery
+import org.apache.ofbiz.manufacturing.mrp.MrpEventPlanBuilder
+import org.apache.ofbiz.manufacturing.mrp.MrpEventPlanQuery
+import org.apache.ofbiz.manufacturing.mrp.MrpServices
+import org.apache.ofbiz.service.ServiceUtil
+import org.apache.ofbiz.ws.rs.util.RestApiUtil
+import org.apache.ofbiz.ws.rs.util.RestQueryOptions
+import org.junit.jupiter.api.Test
+import org.springframework.mock.web.MockHttpServletRequest
+
+import java.sql.Timestamp
+
+@SuppressWarnings('MethodCount')
+class MrpPlanningServicesTests {
+
+    @Test
+    void testEventPlanFiltersIgnoreUnsupportedFrameworkFilterFields() {
+        Map filters = [
+                mrpId: '10010',
+                productId: 'GZ-8544',
+                attentionOnly: true,
+                unsupportedFilter: 'SHOULD_NOT_CHANGE_RESULTS'
+        ]
+        Map matchingPlan = [mrpId: '10010', productId: 'GZ-8544', actionNeeded: true]
+        Map nonAttentionPlan = [mrpId: '10010', productId: 'GZ-8544', actionNeeded: false]
+
+        assert MrpEventPlanBuilder.matchesEventPlanFilters(matchingPlan, filters)
+        assert !MrpEventPlanBuilder.matchesEventPlanFilters(nonAttentionPlan, filters)
+    }
+
+    @Test
+    void testStringBooleanFiltersMatchRestQueryParameters() {
+        Map cleanPlan = [actionNeeded: false]
+
+        assert !MrpEventPlanBuilder.matchesEventPlanFilters(cleanPlan, [attentionOnly: 'true'])
+        assert MrpEventPlanBuilder.matchesEventPlanFilters(cleanPlan, [attentionOnly: 'false'])
+    }
+
+    @Test
+    void testRunSortMapsMrpNameToFrameworkOrderBy() {
+        Script services = runLogData()
+
+        assert services.resolveMrpRunOrderBy('mrpName') == ['mrpName', '-startedAt', '-createdStamp']
+    }
+
+    @Test
+    void testRunSortRejectsUnsupportedField() {
+        Script services = runLogData()
+
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException) {
+            services.resolveMrpRunOrderBy('notAField')
+        }
+        assert exception.message == 'Unsupported sort field: notAField'
+    }
+
+    @Test
+    void testEventPlansSortByProductIdDescending() {
+        List planContexts = [
+                [productId: 'MAT-A', facilityId: 'WH1', mrpId: '10010'],
+                [productId: 'MAT-C', facilityId: 'WH1', mrpId: '10010'],
+                [productId: 'MAT-B', facilityId: 'WH1', mrpId: '10010']
+        ]
+        List sortedPlans = RestApiUtil.sortMapRows(planContexts, '-productId', MrpEventPlanBuilder.EVENT_PLAN_SORT_FIELDS,
+                MrpEventPlanBuilder.buildEventPlanFallbackComparator())
+
+        assert sortedPlans*.productId == ['MAT-C', 'MAT-B', 'MAT-A']
+    }
+
+    @Test
+    void testEventPlansAttentionOnlyFiltersRowsWithActionSignals() {
+        Map attentionPlan = [attentionTypes: ['BELOW_SAFETY_STOCK'], actionNeeded: true]
+        Map cleanPlan = [attentionTypes: [], actionNeeded: false]
+
+        assert MrpEventPlanBuilder.matchesEventPlanFilters(attentionPlan, [attentionOnly: 'true'])
+        assert !MrpEventPlanBuilder.matchesEventPlanFilters(cleanPlan, [attentionOnly: 'true'])
+        assert MrpEventPlanBuilder.matchesEventPlanFilters(cleanPlan, [attentionOnly: 'false'])
+    }
+
+    @Test
+    void testEventPlansAttentionTypeFiltersSpecificSignals() {
+        Map latePlan = [attentionTypes: ['LATE_EVENT', 'BELOW_SAFETY_STOCK'], actionNeeded: true]
+        Map setupPlan = [attentionTypes: ['SETUP_ISSUE'], actionNeeded: true]
+
+        assert MrpEventPlanBuilder.matchesEventPlanFilters(latePlan, [attentionType: 'LATE_EVENT'])
+        assert MrpEventPlanBuilder.matchesEventPlanFilters(latePlan, [attentionType: 'BELOW_SAFETY_STOCK'])
+        assert !MrpEventPlanBuilder.matchesEventPlanFilters(setupPlan, [attentionType: 'LATE_EVENT'])
+    }
+
+    @Test
+    void testFrameworkNavigationMetadataAndLinksUseRequestPath() {
+        MockHttpServletRequest request = new MockHttpServletRequest()
+        request.setRequestURI('/rest/mrp-planning/runs')
+        request.setQueryString('pageIndex=1&pageSize=1&statusId=SERVICE_FINISHED')
+        RestQueryOptions queryOptions = RestQueryOptions.fromParameters([
+                pageIndex: 1,
+                pageSize: 1,
+                statusId: 'SERVICE_FINISHED'
+        ])
+
+        Map result = RestApiUtil.getPagedResult('runs', [], queryOptions, 3L, RestApiUtil.getRelativeRequestPath(request))
+
+        assert result.previousPageCount == 1L
+        assert result.nextPageCount == 1L
+        assert result.links instanceof Map
+        assert result.links.self.href == '/rest/mrp-planning/runs?statusId=SERVICE_FINISHED&pageIndex=1&pageSize=1'
+        assert result.links.prev.href == '/rest/mrp-planning/runs?statusId=SERVICE_FINISHED&pageIndex=0&pageSize=1'
+        assert result.links.next.href == '/rest/mrp-planning/runs?statusId=SERVICE_FINISHED&pageIndex=2&pageSize=1'
+    }
+
+    @Test
+    void testComputedMrpListsAcceptMaxFrameworkPageSize() {
+        Script eventPlanServices = eventPlanData([pageIndex: 0, pageSize: 100])
+        Map eventPlans
+        withRecordingEntityQueries([
+                MrpRunLog: new RecordingQuery([]),
+                MrpEventView: new RecordingQuery([])
+        ]) {
+            eventPlans = eventPlanServices.findMrpEventPlans()
+        }
+
+        assert ServiceUtil.isSuccess(eventPlans)
+        assert eventPlans.pageSize == 100
+    }
+
+    @Test
+    void testComputedMrpListsRejectOversizedFrameworkPageSize() {
+        Script eventPlanServices = eventPlanData([pageIndex: 0, pageSize: 101])
+        Map eventPlans = eventPlanServices.findMrpEventPlans()
+
+        assert ServiceUtil.isError(eventPlans)
+        assert ServiceUtil.getErrorMessage(eventPlans).contains('pageSize must be between 1 and 100')
+    }
+
+    @Test
+    void testLoadMrpEventsConstrainsDefaultCandidateLoadToLatestMrpIdAndLimit() {
+        RecordingQuery latestRunQuery = new RecordingQuery([mrpEvent([mrpId: null]), mrpEvent([mrpId: '10030'])])
+        RecordingQuery eventQuery = new RecordingQuery([])
+        withRecordingEntityQueries([
+                MrpRunLog: latestRunQuery,
+                MrpEventView: eventQuery
+        ]) {
+            MrpEventPlanQuery.loadMrpEventViewRows(null, [:], 1000, false)
+        }
+
+        assert latestRunQuery.entityName == 'MrpRunLog'
+        assert latestRunQuery.whereCondition.toString().contains('mrpId')
+        assert latestRunQuery.maxRowsValue == 25
+        assert eventQuery.entityName == 'MrpEventView'
+        assert eventQuery.maxRowsValue == 1000
+        assert eventQuery.whereCondition.toString().contains('10030')
+    }
+
+    @Test
+    void testLoadMrpEventsPreservesExplicitMrpIdWhileApplyingLimit() {
+        RecordingQuery eventQuery = new RecordingQuery([])
+        withRecordingEntityQueries([MrpEventView: eventQuery]) {
+            MrpEventPlanQuery.loadMrpEventViewRows(null, [mrpId: '10010'], 500, false)
+        }
+
+        assert eventQuery.entityName == 'MrpEventView'
+        assert eventQuery.maxRowsValue == 500
+        assert eventQuery.whereCondition.toString().contains('10010')
+    }
+
+    @Test
+    void testLoadMrpEventsCapsDirectCallerCandidateLimit() {
+        RecordingQuery eventQuery = new RecordingQuery([])
+        withRecordingEntityQueries([MrpEventView: eventQuery]) {
+            MrpEventPlanQuery.loadMrpEventViewRows(null, [mrpId: '10010'], 50000, false)
+        }
+
+        assert eventQuery.maxRowsValue == 5000
+    }
+
+    @Test
+    void testLoadMrpEventsUsesMinimumCandidateLimit() {
+        RecordingQuery eventQuery = new RecordingQuery([])
+        withRecordingEntityQueries([MrpEventView: eventQuery]) {
+            MrpEventPlanQuery.loadMrpEventViewRows(null, [mrpId: '10010'], 0, false)
+        }
+
+        assert eventQuery.maxRowsValue == 1
+    }
+
+    @Test
+    void testCandidateEventLimitAccountsForRequestedPageWindow() {
+        assert MrpEventPlanQuery.candidateRowLimitForPlanGrouping(0, 10) == 1000
+        assert MrpEventPlanQuery.candidateRowLimitForPlanGrouping(2, 10) == 3000
+        assert MrpEventPlanQuery.candidateRowLimitForPlanGrouping(10, 100) == 5000
+    }
+
+    @Test
+    void testEventPlanDetailUsesProductLevelForecastInTimeline() {
+        GenericValue forecast = mrpEvent([
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-03 10:00:00.000'),
+                mrpEventTypeId: 'SALES_FORECAST',
+                facilityId: null,
+                quantity: -5G
+        ])
+        GenericValue warehouseDemand = mrpEvent([
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-03 10:00:00.000'),
+                mrpEventTypeId: 'SALES_ORDER_SHIP',
+                facilityId: 'WebStoreWarehouse',
+                quantity: -3G
+        ])
+        RecordingQuery eventQuery = new RecordingQuery([forecast, warehouseDemand])
+        Map enrichment = emptyEnrichment()
+        enrichment.facilities.WebStoreWarehouse = mrpEvent([facilityId: 'WebStoreWarehouse', facilityName: 'Web Store Warehouse'])
+
+        List events
+        withRecordingEntityQueries([MrpEventView: eventQuery]) {
+            events = MrpEventPlanQuery.loadSelectedPlanEvents(null, [
+                    mrpId: '10022',
+                    productId: 'GZ-8544',
+                    facilityId: 'WebStoreWarehouse'
+            ], 5000)
+        }
+        Map groupContext = MrpEventPlanBuilder.buildWarehouseEventPlanContext('10022|GZ-8544|WebStoreWarehouse', events, enrichment)
+
+        assert eventQuery.whereCondition.toString().contains('WebStoreWarehouse')
+        assert groupContext.timeline*.mrpEventTypeId == ['SALES_FORECAST', 'SALES_ORDER_SHIP']
+        assert MrpEventPlanBuilder.buildEventPlanResponseRow(groupContext).attentionTypes.contains('FORECAST_ALLOCATION')
+    }
+
+    @Test
+    void testInitialQohSortsBeforeSameRunSupplyEvenWhenTimestampIsLater() {
+        GenericValue proposedPurchase = mrpEvent([
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-02 16:35:25.523'),
+                mrpEventTypeId: 'PROP_PUR_O_RECP',
+                facilityId: 'WebStoreWarehouse',
+                quantity: 50G
+        ])
+        GenericValue initialQoh = mrpEvent([
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-02 16:35:25.524'),
+                mrpEventTypeId: 'INITIAL_QOH',
+                facilityId: 'WebStoreWarehouse',
+                quantity: 18G
+        ])
+
+        assert MrpEventPlanBuilder.compareMrpTimelineEvents(initialQoh, proposedPurchase) < 0
+    }
+
+    @Test
+    void testFallbackSortKeepsDomainOrderingAndNullsLast() {
+        List planContexts = [
+                [productId: 'MAT-B', facilityId: 'WH1', mrpId: '10010'],
+                [productId: 'MAT-A', facilityId: 'WH2', mrpId: '10010'],
+                [productId: 'MAT-A', facilityId: 'WH1', mrpId: '10010']
+        ]
+        List sortedPlans = RestApiUtil.sortMapRows(planContexts, null, MrpEventPlanBuilder.EVENT_PLAN_SORT_FIELDS,
+                MrpEventPlanBuilder.buildEventPlanFallbackComparator())
+
+        assert sortedPlans*.productId == ['MAT-A', 'MAT-A', 'MAT-B']
+        assert sortedPlans*.facilityId == ['WH1', 'WH2', 'WH1']
+    }
+
+    @Test
+    void testEventIdIsStableForMapAndGenericValueInputs() {
+        Map event = [
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-02 16:35:25.524'),
+                mrpEventTypeId: 'INITIAL_QOH',
+                facilityId: 'WebStoreWarehouse'
+        ]
+
+        assert MrpEventPlanBuilder.buildTimelineEventId(event) == MrpEventPlanBuilder.buildTimelineEventId(mrpEvent(event))
+    }
+
+    @Test
+    void testTimelineBalancesStartWithInitialQohEvenWhenSupplyTimestampIsEarlier() {
+        GenericValue proposedPurchase = mrpEvent([
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-02 16:35:25.523'),
+                mrpEventTypeId: 'PROP_PUR_O_RECP',
+                facilityId: 'WebStoreWarehouse',
+                quantity: 50G
+        ])
+        GenericValue initialQoh = mrpEvent([
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-02 16:35:25.524'),
+                mrpEventTypeId: 'INITIAL_QOH',
+                facilityId: 'WebStoreWarehouse',
+                quantity: 18G
+        ])
+
+        Map groupContext = MrpEventPlanBuilder.buildWarehouseEventPlanContext('10022|GZ-8544|WebStoreWarehouse',
+                [proposedPurchase, initialQoh], emptyEnrichment())
+
+        assert groupContext.timeline*.mrpEventTypeId == ['INITIAL_QOH', 'PROP_PUR_O_RECP']
+        assert groupContext.timeline*.runningBalance == [18G, 68G]
+        assert MrpEventPlanBuilder.buildEventPlanResponseRow(groupContext).lowestRunningBalance == 18G
+    }
+
+    @Test
+    void testBuildMrpRunResponseRowReturnsMachineReadableFallbackRunDuration() {
+        Script services = runLogData()
+        GenericValue runLog = mrpEvent([
+                mrpRunLogId: 'MRP_RUN_LOG_10030',
+                runStatusId: 'SERVICE_FINISHED',
+                startedAt: Timestamp.valueOf('2026-07-02 19:25:00.000'),
+                finishedAt: Timestamp.valueOf('2026-07-02 19:25:00.274')
+        ])
+
+        assert services.buildMrpRunResponseRow(runLog, null).durationMillis == 274L
+    }
+
+    @Test
+    void testRunLogMapUsesDurableRunFields() {
+        Script services = runLogData()
+        GenericValue runLog = mrpEvent([
+                mrpRunLogId: 'MRP_RUN_LOG_10030',
+                mrpId: '10030',
+                jobId: '12204',
+                mrpName: 'Thursday Evening Run',
+                facilityId: 'WebStoreWarehouse',
+                defaultYearsOffset: 1L,
+                runByUserLoginId: 'admin',
+                runStatusId: 'SERVICE_FINISHED',
+                startedAt: Timestamp.valueOf('2026-07-02 19:25:00.000'),
+                finishedAt: Timestamp.valueOf('2026-07-02 19:25:00.274'),
+                durationMillis: 274L
+        ])
+        GenericValue status = mrpEvent([
+                statusId: 'SERVICE_FINISHED',
+                description: 'Finished'
+        ])
+
+        Map result = services.buildMrpRunResponseRow(runLog, status)
+
+        assert result.runId == 'MRP_RUN_LOG_10030'
+        assert result.mrpId == '10030'
+        assert result.jobId == '12204'
+        assert result.mrpName == 'Thursday Evening Run'
+        assert result.facilityId == 'WebStoreWarehouse'
+        assert result.defaultYearsOffset == 1
+        assert result.runAsUser == 'admin'
+        assert result.statusDescription == 'Finished'
+        assert result.durationMillis == 274L
+    }
+
+    @Test
+    void testRunLogMapExposesFailureDetailsForFailedRuns() {
+        Script services = runLogData()
+        GenericValue runLog = mrpEvent([
+                mrpRunLogId: 'MRP_RUN_LOG_FAILED_10030',
+                mrpName: 'Invalid facility run',
+                facilityId: 'MissingWarehouse',
+                runStatusId: 'SERVICE_FAILED',
+                failureReason: 'SERVICE_ERROR',
+                failureMessage: 'Facility or manufacturing facility is not available.',
+                startedAt: Timestamp.valueOf('2026-07-02 19:25:00.000'),
+                finishedAt: Timestamp.valueOf('2026-07-02 19:25:00.274'),
+                durationMillis: 274L
+        ])
+        GenericValue status = mrpEvent([
+                statusId: 'SERVICE_FAILED',
+                description: 'Failed'
+        ])
+
+        Map result = services.buildMrpRunResponseRow(runLog, status)
+
+        assert result.statusId == 'SERVICE_FAILED'
+        assert result.statusDescription == 'Failed'
+        assert result.failureReason == 'SERVICE_ERROR'
+        assert result.failureMessage == 'Facility or manufacturing facility is not available.'
+    }
+
+    @Test
+    void testCreateMrpRunLogAndTrackerBeforeQueueingMrpDoesNotCreateRunLogWithJobSandboxId() {
+        String source = new File('applications/manufacturing/src/main/java/org/apache/ofbiz/manufacturing/mrp/MrpServices.java').text
+        String methodBody = source.substring(source.indexOf('public static Map<String, Object> createMrpRunLogAndTrackerBeforeQueueingMrp'),
+                source.indexOf('/**\n     * Launch executeMrp'))
+
+        assert methodBody.contains('createMrpRunLogContext.put("mrpRunLogId", mrpRunLogId)')
+        assert methodBody.contains('createMrpRunLogContext.put("userLogin", userLogin)')
+        assert !methodBody.contains('createMrpRunLogContext.put("jobId"')
+    }
+
+    @Test
+    void testCreateMrpRunLogAndTrackerBeforeQueueingMrpCreatesQueueTimeRunLogWithoutUpdateBranch() {
+        String source = new File('applications/manufacturing/src/main/java/org/apache/ofbiz/manufacturing/mrp/MrpServices.java').text
+        String methodBody = source.substring(source.indexOf('public static Map<String, Object> createMrpRunLogAndTrackerBeforeQueueingMrp'),
+                source.indexOf('/**\n     * Launch executeMrp'))
+
+        assert methodBody.contains('"createMrpRunLog"')
+        assert !methodBody.contains('"updateMrpRunLog"')
+    }
+
+    @Test
+    void testMrpRunRestEndpointUsesGenericLaunchService() {
+        String restApiXml = new File('applications/manufacturing/api/mrp-planning.rest.xml').text
+        String controllerXml = new File('applications/manufacturing/webapp/manufacturing/WEB-INF/controller.xml').text
+        String requestMap = controllerXml.substring(controllerXml.indexOf('<request-map uri="runMrpGo">'),
+                controllerXml.indexOf('</request-map>', controllerXml.indexOf('<request-map uri="runMrpGo">')))
+
+        assert restApiXml.contains('<service name="launchMrpRun"/>')
+        assert !restApiXml.contains('<service name="runMrpForPlanning"/>')
+        assert requestMap.contains('<event type="service" invoke="launchMrpRun"/>')
+        assert !requestMap.contains('invoke="executeMrp" path="async"')
+    }
+
+    @Test
+    void testMrpReadServicesUseDirectGroovyServiceScripts() {
+        String servicesXml = new File('applications/manufacturing/servicedef/services_mrp.xml').text
+
+        assert servicesXml.contains('component://manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/mrp/MrpEventPlanServices.groovy')
+        assert servicesXml.contains('component://manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/mrp/MrpRunLogServices.groovy')
+        assert !servicesXml.contains(['MrpPlanningServices', 'groovy'].join('.'))
+        assert !servicesXml.contains(['MrpEventPlanBuilder', 'groovy'].join('.'))
+        assert !servicesXml.contains(['MrpRunLogData', 'groovy'].join('.'))
+    }
+
+    @Test
+    void testMrpRunMutatingServicesOwnCreatePermissionAndInputValidation() {
+        String servicesXml = new File('applications/manufacturing/servicedef/services_mrp.xml').text
+        String executeServiceXml = servicesXml.substring(servicesXml.indexOf('<service name="executeMrp"'),
+                servicesXml.indexOf('<service name="createMrpRunLog"'))
+        String prepareServiceXml = servicesXml.substring(servicesXml.indexOf('<service name="createMrpRunLogAndTrackerBeforeQueueingMrp"'),
+                servicesXml.indexOf('<service name="launchMrpRun"'))
+        String launchServiceXml = servicesXml.substring(servicesXml.indexOf('<service name="launchMrpRun"'),
+                servicesXml.indexOf('<service name="initMrpEvents"'))
+        String source = new File('applications/manufacturing/src/main/java/org/apache/ofbiz/manufacturing/mrp/MrpServices.java').text
+        String methodBody = source.substring(source.indexOf('public static Map<String, Object> launchMrpRun'),
+                source.indexOf('private static GenericValue findLaunchedMrpJob'))
+        String startRunLogBody = source.substring(source.indexOf('private static String startMrpRunLog'),
+                source.indexOf('private static String failureReasonFromResult'))
+
+        assert executeServiceXml.contains('<permission-service service-name="manufacturingPermissionService" main-action="CREATE"/>')
+        assert prepareServiceXml.contains('<permission-service service-name="manufacturingPermissionService" main-action="CREATE"/>')
+        assert launchServiceXml.contains('<permission-service service-name="manufacturingPermissionService" main-action="CREATE"/>')
+        assert methodBody.contains('UtilValidate.isEmpty(facilityId) && UtilValidate.isEmpty(facilityGroupId)')
+        assert methodBody.contains('ManufacturingMrpFacilityNotAvailable')
+        assert startRunLogBody.contains('"JOB_T_SCHEDULED".equals(jobTracker.getString("statusId"))')
+        assert startRunLogBody.contains('"statusId", "JOB_T_RUNNING"')
+        assert startRunLogBody.contains('dispatcher.runSync("updateJobTracker", updateJobTrackerContext, 60, true)')
+    }
+
+    @Test
+    void testRunContextMapUsesDurableRunLog() {
+        Script services = runLogData()
+        GenericValue runLog = mrpEvent([
+                mrpRunLogId: 'MRP_RUN_LOG_10030',
+                mrpId: '10030',
+                jobId: '12204',
+                mrpName: 'Thursday Evening Run',
+                facilityId: 'WebStoreWarehouse',
+                runByUserLoginId: 'admin',
+                statusId: 'SERVICE_FINISHED',
+                startedAt: Timestamp.valueOf('2026-07-02 19:25:00.000'),
+                finishedAt: Timestamp.valueOf('2026-07-02 19:25:00.274'),
+                durationMillis: 274L
+        ])
+        GenericValue status = mrpEvent([statusId: 'SERVICE_FINISHED', description: 'Finished'])
+
+        Map result = services.buildMrpRunResponseRow(runLog, status)
+
+        assert result.runId == 'MRP_RUN_LOG_10030'
+        assert result.mrpId == '10030'
+        assert result.jobId == '12204'
+        assert result.mrpName == 'Thursday Evening Run'
+        assert result.facilityId == 'WebStoreWarehouse'
+        assert result.runAsUser == 'admin'
+        assert result.statusDescription == 'Finished'
+        assert result.durationMillis == 274L
+    }
+
+    @Test
+    void testEventPlanAttentionSummaryExplainsBelowMinimumStock() {
+        GenericValue initialQoh = mrpEvent([
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-02 16:35:25.524'),
+                mrpEventTypeId: 'INITIAL_QOH',
+                facilityId: 'WebStoreWarehouse',
+                quantity: 18G
+        ])
+        Map enrichment = emptyEnrichment()
+        enrichment.productFacilities['GZ-8544|WebStoreWarehouse'] = mrpEvent([
+                productId: 'GZ-8544',
+                facilityId: 'WebStoreWarehouse',
+                minimumStock: 20G,
+                reorderQuantity: 50G,
+                daysToShip: 1L
+        ])
+
+        Map groupContext = MrpEventPlanBuilder.buildWarehouseEventPlanContext('10022|GZ-8544|WebStoreWarehouse', [initialQoh], enrichment)
+        Map plan = MrpEventPlanBuilder.buildEventPlanResponseRow(groupContext)
+
+        assert plan.statusDescription == 'Needs attention'
+        assert plan.actionNeeded
+        assert plan.attentionTypes == ['BELOW_SAFETY_STOCK']
+        assert plan.primaryAttentionType == 'BELOW_SAFETY_STOCK'
+        assert plan.attentionSummary == 'Current below safety stock'
+        assert !plan.containsKey('riskReasonCode')
+        assert !plan.containsKey('riskReason')
+        assert !plan.containsKey('hasRisk')
+    }
+
+    @Test
+    void testEventPlanAttentionSummaryExplainsLateSupplyBeforeStockPolicy() {
+        GenericValue proposedPurchase = mrpEvent([
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-02 16:35:25.523'),
+                mrpEventTypeId: 'PROP_PUR_O_RECP',
+                facilityId: 'WebStoreWarehouse',
+                quantity: 50G,
+                isLate: 'Y'
+        ])
+        GenericValue initialQoh = mrpEvent([
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-02 16:35:25.524'),
+                mrpEventTypeId: 'INITIAL_QOH',
+                facilityId: 'WebStoreWarehouse',
+                quantity: 18G
+        ])
+        Map enrichment = emptyEnrichment()
+        enrichment.productFacilities['GZ-8544|WebStoreWarehouse'] = mrpEvent([
+                productId: 'GZ-8544',
+                facilityId: 'WebStoreWarehouse',
+                minimumStock: 20G
+        ])
+
+        Map groupContext = MrpEventPlanBuilder.buildWarehouseEventPlanContext('10022|GZ-8544|WebStoreWarehouse',
+                [proposedPurchase, initialQoh], enrichment)
+        Map plan = MrpEventPlanBuilder.buildEventPlanResponseRow(groupContext)
+
+        assert plan.actionNeeded
+        assert plan.attentionTypes == ['LATE_EVENT', 'BELOW_SAFETY_STOCK']
+        assert plan.primaryAttentionType == 'LATE_EVENT'
+        assert plan.attentionSummary == 'Proposed supply is late; Current below safety stock'
+        assert !plan.containsKey('riskReasonCode')
+        assert !plan.containsKey('riskReason')
+    }
+
+    @Test
+    void testBuildEventPlanResponseRowIncludesAttentionMetadataForPlannerChips() {
+        GenericValue initialQoh = mrpEvent([
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-02 16:35:25.524'),
+                mrpEventTypeId: 'INITIAL_QOH',
+                facilityId: 'WebStoreWarehouse',
+                quantity: 18G,
+                isLate: 'N'
+        ])
+        GenericValue proposedPurchase = mrpEvent([
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-02 16:35:25.525'),
+                mrpEventTypeId: 'PROP_PUR_O_RECP',
+                facilityId: 'WebStoreWarehouse',
+                quantity: 50G,
+                isLate: 'Y'
+        ])
+        Map groupContext = MrpEventPlanBuilder.buildWarehouseEventPlanContext('10022|GZ-8544|WebStoreWarehouse',
+                [initialQoh, proposedPurchase], enrichmentWithProductFacility(20G))
+
+        Map plan = MrpEventPlanBuilder.buildEventPlanResponseRow(groupContext)
+
+        assert plan.actionNeeded
+        assert plan.attentionSeverity == 'HIGH'
+        assert plan.attentionTypes.contains('LATE_EVENT')
+        assert plan.attentionTypes.contains('BELOW_SAFETY_STOCK')
+        assert plan.primaryAttentionType == 'LATE_EVENT'
+        assert plan.attentionSummary.contains('late')
+        assert plan.inlineGuidance
+    }
+
+    @Test
+    void testEventPlanDoesNotNeedAttentionWhenOnTimeProposedSupplyRecoversCurrentBelowMinimum() {
+        GenericValue initialQoh = mrpEvent([
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-02 16:35:25.524'),
+                mrpEventTypeId: 'INITIAL_QOH',
+                facilityId: 'WebStoreWarehouse',
+                quantity: 18G,
+                isLate: 'N'
+        ])
+        GenericValue proposedPurchase = mrpEvent([
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-02 16:35:25.525'),
+                mrpEventTypeId: 'PROP_PUR_O_RECP',
+                facilityId: 'WebStoreWarehouse',
+                quantity: 50G,
+                isLate: 'N'
+        ])
+        Map enrichment = enrichmentWithProductFacility(20G)
+
+        Map groupContext = MrpEventPlanBuilder.buildWarehouseEventPlanContext('10022|GZ-8544|WebStoreWarehouse',
+                [initialQoh, proposedPurchase], enrichment)
+        Map plan = MrpEventPlanBuilder.buildEventPlanResponseRow(groupContext)
+
+        assert plan.statusDescription == 'On track'
+        assert !plan.actionNeeded
+        assert !plan.attentionTypes.contains('BELOW_SAFETY_STOCK')
+        assert !plan.containsKey('riskReasonCode')
+        assert !plan.containsKey('riskReason')
+    }
+
+    @Test
+    void testEventPlanNeedsAttentionWhenCurrentBelowMinimumAndNoProposedSupplyExists() {
+        GenericValue initialQoh = mrpEvent([
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-02 16:35:25.524'),
+                mrpEventTypeId: 'INITIAL_QOH',
+                facilityId: 'WebStoreWarehouse',
+                quantity: 18G,
+                isLate: 'N'
+        ])
+        Map enrichment = enrichmentWithProductFacility(20G)
+
+        Map groupContext = MrpEventPlanBuilder.buildWarehouseEventPlanContext('10022|GZ-8544|WebStoreWarehouse', [initialQoh], enrichment)
+        Map plan = MrpEventPlanBuilder.buildEventPlanResponseRow(groupContext)
+
+        assert plan.actionNeeded
+        assert plan.attentionTypes == ['BELOW_SAFETY_STOCK']
+        assert plan.attentionSeverity == 'MEDIUM'
+        assert plan.attentionSummary == 'Current below safety stock'
+        assert plan.inventoryContext.projectedBalance == 18G
+    }
+
+    @Test
+    void testEventPlanPrioritizesLateAttentionWhenLateSupplyRecoversBelowMinimum() {
+        GenericValue initialQoh = mrpEvent([
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-02 16:35:25.524'),
+                mrpEventTypeId: 'INITIAL_QOH',
+                facilityId: 'WebStoreWarehouse',
+                quantity: 18G,
+                isLate: 'N'
+        ])
+        GenericValue proposedPurchase = mrpEvent([
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-02 16:35:25.525'),
+                mrpEventTypeId: 'PROP_PUR_O_RECP',
+                facilityId: 'WebStoreWarehouse',
+                quantity: 50G,
+                isLate: 'Y'
+        ])
+        Map enrichment = enrichmentWithProductFacility(20G)
+
+        Map groupContext = MrpEventPlanBuilder.buildWarehouseEventPlanContext('10022|GZ-8544|WebStoreWarehouse',
+                [initialQoh, proposedPurchase], enrichment)
+        Map plan = MrpEventPlanBuilder.buildEventPlanResponseRow(groupContext)
+
+        assert plan.actionNeeded
+        assert plan.attentionTypes == ['LATE_EVENT', 'BELOW_SAFETY_STOCK']
+        assert plan.primaryAttentionType == 'LATE_EVENT'
+        assert plan.attentionSeverity == 'HIGH'
+    }
+
+    @Test
+    void testEventPlanNeedsAttentionWhenFinalBalanceBelowMinimumAfterPlanDoesNotRecover() {
+        GenericValue initialQoh = mrpEvent([
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-02 16:35:25.524'),
+                mrpEventTypeId: 'INITIAL_QOH',
+                facilityId: 'WebStoreWarehouse',
+                quantity: 18G,
+                isLate: 'N'
+        ])
+        GenericValue salesDemand = mrpEvent([
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-03 10:00:00.000'),
+                mrpEventTypeId: 'SALES_ORDER_SHIP',
+                facilityId: 'WebStoreWarehouse',
+                quantity: -5G,
+                isLate: 'N'
+        ])
+        Map enrichment = enrichmentWithProductFacility(20G)
+
+        Map groupContext = MrpEventPlanBuilder.buildWarehouseEventPlanContext('10022|GZ-8544|WebStoreWarehouse',
+                [initialQoh, salesDemand], enrichment)
+        Map plan = MrpEventPlanBuilder.buildEventPlanResponseRow(groupContext)
+
+        assert plan.actionNeeded
+        assert plan.attentionTypes == ['BELOW_SAFETY_STOCK']
+        assert plan.attentionSeverity == 'MEDIUM'
+        assert plan.attentionSummary == 'Projected below safety stock'
+        assert plan.inventoryContext.projectedBalance == 18G
+        assert plan.inventoryContext.lowestRunningBalance == 13G
+    }
+
+    @Test
+    void testEventPlanMarksNegativeRunningBalanceAsHighAttentionSeverity() {
+        GenericValue initialQoh = mrpEvent([
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-02 16:35:25.524'),
+                mrpEventTypeId: 'INITIAL_QOH',
+                facilityId: 'WebStoreWarehouse',
+                quantity: 3G,
+                isLate: 'N'
+        ])
+        GenericValue salesDemand = mrpEvent([
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-03 10:00:00.000'),
+                mrpEventTypeId: 'SALES_ORDER_SHIP',
+                facilityId: 'WebStoreWarehouse',
+                quantity: -10G,
+                isLate: 'N'
+        ])
+        Map enrichment = enrichmentWithProductFacility(2G)
+
+        Map groupContext = MrpEventPlanBuilder.buildWarehouseEventPlanContext('10022|GZ-8544|WebStoreWarehouse',
+                [initialQoh, salesDemand], enrichment)
+        Map plan = MrpEventPlanBuilder.buildEventPlanResponseRow(groupContext)
+
+        assert plan.actionNeeded
+        assert plan.attentionTypes == ['BELOW_SAFETY_STOCK']
+        assert plan.attentionSeverity == 'HIGH'
+        assert plan.inventoryContext.lowestRunningBalance == -7G
+    }
+
+    @Test
+    void testEventPlanMarksNegativeRunningBalanceEvenWithoutMinimumStockPolicy() {
+        GenericValue initialQoh = mrpEvent([
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-02 16:35:25.524'),
+                mrpEventTypeId: 'INITIAL_QOH',
+                facilityId: 'WebStoreWarehouse',
+                quantity: 3G,
+                isLate: 'N'
+        ])
+        GenericValue salesDemand = mrpEvent([
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-03 10:00:00.000'),
+                mrpEventTypeId: 'SALES_ORDER_SHIP',
+                facilityId: 'WebStoreWarehouse',
+                quantity: -10G,
+                isLate: 'N'
+        ])
+
+        Map groupContext = MrpEventPlanBuilder.buildWarehouseEventPlanContext('10022|GZ-8544|WebStoreWarehouse',
+                [initialQoh, salesDemand], emptyEnrichment())
+        Map plan = MrpEventPlanBuilder.buildEventPlanResponseRow(groupContext)
+
+        assert plan.actionNeeded
+        assert plan.attentionTypes == ['BELOW_SAFETY_STOCK']
+        assert plan.attentionSeverity == 'HIGH'
+        assert plan.inventoryContext.lowestRunningBalance == -7G
+    }
+
+    @Test
+    void testPlannerVisibleTimelineRowsHideInternalRequiredMrpMarker() {
+        List timeline = [
+                [mrpEventTypeId: 'INITIAL_QOH', eventTypeLabel: 'Initial QOH'],
+                [mrpEventTypeId: 'REQUIRED_MRP', eventTypeLabel: 'Stock policy trigger'],
+                [mrpEventTypeId: 'PROP_PUR_O_RECP', eventTypeLabel: 'Proposed purchase']
+        ]
+
+        List plannerVisibleTimelineRows = MrpEventPlanBuilder.plannerVisibleTimelineRows(timeline)
+
+        assert plannerVisibleTimelineRows*.mrpEventTypeId == ['INITIAL_QOH', 'PROP_PUR_O_RECP']
+    }
+
+    @Test
+    void testProposedSupplyTimelineExposesRequirementReferenceFromEventName() {
+        GenericValue proposedPurchase = mrpEvent([
+                mrpId: '10022',
+                productId: 'GZ-8544',
+                eventDate: Timestamp.valueOf('2026-07-02 16:35:25.523'),
+                mrpEventTypeId: 'PROP_PUR_O_RECP',
+                facilityId: 'WebStoreWarehouse',
+                quantity: 50G,
+                eventName: '*10026 (2026-07-02 16:35:25.523)*'
+        ])
+
+        List timeline = MrpEventPlanBuilder.buildRunningBalanceTimeline([proposedPurchase], emptyEnrichment(), null)
+
+        assert timeline.first().requirementId == '10026'
+        assert timeline.first().requirementDate == Timestamp.valueOf('2026-07-02 16:35:25.523')
+    }
+
+    @Test
+    void testSameRunStockPolicyProposalIsNotMarkedLate() {
+        Timestamp runStartedAt = Timestamp.valueOf('2026-07-02 16:35:25.524')
+        Timestamp requirementStartDate = Timestamp.valueOf('2026-07-02 16:35:25.523')
+        GenericValue stockPolicyTriggerEvent = mrpEvent([
+                mrpEventTypeId: 'REQUIRED_MRP',
+                quantity: 0G
+        ])
+
+        assert !MrpServices.isProposedOrderLate(requirementStartDate, runStartedAt, stockPolicyTriggerEvent)
+    }
+
+    @Test
+    void testDatedDemandProposalStillMarksLateWhenStartIsBeforeRun() {
+        Timestamp runStartedAt = Timestamp.valueOf('2026-07-02 16:35:25.524')
+        Timestamp requirementStartDate = Timestamp.valueOf('2026-07-01 16:35:25.524')
+        GenericValue salesDemand = mrpEvent([
+                mrpEventTypeId: 'SALES_ORDER_SHIP',
+                quantity: -2G
+        ])
+
+        assert MrpServices.isProposedOrderLate(requirementStartDate, runStartedAt, salesDemand)
+    }
+
+    private static Map enrichmentWithProductFacility(BigDecimal minimumStock, BigDecimal reorderQuantity = 50G,
+            Long daysToShip = 1L) {
+        Map enrichment = emptyEnrichment()
+        enrichment.productFacilities['GZ-8544|WebStoreWarehouse'] = mrpEvent([
+                productId: 'GZ-8544',
+                facilityId: 'WebStoreWarehouse',
+                minimumStock: minimumStock,
+                reorderQuantity: reorderQuantity,
+                daysToShip: daysToShip
+        ])
+        return enrichment
+    }
+
+    private static Script eventPlanData(Map parameters = [:], MockHttpServletRequest request = null) {
+        Binding binding = new Binding([delegator: null, parameters: parameters ?: [:]])
+        if (request != null) {
+            binding.setVariable('request', request)
+        }
+        return new GroovyShell(MrpPlanningServicesTests.classLoader, binding).parse(
+                new File('applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/mrp/MrpEventPlanServices.groovy'))
+    }
+
+    private static Script runLogData(Map parameters = [:], MockHttpServletRequest request = null) {
+        Binding binding = new Binding([delegator: null, parameters: parameters ?: [:]])
+        if (request != null) {
+            binding.setVariable('request', request)
+        }
+        return new GroovyShell(MrpPlanningServicesTests.classLoader, binding).parse(
+                new File('applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/mrp/MrpRunLogServices.groovy'))
+    }
+
+    private static void withRecordingEntityQueries(Map<String, RecordingQuery> queriesByEntityName, Closure work) {
+        EntityQuery.metaClass.static.use = { Object delegator ->
+            return new RecordingQuerySource(queriesByEntityName)
+        }
+        try {
+            work.call()
+        } finally {
+            GroovySystem.metaClassRegistry.removeMetaClass(EntityQuery)
+        }
+    }
+
+    private static GenericValue mrpEvent(Map fields) {
+        return new StubGenericValue(fields)
+    }
+
+    private static Map emptyEnrichment() {
+        return [
+                products: [:],
+                facilities: [:],
+                productFacilities: [:],
+                eventTypes: [:],
+                statuses: [:]
+        ]
+    }
+
+    private static class StubGenericValue extends GenericValue {
+
+        private final Map fields
+
+        StubGenericValue(Map fields) {
+            this.fields = fields
+        }
+
+        @Override
+        Object get(String name) {
+            return fields[name]
+        }
+
+        @Override
+        Object get(Object key) {
+            return fields[key]
+        }
+
+        @Override
+        BigDecimal getBigDecimal(String name) {
+            Object value = fields[name]
+            if (value == null) {
+                return null
+            }
+            return value instanceof BigDecimal ? value : new BigDecimal(value.toString())
+        }
+
+        @Override
+        String getString(String name) {
+            Object value = fields[name]
+            return value != null ? value.toString() : null
+        }
+
+        @Override
+        Timestamp getTimestamp(String name) {
+            Object value = fields[name]
+            if (value == null) {
+                return null
+            }
+            return value instanceof Timestamp ? value : Timestamp.valueOf(value.toString())
+        }
+
+        @Override
+        Long getLong(String name) {
+            Object value = fields[name]
+            if (value == null) {
+                return null
+            }
+            return value instanceof Long ? value : Long.valueOf(value.toString())
+        }
+
+        Object getProperty(String name) {
+            if (fields.containsKey(name)) {
+                return fields[name]
+            }
+            return super.getProperty(name)
+        }
+
+    }
+
+    private static class RecordingQuery {
+
+        final List rows
+        String entityName
+        List selectFields
+        Object whereCondition
+        List orderByFields
+        Integer maxRowsValue
+
+        RecordingQuery(List rows) {
+            this.rows = rows
+        }
+
+        RecordingQuery select(String... fields) {
+            selectFields = fields as List
+            return this
+        }
+
+        RecordingQuery where(Object condition) {
+            whereCondition = condition
+            return this
+        }
+
+        RecordingQuery orderBy(String... fields) {
+            orderByFields = fields as List
+            return this
+        }
+
+        RecordingQuery maxRows(int maxRows) {
+            maxRowsValue = maxRows
+            return this
+        }
+
+        @SuppressWarnings('UnusedMethodParameter')
+        RecordingQuery cache(boolean useCache) {
+            return this
+        }
+
+        GenericValue queryFirst() {
+            return rows ? rows.first() : null
+        }
+
+        List queryList() {
+            return rows
+        }
+
+    }
+
+    private static class RecordingQuerySource {
+
+        private final Map<String, RecordingQuery> queriesByEntityName
+
+        RecordingQuerySource(Map<String, RecordingQuery> queriesByEntityName) {
+            this.queriesByEntityName = queriesByEntityName
+        }
+
+        RecordingQuery from(String entityName) {
+            RecordingQuery query = queriesByEntityName[entityName]
+            assert query != null
+            query.entityName = entityName
+            return query
+        }
+
+    }
+
+}


(ofbiz-framework) 03/04: OFBIZ-13502 Add MRP run history read model

Posted by di...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

diveshdut pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git

commit cf23ae2e0183cd9227649f9adbef920b841fcee3
Author: diveshdut <di...@hotwax.co>
AuthorDate: Fri Aug 21 12:22:06 2026 +0530

    OFBIZ-13502 Add MRP run history read model
    
    Read durable MrpRunLog rows and return REST-compatible run history with status descriptions, filters, sorting, paging metadata, and duration fields.
---
 .../manufacturing/servicedef/services_mrp.xml      |  23 ++++
 .../manufacturing/mrp/MrpRunLogServices.groovy     | 145 +++++++++++++++++++++
 2 files changed, 168 insertions(+)

diff --git a/applications/manufacturing/servicedef/services_mrp.xml b/applications/manufacturing/servicedef/services_mrp.xml
index 3e22af4d1c..e684851de2 100644
--- a/applications/manufacturing/servicedef/services_mrp.xml
+++ b/applications/manufacturing/servicedef/services_mrp.xml
@@ -166,6 +166,29 @@ under the License.
         <attribute mode="OUT" name="links" optional="true" type="Map"/>
         <attribute mode="OUT" name="eventPlans" optional="false" type="java.util.List"/>
     </service>
+    <service name="findMrpRuns" engine="groovy" auth="true"
+            location="component://manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/mrp/MrpRunLogServices.groovy" invoke="findMrpRuns">
+        <description>Find durable MRP run history from MRP run log records.</description>
+        <permission-service service-name="manufacturingPermissionService" main-action="VIEW"/>
+        <attribute mode="IN" name="jobId" optional="true" type="String"/>
+        <attribute mode="IN" name="mrpId" optional="true" type="String"/>
+        <attribute mode="IN" name="statusId" optional="true" type="String"/>
+        <attribute mode="IN" name="fromDate" optional="true" type="Timestamp"/>
+        <attribute mode="IN" name="daysBack" optional="true" type="Integer"/>
+        <attribute mode="IN" name="pageIndex" optional="true" type="Integer"/>
+        <attribute mode="IN" name="pageSize" optional="true" type="Integer"/>
+        <attribute mode="IN" name="sort" optional="true" type="String"/>
+        <attribute mode="IN" name="orderBy" optional="true" type="String"/>
+        <attribute mode="OUT" name="pageIndex" optional="false" type="Integer"/>
+        <attribute mode="OUT" name="pageSize" optional="false" type="Integer"/>
+        <attribute mode="OUT" name="totalCount" optional="false" type="Long"/>
+        <attribute mode="OUT" name="totalPages" optional="false" type="Long"/>
+        <attribute mode="OUT" name="hasNext" optional="false" type="Boolean"/>
+        <attribute mode="OUT" name="previousPageCount" optional="true" type="Long"/>
+        <attribute mode="OUT" name="nextPageCount" optional="true" type="Long"/>
+        <attribute mode="OUT" name="links" optional="true" type="Map"/>
+        <attribute mode="OUT" name="runs" optional="false" type="java.util.List"/>
+    </service>
     <service name="findPlanningFacilities" engine="groovy" auth="true"
             location="component://manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/api/ManufacturingLookupServices.groovy" invoke="findManufacturingFacilities">
         <description>Find warehouse and plant facilities available for MRP planning.</description>
diff --git a/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/mrp/MrpRunLogServices.groovy b/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/mrp/MrpRunLogServices.groovy
new file mode 100644
index 0000000000..57df180479
--- /dev/null
+++ b/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/mrp/MrpRunLogServices.groovy
@@ -0,0 +1,145 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.ofbiz.manufacturing.mrp
+
+import java.sql.Timestamp
+import java.time.Duration
+
+import org.apache.ofbiz.base.util.DateRange
+import org.apache.ofbiz.base.util.UtilMisc
+import org.apache.ofbiz.base.util.UtilProperties
+import org.apache.ofbiz.base.util.UtilValidate
+import org.apache.ofbiz.entity.GenericValue
+import org.apache.ofbiz.entity.condition.EntityCondition
+import org.apache.ofbiz.entity.condition.EntityOperator
+import org.apache.ofbiz.entity.util.EntityQuery
+import org.apache.ofbiz.entity.util.EntityUtil
+import org.apache.ofbiz.service.ServiceUtil
+import org.apache.ofbiz.ws.rs.util.RestApiUtil
+import org.apache.ofbiz.ws.rs.util.RestQueryOptions
+
+/*
+ * Builds paged MRP run history from durable MrpRunLog rows.
+ *
+ * Flow:
+ * 1. Parse REST query options and translate supported sort fields.
+ * 2. Build MrpRunLog filters for status, job, MRP id, and history window.
+ * 3. Query/count paged MrpRunLog rows and batch-load status descriptions.
+ * 4. Build response rows and return framework paging metadata.
+ */
+Map findMrpRuns() {
+    RestQueryOptions queryOptions
+    try {
+        queryOptions = RestQueryOptions.fromParameters(parameters)
+    } catch (IllegalArgumentException e) {
+        return ServiceUtil.returnError(e.message)
+    }
+    Map filters = queryOptions.filters
+    List orderBy
+    try {
+        orderBy = resolveMrpRunOrderBy(queryOptions.sort)
+    } catch (IllegalArgumentException e) {
+        return ServiceUtil.returnError(e.message)
+    }
+    List conditions = []
+    if (UtilValidate.isNotEmpty(filters.statusId)) {
+        conditions.add(EntityCondition.makeCondition('runStatusId', filters.statusId))
+    }
+    if (UtilValidate.isNotEmpty(filters.jobId)) {
+        conditions.add(EntityCondition.makeCondition('jobId', filters.jobId))
+    }
+    if (UtilValidate.isNotEmpty(filters.mrpId)) {
+        conditions.add(EntityCondition.makeCondition('mrpId', filters.mrpId))
+    }
+    Timestamp historyFromDate = runHistoryFromDate(filters)
+    if (historyFromDate) {
+        conditions.add(EntityCondition.makeCondition('startedAt', EntityOperator.GREATER_THAN_EQUAL_TO, historyFromDate))
+    }
+
+    EntityQuery query = EntityQuery.use(delegator).from('MrpRunLog')
+    if (conditions) {
+        query.where(EntityCondition.makeCondition(conditions, EntityOperator.AND))
+    }
+    long totalCount = query.queryCount()
+    List rows = query.orderBy(orderBy)
+            .queryPagedList(queryOptions.pageIndex, queryOptions.pageSize)
+            .getData()
+    Map statuses = rows ? EntityUtil.lookupById(delegator, 'StatusItem', 'statusId',
+            rows*.runStatusId.findAll { it } as Set) : [:]
+    List runs = rows.collect { GenericValue runLog ->
+        buildMrpRunResponseRow(runLog, statuses[runLog.getString('runStatusId')])
+    }
+    String relativeRequestPath = RestApiUtil.getRelativeRequestPath(binding)
+    return ServiceUtil.returnSuccess() + RestApiUtil.getPagedResult('runs', runs, queryOptions, totalCount,
+            relativeRequestPath)
+}
+
+Timestamp runHistoryFromDate(Map filters) {
+    Integer daysBack = filters.daysBack instanceof Integer ? filters.daysBack : null
+    if (daysBack && daysBack > 0) {
+        long millisBack = Duration.ofDays(daysBack.longValue()).toMillis()
+        return new Timestamp(System.currentTimeMillis() - millisBack)
+    }
+    return filters.fromDate as Timestamp
+}
+
+Map buildMrpRunResponseRow(GenericValue runLog, GenericValue status) {
+    Locale serviceLocale = binding.hasVariable('locale') ? locale : Locale.ENGLISH
+    Timestamp startDateTime = runLog.getTimestamp('startedAt')
+    Timestamp finishDateTime = runLog.getTimestamp('finishedAt')
+    Long durationMillis = runLog.getLong('durationMillis')
+    if (durationMillis == null && startDateTime != null && finishDateTime != null) {
+        durationMillis = new DateRange(startDateTime, finishDateTime).durationInMillis()
+    }
+    [
+            runId: runLog.getString('mrpRunLogId'),
+            mrpRunLogId: runLog.getString('mrpRunLogId'),
+            jobId: runLog.getString('jobId'),
+            mrpId: runLog.getString('mrpId'),
+            mrpName: runLog.getString('mrpName') ?: UtilProperties.getMessage('ManufacturingUiLabels',
+                    'ManufacturingMrpRunDefaultName', serviceLocale),
+            facilityGroupId: runLog.getString('facilityGroupId'),
+            facilityId: runLog.getString('facilityId'),
+            defaultYearsOffset: UtilMisc.toIntegerObject(runLog.get('defaultYearsOffset')) ?: 1,
+            statusId: runLog.getString('runStatusId'),
+            statusDescription: status?.getString('description'),
+            failureReason: runLog.getString('failureReason'),
+            failureMessage: runLog.getString('failureMessage'),
+            runTime: durationMillis != null ? Duration.ofMillis(durationMillis).toString() : null,
+            durationMillis: durationMillis,
+            startDateTime: startDateTime,
+            finishDateTime: finishDateTime,
+            runAsUser: runLog.getString('runByUserLoginId')
+    ]
+}
+
+List resolveMrpRunOrderBy(String sortExpression) {
+    return RestApiUtil.resolveOrderBy(sortExpression, [
+            runId: 'mrpRunLogId',
+            mrpRunLogId: 'mrpRunLogId',
+            mrpId: 'mrpId',
+            mrpName: 'mrpName',
+            facilityId: 'facilityId',
+            statusId: 'runStatusId',
+            startDateTime: 'startedAt',
+            finishDateTime: 'finishedAt',
+            durationMillis: 'durationMillis',
+            jobId: 'jobId'
+    ], ['-startedAt', '-createdStamp'])
+}


(ofbiz-framework) 01/04: OFBIZ-13460 Add MRP run lifecycle logging

Posted by di...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

diveshdut pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git

commit d34901e830085b6ac64d9e23c905c9391cd5ee78
Author: diveshdut <di...@hotwax.co>
AuthorDate: Fri Aug 21 12:20:25 2026 +0530

    OFBIZ-13460 Add MRP run lifecycle logging
    
    Add durable run lifecycle logging for MRP execution so queued, running, finished, and failed runs can be tracked independently from the transient event rows produced by the engine.
    
    Update services_mrp.xml with the run-log service contracts. launchMrpRun queues executeMrp as persisted async work, createMrpRunLogAndTrackerBeforeQueueingMrp creates the durable MrpRunLog and JobTracker records before queueing, and the update services keep the run status, timing, job id, error text, and generated mrpId current as the run progresses.
    
    Update MrpServices.java with the core lifecycle behavior used by both API callers and Manufacturing screens. The launch path creates the run log before queueing executeMrp, records queue failures when executeMrp never starts, attaches the JobSandbox job id when queueing succeeds, and lets executeMrp mark MrpRunLog as SERVICE_RUNNING, SERVICE_FINISHED, or SERVICE_FAILED.
    
    Update the Manufacturing run-MRP controller action to call launchMrpRun instead of directly queueing executeMrp, so the standard screen and REST/API flows use the same lifecycle handling.
    
    Add ManufacturingUiLabels entries for the run-log outcome and queue-failure messages shown by the lifecycle services.
---
 .../manufacturing/config/ManufacturingUiLabels.xml |  12 +
 .../manufacturing/servicedef/services_mrp.xml      |  43 +++
 .../ofbiz/manufacturing/mrp/MrpServices.java       | 380 ++++++++++++++++++---
 .../webapp/manufacturing/WEB-INF/controller.xml    |   2 +-
 4 files changed, 394 insertions(+), 43 deletions(-)

diff --git a/applications/manufacturing/config/ManufacturingUiLabels.xml b/applications/manufacturing/config/ManufacturingUiLabels.xml
index 8a87582410..a2fc697327 100644
--- a/applications/manufacturing/config/ManufacturingUiLabels.xml
+++ b/applications/manufacturing/config/ManufacturingUiLabels.xml
@@ -3131,6 +3131,9 @@
         <value xml:lang="zh">上一个工作</value>
         <value xml:lang="zh-TW">上一個工作</value>
     </property>
+    <property key="ManufacturingMrpJobQueueError">
+        <value xml:lang="en">MRP job could not be queued: ${errorString}</value>
+    </property>
     <property key="ManufacturingMrpJobScheduledOrRunning">
         <value xml:lang="de">Geplanter oder aktiver MRP-Lauf</value>
         <value xml:lang="en">Scheduled or running Jobs</value>
@@ -3171,6 +3174,15 @@
         <value xml:lang="zh">生产资源计划名称</value>
         <value xml:lang="zh-TW">生產資源計劃名稱</value>
     </property>
+    <property key="ManufacturingMrpRunDefaultName">
+        <value xml:lang="en">MRP run</value>
+    </property>
+    <property key="ManufacturingMrpRunLogAndTrackerCreateError">
+        <value xml:lang="en">MRP run log and tracker could not be created: ${errorString}</value>
+    </property>
+    <property key="ManufacturingMrpRunOutcomeSummary">
+        <value xml:lang="en">Created ${mrpEventCount} MRP events and ${proposedRequirementCount} proposed requirements</value>
+    </property>
     <property key="ManufacturingMrpRunScheduledSuccessfully">
         <value xml:lang="en">Mrp run scheduled successfully</value>
         <value xml:lang="fr">Exécution de Mrp programmée avec succès</value>
diff --git a/applications/manufacturing/servicedef/services_mrp.xml b/applications/manufacturing/servicedef/services_mrp.xml
index e8e3251f61..ec0be0f5ae 100644
--- a/applications/manufacturing/servicedef/services_mrp.xml
+++ b/applications/manufacturing/servicedef/services_mrp.xml
@@ -29,6 +29,7 @@ under the License.
             location="org.apache.ofbiz.manufacturing.mrp.MrpServices" invoke="executeMrp" auth="true"
             transaction-timeout="7200" max-retry="0">
         <description>Performs a run of Mrp</description>
+        <permission-service service-name="manufacturingPermissionService" main-action="CREATE"/>
         <attribute name="facilityGroupId" type="String" mode="IN" optional="true"/>
         <attribute name="facilityId" type="String" mode="IN" optional="true"/>
         <attribute name="mrpName" type="String" mode="IN" optional="true"/>
@@ -47,6 +48,48 @@ under the License.
         <auto-attributes include="pk" mode="IN" optional="false"/>
         <auto-attributes include="nonpk" mode="IN" optional="true"/>
     </service>
+    <service name="createMrpRunLogAndTrackerBeforeQueueingMrp" engine="java"
+            location="org.apache.ofbiz.manufacturing.mrp.MrpServices" invoke="createMrpRunLogAndTrackerBeforeQueueingMrp" auth="true"
+            require-new-transaction="true">
+        <description>Create the durable MrpRunLog, RuntimeData, and JobTracker records before launchMrpRun queues
+            MRP through executeMrp. Runs in a new transaction so the persisted async executeMrp job can reference committed tracking records.</description>
+        <permission-service service-name="manufacturingPermissionService" main-action="CREATE"/>
+        <attribute name="mrpRunLogId" type="String" mode="INOUT" optional="true"/>
+        <attribute name="jobTrackerId" type="String" mode="IN" optional="true"/>
+        <attribute name="mrpName" type="String" mode="IN" optional="true"/>
+        <attribute name="facilityId" type="String" mode="IN" optional="true"/>
+        <attribute name="facilityGroupId" type="String" mode="IN" optional="true"/>
+        <attribute name="defaultYearsOffset" type="Integer" mode="IN" optional="true"/>
+        <attribute name="runStatusId" type="String" mode="IN" optional="true"/>
+        <attribute name="startedAt" type="Timestamp" mode="IN" optional="true"/>
+    </service>
+    <service name="launchMrpRun" engine="java"
+            location="org.apache.ofbiz.manufacturing.mrp.MrpServices" invoke="launchMrpRun" auth="true">
+        <description>Launch an async MRP run using generic MRP infrastructure. This service owns queued JobSandbox
+            attachment and queue-failure logging so manufacturing screens and API clients share the same durable run tracking behavior.</description>
+        <permission-service service-name="manufacturingPermissionService" main-action="CREATE"/>
+        <attribute name="facilityGroupId" type="String" mode="IN" optional="true"/>
+        <attribute name="facilityId" type="String" mode="IN" optional="true"/>
+        <attribute name="mrpName" type="String" mode="IN" optional="true"/>
+        <attribute name="defaultYearsOffset" type="Integer" mode="IN" optional="true"/>
+        <attribute name="runId" type="String" mode="OUT" optional="false"/>
+        <attribute name="mrpRunLogId" type="String" mode="OUT" optional="false"/>
+        <attribute name="jobId" type="String" mode="OUT" optional="true"/>
+        <attribute name="mrpId" type="String" mode="OUT" optional="true"/>
+        <attribute name="mrpName" type="String" mode="OUT" optional="true"/>
+        <attribute name="facilityGroupId" type="String" mode="OUT" optional="true"/>
+        <attribute name="facilityId" type="String" mode="OUT" optional="true"/>
+        <attribute name="defaultYearsOffset" type="Integer" mode="OUT" optional="true"/>
+        <attribute name="statusId" type="String" mode="OUT" optional="true"/>
+        <attribute name="statusDescription" type="String" mode="OUT" optional="true"/>
+        <attribute name="failureReason" type="String" mode="OUT" optional="true"/>
+        <attribute name="failureMessage" type="String" mode="OUT" optional="true"/>
+        <attribute name="runTime" type="String" mode="OUT" optional="true"/>
+        <attribute name="durationMillis" type="Long" mode="OUT" optional="true"/>
+        <attribute name="startDateTime" type="Timestamp" mode="OUT" optional="true"/>
+        <attribute name="finishDateTime" type="Timestamp" mode="OUT" optional="true"/>
+        <attribute name="runAsUser" type="String" mode="OUT" optional="true"/>
+    </service>
     <service name="initMrpEvents" engine="java"
             location="org.apache.ofbiz.manufacturing.mrp.MrpServices" invoke="initMrpEvents" auth="true">
         <description>Initialize data for the MRP</description>
diff --git a/applications/manufacturing/src/main/java/org/apache/ofbiz/manufacturing/mrp/MrpServices.java b/applications/manufacturing/src/main/java/org/apache/ofbiz/manufacturing/mrp/MrpServices.java
index 1ccec0126c..cac5ed6fd7 100644
--- a/applications/manufacturing/src/main/java/org/apache/ofbiz/manufacturing/mrp/MrpServices.java
+++ b/applications/manufacturing/src/main/java/org/apache/ofbiz/manufacturing/mrp/MrpServices.java
@@ -19,8 +19,10 @@
 
 package org.apache.ofbiz.manufacturing.mrp;
 
+import java.io.IOException;
 import java.math.BigDecimal;
 import java.sql.Timestamp;
+import java.time.Duration;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.LinkedList;
@@ -29,6 +31,7 @@ import java.util.ListIterator;
 import java.util.Locale;
 import java.util.Map;
 
+import org.apache.ofbiz.base.util.DateRange;
 import org.apache.ofbiz.base.util.Debug;
 import org.apache.ofbiz.base.util.UtilDateTime;
 import org.apache.ofbiz.base.util.UtilGenerics;
@@ -40,6 +43,8 @@ import org.apache.ofbiz.entity.GenericEntityException;
 import org.apache.ofbiz.entity.GenericValue;
 import org.apache.ofbiz.entity.condition.EntityCondition;
 import org.apache.ofbiz.entity.condition.EntityOperator;
+import org.apache.ofbiz.entity.serialize.SerializeException;
+import org.apache.ofbiz.entity.serialize.XmlSerializer;
 import org.apache.ofbiz.entity.util.EntityQuery;
 import org.apache.ofbiz.entity.util.EntityUtil;
 import org.apache.ofbiz.manufacturing.bom.BOMNode;
@@ -675,7 +680,7 @@ public class MrpServices {
         }
     }
 
-    private static String mrpRunLogIdFromContext(Delegator delegator, Map<String, ? extends Object> context) {
+    private static String getMrpRunLogIdFromContext(Delegator delegator, Map<String, ? extends Object> context) {
         String mrpRunLogId = (String) context.get("mrpRunLogId");
         if (UtilValidate.isNotEmpty(mrpRunLogId)) {
             return mrpRunLogId;
@@ -684,6 +689,271 @@ public class MrpServices {
         return UtilValidate.isNotEmpty(mrpRunLogId) ? mrpRunLogId : delegator.getNextSeqId("MrpRunLog");
     }
 
+    /**
+     * Create queue-time run log and tracker metadata before queueing MRP through executeMrp.
+     */
+    public static Map<String, Object> createMrpRunLogAndTrackerBeforeQueueingMrp(DispatchContext ctx, Map<String, ? extends Object> context) {
+        Delegator delegator = ctx.getDelegator();
+        LocalDispatcher dispatcher = ctx.getDispatcher();
+        GenericValue userLogin = (GenericValue) context.get("userLogin");
+        Locale locale = (Locale) context.get("locale");
+        Timestamp startedAt = (Timestamp) context.get("startedAt");
+        if (startedAt == null) {
+            startedAt = UtilDateTime.nowTimestamp();
+        }
+        String mrpRunLogId = getMrpRunLogIdFromContext(delegator, context);
+        Map<String, Object> createMrpRunLogContext = new HashMap<>();
+        createMrpRunLogContext.put("mrpRunLogId", mrpRunLogId);
+        createMrpRunLogContext.put("mrpName", UtilValidate.isNotEmpty((String) context.get("mrpName")) ? context.get("mrpName")
+                : UtilProperties.getMessage(RESOURCE, "ManufacturingMrpRunDefaultName", locale));
+        if (UtilValidate.isNotEmpty((String) context.get("facilityId"))) {
+            createMrpRunLogContext.put("facilityId", context.get("facilityId"));
+        }
+        if (UtilValidate.isNotEmpty((String) context.get("facilityGroupId"))) {
+            createMrpRunLogContext.put("facilityGroupId", context.get("facilityGroupId"));
+        }
+        if (context.get("defaultYearsOffset") != null) {
+            createMrpRunLogContext.put("defaultYearsOffset", context.get("defaultYearsOffset"));
+        }
+        if (userLogin != null && UtilValidate.isNotEmpty(userLogin.getString("userLoginId"))) {
+            createMrpRunLogContext.put("runByUserLoginId", userLogin.getString("userLoginId"));
+        }
+        createMrpRunLogContext.put("runStatusId",
+                UtilValidate.isNotEmpty((String) context.get("runStatusId")) ? context.get("runStatusId") : "SERVICE_PENDING");
+        createMrpRunLogContext.put("startedAt", startedAt);
+        createMrpRunLogContext.put("userLogin", userLogin);
+        try {
+            createMrpRunLogContext = ctx.makeValidContext("createMrpRunLog", ModelService.IN_PARAM, createMrpRunLogContext);
+            Map<String, Object> response = dispatcher.runSync("createMrpRunLog", createMrpRunLogContext);
+            if (ServiceUtil.isError(response)) {
+                return ServiceUtil.returnError(ServiceUtil.getErrorMessage(response));
+            }
+            String jobTrackerId = (String) context.get("jobTrackerId");
+            if (mrpRunLogId.equals(jobTrackerId)) {
+                GenericValue existingJobTracker = EntityQuery.use(delegator).from("JobTracker").where("jobTrackerId", mrpRunLogId).queryOne();
+                if (existingJobTracker == null) {
+                    Map<String, Object> trackedParameters = new HashMap<>(context);
+                    trackedParameters.remove("timeZone");
+                    String runtimeDataId = delegator.getNextSeqId("RuntimeData");
+                    delegator.create("RuntimeData", UtilMisc.toMap("runtimeDataId", runtimeDataId,
+                            "runtimeInfo", XmlSerializer.serialize(trackedParameters)));
+
+                    String userLoginId = userLogin != null ? userLogin.getString("userLoginId") : null;
+                    Map<String, Object> createJobTrackerContext = new HashMap<>();
+                    createJobTrackerContext.put("jobTrackerId", mrpRunLogId);
+                    createJobTrackerContext.put("serviceName", "executeMrp");
+                    createJobTrackerContext.put("runtimeDataId", runtimeDataId);
+                    createJobTrackerContext.put("statusId", "JOB_T_SCHEDULED");
+                    createJobTrackerContext.put("jobsTotalQty", 1L);
+                    createJobTrackerContext.put("persistResult", "Y");
+                    if (UtilValidate.isNotEmpty(userLoginId)) {
+                        createJobTrackerContext.put("runAsUser", userLoginId);
+                    }
+                    createJobTrackerContext.put("startDate", startedAt);
+                    createJobTrackerContext.put("createdDate", startedAt);
+                    if (UtilValidate.isNotEmpty(userLoginId)) {
+                        createJobTrackerContext.put("createdByUserLogin", userLoginId);
+                    }
+                    createJobTrackerContext.put("lastModifiedDate", startedAt);
+                    if (UtilValidate.isNotEmpty(userLoginId)) {
+                        createJobTrackerContext.put("lastModifiedByUserLogin", userLoginId);
+                    }
+                    createJobTrackerContext.put("userLogin", userLogin);
+                    createJobTrackerContext = ctx.makeValidContext("createJobTracker", ModelService.IN_PARAM, createJobTrackerContext);
+                    response = dispatcher.runSync("createJobTracker", createJobTrackerContext);
+                    if (ServiceUtil.isError(response)) {
+                        return ServiceUtil.returnError(ServiceUtil.getErrorMessage(response));
+                    }
+                }
+            }
+        } catch (GenericEntityException | GenericServiceException e) {
+            Debug.logError(e, "Unable to create or refresh MrpRunLog [" + mrpRunLogId + "]", MODULE);
+            return ServiceUtil.returnError(e.getMessage());
+        } catch (SerializeException | IOException e) {
+            Debug.logError(e, "Unable to prepare JobTracker for MrpRunLog [" + mrpRunLogId + "]", MODULE);
+            return ServiceUtil.returnError(e.getMessage());
+        }
+        Map<String, Object> result = ServiceUtil.returnSuccess();
+        result.put("mrpRunLogId", mrpRunLogId);
+        return result;
+    }
+
+    /**
+     * Launch executeMrp through generic MRP infrastructure so API and direct callers share durable run tracking.
+     *
+     * MrpRunLog is the execution history record for the MRP run. It stays available even if later MRP events
+     * are re-created or deleted, and it keeps the JobSandbox jobId that shows which persisted async job did
+     * the work. This method starts the lifecycle and executeMrp completes it:
+     * 1. Create MrpRunLog as SERVICE_PENDING, plus RuntimeData and JobTracker for the queued executeMrp job.
+     * 2. Queue executeMrp as persisted async work.
+     * 3. When executeMrp starts, startMrpRunLog marks MrpRunLog as SERVICE_RUNNING.
+     * 4. When executeMrp ends, finishMrpRunLog marks MrpRunLog as SERVICE_FINISHED or SERVICE_FAILED.
+     * 5. If queueing fails and executeMrp never runs, mark MrpRunLog as SERVICE_FAILED.
+     * 6. If queueing succeeds, attach JobSandbox.jobId to MrpRunLog so the run history identifies the job.
+     */
+    public static Map<String, Object> launchMrpRun(DispatchContext ctx, Map<String, ? extends Object> context) {
+        Delegator delegator = ctx.getDelegator();
+        LocalDispatcher dispatcher = ctx.getDispatcher();
+        GenericValue userLogin = (GenericValue) context.get("userLogin");
+        Locale locale = (Locale) context.get("locale");
+        String facilityId = (String) context.get("facilityId");
+        String facilityGroupId = (String) context.get("facilityGroupId");
+        if (UtilValidate.isEmpty(facilityId) && UtilValidate.isEmpty(facilityGroupId)) {
+            return ServiceUtil.returnError(UtilProperties.getMessage(RESOURCE, "ManufacturingMrpFacilityNotAvailable", locale));
+        }
+        Timestamp queuedAfter = UtilDateTime.nowTimestamp();
+        String mrpRunLogId = delegator.getNextSeqId("MrpRunLog");
+        Map<String, Object> executeMrpContext = new HashMap<>(context);
+        executeMrpContext.put("mrpRunLogId", mrpRunLogId);
+        executeMrpContext.put("jobTrackerId", mrpRunLogId);
+        executeMrpContext.put("userLogin", userLogin);
+
+        try {
+            executeMrpContext = ctx.makeValidContext("executeMrp", ModelService.IN_PARAM, executeMrpContext);
+            // Step 1: create durable pending run metadata before persisted async queueing can reference it.
+            /*
+             * createMrpRunLogAndTrackerBeforeQueueingMrp creates the durable MrpRunLog row and, when jobTrackerId matches,
+             * prepares RuntimeData and JobTracker for the upcoming executeMrp job. It requires a new transaction
+             * because the next step calls dispatcher.runAsync("executeMrp", executeMrpContext, true): persisted async
+             * execution creates a JobSandbox row and may start executeMrp before launchMrpRun commits.
+             * Without createMrpRunLogAndTrackerBeforeQueueingMrp's new-transaction boundary, executeMrp can race
+             * ahead of the metadata rows it needs for durable run tracking.
+             */
+            Map<String, Object> createMrpRunLogAndTrackerBeforeQueueingMrpContext =
+                    ctx.makeValidContext("createMrpRunLogAndTrackerBeforeQueueingMrp", ModelService.IN_PARAM, executeMrpContext);
+            Map<String, Object> createMrpRunLogAndTrackerResponse = dispatcher.runSync("createMrpRunLogAndTrackerBeforeQueueingMrp",
+                    createMrpRunLogAndTrackerBeforeQueueingMrpContext, 60, true);
+            if (ServiceUtil.isError(createMrpRunLogAndTrackerResponse)) {
+                return ServiceUtil.returnError(UtilProperties.getMessage(RESOURCE, "ManufacturingMrpRunLogAndTrackerCreateError",
+                        UtilMisc.toMap("errorString", ServiceUtil.getErrorMessage(createMrpRunLogAndTrackerResponse)), locale));
+            }
+        } catch (GenericServiceException e) {
+            Debug.logError(e, "Unable to create MrpRunLog and tracker before queueing executeMrp", MODULE);
+            return ServiceUtil.returnError(UtilProperties.getMessage(RESOURCE, "ManufacturingMrpRunLogAndTrackerCreateError",
+                    UtilMisc.toMap("errorString", e.getMessage()), locale));
+        }
+
+        try {
+            // Step 2: queue executeMrp as a persisted async job so the long-running MRP work can be tracked.
+            dispatcher.runAsync("executeMrp", executeMrpContext, true);
+            // Steps 3 and 4 happen inside executeMrp when the queued job starts and finishes.
+        } catch (GenericServiceException e) {
+            Timestamp failedAt = UtilDateTime.nowTimestamp();
+            Map<String, Object> updateMrpRunLogContext = UtilMisc.toMap("mrpRunLogId", mrpRunLogId, "runStatusId", "SERVICE_FAILED",
+                    "finishedAt", failedAt, "failureReason", "QUEUE_ERROR", "failureMessage", e.getMessage(), "userLogin", userLogin);
+            try {
+                GenericValue runLog = EntityQuery.use(delegator).from("MrpRunLog").where("mrpRunLogId", mrpRunLogId).queryOne();
+                if (runLog != null && runLog.getTimestamp("startedAt") != null) {
+                    updateMrpRunLogContext.put("durationMillis", new DateRange(runLog.getTimestamp("startedAt"), failedAt).durationInMillis());
+                }
+                // Step 5: record SERVICE_FAILED when executeMrp never starts because async queueing failed.
+                /*
+                 * updateMrpRunLog uses a new transaction to mark the queued MrpRunLog as failed because
+                 * dispatcher.runAsync("executeMrp", ...) failed and executeMrp will not run at all. Because
+                 * finishMrpRunLog only runs inside executeMrp, this update records the queue failure before
+                 * launchMrpRun returns an error.
+                 */
+                updateMrpRunLogContext = ctx.makeValidContext("updateMrpRunLog", ModelService.IN_PARAM, updateMrpRunLogContext);
+                Map<String, Object> updateMrpRunLogResponse = dispatcher.runSync("updateMrpRunLog", updateMrpRunLogContext, 60, true);
+                if (ServiceUtil.isError(updateMrpRunLogResponse)) {
+                    Debug.logWarning("Unable to mark queued MrpRunLog [" + mrpRunLogId + "] failed: "
+                            + ServiceUtil.getErrorMessage(updateMrpRunLogResponse), MODULE);
+                }
+            } catch (GenericEntityException | GenericServiceException updateMrpRunLogException) {
+                Debug.logWarning(updateMrpRunLogException,
+                        "Unable to call updateMrpRunLog to mark queued run failed for [" + mrpRunLogId + "]", MODULE);
+            }
+            return ServiceUtil.returnError(UtilProperties.getMessage(RESOURCE, "ManufacturingMrpJobQueueError",
+                    UtilMisc.toMap("errorString", e.getMessage()), locale));
+        }
+
+        // Step 6: find the persisted async executeMrp JobSandbox row so MrpRunLog records which job did the work.
+        GenericValue queuedJob = findLaunchedMrpJob(delegator, queuedAfter, mrpRunLogId, userLogin);
+        if (queuedJob != null && UtilValidate.isNotEmpty(queuedJob.getString("jobId"))) {
+            Map<String, Object> updateMrpRunLogContext = UtilMisc.toMap("mrpRunLogId", mrpRunLogId, "jobId", queuedJob.getString("jobId"),
+                    "userLogin", userLogin);
+            try {
+                /*
+                 * updateMrpRunLog uses a new transaction so the JobSandbox link is retained even if
+                 * launchMrpRun later fails while reading status or shaping the response.
+                 */
+                updateMrpRunLogContext = ctx.makeValidContext("updateMrpRunLog", ModelService.IN_PARAM, updateMrpRunLogContext);
+                Map<String, Object> updateMrpRunLogResponse = dispatcher.runSync("updateMrpRunLog", updateMrpRunLogContext, 60, true);
+                if (ServiceUtil.isError(updateMrpRunLogResponse)) {
+                    Debug.logWarning("Unable to attach JobSandbox [" + queuedJob.getString("jobId") + "] to MrpRunLog [" + mrpRunLogId + "]: "
+                            + ServiceUtil.getErrorMessage(updateMrpRunLogResponse), MODULE);
+                }
+            } catch (GenericServiceException updateMrpRunLogException) {
+                Debug.logWarning(updateMrpRunLogException, "Unable to call updateMrpRunLog to attach job for [" + mrpRunLogId + "]", MODULE);
+            }
+        }
+        // Return the durable run state that was just queued. Steps 3 and 4 happen later inside executeMrp.
+        Map<String, Object> result = ServiceUtil.returnSuccess();
+        GenericValue runLog = null;
+        try {
+            runLog = EntityQuery.use(delegator).from("MrpRunLog").where("mrpRunLogId", mrpRunLogId).queryOne();
+        } catch (GenericEntityException e) {
+            Debug.logWarning(e, "Unable to read MrpRunLog [" + mrpRunLogId + "] after queueing executeMrp", MODULE);
+        }
+        GenericValue status = null;
+        try {
+            if (runLog != null && UtilValidate.isNotEmpty(runLog.getString("runStatusId"))) {
+                status = EntityQuery.use(delegator).from("StatusItem").where("statusId", runLog.getString("runStatusId")).cache(true).queryOne();
+            }
+        } catch (GenericEntityException e) {
+            Debug.logWarning(e, "Unable to read status for MrpRunLog [" + mrpRunLogId + "]", MODULE);
+        }
+        Map<String, Object> resultFields = new HashMap<>();
+        resultFields.put("runId", mrpRunLogId);
+        resultFields.put("mrpRunLogId", mrpRunLogId);
+        resultFields.put("jobId", runLog != null ? runLog.getString("jobId") : null);
+        resultFields.put("mrpId", runLog != null ? runLog.getString("mrpId") : null);
+        resultFields.put("mrpName", runLog != null ? runLog.getString("mrpName") : context.get("mrpName"));
+        resultFields.put("facilityGroupId", runLog != null ? runLog.getString("facilityGroupId") : context.get("facilityGroupId"));
+        resultFields.put("facilityId", runLog != null ? runLog.getString("facilityId") : context.get("facilityId"));
+        resultFields.put("defaultYearsOffset", runLog != null && runLog.getLong("defaultYearsOffset") != null
+                ? runLog.getLong("defaultYearsOffset").intValue() : context.get("defaultYearsOffset"));
+        resultFields.put("statusId", runLog != null ? runLog.getString("runStatusId") : "SERVICE_PENDING");
+        resultFields.put("statusDescription", status != null ? status.getString("description") : null);
+        resultFields.put("failureReason", runLog != null ? runLog.getString("failureReason") : null);
+        resultFields.put("failureMessage", runLog != null ? runLog.getString("failureMessage") : null);
+        Timestamp startedAt = runLog != null ? runLog.getTimestamp("startedAt") : null;
+        Timestamp finishedAt = runLog != null ? runLog.getTimestamp("finishedAt") : null;
+        Long durationMillis = runLog != null ? runLog.getLong("durationMillis") : null;
+        if (durationMillis == null && startedAt != null && finishedAt != null) {
+            durationMillis = new DateRange(startedAt, finishedAt).durationInMillis();
+        }
+        resultFields.put("runTime", durationMillis != null ? Duration.ofMillis(durationMillis).toString() : null);
+        resultFields.put("durationMillis", durationMillis);
+        resultFields.put("startDateTime", startedAt);
+        resultFields.put("finishDateTime", finishedAt);
+        resultFields.put("runAsUser", runLog != null ? runLog.getString("runByUserLoginId") : null);
+        result.putAll(resultFields);
+        return result;
+    }
+
+    private static GenericValue findLaunchedMrpJob(Delegator delegator, Timestamp queuedAfter, String jobTrackerId, GenericValue userLogin) {
+        List<EntityCondition> conditions = new ArrayList<>();
+        conditions.add(EntityCondition.makeCondition("serviceName", "executeMrp"));
+        if (UtilValidate.isNotEmpty(jobTrackerId)) {
+            conditions.add(EntityCondition.makeCondition("jobTrackerId", jobTrackerId));
+        } else if (queuedAfter != null) {
+            conditions.add(EntityCondition.makeCondition("createdStamp", EntityOperator.GREATER_THAN_EQUAL_TO, queuedAfter));
+        }
+        if (userLogin != null && UtilValidate.isNotEmpty(userLogin.getString("userLoginId"))) {
+            conditions.add(EntityCondition.makeCondition("authUserLoginId", userLogin.getString("userLoginId")));
+        }
+        try {
+            return EntityQuery.use(delegator).from("JobSandbox")
+                    .where(EntityCondition.makeCondition(conditions, EntityOperator.AND))
+                    .orderBy("-createdStamp")
+                    .queryFirst();
+        } catch (GenericEntityException e) {
+            Debug.logWarning(e, "Unable to find queued executeMrp JobSandbox for MrpRunLog [" + jobTrackerId + "]", MODULE);
+            return null;
+        }
+    }
+
     private static String startMrpRunLog(DispatchContext ctx, Map<String, ? extends Object> context, GenericValue userLogin, String mrpRunLogId,
                                          Timestamp startedAt) {
         if (UtilValidate.isEmpty(mrpRunLogId)) {
@@ -691,31 +961,19 @@ public class MrpServices {
         }
         LocalDispatcher dispatcher = ctx.getDispatcher();
         Delegator delegator = ctx.getDelegator();
-        String userLoginId = userLogin != null ? userLogin.getString("userLoginId") : null;
         try {
             GenericValue existingMrpRunLog = EntityQuery.use(delegator).from("MrpRunLog").where("mrpRunLogId", mrpRunLogId).queryOne();
             if (existingMrpRunLog == null) {
-                Map<String, Object> createParameters = UtilMisc.toMap("mrpRunLogId", mrpRunLogId, "runStatusId", "SERVICE_RUNNING",
-                        "startedAt", startedAt, "userLogin", userLogin);
-                if (UtilValidate.isNotEmpty((String) context.get("mrpName"))) {
-                    createParameters.put("mrpName", context.get("mrpName"));
-                }
-                if (UtilValidate.isNotEmpty((String) context.get("facilityId"))) {
-                    createParameters.put("facilityId", context.get("facilityId"));
-                }
-                if (UtilValidate.isNotEmpty((String) context.get("facilityGroupId"))) {
-                    createParameters.put("facilityGroupId", context.get("facilityGroupId"));
-                }
-                if (context.get("defaultYearsOffset") != null) {
-                    createParameters.put("defaultYearsOffset", context.get("defaultYearsOffset"));
-                }
-                if (UtilValidate.isNotEmpty((String) context.get("jobId"))) {
-                    createParameters.put("jobId", context.get("jobId"));
-                }
-                if (UtilValidate.isNotEmpty(userLoginId)) {
-                    createParameters.put("runByUserLoginId", userLoginId);
-                }
-                Map<String, Object> createResponse = dispatcher.runSync("createMrpRunLog", createParameters, 60, true);
+                Map<String, Object> createParameters = new HashMap<>();
+                createParameters.put("mrpRunLogId", mrpRunLogId);
+                createParameters.put("mrpName", context.get("mrpName"));
+                createParameters.put("facilityId", context.get("facilityId"));
+                createParameters.put("facilityGroupId", context.get("facilityGroupId"));
+                createParameters.put("defaultYearsOffset", context.get("defaultYearsOffset"));
+                createParameters.put("runStatusId", "SERVICE_RUNNING");
+                createParameters.put("startedAt", startedAt);
+                createParameters.put("userLogin", userLogin);
+                Map<String, Object> createResponse = dispatcher.runSync("createMrpRunLogAndTrackerBeforeQueueingMrp", createParameters, 60, true);
                 if (ServiceUtil.isError(createResponse)) {
                     Debug.logWarning("Unable to create MrpRunLog [" + mrpRunLogId + "]: " + ServiceUtil.getErrorMessage(createResponse), MODULE);
                 } else {
@@ -727,8 +985,46 @@ public class MrpServices {
         } catch (GenericServiceException e) {
             Debug.logWarning(e, "Unable to call createMrpRunLog for [" + mrpRunLogId + "]", MODULE);
         }
-        updateMrpRunLog(dispatcher, UtilMisc.toMap("mrpRunLogId", mrpRunLogId, "runStatusId", "SERVICE_RUNNING",
-                "startedAt", startedAt, "userLogin", userLogin), "mark as running", mrpRunLogId);
+        Map<String, Object> updateMrpRunLogContext = UtilMisc.toMap("mrpRunLogId", mrpRunLogId, "runStatusId", "SERVICE_RUNNING",
+                "startedAt", startedAt, "userLogin", userLogin);
+        try {
+            /*
+             * updateMrpRunLog uses a new transaction so executeMrp records that the durable run log started,
+             * even if later MRP processing fails and executeMrp returns an error.
+             */
+            Map<String, Object> updateMrpRunLogResponse = dispatcher.runSync("updateMrpRunLog", updateMrpRunLogContext, 60, true);
+            if (ServiceUtil.isError(updateMrpRunLogResponse)) {
+                Debug.logWarning("Unable to mark as running MrpRunLog [" + mrpRunLogId + "]: "
+                        + ServiceUtil.getErrorMessage(updateMrpRunLogResponse), MODULE);
+            }
+        } catch (GenericServiceException e) {
+            Debug.logWarning(e, "Unable to call updateMrpRunLog to mark as running [" + mrpRunLogId + "]", MODULE);
+        }
+        String jobTrackerId = (String) context.get("jobTrackerId");
+        if (UtilValidate.isNotEmpty(jobTrackerId)) {
+            try {
+                GenericValue jobTracker = EntityQuery.use(delegator).from("JobTracker").where("jobTrackerId", jobTrackerId).queryOne();
+                if (jobTracker != null && "JOB_T_SCHEDULED".equals(jobTracker.getString("statusId"))) {
+                    Map<String, Object> updateJobTrackerContext = UtilMisc.toMap("jobTrackerId", jobTrackerId, "statusId", "JOB_T_RUNNING",
+                            "startDate", startedAt, "userLogin", userLogin);
+                    /*
+                     * updateJobTracker uses a new transaction because executeMrp is the queued service that moves the
+                     * durable MRP job from scheduled to running. If executeMrp later fails and rolls back, the service
+                     * engine still needs the committed JOB_T_RUNNING state so it can complete the tracker with OFBiz's
+                     * valid JOB_T_RUNNING to JOB_T_FINISHED or JOB_T_FAILED transition.
+                     */
+                    Map<String, Object> updateJobTrackerResponse = dispatcher.runSync("updateJobTracker", updateJobTrackerContext, 60, true);
+                    if (ServiceUtil.isError(updateJobTrackerResponse)) {
+                        Debug.logWarning("Unable to mark JobTracker [" + jobTrackerId + "] as running for MrpRunLog [" + mrpRunLogId + "]: "
+                                + ServiceUtil.getErrorMessage(updateJobTrackerResponse), MODULE);
+                    }
+                }
+            } catch (GenericEntityException e) {
+                Debug.logWarning(e, "Unable to query JobTracker [" + jobTrackerId + "] before starting MrpRunLog [" + mrpRunLogId + "]", MODULE);
+            } catch (GenericServiceException e) {
+                Debug.logWarning(e, "Unable to call updateJobTracker to mark as running [" + jobTrackerId + "]", MODULE);
+            }
+        }
         return mrpRunLogId;
     }
 
@@ -787,11 +1083,12 @@ public class MrpServices {
         }
     }
 
-    private static String buildOutcomeMessage(Long mrpEventCount, long proposedRequirementCount) {
+    private static String buildOutcomeMessage(Long mrpEventCount, long proposedRequirementCount, Locale locale) {
         if (mrpEventCount == null) {
             return null;
         }
-        return "Created " + mrpEventCount + " MRP events and " + proposedRequirementCount + " proposed requirements";
+        return UtilProperties.getMessage(RESOURCE, "ManufacturingMrpRunOutcomeSummary",
+                UtilMisc.toMap("mrpEventCount", mrpEventCount, "proposedRequirementCount", proposedRequirementCount), locale);
     }
 
     private static Map<String, Object> finishMrpRunLog(LocalDispatcher dispatcher, GenericValue userLogin, String mrpRunLogId, Timestamp startedAt,
@@ -826,24 +1123,23 @@ public class MrpServices {
                 parameters.put("outcomeMessage", outcomeMessage);
             }
             if (startedAt != null && finishedAt != null) {
-                parameters.put("durationMillis", finishedAt.getTime() - startedAt.getTime());
+                parameters.put("durationMillis", new DateRange(startedAt, finishedAt).durationInMillis());
+            }
+            /*
+             * updateMrpRunLog uses a new transaction so executeMrp preserves the final run status and counts,
+             * even when executeMrp returns an error result that rolls back the MRP processing transaction.
+             */
+            Map<String, Object> updateMrpRunLogResponse = dispatcher.runSync("updateMrpRunLog", parameters, 60, true);
+            if (ServiceUtil.isError(updateMrpRunLogResponse)) {
+                Debug.logWarning("Unable to finish MrpRunLog [" + mrpRunLogId + "]: "
+                        + ServiceUtil.getErrorMessage(updateMrpRunLogResponse), MODULE);
             }
-            updateMrpRunLog(dispatcher, parameters, "finish", mrpRunLogId);
         } catch (RuntimeException e) {
             Debug.logWarning(e, "Unable to prepare MrpRunLog finish parameters for [" + mrpRunLogId + "]", MODULE);
-        }
-        return result;
-    }
-
-    private static void updateMrpRunLog(LocalDispatcher dispatcher, Map<String, Object> parameters, String action, String mrpRunLogId) {
-        try {
-            Map<String, Object> response = dispatcher.runSync("updateMrpRunLog", parameters, 60, true);
-            if (ServiceUtil.isError(response)) {
-                Debug.logWarning("Unable to " + action + " MrpRunLog [" + mrpRunLogId + "]: " + ServiceUtil.getErrorMessage(response), MODULE);
-            }
         } catch (GenericServiceException e) {
-            Debug.logWarning(e, "Unable to call updateMrpRunLog to " + action + " [" + mrpRunLogId + "]", MODULE);
+            Debug.logWarning(e, "Unable to call updateMrpRunLog to finish [" + mrpRunLogId + "]", MODULE);
         }
+        return result;
     }
 
     /**
@@ -864,7 +1160,7 @@ public class MrpServices {
         LocalDispatcher dispatcher = ctx.getDispatcher();
         GenericValue userLogin = (GenericValue) context.get("userLogin");
         Timestamp now = UtilDateTime.nowTimestamp();
-        String mrpRunLogId = mrpRunLogIdFromContext(delegator, context);
+        String mrpRunLogId = getMrpRunLogIdFromContext(delegator, context);
         mrpRunLogId = startMrpRunLog(ctx, context, userLogin, mrpRunLogId, now);
         Locale locale = (Locale) context.get("locale");
         String mrpName = (String) context.get("mrpName");
@@ -1203,7 +1499,7 @@ public class MrpServices {
             Debug.logInfo("return from executeMrp", MODULE);
             Long mrpEventCount = countMrpEventsForRun(delegator, mrpId);
             Long requirementCount = proposedRequirementCount;
-            String outcomeMessage = buildOutcomeMessage(mrpEventCount, proposedRequirementCount);
+            String outcomeMessage = buildOutcomeMessage(mrpEventCount, proposedRequirementCount, locale);
             return finishMrpRunLog(dispatcher, userLogin, mrpRunLogId, now, mrpId, null, null, mrpEventCount, requirementCount,
                     outcomeMessage, result);
         } catch (RuntimeException e) {
diff --git a/applications/manufacturing/webapp/manufacturing/WEB-INF/controller.xml b/applications/manufacturing/webapp/manufacturing/WEB-INF/controller.xml
index c06b4f939d..0aaeb056fb 100644
--- a/applications/manufacturing/webapp/manufacturing/WEB-INF/controller.xml
+++ b/applications/manufacturing/webapp/manufacturing/WEB-INF/controller.xml
@@ -468,7 +468,7 @@ under the License.
     </request-map>
     <request-map uri="runMrpGo">
         <security https="true" auth="true"/>
-        <event type="service" invoke="executeMrp" path="async"/>
+        <event type="service" invoke="launchMrpRun"/>
         <response name="success" type="view" value="RunMrpGo"/>
         <response name="error" type="view" value="MrpExecution"/>
     </request-map>