diff --git a/applications/accounting/minilang/ledger/GeneralLedgerServices.xml b/applications/accounting/minilang/ledger/GeneralLedgerServices.xml index a192a87d7d..7a9571c1a0 100644 --- a/applications/accounting/minilang/ledger/GeneralLedgerServices.xml +++ b/applications/accounting/minilang/ledger/GeneralLedgerServices.xml @@ -1635,6 +1635,19 @@ under the License. + + + + + + + + + + + + + @@ -1660,6 +1673,19 @@ under the License. + + + + + + + + + + + + + diff --git a/applications/accounting/servicedef/services_ledger.xml b/applications/accounting/servicedef/services_ledger.xml index f455ac895a..182b3147fb 100644 --- a/applications/accounting/servicedef/services_ledger.xml +++ b/applications/accounting/servicedef/services_ledger.xml @@ -411,6 +411,7 @@ under the License. Create an accounting transaction for inventory that is issued to a work effort (Type: INVENTORY D: RAWMAT_INVENTORY, C: WIP_INVENTORY) + diff --git a/applications/datamodel/data/seed/ManufacturingSeedData.xml b/applications/datamodel/data/seed/ManufacturingSeedData.xml index 8860281429..0a06a800f5 100644 --- a/applications/datamodel/data/seed/ManufacturingSeedData.xml +++ b/applications/datamodel/data/seed/ManufacturingSeedData.xml @@ -55,5 +55,7 @@ under the License. + + diff --git a/applications/datamodel/entitydef/product-entitymodel.xml b/applications/datamodel/entitydef/product-entitymodel.xml index 1f3d78a342..6dd7e2c58a 100644 --- a/applications/datamodel/entitydef/product-entitymodel.xml +++ b/applications/datamodel/entitydef/product-entitymodel.xml @@ -1012,6 +1012,9 @@ under the License. This field will define the level of facility. + Flag to control if automated inventory reservation should be performed when a production run reaches the printed/approved status. + Flag to control if high-priority Production Runs can reallocate stock from others. + Flag to enable automated settlement of production run backorders. If enabled (Y), the system will automatically satisfy backorders both in real-time (upon inventory receipt via SECA) and during the nightly scheduled job. diff --git a/applications/datamodel/entitydef/workeffort-entitymodel.xml b/applications/datamodel/entitydef/workeffort-entitymodel.xml index 381b1adb89..73341138bd 100644 --- a/applications/datamodel/entitydef/workeffort-entitymodel.xml +++ b/applications/datamodel/entitydef/workeffort-entitymodel.xml @@ -594,6 +594,33 @@ under the License. + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/applications/manufacturing/config/ManufacturingUiLabels.xml b/applications/manufacturing/config/ManufacturingUiLabels.xml index 478a7c92ce..0ab553324c 100644 --- a/applications/manufacturing/config/ManufacturingUiLabels.xml +++ b/applications/manufacturing/config/ManufacturingUiLabels.xml @@ -790,6 +790,19 @@ 实际开始日期/时间 實際開始日期/時間 + + Edit Inventory Reservations + + + Add Inventory Reservation + + + Reserved + + + Manage Reservations + + Einen bestehenden Arbeitsgang hinzufügen Add a existing RoutingTask @@ -2466,6 +2479,35 @@ 交付数量 交付數量 + + Remaining Quantity + Quantità rimanente + + + Required Quantity + Quantità richiesta + + + Reservation Summary + + + Total Reserved + + + Confirmed Shortage (Tried & Failed) + + + Net Reserved + + + Confirmed Shortage indicates inventory that could not be reserved during the last attempt because the warehouse was empty. + + + Pending Process (Untouched) + + + Pending Process indicates requirements that have not yet been attempted for reservation or issuance. + Produktionsaufträge JobShop @@ -3118,6 +3160,60 @@ Mrp run scheduled successfully Exécution de Mrp programmée avec succès + + Issued Inventory for Production Run Task [${workEffortId}] + + + Auto-Reserve Production Run Inventory + + + Allow Inventory Reallocation + + + Auto-Reconcile Production Backorders + + + Release + + + Reserve + + + Force Issue + + + Force Issue Impact Warning + + + The following inventory reservations will be reallocated if you proceed with Force Issuance: + + + Impact Type + + + Ref ID + + + Impacted Qty + + + Confirm Force Issue + + + No significant impact on other reservations detected. + + + Preview of Issuance Plan + + + Free Stock + + + Existing Reservation + + + Re-allocated Stock + Neuer Kalender New calendar @@ -6040,4 +6136,7 @@ 链接到生产运行 連結生產運行 + + Reservation Strategy + diff --git a/applications/manufacturing/data/ManufacturingScheduledServices.xml b/applications/manufacturing/data/ManufacturingScheduledServices.xml index eb8fcf0761..c07f92bd7d 100644 --- a/applications/manufacturing/data/ManufacturingScheduledServices.xml +++ b/applications/manufacturing/data/ManufacturingScheduledServices.xml @@ -22,6 +22,7 @@ under the License. + + diff --git a/applications/manufacturing/ofbiz-component.xml b/applications/manufacturing/ofbiz-component.xml index 8498a8e884..714ee21caf 100644 --- a/applications/manufacturing/ofbiz-component.xml +++ b/applications/manufacturing/ofbiz-component.xml @@ -29,6 +29,7 @@ under the License. + @@ -42,6 +43,7 @@ under the License. + + + + + + + + + + + + diff --git a/applications/manufacturing/servicedef/services_production_run.xml b/applications/manufacturing/servicedef/services_production_run.xml index 75bddfc14a..036192522f 100644 --- a/applications/manufacturing/servicedef/services_production_run.xml +++ b/applications/manufacturing/servicedef/services_production_run.xml @@ -213,7 +213,7 @@ under the License. the issuance is done. If locationSeqIds are provided, then the items are only issued from the inventory items associated to the locations. If failIfItemsAreNotAvailable is set to "Y" (the default is "Y") then the service fails if there is not enough inventory available: - no reservation will be stolen. + no reservation will be reallocated. If failIfItemsAreNotOnHand is set to "Y" (the default is "Y") then the service fails if there is not enough inventory: no items with negative qoh will be created. If lotId is filled, failIfItemsAreNotAvailable is set to automatically set to "Y". @@ -226,11 +226,42 @@ under the License. + + + + + Bypasses chronological sweeping to instantly issue inventory specifically from the passed inventoryItemId inline. + + + + + + + + + + + + + + + Reconciles reservations system-wide after an inventory issuance to ensure ATP/QOH consistency. + + + + + + + + + @@ -308,7 +339,7 @@ under the License. If the inventory item is for a 'marketing package' run the decomposeInventoryItem service. It is intended to be called as seca when a marketing package is received into warehouse (e.g. from a return). - + Compute the actual costs for the production run task. + + + + Handles DB insert/update for WorkEffortInvRes + + + + + + + + + + + + Manufacturing-specific wrapper for inventory reservation. Provides a flexible extension point for production business rules (e.g., lot selection, task status checks) that should not be implemented in the generic core engine. + + + + + + + + + + + + Automated inventory reservation for all components of a production run. + + + + + + Condition service to check if auto-reservation is enabled for the production run's facility. + + + + + + + + + + + + + + + + + Cancels reservations + + + + + + + + Helper calculating total issued + reserved per component + + + + + + + + + + + + + + + + + + + + + + + + + + Performs Move-Then-Issue: Reallocates reservations from affected reservations to the current task, then issues. + + + + + + + + + + + + Reassigns inventory reservations for a product in a facility to fulfill production run task's backorders. + Commonly triggered after inventory receipt or production issuance. + + + + + + + + + Condition service for SECA: Checks if a facility has reconcilePrunBackorders enabled. + + + + + + + + + Nightly scheduled job responsible for maintaining inventory integrity across active production runs. + Enforces inventory integrity by auditing logical reservations against physical stock and operational policies. + Behavior is governed by two facility-level flags: + 1. allowInventoryReallocation: Informs the auditor's strictness in purging orphaned debt from daily reallocations. + 2. reconcilePrunBackorders: Gates the Satisfier's ability to auto-settle valid production run backorders using pool stock. + + + diff --git a/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/jobshopmgt/ProductionRunDeclaration.groovy b/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/jobshopmgt/ProductionRunDeclaration.groovy index 76095c238f..ed2d74a552 100644 --- a/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/jobshopmgt/ProductionRunDeclaration.groovy +++ b/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/jobshopmgt/ProductionRunDeclaration.groovy @@ -210,6 +210,13 @@ if (productionRunId) { } else { componentData.isRunning = 'null' } + + // Fetch reservation metrics + Map qtyResult = runService('getTaskQuantities', [workEffortId: component.workEffortId, productId: product.productId]) + componentData.reservedQuantity = qtyResult.reservedQuantity ?: 0.0 + componentData.shortfallQuantity = qtyResult.shortfallQuantity ?: 0.0 + componentData.netReservedQuantity = qtyResult.netReservedQuantity ?: 0.0 + productionRunComponentsData.add(componentData) if (productionRunTask.currentStatusId == 'PRUN_RUNNING' && component.getString('statusId') == 'WEGS_CREATED') { productionRunComponentsDataReadyForIssuance.add(componentData) diff --git a/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/jobshopmgt/ProductionRunResServices.groovy b/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/jobshopmgt/ProductionRunResServices.groovy new file mode 100644 index 0000000000..d0a60c2d5d --- /dev/null +++ b/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/jobshopmgt/ProductionRunResServices.groovy @@ -0,0 +1,1410 @@ +/* + * 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.jobshopmgt + +import org.apache.ofbiz.entity.GenericValue +import org.apache.ofbiz.entity.condition.EntityCondition +import org.apache.ofbiz.entity.condition.EntityOperator +import org.apache.ofbiz.service.ServiceUtil + +List sortByReservationStrategy(List items, String reserveOrderEnumId) { + switch (reserveOrderEnumId) { + case 'INVRO_LIFO_REC': + return items.sort { a, b -> (b.datetimeReceived?.time ?: 0L) <=> (a.datetimeReceived?.time ?: 0L) } + case 'INVRO_FIFO_EXP': + return items.sort { a, b -> (a.expireDate?.time ?: 0L) <=> (b.expireDate?.time ?: 0L) } + case 'INVRO_LIFO_EXP': + return items.sort { a, b -> (b.expireDate?.time ?: 0L) <=> (a.expireDate?.time ?: 0L) } + case 'INVRO_COST_DESC': + return items.sort { a, b -> (b.unitCost ?: 0) <=> (a.unitCost ?: 0) } + case 'INVRO_COST_ASC': + return items.sort { a, b -> (a.unitCost ?: 0) <=> (b.unitCost ?: 0) } + default: + return items.sort { a, b -> (a.datetimeReceived?.time ?: 0L) <=> (b.datetimeReceived?.time ?: 0L) } + } +} + +/** + * Automated inventory reservation for all components of a production run. + * + * This service is typically triggered via a SECA rule when a production run status changes + * to 'PRUN_DOC_PRINTED'. However, it is designed to be safe to run at any stage of the + * production run lifecycle: + * + * 1. **Idempotency**: It is safe to re-run multiple times. If components are already + * fully reserved, the service will perform no actions. + * 2. **Manual-First Support**: It respects existing manual reservations. If a user + * already reserved a specific lot for a task, the automation will skip that component + * or only reserve the remaining requirement balance. + * 3. **Partial Recovery**: If a production run is already 'In-Progress' and some + * items have been issued, it correctly accounts for 'totalIssued' and only reserves + * the remaining unmet requirement. + * + * It performs the following steps: + * 1. Resolves all child routing tasks (WorkEffort) associated with the production run. + * 2. Batches fetches all current issuances and reservations for all tasks in two queries. + * 3. Iterates through material requirements and calls 'reserveWorkEffortInventoryItem' + * with 'requireInventory=N' to handle best-effort reservations and shortage tracking. + */ +Map autoReserveWorkEffortInventory() { + String productionRunId = parameters.productionRunId ?: parameters.workEffortId + if (!productionRunId) { + return ServiceUtil.returnError('Production Run ID is required for auto-reservation') + } + + GenericValue productionRun = from('WorkEffort').where('workEffortId', productionRunId).queryOne() + if (!productionRun) { + return ServiceUtil.returnError('Production Run not found: ' + productionRunId) + } + + String facilityId = productionRun.facilityId + if (!facilityId) { + return success() + } + + // 1. Resolve all Child Tasks + List taskIds = from('WorkEffort') + .where('workEffortParentId', productionRunId, 'workEffortTypeId', 'PROD_ORDER_TASK') + .getFieldList('workEffortId') + if (!taskIds) { + return success() + } + + // 2. Resolve all component requirements (WorkEffortGoodStandard) for these tasks + List wegsList = from('WorkEffortGoodStandard') + .where(EntityCondition.makeCondition('workEffortId', EntityOperator.IN, taskIds), + EntityCondition.makeCondition('workEffortGoodStdTypeId', 'PRUNT_PROD_NEEDED')) + .queryList() + if (!wegsList) { + return success() + } + + // 3. Batch fetch current state + // We retrieve all issuances and reservations for the entire task set in two queries. + Map issuedMap = from('WorkEffortAndInventoryAssign') + .where(EntityCondition.makeCondition('workEffortId', EntityOperator.IN, taskIds)) + .queryList() + .groupBy { it.workEffortId + '_' + it.productId } + .collectEntries { key, list -> [key, list.sum { it.getBigDecimal('quantity') ?: BigDecimal.ZERO }] } + + Map reservedMap = from('WorkEffortInvResAndItem') + .where(EntityCondition.makeCondition('workEffortId', EntityOperator.IN, taskIds)) + .queryList() + .groupBy { it.workEffortId + '_' + it.productId } + .collectEntries { key, list -> [key, list.sum { it.getBigDecimal('quantity') ?: BigDecimal.ZERO }] } + + // 4. Component Reservation Loop + for (GenericValue wegs : wegsList) { + String productId = wegs.productId + String taskId = wegs.workEffortId + String lookupKey = taskId + '_' + productId + + // 4.1 Get totals from the pre-fetched batch maps + BigDecimal totalReserved = reservedMap[lookupKey] ?: BigDecimal.ZERO + BigDecimal totalIssued = issuedMap[lookupKey] ?: BigDecimal.ZERO + + // 4.2 Calculate remaining balance (Automation safety) + BigDecimal qtyNeeded = wegs.getBigDecimal('estimatedQuantity').subtract(totalReserved).subtract(totalIssued) + if (qtyNeeded <= 0) { + continue + } + + // 4.3 Trigger reservation, letting the core engine handle shortages (requireInventory=N) + Map serviceResult = runService('reserveWorkEffortInventoryItem', [ + workEffortId: taskId, + productId: productId, + facilityId: facilityId, + quantity: qtyNeeded, + reserveOrderEnumId: parameters.reserveOrderEnumId + ]) + if (ServiceUtil.isError(serviceResult)) { + return serviceResult + } + } + return success() +} + +/** + * Persistence service for the WorkEffortInvRes entity. + * This service is called by the core inventory reservation engine + * reservation succeeds. It handles the low-level DB inserts and updates to track exactly which + * physical inventory items were reserved for which production order task. + */ +Map reserveWorkEffortInventory() { + String workEffortId = parameters.workEffortId + String inventoryItemId = parameters.inventoryItemId + String productId = parameters.productId + + // Self-heal: If productId is missing (e.g. from a slightly broken SECA), recover it from the record + if (!productId && inventoryItemId) { + GenericValue item = from('InventoryItem').where('inventoryItemId', inventoryItemId).cache().queryOne() + productId = item?.productId + logInfo("reserveWorkEffortInventory: Recovered missing productId [${productId}] from Item [${inventoryItemId}]") + } + + // VALIDATION: Strict Product Identity Guard + if (inventoryItemId) { + GenericValue item = from('InventoryItem').where('inventoryItemId', inventoryItemId).cache().queryOne() + if (item && productId && item.productId != productId) { + String errMsg = ("Product Identity Mismatch: Item [${inventoryItemId}] belongs to " + + "[${item.productId}], but you are reserving for [${productId}].") + logError("reserveWorkEffortInventory - FAILED: ${errMsg}") + return error(errMsg) + } + } + + GenericValue existing = from('WorkEffortInvRes') + .where('workEffortId', workEffortId, 'inventoryItemId', inventoryItemId, 'productId', productId) + .queryOne() + + Map serviceResult = [:] + if (existing) { + BigDecimal newQty = (existing.getBigDecimal('quantity') ?: BigDecimal.ZERO).add(parameters.quantity ?: BigDecimal.ZERO) + BigDecimal currentQna = existing.getBigDecimal('quantityNotAvailable') ?: BigDecimal.ZERO + BigDecimal newQtyNotAvailable = currentQna.add(parameters.quantityNotAvailable ?: BigDecimal.ZERO) + serviceResult = runService('updateWorkEffortInvRes', [ + *: parameters, + productId: productId, + quantity: newQty, + quantityNotAvailable: newQtyNotAvailable + ]) + } else { + serviceResult = runService('createWorkEffortInvRes', [*: parameters, productId: productId]) + } + + if (ServiceUtil.isError(serviceResult)) { + return serviceResult + } + return success() +} + +/** + * Manufacturing-specific reservation wrapper (Decoupled Extension Point). + * + * This service acts as a domain-specific bridge to the core inventory engine. + * + * Why use a wrapper instead of calling 'reserveProductInventoryByFacility' directly? + * 1. **Manufacturing Defaults**: Centrally enforces 'requireInventory=N' (backordering) + * for all production-related reservations. + * 2. **Architectural Flexibility**: Provides a clean, decoupled hook for manufacturing-specific + * business rules (e.g., custom lot selection logic, restricting reservations by task + * status, or validating based on production run type). + * 3. **Future Proofing**: Allows implementing complex domain logic that would be + * inappropriate to place in the generic core inventory engine. + * 4. **SECA Isolation**: Enables triggering manufacturing-specific actions (logs, + * notifications, or integrations) specifically for production orders. + */ +Map reserveWorkEffortInventoryItem() { + String strategy = parameters.reserveOrderEnumId ?: 'INVRO_FIFO_REC' + String inventoryItemId = parameters.inventoryItemId + String productId = parameters.productId + + // VALIDATION: Strict Product Identity Guard + if (inventoryItemId) { + GenericValue item = from('InventoryItem').where('inventoryItemId', inventoryItemId).cache().queryOne() + if (item && productId && item.productId != productId) { + String errMsg = ("Product Identity Mismatch: Item [${inventoryItemId}] is for product " + + "[${item.productId}], but the task requires [${productId}].") + logError("reserveWorkEffortInventoryItem - FAILED: ${errMsg}") + return error(errMsg) + } + + /* + * DIRECT RESERVATION OVERRIDE: + * If a specific inventory item is requested, we bypass the generic reservation engine (Minilang) + * which might skip the item if its ATP is temporarily non-positive. + * For manufacturing, we trust the caller's item selection and record the reservation directly. + */ + BigDecimal quantity = parameters.quantity ?: BigDecimal.ZERO + BigDecimal requestedQna = parameters.quantityNotAvailable ?: BigDecimal.ZERO + BigDecimal currentAtp = item.getBigDecimal('availableToPromiseTotal') ?: BigDecimal.ZERO + BigDecimal physicalReservationQty = quantity.subtract(requestedQna).max(BigDecimal.ZERO).min(currentAtp.max(BigDecimal.ZERO)) + BigDecimal quantityNotAvailable = quantity.subtract(physicalReservationQty).max(requestedQna) + + Map resCtx = [*: parameters, quantityNotAvailable: quantityNotAvailable] + Map serviceResult = runService('reserveWorkEffortInventory', resCtx) + if (ServiceUtil.isError(serviceResult)) { + return serviceResult + } + + if (physicalReservationQty > 0) { + serviceResult = runService('createInventoryItemDetail', [ + inventoryItemId: inventoryItemId, + workEffortId: parameters.workEffortId, + availableToPromiseDiff: -physicalReservationQty, + quantityOnHandDiff: BigDecimal.ZERO + ]) + if (ServiceUtil.isError(serviceResult)) { + return serviceResult + } + } + + return success(quantityNotReserved: 0.0) + } + + Map reserveCtx = [*: parameters, + reserveOrderEnumId: strategy, + requireInventory: (parameters.requireInventory ?: 'N')] + Map serviceResult = runService('reserveProductInventoryByFacility', reserveCtx) + return serviceResult +} + +/** + * SECA condition service to gate automated reservations. + * It checks the 'autoReservePrun' flag on the Facility associated with the production run. + * If the flag is set to 'Y', automated reservations are permitted to proceed. + */ +Map checkProductionRunFacilityAutoReserve() { + String productionRunId = parameters.productionRunId ?: parameters.serviceContext?.productionRunId + GenericValue header = null + + if (productionRunId) { + header = from('WorkEffort').where('workEffortId', productionRunId).queryOne() + } else { + String workEffortId = parameters.workEffortId ?: parameters.serviceContext?.workEffortId + if (workEffortId) { + GenericValue workEffort = from('WorkEffort').where('workEffortId', workEffortId).queryOne() + if (workEffort) { + header = (workEffort.workEffortTypeId == 'PROD_ORDER_HEADER') ? + workEffort : from('WorkEffort').where('workEffortId', workEffort.workEffortParentId).queryOne() + } + } + } + + if (!header || !header.facilityId) { + return success(conditionReply: false) + } + + GenericValue facility = from('Facility').where('facilityId', header.facilityId).queryOne() + boolean reply = (facility && facility.autoReservePrun == 'Y') + return success(conditionReply: reply) +} + +/** + * Releases, cancels, or satisfies an existing inventory reservation. + * + * Supports both partial and full releases: + * - If 'quantity' is less than the current reservation, it performs a partial release by + * decrementing the WorkEffortInvRes record (e.g., Releasing 5 units from a 10-unit reservation). + * - If 'quantity' is null or >= current reservation, it performs a full release by + * deleting the WorkEffortInvRes record. + * + * SATISFACTION vs CANCELLATION (appendInventoryItemDetail flag): + * - Cancellation (Default: true): The reservation is removed without being consumed by production. + * The system creates an InventoryItemDetail record to restore the Available-To-Promise (ATP) + * count, as the stock is now back in the free pool. + * - Satisfaction/Consumption (false): The reservation is removed because physical stock was + * already issued to the task. In this case, we suppress ATP restoration because the physical + * issuance already accounted for the availability change. Using this flag prevents "ATP Leaks". + * + * It performs a reversal of the reservation by: + * 1. Validating the current reservation quantity for the task/lot combination. + * 2. Updating or deleting the WorkEffortInvRes record depending on the quantity to release. + * 3. Conditionally restoring ATP based on the appendInventoryItemDetail flag. + */ +Map releaseProductionRunTaskComponent() { + String workEffortId = parameters.workEffortId + String productId = parameters.productId + String inventoryItemId = parameters.inventoryItemId + BigDecimal quantityToRelease = (BigDecimal) parameters.quantity + + // Attempt direct lookup if productId is provided; otherwise proceed straight to broad lookup + GenericValue res = productId ? from('WorkEffortInvRes') + .where(workEffortId: workEffortId, productId: productId, inventoryItemId: inventoryItemId) + .queryOne() : null + + /* + * SELF-HEALING SAFETY NET: + * Due to the composite Primary Key of WorkEffortInvRes (which includes productId), a "Direct Match" + * can fail if there is any data drift (e.g., product aliases) or if productId is missing from parameters. + */ + if (!res) { + logWarning('releaseProductionRunTaskComponent - No direct match found ' + + "(Product missing or mismatch). Running broad lookup for Task [${workEffortId}] " + + "Item [${inventoryItemId}]...") + List broadSearch = from('WorkEffortInvRes') + .where(workEffortId: workEffortId, inventoryItemId: inventoryItemId) + .queryList() + + if (broadSearch.size() == 1) { + res = broadSearch[0] + logWarning('releaseProductionRunTaskComponent - [SELF-HEALING]: Found orphan reservation. ' + + "Record Product: ${res.productId}, Target Product: ${productId}. Proceeding with release.") + } else if (broadSearch.size() > 1) { + logError("releaseProductionRunTaskComponent - CRITICAL: Found ${broadSearch.size()} ambiguous " + + 'reservations for Task-Item pair. Cannot safely determine which to release without ' + + 'Product ID. Skipping.') + } else { + logWarning('releaseProductionRunTaskComponent - No reservation found even in broad search. Nothing to clean up.') + } + } + + if (res) { + BigDecimal reservedQty = res.getBigDecimal('quantity') ?: BigDecimal.ZERO + BigDecimal currentQna = res.getBigDecimal('quantityNotAvailable') ?: BigDecimal.ZERO + BigDecimal releaseQty = (quantityToRelease != null && quantityToRelease < reservedQty) ? quantityToRelease : reservedQty + + // Calculate QNA change (Debt is always released first) + BigDecimal newQna = (releaseQty < reservedQty) ? currentQna.subtract(releaseQty).max(BigDecimal.ZERO) : BigDecimal.ZERO + BigDecimal qnaReleased = currentQna.subtract(newQna) + BigDecimal physicalReleased = releaseQty.subtract(qnaReleased) + // Low-level inventory adjustment + GenericValue inventoryItem = from('InventoryItem').where('inventoryItemId', inventoryItemId).queryOne() + if (inventoryItem) { + if (inventoryItem.inventoryItemTypeId == 'SERIALIZED_INV_ITEM') { + Map serviceResult = runService('updateInventoryItem', [inventoryItemId: inventoryItemId, statusId: 'INV_AVAILABLE']) + if (ServiceUtil.isError(serviceResult)) { + return serviceResult + } + } else { + /* + * SATISFACTION vs CANCELLATION: + * - Cancellation (Default): Reservation is removed without consumption. ATP must be restored. + * - Satisfaction/Consumption (appendInventoryItemDetail=false): Reservation is removed because physical + * stock was already issued. ATP must NOT be restored because the physical issuance + * already accounted for the reduction and the reservation disappearance. + */ + boolean shouldAppend = !(parameters.appendInventoryItemDetail in [false, 'N', 'false', 'n']) + if (shouldAppend && physicalReleased > 0) { + Map serviceResult = runService('createInventoryItemDetail', [ + inventoryItemId: inventoryItemId, + workEffortId: workEffortId, + availableToPromiseDiff: physicalReleased + ]) + if (ServiceUtil.isError(serviceResult)) { + return serviceResult + } + } + } + } + + if (releaseQty < reservedQty) { + Map serviceResult = runService('updateWorkEffortInvRes', [ + workEffortId: workEffortId, + productId: res.productId, + inventoryItemId: inventoryItemId, + quantity: reservedQty.subtract(releaseQty), + quantityNotAvailable: newQna + ]) + if (ServiceUtil.isError(serviceResult)) { + return serviceResult + } + } else { + Map serviceResult = runService('deleteWorkEffortInvRes', [ + workEffortId: workEffortId, + productId: res.productId, // Use the record's actual productId + inventoryItemId: inventoryItemId + ]) + if (ServiceUtil.isError(serviceResult)) { + return serviceResult + } + } + } else { + logError("releaseProductionRunTaskComponent - CRITICAL: No reservation found for Task [${workEffortId}] " + + "on Item [${inventoryItemId}]. Skipping cleanup.") + } + return success() +} + +/** + * Utility service to calculate current issued and reserved totals for a component. + * This is primarily used by the UI components to display accurate "Remaining Balance" + * information and to provide suggested quantities when manually adding new reservations. + */ +Map getTaskQuantities() { + String workEffortId = parameters.workEffortId + String productId = parameters.productId + + BigDecimal issued = from('WorkEffortAndInventoryAssign') + .where('workEffortId', workEffortId, 'productId', productId) + .queryList().sum { it.getBigDecimal('quantity') ?: BigDecimal.ZERO } ?: BigDecimal.ZERO + + List resList = from('WorkEffortInvRes') + .where('workEffortId', workEffortId, 'productId', productId) + .queryList() + + BigDecimal reserved = resList.sum { it.getBigDecimal('quantity') ?: BigDecimal.ZERO } ?: BigDecimal.ZERO + BigDecimal shortfall = resList.sum { it.getBigDecimal('quantityNotAvailable') ?: BigDecimal.ZERO } ?: BigDecimal.ZERO + BigDecimal netReserved = (reserved - shortfall).max(BigDecimal.ZERO) + + GenericValue wegs = from('WorkEffortGoodStandard') + .where('workEffortId', workEffortId, 'productId', productId, 'workEffortGoodStdTypeId', 'PRUNT_PROD_NEEDED') + .queryFirst() + BigDecimal estimated = wegs?.getBigDecimal('estimatedQuantity') ?: BigDecimal.ZERO + BigDecimal remaining = (estimated - issued - netReserved).max(BigDecimal.ZERO) + BigDecimal unaccounted = (estimated - issued - reserved).max(BigDecimal.ZERO) + + return success(issuedQuantity: issued, + reservedQuantity: reserved, + netReservedQuantity: netReserved, + shortfallQuantity: shortfall, + unaccountedQuantity: unaccounted, + remainingQuantity: remaining, + requiredQuantity: estimated) +} + +Map getProductionRunTaskForceIssueImpact() { + String workEffortId = parameters.workEffortId + String productIdParam = parameters.productId + String reserveOrderEnumId = parameters.reserveOrderEnumId ?: 'INVRO_FIFO_REC' + if (!workEffortId) { + return ServiceUtil.returnError('Task ID is required for impact analysis') + } + + GenericValue task = from('WorkEffort').where('workEffortId', workEffortId).queryOne() + if (!task) { + return ServiceUtil.returnError('Task not found: ' + workEffortId) + } + + // Find the actual Production Run identity and resolve facility + GenericValue productionRun = null + String facilityId = null + GenericValue currentEffort = task + while (currentEffort) { + if (!facilityId && currentEffort.facilityId) { + facilityId = currentEffort.facilityId + } + if (currentEffort.workEffortTypeId == 'PROD_RUN') { + productionRun = currentEffort + } + if (currentEffort.workEffortParentId) { + currentEffort = from('WorkEffort').where('workEffortId', currentEffort.workEffortParentId).cache().queryOne() + } else { + break + } + } + + // If we didn't find a PROD_RUN type parent, use the top-most effort as fallback identity + productionRun = productionRun ?: currentEffort + + // Policy Check: Audit allowInventoryReallocation flag + if (facilityId) { + GenericValue facility = from('Facility').where('facilityId', facilityId).cache().queryOne() + if (facility && facility.allowInventoryReallocation != 'Y') { + logInfo("Policy Check Failed: allowInventoryReallocation is 'N' for Facility [${facilityId}]. Blocking reallocation analysis.") + Map result = ServiceUtil.returnSuccess() + result.policyViolation = ('Inventory reallocation is forbidden for Facility ' + + "[${facilityId}]. Please adjust facility policies to allow this operation.") + return result + } + } + + List impactList = [] + List inventoryIssuePlan = [] + BigDecimal totalNeededQuantity = BigDecimal.ZERO + + // 1. Resolve component requirements (WorkEffortGoodStandard) + Map wegsCond = [workEffortId: workEffortId, workEffortGoodStdTypeId: 'PRUNT_PROD_NEEDED'] + if (productIdParam) { + wegsCond.productId = productIdParam + } + List wegsList = from('WorkEffortGoodStandard') + .where(wegsCond) + .queryList() + + for (GenericValue wegs : wegsList) { + String productId = wegs.productId + BigDecimal estimated = wegs.getBigDecimal('estimatedQuantity') ?: BigDecimal.ZERO + totalNeededQuantity = totalNeededQuantity.add(estimated) + + // Waterfall State for this component + BigDecimal balanceToAccountFor = estimated + + // MAP: Track physical capacity to prevent double-counting across stages + Map remainingQohPerItem = [:] + + // Stage 1: Account for existing issuances (Stock already out the door) + BigDecimal issued = from('WorkEffortAndInventoryAssign') + .where('workEffortId', workEffortId, 'productId', productId) + .queryList().sum { it.getBigDecimal('quantity') ?: BigDecimal.ZERO } ?: BigDecimal.ZERO + + if (issued > 0) { + inventoryIssuePlan << [ + productId: productId, + inventoryItemId: 'ALREADY_ISSUED', + quantity: issued, + type: 'ALREADY_ISSUED' + ] + balanceToAccountFor = balanceToAccountFor.subtract(issued) + } + if (balanceToAccountFor <= 0) { + continue + } + + // Stage 2: Account for OUR existing reservations (Stock already set aside for us) + List taskReservations = from('WorkEffortInvResAndItem') + .where(workEffortId: workEffortId, productId: productId) + .queryList() + + taskReservations.each { res -> + if (balanceToAccountFor > 0) { + BigDecimal netRes = (res.getBigDecimal('quantity') ?: 0).subtract(res.getBigDecimal('quantityNotAvailable') ?: 0) + BigDecimal qoh = res.getBigDecimal('quantityOnHandTotal') ?: 0 + if (netRes > 0 && qoh > 0) { + if (remainingQohPerItem[res.inventoryItemId] == null) { + remainingQohPerItem[res.inventoryItemId] = qoh + } + + BigDecimal availablePhysics = remainingQohPerItem[res.inventoryItemId] + BigDecimal qtyToTake = balanceToAccountFor.min(netRes).min(availablePhysics) + + if (qtyToTake > 0) { + inventoryIssuePlan << [ + productId: productId, + inventoryItemId: res.inventoryItemId, + quantity: qtyToTake, + lotId: res.get('lotId'), + type: 'TASK_RESERVATION' + ] + balanceToAccountFor = balanceToAccountFor.subtract(qtyToTake) + remainingQohPerItem[res.inventoryItemId] = remainingQohPerItem[res.inventoryItemId].subtract(qtyToTake) + } + } + } + } + if (balanceToAccountFor <= 0) { + continue + } + + // Stage 3: Pick from Free Pool (Physical unreserved stock) + List freeStock = from('InventoryItem') + .where('productId', productId, 'facilityId', facilityId) + .queryList() + .findAll { (it.availableToPromiseTotal ?: 0) > 0 && (it.quantityOnHandTotal ?: 0) > 0 } + + sortByReservationStrategy(freeStock, reserveOrderEnumId) + + freeStock.each { item -> + if (balanceToAccountFor > 0) { + if (remainingQohPerItem[item.inventoryItemId] == null) { + remainingQohPerItem[item.inventoryItemId] = item.getBigDecimal('quantityOnHandTotal') ?: 0 + } + + BigDecimal atp = item.getBigDecimal('availableToPromiseTotal') ?: 0 + BigDecimal availablePhysics = remainingQohPerItem[item.inventoryItemId] + + // We can only take what is BOTH available logically (ATP) and physically (remaining QOH) + BigDecimal qtyToTake = balanceToAccountFor.min(atp).min(availablePhysics) + + if (qtyToTake > 0) { + inventoryIssuePlan << [ + productId: productId, + inventoryItemId: item.inventoryItemId, + quantity: qtyToTake, + type: 'POOL_STOCK' + ] + balanceToAccountFor = balanceToAccountFor.subtract(qtyToTake) + remainingQohPerItem[item.inventoryItemId] = remainingQohPerItem[item.inventoryItemId].subtract(qtyToTake) + } + } + } + if (balanceToAccountFor <= 0) { + continue + } + + // Stage 4: Reallocate from other reservations. + List reallocationCandidates = [] + + // 4.1 Production Task Reservations + from('WorkEffortInvResAndItem') + .where(EntityCondition.makeCondition([ + EntityCondition.makeCondition(productId: productId, facilityId: facilityId), + EntityCondition.makeCondition('workEffortId', EntityOperator.NOT_EQUAL, workEffortId) + ], EntityOperator.AND)) + .queryList() + .each { res -> + if (res.get('productId') != productId) { + return + } + BigDecimal netRes = (res.getBigDecimal('quantity') ?: 0).subtract(res.getBigDecimal('quantityNotAvailable') ?: 0) + BigDecimal qoh = res.getBigDecimal('quantityOnHandTotal') ?: 0 + if (netRes > 0 && qoh > 0) { + reallocationCandidates << [ + productId: res.get('productId'), + impactType: 'Production Task', + impactId: res.workEffortId, + parentWorkEffortId: res.workEffortParentId, + inventoryItemId: res.inventoryItemId, + datetimeReceived: res.datetimeReceived, + expireDate: res.expireDate, + unitCost: res.getBigDecimal('unitCost') ?: 0, + netQuantity: netRes, + qoh: qoh + ] + } + } + + // 4.2 Sales Order Reservations + from('OrderItemShipGrpInvResAndItem') + .where('productId', productId, 'facilityId', facilityId) + .queryList() + .each { res -> + if (res.get('productId') != productId) { + return + } + BigDecimal netRes = (res.getBigDecimal('quantity') ?: 0).subtract(res.getBigDecimal('quantityNotAvailable') ?: 0) + BigDecimal qoh = res.getBigDecimal('quantityOnHandTotal') ?: 0 + if (netRes > 0 && qoh > 0) { + reallocationCandidates << [ + productId: res.get('productId'), + impactType: 'Sales Order', + impactId: res.orderId, + orderItemSeqId: res.orderItemSeqId, + shipGroupSeqId: res.shipGroupSeqId, + parentWorkEffortId: null, + inventoryItemId: res.inventoryItemId, + datetimeReceived: res.datetimeReceived, + expireDate: res.expireDate, + unitCost: res.getBigDecimal('unitCost') ?: 0, + netQuantity: netRes, + qoh: qoh + ] + } + } + + sortByReservationStrategy(reallocationCandidates, reserveOrderEnumId) + + reallocationCandidates.each { affectedReservation -> + if (balanceToAccountFor > 0) { + if (remainingQohPerItem[affectedReservation.inventoryItemId] == null) { + remainingQohPerItem[affectedReservation.inventoryItemId] = affectedReservation.qoh + } + BigDecimal availablePhysics = remainingQohPerItem[affectedReservation.inventoryItemId] + + // Reallocated quantity is capped by the affected reservation and remaining physical stock. + BigDecimal reallocatedQty = balanceToAccountFor.min(affectedReservation.netQuantity).min(availablePhysics) + + if (reallocatedQty > 0) { + impactList << [ + productId: affectedReservation.productId, + impactType: affectedReservation.impactType, + impactId: affectedReservation.impactId, + orderItemSeqId: affectedReservation.orderItemSeqId, + shipGroupSeqId: affectedReservation.shipGroupSeqId, + parentWorkEffortId: affectedReservation.parentWorkEffortId, + inventoryItemId: affectedReservation.inventoryItemId, + impactQuantity: reallocatedQty, + quantity: reallocatedQty, + type: 'REALLOCATED', + impactedWorkEffortId: affectedReservation.impactId + ] + balanceToAccountFor = balanceToAccountFor.subtract(reallocatedQty) + remainingQohPerItem[affectedReservation.inventoryItemId] = + remainingQohPerItem[affectedReservation.inventoryItemId].subtract(reallocatedQty) + } + } + } + } + return success(inventoryIssuePlan: inventoryIssuePlan, impactList: impactList, totalNeededQuantity: totalNeededQuantity, facilityId: facilityId) +} + +/** + * Performs Move-Then-Issue Strategy using the impact analysis as context. + */ +Map reallocateAndIssueInventory() { + String workEffortId = parameters.workEffortId + String productId = parameters.productId + String reserveOrderEnumId = parameters.reserveOrderEnumId ?: 'INVRO_FIFO_REC' + List inventoryIssuePlan = parameters.inventoryIssuePlan ?: [] + List impactList = parameters.impactList ?: [] + String facilityId = parameters.facilityId + String productionRunId = parameters.productionRunId + + // Self-healing: If context is STILL missing, re-calculate it (Backend-only or fallback) + if (!inventoryIssuePlan) { + Map impactResult = runService('getProductionRunTaskForceIssueImpact', [ + workEffortId: workEffortId, + productId: productId, + reserveOrderEnumId: reserveOrderEnumId + ]) + if (ServiceUtil.isError(impactResult)) { + return impactResult + } + if (impactResult.policyViolation) { + return ServiceUtil.returnError("Policy Violation: ${impactResult.policyViolation}") + } + inventoryIssuePlan = (List) impactResult.inventoryIssuePlan + impactList = (List) impactResult.impactList + facilityId = facilityId ?: (String) impactResult.facilityId + } + + // Resolve facilityId and productionRunId if still missing (Direct call fallback) + if (!facilityId || !productionRunId) { + GenericValue task = from('WorkEffort').where('workEffortId', workEffortId).queryOne() + GenericValue currentEffort = task + while (currentEffort) { + facilityId = facilityId ?: currentEffort.facilityId + if (!productionRunId && currentEffort.workEffortTypeId == 'PRODUCTION_RUN') { + productionRunId = currentEffort.workEffortId + } + if (currentEffort.workEffortParentId) { + currentEffort = from('WorkEffort').where('workEffortId', currentEffort.workEffortParentId).cache().queryOne() + } else { + break + } + } + } + + // API Safety: Policy Enforcement + // If there's an impactList (reallocation is happening) and allowInventoryReallocation is 'N', block it. + if (facilityId && impactList) { + GenericValue facility = from('Facility').where('facilityId', facilityId).cache().queryOne() + if (facility && facility.allowInventoryReallocation == 'N') { + logInfo("API Policy Check Failed: allowInventoryReallocation is 'N' for Facility [${facilityId}]. Blocking reallocation.") + return ServiceUtil.returnError("Policy Violation: Inventory reallocation is forbidden for Facility [${facilityId}].") + } + } + // MINIMUM LOGIC GUARD: If the entire plan consists only of already issued items, short-circuit everything + List allItems = (inventoryIssuePlan ?: []) + (impactList ?: []) + boolean hasActionableItems = allItems.any { it.get('type') != 'ALREADY_ISSUED' && it.get('inventoryItemId') != 'ALREADY_ISSUED' } + + if (!hasActionableItems && allItems) { + return success(inventoryIssuePlan: inventoryIssuePlan, impactList: impactList, productionRunId: productionRunId) + } + if (inventoryIssuePlan) { + for (Map item : inventoryIssuePlan) { + if (item.type == 'POOL_STOCK') { + Map reserveResult = runService('reserveProductInventoryByFacility', [ + workEffortId: workEffortId, facilityId: facilityId, productId: item.productId, + inventoryItemId: item.inventoryItemId, quantity: item.quantity, requireInventory: 'Y' + ]) + if (ServiceUtil.isError(reserveResult)) { + return reserveResult + } + } + } + } + + // Phase 2: Reallocation preparation. + if (impactList) { + for (Map prospectiveRes : impactList) { + BigDecimal reallocatedQty = prospectiveRes.impactQuantity instanceof String + ? new BigDecimal(prospectiveRes.impactQuantity) + : (prospectiveRes.impactQuantity ?: BigDecimal.ZERO) + if (reallocatedQty <= 0) { + continue + } + + Map releaseResult = [:] + if (prospectiveRes.impactType == 'Sales Order') { + // CATEGORY A: Sales Order Affected reservation -> Standard Cancellation + // This triggers standard order-management shortfall logic. + releaseResult = runService('cancelOrderItemShipGrpInvRes', [ + orderId: prospectiveRes.impactId, + orderItemSeqId: prospectiveRes.orderItemSeqId, + shipGroupSeqId: prospectiveRes.shipGroupSeqId, + inventoryItemId: prospectiveRes.inventoryItemId, + cancelQuantity: reallocatedQty + ]) + if (ServiceUtil.isError(releaseResult)) { + logError("Phase 1: Failed to release Sales Order reservation [${prospectiveRes.impactId}]: " + + ServiceUtil.getErrorMessage(releaseResult)) + return releaseResult + } + } else { + // CATEGORY B: Work Effort / Production Task Affected reservation -> Reallocation-to-Backorder Conversion + // We increment 'quantityNotAvailable' instead of deleting the reservation. + // This preserves the "Need" on the books while freeing the stock for current task. + GenericValue affectedRes = from('WorkEffortInvRes') + .where(workEffortId: prospectiveRes.impactId, + inventoryItemId: prospectiveRes.inventoryItemId, + productId: prospectiveRes.productId) + .queryOne() + + if (affectedRes) { + BigDecimal curNotAvail = affectedRes.getBigDecimal('quantityNotAvailable') ?: 0.0 + BigDecimal newNotAvail = curNotAvail.add(reallocatedQty) + + logInfo("Phase 1: Converting affected reservation [${prospectiveRes.impactType}: " + + "${prospectiveRes.impactId}] Item ${prospectiveRes.inventoryItemId} to backorder. " + + "QNA: ${curNotAvail} -> ${newNotAvail}") + + releaseResult = runService('updateWorkEffortInvRes', [ + workEffortId: prospectiveRes.impactId, + productId: prospectiveRes.productId, + inventoryItemId: prospectiveRes.inventoryItemId, + quantityNotAvailable: newNotAvail + ]) + if (ServiceUtil.isError(releaseResult)) { + return releaseResult + } + + // Since we manually moved the affected reservation into backorder, + // restore ATP to the item pool so Phase 2 can consume it. + if (from('InventoryItem').where('inventoryItemId', prospectiveRes.inventoryItemId).queryOne()) { + runService('createInventoryItemDetail', [ + inventoryItemId: prospectiveRes.inventoryItemId, + availableToPromiseDiff: reallocatedQty, + quantityOnHandDiff: 0.0, + workEffortId: prospectiveRes.impactId + ]) + } + + // --- AFFECTED RESERVATION SANITY GUARD START --- + /* + * This cleans up redundant backorder reservations created when an operator manually + * reserved stock after the system already created negative reservations (backorders) + * due to stockout during approval. The system automatically + * reconciles this overlap during reallocation to maintain inventory integrity. + */ + List affectedPeers = from('WorkEffortInvRes') + .where(workEffortId: prospectiveRes.impactId, productId: prospectiveRes.productId) + .queryList() + + BigDecimal totalCommitted = affectedPeers.sum { it.getBigDecimal('quantity') ?: 0.0 } ?: 0.0 + + GenericValue affectedWegs = from('WorkEffortGoodStandard') + .where(workEffortId: prospectiveRes.impactId, productId: prospectiveRes.productId, + workEffortGoodStdTypeId: 'PRUNT_PROD_NEEDED') + .queryFirst() + BigDecimal affectedEstimated = affectedWegs?.getBigDecimal('estimatedQuantity') ?: 0.0 + + BigDecimal excess = totalCommitted.subtract(affectedEstimated) + if (excess > 0) { + logInfo("Affected Reservation Sanity Guard - Detected excess [${excess}] for affected reservation " + + "[${prospectiveRes.impactId}] / Product [${prospectiveRes.productId}]. " + + "Estimate: ${affectedEstimated}. Reducing backorders.") + + // Prioritize purging pure backorders (QOH=0 / QNA=Qty) + List backorders = affectedPeers.findAll { (it.getBigDecimal('quantityNotAvailable') ?: 0.0) > 0 } + .sort { a, b -> (b.quantityNotAvailable ?: 0) <=> (a.quantityNotAvailable ?: 0) } + + backorders.each { backorder -> + if (excess > 0) { + BigDecimal qna = backorder.getBigDecimal('quantityNotAvailable') ?: 0.0 + BigDecimal qty = backorder.getBigDecimal('quantity') ?: 0.0 + BigDecimal purgeQty = excess.min(qna).min(qty) + + if (purgeQty > 0) { + if (purgeQty >= qty) { + releaseResult = runService('releaseProductionRunTaskComponent', [ + workEffortId: backorder.workEffortId, + inventoryItemId: backorder.inventoryItemId, + productId: backorder.productId, + quantity: qty, + appendInventoryItemDetail: true // Explicitly restore ATP for the affected reservation + ]) + if (ServiceUtil.isError(releaseResult)) { + return releaseResult + } + excess = excess.subtract(qty) + } else { + Map releaseProductionRunTaskComponentResult = runService('releaseProductionRunTaskComponent', [ + workEffortId: backorder.workEffortId, + inventoryItemId: backorder.inventoryItemId, + productId: backorder.productId, + quantity: purgeQty, + appendInventoryItemDetail: true + ]) + if (ServiceUtil.isError(releaseProductionRunTaskComponentResult)) { + return releaseProductionRunTaskComponentResult + } + excess = excess.subtract(purgeQty) + } + } + } + } + } + // --- AFFECTED RESERVATION SANITY GUARD END --- + + // Note: ATP restoration is now handled centrally by reconcileGlobalReservations + // during the issuance phase to prevent double-restoration or sync issues. + } + } + + if (reallocatedQty > 0) { + // Stage 2: Direct Re-reservation / Backorder Satisfaction + // LEDGER AWARENESS: Distinguish between local fulfillment and global satisfaction + GenericValue localizedRes = from('WorkEffortInvRes') + .where(workEffortId: workEffortId, inventoryItemId: prospectiveRes.inventoryItemId, productId: prospectiveRes.productId) + .queryOne() + GenericValue globalBackorder = from('WorkEffortInvRes') + .where(workEffortId: workEffortId, inventoryItemId: null, productId: prospectiveRes.productId) + .queryFirst() + + if (localizedRes) { + // Scenario A: Updating pre-existing local reservation (Fulfillment/Reallocation) + BigDecimal currentQty = localizedRes.getBigDecimal('quantity') ?: BigDecimal.ZERO + BigDecimal currentNotAvail = localizedRes.getBigDecimal('quantityNotAvailable') ?: BigDecimal.ZERO + + BigDecimal satisfactionQty = reallocatedQty.min(currentNotAvail) + BigDecimal promotionQty = reallocatedQty.subtract(satisfactionQty) + + Map updateResResult = runService('updateWorkEffortInvRes', [ + workEffortId: workEffortId, + productId: prospectiveRes.productId, + inventoryItemId: prospectiveRes.inventoryItemId, + quantity: currentQty.add(promotionQty), + quantityNotAvailable: currentNotAvail.subtract(satisfactionQty) + ]) + if (ServiceUtil.isError(updateResResult)) { + return updateResResult + } + + // Any reallocated stock localized to this task now becomes a physical promise. + if (reallocatedQty > 0) { + Map detailResult = runService('createInventoryItemDetail', [ + inventoryItemId: prospectiveRes.inventoryItemId, + workEffortId: workEffortId, + availableToPromiseDiff: reallocatedQty.negate(), + quantityOnHandDiff: BigDecimal.ZERO + ]) + if (ServiceUtil.isError(detailResult)) { + return detailResult + } + } + } else if (globalBackorder) { + // Scenario B: Satisfying a Global Backorder using this specific Item + BigDecimal boQty = globalBackorder.getBigDecimal('quantity') ?: BigDecimal.ZERO + BigDecimal boQna = globalBackorder.getBigDecimal('quantityNotAvailable') ?: BigDecimal.ZERO + + BigDecimal satisfactionQty = reallocatedQty.min(boQty) + + // 1. Localize the satisfaction to the item (reserveWorkEffortInventory handles ATP deduction) + Map reserveResult = runService('reserveWorkEffortInventory', [ + workEffortId: workEffortId, + inventoryItemId: prospectiveRes.inventoryItemId, + productId: prospectiveRes.productId, + quantity: satisfactionQty + ]) + if (ServiceUtil.isError(reserveResult)) { + return reserveResult + } + Map reserveDetailResult = runService('createInventoryItemDetail', [ + inventoryItemId: prospectiveRes.inventoryItemId, + workEffortId: workEffortId, + availableToPromiseDiff: satisfactionQty.negate(), + quantityOnHandDiff: BigDecimal.ZERO + ]) + if (ServiceUtil.isError(reserveDetailResult)) { + return reserveDetailResult + } + + // 2. Reduce the global backorder + BigDecimal remainingBo = boQty.subtract(satisfactionQty) + if (remainingBo <= 0) { + runService('deleteWorkEffortInvRes', [workEffortId: workEffortId, inventoryItemId: null, productId: prospectiveRes.productId]) + } else { + runService('updateWorkEffortInvRes', [ + workEffortId: workEffortId, inventoryItemId: null, productId: prospectiveRes.productId, + quantity: remainingBo, quantityNotAvailable: boQna.subtract(satisfactionQty).max(0.0) + ]) + } + + // 3. Handle any excess reallocated stock as a new reservation (Promotion) + BigDecimal promotionQty = reallocatedQty.subtract(satisfactionQty) + if (promotionQty > 0) { + Map promoResult = runService('reserveWorkEffortInventory', [ + workEffortId: workEffortId, + inventoryItemId: prospectiveRes.inventoryItemId, + productId: prospectiveRes.productId, + quantity: promotionQty + ]) + if (ServiceUtil.isError(promoResult)) { + return promoResult + } + Map promoDetailResult = runService('createInventoryItemDetail', [ + inventoryItemId: prospectiveRes.inventoryItemId, + workEffortId: workEffortId, + availableToPromiseDiff: promotionQty.negate(), + quantityOnHandDiff: BigDecimal.ZERO + ]) + if (ServiceUtil.isError(promoDetailResult)) { + return promoDetailResult + } + } + } else { + // Create: No existing reservation, create a new one and consume the freed ATP. + Map reserveResult = runService('reserveWorkEffortInventory', [ + workEffortId: workEffortId, + inventoryItemId: prospectiveRes.inventoryItemId, + productId: prospectiveRes.productId, + quantity: reallocatedQty + ]) + if (ServiceUtil.isError(reserveResult)) { + return reserveResult + } + Map reserveDetailResult = runService('createInventoryItemDetail', [ + inventoryItemId: prospectiveRes.inventoryItemId, + workEffortId: workEffortId, + availableToPromiseDiff: reallocatedQty.negate(), + quantityOnHandDiff: BigDecimal.ZERO + ]) + if (ServiceUtil.isError(reserveDetailResult)) { + return reserveDetailResult + } + } + } + } + } + + // Phase 3: Final Issuance (Dual-List Explicit Execution) + // We merge both lists locally to ensure that every planned item (Reserved, Pool, or Reallocated) + // is physically issued to the task. + List executionPlan = [] + executionPlan.addAll(inventoryIssuePlan ?: []) + executionPlan.addAll(impactList ?: []) + + for (Map line : executionPlan) { + BigDecimal qtyToIssue = line.quantity instanceof String + ? new BigDecimal(line.quantity) + : (line.quantity ?: (line.impactQuantity ?: BigDecimal.ZERO)) + if (qtyToIssue <= 0) { + continue + } + + // DELTA ISSUANCE GUARD: Skip informational UI placeholders or previously issued lines + if (line.type == 'ALREADY_ISSUED' || line.inventoryItemId == 'ALREADY_ISSUED' || !line.inventoryItemId) { + continue + } + + Map issueParams = [ + workEffortId: workEffortId, + productId: line.productId, + inventoryItemId: line.inventoryItemId, + quantity: qtyToIssue, + failIfItemsAreNotAvailable: parameters.failIfItemsAreNotAvailable ?: 'Y', + failIfItemsAreNotOnHand: parameters.failIfItemsAreNotOnHand ?: 'Y' + ] + + Map issueResult = runService('executeProductionRunTaskComponentAtomic', issueParams) + if (ServiceUtil.isError(issueResult)) { + logError("Phase 3: Failed to issue ${qtyToIssue} of ${line.productId} from Item " + + "${line.inventoryItemId}: ${ServiceUtil.getErrorMessage(issueResult)}") + return issueResult + } + } + // Phase 4: Purge Ghost Reservations + // Because we skipped physically issuing from items that lacked QOH, their original + // reservations were bypassed by the standard issuance loop and left hovering. + List issuedProducts = executionPlan*.productId.unique() + for (String prodId : issuedProducts) { + // GET: Total Requirement vs Total Issued (to determine if we are fully satisfied) + GenericValue wegs = from('WorkEffortGoodStandard') + .where(workEffortId: workEffortId, productId: prodId, workEffortGoodStdTypeId: 'PRUNT_PROD_NEEDED') + .queryFirst() + + BigDecimal estimated = wegs?.getBigDecimal('estimatedQuantity') ?: BigDecimal.ZERO + BigDecimal totalIssuedNow = from('WorkEffortAndInventoryAssign') + .where('workEffortId', workEffortId, 'productId', prodId) + .queryList().sum { it.getBigDecimal('quantity') ?: BigDecimal.ZERO } ?: BigDecimal.ZERO + + boolean isFullySatisfied = totalIssuedNow >= estimated + + // GET: All reservations we specifically issued against during this transaction + List explicitlySatisfiedItems = executionPlan + .findAll { it.productId == prodId && it.inventoryItemId != 'ALREADY_ISSUED' }*.inventoryItemId + + List ghosts = from('WorkEffortInvRes').where(workEffortId: workEffortId, productId: prodId).queryList() + if (ghosts) { + ghosts.each { ghost -> + // RULE: Purge if either: + // 1. The entire product requirement is now met (Fulfillment Guard) + // 2. The specific item was satisfied AND its remaining reservation quantity is now zero (Satisfaction Guard) + BigDecimal currentResQty = ghost.getBigDecimal('quantity') ?: BigDecimal.ZERO + if (isFullySatisfied || (explicitlySatisfiedItems.contains(ghost.inventoryItemId) && currentResQty <= 0)) { + releaseResult = runService('releaseProductionRunTaskComponent', [ + workEffortId: ghost.workEffortId, + productId: ghost.productId, + inventoryItemId: ghost.inventoryItemId, + quantity: ghost.getBigDecimal('quantity'), + appendInventoryItemDetail: true // MUST restore ATP when purging logical debt + ]) + if (ServiceUtil.isError(releaseResult)) { + return releaseResult + } + } + } + } + } + + return success(inventoryIssuePlan: inventoryIssuePlan, impactList: impactList, productionRunId: productionRunId) +} + +/** + * Balances manufacturing reservations by re-reserving backordered items against pool stock. + * Following the standard OFBiz pattern for order reassignment, this service: + * 1. Identifies all WorkEffortInvRes records with quantityNotAvailable > 0. + * 2. Cancels the existing reservation (restoring any physical portion to ATP). + * 3. Re-reserves the requirement using the core engine, which will prioritize physical stock. + */ +Map reassignWorkEffortInventoryReservations() { + String productId = parameters.productId + String facilityId = parameters.facilityId + String inventoryItemId = parameters.inventoryItemId + if ((!productId || !facilityId) && inventoryItemId) { + GenericValue inventoryItem = from('InventoryItem').where('inventoryItemId', inventoryItemId).queryOne() + productId = productId ?: inventoryItem?.productId + facilityId = facilityId ?: inventoryItem?.facilityId + } + if (!productId || !facilityId) { + return success(satisfiedCount: 0) + } + + Set eligibleWorkEffortIds = parameters.workEffortIds ? new HashSet(parameters.workEffortIds) : null + List activeProductionRunStatuses = ['PRUN_SCHEDULED', 'PRUN_DOC_PRINTED', 'PRUN_RUNNING'] + + // Find all backordered reservations for this product + List backorders = from('WorkEffortInvRes') + .where(EntityCondition.makeCondition('productId', EntityOperator.EQUALS, productId), + EntityCondition.makeCondition('quantityNotAvailable', EntityOperator.GREATER_THAN, BigDecimal.ZERO)) + .queryList() + .findAll { res -> + // Filter by facility (Traverse up to find the root Production Run's facility) + GenericValue task = from('WorkEffort').where('workEffortId', res.workEffortId).cache().queryOne() + String taskFacilityId = null + String statusId = null + GenericValue current = task + while (current) { + if (!taskFacilityId && current.facilityId) { + taskFacilityId = current.facilityId + } + if (!statusId && current.workEffortTypeId == 'PROD_ORDER_HEADER') { + statusId = current.currentStatusId + } + current = current.workEffortParentId + ? from('WorkEffort').where('workEffortId', current.workEffortParentId).cache().queryOne() + : null + } + return taskFacilityId == facilityId && + statusId in activeProductionRunStatuses && + (!eligibleWorkEffortIds || eligibleWorkEffortIds.contains(res.workEffortId)) + } + + if (!backorders) { + return success() + } + + // FIFO Sort: Settle oldest backorders first + backorders.sort { it.createdStamp } + + int satisfiedCount = 0 + for (GenericValue res : backorders) { + BigDecimal qty = res.getBigDecimal('quantity') ?: BigDecimal.ZERO + BigDecimal originalQna = res.getBigDecimal('quantityNotAvailable') ?: BigDecimal.ZERO + + // 1. Release the whole reservation (Debt + Physical) + // appendInventoryItemDetail: true ensures that if there was any physical backing, it's returned to the pool + // so it can be re-allocated fairly by the core engine. + Map releaseResult = runService('releaseProductionRunTaskComponent', [ + workEffortId: res.workEffortId, + productId: res.productId, + inventoryItemId: res.inventoryItemId, + quantity: qty, + appendInventoryItemDetail: true + ]) + if (ServiceUtil.isError(releaseResult)) { + return releaseResult + } + + // 2. Re-reserve using the core engine + // requireInventory: 'N' allows the engine to create a new backorder if stock is still insufficient, + // but it will pick pool stock first if available. + Map reserveResult = runService('reserveWorkEffortInventoryItem', [ + workEffortId: res.workEffortId, + productId: res.productId, + facilityId: facilityId, + quantity: qty, + requireInventory: 'N' + ]) + if (ServiceUtil.isError(reserveResult)) { + return reserveResult + } + + List updatedReservations = from('WorkEffortInvRes') + .where('workEffortId', res.workEffortId, 'productId', res.productId) + .queryList() + BigDecimal updatedQna = updatedReservations.inject(BigDecimal.ZERO) { sum, updatedRes -> + sum + (updatedRes.getBigDecimal('quantityNotAvailable') ?: BigDecimal.ZERO) + } + if (updatedQna < originalQna) { + satisfiedCount++ + } + } + + return success(satisfiedCount: satisfiedCount) +} + +/** + * Nightly job to reconcile manufacturing inventory (Optimized Performance Version). + * 1. Data-Driven Entry: Targets only records with logical debt (shortfall). + * 2. ATP Caching: Groups lookups by Product/Facility to minimize DB round-trips. + * 3. The Auditor: Purges phantom backorders for data integrity. + * 4. The Satisfier: Settles legitimate backorders using Pool ATP. + */ +Map reconcileInventoryForProductionJobs() { + // Phase 1: Identify all logical debt records (backorders) in the system + List backorders = from('WorkEffortInvRes') + .where(EntityCondition.makeCondition('quantityNotAvailable', EntityOperator.GREATER_THAN, BigDecimal.ZERO)) + .queryList() + + if (!backorders) { + return success('No backorders found.') + } + + logInfo("RECON: Found ${backorders.size()} total backorder records. System-wide logical debt detected.") + + // Phase 2: Filter by active Production Run status and group by Facility/Product + Map groupedBackorders = [:] + Map workEffortCache = [:] + + for (GenericValue res : backorders) { + String weId = res.workEffortId + if (!workEffortCache.containsKey(weId)) { + // Traverse up to find the root Production Run and its Facility + GenericValue task = from('WorkEffort').where('workEffortId', weId).queryOne() + String facilityId = null + String statusId = null + + GenericValue current = task + while (current) { + facilityId = facilityId ?: current.facilityId + if (!statusId && current.workEffortTypeId == 'PROD_ORDER_HEADER') { + statusId = current.currentStatusId + } + if (current.workEffortParentId) { + current = from('WorkEffort').where('workEffortId', current.workEffortParentId).cache().queryOne() + } else { + break + } + } + workEffortCache[weId] = [facilityId: facilityId, statusId: statusId] + } + + Map context = workEffortCache[weId] + // Only process if it belongs to an active PRUN (Scheduled, Printed, or Running) + if (context.statusId in ['PRUN_SCHEDULED', 'PRUN_DOC_PRINTED', 'PRUN_RUNNING'] && context.facilityId) { + String key = "${context.facilityId}|${res.productId}" + if (!groupedBackorders.containsKey(key)) { + groupedBackorders[key] = [] + } + groupedBackorders[key].add(res) + } + } + logInfo("RECON: Grouped into ${groupedBackorders.size()} batches.") + + int totalPurged = 0 + int totalSatisfied = 0 + Map facilityPolicyCache = [:] + + // Phase 3: Process grouped backorders by facility/product + groupedBackorders.each { key, members -> + // FIFO: Sort backorders by createdStamp (oldest first) + members.sort { it.createdStamp } + String[] parts = key.split('\\|') + String facilityId = parts[0] + String productId = parts[1] + + // Fetch Facility Policies (Cached per group) + if (!facilityPolicyCache.containsKey(facilityId)) { + facilityPolicyCache[facilityId] = from('Facility').where('facilityId', facilityId).cache().queryOne() + } + GenericValue facility = facilityPolicyCache[facilityId] + String allowReallocation = facility?.allowInventoryReallocation ?: 'N' + String enableRecon = facility?.reconcilePrunBackorders ?: 'N' + + // Track how much we've already purged for each task in this group to avoid over-purging + // when multiple reservation records exist for the same task/product. + Map purgedPerTask = [:] + + // --- PHASE 3.1: THE AUDITOR (Sanity Check) - LIFO Priority --- + // Goal: Eliminate newest "phantom" debt that exceeds legitimate requirements. + members.reverse().each { res -> + String taskId = res.workEffortId + + // We use a "Total Life Commitment" check: Issued + Reserved + QNA. + Map qtyResult = runService('getTaskQuantities', [workEffortId: taskId, productId: productId]) + BigDecimal estimated = qtyResult.requiredQuantity ?: 0 + BigDecimal issued = qtyResult.issuedQuantity ?: 0 + BigDecimal reserved = qtyResult.reservedQuantity ?: 0 + + boolean issuedOverEstimate = issued > estimated + BigDecimal commitmentCeiling = (enableRecon == 'Y' && issuedOverEstimate) ? BigDecimal.ZERO : estimated + BigDecimal totalCommitment = issued + reserved + boolean isExcess = totalCommitment > estimated + if (isExcess) { + // Account for what we've already purged for this task in this group loop + BigDecimal alreadyPurged = purgedPerTask[taskId] ?: BigDecimal.ZERO + BigDecimal totalExcess = (reserved - commitmentCeiling).max(BigDecimal.ZERO) + BigDecimal remainingToPurge = (totalExcess - alreadyPurged).max(BigDecimal.ZERO) + + BigDecimal resQna = res.getBigDecimal('quantityNotAvailable') ?: BigDecimal.ZERO + BigDecimal purgeAmount = remainingToPurge.min(resQna) + + if (purgeAmount > 0) { + logInfo("AUDITOR: Purging ${purgeAmount} phantom backorders for Task [${taskId}] " + + "Product [${productId}]. Mode: ${allowReallocation}") + + // Standard Release (Satisfied/Purged pattern): + // appendInventoryItemDetail: false because logical debt doesn't restore ATP. + Map releaseResult = runService('releaseProductionRunTaskComponent', [ + workEffortId: taskId, + productId: productId, + inventoryItemId: res.inventoryItemId, + quantity: purgeAmount, + appendInventoryItemDetail: false + ]) + if (ServiceUtil.isError(releaseResult)) { + return releaseResult + } + + alreadyPurged += purgeAmount + totalPurged += purgeAmount.intValue() + purgedPerTask[taskId] = alreadyPurged + } + } + } + + // --- PHASE 3.2: THE SATISFIER (Auto-healing) - FIFO Priority --- + // Goal: Settle legitimate backorders using newly available physical stock in the facility pool. + // Offload satisfaction and reassignment to native ledger-compliant inventory services. + if (enableRecon == 'Y') { + Map reassignResult = runService('reassignWorkEffortInventoryReservations', [ + productId: productId, + facilityId: facilityId, + workEffortIds: members*.workEffortId.unique() + ]) + if (ServiceUtil.isError(reassignResult)) { + return reassignResult + } + totalSatisfied += (reassignResult.satisfiedCount ?: 0) + } + } + + String msg = ("Nightly reconciliation completed. Analyzed ${groupedBackorders.size()} " + + "unique Product/Facility pairs. Purged: ${totalPurged}, Satisfied: ${totalSatisfied}") + logInfo(msg) + return success(msg) +} +/** + * Condition service for SECA: Checks if the facility associated with an inventory item + * has real-time backorder reconciliation enabled for production runs. + */ +Map checkProductionRunFacilityRecon() { + String inventoryItemId = parameters.inventoryItemId ?: parameters.serviceContext?.inventoryItemId + GenericValue inventoryItem = from('InventoryItem').where('inventoryItemId', inventoryItemId).queryOne() + + if (inventoryItem && inventoryItem.facilityId) { + GenericValue facility = from('Facility').where('facilityId', inventoryItem.facilityId).cache().queryOne() + if (facility && facility.reconcilePrunBackorders == 'Y') { + return success([conditionReply: true, productId: inventoryItem.productId, facilityId: inventoryItem.facilityId]) + } + } + return success(conditionReply: false) +} diff --git a/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/jobshopmgt/ProductionRunServicesScript.groovy b/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/jobshopmgt/ProductionRunServicesScript.groovy index ca7e7f8cc8..ffbf9e52c4 100644 --- a/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/jobshopmgt/ProductionRunServicesScript.groovy +++ b/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/jobshopmgt/ProductionRunServicesScript.groovy @@ -18,8 +18,9 @@ */ package org.apache.ofbiz.manufacturing.jobshopmgt -import org.apache.ofbiz.base.util.UtilDateTime import org.apache.ofbiz.entity.GenericValue +import org.apache.ofbiz.entity.condition.EntityCondition +import org.apache.ofbiz.entity.condition.EntityOperator import org.apache.ofbiz.service.ServiceUtil /** @@ -29,7 +30,7 @@ Map createProductionRunPartyAssign() { parameters.statusId = 'PRTYASGN_ASSIGNED' parameters.workEffortId = parameters.workEffortId ?: parameters.productionRunId - Map serviceResult = run service: 'assignPartyToWorkEffort', with: parameters + Map serviceResult = runService('assignPartyToWorkEffort', parameters) if (ServiceUtil.isError(serviceResult)) { return serviceResult } @@ -49,7 +50,7 @@ Map createProductionRunAssoc() { serviceContext.workEffortIdTo = parameters.productionRunIdTo } - Map serviceResult = run service: 'createWorkEffortAssoc', with: serviceContext + Map serviceResult = runService('createWorkEffortAssoc', serviceContext) if (ServiceUtil.isError(serviceResult)) { return serviceResult } @@ -64,41 +65,108 @@ Map issueProductionRunTask() { parameters.failIfItemsAreNotAvailable = parameters.failIfItemsAreNotAvailable ?: 'Y' parameters.failIfItemsAreNotOnHand = parameters.failIfItemsAreNotOnHand ?: 'Y' if (workEffort && 'PRUN_CANCELLED' != workEffort.currentStatusId) { - from('WorkEffortGoodStandard') + List components = from('WorkEffortGoodStandard') .where(workEffortId: workEffort.workEffortId, statusId: 'WEGS_CREATED', workEffortGoodStdTypeId: 'PRUNT_PROD_NEEDED') .filterByDate() .queryList() - .each { component -> + + // 1. GLOBAL PRE-CHECK: Prevent partial issuance if any component is missing and strict mode is active + if (parameters.failIfItemsAreNotAvailable == 'Y' || parameters.failIfItemsAreNotOnHand == 'Y') { + for (GenericValue component : components) { + if (!component.productId) { + continue + } + BigDecimal needed = component.estimatedQuantity ?: 0.0 + BigDecimal totalIssuance = from('WorkEffortAndInventoryAssign') + .where(workEffortId: workEffort.workEffortId, productId: component.productId) + .queryList() + .sum { it.getBigDecimal('quantity') ?: 0.0 } ?: 0.0 + BigDecimal quantityToIssue = needed - totalIssuance + + if (quantityToIssue <= 0) { + continue + } + + // Check availability by summing positive item-level contributions plus our existing reservations + List inventoryItems = from('InventoryItem') + .where(EntityCondition.makeCondition([ + EntityCondition.makeCondition(productId: component.productId, facilityId: workEffort.facilityId), + EntityCondition.makeCondition([ + EntityCondition.makeCondition('availableToPromiseTotal', EntityOperator.GREATER_THAN, BigDecimal.ZERO), + EntityCondition.makeCondition('quantityOnHandTotal', EntityOperator.GREATER_THAN, BigDecimal.ZERO) + ], EntityOperator.OR) + ], EntityOperator.AND)) + .queryList() + + // UNIVERSAL AVAILABILITY CHECK: + // We sum up: + // 1. "Free" ATP from all items (atp > 0). + // 2. Reservations already held by THIS task (which are "Pre-paid" ATP). + // This ensures we don't fail-fast if the only reason ATP is 0 is because we already reserved it. + BigDecimal totalAvailableToUsAtp = inventoryItems.sum { + BigDecimal atp = it.getBigDecimal('availableToPromiseTotal') ?: 0.0 + return atp > 0 ? atp : 0.0 + } ?: 0.0 + + BigDecimal totalAvailableToUsQoh = inventoryItems.sum { + BigDecimal qoh = it.getBigDecimal('quantityOnHandTotal') ?: 0.0 + return qoh > 0 ? qoh : 0.0 + } ?: 0.0 + + // Account for our own reservations (which are deducted from Item ATP but still available to us) + from('WorkEffortInvRes') + .where(workEffortId: workEffort.workEffortId, productId: component.productId) + .queryList() + .each { res -> + BigDecimal resQty = res.getBigDecimal('quantity') ?: 0.0 + BigDecimal notAvail = res.getBigDecimal('quantityNotAvailable') ?: 0.0 + totalAvailableToUsAtp += (resQty - notAvail) + } + + // If Fail-Fast is ON, compare current need against our "Universal Pool" (Free + Promised) + if ((parameters.failIfItemsAreNotAvailable == 'Y' && quantityToIssue > totalAvailableToUsAtp) + || (parameters.failIfItemsAreNotOnHand == 'Y' && quantityToIssue > totalAvailableToUsQoh)) { + logError("issueProductionRunTask - Pre-check Fail: Product ${component.productId}. Need: " + + "${quantityToIssue}, Effective ATP: ${totalAvailableToUsAtp}, Effective QOH: " + + "${totalAvailableToUsQoh}") + GenericValue product = from('Product').where(productId: component.productId).cache().queryOne() + Map paramMap = [productId: component.productId, + internalName: product ? product.internalName : '', + parameters: parameters] + return error(label('ManufacturingUiLabels', 'ManufacturingMaterialsNotAvailable', paramMap)) + } + } + } + + // 2. ACTUAL ISSUANCE LOOP + components.each { component -> if (component.productId) { Map callSvcMap = component.getAllFields() - BigDecimal totalIssuance = 0.0 - from('WorkEffortAndInventoryAssign') - .where(workEffortId: workEffort.workEffortId, - productId: component.productId) + BigDecimal totalIssuance = from('WorkEffortAndInventoryAssign') + .where(workEffortId: workEffort.workEffortId, productId: component.productId) .queryList() - .findAll { it.quantity } - .each { issuance -> - totalIssuance += issuance.quantity - } - if (totalIssuance != 0) { - callSvcMap.quantity = component.estimatedQuantity - totalIssuance - } - callSvcMap.reserveOrderEnumId = parameters.reserveOrderEnumId - callSvcMap.description = 'BOM Part' - callSvcMap.failIfItemsAreNotAvailable = parameters.failIfItemsAreNotAvailable - callSvcMap.failIfItemsAreNotOnHand = parameters.failIfItemsAreNotOnHand - - Map serviceResult = run service: 'issueProductionRunTaskComponent', with: callSvcMap - if (ServiceUtil.isError(serviceResult)) { - return serviceResult + .sum { it.getBigDecimal('quantity') ?: 0.0 } ?: 0.0 + + callSvcMap.quantity = (component.getBigDecimal('estimatedQuantity') ?: 0.0) - totalIssuance + + if ((callSvcMap.quantity ?: 0.0) > 0) { + callSvcMap.reserveOrderEnumId = parameters.reserveOrderEnumId + callSvcMap.description = 'BOM Part' + callSvcMap.failIfItemsAreNotAvailable = parameters.failIfItemsAreNotAvailable + callSvcMap.failIfItemsAreNotOnHand = parameters.failIfItemsAreNotOnHand + + Map serviceResult = run service: 'issueProductionRunTaskComponent', with: callSvcMap + if (ServiceUtil.isError(serviceResult)) { + return serviceResult + } } } - logInfo("Issued inventory for workEffortId ${workEffort.workEffortId}") } } - return success() + return success(label('ManufacturingUiLabels', 'ManufacturingProductionRunTaskIssuedSuccessfully', + [workEffortId: parameters.workEffortId])) } /** @@ -106,39 +174,117 @@ Map issueProductionRunTask() { */ Map issueProductionRunTaskComponent() { GenericValue workEffort = from('WorkEffort').where(workEffortId: parameters.workEffortId).queryOne() + + // POLICY GATE: Early enforcement + String facilityId = parameters.facilityId ?: workEffort?.facilityId + GenericValue facility = from('Facility').where(facilityId: facilityId).queryOne() + if (parameters.failIfItemsAreNotAvailable == 'N' && facility?.allowInventoryReallocation == 'N') { + return ServiceUtil.returnError('Inventory Policy forbids inventory reallocation for this facility [' + + facilityId + ']. Reallocation mode (failIfItemsAreNotAvailable=N) cannot be used.') + } + GenericValue productionRun = from('WorkEffort').where(workEffortId: workEffort.workEffortParentId).queryOne() if (['PRUN_CANCELLED', 'PRUN_CLOSED'].contains(productionRun.currentStatusId)) { return error(label('ManufacturingUiLabels', 'ManufacturingAddProdCompInCompCanStatusError')) } String productId = parameters.productId GenericValue workEffortGoodStandard = null - BigDecimal estimatedQuantity = parameters.quantity ?: 0.0 + if (parameters.fromDate) { Map wegsPk = [workEffortId: parameters.workEffortId, productId: productId, fromDate: parameters.fromDate, workEffortGoodStdTypeId: 'PRUNT_PROD_NEEDED'] workEffortGoodStandard = from('WorkEffortGoodStandard').where(wegsPk).queryOne() - estimatedQuantity = estimatedQuantity ?: workEffortGoodStandard?.estimatedQuantity - - if (!workEffortGoodStandard) { - run service: 'createWorkEffortGoodStandard', with: [*: wegsPk, - estimatedQuantity: estimatedQuantity, - statusId: 'WEGS_CREATED'] - // if the task is in completed status we want to make WEIA for the added product as well - if (workEffort.currentStatusId == 'PRUN_COMPLETED') { - workEffortGoodStandard = from('WorkEffortGoodStandard').where(wegsPk).queryOne() - } + } + + // Fallback: If fromDate is missing, try finding based on product identity + workEffortGoodStandard = workEffortGoodStandard ?: from('WorkEffortGoodStandard') + .where(workEffortId: parameters.workEffortId, productId: productId, workEffortGoodStdTypeId: 'PRUNT_PROD_NEEDED') + .queryFirst() + + if (workEffortGoodStandard) { + BigDecimal estimatedQuantity = parameters.quantity ?: workEffortGoodStandard.estimatedQuantity ?: 0.0 + // Initialize the counter for total needed to issue + parameters.quantityNotIssued = estimatedQuantity + // Ensure strategy and failure flags are defaulted early for all subsequent calls and reconciliation + parameters.reserveOrderEnumId = parameters.reserveOrderEnumId ?: 'INVRO_FIFO_REC' + parameters.failIfItemsAreNotAvailable = parameters.failIfItemsAreNotAvailable ?: 'Y' + parameters.failIfItemsAreNotOnHand = parameters.failIfItemsAreNotOnHand ?: 'Y' + + // Component Issuance Lifecycle: + // 1. Consume existing reservations for this component on the task first. + // 2. If additional quantity is needed, find available InventoryItems based on strategy. + // 3. Create issuance records and decrease inventory for the total consumed quantity. + + // Step 1: Check for Manual Overrides (Execution Override) + Map overrideResult = handleManualIssuanceOverride(parameters) + if (overrideResult && !overrideResult.isEmpty()) { + return overrideResult + } + + // Step 2: Default Reservation-First Flow (No Override) + Map resCond = [workEffortId: parameters.workEffortId, productId: productId] + if (parameters.inventoryItemId) { + resCond.inventoryItemId = parameters.inventoryItemId } + List resList = from('WorkEffortInvRes').where(resCond).queryList() + + resList.each { reservation -> + if (parameters.quantityNotIssued > 0) { + GenericValue inventoryItem = from('InventoryItem').where(inventoryItemId: reservation.inventoryItemId).queryOne() + if (inventoryItem) { + BigDecimal resQty = reservation.getBigDecimal('quantity') ?: 0.0 + BigDecimal availableQoh = inventoryItem.getBigDecimal('quantityOnHandTotal') ?: 0.0 - // kind of like the inventory reservation routine, find InventoryItems to issue from, - // but instead of doing the reservation just create an issuance and an inventory item detail for the change - if (workEffortGoodStandard) { - String orderBy = '+datetimeReceived' - nowTimestamp = UtilDateTime.nowTimestamp() + /* + * Example: + * - Component Needs: 60 (quantityNotIssued) + * - Item 10197: Reserved=60 (resQty), Physically On Hand=50 (availableQoh) + * - Calculation: + * 1. Initial issueQty = 60 (Min of Need and Reserved) + * 2. Capped issueQty = 50 (Limited by availableQoh) + * 3. Remaining Need = 10 (60 - 50) -> This allows the loop to pick up the next item in the plan. + */ + BigDecimal issueQty = parameters.quantityNotIssued > resQty ? resQty : parameters.quantityNotIssued + if (parameters.failIfItemsAreNotOnHand == 'Y' && issueQty > availableQoh) { + logInfo('issueProductionRunTaskComponent - Step 2: Capping reservation issuance for Item: ' + + "${reservation.inventoryItemId} to available QOH: ${availableQoh} " + + "(Reserved: ${resQty})") + issueQty = availableQoh + } + + if (issueQty > 0) { + Map issueParams = [*:parameters, inventoryItemId: reservation.inventoryItemId, + quantityNotIssued: issueQty, useReservedItems: 'Y'] + issueProductionRunTaskComponentInline(issueParams, inventoryItem, null) + + parameters.quantityNotIssued -= issueQty + } else if (resQty > 0) { + // Self-Healing: If we have a reservation but no QOH, it's a "ghost". Clean it up. + logInfo('issueProductionRunTaskComponent - Step 2: Ghost Reservation detected for Item: ' + + "${reservation.inventoryItemId} (Reserved: ${resQty}, QOH: 0). Releasing now.") + run service: 'releaseProductionRunTaskComponent', with: [ + workEffortId: parameters.workEffortId, + inventoryItemId: reservation.inventoryItemId, + productId: productId, + quantity: resQty + ] + } + } + } + } - // before we do the find, put together the orderBy list based on which reserveOrderEnumId is specified + // Step 3: Pool Stock Issuance. + if (parameters.quantityNotIssued > 0) { + String orderBy = '' switch (parameters.reserveOrderEnumId) { + case 'INVRO_COST_DESC': + orderBy = '-unitCost' + break + case 'INVRO_COST_ASC': + orderBy = '+unitCost' + break case 'INVRO_FIFO_EXP': orderBy = '+expireDate' break @@ -148,110 +294,180 @@ Map issueProductionRunTaskComponent() { case 'INVRO_LIFO_REC': orderBy = '-datetimeReceived' break + case 'INVRO_FIFO_REC': + orderBy = '+datetimeReceived' + break default: - parameters.reserveOrderEnumId = 'INVRO_FIFO_REC' + orderBy = '+datetimeReceived' break } - Map lookupFieldMap = [productId: productId, - facilityId: workEffort.facilityId] - if (parameters.lotId) { - parameters.failIfItemsAreNotAvailable = 'Y' - lookupFieldMap.lotId = parameters.lotId - } - if (parameters.locationSeqId) { - lookupFieldMap.locationSeqId = parameters.locationSeqId + + List poolCond = [ + EntityCondition.makeCondition(productId: productId, facilityId: workEffort.facilityId), + EntityCondition.makeCondition([ + EntityCondition.makeCondition('availableToPromiseTotal', EntityOperator.GREATER_THAN, + BigDecimal.ZERO), + EntityCondition.makeCondition('quantityOnHandTotal', EntityOperator.GREATER_THAN, BigDecimal.ZERO) + ], EntityOperator.OR) + ] + + if (parameters.inventoryItemId) { + poolCond << EntityCondition.makeCondition(inventoryItemId: parameters.inventoryItemId) } + List inventoryItemList = from('InventoryItem') - .where(lookupFieldMap) + .where(EntityCondition.makeCondition(poolCond, EntityOperator.AND)) .orderBy(orderBy) .queryList() - if (parameters.locationSeqId && parameters.secondaryLocationSeqId) { - lookupFieldMap.locationSeqId = parameters.locationSeqId - inventoryItemList << from('InventoryItem') - .where(lookupFieldMap) - .orderBy(orderBy) - .queryList() + // PRE-CHECK: Early Fail-Fast Validation + // We sum up the "Universal Pool" for this specific product across the whole facility: + // 1. FREE ATP: Any items with positive availability today. + // 2. OUR PROMISES: Any inventory explicitly reserved for THIS production task. + // This ensures that 'Strict' fulfillment mode (failIfItemsAreNotAvailable=Y) correctly recognizes its own reserved stock. + BigDecimal totalAvailableAtp = inventoryItemList.sum { + BigDecimal itemAtp = it.getBigDecimal('availableToPromiseTotal') ?: 0.0 + // Look up our reservation on this specific item + BigDecimal ourRes = from('WorkEffortInvRes') + .where(workEffortId: parameters.workEffortId, inventoryItemId: it.inventoryItemId, + productId: productId) + .queryOne()?.getBigDecimal('quantity') ?: 0.0 + // Logic: Use the free pool (if any) PLUS whatever is promised to us. + BigDecimal usableFromThis = (itemAtp > 0 ? itemAtp : 0.0) + ourRes + // PHYSICAL INTEGRITY CAPPING: Do not promise more than we physically have in Normal mode. + BigDecimal qohValue = it.getBigDecimal('quantityOnHandTotal') ?: 0.0 + if (parameters.failIfItemsAreNotOnHand == 'Y' && usableFromThis > qohValue) { + usableFromThis = qohValue + } + return usableFromThis + } ?: 0.0 + + BigDecimal totalPhysicalQoh = inventoryItemList.sum { + (it.getBigDecimal('quantityOnHandTotal') && it.getBigDecimal('quantityOnHandTotal') > 0) + ? it.getBigDecimal('quantityOnHandTotal') : 0.0 + } ?: 0.0 + + if ((parameters.failIfItemsAreNotAvailable == 'Y' && parameters.quantityNotIssued > totalAvailableAtp) || + (parameters.failIfItemsAreNotOnHand == 'Y' && parameters.quantityNotIssued > totalPhysicalQoh)) { + GenericValue product = from('Product').where(productId: productId).cache().queryOne() + Map paramMap = [productId: productId, + internalName: product ? product.internalName : '', + parameters: parameters] + String errorMsg = label('ManufacturingUiLabels', 'ManufacturingMaterialsNotAvailable', paramMap) + logError('issueProductionRunTaskComponent - FAILED: ' + + "${errorMsg}. Shortfall: ${parameters.quantityNotIssued} for Product [${productId}]") + return error(errorMsg) } GenericValue lastNonSerInventoryItem = null - parameters.quantityNotIssued = estimatedQuantity - parameters.useReservedItems = 'N' - inventoryItemList.each { inventoryItem -> - issueProductionRunTaskComponentInline(parameters, inventoryItem, lastNonSerInventoryItem) - } + if (parameters.quantityNotIssued > 0) { + // Flexible mode (consuming QOH) is only allowed if BOTH the task requests it (failIfItemsAreNotAvailable=N) + // AND the facility policy permits it (allowInventoryReallocation=Y). + boolean isFlexible = (parameters.failIfItemsAreNotAvailable == 'N' && (facility?.allowInventoryReallocation ?: 'N') == 'Y') + BigDecimal itemAtp = inventoryItem.getBigDecimal('availableToPromiseTotal') ?: 0.0 + GenericValue resLookup = from('WorkEffortInvRes') + .where(workEffortId: parameters.workEffortId, inventoryItemId: inventoryItem.inventoryItemId, productId: productId) + .queryOne() + BigDecimal ourRes = resLookup?.getBigDecimal('quantity') ?: 0.0 + + BigDecimal availableToUs = isFlexible ? + (inventoryItem.getBigDecimal('quantityOnHandTotal') ?: 0.0) : + ((itemAtp > 0 ? itemAtp : 0.0) + ourRes) - if (parameters.failIfItemsAreNotAvailable != 'Y' && parameters.quantityNotIssued != 0) { - parameters.useReservedItems = 'Y' - inventoryItemList.each { inventoryItem -> - if (parameters.quantityNotIssued > 0) { - inventoryItem.refresh() - issueProductionRunTaskComponentInline(parameters, inventoryItem, lastNonSerInventoryItem) + // STRICT PHYSICAL CAPPING: If we cannot go negative on hand, cap our availability by the physical QOH. + BigDecimal qoh = inventoryItem.getBigDecimal('quantityOnHandTotal') ?: 0.0 + if (parameters.failIfItemsAreNotOnHand == 'Y' && availableToUs > qoh) { + availableToUs = qoh + } + + if (availableToUs > 0) { + BigDecimal issueQty = parameters.quantityNotIssued > availableToUs ? availableToUs : parameters.quantityNotIssued + + Map issueParams = [*:parameters, inventoryItemId: inventoryItem.inventoryItemId, + quantityNotIssued: issueQty, useReservedItems: 'N'] + Map inlineResult = issueProductionRunTaskComponentInline(issueParams, inventoryItem, null) + if (ServiceUtil.isError(inlineResult)) { + // Propagate policy violation errors from inline call + return inlineResult + } + parameters.quantityNotIssued -= issueQty + } + + if (inventoryItem.inventoryItemTypeId == 'NON_SERIAL_INV_ITEM') { + lastNonSerInventoryItem = inventoryItem } } } - // if quantityNotIssued is not 0, then pull it from the last non-serialized inventory item found, in the quantityNotIssued field - if (parameters.quantityNotIssued != 0) { - if (parameters.failIfItemsAreNotAvailable == 'Y' || !parameters.failIfItemsAreNotOnHand) { - GenericValue product - if (productId) { - product = from('Product').where(productId: productId).cache().queryOne() - } + + // Step 4: Final Fallback - Forced Creation (Last Resort for non-strict modes) + if (parameters.quantityNotIssued > 0) { + if (parameters.failIfItemsAreNotAvailable == 'Y' || parameters.failIfItemsAreNotOnHand == 'Y') { + GenericValue product = from('Product').where(productId: productId).cache().queryOne() Map paramMap = [productId: productId, - internalName: product ? product.internalName : '', - parameters: parameters] - return ServiceUtil.returnError(label('ManufacturingUiLabels', 'ManufacturingMaterialsNotAvailable', paramMap)) + internalName: product ? product.internalName : '', + parameters: parameters] + String errorMsg = label('ManufacturingUiLabels', 'ManufacturingMaterialsNotAvailable', paramMap) + logError('issueProductionRunTaskComponent - FAILED: ' + + "${errorMsg}. Shortfall: ${parameters.quantityNotIssued} for Product [${productId}]") + return error(errorMsg) } + + // If we got here, it means both fail flags were 'N', allowing negative issuance on the last non-serialized item if (lastNonSerInventoryItem) { run service: 'assignInventoryToWorkEffort', with: [workEffortId: parameters.workEffortId, - inventoryItemId: lastNonSerInventoryItem.inventoryItemId, + inventoryItemId: lastNonSerInventoryItem.inventoryItemId, quantity: parameters.quantityNotIssued] // subtract from quantityNotIssued from the availableToPromise and quantityOnHand of existing inventory item // instead of updating InventoryItem, add an InventoryItemDetail run service: 'createInventoryItemDetail', with: [inventoryItemId: lastNonSerInventoryItem.inventoryItemId, - workEffortId: parameters.workEffortId, - availableToPromiseDiff: -parameters.quantityNotIssued, - quantityOnHandDiff: -parameters.quantityNotIssued, - reasonEnumId: parameters.reasonEnumId, + workEffortId: parameters.workEffortId, + availableToPromiseDiff: -parameters.quantityNotIssued, + quantityOnHandDiff: -parameters.quantityNotIssued, + reasonEnumId: parameters.reasonEnumId, description: parameters.description] run service: 'balanceInventoryItems', with: [inventoryItemId: lastNonSerInventoryItem.inventoryItemId] } else { // no non-ser inv item, create a non-ser InventoryItem with availableToPromise = -quantityNotIssued Map serviceResult = run service: 'createInventoryItem', with: [productId: productId, - facilityId: workEffort.facilityId, + facilityId: workEffort.facilityId, inventoryItemTypeId: 'NON_SERIAL_INV_ITEM'] String inventoryItemId = serviceResult.inventoryItemId run service: 'assignInventoryToWorkEffort', with: [workEffortId: workEffort.workEffortId, - inventoryItemId: inventoryItemId, + inventoryItemId: inventoryItemId, quantity: parameters.quantityNotIssued] // also create a detail record with the quantities run service: 'createInventoryItemDetail', with: [workEffortId: workEffort.workEffortId, - inventoryItemId: inventoryItemId, - availableToPromiseDiff: -parameters.quantityNotIssued, - quantityOnHandDiff: -parameters.quantityNotIssued, - reasonEnumId: parameters.reasonEnumId, - description: parameters.description] + inventoryItemId: inventoryItemId, + availableToPromiseDiff: -parameters.quantityNotIssued, + quantityOnHandDiff: -parameters.quantityNotIssued, + reasonEnumId: parameters.reasonEnumId, + description: parameters.description] parameters.quantityNotIssued = 0.0 } } + } - BigDecimal totalIssuance = 0.0 - from('WorkEffortAndInventoryAssign') - .where(workEffortId: workEffortGoodStandard.workEffortId, - productId: workEffortGoodStandard.productId) - .queryList() - .each { issuance -> - totalIssuance += issuance.quantity - } - if (workEffortGoodStandard.estimatedQuantity <= totalIssuance) { - workEffortGoodStandard.statusId = 'WEGS_COMPLETED' - workEffortGoodStandard.store() - } + // Final BOM Status Reconciliation + BigDecimal totalIssuance = BigDecimal.ZERO + from('WorkEffortAndInventoryAssign') + .where(workEffortId: workEffortGoodStandard.workEffortId, + productId: workEffortGoodStandard.productId) + .queryList() + .each { issuance -> + totalIssuance = totalIssuance.add(issuance.getBigDecimal('quantity') ?: BigDecimal.ZERO) + } + if (workEffortGoodStandard.getBigDecimal('estimatedQuantity') <= totalIssuance) { + workEffortGoodStandard.statusId = 'WEGS_COMPLETED' + workEffortGoodStandard.store() } + } else { + logInfo('issueProductionRunTaskComponent - SKIPPING ISSUANCE: No WorkEffortGoodStandard ' + + "(BOM Component) record found for Task [${parameters.workEffortId}] Product [${productId}]. " + + 'Force Issue only works for planned BOM items.') } return success() } @@ -262,54 +478,180 @@ Map issueProductionRunTaskComponent() { Map issueProductionRunTaskComponentInline(Map parameters, GenericValue inventoryItem, GenericValue lastNonSerInventoryItem) { - if (parameters.quantityNotIssued > 0) { if (inventoryItem.inventoryItemTypeId == 'SERIALIZED_INV_ITEM' && inventoryItem.statusId == 'INV_AVAILABLE') { inventoryItem.statusId = 'INV_DELIVERED' inventoryItem.store() Map serviceResult = run service: 'assignInventoryToWorkEffort', with: [workEffortId: parameters.workEffortId, - inventoryItemId: parameters.inventoryItemId, + inventoryItemId: parameters.inventoryItemId, quantity: 1] if (ServiceUtil.isError(serviceResult)) { return serviceResult } - parameters.quantityNotIssued = parameters.quantityNotIssued - 1 + parameters.quantityNotIssued = ((BigDecimal) parameters.quantityNotIssued).subtract(BigDecimal.ONE) } if ((!inventoryItem.statusId || inventoryItem.statusId == 'INV_AVAILABLE') && inventoryItem.inventoryItemTypeId == 'NON_SERIAL_INV_ITEM') { - BigDecimal inventoryItemQuantity = parameters.useReservedItems == 'Y' ? - inventoryItem.quantityOnHandTotal : - inventoryItem.availableToPromiseTotal - - // reduce atp on inventoryItem if availableToPromise greater than 0, if not the code at the end of this method will handle it - if (inventoryItemQuantity && inventoryItemQuantity > 0) { - parameters.deductAmount = parameters.quantityNotIssued > inventoryItemQuantity ? - inventoryItemQuantity : - parameters.quantityNotIssued - Map serviceResult = run service: 'assignInventoryToWorkEffort', with: [workEffortId: parameters.workEffortId, - inventoryItemId: inventoryItem.inventoryItemId, - quantity: parameters.deductAmount] - if (ServiceUtil.isError(serviceResult)) { - return serviceResult - } + // PHYSICAL REFRESH: Ensure we have the latest ledger values in this atomic pass + inventoryItem.refresh() + + // Smart Availability: We can always use what we've already reserved, plus the pool + GenericValue ourRes = from('WorkEffortInvRes') + .where(workEffortId: parameters.workEffortId, inventoryItemId: inventoryItem.inventoryItemId, + productId: parameters.productId ?: inventoryItem.productId) + .queryOne() + BigDecimal ourResQty = ourRes?.getBigDecimal('quantity') ?: 0.0 + BigDecimal ourResQna = ourRes?.getBigDecimal('quantityNotAvailable') ?: 0.0 + BigDecimal ourPhysicalResQty = ourResQty.subtract(ourResQna).max(BigDecimal.ZERO) + + // LEDGER AWARENESS: Find any backordered reservation (unlocalized) for the same task/product. + // When we issue from an item, we must satisfy these backorders to prevent "Logical Debt" double-counting. + GenericValue ourBackorder = from('WorkEffortInvRes') + .where(workEffortId: parameters.workEffortId, inventoryItemId: null, + productId: parameters.productId ?: inventoryItem.productId) + .queryFirst() + + // Guard Logic: How much can we safely issue right now? + // Facility Check: Does the facility allow reallocation? + GenericValue task = from('WorkEffort').where(workEffortId: parameters.workEffortId).queryOne() + GenericValue facility = task ? from('Facility').where(facilityId: task.facilityId).queryOne() : null + String allowReallocation = facility?.allowInventoryReallocation ?: 'N' - serviceResult = run service: 'createInventoryItemDetail', with: [inventoryItemId: inventoryItem.inventoryItemId, - workEffortId: parameters.workEffortId, - availableToPromiseDiff: -parameters.deductAmount, - quantityOnHandDiff: -parameters.deductAmount, - reasonEnumId: parameters.reasonEnumId, - description: parameters.description] - if (ServiceUtil.isError(serviceResult)) { - return serviceResult + BigDecimal effectiveAvailable = (inventoryItem.getBigDecimal('availableToPromiseTotal') ?: BigDecimal.ZERO) + ourPhysicalResQty + BigDecimal inventoryItemQuantity + + // POLICY GATE: Traditional (Strict) vs Fluid (Reallocation) + if (allowReallocation == 'N') { + // TRADITIONAL: Strictly limited to what is FREE (ATP + Our Res). + // We cannot reallocate from others even if failIfItemsAreNotAvailable is 'N'. + inventoryItemQuantity = effectiveAvailable + } else if (parameters.failIfItemsAreNotAvailable == 'N') { + // FLEXIBLE: Unlimited issuance (allows going into negative QOH) + inventoryItemQuantity = BigDecimal.valueOf(9999999) + } else { + // FLUID (Reallocation Allowed): Can take anything physical (QOH) + inventoryItemQuantity = inventoryItem.getBigDecimal('quantityOnHandTotal') ?: BigDecimal.ZERO + } + + // PHYSICAL INTEGRITY GUARD: + if (inventoryItemQuantity < parameters.quantityNotIssued) { + // If we are in a Traditional warehouse, this error is a Policy violation. + // In a Fluid warehouse, this only happens if failIfItemsAreNotAvailable is 'Y' and we ran out of QOH. + String errorPrefix = (allowReallocation == 'N') ? 'Inventory Policy forbids inventory reallocation' + : 'Inventory Shortfall' + return ServiceUtil.returnError("${errorPrefix}: Item [${inventoryItem.inventoryItemId}] " + + 'has insufficient capacity ' + + "(Issuable: ${inventoryItemQuantity}). ATP: ${inventoryItem.availableToPromiseTotal}, " + + "QOH: ${inventoryItem.quantityOnHandTotal}, Our Physical Reservation: ${ourPhysicalResQty}. " + + 'Atomic issuance cannot proceed.') + } + + BigDecimal deductAmount = parameters.quantityNotIssued > inventoryItemQuantity + ? inventoryItemQuantity : (BigDecimal) parameters.quantityNotIssued + + if (deductAmount <= 0) { + return success() + } + + Map serviceResult = runService('assignInventoryToWorkEffort', [workEffortId: parameters.workEffortId, + inventoryItemId: inventoryItem.inventoryItemId, + quantity: deductAmount]) + if (ServiceUtil.isError(serviceResult)) { + return serviceResult + } + + // SMART ATP AWARENESS: Determining Ledger Impact + // Scenario A (Fulfillment): If we hold a reservation for THIS task on THIS item, + // we've already "paid" for the ATP. Deduction should be 0 to avoid double-counting. + // Scenario B (Reallocation/Pool): If we have no reservation, we are consuming "Fresh" availability. + // Deduction should be -FullQty. (Any impacted tasks released during reconciliation will then restore + // the ledger to 0, preventing stale backorder ATP). + + // Only subtract what hasn't already been promised (reserved) to this work effort. + BigDecimal atpImpact = (deductAmount - ourPhysicalResQty).max(0.0) + BigDecimal atpDiff = -atpImpact + + serviceResult = runService('createInventoryItemDetail', [inventoryItemId: inventoryItem.inventoryItemId, + workEffortId: parameters.workEffortId, + availableToPromiseDiff: atpDiff, + quantityOnHandDiff: -deductAmount, + reasonEnumId: parameters.reasonEnumId, + description: parameters.description]) + if (ServiceUtil.isError(serviceResult)) { + return serviceResult + } + + // CONSUME OWN RESERVATION: If we had a reservation for this task, we must reduce it + // because we just fulfilled it physically. + if (ourResQty > 0) { + BigDecimal qnaSatisfied = deductAmount.min(ourResQna) + BigDecimal remainingQna = ourResQna.subtract(qnaSatisfied).max(BigDecimal.ZERO) + BigDecimal remainingRes = ourResQty.subtract(deductAmount).max(BigDecimal.ZERO) + remainingQna = remainingQna.min(remainingRes) + + if (remainingRes <= 0) { + run service: 'deleteWorkEffortInvRes', with: [ + workEffortId: parameters.workEffortId, + inventoryItemId: inventoryItem.inventoryItemId, + productId: parameters.productId ?: inventoryItem.productId + ] + } else { + run service: 'updateWorkEffortInvRes', with: [ + workEffortId: parameters.workEffortId, + inventoryItemId: inventoryItem.inventoryItemId, + productId: parameters.productId ?: inventoryItem.productId, + quantity: remainingRes, + quantityNotAvailable: remainingQna + ] } - parameters.quantityNotIssued -= parameters.deductAmount - serviceResult = run service: 'balanceInventoryItems', with: [inventoryItemId: inventoryItem.inventoryItemId] - if (ServiceUtil.isError(serviceResult)) { - return serviceResult + } + + // SATISFY GLOBAL BACKORDER: If we held a global backorder (null item), reduce it now + // because we've physically fulfilled the requirement. + if (ourBackorder) { + BigDecimal boQty = ourBackorder.getBigDecimal('quantity') ?: 0.0 + BigDecimal boQna = ourBackorder.getBigDecimal('quantityNotAvailable') ?: 0.0 + // How much of this issuance satisfies the backorder? + BigDecimal satisfactionQty = deductAmount.min(boQty) + BigDecimal remainingBo = boQty - satisfactionQty + + if (remainingBo <= 0) { + run service: 'deleteWorkEffortInvRes', with: [ + workEffortId: parameters.workEffortId, + inventoryItemId: null, + productId: parameters.productId ?: inventoryItem.productId + ] + } else { + // Update: Reduce the logical debt + run service: 'updateWorkEffortInvRes', with: [ + workEffortId: parameters.workEffortId, + inventoryItemId: null, + productId: parameters.productId ?: inventoryItem.productId, + quantity: remainingBo, + quantityNotAvailable: (boQna - satisfactionQty).max(0.0) + ] } - lastNonSerInventoryItem = inventoryItem } + + // RECONCILIATION HOOK: If we issued stock that was promised to others, reconcile those reservations. + // Find Facility Policy for the current task + GenericValue taskWe = from('WorkEffort').where(workEffortId: parameters.workEffortId).queryOne() + GenericValue taskFac = taskWe ? from('Facility').where(facilityId: taskWe.facilityId).queryOne() : null + String taskAllowReallocation = taskFac?.allowInventoryReallocation ?: 'N' + + // Robustly resolve strategy (Fallback to FIFO if missing) + String strategy = parameters.reserveOrderEnumId ?: 'INVRO_FIFO_REC' + + reconcileGlobalReservationsInternal(inventoryItem.inventoryItemId, atpImpact, + parameters.workEffortId, strategy, parameters.failIfItemsAreNotAvailable, taskAllowReallocation) + + parameters.quantityNotIssued = ((BigDecimal) parameters.quantityNotIssued).subtract(deductAmount) + serviceResult = runService('balanceInventoryItems', [inventoryItemId: inventoryItem.inventoryItemId]) + if (ServiceUtil.isError(serviceResult)) { + return serviceResult + } + lastNonSerInventoryItem = inventoryItem } } return success() @@ -319,42 +661,465 @@ Map issueProductionRunTaskComponentInline(Map parameters, *Issue one InventoryItem to a WorkEffort */ Map issueInventoryItemToWorkEffort() { + String workEffortId = parameters.workEffortId GenericValue inventoryItem = parameters.inventoryItem BigDecimal quantityIssued = 0.0 + if (inventoryItem.inventoryItemTypeId == 'SERIALIZED_INV_ITEM' && inventoryItem.statusId) { inventoryItem.statusId = 'INV_DELIVERED' inventoryItem.store() quantityIssued = 1 - Map serviceResult = run service: 'assignInventoryToWorkEffort', with: [workEffortId: parameters.workEffortId, + Map serviceResult = run service: 'assignInventoryToWorkEffort', with: [workEffortId: workEffortId, inventoryItemId: inventoryItem.inventoryItemId, quantity: quantityIssued] if (ServiceUtil.isError(serviceResult)) { return serviceResult } } - if (inventoryItem.inventoryItemTypeId == 'NON_SERIAL_INV_ITEM' && - inventoryItem.availableToPromiseTotal && - inventoryItem.availableToPromiseTotal > 0) { - quantityIssued = !parameters.quantity || parameters.quantity > inventoryItem.availableToPromiseTotal ? - parameters.quantity : - inventoryItem.availableToPromiseTotal - Map serviceResult = run service: 'assignInventoryToWorkEffort', with: [workEffortId: parameters.workEffortId, - inventoryItemId: inventoryItem.inventoryItemId, - quantity: quantityIssued] - if (ServiceUtil.isError(serviceResult)) { - return serviceResult - } - serviceResult = run service: 'createInventoryItemDetail', with: [inventoryItemId: inventoryItem.inventoryItemId, - workEffortId: parameters.workEffortId, - availableToPromiseDiff: - quantityIssued, - quantityOnHandDiff: - quantityIssued, - reasonEnumId: parameters.reasonEnumId, - description: parameters.description] - if (ServiceUtil.isError(serviceResult)) { - return serviceResult + if (inventoryItem.inventoryItemTypeId == 'NON_SERIAL_INV_ITEM') { + BigDecimal qoh = inventoryItem.getBigDecimal('quantityOnHandTotal') ?: 0.0 + if (qoh > 0) { + quantityIssued = (parameters.quantity == null || parameters.quantity > qoh) ? qoh : parameters.quantity + + Map serviceResult = run service: 'assignInventoryToWorkEffort', with: [workEffortId: workEffortId, + inventoryItemId: inventoryItem.inventoryItemId, + quantity: quantityIssued] + if (ServiceUtil.isError(serviceResult)) { + return serviceResult + } + + serviceResult = run service: 'createInventoryItemDetail', with: [inventoryItemId: inventoryItem.inventoryItemId, + workEffortId: workEffortId, + availableToPromiseDiff: -quantityIssued, + quantityOnHandDiff: -quantityIssued, + reasonEnumId: parameters.reasonEnumId, + description: parameters.description] + if (ServiceUtil.isError(serviceResult)) { + return serviceResult + } } - parameters.quantityNotIssued -= parameters.deductAmount } return [successMessage: null, finishedProductId: inventoryItem.productId, quantityIssued: quantityIssued] } + +/** + * Atomic execution bridge to bypass chronological loop and force deterministic execution. + */ +Map executeProductionRunTaskComponentAtomic() { + String inventoryItemId = parameters.inventoryItemId + GenericValue inventoryItem = from('InventoryItem').where(inventoryItemId: inventoryItemId).queryOne() + if (!inventoryItem) { + return ServiceUtil.returnError("InventoryItem ${inventoryItemId} not found for atomic execution.") + } + + Map inlineParams = [*:parameters, quantityNotIssued: parameters.quantity] + Map issueResult = issueProductionRunTaskComponentInline(inlineParams, inventoryItem, null) + if (ServiceUtil.isError(issueResult)) { + return issueResult + } + + // Evaluate if this specific product's BOM component is now fully satisfied + GenericValue workEffortGoodStandard = from('WorkEffortGoodStandard') + .where(workEffortId: parameters.workEffortId, productId: parameters.productId, workEffortGoodStdTypeId: 'PRUNT_PROD_NEEDED') + .queryFirst() + + if (workEffortGoodStandard) { + BigDecimal totalIssuance = BigDecimal.ZERO + from('WorkEffortAndInventoryAssign') + .where(workEffortId: parameters.workEffortId, productId: parameters.productId) + .queryList() + .each { issuance -> + totalIssuance = totalIssuance.add(issuance.getBigDecimal('quantity') ?: BigDecimal.ZERO) + } + + BigDecimal needed = workEffortGoodStandard.getBigDecimal('estimatedQuantity') ?: BigDecimal.ZERO + + if (totalIssuance >= needed && needed > 0) { + try { + workEffortGoodStandard.statusId = 'WEGS_COMPLETED' + workEffortGoodStandard.store() + } catch (Exception e) { + logWarning("executeProductionRunTaskComponentAtomic - Failed to update BOM component status: ${e.message}") + } + } + } + + return success("Successfully performed atomic issuance of ${parameters.quantity} for task ${parameters.workEffortId}") +} + +/** + * Handles manual lot/location override for production run's task issuance. + */ +Map handleManualIssuanceOverride(Map parameters) { + String lotId = parameters.lotId + String locationSeqId = parameters.locationSeqId + String inventoryItemId = parameters.inventoryItemId + String productId = parameters.productId + if (!(lotId || locationSeqId || inventoryItemId)) { + return [:] + } + + // Find matching inventory items for the override + Map whereMap = [productId: productId] + if (inventoryItemId) { + whereMap.inventoryItemId = inventoryItemId + } + if (lotId) { + whereMap.lotId = lotId + } + if (locationSeqId) { + whereMap.locationSeqId = locationSeqId + } + + GenericValue inventoryItem = from('InventoryItem').where(whereMap) + .queryFirst() + + if (!inventoryItem) { + return error('No inventory item found for the selected Lot/Location.') + } + + BigDecimal qoh = inventoryItem.getBigDecimal('quantityOnHandTotal') ?: 0.0 + List workEffortReservations = from('WorkEffortInvRes') + .where(inventoryItemId: inventoryItem.inventoryItemId) + .queryList() + BigDecimal totalRes = workEffortReservations + .findAll { it.workEffortId != parameters.workEffortId } + .sum { it.getBigDecimal('quantity') ?: 0.0 } ?: 0.0 + BigDecimal ownRes = workEffortReservations + .findAll { it.workEffortId == parameters.workEffortId && (!productId || it.productId == productId) } + .sum { it.getBigDecimal('quantity') ?: 0.0 } ?: 0.0 + BigDecimal atp = qoh - totalRes + ownRes + + // 1. Physical Stock Check + if ((parameters.failIfItemsAreNotOnHand ?: 'Y') == 'Y' && qoh < parameters.quantityNotIssued) { + logWarning("handleManualIssuanceOverride - FAILED Physical Check: QOH ${qoh} < Needed ${parameters.quantityNotIssued}") + return error('Insufficient physical inventory in selected Lot/Location.') + } + + // Find Facility Policy + GenericValue workEffort = from('WorkEffort').where(workEffortId: parameters.workEffortId).queryOne() + GenericValue facility = workEffort ? from('Facility').where(facilityId: workEffort.facilityId).queryOne() : null + String allowReallocation = facility?.allowInventoryReallocation ?: 'N' + + // 2. Promise Check (ATP) + // We check ATP if we are in Strict mode (failIfItemsAreNotAvailable=Y) OR if the Facility strictly forbids reallocation. + boolean mustCheckAtp = (parameters.failIfItemsAreNotAvailable == 'Y' || allowReallocation == 'N') + if (mustCheckAtp && atp < parameters.quantityNotIssued) { + String msg = (allowReallocation == 'N') ? + 'Manual issuance blocked: This item is promised to other tasks and Facility policy forbids inventory reallocation.' : + 'Inventory in selected Lot is already promised to other tasks.' + return error(msg) + } + + // Step 3: Issue the item (Reservations will be handled by issueProductionRunTaskComponentInline) + + // 4. Issue the item + BigDecimal amountToIssue = parameters.quantityNotIssued + Map issueParams = [*:parameters, inventoryItemId: inventoryItem.inventoryItemId, + quantityNotIssued: amountToIssue, failIfItemsAreNotAvailable: 'N'] + issueProductionRunTaskComponentInline(issueParams, inventoryItem, null) + + List impactedTasks = [] // reconcileGlobalReservationsInternal already called via issueProductionRunTaskComponentInline + + String successMsg = "Successfully issued ${amountToIssue} from ${inventoryItem.inventoryItemId}." + if (impactedTasks) { + successMsg += " Note: Stock was reallocated from reservations for Task(s): ${impactedTasks.join(', ')}." + } + successMsg += ' Previous reservations for this task were reconciled.' + return success(successMsg) +} + +/** + * Service wrapper for reconcileGlobalReservations. + */ +Map reconcileGlobalReservationsService() { + List impactedTaskIds = reconcileGlobalReservationsInternal( + parameters.inventoryItemId, + parameters.amountToIssue, + parameters.currentWorkEffortId, + parameters.strategy ?: 'INVRO_FIFO_REC', + parameters.failAvailable ?: 'Y', + parameters.allowInventoryReallocation ?: 'N' + ) + return success([impactedTaskIds: impactedTaskIds]) +} + +/** + * Reconciles reservations system-wide after an inventory issuance. + * Returns a list of workEffortIds that were impacted. + */ +List reconcileGlobalReservationsInternal(String inventoryItemId, BigDecimal amountToIssue, + String currentWorkEffortId, String strategy = 'INVRO_FIFO_REC', + String failAvailable = 'Y', String allowInventoryReallocation = 'N') { + GenericValue inventoryItem = from('InventoryItem').where('inventoryItemId', inventoryItemId).queryOne() + if (!inventoryItem) { + return [] + } + String productId = inventoryItem.productId + + // SELF-HEALING: Purge logical debt before reconciliation + // Use PRODUCT-SPECIFIC estimated and committed quantities to avoid cross-component leakage. + BigDecimal currentEstimated = from('WorkEffortGoodStandard') + .where(workEffortId: currentWorkEffortId, productId: productId, workEffortGoodStdTypeId: 'PRUNT_PROD_NEEDED') + .queryFirst()?.getBigDecimal('estimatedQuantity') ?: BigDecimal.ZERO + + BigDecimal totalCommitted = from('WorkEffortInvRes') + .where(workEffortId: currentWorkEffortId, productId: productId) + .queryList() + .inject(BigDecimal.ZERO) { sum, r -> sum + (r.getBigDecimal('quantity') ?: BigDecimal.ZERO) } + + if (totalCommitted > currentEstimated) { + BigDecimal excessCommitment = totalCommitted - currentEstimated + logWarning('reconcileGlobalReservations - SELF-HEALING: Purging ' + + "${excessCommitment} excess commitment for WE ${currentWorkEffortId}") + Map releaseResult = runService('releaseProductionRunTaskComponent', [ + workEffortId: currentWorkEffortId, + inventoryItemId: inventoryItemId, + productId: productId, + quantity: excessCommitment, + appendInventoryItemDetail: false + ]) + if (ServiceUtil.isError(releaseResult)) { + logError('reconcileGlobalReservations - FAILED to release own reservation: ' + + ServiceUtil.getErrorMessage(releaseResult)) + } + } + + List impactedTaskIds = [] + BigDecimal remainingToDeduct = amountToIssue + + // 0. Policy & Context Discovery + + GenericValue facility = from('Facility').where(facilityId: inventoryItem.facilityId).cache().queryOne() + String allowReallocation = facility?.allowInventoryReallocation ?: allowInventoryReallocation ?: 'N' + strategy = strategy ?: 'INVRO_FIFO_REC' + + // 1. Discovery: Find all prospective source reservations (WorkEfforts and Sales Orders) + List prospectiveReservations = [] + + from('WorkEffortInvResAndItem') + .where(inventoryItemId: inventoryItemId) + .queryList() + .each { res -> + prospectiveReservations << [ + type: 'WorkEffort', + res: res, + reservedDatetime: res.reservedDatetime, + datetimeReceived: res.datetimeReceived, + expireDate: res.expireDate, + unitCost: res.unitCost ?: 0 + ] + } + + from('OrderItemShipGrpInvResAndItem') + .where(inventoryItemId: inventoryItemId) + .queryList() + .each { res -> + prospectiveReservations << [ + type: 'SalesOrder', + res: res, + reservedDatetime: res.reservedDatetime, + datetimeReceived: res.datetimeReceived, + expireDate: res.expireDate, + unitCost: res.unitCost ?: 0 + ] + } + + // 1.5 Cross-Item Shadow Cleanup: Find reservations for this same product/task on OTHER items. + if (productId && currentWorkEffortId) { + from('WorkEffortInvResAndItem') + .where(EntityCondition.makeCondition( + EntityCondition.makeCondition('workEffortId', currentWorkEffortId), + EntityCondition.makeCondition('productId', productId), + EntityCondition.makeCondition('inventoryItemId', EntityOperator.NOT_EQUAL, inventoryItemId) + )) + .queryList() + .each { res -> + prospectiveReservations << [ + type: 'WorkEffort', + res: res, + reservedDatetime: res.reservedDatetime, + datetimeReceived: res.datetimeReceived, + expireDate: res.expireDate, + unitCost: res.unitCost ?: 0 + ] + } + } + + // 2. Strategy Sort + switch (strategy) { + case 'INVRO_LIFO_REC': + prospectiveReservations.sort { a, b -> (b.datetimeReceived?.time ?: 0L) <=> (a.datetimeReceived?.time ?: 0L) } + break + case 'INVRO_FIFO_EXP': + prospectiveReservations.sort { a, b -> (a.expireDate?.time ?: 0L) <=> (b.expireDate?.time ?: 0L) } + break + case 'INVRO_LIFO_EXP': + prospectiveReservations.sort { a, b -> (b.expireDate?.time ?: 0L) <=> (a.expireDate?.time ?: 0L) } + break + case 'INVRO_COST_DESC': + prospectiveReservations.sort { a, b -> (b.unitCost ?: 0) <=> (a.unitCost ?: 0) } + break + case 'INVRO_COST_ASC': + prospectiveReservations.sort { a, b -> (a.unitCost ?: 0) <=> (b.unitCost ?: 0) } + break + default: + prospectiveReservations.sort { a, b -> (a.datetimeReceived?.time ?: 0L) <=> (b.datetimeReceived?.time ?: 0L) } + } + + // 3. Selection: Self-Consumption vs Smart Reallocation + List reconciliationList = [] + List ourReservations = prospectiveReservations.findAll { it.type == 'WorkEffort' && it.res.workEffortId == currentWorkEffortId } + List otherReservations = prospectiveReservations.findAll { !(it.type == 'WorkEffort' && it.res.workEffortId == currentWorkEffortId) } + + reconciliationList.addAll(ourReservations) + // Only reallocate if BOTH the context allows it (Force/Auto mode) AND the facility policy allows reallocation. + if (failAvailable != 'Y' && allowReallocation == 'Y') { + reconciliationList.addAll(otherReservations) + } + + // 4. Consumption Loop + for (Map source : reconciliationList) { + if (remainingToDeduct <= 0) { + break + } + GenericValue res = source.res + + boolean isSelf = (source.type == 'WorkEffort' && res.workEffortId == currentWorkEffortId) + + BigDecimal resQty = res.getBigDecimal('quantity') ?: 0.0 + BigDecimal deductNow = remainingToDeduct.min(resQty) + + if (source.type == 'WorkEffort') { + if (isSelf) { + // SATISFACTION: We are issuing against our own reservation. + Map releaseResult = runService('releaseProductionRunTaskComponent', [ + workEffortId: res.workEffortId, + inventoryItemId: res.inventoryItemId, + productId: res.productId, + quantity: deductNow, + // Only restore ATP if we are releasing from a DIFFERENT item than the one we issued from. + appendInventoryItemDetail: (res.inventoryItemId != inventoryItemId) + ]) + if (ServiceUtil.isError(releaseResult)) { + logError("reconcileGlobalReservations - FAILED to release reservation for Task [${res.workEffortId}]: " + + ServiceUtil.getErrorMessage(releaseResult)) + continue + } + } else { + // REALLOCATION: Instead of releasing the reservation, we "demote" it to a backorder. + Map updateResult = runService('updateWorkEffortInvRes', [ + workEffortId: res.workEffortId, + inventoryItemId: res.inventoryItemId, + productId: res.productId, + quantity: res.getBigDecimal('quantity'), + quantityNotAvailable: (res.getBigDecimal('quantityNotAvailable') ?: BigDecimal.ZERO) + deductNow + ]) + if (ServiceUtil.isError(updateResult)) { + logError("reconcileGlobalReservations - FAILED to demote reservation for Task [${res.workEffortId}]: " + + ServiceUtil.getErrorMessage(updateResult)) + continue + } + impactedTaskIds << res.workEffortId.toString() + + // Restore ATP: Demoting a physical reservation to a backorder frees up the physical stock in the ledger. + Map detailResult = runService('createInventoryItemDetail', [ + inventoryItemId: inventoryItemId, + workEffortId: res.workEffortId, + availableToPromiseDiff: deductNow, + quantityOnHandDiff: BigDecimal.ZERO, + description: "Reconciliation: Reservation demoted to Backorder for Task [${res.workEffortId}]" + ]) + if (ServiceUtil.isError(detailResult)) { + logError("reconcileGlobalReservations - FAILED to restore ATP for Task [${res.workEffortId}]: " + + ServiceUtil.getErrorMessage(detailResult)) + } + logInfo("reconcileGlobalReservations - REALLOCATION: Demoted ${deductNow} from Task [${res.workEffortId}] to Backorder.") + } + } else if (source.type == 'SalesOrder') { + impactedTaskIds << "Order: ${res.orderId}".toString() + runService('cancelOrderItemShipGrpInvRes', [ + orderId: res.orderId, + orderItemSeqId: res.orderItemSeqId, + shipGroupSeqId: res.shipGroupSeqId, + inventoryItemId: res.inventoryItemId, + cancelQuantity: deductNow + ]) + logInfo("reconcileGlobalReservations - Released SalesOrder [${res.orderId}] for ${deductNow} units") + } + remainingToDeduct = remainingToDeduct.subtract(deductNow) + } + + // 5. SANITY GUARD: Multi-Item Over-Commitment Cleanup + // Loop over ALL impacted tasks (including current one) to ensure they aren't holding excess debt + Set allImpacted = (impactedTaskIds + [currentWorkEffortId]).findAll { it } as Set + + for (String targetWeId : allImpacted) { + if (productId && targetWeId) { + GenericValue wegs = from('WorkEffortGoodStandard') + .where(workEffortId: targetWeId, productId: productId, workEffortGoodStdTypeId: 'PRUNT_PROD_NEEDED') + .queryFirst() + + if (wegs) { + BigDecimal estimatedQty = wegs.getBigDecimal('estimatedQuantity') ?: BigDecimal.ZERO + + // Total Issued + BigDecimal totalIssued = from('WorkEffortAndInventoryAssign') + .where(workEffortId: targetWeId, productId: productId) + .queryList() + .inject(BigDecimal.ZERO) { sum, it -> sum + (it.getBigDecimal('quantity') ?: BigDecimal.ZERO) } + + // Total Commitment (Physical + Backorder) + List allResList = from('WorkEffortInvRes') + .where(workEffortId: targetWeId, productId: productId) + .queryList() + + BigDecimal totalCommitment = allResList.inject(BigDecimal.ZERO) { sum, it -> sum + (it.getBigDecimal('quantity') ?: BigDecimal.ZERO) } + + // POLICY: Definition of "Excess" depends on allowInventoryReallocation (Strictness) + // Aggressive (Y): Ceiling = Estimate - Issued (Hard life cap) + // Conservative (N): Ceiling = Estimate (Remaining backorders allowed if Issued > Est) + BigDecimal commitmentCeiling = (allowInventoryReallocation == 'Y') ? (estimatedQty - totalIssued).max(BigDecimal.ZERO) : estimatedQty + + if (totalCommitment > commitmentCeiling) { + BigDecimal excessToPurge = totalCommitment.subtract(commitmentCeiling) + if (excessToPurge > 0) { + logInfo("reconcileGlobalReservations - [SANITY GUARD]: Task [${targetWeId}] Total Commitment " + + "(${totalCommitment}) exceeds Policy Ceiling (${commitmentCeiling}) " + + "[Policy: ${allowInventoryReallocation}]. Purging ${excessToPurge} units of logical debt...") + + // Sort by backorder first (we want to purge debt before physical reservations) + allResList.sort { a, b -> (b.quantityNotAvailable ?: 0) <=> (a.quantityNotAvailable ?: 0) } + + allResList.each { resToClean -> + if (excessToPurge > 0) { + BigDecimal qna = resToClean.getBigDecimal('quantityNotAvailable') ?: BigDecimal.ZERO + if (qna > 0) { + BigDecimal purgeFromQna = excessToPurge.min(qna) + Map releaseResResult = runService('releaseProductionRunTaskComponent', [ + workEffortId: targetWeId, + inventoryItemId: resToClean.inventoryItemId, + productId: productId, + quantity: purgeFromQna, + appendInventoryItemDetail: true // Logical debt purge MUST restore ATP + ]) + if (ServiceUtil.isError(releaseResResult)) { + logError('reconcileGlobalReservations - [SANITY GUARD] FAILED to purge excess ' + + "debt for Task [${targetWeId}]: " + + ServiceUtil.getErrorMessage(releaseResResult)) + excessToPurge = BigDecimal.ZERO + } else { + excessToPurge = excessToPurge.subtract(purgeFromQna) + } + } + } + } + } + } + } + } + } + + return allImpacted.toList() +} diff --git a/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/test/InventoryIssuanceTests.groovy b/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/test/InventoryIssuanceTests.groovy new file mode 100644 index 0000000000..a4ac645d84 --- /dev/null +++ b/applications/manufacturing/src/main/groovy/org/apache/ofbiz/manufacturing/test/InventoryIssuanceTests.groovy @@ -0,0 +1,1046 @@ +package org.apache.ofbiz.manufacturing.test + +import org.apache.ofbiz.entity.GenericValue +import org.apache.ofbiz.entity.condition.EntityCondition +import org.apache.ofbiz.entity.condition.EntityOperator +import org.apache.ofbiz.service.ServiceUtil +import org.apache.ofbiz.service.testtools.OFBizTestCase +import java.math.RoundingMode +import org.apache.ofbiz.base.util.Debug +import org.apache.ofbiz.base.util.UtilDateTime + +class InventoryIssuanceTests extends InventoryIssuanceTestSupport { + + public InventoryIssuanceTests(String name) { + super(name) + } + + @Override + void setUp() { + super.setUp() + userLogin = from('UserLogin').where('userLoginId', 'system').queryOne() + + // Target Purge: Clean up only the items and reservations we own. + delegator.removeByCondition('WorkEffortInvRes', EntityCondition.makeCondition('productId', EntityOperator.LIKE, 'II_%')) + delegator.removeByCondition('WorkEffortInventoryAssign', + EntityCondition.makeCondition('inventoryItemId', EntityOperator.LIKE, 'II_%')) + delegator.removeByCondition('InventoryItemDetail', + EntityCondition.makeCondition('inventoryItemId', EntityOperator.LIKE, 'II_%')) + delegator.removeByCondition('InventoryItem', + EntityCondition.makeCondition('inventoryItemId', EntityOperator.LIKE, 'II_%')) + + ensureProductBackbone() + ensureFacilityPolicies() + delegator.clearAllCaches() + Debug.logInfo('InventoryIssuanceTests: Environment purged for II_% items.', 'InventoryIssuanceTests') + } + + // --- Category 1 & 2: Basic & Force Issuance --- + + void testS1_1_StrictIssuanceFailure() { + String facId = 'WH_S1_1' + storeFacility([facilityId: facId, facilityName: facId, facilityTypeId: 'WAREHOUSE', + ownerPartyId: 'II_COMPANY', autoReservePrun: 'Y', allowInventoryReallocation: 'Y', reconcilePrunBackorders: 'N']) + setUpInventory(facId, 'II_MAT_A_COST', 'II_MAT_A_S1_1', 100.0) + // Request PR for 60 units (Needs 120 MAT_A). + String weId = setUpProductionRun('II_PROD_MANUF', 60.0, facId) + + // Run in a new transaction so an error-return does NOT poison the outer tx. + Map result = dispatcher.runSync('issueProductionRunTask', [ + workEffortId: weId, failIfItemsAreNotAvailable: 'Y', userLogin: userLogin + ], 0, true) + assert ServiceUtil.isError(result) + } + + void testS1_2_ForceWithReallocation() { + String facId = 'WH_S1_2' + storeFacility([facilityId: facId, facilityName: facId, facilityTypeId: 'WAREHOUSE', + ownerPartyId: 'II_COMPANY', autoReservePrun: 'Y', allowInventoryReallocation: 'Y', reconcilePrunBackorders: 'N']) + setUpInventory(facId, 'II_MAT_A_COST', 'II_MAT_A_S1_2', 100.0) + setUpInventory(facId, 'II_MAT_C_COST', 'II_MAT_C_S1_2', 100.0) + // Setup Affected reservation: Needs 50 PR -> 100 MAT_A Reserved. (Pool = 100, so Affected reservation gets all). + String affectedWeId = setUpProductionRun('II_PROD_MANUF', 50.0, facId) + assertInventoryIntegrity('II_MAT_A_COST', affectedWeId, 0.0, 100.0, 0.0, facId) + + // Setup issuing task: Needs 25 PR -> 50 MAT_A. Pool is empty. + String issuingWeId = setUpProductionRun('II_PROD_MANUF', 25.0, facId) + + // Action: Force issue. Should reallocate 50 from affected reservation. + Map result = dispatcher.runSync('issueProductionRunTask', [ + workEffortId: issuingWeId, failIfItemsAreNotAvailable: 'N', userLogin: userLogin + ]) + assert ServiceUtil.isSuccess(result) + + // Verify integrity: issuing task has 50 issued. Affected reservation has 50 issued, 50 reserved, 0 ATP. + // ATP is 0 because the 50 remaining reserved units are backordered (QNA=50). + assertInventoryIntegrity('II_MAT_A_COST', issuingWeId, 50.0, 0.0, 0.0, facId) + assertInventoryIntegrity('II_MAT_A_COST', affectedWeId, 0.0, 50.0, 0.0, facId) + + // Verify Affected reservation's Backorder + GenericValue affectedRes = from('WorkEffortInvRes').where('workEffortId', affectedWeId, 'productId', 'II_MAT_A_COST') + .queryFirst() + assert scaleQuantity(affectedRes.getBigDecimal('quantityNotAvailable')) == scaleQuantity(50.0) : + 'Affected reservation should be backordered by 50' + } + + // --- Category 4: Real-time Reconciliation (SECA) --- + + void testS4_1_RealTimeReconciliationOnReceipt() { + String facId = 'WH_RECON_Y' + storeFacility([facilityId: facId, facilityName: facId, facilityTypeId: 'WAREHOUSE', + ownerPartyId: 'II_COMPANY', autoReservePrun: 'Y', allowInventoryReallocation: 'Y', reconcilePrunBackorders: 'Y']) + + setUpInventory(facId, 'II_MAT_C_COST', 'II_MAT_C_RECON', 0.0) + String weId = setUpProductionRun('II_PROD_MANUF', 20.0, facId) + + GenericValue res = from('WorkEffortInvRes').where('workEffortId', weId, 'productId', 'II_MAT_C_COST').queryFirst() + assert res != null : 'Reservation should exist' + assert scaleQuantity(res.getBigDecimal('quantityNotAvailable')) == scaleQuantity(20.0) : + 'Should be 20 backordered initially' + assert res.inventoryItemId != null : 'Backorder should be anchored to a logical inventory item' + + String newItemId = 'II_MAT_C_RECON_RCPT' + delegator.create('InventoryItem', [ + inventoryItemId: newItemId, productId: 'II_MAT_C_COST', facilityId: facId, + inventoryItemTypeId: 'NON_SERIAL_INV_ITEM', ownerPartyId: 'II_COMPANY', + quantityOnHandTotal: 0.0, availableToPromiseTotal: 0.0, accountingQuantityTotal: 0.0, + datetimeReceived: UtilDateTime.nowTimestamp() + ]) + dispatcher.runSync('createInventoryItemDetail', [ + inventoryItemId: newItemId, availableToPromiseDiff: 50.0, + quantityOnHandDiff: 50.0, accountingQuantityDiff: 50.0, userLogin: userLogin + ]) + Map balanceResult = dispatcher.runSync('balanceInventoryItems', [inventoryItemId: newItemId, userLogin: userLogin]) + assert ServiceUtil.isSuccess(balanceResult) + + assertInventoryIntegrity('II_MAT_C_COST', weId, 0.0, 20.0, 30.0, facId) + + res = from('WorkEffortInvRes').where('workEffortId', weId, 'productId', 'II_MAT_C_COST').queryFirst() + assert scaleQuantity(res.getBigDecimal('quantityNotAvailable')) == scaleQuantity(0.0) : + 'Reservation should now be fully available (QNA=0)' + assert res.inventoryItemId == newItemId : 'Reservation should be tied to the newly received item' + } + + void testS4_2_ProductionChainAutoSatisfaction() { + String facId = 'WH_CHAIN_RECON' + storeFacility([facilityId: facId, facilityName: facId, facilityTypeId: 'WAREHOUSE', + ownerPartyId: 'II_COMPANY', autoReservePrun: 'Y', allowInventoryReallocation: 'Y', reconcilePrunBackorders: 'Y']) + + setUpInventory(facId, 'II_MAT_E_RAW', 'II_MAT_E_RAW_S4_2', 100.0) + setUpInventory(facId, 'II_MAT_D_PRODUCED', 'II_MAT_D_RECON', 0.0) + + String prATaskId = setUpProductionRun('II_PROD_CHAIN', 10.0, facId) + + GenericValue resA = from('WorkEffortInvRes').where('workEffortId', prATaskId, 'productId', 'II_MAT_D_PRODUCED').queryFirst() + assert resA != null : 'Reservation for PR-A should exist' + assert scaleQuantity(resA.getBigDecimal('quantityNotAvailable')) == scaleQuantity(10.0) : + 'PR-A should be backordered for 10 units' + + String prBTaskId = setUpProductionRun('II_MAT_D_PRODUCED', 15.0, facId) + GenericValue taskB = from('WorkEffort').where('workEffortId', prBTaskId).queryOne() + String prBId = taskB.workEffortParentId + + dispatcher.runSync('changeProductionRunTaskStatus', [ + productionRunId: prBId, workEffortId: prBTaskId, + statusId: 'PRUN_RUNNING', userLogin: userLogin + ]) + + GenericValue resB = from('WorkEffortInvRes').where('workEffortId', prBTaskId, 'productId', 'II_MAT_E_RAW').queryFirst() + assert resB != null : 'Reservation for PR-B should exist' + + Map issueResult = dispatcher.runSync('issueProductionRunTask', [ + workEffortId: prBTaskId, userLogin: userLogin + ]) + assert ServiceUtil.isSuccess(issueResult) + + dispatcher.runSync('changeProductionRunTaskStatus', [ + productionRunId: prBId, workEffortId: prBTaskId, + statusId: 'PRUN_COMPLETED', userLogin: userLogin + ]) + + Map produceResult = dispatcher.runSync('productionRunProduce', [ + workEffortId: prBId, quantity: 15.0, userLogin: userLogin + ]) + assert ServiceUtil.isSuccess(produceResult) + String newInventoryItemId = ((List) produceResult.inventoryItemIds)[0] + + resA = from('WorkEffortInvRes').where('workEffortId', prATaskId, 'productId', 'II_MAT_D_PRODUCED').queryFirst() + assert scaleQuantity(resA.getBigDecimal('quantityNotAvailable')) == scaleQuantity(0.0) : + 'PR-A\'s reservation should now be satisfied (QNA=0)' + assert resA.inventoryItemId == newInventoryItemId : 'PR-A\'s reservation should be tied to the item produced by PR-B' + + assertInventoryIntegrity('II_MAT_D_PRODUCED', prATaskId, 0.0, 10.0, 5.0, facId) + } + + // --- Category 3: Backorders & Residuals --- + + void testS3_2_NegativeReservationResolution() { + String facId = 'WH_S3_2' + storeFacility([facilityId: facId, facilityName: facId, facilityTypeId: 'WAREHOUSE', + ownerPartyId: 'II_COMPANY', autoReservePrun: 'Y', allowInventoryReallocation: 'Y', reconcilePrunBackorders: 'N']) + + setUpInventory(facId, 'II_MAT_A_COST', 'II_MAT_A_S3_2', 100.0) + setUpInventory(facId, 'II_MAT_C_COST', 'II_MAT_C_S3_2', 0.0) + String weId = setUpProductionRun('II_PROD_MANUF', 20.0, facId) + + GenericValue res = from('WorkEffortInvRes').where('workEffortId', weId, 'productId', 'II_MAT_C_COST').queryFirst() + assert scaleQuantity(res.getBigDecimal('quantityNotAvailable')) == scaleQuantity(20.0) : + 'Initial state should be 20 backordered' + + dispatcher.runSync('createInventoryItemDetail', [ + inventoryItemId: 'II_MAT_C_S3_2', quantityOnHandDiff: 50.0, + availableToPromiseDiff: 50.0, accountingQuantityDiff: 50.0, userLogin: userLogin + ]) + + Map result = dispatcher.runSync('issueProductionRunTask', [ + workEffortId: weId, failIfItemsAreNotAvailable: 'N', userLogin: userLogin + ]) + assert ServiceUtil.isSuccess(result) + + assertInventoryIntegrity('II_MAT_C_COST', weId, 20.0, 0.0, 30.0, facId) + } + + void testS3_4_ImpactPlanAudit() { + String facId = 'WH_S3_4' + storeFacility([facilityId: facId, facilityName: facId, facilityTypeId: 'WAREHOUSE', + ownerPartyId: 'II_COMPANY', autoReservePrun: 'Y', allowInventoryReallocation: 'Y', reconcilePrunBackorders: 'N']) + setUpInventory(facId, 'II_MAT_A_COST', 'II_MAT_A_S3_4', 100.0) + setUpInventory(facId, 'II_MAT_C_COST', 'II_MAT_C_S3_4', 100.0) + + String v1 = setUpProductionRun('II_PROD_MANUF', 12.5, facId) + setUpProductionRun('II_PROD_MANUF', 12.5, facId) + setUpProductionRun('II_PROD_MANUF', 25.0, facId) + + String t4 = setUpProductionRun('II_PROD_MANUF', 7.5, facId) + + Map impactResult = dispatcher.runSync('getProductionRunTaskForceIssueImpact', [ + workEffortId: t4, facilityId: facId, productId: 'II_MAT_A_COST', userLogin: userLogin + ]) + assert ServiceUtil.isSuccess(impactResult) + + List impactList = (List) impactResult.impactList + List issuePlan = (List) impactResult.inventoryIssuePlan + + BigDecimal totalImpact = impactList.inject(BigDecimal.ZERO) { sum, it -> sum + (it.quantity ?: BigDecimal.ZERO) } + assert scaleQuantity(totalImpact) == scaleQuantity(15.0) : 'Issuing task should reallocate 15 units' + assert issuePlan.size() == 0 : 'Pool should have 0 available' + + Map v1Impact = (Map) impactList.find { it.impactedWorkEffortId == v1 } + assert v1Impact != null : 'Affected reservation 1 should be in the Impact List' + assert v1Impact.type == 'REALLOCATED' : 'Affected reservation 1 impact type should be REALLOCATED' + } + + void testS3_5_AffectedReservationSanityGuard() { + String facId = 'WH_S3_5' + storeFacility([facilityId: facId, facilityName: facId, facilityTypeId: 'WAREHOUSE', + ownerPartyId: 'II_COMPANY', autoReservePrun: 'Y', allowInventoryReallocation: 'Y', reconcilePrunBackorders: 'N']) + setUpInventory(facId, 'II_MAT_A_COST', 'II_MAT_A_S3_5', 20.0) + setUpInventory(facId, 'II_MAT_C_COST', 'II_MAT_C_S3_5', 100.0) + String affectedWeId = setUpProductionRun('II_PROD_MANUF', 10.0, facId) + dispatcher.runSync('reserveWorkEffortInventoryItem', [ + workEffortId: affectedWeId, productId: 'II_MAT_A_COST', facilityId: facId, + inventoryItemId: null, quantity: 20.0, quantityNotAvailable: 20.0, + requireInventory: 'N', userLogin: userLogin + ]) + + int resCount = from('WorkEffortInvRes').where('workEffortId', affectedWeId, 'productId', 'II_MAT_A_COST').queryCount() + assert resCount == 2 : 'Affected reservation should have 2 records (Dirty State)' + + String issuingWeId = setUpProductionRun('II_PROD_MANUF', 5.0, facId) + + List impactList = [[ + productId: 'II_MAT_A_COST', impactType: 'Production Task', impactId: affectedWeId, + inventoryItemId: 'II_MAT_A_S3_5', quantity: 10.0, type: 'REALLOCATED', impactedWorkEffortId: affectedWeId + ]] + + Map reallocResult = dispatcher.runSync('reallocateAndIssueInventory', [ + workEffortId: issuingWeId, impactList: impactList, userLogin: userLogin + ]) + assert ServiceUtil.isSuccess(reallocResult) + + int finalResCount = from('WorkEffortInvRes').where('workEffortId', affectedWeId, 'productId', 'II_MAT_A_COST').queryCount() + assert finalResCount == 1 : 'Affected reservation should now have exactly 1 record (Sanity Guard worked)' + + GenericValue remainingRes = from('WorkEffortInvRes').where('workEffortId', affectedWeId, 'productId', 'II_MAT_A_COST').queryFirst() + BigDecimal totalCommited = (remainingRes.getBigDecimal('quantity') ?: BigDecimal.ZERO) + assert scaleQuantity(totalCommited) == scaleQuantity(20.0) : 'Total commitment should match Requirement (20)' + } + + void testS3_6_ExplicitReallocationWorkflow() { + String facId = 'WH_S3_6' + storeFacility([facilityId: facId, facilityName: facId, facilityTypeId: 'WAREHOUSE', + ownerPartyId: 'II_COMPANY', autoReservePrun: 'Y', allowInventoryReallocation: 'Y', reconcilePrunBackorders: 'N']) + setUpInventory(facId, 'II_MAT_A_COST', 'II_MAT_A_S3_6', 100.0) + setUpInventory(facId, 'II_MAT_C_COST', 'II_MAT_C_S3_6', 100.0) + + String affectedWeId = setUpProductionRun('II_PROD_MANUF', 50.0, facId) + assertInventoryIntegrity('II_MAT_A_COST', affectedWeId, 0.0, 100.0, 0.0, facId) + + String issuingWeId = setUpProductionRun('II_PROD_MANUF', 25.0, facId) + + Map impactResult = dispatcher.runSync('getProductionRunTaskForceIssueImpact', [ + workEffortId: issuingWeId, facilityId: facId, userLogin: userLogin + ]) + assert ServiceUtil.isSuccess(impactResult) + List impactList = (List) impactResult.impactList + BigDecimal plannedReallocation = impactList.inject(BigDecimal.ZERO) { sum, it -> sum + (it.quantity ?: BigDecimal.ZERO) } + assert scaleQuantity(plannedReallocation) == scaleQuantity(50.0) : 'Audit should plan to reallocate 50 units' + + Map reallocResult = dispatcher.runSync('reallocateAndIssueInventory', [ + workEffortId: issuingWeId, impactList: impactList, userLogin: userLogin + ]) + assert ServiceUtil.isSuccess(reallocResult) + + assertInventoryIntegrity('II_MAT_A_COST', issuingWeId, 50.0, 0.0, 0.0, facId) + + List affectedResList = from('WorkEffortInvRes').where('workEffortId', affectedWeId, 'productId', 'II_MAT_A_COST').queryList() + BigDecimal affectedQna = affectedResList.inject(BigDecimal.ZERO) { sum, it -> sum + (it.getBigDecimal('quantityNotAvailable') ?: 0) } + assert scaleQuantity(affectedQna) == scaleQuantity(50.0) : 'Affected reservation should be backordered by 50 units' + + GenericValue invItem = from('InventoryItem').where('inventoryItemId', 'II_MAT_A_S3_6').queryOne() + assert scaleQuantity(invItem.getBigDecimal('quantityOnHandTotal')) == scaleQuantity(50.0) : 'Physical QOH should be 50' + assert scaleQuantity(invItem.getBigDecimal('accountingQuantityTotal')) == scaleQuantity(50.0) : 'Accounting Total should be 50' + } + + void testS4_1_ReleaseWithRestore() { + String facId = 'WH_S4_1' + storeFacility([facilityId: facId, facilityName: facId, facilityTypeId: 'WAREHOUSE', + ownerPartyId: 'II_COMPANY', autoReservePrun: 'Y', allowInventoryReallocation: 'Y', reconcilePrunBackorders: 'N']) + setUpInventory(facId, 'II_MAT_A_COST', 'II_MAT_A_S4_1', 100.0) + String weId = setUpProductionRun('II_PROD_MANUF', 10.0, facId) + + long countBefore = from('InventoryItemDetail').where('inventoryItemId', 'II_MAT_A_S4_1').queryCount() + + Map result = dispatcher.runSync('releaseProductionRunTaskComponent', [ + workEffortId: weId, productId: 'II_MAT_A_COST', + inventoryItemId: 'II_MAT_A_S4_1', appendInventoryItemDetail: true, userLogin: userLogin + ]) + assert ServiceUtil.isSuccess(result) + + long countAfter = from('InventoryItemDetail').where('inventoryItemId', 'II_MAT_A_S4_1').queryCount() + assert countAfter == countBefore + 1 : 'InventoryItemDetail record should be created for restoration' + + GenericValue res = from('WorkEffortInvRes').where('workEffortId', weId, 'productId', 'II_MAT_A_COST').queryOne() + assert res == null + } + + void testS4_2_ReleaseSatisfaction() { + String weId = setUpProductionRun('II_PROD_MANUF', 10.0) + + GenericValue resRecord = from('WorkEffortInvRes').where('workEffortId', weId, 'productId', 'II_MAT_A_COST').queryFirst() + assert resRecord != null : 'Reservation should have been created by setUpProductionRun' + String reservedItemId = resRecord.inventoryItemId + + long countBefore = from('InventoryItemDetail').where('inventoryItemId', reservedItemId).queryCount() + + Map result = dispatcher.runSync('releaseProductionRunTaskComponent', [ + workEffortId: weId, productId: 'II_MAT_A_COST', + inventoryItemId: reservedItemId, appendInventoryItemDetail: false, userLogin: userLogin + ]) + assert ServiceUtil.isSuccess(result) + + long countAfter = from('InventoryItemDetail').where('inventoryItemId', reservedItemId).queryCount() + assert countAfter == countBefore : 'InventoryItemDetail record should NOT be created for satisfaction' + + GenericValue res = from('WorkEffortInvRes').where('workEffortId', weId, 'productId', 'II_MAT_A_COST').queryOne() + assert res == null + } + + // --- Category 5: Hardened Policy & Reconciliation --- + + void testS5_1_PolicyGateFail() { + String itemId = 'II_INV_TRAD_S5_1' + setUpInventory('WH_TRADITIONAL', 'II_MAT_A_COST', itemId, 100.0) + setUpInventory('WH_TRADITIONAL', 'II_MAT_C_COST', 'II_INV_TRAD_C_S5_1', 100.0) + + String affectedWeId = setUpProductionRun('II_PROD_MANUF', 50.0, 'WH_TRADITIONAL') + assertInventoryIntegrity('II_MAT_A_COST', affectedWeId, 0.0, 100.0, 0.0, 'WH_TRADITIONAL') + + String issuingWeId = setUpProductionRun('II_PROD_MANUF', 10.0, 'WH_TRADITIONAL') + + Map result = dispatcher.runSync('issueProductionRunTask', [ + workEffortId: issuingWeId, failIfItemsAreNotAvailable: 'N', userLogin: userLogin + ], 0, true) + + assert ServiceUtil.isError(result) + assert ServiceUtil.getErrorMessage(result).contains('forbids inventory reallocation') : + 'Error message should contain reallocation policy violation' + } + + void testS5_2_APIPolicyEnforcement() { + String itemId = 'II_INV_TRAD_S5_2' + String issuingWeId = setUpProductionRun('II_PROD_MANUF', 10.0, 'WH_TRADITIONAL') + setUpInventory('WH_TRADITIONAL', 'II_MAT_A_COST', itemId, 100.0) + setUpInventory('WH_TRADITIONAL', 'II_MAT_C_COST', 'II_INV_TRAD_C_S5_2', 100.0) + + List impactList = [[ + productId: 'II_MAT_A_COST', impactType: 'Production Task', impactId: 'SOME_AFFECTED_TASK', + inventoryItemId: itemId, quantity: 10.0, type: 'REALLOCATED', impactedWorkEffortId: 'SOME_AFFECTED_TASK' + ]] + + Map reallocResult = dispatcher.runSync('reallocateAndIssueInventory', [ + workEffortId: issuingWeId, impactList: impactList, facilityId: 'WH_TRADITIONAL', userLogin: userLogin + ], 0, true) + + assert ServiceUtil.isError(reallocResult) + assert reallocResult.errorMessage.contains('Policy Violation') + } + + void testS5_3_NightlyReconciliation() { + String itemIdA = 'II_INV_FLUID_A_S5_3' + String itemIdC = 'II_INV_FLUID_C_S5_3' + setUpInventory('WH_FLUID', 'II_MAT_A_COST', itemIdA, 100.0) + setUpInventory('WH_FLUID', 'II_MAT_C_COST', itemIdC, 0.0) + String affectedWeId = setUpProductionRun('II_PROD_MANUF', 10.0, 'WH_FLUID') + dispatcher.runSync('reserveWorkEffortInventoryItem', [ + workEffortId: affectedWeId, productId: 'II_MAT_A_COST', facilityId: 'WH_FLUID', + inventoryItemId: itemIdA, quantity: 20.0, quantityNotAvailable: 20.0, + requireInventory: 'N', userLogin: userLogin + ]) + + String backorderWeId = setUpProductionRun('II_PROD_MANUF', 5.0, 'WH_FLUID') + dispatcher.runSync('issueProductionRunTask', [workEffortId: backorderWeId, userLogin: userLogin]) + + dispatcher.runSync('createInventoryItemDetail', [ + inventoryItemId: itemIdC, quantityOnHandDiff: 50.0, + availableToPromiseDiff: 50.0, accountingQuantityDiff: 50.0, userLogin: userLogin + ]) + + dispatcher.runSync('reconcileInventoryForProductionJobs', [userLogin: userLogin]) + + GenericValue resA = from('WorkEffortInvRes').where('workEffortId', affectedWeId, 'productId', 'II_MAT_A_COST').queryFirst() + assert resA != null : 'Auditor reservation should exist' + assert scaleQuantity(resA.getBigDecimal('quantityNotAvailable')) == scaleQuantity(0.0) : + 'Auditor should have purged phantom debt' + + GenericValue boRes = from('WorkEffortInvRes').where('workEffortId', backorderWeId, 'productId', 'II_MAT_C_COST').queryFirst() + assert boRes != null : 'Satisfier reservation should exist' + assert scaleQuantity(boRes.getBigDecimal('quantityNotAvailable')) == scaleQuantity(0.0) : + 'Satisfier should have settled the debt' + assert scaleQuantity(boRes.getBigDecimal('quantity')) == scaleQuantity(5.0) : + 'Physical reservation should now be 5' + assert (boRes.getBigDecimal('quantityNotAvailable') ?: BigDecimal.ZERO) == BigDecimal.ZERO + } + + void testS5_4_ReconFluidPolicy() { + String itemId = 'II_INV_FLUID_A_S5_4' + setUpInventory('WH_FLUID', 'II_MAT_A_COST', itemId, 5.0) + setUpInventory('WH_FLUID', 'II_MAT_C_COST', 'II_INV_FLUID_C_S5_4', 100.0) + setUpInventory('WH_FLUID', 'II_MAT_A_COST', 'II_INV_FLUID_B_S5_4', 0.0, false) + + String affectedWeId = setUpProductionRun('II_PROD_MANUF', 5.0, 'WH_FLUID') + + Map issueAffectedResult = dispatcher.runSync('issueProductionRunTaskComponent', [ + workEffortId: affectedWeId, productId: 'II_MAT_A_COST', quantity: 5.0, + failIfItemsAreNotAvailable: 'N', userLogin: userLogin + ]) + assert ServiceUtil.isSuccess(issueAffectedResult) + + dispatcher.runSync('reserveWorkEffortInventoryItem', [ + workEffortId: affectedWeId, productId: 'II_MAT_A_COST', facilityId: 'WH_FLUID', + inventoryItemId: itemId, quantity: 7.0, quantityNotAvailable: 7.0, + requireInventory: 'N', userLogin: userLogin + ]) + + String boWeId = setUpProductionRun('II_PROD_MANUF', 5.0, 'WH_FLUID') + + dispatcher.runSync('reconcileInventoryForProductionJobs', [userLogin: userLogin]) + + List resList = from('WorkEffortInvRes').where('workEffortId', affectedWeId).queryList() + BigDecimal totalQna = resList.inject(BigDecimal.ZERO) { sum, it -> sum + (it.getBigDecimal('quantityNotAvailable') ?: 0) } + assert scaleQuantity(totalQna) == scaleQuantity(5.0) : 'Aggressive Auditor should have purged excess logical debt to match estimate ceiling' + + dispatcher.runSync('createInventoryItemDetail', [ + inventoryItemId: 'II_INV_FLUID_B_S5_4', quantityOnHandDiff: 20.0, + availableToPromiseDiff: 20.0, accountingQuantityDiff: 20.0, userLogin: userLogin + ]) + dispatcher.runSync('reconcileInventoryForProductionJobs', [userLogin: userLogin]) + + GenericValue boRes = from('WorkEffortInvRes').where('workEffortId', boWeId, 'productId', 'II_MAT_A_COST').queryFirst() + assert boRes != null : 'Backorder reservation should exist for boWeId' + assert scaleQuantity(boRes.getBigDecimal('quantityNotAvailable')) == scaleQuantity(0.0) : + 'Satisfier should have settled the debt in Fluid Warehouse' + } + + void testS5_5_ReconTraditionalPolicy() { + String itemId = 'II_INV_TRAD_A_S5_5' + setUpInventory('WH_TRADITIONAL', 'II_MAT_A_COST', itemId, 20.0) + setUpInventory('WH_TRADITIONAL', 'II_MAT_C_COST', 'II_INV_TRAD_C_S5_5', 100.0) + setUpInventory('WH_TRADITIONAL', 'II_MAT_A_COST', 'II_INV_TRAD_B_S5_5', 0.0, false) + + String weId = setUpProductionRun('II_PROD_MANUF', 5.0, 'WH_TRADITIONAL') + + Map issueScrapResult = dispatcher.runSync('issueProductionRunTaskComponent', [ + workEffortId: weId, productId: 'II_MAT_A_COST', quantity: 12.0, + failIfItemsAreNotAvailable: 'Y', userLogin: userLogin + ]) + assert ServiceUtil.isSuccess(issueScrapResult) + + dispatcher.runSync('reserveWorkEffortInventoryItem', [ + workEffortId: weId, productId: 'II_MAT_A_COST', facilityId: 'WH_TRADITIONAL', + inventoryItemId: itemId, quantity: 2.0, quantityNotAvailable: 2.0, + requireInventory: 'N', userLogin: userLogin + ]) + + String boWeId = setUpProductionRun('II_PROD_MANUF', 5.0, 'WH_TRADITIONAL') + dispatcher.runSync('issueProductionRunTask', [workEffortId: boWeId, userLogin: userLogin]) + dispatcher.runSync('createInventoryItemDetail', [ + inventoryItemId: 'II_INV_TRAD_B_S5_5', quantityOnHandDiff: 20.0, + availableToPromiseDiff: 20.0, accountingQuantityDiff: 20.0, userLogin: userLogin + ]) + + dispatcher.runSync('reconcileInventoryForProductionJobs', [userLogin: userLogin]) + + GenericValue res = from('WorkEffortInvRes').where('workEffortId', weId, 'productId', 'II_MAT_A_COST', + 'inventoryItemId', itemId).queryFirst() + assert res != null : 'Reservation record should exist' + assert scaleQuantity(res.getBigDecimal('quantityNotAvailable')) == scaleQuantity(2.0) : + 'Conservative Auditor should have spared the scrap backorder' + + List boResList = from('WorkEffortInvRes').where('workEffortId', boWeId, 'productId', 'II_MAT_A_COST').queryList() + assert !boResList.isEmpty() : 'Reservation should exist for boWeId' + BigDecimal boQna = boResList.inject(BigDecimal.ZERO) { sum, it -> sum + (it.getBigDecimal('quantityNotAvailable') ?: 0) } + assert scaleQuantity(boQna) == scaleQuantity(2.0) : 'Satisfier should NOT have settled in Traditional Warehouse' + } + + void testS5_6_ReconSafePolicy() { + setUpInventory('WH_SAFE', 'II_MAT_A_COST', 'INV_SAFE_A', 20.0) + setUpInventory('WH_SAFE', 'II_MAT_C_COST', 'INV_SAFE_C_HEAL', 0.0) + String weId = setUpProductionRun('II_PROD_MANUF', 5.0, 'WH_SAFE') + Map issueScrapResult = dispatcher.runSync('issueProductionRunTaskComponent', [ + workEffortId: weId, productId: 'II_MAT_A_COST', quantity: 12.0, + failIfItemsAreNotAvailable: 'Y', userLogin: userLogin + ]) + assert ServiceUtil.isSuccess(issueScrapResult) + dispatcher.runSync('reserveWorkEffortInventoryItem', [ + workEffortId: weId, productId: 'II_MAT_A_COST', facilityId: 'WH_SAFE', + inventoryItemId: 'INV_SAFE_A', quantity: 5.0, quantityNotAvailable: 5.0, + requireInventory: 'N', userLogin: userLogin + ]) + + String boWeId = setUpProductionRun('II_PROD_MANUF', 5.0, 'WH_SAFE') + dispatcher.runSync('issueProductionRunTask', [workEffortId: boWeId, userLogin: userLogin]) + dispatcher.runSync('createInventoryItemDetail', [ + inventoryItemId: 'INV_SAFE_C_HEAL', quantityOnHandDiff: 20.0, + availableToPromiseDiff: 20.0, accountingQuantityDiff: 20.0, userLogin: userLogin + ]) + + dispatcher.runSync('reconcileInventoryForProductionJobs', [userLogin: userLogin]) + + List resList = from('WorkEffortInvRes').where('workEffortId', weId, 'productId', 'II_MAT_A_COST').queryList() + BigDecimal totalQna = resList.inject(BigDecimal.ZERO) { sum, it -> sum + (it.getBigDecimal('quantityNotAvailable') ?: 0) } + assert scaleQuantity(totalQna) == scaleQuantity(0.0) : 'Safe Mode (Aggressive Auditor) should have purged the excess' + + List boResList = from('WorkEffortInvRes').where('workEffortId', boWeId, 'productId', 'II_MAT_C_COST').queryList() + BigDecimal boQna = boResList.inject(BigDecimal.ZERO) { sum, it -> sum + (it.getBigDecimal('quantityNotAvailable') ?: 0) } + assert scaleQuantity(boQna) == scaleQuantity(0.0) : 'Safe Mode (Conservative Satisfier) should have healed backorder' + } + + void testM1_ManualLotReallocationSuccess() { + setUpInventory('WH_FLUID', 'II_MAT_A_COST', 'II_MAN_FLUID', 10.0) + GenericValue item = from('InventoryItem').where('inventoryItemId', 'II_MAN_FLUID').queryOne() + item.set('locationSeqId', 'FLOC_01') + item.set('lotId', 'LOT_MANUAL_01') + item.store() + + dispatcher.runSync('reserveWorkEffortInventoryItem', [ + workEffortId: 'WE_AFFECTED', productId: 'II_MAT_A_COST', facilityId: 'WH_FLUID', + inventoryItemId: 'II_MAN_FLUID', quantity: 10.0, userLogin: userLogin + ]) + + Map result = dispatcher.runSync('issueProductionRunTaskComponent', [ + workEffortId: 'WE_ISSUING_FLUID', productId: 'II_MAT_A_COST', + lotId: 'LOT_MANUAL_01', quantity: 5.0, failIfItemsAreNotAvailable: 'N', userLogin: userLogin + ]) + assert ServiceUtil.isSuccess(result) + + assertInventoryIntegrity('II_MAT_A_COST', 'WE_ISSUING_FLUID', 5.0, 0.0, 0.0, 'WH_FLUID') + GenericValue affectedRes = from('WorkEffortInvRes').where(workEffortId: 'WE_AFFECTED').queryFirst() + assert scaleQuantity(affectedRes.getBigDecimal('quantityNotAvailable')) == scaleQuantity(5.0) : + 'Affected reservation should be backordered by 5' + } + + void testM2_ManualForceNegative() { + setUpInventory('WH_FLUID', 'II_MAT_A_COST', 'II_MAN_FLUID', 0.0) + + Map result = dispatcher.runSync('issueProductionRunTaskComponent', [ + workEffortId: 'WE_ISSUING_FLUID', productId: 'II_MAT_A_COST', + inventoryItemId: 'II_MAN_FLUID', quantity: 10.0, + failIfItemsAreNotAvailable: 'N', failIfItemsAreNotOnHand: 'N', userLogin: userLogin + ]) + assert ServiceUtil.isSuccess(result) + + GenericValue item = from('InventoryItem').where(inventoryItemId: 'II_MAN_FLUID').queryOne() + assert scaleQuantity(item.getBigDecimal('quantityOnHandTotal')) == scaleQuantity(-10.0) : 'QOH should be -10' + } + + void testM3_UserStrictnessOverridesFacilityFluidity() { + setUpInventory('WH_FLUID', 'II_MAT_A_COST', 'II_MAN_FLUID', 10.0) + GenericValue item = from('InventoryItem').where('inventoryItemId', 'II_MAN_FLUID').queryOne() + item.set('lotId', 'LOT_MANUAL_01') + item.store() + dispatcher.runSync('reserveWorkEffortInventoryItem', [ + workEffortId: 'WE_AFFECTED', productId: 'II_MAT_A_COST', facilityId: 'WH_FLUID', + inventoryItemId: 'II_MAN_FLUID', quantity: 10.0, userLogin: userLogin + ]) + + Map result = dispatcher.runSync('issueProductionRunTaskComponent', [ + workEffortId: 'WE_ISSUING_FLUID', productId: 'II_MAT_A_COST', + lotId: 'LOT_MANUAL_01', quantity: 5.0, failIfItemsAreNotAvailable: 'Y', userLogin: userLogin + ], 0, true) + + assert ServiceUtil.isError(result) + String error = ServiceUtil.getErrorMessage(result) + assert error.contains('Materials Not Available') + || error.contains('shortfall') + || error.contains('promised to other tasks') : + "Error should contain 'Materials Not Available' or 'promised to other tasks'. Got: ${error}" + } + + void testM4_FacilityStrictnessOverridesUserFluidity() { + setUpInventory('WH_TRADITIONAL', 'II_MAT_A_COST', 'II_MAN_STRICT', 10.0) + GenericValue item = from('InventoryItem').where('inventoryItemId', 'II_MAN_STRICT').queryOne() + item.set('locationSeqId', 'TLOC_01') + item.store() + dispatcher.runSync('reserveWorkEffortInventoryItem', [ + workEffortId: 'WE_AFFECTED', productId: 'II_MAT_A_COST', facilityId: 'WH_TRADITIONAL', + inventoryItemId: 'II_MAN_STRICT', quantity: 10.0, userLogin: userLogin + ]) + + Map result = dispatcher.runSync('issueProductionRunTaskComponent', [ + workEffortId: 'WE_ISSUING_STRICT', productId: 'II_MAT_A_COST', + locationSeqId: 'TLOC_01', quantity: 5.0, failIfItemsAreNotAvailable: 'N', userLogin: userLogin + ], 0, true) + + assert ServiceUtil.isError(result) + String error = ServiceUtil.getErrorMessage(result) + assert error.contains('Materials Not Available') + || error.contains('shortfall') + || error.contains('forbids inventory reallocation') : + "Error should contain 'Materials Not Available' or 'forbids inventory reallocation'. Got: ${error}" + } + + void testM6_SelfConsumptionSuccessInStrictMode() { + setUpInventory('WH_TRADITIONAL', 'II_MAT_A_COST', 'II_MAN_STRICT', 10.0) + + dispatcher.runSync('reserveWorkEffortInventoryItem', [ + workEffortId: 'WE_ISSUING_STRICT', productId: 'II_MAT_A_COST', facilityId: 'WH_TRADITIONAL', + inventoryItemId: 'II_MAN_STRICT', quantity: 10.0, userLogin: userLogin + ]) + + Map result = dispatcher.runSync('issueProductionRunTaskComponent', [ + workEffortId: 'WE_ISSUING_STRICT', productId: 'II_MAT_A_COST', + inventoryItemId: 'II_MAN_STRICT', quantity: 5.0, failIfItemsAreNotAvailable: 'Y', userLogin: userLogin + ]) + + assert ServiceUtil.isSuccess(result) + assertInventoryIntegrity('II_MAT_A_COST', 'WE_ISSUING_STRICT', 5.0, 5.0, 0.0, 'WH_TRADITIONAL') + } + + void testS5_7_NullPolicyDefaultsToStrict() { + String facId = 'WH_NULL_POLICY' + storeFacility([ + facilityId: facId, facilityName: facId, facilityTypeId: 'WAREHOUSE', + ownerPartyId: 'II_COMPANY', autoReservePrun: 'Y' + ]) + + String issuingWeId = setUpProductionRun('II_PROD_MANUF', 10.0, facId) + + Map impactResult = dispatcher.runSync('getProductionRunTaskForceIssueImpact', [ + workEffortId: issuingWeId, facilityId: facId, productId: 'II_MAT_A_COST', userLogin: userLogin + ]) + + assert ServiceUtil.isSuccess(impactResult) + assert impactResult.policyViolation != null : 'Should contain a policyViolation message' + assert impactResult.policyViolation.contains('forbidden') : 'Message should explain that reallocation is forbidden' + } + + void testFacilityPolicyPersistence() { + String facId = 'WH_POLICY_TEST' + storeFacility([ + facilityId: facId, facilityName: facId, facilityTypeId: 'WAREHOUSE', + ownerPartyId: 'II_COMPANY', autoReservePrun: 'Y', + allowInventoryReallocation: 'Y', reconcilePrunBackorders: 'Y' + ]) + + GenericValue facility = from('Facility').where('facilityId', facId).queryOne() + assert facility != null : 'Facility should be created' + assert facility.allowInventoryReallocation == 'Y' : 'allowInventoryReallocation should be Y' + assert facility.reconcilePrunBackorders == 'Y' : 'reconcilePrunBackorders should be Y' + + facility.set('allowInventoryReallocation', 'N') + facility.set('reconcilePrunBackorders', 'N') + facility.store() + + facility.refresh() + assert facility.allowInventoryReallocation == 'N' : 'allowInventoryReallocation should be N after update' + assert facility.reconcilePrunBackorders == 'N' : 'reconcilePrunBackorders should be N after update' + assert facility.allowInventoryReallocation == 'N' + } + +} + +class InventoryIssuancePolicyMatrixTests extends InventoryIssuanceTestSupport { + + InventoryIssuancePolicyMatrixTests(String name) { + super(name) + } + + @Override + void setUp() { + super.setUp() + userLogin = from('UserLogin').where('userLoginId', 'system').queryOne() + + delegator.removeByCondition('WorkEffortInvRes', EntityCondition.makeCondition('productId', EntityOperator.LIKE, 'II_%')) + delegator.removeByCondition('WorkEffortInventoryAssign', + EntityCondition.makeCondition('inventoryItemId', EntityOperator.LIKE, 'II_%')) + delegator.removeByCondition('InventoryItemDetail', + EntityCondition.makeCondition('inventoryItemId', EntityOperator.LIKE, 'II_%')) + delegator.removeByCondition('InventoryItem', + EntityCondition.makeCondition('inventoryItemId', EntityOperator.LIKE, 'II_%')) + + ensureProductBackbone() + ensureFacilityPolicies() + delegator.clearAllCaches() + Debug.logInfo('InventoryIssuancePolicyMatrixTests: Environment purged for II_% items.', + 'InventoryIssuancePolicyMatrixTests') + } + + void testS6_1_ManualReserve_IssueDefault_Success() { + String weId = setUpNoAutoReserveProductionRun('II_S6_1', 100.0) + dispatcher.runSync('reserveWorkEffortInventoryItem', [ + workEffortId: weId, productId: 'II_MAT_A_COST', facilityId: 'WH_NO_AUTO_RES', + inventoryItemId: 'II_S6_1', quantity: 20.0, userLogin: userLogin + ]) + + Map result = dispatcher.runSync('issueProductionRunTask', [workEffortId: weId, userLogin: userLogin]) + assert ServiceUtil.isSuccess(result) + assertInventoryIntegrity('II_MAT_A_COST', weId, 20.0, 0.0, 80.0, 'WH_NO_AUTO_RES') + } + + void testS6_2_ManualReserve_IssueFailIfAvail_Y_Success() { + String weId = setUpNoAutoReserveProductionRun('II_S6_2', 100.0) + dispatcher.runSync('reserveWorkEffortInventoryItem', [ + workEffortId: weId, productId: 'II_MAT_A_COST', facilityId: 'WH_NO_AUTO_RES', + inventoryItemId: 'II_S6_2', quantity: 20.0, userLogin: userLogin + ]) + + Map result = dispatcher.runSync('issueProductionRunTask', [ + workEffortId: weId, failIfItemsAreNotAvailable: 'Y', userLogin: userLogin + ]) + assert ServiceUtil.isSuccess(result) + assertInventoryIntegrity('II_MAT_A_COST', weId, 20.0, 0.0, 80.0, 'WH_NO_AUTO_RES') + } + + void testS6_3_ManualReserve_IssueFailIfAvail_N_Success() { + String weId = setUpNoAutoReserveProductionRun('II_S6_3', 100.0) + dispatcher.runSync('reserveWorkEffortInventoryItem', [ + workEffortId: weId, productId: 'II_MAT_A_COST', facilityId: 'WH_NO_AUTO_RES', + inventoryItemId: 'II_S6_3', quantity: 20.0, userLogin: userLogin + ]) + + Map result = dispatcher.runSync('issueProductionRunTask', [ + workEffortId: weId, failIfItemsAreNotAvailable: 'N', failIfItemsAreNotOnHand: 'N', userLogin: userLogin + ]) + assert ServiceUtil.isSuccess(result) + assertInventoryIntegrity('II_MAT_A_COST', weId, 20.0, 0.0, 80.0, 'WH_NO_AUTO_RES') + } + + void testS6_4_NoReserve_DirectIssueDefault_Success() { + String weId = setUpNoAutoReserveProductionRun('II_S6_4', 100.0) + + Map result = dispatcher.runSync('issueProductionRunTask', [workEffortId: weId, userLogin: userLogin]) + assert ServiceUtil.isSuccess(result) + assertInventoryIntegrity('II_MAT_A_COST', weId, 20.0, 0.0, 80.0, 'WH_NO_AUTO_RES') + } + + void testS6_5_NoReserve_DirectIssueFailIfAvail_Y_Success() { + String weId = setUpNoAutoReserveProductionRun('II_S6_5', 100.0) + + Map result = dispatcher.runSync('issueProductionRunTask', [ + workEffortId: weId, failIfItemsAreNotAvailable: 'Y', userLogin: userLogin + ]) + assert ServiceUtil.isSuccess(result) + assertInventoryIntegrity('II_MAT_A_COST', weId, 20.0, 0.0, 80.0, 'WH_NO_AUTO_RES') + } + + void testS6_6_NoReserve_DirectIssueFailIfAvail_N_Success() { + String weId = setUpNoAutoReserveProductionRun('II_S6_6', 100.0) + + Map result = dispatcher.runSync('issueProductionRunTask', [ + workEffortId: weId, failIfItemsAreNotAvailable: 'N', failIfItemsAreNotOnHand: 'N', userLogin: userLogin + ]) + assert ServiceUtil.isSuccess(result) + assertInventoryIntegrity('II_MAT_A_COST', weId, 20.0, 0.0, 80.0, 'WH_NO_AUTO_RES') + } + + void testS6_7_Insufficient_ManualReserve_IssueDefault_Fail() { + String weId = setUpNoAutoReserveProductionRun('II_S6_7', 0.0) + dispatcher.runSync('reserveWorkEffortInventoryItem', [ + workEffortId: weId, productId: 'II_MAT_A_COST', facilityId: 'WH_NO_AUTO_RES', + inventoryItemId: 'II_S6_7', quantity: 20.0, userLogin: userLogin + ]) + + Map result = dispatcher.runSync('issueProductionRunTask', [workEffortId: weId, userLogin: userLogin], 0, true) + assert ServiceUtil.isError(result) + } + + void testS6_8_Insufficient_ManualReserve_IssueFailIfAvail_Y_Fail() { + String weId = setUpNoAutoReserveProductionRun('II_S6_8', 0.0) + dispatcher.runSync('reserveWorkEffortInventoryItem', [ + workEffortId: weId, productId: 'II_MAT_A_COST', facilityId: 'WH_NO_AUTO_RES', + inventoryItemId: 'II_S6_8', quantity: 20.0, userLogin: userLogin + ]) + + Map result = dispatcher.runSync('issueProductionRunTask', [ + workEffortId: weId, failIfItemsAreNotAvailable: 'Y', userLogin: userLogin + ], 0, true) + assert ServiceUtil.isError(result) + } + + void testS6_9_Insufficient_ManualReserve_IssueFailIfAvail_N_Success() { + String weId = setUpNoAutoReserveProductionRun('II_S6_9', 0.0) + dispatcher.runSync('reserveWorkEffortInventoryItem', [ + workEffortId: weId, productId: 'II_MAT_A_COST', facilityId: 'WH_NO_AUTO_RES', + inventoryItemId: 'II_S6_9', quantity: 20.0, userLogin: userLogin + ]) + + Map result = dispatcher.runSync('issueProductionRunTask', [ + workEffortId: weId, failIfItemsAreNotAvailable: 'N', failIfItemsAreNotOnHand: 'N', userLogin: userLogin + ]) + assert ServiceUtil.isSuccess(result) + assertInventoryIntegrity('II_MAT_A_COST', weId, 20.0, 0.0, -20.0, 'WH_NO_AUTO_RES') + } + + void testS6_10_Insufficient_NoReserve_DirectIssueDefault_Fail() { + String weId = setUpNoAutoReserveProductionRun('II_S6_10', 0.0) + Map result = dispatcher.runSync('issueProductionRunTask', [workEffortId: weId, userLogin: userLogin], 0, true) + assert ServiceUtil.isError(result) + } + + void testS6_11_Insufficient_NoReserve_DirectIssueFailIfAvail_Y_Fail() { + String weId = setUpNoAutoReserveProductionRun('II_S6_11', 0.0) + Map result = dispatcher.runSync('issueProductionRunTask', [ + workEffortId: weId, failIfItemsAreNotAvailable: 'Y', userLogin: userLogin + ], 0, true) + assert ServiceUtil.isError(result) + } + + void testS6_12_Insufficient_NoReserve_DirectIssueFailIfAvail_N_Success() { + String weId = setUpNoAutoReserveProductionRun('II_S6_12', 0.0) + Map result = dispatcher.runSync('issueProductionRunTask', [ + workEffortId: weId, failIfItemsAreNotAvailable: 'N', failIfItemsAreNotOnHand: 'N', userLogin: userLogin + ]) + assert ServiceUtil.isSuccess(result) + assertInventoryIntegrity('II_MAT_A_COST', weId, 20.0, 0.0, -20.0, 'WH_NO_AUTO_RES') + } + +} + +class InventoryIssuanceTestSupport extends OFBizTestCase { + + protected GenericValue userLogin + + protected InventoryIssuanceTestSupport(String name) { + super(name) + } + + protected void storeFacility(Map fields) { + GenericValue gv = delegator.makeValue('Facility', fields) + delegator.createOrStore(gv) + } + + protected void storeProduct(Map fields) { + GenericValue gv = delegator.makeValue('Product', fields) + delegator.createOrStore(gv) + } + + protected void storeProductAssoc(Map fields) { + GenericValue gv = delegator.makeValue('ProductAssoc', fields) + delegator.createOrStore(gv) + } + + protected void ensureProductBackbone() { + storeProduct([productId: 'II_MAT_D_PRODUCED', productTypeId: 'FINISHED_GOOD', + isVirtual: 'N', isVariant: 'N', internalName: 'Produced Material D']) + storeProduct([productId: 'II_MAT_E_RAW', productTypeId: 'FINISHED_GOOD', + isVirtual: 'N', isVariant: 'N', internalName: 'Raw Material E']) + storeProduct([productId: 'II_PROD_CHAIN', productTypeId: 'FINISHED_GOOD', + isVirtual: 'N', isVariant: 'N', internalName: 'Chain Test Product']) + + java.sql.Timestamp fromDate = java.sql.Timestamp.valueOf('2020-01-01 00:00:00') + storeProductAssoc([productId: 'II_PROD_CHAIN', productIdTo: 'II_MAT_D_PRODUCED', + productAssocTypeId: 'MANUF_COMPONENT', fromDate: fromDate, quantity: 1.0]) + storeProductAssoc([productId: 'II_MAT_D_PRODUCED', productIdTo: 'II_MAT_E_RAW', + productAssocTypeId: 'MANUF_COMPONENT', fromDate: fromDate, quantity: 1.0]) + } + + protected void ensureFacilityPolicies() { + storeFacility([facilityId: 'II_WH', facilityName: 'Test Warehouse', facilityTypeId: 'WAREHOUSE', + ownerPartyId: 'II_COMPANY', autoReservePrun: 'Y', allowInventoryReallocation: 'Y', reconcilePrunBackorders: 'N']) + storeFacility([facilityId: 'WH_FLUID', facilityName: 'Fluid Warehouse', facilityTypeId: 'WAREHOUSE', + ownerPartyId: 'II_COMPANY', autoReservePrun: 'Y', allowInventoryReallocation: 'Y', reconcilePrunBackorders: 'Y']) + storeFacility([facilityId: 'WH_TRADITIONAL', facilityName: 'Traditional Warehouse', + facilityTypeId: 'WAREHOUSE', ownerPartyId: 'II_COMPANY', autoReservePrun: 'Y', allowInventoryReallocation: 'N', + reconcilePrunBackorders: 'N']) + storeFacility([facilityId: 'WH_SAFE', facilityName: 'Safe Recovery Warehouse', + facilityTypeId: 'WAREHOUSE', ownerPartyId: 'II_COMPANY', autoReservePrun: 'Y', allowInventoryReallocation: 'N', + reconcilePrunBackorders: 'Y']) + storeFacility([facilityId: 'WH_NO_AUTO_RES', facilityName: 'No Auto Reservation Warehouse', + facilityTypeId: 'WAREHOUSE', ownerPartyId: 'II_COMPANY', autoReservePrun: 'N', + allowInventoryReallocation: 'Y', reconcilePrunBackorders: 'N']) + } + + protected void setUpInventory(String facilityId, String productId, String itemId, BigDecimal quantity, + boolean purgeExisting = true) { + userLogin = userLogin ?: from('UserLogin').where('userLoginId', 'system').queryOne() + + if (purgeExisting && !from('InventoryItem').where('inventoryItemId', itemId).queryOne()) { + List items = from('InventoryItem').where('productId', productId, 'facilityId', facilityId).queryList() + List itemIds = (List) items.inventoryItemId + if (itemIds) { + delegator.removeByCondition('InventoryItemDetail', + EntityCondition.makeCondition('inventoryItemId', EntityOperator.IN, itemIds)) + delegator.removeByCondition('WorkEffortInventoryAssign', + EntityCondition.makeCondition('inventoryItemId', EntityOperator.IN, itemIds)) + delegator.removeByCondition('WorkEffortInvRes', + EntityCondition.makeCondition('inventoryItemId', EntityOperator.IN, itemIds)) + delegator.removeByCondition('InventoryItem', + EntityCondition.makeCondition('inventoryItemId', EntityOperator.IN, itemIds)) + } + delegator.removeByCondition('WorkEffortInvRes', EntityCondition.makeCondition('productId', productId)) + } + + if (!from('InventoryItem').where('inventoryItemId', itemId).queryOne()) { + delegator.create('InventoryItem', [ + inventoryItemId: itemId, productId: productId, facilityId: facilityId, + inventoryItemTypeId: 'NON_SERIAL_INV_ITEM', ownerPartyId: 'II_COMPANY', + quantityOnHandTotal: 0.0, availableToPromiseTotal: 0.0, accountingQuantityTotal: 0.0, + datetimeReceived: UtilDateTime.nowTimestamp() + ]) + } + + if (quantity != 0) { + delegator.create('InventoryItemDetail', [ + inventoryItemDetailSeqId: delegator.getNextSeqId('InventoryItemDetail'), + inventoryItemId: itemId, + availableToPromiseDiff: quantity, + quantityOnHandDiff: quantity, + accountingQuantityDiff: quantity, + effectiveDate: UtilDateTime.nowTimestamp() + ]) + } + + delegator.clearAllCaches() + dispatcher.runSync('reconcileGlobalReservations', + [inventoryItemId: itemId, amountToIssue: 0.0, userLogin: userLogin]) + } + + // --- Helper Methods --- + + protected BigDecimal scaleQuantity(Object quantity) { + BigDecimal value = (quantity ?: BigDecimal.ZERO) as BigDecimal + return value.setScale(2, RoundingMode.HALF_UP) + } + + protected void assertInventoryIntegrity(String productId, String workEffortId, BigDecimal expectedIssued, + BigDecimal expectedRes, BigDecimal expectedAtp = null, + String facilityId = 'II_WH') { + List assignments = from('WorkEffortInventoryAssign') + .where('workEffortId', workEffortId) + .queryList() + BigDecimal totalIssued = assignments.inject(BigDecimal.ZERO) { sum, item -> + GenericValue inventoryItem = from('InventoryItem').where('inventoryItemId', item.inventoryItemId).queryOne() + if (inventoryItem && inventoryItem.productId == productId) { + return sum + (item.getBigDecimal('quantity') ?: BigDecimal.ZERO) + } + return sum + } + assert totalIssued.setScale(2, RoundingMode.HALF_UP) == expectedIssued.setScale(2, RoundingMode.HALF_UP) : + "Physical Issuance Mismatch for ${productId}" + + List resRecords = from('WorkEffortInvRes') + .where('workEffortId', workEffortId, 'productId', productId) + .queryList() + BigDecimal totalRes = resRecords.inject(BigDecimal.ZERO) { sum, res -> + BigDecimal qty = res.getBigDecimal('quantity') ?: BigDecimal.ZERO + BigDecimal qna = res.getBigDecimal('quantityNotAvailable') ?: BigDecimal.ZERO + return sum + (qty - qna) + } + assert totalRes.setScale(2, RoundingMode.HALF_UP) == expectedRes.setScale(2, RoundingMode.HALF_UP) : + "Net Reservation (Physical) Mismatch for ${productId}" + + List inventoryItems = from('InventoryItem').where('productId', productId, 'facilityId', facilityId) + .cache(false).queryList() + BigDecimal totalAtp = inventoryItems.inject(BigDecimal.ZERO) { sum, item -> + sum + (item.getBigDecimal('availableToPromiseTotal') ?: BigDecimal.ZERO) + } + BigDecimal totalQoh = inventoryItems.inject(BigDecimal.ZERO) { sum, item -> + sum + (item.getBigDecimal('quantityOnHandTotal') ?: BigDecimal.ZERO) + } + BigDecimal totalAccounting = inventoryItems.inject(BigDecimal.ZERO) { sum, item -> + sum + (item.getBigDecimal('accountingQuantityTotal') ?: BigDecimal.ZERO) + } + + BigDecimal globalDebt = from('WorkEffortInvRes').where('productId', productId, 'inventoryItemId', null).queryList() + .inject(BigDecimal.ZERO) { sum, it -> + BigDecimal qty = it.getBigDecimal('quantity') ?: 0 + BigDecimal qna = it.getBigDecimal('quantityNotAvailable') ?: 0 + return sum + (qty - qna) + } + + BigDecimal trueTotalAtp = totalAtp - globalDebt + + if (expectedAtp != null) { + assert trueTotalAtp.setScale(2, RoundingMode.HALF_UP) == expectedAtp.setScale(2, RoundingMode.HALF_UP) : + "ATP Global Pool Mismatch for ${productId}" + } + + assert totalAccounting.setScale(2, RoundingMode.HALF_UP) == totalQoh.setScale(2, RoundingMode.HALF_UP) : + "Ledger Sync (Accounting vs QOH) Mismatch for ${productId}" + + GenericValue wegs = from('WorkEffortGoodStandard') + .where('workEffortId', workEffortId, 'productId', productId, 'workEffortGoodStdTypeId', 'PRUNT_PROD_NEEDED') + .queryFirst() + if (wegs) { + BigDecimal needed = wegs.getBigDecimal('estimatedQuantity') ?: BigDecimal.ZERO + if (totalIssued >= needed && needed > 0) { + assert wegs.statusId == 'WEGS_COMPLETED' : + "Logical Satisfaction Failure: WorkEffortGoodStandard should be WEGS_COMPLETED for fully issued ${productId}" + } else if (totalIssued > 0) { + assert wegs.statusId != 'WEGS_COMPLETED' : + "Logical Satisfaction Failure: WorkEffortGoodStandard should NOT be WEGS_COMPLETED for partially issued ${productId}" + } + } + } + + protected String setUpProductionRun(String productId, BigDecimal quantity, String facilityId = 'II_WH') { + userLogin = userLogin ?: from('UserLogin').where('userLoginId', 'system').queryOne() + assert userLogin != null : 'UserLogin \'system\' must exist for tests' + Map createResult = dispatcher.runSync('createProductionRun', [ + productId: productId, pRQuantity: quantity, + startDate: UtilDateTime.nowTimestamp(), + facilityId: facilityId, userLogin: userLogin + ]) + String productionRunId = (String) createResult.productionRunId + + Map statusResult = dispatcher.runSync('changeProductionRunStatus', [ + productionRunId: productionRunId, statusId: 'PRUN_DOC_PRINTED', + userLogin: userLogin + ]) + assert ServiceUtil.isSuccess(statusResult) + + List tasks = from('WorkEffort').where('workEffortParentId', productionRunId).orderBy('workEffortId').queryList() + return tasks ? (String) tasks[0].workEffortId : productionRunId + } + + protected String setUpNoAutoReserveProductionRun(String matAItemId, BigDecimal matAQuantity) { + storeFacility([facilityId: 'WH_NO_AUTO_RES', facilityName: 'No Auto Reservation Warehouse', + facilityTypeId: 'WAREHOUSE', ownerPartyId: 'II_COMPANY', autoReservePrun: 'N', + allowInventoryReallocation: 'Y', reconcilePrunBackorders: 'N']) + setUpInventory('WH_NO_AUTO_RES', 'II_MAT_A_COST', matAItemId, matAQuantity) + setUpInventory('WH_NO_AUTO_RES', 'II_MAT_C_COST', "${matAItemId}_C", 100.0) + return setUpProductionRun('II_PROD_MANUF', 10.0, 'WH_NO_AUTO_RES') + } + +} diff --git a/applications/manufacturing/testdef/InventoryIssuanceTests.xml b/applications/manufacturing/testdef/InventoryIssuanceTests.xml new file mode 100644 index 0000000000..a1a4712172 --- /dev/null +++ b/applications/manufacturing/testdef/InventoryIssuanceTests.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + diff --git a/applications/manufacturing/testdef/data/InventoryIssuanceTestData.xml b/applications/manufacturing/testdef/data/InventoryIssuanceTestData.xml new file mode 100644 index 0000000000..9b8a6a540d --- /dev/null +++ b/applications/manufacturing/testdef/data/InventoryIssuanceTestData.xml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/applications/manufacturing/webapp/manufacturing/WEB-INF/controller.xml b/applications/manufacturing/webapp/manufacturing/WEB-INF/controller.xml index 99c3fb095b..c06b4f939d 100644 --- a/applications/manufacturing/webapp/manufacturing/WEB-INF/controller.xml +++ b/applications/manufacturing/webapp/manufacturing/WEB-INF/controller.xml @@ -81,6 +81,10 @@ under the License. + + + + @@ -603,6 +607,38 @@ under the License. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -858,6 +894,7 @@ under the License. + @@ -891,6 +928,8 @@ under the License. + + diff --git a/applications/manufacturing/widget/manufacturing/FieldLookupForms.xml b/applications/manufacturing/widget/manufacturing/FieldLookupForms.xml index b00ab077f1..5aceeb98e1 100644 --- a/applications/manufacturing/widget/manufacturing/FieldLookupForms.xml +++ b/applications/manufacturing/widget/manufacturing/FieldLookupForms.xml @@ -83,4 +83,55 @@ under the License. +
+ + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/applications/manufacturing/widget/manufacturing/JobshopScreens.xml b/applications/manufacturing/widget/manufacturing/JobshopScreens.xml index 004e8cadc8..07c4cbe48c 100644 --- a/applications/manufacturing/widget/manufacturing/JobshopScreens.xml +++ b/applications/manufacturing/widget/manufacturing/JobshopScreens.xml @@ -782,4 +782,146 @@ under the License. + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
diff --git a/applications/manufacturing/widget/manufacturing/LookupScreens.xml b/applications/manufacturing/widget/manufacturing/LookupScreens.xml index 82411bb09f..f84d4763f6 100644 --- a/applications/manufacturing/widget/manufacturing/LookupScreens.xml +++ b/applications/manufacturing/widget/manufacturing/LookupScreens.xml @@ -78,4 +78,35 @@ under the License. + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
diff --git a/applications/manufacturing/widget/manufacturing/ProductionRunForms.xml b/applications/manufacturing/widget/manufacturing/ProductionRunForms.xml index 57573320f3..e514189f86 100644 --- a/applications/manufacturing/widget/manufacturing/ProductionRunForms.xml +++ b/applications/manufacturing/widget/manufacturing/ProductionRunForms.xml @@ -250,6 +250,13 @@ under the License. + + + + + + +
@@ -392,8 +399,8 @@ under the License. - - + + @@ -420,12 +427,6 @@ under the License. - - - - @@ -433,7 +434,30 @@ under the License. + + + + + + + + + + + + + + + + + + + + + @@ -1008,5 +1032,125 @@ under the License. - + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + +
+ diff --git a/applications/product/minilang/product/inventory/InventoryReserveServices.xml b/applications/product/minilang/product/inventory/InventoryReserveServices.xml index 41f05fef3c..c556ab1d0c 100644 --- a/applications/product/minilang/product/inventory/InventoryReserveServices.xml +++ b/applications/product/minilang/product/inventory/InventoryReserveServices.xml @@ -43,7 +43,12 @@ under the License. - + + + + + + @@ -200,10 +211,12 @@ under the License. + + @@ -217,8 +230,24 @@ under the License. + + + + + + + + + + + + + + + + @@ -247,8 +276,11 @@ under the License. + - + + + @@ -264,10 +296,12 @@ under the License. + + @@ -281,8 +315,25 @@ under the License. + + + + + + + + + + + + + + + + + @@ -312,6 +363,7 @@ under the License. + @@ -385,10 +437,25 @@ under the License. - - - - + + + + + + + + + + + + + + + + + + + @@ -404,6 +471,7 @@ under the License. + @@ -418,6 +486,21 @@ under the License. + + + + + + + + + + + + + + + @@ -456,12 +539,14 @@ under the License. + + @@ -477,6 +562,21 @@ under the License. + + + + + + + + + + + + + + + @@ -638,6 +738,7 @@ under the License. + diff --git a/applications/product/servicedef/services_facility.xml b/applications/product/servicedef/services_facility.xml index 05ffbc0ef9..e4c627c7f6 100644 --- a/applications/product/servicedef/services_facility.xml +++ b/applications/product/servicedef/services_facility.xml @@ -512,21 +512,9 @@ under the License.
- - - - - - - - - - - - - - - + + + @@ -544,6 +532,9 @@ under the License. + + + @@ -564,21 +555,10 @@ under the License. - - - - - - - - - - - - - - - + + + + @@ -596,6 +576,9 @@ under the License. + + + diff --git a/applications/product/template/facility/EditFacility.ftl b/applications/product/template/facility/EditFacility.ftl index 45ac91fe44..aceb315d73 100644 --- a/applications/product/template/facility/EditFacility.ftl +++ b/applications/product/template/facility/EditFacility.ftl @@ -119,6 +119,33 @@ under the License. ${uiLabelMap.ProductProductDescription} + + ${uiLabelMap.ManufacturingAutoReservePrun} + + + + + + ${uiLabelMap.ManufacturingAllowInventoryReallocation} + + + + + + ${uiLabelMap.ManufacturingReconcilePrunBackorders} + + + + ${uiLabelMap.ProductDefaultDaysToShip} diff --git a/applications/workeffort/entitydef/entitymodel_view.xml b/applications/workeffort/entitydef/entitymodel_view.xml index 301beea306..916c01a6ab 100644 --- a/applications/workeffort/entitydef/entitymodel_view.xml +++ b/applications/workeffort/entitydef/entitymodel_view.xml @@ -608,6 +608,37 @@ under the License. + + Inventory Item and Inventory Reservation For Work Effort + + + + + + + + + + + + + + + + + + + + + + + + +