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/07/31 14:09:58 UTC

(ofbiz-framework) branch trunk updated (0bb30afed4 -> b9895e737a)

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 0bb30afed4 Fix Party CSV import failure caused by trailing commas. (OFBIZ-13435) (#1339)
     new b1a86de910 OFBIZ-13468 Improved: add generic entity lookup helpers
     new 4d0a4a683d OFBIZ-13468 Improved: validate and secure BOM association services
     new c8170e9fff OFBIZ-13468 Improved: add BOM management service APIs
     new b9895e737a OFBIZ-13468 Improved: expose BOM management REST endpoints

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/bom-management.rest.xml      |  84 ++++
 .../manufacturing/config/ManufacturingUiLabels.xml |  21 +
 .../minilang/bom/BomSimpleMethods.xml              |  36 +-
 .../manufacturing/servicedef/services_bom.xml      | 150 ++++++-
 .../manufacturing/api/BomManagementServices.groovy | 497 +++++++++++++++++++++
 .../api/ManufacturingLookupServices.groovy         |  93 ++++
 .../api/ManufacturingServiceUtil.groovy            |  20 +-
 .../ofbiz/manufacturing/bom/BOMServices.java       |  29 +-
 .../org/apache/ofbiz/entity/util/EntityUtil.java   | 135 ++++++
 9 files changed, 1030 insertions(+), 35 deletions(-)
 create mode 100644 applications/manufacturing/api/bom-management.rest.xml
 create mode 100644 applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/api/BomManagementServices.groovy
 create mode 100644 applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/api/ManufacturingLookupServices.groovy
 copy framework/resources/templates/build.gradle => applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/api/ManufacturingServiceUtil.groovy (72%)


(ofbiz-framework) 02/04: OFBIZ-13468 Improved: validate and secure BOM association services

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 4d0a4a683daa57e5c78f9a068a838acce287c938
Author: diveshdut <di...@hotwax.co>
AuthorDate: Fri Jul 31 14:24:29 2026 +0530

    OFBIZ-13468 Improved: validate and secure BOM association services
    
    Add Manufacturing CREATE permission checks to createBOMAssoc and copyBOMAssocs so REST-mapped BOM mutations enforce service-level authorization.
    
    Tighten createBOMAssoc validation by rejecting self-referencing BOM associations, checking for an existing ProductAssoc before create, and returning validation failures as service errors.
    
    Improve copyBOMAssocs by filtering the source associations with the requested BOM key fields and preserving a supplied fromDate when creating copied associations.
---
 .../minilang/bom/BomSimpleMethods.xml              | 36 ++++++++++++++++++++--
 .../manufacturing/servicedef/services_bom.xml      |  7 +++--
 2 files changed, 37 insertions(+), 6 deletions(-)

diff --git a/applications/manufacturing/minilang/bom/BomSimpleMethods.xml b/applications/manufacturing/minilang/bom/BomSimpleMethods.xml
index c72e310c5d..3fd854f4cc 100644
--- a/applications/manufacturing/minilang/bom/BomSimpleMethods.xml
+++ b/applications/manufacturing/minilang/bom/BomSimpleMethods.xml
@@ -26,6 +26,25 @@ under the License.
             <now-timestamp field="parameters.fromDate"/>
         </if-empty>
 
+        <if-compare-field field="parameters.productId" to-field="parameters.productIdTo" operator="equals">
+            <property-to-field property="ManufacturingBomProductCannotBeComponentOfItself" resource="ManufacturingUiLabels" field="errorMessage"/>
+            <add-error>
+                <fail-message message="${errorMessage}"/>
+            </add-error>
+            <check-errors/>
+        </if-compare-field>
+
+        <make-value entity-name="ProductAssoc" value-field="productAssocLookup"/>
+        <set-pk-fields map="parameters" value-field="productAssocLookup"/>
+        <find-by-primary-key entity-name="ProductAssoc" map="productAssocLookup" value-field="existingProductAssoc"/>
+        <if-not-empty field="existingProductAssoc.productId">
+            <set field="errorMessage" value="Value found, cannot create a new one"/>
+            <add-error>
+                <fail-message message="${errorMessage}"/>
+            </add-error>
+            <check-errors/>
+        </if-not-empty>
+
         <set field="searchDuplicatedAncestorContext.productId" from-field="parameters.productId"/>
         <set field="searchDuplicatedAncestorContext.productIdTo" from-field="parameters.productIdTo"/>
         <if-not-empty field="parameters.fromDate">
@@ -39,8 +58,10 @@ under the License.
             <set field="productToProduct[]" from-field="duplicatedProductAssoc.productId"/>
             <set field="productToProduct[]" from-field="duplicatedProductAssoc.productIdTo"/>
             <property-to-field property="ManufacturingDuplicatedComponentInBOM" resource="ManufacturingUiLabels" field="errorMessage" arg-list-name="productToProduct"/>
-            <field-to-result field="errorMessage" result-name="errorMessage"/>
-            <return/>
+            <add-error>
+                <fail-message message="${errorMessage}"/>
+            </add-error>
+            <check-errors/>
         </if-not-empty>
         <set-service-fields service-name="createProductAssoc" map="parameters" to-map="context"/>
         <call-service service-name="createProductAssoc" in-map-name="context"/>
@@ -48,12 +69,21 @@ under the License.
 
     <simple-method method-name="copyBOMAssocs" short-description="Copy BOM associations from one product to another">
         <make-value entity-name="ProductAssoc" value-field="lookupMap"/>
-        <set-pk-fields map="parameters" value-field="lookupMap"/>
+        <set field="lookupMap.productId" from-field="parameters.productId"/>
+        <if-not-empty field="parameters.productAssocTypeId">
+            <set field="lookupMap.productAssocTypeId" from-field="parameters.productAssocTypeId"/>
+        </if-not-empty>
+        <if-not-empty field="parameters.productIdTo">
+            <set field="lookupMap.productIdTo" from-field="parameters.productIdTo"/>
+        </if-not-empty>
         <find-by-and entity-name="ProductAssoc" map="lookupMap" list="assocs"/>
         <filter-list-by-date list="assocs"/>
         <iterate list="assocs" entry="assoc">
             <set-service-fields service-name="createBOMAssoc" map="assoc" to-map="context"/>
             <set field="context.productId" from-field="parameters.copyToProductId"/>
+            <if-not-empty field="parameters.fromDate">
+                <set field="context.fromDate" from-field="parameters.fromDate"/>
+            </if-not-empty>
             <call-service service-name="createBOMAssoc" in-map-name="context" break-on-error="false"/>
         </iterate>
     </simple-method>
diff --git a/applications/manufacturing/servicedef/services_bom.xml b/applications/manufacturing/servicedef/services_bom.xml
index b46afa4fa8..a7a78299ed 100644
--- a/applications/manufacturing/servicedef/services_bom.xml
+++ b/applications/manufacturing/servicedef/services_bom.xml
@@ -27,15 +27,16 @@ under the License.
     <!-- Bill of Materials services -->
     <service name="createBOMAssoc" default-entity-name="ProductAssoc" engine="simple"
                 location="component://manufacturing/minilang/bom/BomSimpleMethods.xml" invoke="createBOMAssoc" auth="true">
-        <description>Add Product to Product Association</description>
+        <description>Add a product to a BOM as a ProductAssoc component. Validates that a product is not added as its own component, rejects duplicate associations before create, and delegates the actual ProductAssoc create to the existing product association service.</description>
+        <permission-service service-name="manufacturingPermissionService" main-action="CREATE"/>
         <auto-attributes include="pk" mode="IN" optional="false"/>
         <auto-attributes include="nonpk" mode="IN" optional="true"/>
-        <attribute name="errorMessage" type="String" optional="true" mode="OUT"/>
         <override name="fromDate" optional="true"/>
     </service>
     <service name="copyBOMAssocs" default-entity-name="ProductAssoc" engine="simple"
                 location="component://manufacturing/minilang/bom/BomSimpleMethods.xml" invoke="copyBOMAssocs" auth="true">
-        <description>Copy BOM associations from one product to another</description>
+        <description>Copy active BOM associations from one product to another. Reuses BOM association creation for each copied component, supports narrowing the source associations by BOM key fields, and preserves a supplied effective fromDate for REST/API copy workflows.</description>
+        <permission-service service-name="manufacturingPermissionService" main-action="CREATE"/>
         <auto-attributes include="pk" mode="IN" optional="false"/>
         <attribute name="copyToProductId" type="String" optional="false" mode="IN"/>
         <override name="fromDate" optional="true"/>


(ofbiz-framework) 03/04: OFBIZ-13468 Improved: add BOM management service APIs

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 c8170e9fff55bff50eec5b55e35a3890fdbf195a
Author: diveshdut <di...@hotwax.co>
AuthorDate: Fri Jul 31 14:24:39 2026 +0530

    OFBIZ-13468 Improved: add BOM management service APIs
---
 .../manufacturing/config/ManufacturingUiLabels.xml |  21 +
 .../manufacturing/servicedef/services_bom.xml      | 143 ++++++
 .../manufacturing/api/BomManagementServices.groovy | 497 +++++++++++++++++++++
 .../api/ManufacturingLookupServices.groovy         |  93 ++++
 .../api/ManufacturingServiceUtil.groovy            |  31 ++
 .../ofbiz/manufacturing/bom/BOMServices.java       |  29 +-
 6 files changed, 798 insertions(+), 16 deletions(-)

diff --git a/applications/manufacturing/config/ManufacturingUiLabels.xml b/applications/manufacturing/config/ManufacturingUiLabels.xml
index 0ab553324c..8a87582410 100644
--- a/applications/manufacturing/config/ManufacturingUiLabels.xml
+++ b/applications/manufacturing/config/ManufacturingUiLabels.xml
@@ -994,6 +994,9 @@
         <value xml:lang="zh">物料清单</value>
         <value xml:lang="zh-TW">物料清單</value>
     </property>
+    <property key="ManufacturingBomAssociationNotFound">
+        <value xml:lang="en">BOM association was not found.</value>
+    </property>
     <property key="ManufacturingBomErrorCreatingBillOfMaterialsTree">
         <value xml:lang="en">Error creating bill of materials tree: ${errorString}</value>
         <value xml:lang="it">Errore durante la creazione dell'albero dei materiali: ${errorString}</value>
@@ -1079,6 +1082,18 @@
         <value xml:lang="zh">物料清单仿真</value>
         <value xml:lang="zh-TW">物料清單模擬</value>
     </property>
+    <property key="ManufacturingBomSimulationAvailabilityCheckFailed">
+        <value xml:lang="en">Availability check failed for product ${productId}: ${errorString}</value>
+    </property>
+    <property key="ManufacturingBomSimulationAvailabilityNotChecked">
+        <value xml:lang="en">Availability not checked because no facility was selected.</value>
+    </property>
+    <property key="ManufacturingBomSimulationCostCheckFailed">
+        <value xml:lang="en">Cost check failed for product ${productId}: ${errorString}</value>
+    </property>
+    <property key="ManufacturingBomSimulationCostNotChecked">
+        <value xml:lang="en">Cost not checked because no currency was selected.</value>
+    </property>
     <property key="ManufacturingBomType">
         <value xml:lang="de">Stücklistenart</value>
         <value xml:lang="en">Bom Type</value>
@@ -3598,6 +3613,9 @@
         <value xml:lang="zh">产品标识</value>
         <value xml:lang="zh-TW">產品識別</value>
     </property>
+    <property key="ManufacturingProductIdRequired">
+        <value xml:lang="en">Product ID is required.</value>
+    </property>
     <property key="ManufacturingProductIdTo">
         <value xml:lang="de">Produkt-Nr. an</value>
         <value xml:lang="en">Product ID To</value>
@@ -3653,6 +3671,9 @@
         <value xml:lang="zh">产品不存在</value>
         <value xml:lang="zh-TW">產品不存在</value>
     </property>
+    <property key="ManufacturingProductNotFound">
+        <value xml:lang="en">Product not found: ${productId}</value>
+    </property>
     <property key="ManufacturingProductRoutingNotExist">
         <value xml:lang="de">Arbeitsplan-Verbindung nicht vorhanden</value>
         <value xml:lang="en">Product Routing link doesn't exist</value>
diff --git a/applications/manufacturing/servicedef/services_bom.xml b/applications/manufacturing/servicedef/services_bom.xml
index a7a78299ed..9be7f1fb72 100644
--- a/applications/manufacturing/servicedef/services_bom.xml
+++ b/applications/manufacturing/servicedef/services_bom.xml
@@ -146,4 +146,147 @@ under the License.
         <attribute mode="IN" name="fromDate" optional="true" type="String"/>
         <attribute mode="OUT" name="notAssembledComponents" type="java.util.List"/>
     </service>
+    <!-- API wrappers for BOM Management -->
+    <service name="findBomProducts" engine="groovy" auth="true"
+            location="component://manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/api/BomManagementServices.groovy" invoke="findBomProducts">
+        <description>Find BOM-managed products that have component associations and return BOM summary metadata for list views.</description>
+        <permission-service service-name="manufacturingPermissionService" main-action="VIEW"/>
+        <attribute mode="IN" name="query" optional="true" type="String"/>
+        <attribute mode="IN" name="productId" optional="true" type="String"/>
+        <attribute mode="IN" name="componentProductId" optional="true" type="String"/>
+        <attribute mode="IN" name="productIdTo" optional="true" type="String"/>
+        <attribute mode="IN" name="productAssocTypeId" optional="true" type="String"/>
+        <attribute mode="IN" name="pageIndex" optional="true" type="Integer"/>
+        <attribute mode="IN" name="pageSize" optional="true" type="Integer"/>
+        <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="products" optional="false" type="java.util.List"/>
+    </service>
+    <service name="findProductLookupOptions" engine="groovy" auth="true"
+            location="component://manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/api/ManufacturingLookupServices.groovy" invoke="findProductLookupOptions">
+        <description>Find any product by id, name, or internal name for BOM picker and autocomplete workflows.</description>
+        <permission-service service-name="manufacturingPermissionService" main-action="VIEW"/>
+        <attribute mode="IN" name="query" optional="true" type="String"/>
+        <attribute mode="IN" name="pageIndex" optional="true" type="Integer"/>
+        <attribute mode="IN" name="pageSize" optional="true" type="Integer"/>
+        <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="products" optional="false" type="java.util.List"/>
+    </service>
+    <service name="getBomSnapshot" engine="groovy" auth="true"
+            location="component://manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/api/BomManagementServices.groovy" invoke="getBomSnapshot">
+        <description>Get a frontend-friendly BOM snapshot for one product.</description>
+        <permission-service service-name="manufacturingPermissionService" main-action="VIEW"/>
+        <attribute mode="IN" name="productId" optional="false" type="String"/>
+        <attribute mode="IN" name="productAssocTypeId" optional="true" type="String"/>
+        <attribute mode="IN" name="fromDate" optional="true" type="Timestamp"/>
+        <attribute mode="IN" name="asOfDate" optional="true" type="Timestamp"/>
+        <attribute mode="IN" name="quantity" optional="true" type="BigDecimal"/>
+        <attribute mode="IN" name="amount" optional="true" type="BigDecimal"/>
+        <attribute mode="IN" name="type" optional="true" type="Integer"/>
+        <attribute mode="IN" name="facilityId" optional="true" type="String"/>
+        <attribute mode="IN" name="currencyUomId" optional="true" type="String"/>
+        <attribute mode="OUT" name="productId" optional="false" type="String"/>
+        <attribute mode="OUT" name="productName" optional="true" type="String"/>
+        <attribute mode="OUT" name="internalName" optional="true" type="String"/>
+        <attribute mode="OUT" name="productAssocTypeId" optional="false" type="String"/>
+        <attribute mode="OUT" name="bomType" optional="false" type="String"/>
+        <attribute mode="OUT" name="fromDate" optional="true" type="Timestamp"/>
+        <attribute mode="OUT" name="quantity" optional="true" type="BigDecimal"/>
+        <attribute mode="OUT" name="amount" optional="true" type="BigDecimal"/>
+        <attribute mode="OUT" name="type" optional="true" type="Integer"/>
+        <attribute mode="OUT" name="facilityId" optional="true" type="String"/>
+        <attribute mode="OUT" name="currencyUomId" optional="true" type="String"/>
+        <attribute mode="OUT" name="componentCount" optional="false" type="Integer"/>
+        <attribute mode="OUT" name="usedInCount" optional="false" type="Integer"/>
+        <attribute mode="OUT" name="usedInParents" optional="false" type="java.util.List"/>
+        <attribute mode="OUT" name="bomLevels" optional="false" type="java.util.List"/>
+        <attribute mode="OUT" name="components" optional="false" type="java.util.List"/>
+    </service>
+    <service name="removeBomAssociation" engine="groovy" auth="true"
+            location="component://manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/api/BomManagementServices.groovy" invoke="removeBomAssociation">
+        <description>Expire a BOM component association by setting ProductAssoc.thruDate instead of deleting either product. This REST-facing wrapper validates the association, defaults the BOM association type when omitted, delegates persistence to updateProductAssoc, and returns a BOM-shaped removal response.</description>
+        <permission-service service-name="manufacturingPermissionService" main-action="UPDATE"/>
+        <attribute mode="IN" name="productId" optional="false" type="String"/>
+        <attribute mode="IN" name="productIdTo" optional="false" type="String"/>
+        <attribute mode="IN" name="productAssocTypeId" optional="true" type="String"/>
+        <attribute mode="IN" name="fromDate" optional="false" type="Timestamp"/>
+        <attribute mode="OUT" name="removed" optional="false" type="Boolean"/>
+        <attribute mode="OUT" name="productId" optional="false" type="String"/>
+        <attribute mode="OUT" name="parentProductId" optional="true" type="String"/>
+        <attribute mode="OUT" name="productIdTo" optional="true" type="String"/>
+        <attribute mode="OUT" name="componentProductId" optional="true" type="String"/>
+        <attribute mode="OUT" name="productAssocTypeId" optional="true" type="String"/>
+        <attribute mode="OUT" name="fromDate" optional="true" type="Timestamp"/>
+        <attribute mode="OUT" name="thruDate" optional="true" type="Timestamp"/>
+    </service>
+    <service name="removeBomParentAssociation" engine="groovy" auth="true"
+            location="component://manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/api/BomManagementServices.groovy" invoke="removeBomParentAssociation">
+        <description>Expire a used-in parent association for a component product by setting ProductAssoc.thruDate. This REST-facing wrapper keeps the parent route contract explicit, maps componentProductId to the native ProductAssoc key fields, delegates persistence to updateProductAssoc, and returns the same BOM-shaped removal response as component removal.</description>
+        <permission-service service-name="manufacturingPermissionService" main-action="UPDATE"/>
+        <attribute mode="IN" name="componentProductId" optional="false" type="String"/>
+        <attribute mode="IN" name="productId" optional="false" type="String"/>
+        <attribute mode="IN" name="productAssocTypeId" optional="true" type="String"/>
+        <attribute mode="IN" name="fromDate" optional="false" type="Timestamp"/>
+        <attribute mode="OUT" name="removed" optional="false" type="Boolean"/>
+        <attribute mode="OUT" name="productId" optional="false" type="String"/>
+        <attribute mode="OUT" name="parentProductId" optional="true" type="String"/>
+        <attribute mode="OUT" name="productIdTo" optional="true" type="String"/>
+        <attribute mode="OUT" name="componentProductId" optional="true" type="String"/>
+        <attribute mode="OUT" name="productAssocTypeId" optional="true" type="String"/>
+        <attribute mode="OUT" name="fromDate" optional="true" type="Timestamp"/>
+        <attribute mode="OUT" name="thruDate" optional="true" type="Timestamp"/>
+    </service>
+    <service name="runBomSimulation" engine="groovy" auth="true"
+            location="component://manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/api/BomManagementServices.groovy" invoke="runBomSimulation">
+        <description>Run a BOM explosion simulation for REST/API clients. Delegates BOM traversal to getBOMTree and existing cost/availability services, then converts the internal BOMTree result into JSON-ready parameters, summary, availability rows, cost rows, and warnings.</description>
+        <permission-service service-name="manufacturingPermissionService" main-action="VIEW"/>
+        <attribute mode="IN" name="productId" optional="false" type="String"/>
+        <attribute mode="IN" name="productAssocTypeId" optional="true" type="String"/>
+        <attribute mode="IN" name="effectiveDate" optional="true" type="Timestamp"/>
+        <attribute mode="IN" name="fromDate" optional="true" type="Timestamp"/>
+        <attribute mode="IN" name="asOfDate" optional="true" type="Timestamp"/>
+        <attribute mode="IN" name="quantity" optional="true" type="BigDecimal"/>
+        <attribute mode="IN" name="simulationMode" optional="true" type="String"/>
+        <attribute mode="IN" name="type" optional="true" type="Integer"/>
+        <attribute mode="IN" name="facilityId" optional="true" type="String"/>
+        <attribute mode="IN" name="currencyUomId" optional="true" type="String"/>
+        <attribute mode="OUT" name="parameters" optional="false" type="Map"/>
+        <attribute mode="OUT" name="summary" optional="false" type="Map"/>
+        <attribute mode="OUT" name="availabilityRows" optional="false" type="java.util.List"/>
+        <attribute mode="OUT" name="costRows" optional="false" type="java.util.List"/>
+        <attribute mode="OUT" name="warnings" optional="false" type="java.util.List"/>
+    </service>
+    <service name="findWarehouses" engine="groovy" auth="true"
+            location="component://manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/api/ManufacturingLookupServices.groovy" invoke="findWarehouses">
+        <description>Find warehouse facilities available for manufacturing application workflows.</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="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="facilities" optional="false" type="java.util.List"/>
+    </service>
+    <service name="findBomTypes" engine="groovy" auth="true"
+            location="component://manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/api/BomManagementServices.groovy" invoke="findBomTypes">
+        <description>Find product association types available for BOM workflows.</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="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="bomTypes" optional="false" type="java.util.List"/>
+    </service>
 </services>
diff --git a/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/api/BomManagementServices.groovy b/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/api/BomManagementServices.groovy
new file mode 100644
index 0000000000..7f60a52bfa
--- /dev/null
+++ b/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/api/BomManagementServices.groovy
@@ -0,0 +1,497 @@
+/*
+ * 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 java.sql.Timestamp
+
+import org.apache.ofbiz.base.util.UtilDateTime
+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.manufacturing.bom.BOMNode
+import org.apache.ofbiz.manufacturing.bom.BOMTree
+import org.apache.ofbiz.service.ServiceUtil
+import org.apache.ofbiz.ws.rs.util.RestApiUtil
+import org.apache.ofbiz.ws.rs.util.RestQueryOptions
+
+Map findBomProducts() {
+    RestQueryOptions queryOptions
+    try {
+        queryOptions = RestQueryOptions.fromParameters(parameters)
+    } catch (IllegalArgumentException e) {
+        return ServiceUtil.returnError(e.message)
+    }
+    List bomTypeIds = bomAssocTypeIds()
+    if (!bomTypeIds) {
+        return success(RestApiUtil.getPagedResult('products', [], queryOptions, 0L, null))
+    }
+
+    List selectedBomTypeIds = UtilValidate.isNotEmpty(parameters.productAssocTypeId)
+            ? [parameters.productAssocTypeId]
+            : bomTypeIds
+    List conditions = [EntityCondition.makeCondition('productAssocTypeId', EntityOperator.IN, selectedBomTypeIds)]
+    if (UtilValidate.isNotEmpty(parameters.productId)) {
+        conditions.add(EntityCondition.makeCondition('productId', parameters.productId))
+    }
+    if (UtilValidate.isNotEmpty(parameters.componentProductId ?: parameters.productIdTo)) {
+        conditions.add(EntityCondition.makeCondition('productIdTo', parameters.componentProductId ?: parameters.productIdTo))
+    }
+    if (UtilValidate.isNotEmpty(parameters.query)) {
+        Set matchingProductIds = EntityUtil.searchIds(delegator, 'Product', 'productId',
+                ['productId', 'productName', 'internalName'], parameters.query, 500)
+        if (!matchingProductIds) {
+            return success(RestApiUtil.getPagedResult('products', [], queryOptions, 0L, null))
+        }
+        conditions.add(EntityCondition.makeCondition('productId', EntityOperator.IN, matchingProductIds as List))
+    }
+
+    EntityCondition whereCondition = EntityCondition.makeCondition(conditions, EntityOperator.AND)
+    EntityQuery query = select('productId', 'productAssocTypeId').from('ProductAssoc')
+            .where(whereCondition).filterByDate().distinct()
+    long totalCount = query.queryCount()
+    List rows = query.orderBy('productId', 'productAssocTypeId')
+            .queryPagedList(queryOptions.pageIndex, queryOptions.pageSize).getData()
+    Set productIds = rows*.productId.findAll { it } as Set
+    Map productsById = EntityUtil.lookupById(delegator, 'Product', 'productId', productIds, false)
+    Map componentSummaries = componentSummaryByProduct(productIds, selectedBomTypeIds)
+    Map usedInCounts = EntityUtil.countByField(delegator, 'ProductAssoc', 'productIdTo', productIds,
+            [EntityCondition.makeCondition('productAssocTypeId', EntityOperator.IN, selectedBomTypeIds)], true)
+
+    List products = rows.collect { GenericValue row ->
+        GenericValue product = productsById[row.productId]
+        Map componentSummary = componentSummaries[row.productId] ?: [:]
+        [
+                productId: row.productId,
+                productName: ManufacturingServiceUtil.displayProductName(product),
+                internalName: product?.internalName,
+                productAssocTypeId: row.productAssocTypeId,
+                bomType: row.productAssocTypeId,
+                componentCount: componentSummary.count ?: 0,
+                usedInCount: usedInCounts[row.productId] ?: 0,
+                activeFromDate: componentSummary.activeFromDate
+        ]
+    }
+    return success(RestApiUtil.getPagedResult('products', products, queryOptions, totalCount, null))
+}
+
+Map getBomSnapshot() {
+    String productId = parameters.productId
+    if (!productId) {
+        return ServiceUtil.returnError(manufacturingMessage('ManufacturingProductIdRequired'))
+    }
+    GenericValue product = from('Product').where(productId: productId).queryOne()
+    if (!product) {
+        return ServiceUtil.returnError(manufacturingMessage('ManufacturingProductNotFound',
+                [productId: productId]))
+    }
+
+    String productAssocTypeId = parameters.productAssocTypeId ?: 'MANUF_COMPONENT'
+    Timestamp fromDate = parameters.fromDate
+            ?: parameters.asOfDate
+            ?: UtilDateTime.nowTimestamp()
+    BigDecimal quantity = parameters.quantity ?: BigDecimal.ONE
+    BigDecimal amount = parameters.amount ?: BigDecimal.ZERO
+    Integer type = UtilMisc.toIntegerObject(parameters.type) ?: 0
+
+    List componentRows = datedProductAssocRows([productId: productId, productAssocTypeId: productAssocTypeId],
+            fromDate, 'sequenceNum', 'productIdTo')
+    List components = snapshotComponentRows(componentRows, quantity)
+    List bomLevels = bomLevelRows(productId, productAssocTypeId, fromDate, quantity, type)
+    List parentRows = datedProductAssocRows([productIdTo: productId, productAssocTypeId: productAssocTypeId],
+            fromDate, 'sequenceNum', 'productId')
+    List usedInParents = snapshotParentRows(parentRows, product.quantityUomId as String)
+
+    return success(productId: productId, productName: ManufacturingServiceUtil.displayProductName(product), internalName: product.internalName,
+            productAssocTypeId: productAssocTypeId, bomType: productAssocTypeId, fromDate: fromDate, quantity: quantity,
+            amount: amount, type: type, facilityId: parameters.facilityId, currencyUomId: parameters.currencyUomId,
+            componentCount: components.size(), usedInCount: usedInParents.size(), usedInParents: usedInParents,
+            bomLevels: bomLevels, components: components)
+}
+
+List bomLevelRows(String productId, String productAssocTypeId, Timestamp fromDate, BigDecimal quantity, Integer type) {
+    Map treeResult = runService('getBOMTree', [
+            productId: productId,
+            bomType: productAssocTypeId,
+            fromDate: fromDate,
+            quantity: quantity,
+            type: type,
+            userLogin: userLogin
+    ].findAll { it.value != null })
+    if (ServiceUtil.isError(treeResult)) {
+        return []
+    }
+
+    BOMTree tree = treeResult.tree as BOMTree
+    List nodes = []
+    tree?.print(nodes)
+    return nodes.collect { BOMNode node ->
+        GenericValue nodeProduct = node.getProduct()
+        GenericValue productAssoc = node.getProductAssoc()
+        [
+                level: node.getDepth(),
+                productId: nodeProduct?.productId,
+                productName: ManufacturingServiceUtil.displayProductName(nodeProduct),
+                parentProductId: productAssoc?.productId,
+                componentProductId: node.getDepth() == 0 ? null : nodeProduct?.productId,
+                componentName: node.getDepth() == 0 ? null : ManufacturingServiceUtil.displayProductName(nodeProduct),
+                quantity: node.getQuantity(),
+                quantityUomId: nodeProduct?.quantityUomId,
+                fromDate: productAssoc?.fromDate,
+                thruDate: productAssoc?.thruDate,
+                productAssocTypeId: productAssoc?.productAssocTypeId ?: productAssocTypeId
+        ]
+    }
+}
+
+List datedProductAssocRows(Map fields, Timestamp fromDate, String... orderBy) {
+    return from('ProductAssoc')
+            .where(fields)
+            .filterByDate(fromDate)
+            .orderBy(orderBy as List)
+            .queryList()
+}
+
+List snapshotComponentRows(List componentRows, BigDecimal bomQuantity) {
+    Set componentProductIds = componentRows*.productIdTo.findAll { it } as Set
+    Set routingIds = componentRows*.routingWorkEffortId.findAll { it } as Set
+    Map componentProducts = EntityUtil.lookupById(delegator, 'Product', 'productId',
+            componentProductIds, false)
+    Map routings = EntityUtil.lookupById(delegator, 'WorkEffort', 'workEffortId', routingIds)
+    return componentRows.collect { GenericValue component ->
+        BigDecimal componentQuantity = component.getBigDecimal('quantity') ?: BigDecimal.ZERO
+        GenericValue componentProduct = componentProducts[component.productIdTo]
+        GenericValue routing = routings[component.routingWorkEffortId]
+        [
+                level: 1,
+                productId: component.productId,
+                componentProductId: component.productIdTo,
+                componentName: ManufacturingServiceUtil.displayProductName(componentProduct),
+                componentProductName: ManufacturingServiceUtil.displayProductName(componentProduct),
+                quantityUomId: componentProduct?.quantityUomId,
+                productAssocTypeId: component.productAssocTypeId,
+                fromDate: component.fromDate,
+                thruDate: component.thruDate,
+                sequenceNum: component.sequenceNum,
+                quantity: componentQuantity,
+                extendedQuantity: componentQuantity * bomQuantity,
+                scrapFactor: component.scrapFactor,
+                instruction: component.instruction,
+                routingWorkEffortId: component.routingWorkEffortId,
+                routingName: routing?.workEffortName,
+                estimateCalcMethod: component.estimateCalcMethod,
+                recurrenceInfoId: component.recurrenceInfoId
+        ]
+    }
+}
+
+List snapshotParentRows(List parentRows, String quantityUomId) {
+    Set parentProductIds = parentRows*.productId.findAll { it } as Set
+    Map parentProducts = EntityUtil.lookupById(delegator, 'Product', 'productId', parentProductIds,
+            false)
+    return parentRows.collect { GenericValue association ->
+        GenericValue parentProduct = parentProducts[association.productId]
+        [
+                parentProductId: association.productId,
+                parentProductName: ManufacturingServiceUtil.displayProductName(parentProduct),
+                componentProductId: association.productIdTo,
+                quantity: association.getBigDecimal('quantity'),
+                quantityUomId: quantityUomId,
+                sequenceNum: association.sequenceNum,
+                fromDate: association.fromDate,
+                thruDate: association.thruDate,
+                productAssocTypeId: association.productAssocTypeId
+        ]
+    }
+}
+
+Map removeBomAssociation() {
+    return expireBomAssociation(parameters.productId, parameters.productIdTo, parameters.productAssocTypeId,
+            parameters.fromDate)
+}
+
+Map removeBomParentAssociation() {
+    return expireBomAssociation(parameters.productId, parameters.componentProductId, parameters.productAssocTypeId,
+            parameters.fromDate)
+}
+
+Map expireBomAssociation(String productId, String productIdTo, String assocTypeId, Timestamp fromDate) {
+    String productAssocTypeId = assocTypeId ?: 'MANUF_COMPONENT'
+    GenericValue association = from('ProductAssoc')
+            .where(productId: productId, productIdTo: productIdTo,
+                    productAssocTypeId: productAssocTypeId, fromDate: fromDate)
+            .queryOne()
+    if (!association) {
+        return ServiceUtil.returnError(manufacturingMessage('ManufacturingBomAssociationNotFound'))
+    }
+    Timestamp thruDate = UtilDateTime.nowTimestamp()
+    Map updateResult = runService('updateProductAssoc', [
+            productId: association.productId,
+            productIdTo: association.productIdTo,
+            productAssocTypeId: association.productAssocTypeId,
+            fromDate: association.fromDate,
+            thruDate: thruDate,
+            userLogin: userLogin
+    ])
+    if (ServiceUtil.isError(updateResult)) {
+        return updateResult
+    }
+    if (UtilValidate.isNotEmpty(updateResult.errorMessage)) {
+        return ServiceUtil.returnError(updateResult.errorMessage)
+    }
+    return success([
+            removed: true,
+            productId: association.productId,
+            parentProductId: association.productId,
+            productIdTo: association.productIdTo,
+            componentProductId: association.productIdTo,
+            productAssocTypeId: association.productAssocTypeId,
+            fromDate: association.fromDate,
+            thruDate: thruDate,
+            quantity: association.quantity,
+            sequenceNum: association.sequenceNum
+    ])
+}
+
+Map runBomSimulation() {
+    String productId = parameters.productId
+    if (!productId) {
+        return ServiceUtil.returnError(manufacturingMessage('ManufacturingProductIdRequired'))
+    }
+    String productAssocTypeId = parameters.productAssocTypeId ?: 'MANUF_COMPONENT'
+    Timestamp effectiveDate = parameters.effectiveDate
+            ?: parameters.fromDate
+            ?: parameters.asOfDate
+    BigDecimal quantity = parameters.quantity ?: BigDecimal.ONE
+    String simulationMode = parameters.simulationMode ?: 'EXPLOSION'
+    String currencyUomId = parameters.currencyUomId
+    String facilityId = parameters.facilityId
+    List warnings = []
+
+    Integer type = UtilMisc.toIntegerObject(parameters.type)
+    if (type == null) {
+        type = simulationMode == 'IMPLOSION' ? BOMTree.IMPLOSION : BOMTree.EXPLOSION
+    }
+    Map treeResult = runService('getBOMTree', [
+            productId: productId,
+            bomType: productAssocTypeId,
+            fromDate: effectiveDate,
+            quantity: quantity,
+            type: type,
+            userLogin: userLogin
+    ].findAll { it.value != null })
+    if (ServiceUtil.isError(treeResult)) {
+        return treeResult
+    }
+
+    GenericValue rootProduct = from('Product').where(productId: productId).cache().queryOne()
+    BOMTree tree = treeResult.tree as BOMTree
+    List nodes = []
+    tree?.print(nodes)
+    List componentNodes = nodes.findAll { BOMNode node -> node && node.getDepth() > 0 }
+    Set productsWithCostSources = costSourceProductIds(componentNodes.collect { BOMNode node -> node.getProduct()?.productId },
+            currencyUomId)
+    List availabilityRows = componentNodes.collect { BOMNode node ->
+        availabilityRow(node, facilityId, warnings)
+    }
+    List costRows = componentNodes.collect { BOMNode node ->
+        costRow(node, currencyUomId, warnings, productsWithCostSources)
+    }
+    BigDecimal knownMaterialCost = costRows.inject(BigDecimal.ZERO) { BigDecimal total, Map row ->
+        row.totalCost != null ? total.add(row.totalCost as BigDecimal) : total
+    }
+    int missingCostCount = costRows.count { Map row -> row.costStatus == 'MISSING_COST' }
+    if (!facilityId) {
+        warnings.add(manufacturingMessage('ManufacturingBomSimulationAvailabilityNotChecked'))
+    }
+    if (!currencyUomId) {
+        warnings.add(manufacturingMessage('ManufacturingBomSimulationCostNotChecked'))
+    }
+
+    Map requestParameters = [
+            productId: productId,
+            productName: ManufacturingServiceUtil.displayProductName(rootProduct),
+            quantity: quantity,
+            productAssocTypeId: productAssocTypeId,
+            facilityId: facilityId,
+            currencyUomId: currencyUomId,
+            effectiveDate: effectiveDate,
+            simulationMode: simulationMode
+    ]
+    Map summary = [
+            requiredComponentCount: componentNodes.size(),
+            knownMaterialCost: knownMaterialCost,
+            missingCostCount: missingCostCount,
+            availabilityStatus: facilityId ? 'CHECKED' : 'NOT_CHECKED'
+    ]
+    return success(parameters: requestParameters, summary: summary, availabilityRows: availabilityRows,
+            costRows: costRows, warnings: warnings.unique())
+}
+
+Map findBomTypes() {
+    RestQueryOptions queryOptions
+    try {
+        queryOptions = RestQueryOptions.fromParameters(parameters)
+    } catch (IllegalArgumentException e) {
+        return ServiceUtil.returnError(e.message)
+    }
+    EntityQuery query = from('ProductAssocType')
+            .select('productAssocTypeId', 'description')
+            .where(parentTypeId: 'PRODUCT_COMPONENT')
+            .cache(true)
+    long totalCount = query.queryCount()
+    List bomTypes = query.orderBy('description', 'productAssocTypeId')
+            .queryPagedList(queryOptions.pageIndex, queryOptions.pageSize).getData()
+            .collect { GenericValue productAssocType ->
+                String productAssocTypeId = productAssocType.productAssocTypeId
+                String description = productAssocType.description ?: productAssocTypeId
+                [
+                        productAssocTypeId: productAssocTypeId,
+                        description: description,
+                        label: description
+                ]
+            }
+    return success(RestApiUtil.getPagedResult('bomTypes', bomTypes, queryOptions, totalCount, null))
+}
+
+List bomAssocTypeIds() {
+    return from('ProductAssocType').where(parentTypeId: 'PRODUCT_COMPONENT')
+            .cache(true).getFieldList('productAssocTypeId') ?: []
+}
+
+Map componentSummaryByProduct(Collection productIds, List bomTypeIds) {
+    if (!productIds || !bomTypeIds) {
+        return [:]
+    }
+    List components = from('ProductAssoc')
+            .select('productId', 'fromDate')
+            .where(EntityCondition.makeCondition([
+                    EntityCondition.makeCondition('productId', EntityOperator.IN, productIds as List),
+                    EntityCondition.makeCondition('productAssocTypeId', EntityOperator.IN, bomTypeIds)
+            ], EntityOperator.AND))
+            .filterByDate()
+            .queryList()
+    return components.groupBy { it.productId }.collectEntries { String id, List rows ->
+        [(id): [count: rows.size(), activeFromDate: rows*.fromDate.min()]]
+    }
+}
+
+Map availabilityRow(BOMNode node, String facilityId, List warnings) {
+    GenericValue product = node.getProduct()
+    BigDecimal requiredQuantity = node.getQuantity()
+    BigDecimal quantityOnHand = null
+    BigDecimal shortageQuantity = null
+    if (facilityId) {
+        Map inventoryResult = runService('getInventoryAvailableByFacility', [
+                productId: product.productId,
+                facilityId: facilityId,
+                userLogin: userLogin
+        ])
+        if (ServiceUtil.isError(inventoryResult)) {
+            warnings.add(manufacturingMessage('ManufacturingBomSimulationAvailabilityCheckFailed',
+                    [productId: product.productId, errorString: ServiceUtil.getErrorMessage(inventoryResult)]))
+        } else {
+            quantityOnHand = inventoryResult.quantityOnHandTotal as BigDecimal
+            if (quantityOnHand != null && requiredQuantity != null) {
+                shortageQuantity = requiredQuantity.subtract(quantityOnHand).max(BigDecimal.ZERO)
+            }
+        }
+    }
+    return [
+            level: node.getDepth(),
+            productId: product.productId,
+            productName: ManufacturingServiceUtil.displayProductName(product),
+            requiredQuantity: requiredQuantity,
+            quantityUomId: product.quantityUomId,
+            quantityOnHand: quantityOnHand,
+            shortageQuantity: shortageQuantity,
+            availabilityStatus: facilityId ? 'CHECKED' : 'NOT_CHECKED'
+    ]
+}
+
+Map costRow(BOMNode node, String currencyUomId, List warnings, Set costSourceProductIds) {
+    GenericValue product = node.getProduct()
+    BigDecimal requiredQuantity = node.getQuantity()
+    BigDecimal unitCost = null
+    BigDecimal totalCost = null
+    String costStatus = 'MISSING_COST'
+    if (currencyUomId) {
+        if (costSourceProductIds.contains(product.productId)) {
+            Map costResult = runService('getProductCost', [
+                    productId: product.productId,
+                    currencyUomId: currencyUomId,
+                    costComponentTypePrefix: 'EST_STD',
+                    userLogin: userLogin
+            ])
+            if (ServiceUtil.isError(costResult)) {
+                warnings.add(manufacturingMessage('ManufacturingBomSimulationCostCheckFailed',
+                        [productId: product.productId, errorString: ServiceUtil.getErrorMessage(costResult)]))
+            } else {
+                unitCost = costResult.productCost as BigDecimal
+                totalCost = unitCost != null && requiredQuantity != null ? unitCost * requiredQuantity : null
+                costStatus = 'KNOWN'
+            }
+        }
+    }
+    return [
+            level: node.getDepth(),
+            productId: product.productId,
+            productName: ManufacturingServiceUtil.displayProductName(product),
+            requiredQuantity: requiredQuantity,
+            unitCost: unitCost,
+            totalCost: totalCost,
+            costStatus: costStatus
+    ]
+}
+
+Set costSourceProductIds(Collection productIds, String currencyUomId) {
+    Set selectedProductIds = productIds.findAll { it } as Set
+    if (!currencyUomId || !selectedProductIds) {
+        return [] as Set
+    }
+    EntityCondition costCondition = EntityCondition.makeCondition([
+            EntityCondition.makeCondition('productId', EntityOperator.IN, selectedProductIds as List),
+            EntityCondition.makeCondition('costUomId', currencyUomId),
+            EntityCondition.makeCondition('costComponentTypeId', EntityOperator.LIKE, 'EST_STD_%')
+    ], EntityOperator.AND)
+    Set productIdsWithCosts = from('CostComponent')
+            .select('productId')
+            .where(costCondition)
+            .filterByDate()
+            .distinct()
+            .queryList()*.productId.findAll { it } as Set
+    Set supplierProductIds = from('SupplierProduct')
+            .select('productId')
+            .where(EntityCondition.makeCondition([
+                    EntityCondition.makeCondition('productId', EntityOperator.IN, selectedProductIds as List),
+                    EntityCondition.makeCondition('currencyUomId', currencyUomId)
+            ], EntityOperator.AND))
+            .filterByDate('availableFromDate', 'availableThruDate')
+            .distinct()
+            .queryList()*.productId.findAll { it } as Set
+    return productIdsWithCosts + supplierProductIds
+}
+
+String manufacturingMessage(String property, Map context = [:]) {
+    return UtilProperties.getMessage('ManufacturingUiLabels', property, context, locale)
+}
diff --git a/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/api/ManufacturingLookupServices.groovy b/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/api/ManufacturingLookupServices.groovy
new file mode 100644
index 0000000000..19ae356b8c
--- /dev/null
+++ b/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/api/ManufacturingLookupServices.groovy
@@ -0,0 +1,93 @@
+/*
+ * 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 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
+
+Map findProductLookupOptions() {
+    RestQueryOptions queryOptions
+    try {
+        queryOptions = RestQueryOptions.fromParameters(parameters)
+    } catch (IllegalArgumentException e) {
+        return ServiceUtil.returnError(e.message)
+    }
+    String queryText = parameters.query?.trim()
+    if (UtilValidate.isEmpty(queryText)) {
+        return success(RestApiUtil.getPagedResult('products', [], queryOptions, 0L, null))
+    }
+
+    EntityCondition searchCondition = EntityUtil.upperLikeAny(['productId', 'productName', 'internalName'], queryText)
+    EntityQuery productQuery = from('Product')
+            .select('productId', 'productName', 'internalName', 'productTypeId', 'quantityUomId')
+            .where(searchCondition)
+    long totalCount = productQuery.queryCount()
+    List productRows = productQuery.orderBy('productId')
+            .queryPagedList(queryOptions.pageIndex, queryOptions.pageSize).getData()
+    List products = productRows.collect { GenericValue product ->
+        String productName = ManufacturingServiceUtil.displayProductName(product)
+        [
+                productId: product.productId,
+                productName: productName,
+                internalName: product.internalName,
+                productTypeId: product.productTypeId,
+                quantityUomId: product.quantityUomId,
+                label: productName
+        ]
+    }
+    return success(RestApiUtil.getPagedResult('products', products, queryOptions, totalCount, null))
+}
+
+Map findWarehouses() {
+    return findFacilitiesByTypes(['WAREHOUSE', 'PLANT'])
+}
+
+Map findFacilitiesByTypes(List facilityTypeIds) {
+    RestQueryOptions queryOptions
+    try {
+        queryOptions = RestQueryOptions.fromParameters(parameters)
+    } catch (IllegalArgumentException e) {
+        return ServiceUtil.returnError(e.message)
+    }
+    EntityQuery query = from('Facility')
+            .select('facilityId', 'facilityName', 'facilityTypeId')
+            .where(EntityCondition.makeCondition('facilityTypeId', EntityOperator.IN, facilityTypeIds))
+    long totalCount = query.queryCount()
+    List facilities = query.orderBy('facilityName', 'facilityId')
+            .queryPagedList(queryOptions.pageIndex, queryOptions.pageSize).getData()
+            .collect { GenericValue facility ->
+                String facilityId = facility.facilityId
+                String facilityName = facility.facilityName ?: facilityId
+                String label = facilityName == facilityId ? facilityId : "${facilityName} [${facilityId}]".toString()
+                [
+                        facilityId: facilityId,
+                        facilityName: facilityName,
+                        facilityTypeId: facility.facilityTypeId,
+                        label: label
+                ]
+    }
+    return success(RestApiUtil.getPagedResult('facilities', facilities, queryOptions, totalCount, null))
+}
diff --git a/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/api/ManufacturingServiceUtil.groovy b/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/api/ManufacturingServiceUtil.groovy
new file mode 100644
index 0000000000..c1778e3e55
--- /dev/null
+++ b/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/api/ManufacturingServiceUtil.groovy
@@ -0,0 +1,31 @@
+/*
+ * 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 org.apache.ofbiz.entity.GenericValue
+
+final class ManufacturingServiceUtil {
+
+    private ManufacturingServiceUtil() { }
+
+    static String displayProductName(GenericValue product) {
+        return product?.productName ?: product?.internalName ?: product?.productId
+    }
+
+}
diff --git a/applications/manufacturing/src/main/java/org/apache/ofbiz/manufacturing/bom/BOMServices.java b/applications/manufacturing/src/main/java/org/apache/ofbiz/manufacturing/bom/BOMServices.java
index a019c5d996..97910d453a 100644
--- a/applications/manufacturing/src/main/java/org/apache/ofbiz/manufacturing/bom/BOMServices.java
+++ b/applications/manufacturing/src/main/java/org/apache/ofbiz/manufacturing/bom/BOMServices.java
@@ -30,6 +30,8 @@ import java.util.Locale;
 import java.util.Map;
 
 import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.GeneralException;
+import org.apache.ofbiz.base.util.ObjectType;
 import org.apache.ofbiz.base.util.UtilGenerics;
 import org.apache.ofbiz.base.util.UtilMisc;
 import org.apache.ofbiz.base.util.UtilProperties;
@@ -65,17 +67,10 @@ public class BOMServices {
         Map<String, Object> result = new HashMap<>();
         Delegator delegator = dctx.getDelegator();
         String productId = (String) context.get("productId");
-        String fromDateStr = (String) context.get("fromDate");
         String bomType = (String) context.get("bomType");
         Locale locale = (Locale) context.get("locale");
+        Date fromDate = getDateValue(context.get("fromDate"), locale);
 
-        Date fromDate = null;
-        if (UtilValidate.isNotEmpty(fromDateStr)) {
-            try {
-                fromDate = Timestamp.valueOf(fromDateStr);
-            } catch (Exception e) {
-            }
-        }
         if (fromDate == null) {
             fromDate = new Date();
         }
@@ -303,7 +298,6 @@ public class BOMServices {
         LocalDispatcher dispatcher = dctx.getDispatcher();
         GenericValue userLogin = (GenericValue) context.get("userLogin");
         String productId = (String) context.get("productId");
-        String fromDateStr = (String) context.get("fromDate");
         String bomType = (String) context.get("bomType");
         Integer type = (Integer) context.get("type");
         BigDecimal quantity = (BigDecimal) context.get("quantity");
@@ -313,13 +307,7 @@ public class BOMServices {
             type = 0;
         }
 
-        Date fromDate = null;
-        if (UtilValidate.isNotEmpty(fromDateStr)) {
-            try {
-                fromDate = Timestamp.valueOf(fromDateStr);
-            } catch (Exception e) {
-            }
-        }
+        Date fromDate = getDateValue(context.get("fromDate"), locale);
         if (fromDate == null) {
             fromDate = new Date();
         }
@@ -342,6 +330,15 @@ public class BOMServices {
         return result;
     }
 
+    private static Date getDateValue(Object dateValue, Locale locale) {
+        try {
+            Object convertedDate = ObjectType.simpleTypeOrObjectConvert(dateValue, "Timestamp", null, locale, false);
+            return convertedDate instanceof Timestamp ? (Timestamp) convertedDate : null;
+        } catch (GeneralException e) {
+            return null;
+        }
+    }
+
     /** It reads the product's bill of materials,
      * if necessary configures it, and it returns its (possibly configured) components in
      * a List of {@link BOMNode}).


(ofbiz-framework) 04/04: OFBIZ-13468 Improved: expose BOM management REST endpoints

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 b9895e737a86a96a454358f9f0e3a9de5cbeaf55
Author: diveshdut <di...@hotwax.co>
AuthorDate: Fri Jul 31 14:24:43 2026 +0530

    OFBIZ-13468 Improved: expose BOM management REST endpoints
---
 .../manufacturing/api/bom-management.rest.xml      | 84 ++++++++++++++++++++++
 1 file changed, 84 insertions(+)

diff --git a/applications/manufacturing/api/bom-management.rest.xml b/applications/manufacturing/api/bom-management.rest.xml
new file mode 100644
index 0000000000..6dc0e51c29
--- /dev/null
+++ b/applications/manufacturing/api/bom-management.rest.xml
@@ -0,0 +1,84 @@
+<?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 BOM Management API"
+        path="bom-management"
+        displayName="BOM Management"
+        description="REST API for manufacturing bill of materials management"
+        publish="true">
+    <resource name="BomManagement" path="" auth="true" publish="true">
+        <operation verb="get" produces="application/json" auth="true"
+                description="Find products with bill of material component associations">
+            <service name="findBomProducts"/>
+        </operation>
+        <operation verb="post" consumes="application/json" produces="application/json" auth="true"
+                description="Create the first component association for a product BOM">
+            <service name="createBOMAssoc"/>
+        </operation>
+        <operation verb="post" path="/simulation" consumes="application/json" produces="application/json" auth="true"
+                description="Run a BOM explosion simulation for availability and cost">
+            <service name="runBomSimulation"/>
+        </operation>
+        <operation verb="get" path="/warehouses" produces="application/json" auth="true"
+                description="Find warehouse facilities for manufacturing application workflows">
+            <service name="findWarehouses"/>
+        </operation>
+        <operation verb="get" path="/bom-types" produces="application/json" auth="true"
+                description="Find BOM types for manufacturing BOM workflows">
+            <service name="findBomTypes"/>
+        </operation>
+        <operation verb="get" path="/products/search" produces="application/json" auth="true"
+                description="Find products for BOM product selection workflows">
+            <service name="findProductLookupOptions"/>
+        </operation>
+        <operation verb="get" path="/{productId}" produces="application/json" auth="true"
+                description="Get one product BOM snapshot">
+            <service name="getBomSnapshot"/>
+        </operation>
+        <operation verb="post" path="/{productId}/components" consumes="application/json" produces="application/json" auth="true"
+                description="Add a component to a product BOM">
+            <service name="createBOMAssoc"/>
+        </operation>
+        <operation verb="put" path="/{productId}/components" consumes="application/json" produces="application/json" auth="true"
+                description="Update a component in a product BOM">
+            <service name="updateProductAssoc"/>
+        </operation>
+        <operation verb="delete" path="/{productId}/components" produces="application/json" auth="true"
+                description="Remove a component association from a product BOM">
+            <service name="removeBomAssociation"/>
+        </operation>
+        <operation verb="post" path="/{componentProductId}/parents" consumes="application/json" produces="application/json" auth="true"
+                description="Add a used-in parent association for the selected component product">
+            <service name="createBOMAssoc"/>
+        </operation>
+        <operation verb="put" path="/{componentProductId}/parents" consumes="application/json" produces="application/json" auth="true"
+                description="Update a used-in parent association for the selected component product">
+            <service name="updateProductAssoc"/>
+        </operation>
+        <operation verb="delete" path="/{componentProductId}/parents" produces="application/json" auth="true"
+                description="Remove a used-in parent association for the selected component product">
+            <service name="removeBomParentAssociation"/>
+        </operation>
+        <operation verb="post" path="/{productId}/copy" consumes="application/json" produces="application/json" auth="true"
+                description="Copy BOM components from one product to another">
+            <service name="copyBOMAssocs"/>
+        </operation>
+    </resource>
+</api>


(ofbiz-framework) 01/04: OFBIZ-13468 Improved: add generic entity lookup helpers

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 b1a86de910cdb0cea266264812f6fc8a6fcfa8f1
Author: diveshdut <di...@hotwax.co>
AuthorDate: Fri Jul 31 14:24:09 2026 +0530

    OFBIZ-13468 Improved: add generic entity lookup helpers
---
 .../org/apache/ofbiz/entity/util/EntityUtil.java   | 135 +++++++++++++++++++++
 1 file changed, 135 insertions(+)

diff --git a/framework/entity/src/main/java/org/apache/ofbiz/entity/util/EntityUtil.java b/framework/entity/src/main/java/org/apache/ofbiz/entity/util/EntityUtil.java
index afe8fd9476..f9a1749cc9 100644
--- a/framework/entity/src/main/java/org/apache/ofbiz/entity/util/EntityUtil.java
+++ b/framework/entity/src/main/java/org/apache/ofbiz/entity/util/EntityUtil.java
@@ -49,6 +49,9 @@ import org.apache.ofbiz.entity.GenericEntity;
 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.EntityFieldValue;
+import org.apache.ofbiz.entity.condition.EntityFunction;
+import org.apache.ofbiz.entity.condition.EntityOperator;
 import org.apache.ofbiz.entity.condition.OrderByList;
 import org.apache.ofbiz.entity.model.ModelEntity;
 import org.apache.ofbiz.entity.model.ModelField;
@@ -129,6 +132,138 @@ public final class EntityUtil {
         }
     }
 
+    public static Map<String, GenericValue> lookupById(Delegator delegator, String entityName, String fieldName, Collection<?> ids) {
+        try {
+            return lookupById(delegator, entityName, fieldName, ids, true);
+        } catch (GenericEntityException e) {
+            throw new IllegalStateException("Unable to lookup " + entityName + " by " + fieldName, e);
+        }
+    }
+
+    public static Map<String, GenericValue> lookupById(Delegator delegator, String entityName, String fieldName, Collection<?> ids,
+            boolean useCache) throws GenericEntityException {
+        Map<String, GenericValue> valuesById = new HashMap<>();
+        if (UtilValidate.isEmpty(ids)) {
+            return valuesById;
+        }
+
+        Set<String> keys = new HashSet<>();
+        for (Object id : ids) {
+            if (id == null) {
+                continue;
+            }
+            String key = id.toString();
+            if (UtilValidate.isEmpty(key) || UtilValidate.isEmpty(key.trim())) {
+                continue;
+            }
+            keys.add(key);
+        }
+        if (keys.isEmpty()) {
+            return valuesById;
+        }
+
+        EntityQuery query = EntityQuery.use(delegator)
+                .from(entityName)
+                .where(EntityCondition.makeCondition(fieldName, EntityOperator.IN, keys));
+        if (useCache) {
+            query.cache(true);
+        }
+        for (GenericValue value : query.queryList()) {
+            Object fieldValue = value.get(fieldName);
+            if (fieldValue != null) {
+                valuesById.put(fieldValue.toString(), value);
+            }
+        }
+        return valuesById;
+    }
+
+    public static Set<String> searchIds(Delegator delegator, String entityName, String idFieldName, Collection<String> searchFieldNames,
+            Object queryText, int maxRows) throws GenericEntityException {
+        return searchIds(delegator, entityName, idFieldName, searchFieldNames, queryText, new ArrayList<>(), maxRows);
+    }
+
+    public static Set<String> searchIds(Delegator delegator, String entityName, String idFieldName, Collection<String> searchFieldNames,
+            Object queryText, Collection<EntityCondition> extraConditions, int maxRows) throws GenericEntityException {
+        Set<String> matchingIds = new HashSet<>();
+        if (UtilValidate.isEmpty(queryText)) {
+            return matchingIds;
+        }
+
+        List<EntityCondition> conditions = new ArrayList<>();
+        if (UtilValidate.isNotEmpty(extraConditions)) {
+            conditions.addAll(extraConditions);
+        }
+        conditions.add(upperLikeAny(searchFieldNames, queryText));
+        EntityCondition whereCondition = conditions.size() == 1
+                ? conditions.get(0)
+                : EntityCondition.makeCondition(conditions, EntityOperator.AND);
+        for (Object id : EntityQuery.use(delegator)
+                .from(entityName)
+                .select(idFieldName)
+                .where(whereCondition)
+                .maxRows(maxRows)
+                .getFieldList(idFieldName)) {
+            if (id != null) {
+                matchingIds.add(id.toString());
+            }
+        }
+        return matchingIds;
+    }
+
+    public static Map<String, Integer> countByField(Delegator delegator, String entityName, String fieldName, Collection<?> fieldValues,
+            Collection<EntityCondition> extraConditions, boolean filterByDate) throws GenericEntityException {
+        Map<String, Integer> countsByField = new HashMap<>();
+        if (UtilValidate.isEmpty(fieldValues)) {
+            return countsByField;
+        }
+
+        Set<String> values = new HashSet<>();
+        for (Object fieldValue : fieldValues) {
+            if (fieldValue == null) {
+                continue;
+            }
+            String value = fieldValue.toString();
+            if (UtilValidate.isEmpty(value) || UtilValidate.isEmpty(value.trim())) {
+                continue;
+            }
+            values.add(value);
+        }
+        if (values.isEmpty()) {
+            return countsByField;
+        }
+
+        List<EntityCondition> conditions = new ArrayList<>();
+        conditions.add(EntityCondition.makeCondition(fieldName, EntityOperator.IN, values));
+        if (UtilValidate.isNotEmpty(extraConditions)) {
+            conditions.addAll(extraConditions);
+        }
+        EntityQuery query = EntityQuery.use(delegator)
+                .from(entityName)
+                .select(fieldName)
+                .where(EntityCondition.makeCondition(conditions, EntityOperator.AND));
+        if (filterByDate) {
+            query.filterByDate();
+        }
+        for (GenericValue row : query.queryList()) {
+            Object fieldValue = row.get(fieldName);
+            if (fieldValue != null) {
+                String key = fieldValue.toString();
+                countsByField.put(key, countsByField.getOrDefault(key, 0) + 1);
+            }
+        }
+        return countsByField;
+    }
+
+    public static EntityCondition upperLikeAny(Collection<String> fieldNames, Object queryText) {
+        String likeQuery = "%" + queryText.toString().trim().toUpperCase(Locale.ROOT) + "%";
+        List<EntityCondition> conditions = new ArrayList<>();
+        for (String fieldName : fieldNames) {
+            conditions.add(EntityCondition.makeCondition(EntityFunction.upper(EntityFieldValue.makeFieldValue(fieldName)),
+                    EntityOperator.LIKE, likeQuery));
+        }
+        return EntityCondition.makeCondition(conditions, EntityOperator.OR);
+    }
+
     public static EntityCondition getFilterByDateExpr() {
         return EntityCondition.makeConditionDate("fromDate", "thruDate");
     }