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 <[email protected]> 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 + } + + } + +}

