Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions grails-app/conf/spring/resources.groovy
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package spring

import org.pih.warehouse.custom.outboundExpiryRestrictions.service.StockMovementServiceWithExpiryFilter
import org.pih.warehouse.product.ProductValidator
import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.core.Ordered
Expand All @@ -19,4 +20,10 @@ beans = {
order = Ordered.HIGHEST_PRECEDENCE + 1
}
productValidator(ProductValidator)

// outboundExpiryRestrictions: replace stockMovementService with a subclass that
// filters expired AvailableItems from autopick suggestions.
stockMovementService(StockMovementServiceWithExpiryFilter) { bean ->
bean.autowire = 'byName'
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package org.pih.warehouse.custom.outboundExpiryRestrictions

import grails.converters.JSON
import org.pih.warehouse.api.StockMovementType
import org.pih.warehouse.custom.outboundExpiryRestrictions.support.ExpiryRule
import org.pih.warehouse.inventory.InventoryItem
import org.pih.warehouse.inventory.OutboundStockMovement
import org.pih.warehouse.requisition.RequisitionItem

import java.math.BigDecimal
import java.text.DateFormat

class OutboundExpiryGuardInterceptor {

static final String ERROR_CODE = 'outboundExpiryRestrictions.expired.cannotShip'

// Reason: must run AFTER SecurityInterceptor (default order 0) so unauthenticated
// requests are short-circuited before this guard touches the DB or leaks ID existence.
// Matches the pattern used by RoleInterceptor and SentryInterceptor.
int order = LOWEST_PRECEDENCE

def messageSource

OutboundExpiryGuardInterceptor() {
match(controller: 'stockMovementItemApi', action: 'updatePicklist')
}

boolean before() {
def jsonObject = request.JSON
List picklistItems = jsonObject?.picklistItems
if (!picklistItems) {
return true
}

RequisitionItem requisitionItem = RequisitionItem.get(params.id)
if (!requisitionItem) {
return true
}

// STOCK_MOVEMENT β†’ enforce; RETURN_ORDER β†’ allow; null β†’ enforce (fail closed).
OutboundStockMovement parentMovement = OutboundStockMovement.findByRequisition(requisitionItem.requisition)
if (parentMovement && parentMovement.stockMovementType != StockMovementType.STOCK_MOVEMENT) {
return true
}

List<String> ids = picklistItems
.findAll { it?.inventoryItem?.id && isPositiveQuantity(it.quantityPicked) }
.collect { it.inventoryItem.id as String }
if (!ids) {
return true
}

// One projection query instead of N InventoryItem.read() round-trips plus N lazy-product fetches.
List<Object[]> rows = InventoryItem.executeQuery(
'select ii.lotNumber, ii.expirationDate, p.productCode ' +
'from InventoryItem ii left join ii.product p where ii.id in :ids',
[ids: ids]
)

Date today = new Date().clearTime()
List expired = rows.findAll { ExpiryRule.isExpired((Date) it[1], today) }

if (!expired) {
return true
}

DateFormat dateFormat = DateFormat.getDateInstance(DateFormat.SHORT, request.locale)
List<String> errorMessages = expired.collect { formatMessage(it, dateFormat) }
log.info("outbound_expiry_guard_blocked requisition_item_id=${params.id} expired_count=${expired.size()}")

response.status = 400
render([errorCode: ERROR_CODE, errorMessages: errorMessages] as JSON)
return false
}

// Reason: the formatted user-facing string lives only in the i18n bundle
// (`outboundExpiryRestrictions-messages.properties`) so translators have a single
// source of truth. The defaultMessage is just a non-interpolated safety net for the
// catastrophic case where the bundle is missing β€” it never renders in normal operation.
private static final String MISSING_BUNDLE_FALLBACK = 'Expired stock cannot be picked.'

private String formatMessage(row, DateFormat dateFormat) {
String lotNumber = (row[0] ?: '') as String
Date expirationDate = (Date) row[1]
String productCode = (row[2] ?: '') as String
String formattedExpiry = expirationDate ? dateFormat.format(expirationDate) : ''
Object[] args = [productCode, lotNumber, formattedExpiry] as Object[]
return messageSource.getMessage(ERROR_CODE, args, MISSING_BUNDLE_FALLBACK, request.locale)
}

// Reason: must parse as BigDecimal (not Integer) to match upstream's parser. Integer truncates
// "1.5" to 0, which would let an expired-lot row slip past the guard; upstream then crashes
// with ArithmeticException AFTER clearPicklist() runs, wiping the requisition's existing picks.
private static boolean isPositiveQuantity(quantityPicked) {
if (quantityPicked == null) {
return false
}
if (quantityPicked instanceof Number) {
return ((Number) quantityPicked).doubleValue() > 0
}
String s = quantityPicked.toString().trim()
if (!s) {
return false
}
try {
return new BigDecimal(s).signum() > 0
} catch (NumberFormatException ignored) {
return false
}
}
}
5 changes: 5 additions & 0 deletions grails-app/i18n/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4942,6 +4942,11 @@ react.confirmExpirationDate.modal.lot.label=Lot
react.confirmExpirationDate.modal.previousExpiry.label=Previous Expiry
react.confirmExpirationDate.modal.newExpiry.label=New Expiry

# outboundExpiryRestrictions (custom)
outboundExpiryRestrictions.edit.expiredHint=({0} expired)
outboundExpiryRestrictions.expired.cannotShip=Cannot pick lot {1} of product {0} β€” it expired on {2}.
outboundExpiryRestrictions.expired.tooltip=Cannot ship β€” expired on {0}.

# Custom: stock-transfer-document-upload
customStockTransferDocument.required.error=A document is required to complete this stock transfer
customStockTransferDocument.noDocuments.label=No supporting documents
Expand Down
5 changes: 5 additions & 0 deletions grails-app/i18n/messages_ru.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4919,3 +4919,8 @@ customStockTransferDocument.upload.invalidFilename.error=\u0418\u043c\u044f \u04
react.custom.stockTransferDocuments.upload.invalidType.error=\u041d\u0435\u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u044b\u0439 \u0442\u0438\u043f \u0444\u0430\u0439\u043b\u0430. \u0420\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u044b: PDF, \u0438\u0437\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435, Word, Excel, CSV, ZIP.
react.custom.stockTransferDocuments.upload.tooLarge.error=\u0424\u0430\u0439\u043b \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u0431\u043e\u043b\u044c\u0448\u043e\u0439.
react.custom.stockTransferDocuments.upload.invalidFilename.error=\u0418\u043c\u044f \u0444\u0430\u0439\u043b\u0430 \u043e\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0438\u043b\u0438 \u043d\u0435\u043a\u043e\u0440\u0440\u0435\u043a\u0442\u043d\u043e.

# outboundExpiryRestrictions (custom)
outboundExpiryRestrictions.edit.expiredHint=({0} \u043f\u0440\u043e\u0441\u0440\u043e\u0447\u0435\u043d\u043e)
outboundExpiryRestrictions.expired.cannotShip=\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0432\u044b\u0431\u0440\u0430\u0442\u044c \u043f\u0430\u0440\u0442\u0438\u044e {1} \u0442\u043e\u0432\u0430\u0440\u0430 {0} \u2014 \u0441\u0440\u043e\u043a \u0433\u043e\u0434\u043d\u043e\u0441\u0442\u0438 \u0438\u0441\u0442\u0451\u043a {2}.
outboundExpiryRestrictions.expired.tooltip=\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u043e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u044c \u2014 \u0441\u0440\u043e\u043a \u0433\u043e\u0434\u043d\u043e\u0441\u0442\u0438 \u0438\u0441\u0442\u0451\u043a {0}.
5 changes: 5 additions & 0 deletions grails-app/i18n/messages_tg.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4919,3 +4919,8 @@ customStockTransferDocument.upload.invalidFilename.error=\u041d\u043e\u043c\u043
react.custom.stockTransferDocuments.upload.invalidType.error=\u041d\u0430\u0432\u044a\u0438 \u0444\u0430\u0439\u043b\u0438 \u0434\u0430\u0441\u0442\u0433\u0438\u0440\u043d\u0430\u0448\u0430\u0432\u0430\u043d\u0434\u0430. \u0418\u04b7\u043e\u0437\u0430\u0442 \u0434\u043e\u0434\u0430 \u0448\u0443\u0434\u0430\u0430\u0441\u0442: PDF, \u0442\u0430\u0441\u0432\u0438\u0440, Word, Excel, CSV, ZIP.
react.custom.stockTransferDocuments.upload.tooLarge.error=\u0424\u0430\u0439\u043b \u0430\u0437 \u04b3\u0430\u0434 \u0437\u0438\u0451\u0434 \u043a\u0430\u043b\u043e\u043d \u0430\u0441\u0442.
react.custom.stockTransferDocuments.upload.invalidFilename.error=\u041d\u043e\u043c\u0438 \u0444\u0430\u0439\u043b \u043d\u0435\u0441\u0442 \u0451 \u043d\u043e\u0434\u0443\u0440\u0443\u0441\u0442 \u0430\u0441\u0442.

# outboundExpiryRestrictions (custom)
outboundExpiryRestrictions.edit.expiredHint=({0} \u043c\u04ef\u04b3\u043b\u0430\u0442\u0430\u0448 \u0433\u0443\u0437\u0430\u0448\u0442\u0430)
outboundExpiryRestrictions.expired.cannotShip=\u041f\u0430\u0440\u0442\u0438\u044f\u0438 {1}-\u0438 \u043c\u0430\u04b3\u0441\u0443\u043b\u043e\u0442\u0438 {0}-\u0440\u043e \u0438\u043d\u0442\u0438\u0445\u043e\u0431 \u043a\u0430\u0440\u0434\u0430\u043d \u043c\u0443\u043c\u043a\u0438\u043d \u043d\u0435\u0441\u0442 \u2014 \u043c\u04ef\u04b3\u043b\u0430\u0442\u0430\u0448 {2} \u0433\u0443\u0437\u0430\u0448\u0442\u0430\u0430\u0441\u0442.
outboundExpiryRestrictions.expired.tooltip=\u0424\u0438\u0440\u0438\u0441\u0442\u043e\u0434\u0430\u043d \u043c\u0443\u043c\u043a\u0438\u043d \u043d\u0435\u0441\u0442 \u2014 \u043c\u04ef\u04b3\u043b\u0430\u0442\u0430\u0448 {0} \u0433\u0443\u0437\u0430\u0448\u0442\u0430\u0430\u0441\u0442.
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import org.pih.warehouse.core.StockMovementItemParamsCommand
import org.pih.warehouse.core.StockMovementItemsParamsCommand
import org.pih.warehouse.core.User
import org.pih.warehouse.core.UserService
import org.pih.warehouse.custom.outboundExpiryRestrictions.support.ExpiryRule
import org.pih.warehouse.data.DataService
import org.pih.warehouse.forecasting.ForecastingService
import org.pih.warehouse.importer.CSVUtils
Expand Down Expand Up @@ -1031,6 +1032,7 @@ class StockMovementService {
.getAllAvailableBinLocations(requisition.origin, productsIds)
.groupBy { it?.inventoryItem?.product?.id }

Date today = new Date().clearTime()
def editPageItems = data.collect {
def substitutionItems = substitutionItemsMap[it.id]

Expand All @@ -1049,6 +1051,7 @@ class StockMovementService {

def quantityAvailable = availableItems?.findAll { it.quantityAvailable > 0 }?.sum { it.quantityAvailable }
def quantityOnHand = availableItems?.sum { it.quantityOnHand }
def quantityPickable = ExpiryRule.sumPickableQuantity(availableItems, today)
def quantityDemandFulfilling = forecastingService.getDemand(requisition.origin, null, productsMap[it.product_id])

[
Expand All @@ -1063,6 +1066,7 @@ class StockMovementService {
quantityDemandFulfilling : quantityDemandFulfilling ? quantityDemandFulfilling.monthlyDemand : 0,
quantityOnHand : (quantityOnHand && quantityOnHand > 0 ? quantityOnHand : 0),
quantityAvailable : (quantityAvailable && quantityAvailable > 0 ? quantityAvailable : 0),
quantityPickable : quantityPickable,
quantityCounted : it.quantity_counted,
substitutionStatus : it.substitution_status,
sortOrder : it.sort_order,
Expand Down
Loading