diff --git a/grails-app/controllers/org/pih/warehouse/UrlMappings.groovy b/grails-app/controllers/org/pih/warehouse/UrlMappings.groovy index dbd7159a405..75f8951c286 100644 --- a/grails-app/controllers/org/pih/warehouse/UrlMappings.groovy +++ b/grails-app/controllers/org/pih/warehouse/UrlMappings.groovy @@ -619,6 +619,12 @@ class UrlMappings { action = [GET: "refreshFilteredBinLocations"] } + // Custom: putaway-document-upload + "/api/custom/putaways/$id/documents"(parseRequest: true) { + controller = "customPutawayDocument" + action = [GET: "list", POST: "upload"] + } + // Requirement API "/api/requirements"(parseRequest: true) { diff --git a/grails-app/controllers/org/pih/warehouse/custom/putawayDocuments/CustomPutawayDocumentController.groovy b/grails-app/controllers/org/pih/warehouse/custom/putawayDocuments/CustomPutawayDocumentController.groovy new file mode 100644 index 00000000000..a69218d04bb --- /dev/null +++ b/grails-app/controllers/org/pih/warehouse/custom/putawayDocuments/CustomPutawayDocumentController.groovy @@ -0,0 +1,72 @@ +package org.pih.warehouse.custom.putawayDocuments + +import grails.converters.JSON +import org.pih.warehouse.custom.stockTransferDocuments.UploadValidationException +import org.pih.warehouse.order.Order +import org.springframework.context.i18n.LocaleContextHolder +import org.springframework.web.multipart.MultipartFile +import org.springframework.web.multipart.MultipartHttpServletRequest + +class CustomPutawayDocumentController { + + def customPutawayDocumentService + def messageSource + + def list() { + Order order = Order.get(params.id) + if (!order) { + response.status = 404 + render([errorMessage: "No putaway found for order ID ${params.id}"] as JSON) + return + } + + render([ + data: [ + documentRequired: customPutawayDocumentService.isDocumentRequired(params.id), + documents : customPutawayDocumentService.listDocuments(params.id), + ], + ] as JSON) + } + + def upload() { + MultipartFile fileContents = (request instanceof MultipartHttpServletRequest) + ? (request as MultipartHttpServletRequest).getFile("fileContents") + : null + if (!fileContents || fileContents.empty) { + response.status = 400 + render([errorMessage: "fileContents is required"] as JSON) + return + } + + try { + customPutawayDocumentService.uploadDocument(params.id, fileContents) + render([data: "Document was uploaded successfully"] as JSON) + } catch (UploadValidationException ex) { + log.warn "custom_putaway_document_upload_rejected putawayId=${params.id}" + + " code=${ex.messageCode} originalFilename=${fileContents.originalFilename}" + + " contentType=${fileContents.contentType} size=${fileContents.size}" + response.status = 400 + render([errorMessage: resolveMessage(ex)] as JSON) + } catch (IllegalArgumentException ex) { + log.warn "custom_putaway_document_upload_bad_request putawayId=${params.id} message=${ex.message}" + response.status = 404 + render([errorMessage: ex.message] as JSON) + } catch (Exception ex) { + log.error "custom_putaway_document_upload_failed putawayId=${params.id}", ex + response.status = 500 + render([errorMessage: 'Document upload failed. Please try again.'] as JSON) + } + } + + private String resolveMessage(UploadValidationException ex) { + try { + return messageSource.getMessage( + ex.messageCode, + ex.messageArgs, + ex.message, + LocaleContextHolder.locale) + } catch (Exception ignore) { + return ex.message + } + } +} diff --git a/grails-app/i18n/messages.properties b/grails-app/i18n/messages.properties index 4b8333fd77a..f310691e5f4 100644 --- a/grails-app/i18n/messages.properties +++ b/grails-app/i18n/messages.properties @@ -760,8 +760,8 @@ enum.ActivityCode.ENABLE_FULFILLER_APPROVAL_NOTIFICATIONS=Enable fulfiller appro enum.ActivityCode.PACK_SHIPMENT=Pack shipment enum.ActivityCode.PARTIAL_RECEIVING=Partial receiving enum.ActivityCode.REQUIRE_ACCOUNTING=Require accounting -enum.ActivityCode.REQUIRE_TRANSFER_OUT_DOCUMENT=Require document on outbound stock transfer -enum.ActivityCode.REQUIRE_TRANSFER_IN_DOCUMENT=Require document on inbound stock transfer +enum.ActivityCode.REQUIRE_TRANSFER_OUT_DOCUMENT=Require document on outbound +enum.ActivityCode.REQUIRE_TRANSFER_IN_DOCUMENT=Require document on inbound enum.ActivityCode.ENABLE_CENTRAL_PURCHASING=Enable central purchasing enum.ActivityCode.HOLD_STOCK=Hold stock enum.ActivityCode.SUBMIT_REQUEST=Submit request @@ -4330,8 +4330,8 @@ react.locationsConfiguration.ActivityCode.ENABLE_NOTIFICATIONS=Enable Notificati react.locationsConfiguration.ActivityCode.PACK_SHIPMENT=Pack shipment react.locationsConfiguration.ActivityCode.PARTIAL_RECEIVING=Partial receiving react.locationsConfiguration.ActivityCode.REQUIRE_ACCOUNTING=Require accounting -react.locationsConfiguration.ActivityCode.REQUIRE_TRANSFER_OUT_DOCUMENT=Require document on outbound stock transfer -react.locationsConfiguration.ActivityCode.REQUIRE_TRANSFER_IN_DOCUMENT=Require document on inbound stock transfer +react.locationsConfiguration.ActivityCode.REQUIRE_TRANSFER_OUT_DOCUMENT=Require document on outbound +react.locationsConfiguration.ActivityCode.REQUIRE_TRANSFER_IN_DOCUMENT=Require document on inbound react.locationsConfiguration.ActivityCode.ENABLE_CENTRAL_PURCHASING=Enable central purchasing react.locationsConfiguration.ActivityCode.HOLD_STOCK=Hold stock react.locationsConfiguration.ActivityCode.SUBMIT_REQUEST=Submit request @@ -4961,3 +4961,4 @@ customStockTransferDocument.upload.invalidFilename.error=File name is missing or react.custom.stockTransferDocuments.upload.invalidType.error=Unsupported file type. Allowed: PDF, image, Word, Excel, CSV, ZIP. react.custom.stockTransferDocuments.upload.tooLarge.error=File is too large. react.custom.stockTransferDocuments.upload.invalidFilename.error=File name is missing or invalid. +react.custom.putawayDocuments.required.warning=A document must be attached before this putaway can be completed diff --git a/grails-app/i18n/messages_ru.properties b/grails-app/i18n/messages_ru.properties index 50569ed5adc..d45d95be0e3 100644 --- a/grails-app/i18n/messages_ru.properties +++ b/grails-app/i18n/messages_ru.properties @@ -760,8 +760,8 @@ enum.ActivityCode.ENABLE_FULFILLER_APPROVAL_NOTIFICATIONS=\u0412\u043a\u043b\u04 enum.ActivityCode.PACK_SHIPMENT=\u041f\u0430\u043a\u0435\u0442\u043d\u0430\u044f \u043e\u0442\u0433\u0440\u0443\u0437\u043a\u0430 enum.ActivityCode.PARTIAL_RECEIVING=\u0427\u0430\u0441\u0442\u0438\u0447\u043d\u044b\u0439 \u043f\u0440\u0438\u0435\u043c enum.ActivityCode.REQUIRE_ACCOUNTING=\u0422\u0440\u0435\u0431\u043e\u0432\u0430\u0442\u044c \u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0441\u043a\u0438\u0439 \u0443\u0447\u0435\u0442 -enum.ActivityCode.REQUIRE_TRANSFER_OUT_DOCUMENT=\u0422\u0440\u0435\u0431\u043e\u0432\u0430\u0442\u044c \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u043f\u0440\u0438 \u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0435\u043c \u043f\u0435\u0440\u0435\u043c\u0435\u0449\u0435\u043d\u0438\u0438 \u0442\u043e\u0432\u0430\u0440\u0430 -enum.ActivityCode.REQUIRE_TRANSFER_IN_DOCUMENT=\u0422\u0440\u0435\u0431\u043e\u0432\u0430\u0442\u044c \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u043f\u0440\u0438 \u0432\u0445\u043e\u0434\u044f\u0449\u0435\u043c \u043f\u0435\u0440\u0435\u043c\u0435\u0449\u0435\u043d\u0438\u0438 \u0442\u043e\u0432\u0430\u0440\u0430 +enum.ActivityCode.REQUIRE_TRANSFER_OUT_DOCUMENT=\u0422\u0440\u0435\u0431\u043e\u0432\u0430\u0442\u044c \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u0434\u043b\u044f \u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0445 +enum.ActivityCode.REQUIRE_TRANSFER_IN_DOCUMENT=\u0422\u0440\u0435\u0431\u043e\u0432\u0430\u0442\u044c \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u0434\u043b\u044f \u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0445 enum.ActivityCode.ENABLE_CENTRAL_PURCHASING=\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0446\u0435\u043d\u0442\u0440\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d\u043d\u044b\u0435 \u0437\u0430\u043a\u0443\u043f\u043a\u0438 enum.ActivityCode.HOLD_STOCK=\u0414\u0435\u0440\u0436\u0438\u0442\u0435 \u0437\u0430\u043f\u0430\u0441 enum.ActivityCode.SUBMIT_REQUEST=\u041e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u044c \u0437\u0430\u043f\u0440\u043e\u0441 @@ -4297,8 +4297,8 @@ react.locationsConfiguration.ActivityCode.ENABLE_NOTIFICATIONS=\u0412\u043a\u043 react.locationsConfiguration.ActivityCode.PACK_SHIPMENT=\u041f\u0430\u043a\u0435\u0442\u043d\u0430\u044f \u043e\u0442\u0433\u0440\u0443\u0437\u043a\u0430 react.locationsConfiguration.ActivityCode.PARTIAL_RECEIVING=\u0427\u0430\u0441\u0442\u0438\u0447\u043d\u044b\u0439 \u043f\u0440\u0438\u0435\u043c react.locationsConfiguration.ActivityCode.REQUIRE_ACCOUNTING=\u0422\u0440\u0435\u0431\u043e\u0432\u0430\u0442\u044c \u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0441\u043a\u0438\u0439 \u0443\u0447\u0435\u0442 -react.locationsConfiguration.ActivityCode.REQUIRE_TRANSFER_OUT_DOCUMENT=\u0422\u0440\u0435\u0431\u043e\u0432\u0430\u0442\u044c \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u043f\u0440\u0438 \u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0435\u043c \u043f\u0435\u0440\u0435\u043c\u0435\u0449\u0435\u043d\u0438\u0438 \u0442\u043e\u0432\u0430\u0440\u0430 -react.locationsConfiguration.ActivityCode.REQUIRE_TRANSFER_IN_DOCUMENT=\u0422\u0440\u0435\u0431\u043e\u0432\u0430\u0442\u044c \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u043f\u0440\u0438 \u0432\u0445\u043e\u0434\u044f\u0449\u0435\u043c \u043f\u0435\u0440\u0435\u043c\u0435\u0449\u0435\u043d\u0438\u0438 \u0442\u043e\u0432\u0430\u0440\u0430 +react.locationsConfiguration.ActivityCode.REQUIRE_TRANSFER_OUT_DOCUMENT=\u0422\u0440\u0435\u0431\u043e\u0432\u0430\u0442\u044c \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u0434\u043b\u044f \u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0445 +react.locationsConfiguration.ActivityCode.REQUIRE_TRANSFER_IN_DOCUMENT=\u0422\u0440\u0435\u0431\u043e\u0432\u0430\u0442\u044c \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 \u0434\u043b\u044f \u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0445 react.locationsConfiguration.ActivityCode.ENABLE_CENTRAL_PURCHASING=\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0446\u0435\u043d\u0442\u0440\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d\u043d\u044b\u0435 \u0437\u0430\u043a\u0443\u043f\u043a\u0438 react.locationsConfiguration.ActivityCode.HOLD_STOCK=\u0414\u0435\u0440\u0436\u0438\u0442\u0435 \u0437\u0430\u043f\u0430\u0441 react.locationsConfiguration.ActivityCode.SUBMIT_REQUEST=\u041e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u044c \u0437\u0430\u043f\u0440\u043e\u0441 @@ -4919,3 +4919,4 @@ 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. +react.custom.putawayDocuments.required.warning=\u041f\u0435\u0440\u0435\u0434 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u0435\u043c \u0440\u0430\u0437\u043c\u0435\u0449\u0435\u043d\u0438\u044f \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u043f\u0440\u0438\u043a\u0440\u0435\u043f\u0438\u0442\u044c \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442 diff --git a/grails-app/i18n/messages_tg.properties b/grails-app/i18n/messages_tg.properties index 1c507f79f40..7f1b32b013c 100644 --- a/grails-app/i18n/messages_tg.properties +++ b/grails-app/i18n/messages_tg.properties @@ -760,8 +760,8 @@ enum.ActivityCode.ENABLE_FULFILLER_APPROVAL_NOTIFICATIONS=\u041e\u0433\u043e\u04 enum.ActivityCode.PACK_SHIPMENT=\u0418\u043d\u0442\u0438\u049b\u043e\u043b\u0438 \u0431\u0430\u0441\u0442\u0430\u0431\u0430\u043d\u0434\u04e3 enum.ActivityCode.PARTIAL_RECEIVING=\u049a\u0438\u0441\u043c\u0430\u043d \u049b\u0430\u0431\u0443\u043b enum.ActivityCode.REQUIRE_ACCOUNTING=\u0411\u0430\u04b3\u0438\u0441\u043e\u0431\u0433\u0438\u0440\u0438\u0438 \u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0438\u0440\u043e \u0442\u0430\u043b\u0430\u0431 \u043a\u0443\u043d\u0435\u0434 -enum.ActivityCode.REQUIRE_TRANSFER_OUT_DOCUMENT=\u0411\u0430\u0440\u043e\u0438 \u0438\u043d\u0442\u0438\u049b\u043e\u043b\u0438 \u0437\u0430\u0445\u0438\u0440\u0430\u0438 \u0431\u0435\u0440\u0443\u043d\u04e3 \u04b3\u0443\u04b7\u04b7\u0430\u0442 \u0442\u0430\u043b\u0430\u0431 \u043a\u0430\u0440\u0434\u0430 \u0448\u0430\u0432\u0430\u0434 -enum.ActivityCode.REQUIRE_TRANSFER_IN_DOCUMENT=\u0411\u0430\u0440\u043e\u0438 \u0438\u043d\u0442\u0438\u049b\u043e\u043b\u0438 \u0437\u0430\u0445\u0438\u0440\u0430\u0438 \u0434\u0430\u0440\u0443\u043d\u04e3 \u04b3\u0443\u04b7\u04b7\u0430\u0442 \u0442\u0430\u043b\u0430\u0431 \u043a\u0430\u0440\u0434\u0430 \u0448\u0430\u0432\u0430\u0434 +enum.ActivityCode.REQUIRE_TRANSFER_OUT_DOCUMENT=\u0411\u0430\u0440\u043e\u0438 \u0438\u043d\u0442\u0438\u049b\u043e\u043b\u0438 \u0431\u0435\u0440\u0443\u043d\u04e3 \u04b3\u0443\u04b7\u04b7\u0430\u0442 \u0442\u0430\u043b\u0430\u0431 \u043a\u0430\u0440\u0434\u0430 \u0448\u0430\u0432\u0430\u0434 +enum.ActivityCode.REQUIRE_TRANSFER_IN_DOCUMENT=\u0411\u0430\u0440\u043e\u0438 \u0438\u043d\u0442\u0438\u049b\u043e\u043b\u0438 \u0434\u0430\u0440\u0443\u043d\u04e3 \u04b3\u0443\u04b7\u04b7\u0430\u0442 \u0442\u0430\u043b\u0430\u0431 \u043a\u0430\u0440\u0434\u0430 \u0448\u0430\u0432\u0430\u0434 enum.ActivityCode.ENABLE_CENTRAL_PURCHASING=\u0425\u0430\u0440\u0438\u0434\u0438 \u043c\u0430\u0440\u043a\u0430\u0437\u0438\u0440\u043e \u0444\u0430\u044a\u043e\u043b \u0441\u043e\u0437\u0435\u0434 enum.ActivityCode.HOLD_STOCK=\u0417\u0430\u0445\u0438\u0440\u0430 \u043d\u0438\u0433\u043e\u04b3 \u0434\u043e\u0440\u0435\u0434 enum.ActivityCode.SUBMIT_REQUEST=\u041f\u0435\u0448\u043d\u0438\u04b3\u043e\u0434\u0438 \u0434\u0430\u0440\u0445\u043e\u0441\u0442 @@ -4297,8 +4297,8 @@ react.locationsConfiguration.ActivityCode.ENABLE_NOTIFICATIONS=\u041e\u0433\u043 react.locationsConfiguration.ActivityCode.PACK_SHIPMENT=\u0418\u043d\u0442\u0438\u049b\u043e\u043b\u0438 \u0431\u0430\u0441\u0442\u0430\u0431\u0430\u043d\u0434\u04e3 react.locationsConfiguration.ActivityCode.PARTIAL_RECEIVING=\u049a\u0438\u0441\u043c\u0430\u043d \u049b\u0430\u0431\u0443\u043b react.locationsConfiguration.ActivityCode.REQUIRE_ACCOUNTING=\u0411\u0430\u04b3\u0438\u0441\u043e\u0431\u0433\u0438\u0440\u0438\u0438 \u0431\u0443\u0445\u0433\u0430\u043b\u0442\u0435\u0440\u0438\u0440\u043e \u0442\u0430\u043b\u0430\u0431 \u043a\u0443\u043d\u0435\u0434 -react.locationsConfiguration.ActivityCode.REQUIRE_TRANSFER_OUT_DOCUMENT=\u0411\u0430\u0440\u043e\u0438 \u0438\u043d\u0442\u0438\u049b\u043e\u043b\u0438 \u0437\u0430\u0445\u0438\u0440\u0430\u0438 \u0431\u0435\u0440\u0443\u043d\u04e3 \u04b3\u0443\u04b7\u04b7\u0430\u0442 \u0442\u0430\u043b\u0430\u0431 \u043a\u0430\u0440\u0434\u0430 \u0448\u0430\u0432\u0430\u0434 -react.locationsConfiguration.ActivityCode.REQUIRE_TRANSFER_IN_DOCUMENT=\u0411\u0430\u0440\u043e\u0438 \u0438\u043d\u0442\u0438\u049b\u043e\u043b\u0438 \u0437\u0430\u0445\u0438\u0440\u0430\u0438 \u0434\u0430\u0440\u0443\u043d\u04e3 \u04b3\u0443\u04b7\u04b7\u0430\u0442 \u0442\u0430\u043b\u0430\u0431 \u043a\u0430\u0440\u0434\u0430 \u0448\u0430\u0432\u0430\u0434 +react.locationsConfiguration.ActivityCode.REQUIRE_TRANSFER_OUT_DOCUMENT=\u0411\u0430\u0440\u043e\u0438 \u0438\u043d\u0442\u0438\u049b\u043e\u043b\u0438 \u0431\u0435\u0440\u0443\u043d\u04e3 \u04b3\u0443\u04b7\u04b7\u0430\u0442 \u0442\u0430\u043b\u0430\u0431 \u043a\u0430\u0440\u0434\u0430 \u0448\u0430\u0432\u0430\u0434 +react.locationsConfiguration.ActivityCode.REQUIRE_TRANSFER_IN_DOCUMENT=\u0411\u0430\u0440\u043e\u0438 \u0438\u043d\u0442\u0438\u049b\u043e\u043b\u0438 \u0434\u0430\u0440\u0443\u043d\u04e3 \u04b3\u0443\u04b7\u04b7\u0430\u0442 \u0442\u0430\u043b\u0430\u0431 \u043a\u0430\u0440\u0434\u0430 \u0448\u0430\u0432\u0430\u0434 react.locationsConfiguration.ActivityCode.ENABLE_CENTRAL_PURCHASING=\u0425\u0430\u0440\u0438\u0434\u0438 \u043c\u0430\u0440\u043a\u0430\u0437\u0438\u0440\u043e \u0444\u0430\u044a\u043e\u043b \u0441\u043e\u0437\u0435\u0434 react.locationsConfiguration.ActivityCode.HOLD_STOCK=\u0417\u0430\u0445\u0438\u0440\u0430 \u043d\u0438\u0433\u043e\u04b3 \u0434\u043e\u0440\u0435\u0434 react.locationsConfiguration.ActivityCode.SUBMIT_REQUEST=\u041f\u0435\u0448\u043d\u0438\u04b3\u043e\u0434\u0438 \u0434\u0430\u0440\u0445\u043e\u0441\u0442 @@ -4919,3 +4919,4 @@ 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. +react.custom.putawayDocuments.required.warning=\u041f\u0435\u0448 \u0430\u0437 \u043e\u043d \u043a\u0438 \u0438\u043d \u04b7\u043e\u0431\u0430\u04b7\u043e\u0433\u0443\u0437\u043e\u0440\u04e3 \u0430\u043d\u04b7\u043e\u043c \u0434\u043e\u0434\u0430 \u0448\u0430\u0432\u0430\u0434, \u0431\u043e\u044f\u0434 \u04b3\u0443\u04b7\u04b7\u0430\u0442 \u0437\u0430\u043c\u0438\u043c\u0430 \u043a\u0430\u0440\u0434\u0430 \u0448\u0430\u0432\u0430\u0434 diff --git a/grails-app/services/org/pih/warehouse/custom/putawayDocuments/CustomPutawayDocumentService.groovy b/grails-app/services/org/pih/warehouse/custom/putawayDocuments/CustomPutawayDocumentService.groovy new file mode 100644 index 00000000000..9b52023d679 --- /dev/null +++ b/grails-app/services/org/pih/warehouse/custom/putawayDocuments/CustomPutawayDocumentService.groovy @@ -0,0 +1,40 @@ +package org.pih.warehouse.custom.putawayDocuments + +import grails.gorm.transactions.Transactional +import org.pih.warehouse.api.Putaway +import org.pih.warehouse.custom.stockTransferDocuments.CustomStockTransferDocumentService +import org.pih.warehouse.order.Order +import org.springframework.web.multipart.MultipartFile + +@Transactional +class CustomPutawayDocumentService { + + CustomStockTransferDocumentService customStockTransferDocumentService + + Boolean isDocumentRequired(String putawayId) { + Order order = Order.get(putawayId) + return order ? customStockTransferDocumentService.isDocumentRequired(order) : false + } + + List listDocuments(String putawayId) { + Order order = Order.get(putawayId) + return order ? customStockTransferDocumentService.listDocuments(order) : [] + } + + Order uploadDocument(String putawayId, MultipartFile fileContents) { + return customStockTransferDocumentService.uploadDocument(putawayId, fileContents) + } + + // Skips validation when the underlying Order doesn't exist yet — supporting docs can only be + // attached to a saved putaway, so a missing Order can't have a missing-document state. + void validateForCompletion(Putaway putaway) { + if (!putaway?.id) { + return + } + Order order = Order.get(putaway.id) + if (!order) { + return + } + customStockTransferDocumentService.validateForCompletion(order) + } +} diff --git a/grails-app/services/org/pih/warehouse/putaway/PutawayService.groovy b/grails-app/services/org/pih/warehouse/putaway/PutawayService.groovy index af9c97d8fea..31aa218792e 100644 --- a/grails-app/services/org/pih/warehouse/putaway/PutawayService.groovy +++ b/grails-app/services/org/pih/warehouse/putaway/PutawayService.groovy @@ -37,6 +37,7 @@ class PutawayService { LocationService locationService InventoryService inventoryService def productAvailabilityService + def customPutawayDocumentService GrailsApplication grailsApplication def getPutawayCandidates(Location location) { @@ -136,6 +137,7 @@ class PutawayService { Order completePutaway(Putaway putaway) { + customPutawayDocumentService.validateForCompletion(putaway) validatePutaway(putaway) // Save the putaway as a transfer order diff --git a/src/js/components/put-away/PutAwayCheckPage.jsx b/src/js/components/put-away/PutAwayCheckPage.jsx index a43adaf53e6..faf5310e838 100644 --- a/src/js/components/put-away/PutAwayCheckPage.jsx +++ b/src/js/components/put-away/PutAwayCheckPage.jsx @@ -1,5 +1,6 @@ import React, { Component } from 'react'; +import SupportingDocumentsPanel from 'custom/stockTransferDocuments/components/SupportingDocumentsPanel'; import _ from 'lodash'; import PropTypes from 'prop-types'; import { confirmAlert } from 'react-confirm-alert'; @@ -73,11 +74,15 @@ class PutAwayCheckPage extends Component { pivotBy, expanded, location: this.props.location, + customCanComplete: false, }; this.confirmEmptyBin = this.confirmEmptyBin.bind(this); this.confirmLowerQuantity = this.confirmLowerQuantity.bind(this); this.save = this.save.bind(this); + this.handleCanCompleteChange = (canComplete) => { + this.setState({ customCanComplete: canComplete }); + }; } componentWillReceiveProps(nextProps) { @@ -425,6 +430,7 @@ class PutAwayCheckPage extends Component { onClick={() => this.completePutAway()} className="btn btn-outline-secondary btn-xs mr-3" data-testid="complete-putaway-button" + disabled={this.state.customCanComplete === false} > @@ -453,6 +459,16 @@ class PutAwayCheckPage extends Component { ) : null } +
{ this.state.completed @@ -472,6 +488,7 @@ class PutAwayCheckPage extends Component { onClick={() => this.completePutAway()} className="btn btn-outline-primary btn-form float-right btn-xs" data-testid="complete-putaway-button" + disabled={this.state.customCanComplete === false} > diff --git a/src/js/components/stock-transfer/StockTransferCheckPage.jsx b/src/js/components/stock-transfer/StockTransferCheckPage.jsx index 388ab9b1ee1..7d0a0b053bb 100644 --- a/src/js/components/stock-transfer/StockTransferCheckPage.jsx +++ b/src/js/components/stock-transfer/StockTransferCheckPage.jsx @@ -1,6 +1,6 @@ import React, { Component } from 'react'; -import StockTransferDocumentsPanel from 'custom/stockTransferDocuments/components/StockTransferDocumentsPanel'; +import SupportingDocumentsPanel from 'custom/stockTransferDocuments/components/SupportingDocumentsPanel'; import _ from 'lodash'; import PropTypes from 'prop-types'; import { confirmAlert } from 'react-confirm-alert'; @@ -309,8 +309,14 @@ class StockTransferSecondPage extends Component { ) : null } - diff --git a/src/js/custom/stockTransferDocuments/utils/api.js b/src/js/custom/stockTransferDocuments/utils/api.js deleted file mode 100644 index 93caa79799f..00000000000 --- a/src/js/custom/stockTransferDocuments/utils/api.js +++ /dev/null @@ -1,15 +0,0 @@ -import apiClient from 'utils/apiClient'; - -export const stockTransferDocumentsUrl = (stockTransferId) => - `/api/custom/stockTransfers/${stockTransferId}/documents`; - -export const fetchStockTransferDocuments = (stockTransferId) => - apiClient.get(stockTransferDocumentsUrl(stockTransferId)); - -export const uploadStockTransferDocument = (stockTransferId, file) => { - const formData = new FormData(); - formData.append('fileContents', file); - return apiClient.post(stockTransferDocumentsUrl(stockTransferId), formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); -}; diff --git a/src/js/custom/stockTransferDocuments/__tests__/StockTransferDocumentsPanel.test.jsx b/src/js/custom/supportingDocuments/__tests__/SupportingDocumentsPanel.test.jsx similarity index 80% rename from src/js/custom/stockTransferDocuments/__tests__/StockTransferDocumentsPanel.test.jsx rename to src/js/custom/supportingDocuments/__tests__/SupportingDocumentsPanel.test.jsx index 76552a1aed2..4c1e4568251 100644 --- a/src/js/custom/stockTransferDocuments/__tests__/StockTransferDocumentsPanel.test.jsx +++ b/src/js/custom/supportingDocuments/__tests__/SupportingDocumentsPanel.test.jsx @@ -4,10 +4,10 @@ import React from 'react'; import { fireEvent, render, screen, waitFor, } from '@testing-library/react'; -import StockTransferDocumentsPanel from 'custom/stockTransferDocuments/components/StockTransferDocumentsPanel'; +import SupportingDocumentsPanel from 'custom/stockTransferDocuments/components/SupportingDocumentsPanel'; import { - fetchStockTransferDocuments, - uploadStockTransferDocument, + fetchDocuments, + uploadDocument, } from 'custom/stockTransferDocuments/utils/api'; import '@testing-library/jest-dom'; @@ -19,6 +19,7 @@ jest.mock('utils/Translate', () => ({ default: ({ defaultMessage }) => {defaultMessage}, })); +const STOCK_TRANSFER_API_BASE = '/api/custom/stockTransfers'; const STOCK_TRANSFER_ID = 'st-123'; const SAMPLE_DOCUMENT = { id: 'doc-1', @@ -27,11 +28,16 @@ const SAMPLE_DOCUMENT = { uri: '/openboxes/document/download/doc-1', }; +const REQUIRED_WARNING = { + id: 'react.custom.stockTransferDocuments.required.warning', + defaultMessage: + 'A document must be attached before this stock transfer can be completed', +}; + const LABELS = { panelTitle: 'Supporting documents', empty: 'No documents attached yet', - requiredWarning: - 'A document must be attached before this stock transfer can be completed', + requiredWarning: REQUIRED_WARNING.defaultMessage, fetchError: 'Unable to load documents. Please refresh to try again.', uploadError: 'Document upload failed', partialUploadError: 'Some documents failed to upload. The remaining files above can be retried.', @@ -42,18 +48,20 @@ const LABELS = { }; const mockFetchResolved = (payload) => { - fetchStockTransferDocuments.mockResolvedValueOnce({ data: { data: payload } }); + fetchDocuments.mockResolvedValueOnce({ data: { data: payload } }); }; const mockFetchRejected = () => { - fetchStockTransferDocuments.mockRejectedValueOnce(new Error('network')); + fetchDocuments.mockRejectedValueOnce(new Error('network')); }; const renderPanel = (overrides = {}) => { const onCanCompleteChange = jest.fn(); const utils = render( - , @@ -92,7 +100,7 @@ beforeEach(() => { jest.clearAllMocks(); }); -describe('StockTransferDocumentsPanel', () => { +describe('SupportingDocumentsPanel', () => { describe('initial load', () => { it('shows the empty state and reports canComplete=true when not required', async () => { mockFetchResolved({ documentRequired: false, documents: [] }); @@ -110,7 +118,7 @@ describe('StockTransferDocumentsPanel', () => { expect(screen.queryByText(LABELS.requiredWarning)).not.toBeInTheDocument(); }); - it('shows the required warning and reports canComplete=false when required with no documents', async () => { + it('shows the requiredWarning passed via props and reports canComplete=false', async () => { mockFetchResolved({ documentRequired: true, documents: [] }); const { onCanCompleteChange } = renderPanel(); @@ -121,6 +129,21 @@ describe('StockTransferDocumentsPanel', () => { expect(onCanCompleteChange).toHaveBeenLastCalledWith(false); }); + it('renders a different requiredWarning when the caller supplies one', async () => { + const customWarning = { + id: 'react.custom.putawayDocuments.required.warning', + defaultMessage: 'A document must be attached before this putaway can be completed', + }; + mockFetchResolved({ documentRequired: true, documents: [] }); + + renderPanel({ requiredWarning: customWarning }); + + await waitFor(() => { + expect(screen.getByText(customWarning.defaultMessage)).toBeInTheDocument(); + }); + expect(screen.queryByText(LABELS.requiredWarning)).not.toBeInTheDocument(); + }); + it('lists loaded documents and reports canComplete=true when required with documents', async () => { mockFetchResolved({ documentRequired: true, documents: [SAMPLE_DOCUMENT] }); @@ -148,7 +171,7 @@ describe('StockTransferDocumentsPanel', () => { describe('uploading', () => { it('uploads pending files and refreshes the list on success', async () => { mockFetchResolved({ documentRequired: true, documents: [] }); - uploadStockTransferDocument.mockResolvedValueOnce({ + uploadDocument.mockResolvedValueOnce({ data: { data: 'Document was uploaded successfully' }, }); mockFetchResolved({ documentRequired: true, documents: [SAMPLE_DOCUMENT] }); @@ -164,14 +187,18 @@ describe('StockTransferDocumentsPanel', () => { fireEvent.click(screen.getByRole('button', { name: LABELS.uploadButton })); await waitFor(() => { - expect(uploadStockTransferDocument).toHaveBeenCalledWith(STOCK_TRANSFER_ID, file); + expect(uploadDocument).toHaveBeenCalledWith( + STOCK_TRANSFER_API_BASE, + STOCK_TRANSFER_ID, + file, + ); }); expect(await screen.findByText(SAMPLE_DOCUMENT.name)).toBeInTheDocument(); }); it('keeps only the failed files pending and shows partialError on partial failure', async () => { mockFetchResolved({ documentRequired: true, documents: [] }); - uploadStockTransferDocument + uploadDocument .mockResolvedValueOnce({ data: { data: 'ok' } }) .mockRejectedValueOnce(new Error('boom')) .mockResolvedValueOnce({ data: { data: 'ok' } }); @@ -191,12 +218,15 @@ describe('StockTransferDocumentsPanel', () => { fireEvent.click(screen.getByRole('button', { name: LABELS.uploadButton })); await waitFor(() => { - expect(uploadStockTransferDocument).toHaveBeenCalledTimes(3); + expect(uploadDocument).toHaveBeenCalledTimes(3); }); - expect(uploadStockTransferDocument).toHaveBeenNthCalledWith(1, STOCK_TRANSFER_ID, fileA); - expect(uploadStockTransferDocument).toHaveBeenNthCalledWith(2, STOCK_TRANSFER_ID, fileB); - expect(uploadStockTransferDocument).toHaveBeenNthCalledWith(3, STOCK_TRANSFER_ID, fileC); + expect(uploadDocument) + .toHaveBeenNthCalledWith(1, STOCK_TRANSFER_API_BASE, STOCK_TRANSFER_ID, fileA); + expect(uploadDocument) + .toHaveBeenNthCalledWith(2, STOCK_TRANSFER_API_BASE, STOCK_TRANSFER_ID, fileB); + expect(uploadDocument) + .toHaveBeenNthCalledWith(3, STOCK_TRANSFER_API_BASE, STOCK_TRANSFER_ID, fileC); await waitFor(() => { expect(screen.getByText(LABELS.partialUploadError)).toBeInTheDocument(); @@ -208,11 +238,11 @@ describe('StockTransferDocumentsPanel', () => { it('does not re-send already-uploaded files when the user retries after a partial failure', async () => { mockFetchResolved({ documentRequired: true, documents: [] }); - uploadStockTransferDocument + uploadDocument .mockResolvedValueOnce({ data: { data: 'ok' } }) // a.pdf - .mockRejectedValueOnce(new Error('boom')); // b.pdf + .mockRejectedValueOnce(new Error('boom')); // b.pdf mockFetchResolved({ documentRequired: true, documents: [] }); - uploadStockTransferDocument + uploadDocument .mockResolvedValueOnce({ data: { data: 'ok' } }); // b.pdf retry mockFetchResolved({ documentRequired: true, documents: [SAMPLE_DOCUMENT] }); @@ -232,15 +262,15 @@ describe('StockTransferDocumentsPanel', () => { fireEvent.click(screen.getByRole('button', { name: LABELS.uploadButton })); await waitFor(() => { - expect(uploadStockTransferDocument).toHaveBeenCalledTimes(3); + expect(uploadDocument).toHaveBeenCalledTimes(3); }); - const calledFiles = uploadStockTransferDocument.mock.calls.map(([, file]) => file.name); + const calledFiles = uploadDocument.mock.calls.map(([, , file]) => file.name); expect(calledFiles).toEqual(['a.pdf', 'b.pdf', 'b.pdf']); }); it('shows an upload error and keeps pending files when upload fails', async () => { mockFetchResolved({ documentRequired: true, documents: [] }); - uploadStockTransferDocument.mockRejectedValueOnce(new Error('boom')); + uploadDocument.mockRejectedValueOnce(new Error('boom')); const { container } = renderPanel(); @@ -279,7 +309,7 @@ describe('StockTransferDocumentsPanel', () => { await waitFor(() => { expect(screen.getByText(LABELS.invalidTypeError)).toBeInTheDocument(); }); - expect(uploadStockTransferDocument).not.toHaveBeenCalled(); + expect(uploadDocument).not.toHaveBeenCalled(); }); it('rejects an oversize file with an inline warning', async () => { @@ -304,7 +334,7 @@ describe('StockTransferDocumentsPanel', () => { await waitFor(() => { expect(screen.getByText(LABELS.tooLargeError)).toBeInTheDocument(); }); - expect(uploadStockTransferDocument).not.toHaveBeenCalled(); + expect(uploadDocument).not.toHaveBeenCalled(); }); it('removes a pending file when the remove button is clicked', async () => { diff --git a/src/js/custom/stockTransferDocuments/components/StockTransferDocumentsPanel.jsx b/src/js/custom/supportingDocuments/components/SupportingDocumentsPanel.jsx similarity index 89% rename from src/js/custom/stockTransferDocuments/components/StockTransferDocumentsPanel.jsx rename to src/js/custom/supportingDocuments/components/SupportingDocumentsPanel.jsx index 9c192a3828b..387c8ecb951 100644 --- a/src/js/custom/stockTransferDocuments/components/StockTransferDocumentsPanel.jsx +++ b/src/js/custom/supportingDocuments/components/SupportingDocumentsPanel.jsx @@ -3,8 +3,9 @@ import React, { } from 'react'; import { - fetchStockTransferDocuments, - uploadStockTransferDocument, + buildDocumentsUrl, + fetchDocuments, + uploadDocument, } from 'custom/stockTransferDocuments/utils/api'; import M from 'custom/stockTransferDocuments/utils/messages'; import PropTypes from 'prop-types'; @@ -12,9 +13,9 @@ import Dropzone from 'react-dropzone'; import Translate from 'utils/Translate'; -import 'custom/stockTransferDocuments/components/StockTransferDocumentsPanel.scss'; +import 'custom/stockTransferDocuments/components/SupportingDocumentsPanel.scss'; -const BLOCK = 'custom-stock-transfer-documents'; +const BLOCK = 'custom-supporting-documents'; const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; @@ -44,8 +45,10 @@ Warning.propTypes = { defaultMessage: PropTypes.string.isRequired, }; -const StockTransferDocumentsPanel = ({ - stockTransferId, +const SupportingDocumentsPanel = ({ + entityId, + apiBasePath, + requiredWarning, disabled, onCanCompleteChange, }) => { @@ -70,8 +73,8 @@ const StockTransferDocumentsPanel = ({ }, [onCanCompleteChange]); const loadDocuments = useCallback(() => { - if (!stockTransferId) return; - fetchStockTransferDocuments(stockTransferId) + if (!entityId) return; + fetchDocuments(apiBasePath, entityId) .then((response) => { if (!isMountedRef.current) return; const payload = response?.data?.data ?? {}; @@ -87,7 +90,7 @@ const StockTransferDocumentsPanel = ({ setFetchError(true); if (onCanCompleteChange) onCanCompleteChange(false); }); - }, [stockTransferId, reportCanComplete, onCanCompleteChange]); + }, [apiBasePath, entityId, reportCanComplete, onCanCompleteChange]); useEffect(() => { loadDocuments(); @@ -125,7 +128,7 @@ const StockTransferDocumentsPanel = ({ }, []); const uploadPendingFiles = useCallback(async () => { - if (pendingFiles.length === 0 || !stockTransferId) return; + if (pendingFiles.length === 0 || !entityId) return; const attempted = pendingFiles; setUploading(true); setUploadErrorMessage(null); @@ -135,7 +138,7 @@ const StockTransferDocumentsPanel = ({ const failed = await attempted.reduce( (chain, file) => chain.then(async (acc) => { try { - await uploadStockTransferDocument(stockTransferId, file); + await uploadDocument(apiBasePath, entityId, file); return acc; } catch { return [...acc, file]; @@ -158,7 +161,7 @@ const StockTransferDocumentsPanel = ({ if (failed.length < attempted.length) { loadDocuments(); } - }, [pendingFiles, stockTransferId, loadDocuments]); + }, [apiBasePath, pendingFiles, entityId, loadDocuments]); const showRequiredWarning = documentRequired && documents.length === 0; @@ -175,7 +178,7 @@ const StockTransferDocumentsPanel = ({ >

- {collapsed ? '\u25B6' : '\u25BC'} + {collapsed ? '▶' : '▼'} {documentRequired && ( @@ -190,8 +193,8 @@ const StockTransferDocumentsPanel = ({ <> {showRequiredWarning && ( )} @@ -295,16 +298,22 @@ const StockTransferDocumentsPanel = ({ ); }; -StockTransferDocumentsPanel.propTypes = { - stockTransferId: PropTypes.string, +SupportingDocumentsPanel.propTypes = { + entityId: PropTypes.string, + apiBasePath: PropTypes.string.isRequired, + requiredWarning: PropTypes.shape({ + id: PropTypes.string.isRequired, + defaultMessage: PropTypes.string.isRequired, + }).isRequired, disabled: PropTypes.bool, onCanCompleteChange: PropTypes.func, }; -StockTransferDocumentsPanel.defaultProps = { - stockTransferId: null, +SupportingDocumentsPanel.defaultProps = { + entityId: null, disabled: false, onCanCompleteChange: null, }; -export default StockTransferDocumentsPanel; +export { buildDocumentsUrl }; +export default SupportingDocumentsPanel; diff --git a/src/js/custom/stockTransferDocuments/components/StockTransferDocumentsPanel.scss b/src/js/custom/supportingDocuments/components/SupportingDocumentsPanel.scss similarity index 98% rename from src/js/custom/stockTransferDocuments/components/StockTransferDocumentsPanel.scss rename to src/js/custom/supportingDocuments/components/SupportingDocumentsPanel.scss index 3a309dbfdb2..48007abf50f 100644 --- a/src/js/custom/stockTransferDocuments/components/StockTransferDocumentsPanel.scss +++ b/src/js/custom/supportingDocuments/components/SupportingDocumentsPanel.scss @@ -1,4 +1,4 @@ -.custom-stock-transfer-documents { +.custom-supporting-documents { border: 1px solid #dee2e6; border-radius: 4px; padding: 12px 16px; diff --git a/src/js/custom/supportingDocuments/utils/api.js b/src/js/custom/supportingDocuments/utils/api.js new file mode 100644 index 00000000000..fbec7ba5df3 --- /dev/null +++ b/src/js/custom/supportingDocuments/utils/api.js @@ -0,0 +1,15 @@ +import apiClient from 'utils/apiClient'; + +export const buildDocumentsUrl = (apiBasePath, entityId) => + `${apiBasePath}/${entityId}/documents`; + +export const fetchDocuments = (apiBasePath, entityId) => + apiClient.get(buildDocumentsUrl(apiBasePath, entityId)); + +export const uploadDocument = (apiBasePath, entityId, file) => { + const formData = new FormData(); + formData.append('fileContents', file); + return apiClient.post(buildDocumentsUrl(apiBasePath, entityId), formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); +}; diff --git a/src/js/custom/stockTransferDocuments/utils/messages.js b/src/js/custom/supportingDocuments/utils/messages.js similarity index 89% rename from src/js/custom/stockTransferDocuments/utils/messages.js rename to src/js/custom/supportingDocuments/utils/messages.js index 936a487dbfa..c77a9919be2 100644 --- a/src/js/custom/stockTransferDocuments/utils/messages.js +++ b/src/js/custom/supportingDocuments/utils/messages.js @@ -35,11 +35,6 @@ const STOCK_TRANSFER_DOCUMENTS_MESSAGES = { id: 'react.custom.stockTransferDocuments.fetch.error', defaultMessage: 'Unable to load documents. Please refresh to try again.', }, - requiredWarning: { - id: 'react.custom.stockTransferDocuments.required.warning', - defaultMessage: - 'A document must be attached before this stock transfer can be completed', - }, invalidTypeError: { id: 'react.custom.stockTransferDocuments.upload.invalidType.error', defaultMessage: 'Unsupported file type. Allowed: PDF, image, Word, Excel, CSV, ZIP.',