Require document on internal stock transfer (bin-aware)#1
Require document on internal stock transfer (bin-aware)#1gqcorneby wants to merge 15 commits intorelease/est/tjk/0.9.7from
Conversation
Adds the ability to attach supporting documents (e.g. quarantine
release certificates) to stock transfers, and optionally require at
least one document before completion via a new REQUIRE_TRANSFER_DOCUMENT
activity code configurable per location.
All new code lives under a dedicated custom package
(org.pih.warehouse.custom.stocktransferdocuments,
src/js/custom/stockTransferDocuments, grails-app/i18n/custom/) so the
change pulls cleanly with upstream. Upstream touches are surgical
(~22 lines across 5 files) and documented in
openspec/changes/stock-transfer-document-upload/design.md.
- Backend: new CustomStockTransferDocumentController +
CustomStockTransferDocumentService expose
GET/POST /api/custom/stockTransfers/{id}/documents;
StockTransferService.completeStockTransfer delegates to a new
validator that throws ValidationException when the origin location
enforces the activity code and no document is attached.
- Frontend: new self-contained StockTransferDocumentsPanel mounted
into StockTransferCheckPage; drives the Complete button's disabled
state via a fail-open onCanCompleteChange contract (backend is the
authoritative gate).
- Custom webpack alias registered so custom/* imports resolve
(prerequisite for any future custom frontend feature).
- Tests: Jest (7), Spock unit (8), Spock integration (5) all green.
Initial commit — known bugs to fix in follow-ups.
…ocuments tab - Split REQUIRE_TRANSFER_DOCUMENT into OUT/IN variants for origin/destination - isDocumentRequired now walks order header AND every orderItem bin location - Redirect after completion uses stockTransfer/show (not order/show) - Added read-only Documents tab to stockTransfer/show.gsp via custom template - Fixed re-render loop: bound handleCanCompleteChange in parent constructor - Fail-closed: customCanComplete defaults to false until panel confirms - Updated OpenSpec proposal, design, and tasks for accurate archival - 15 unit tests + 8 integration tests cover all bin-aware scenarios Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…camelCase - Documents section is collapsed by default, auto-expands when required - Clickable header with toggle icon and required badge indicator - Keyboard accessible (Enter/Space to toggle) - Rename backend package from stocktransferdocuments to stockTransferDocuments for consistency with frontend folder naming
- Hide "Transfer Stock" action on stock card when source bin has REQUIRE_TRANSFER_OUT_DOCUMENT activity code - Filter destination bins with REQUIRE_TRANSFER_IN_DOCUMENT from the stock card transfer dialog dropdown - Custom endpoint refreshFilteredBinLocations on existing controller - Update design.md upstream touch points for archival accuracy
xurxodev
left a comment
There was a problem hiding this comment.
thanks @gqcorneby
Disclaimer: I have only reviewed the code in this PR — I have not run it or tested it functionally. I don't have prior knowledge of OpenBoxes or Groovy, but I have identified common patterns from other technologies (MVC with services). I leaned on Claude to assist with this review, abstracting away the low-level technical aspects so I could focus on higher-level concerns.
1. Should fix
-
File upload has no MIME/size guard, client or server.
src/js/custom/stockTransferDocuments/components/StockTransferDocumentsPanel.jsx:176uses<Dropzone>withoutacceptormaxSize.grails-app/services/.../CustomStockTransferDocumentService.groovy:61-79(uploadDocument) readsfileContents.bytesstraight into aDocumentwith no content-type allowlist, no size cap, and no filename sanitization. The repo'sweb/security.mdexplicitly requires both ("Validate MIME type and size on the client for UX, and always validate again server-side.react-dropzoneis the standard — use itsacceptandmaxSizeprops"). Add an allowlist (e.g.application/pdf,image/*) and a max size (e.g. 10 MB) on both sides; reject with 400 + i18n error on the server. -
Re-uploading after a partial failure duplicates already-uploaded files.
StockTransferDocumentsPanel.jsx:95-115runs uploads serially and oncatchonly setsuploadError, leaving the fullpendingFilesarray intact. If file 1/3 succeeds and file 2/3 fails, hitting "Upload" again resends file 1, creating a duplicateDocument. Drop each file frompendingFilesas soon as its upload resolves, or track success/failure per file and only retry the failed ones. -
Fetch error is invisible to the user when the panel is collapsed.
StockTransferDocumentsPanel.jsx:67-71, 143-157— on fetch failure the panel reportscanComplete=false(button disabled) but theWarningis rendered inside{!collapsed && ...}and the panel only auto-expands ondocumentRequired, not onfetchError. Users see a disabled "Complete" button with no explanation. Either auto-expand onfetchError, or render the warning above the collapsible body.
2. Recommendations non blocking
CustomStockTransferDocumentController.uploadswallows the service'sIllegalArgumentException.grails-app/controllers/.../CustomStockTransferDocumentController.groovy:50-62— ifuploadDocumentthrows (no order, save fails), the controller has notry/catchand the user gets a default Grails 500 error page. Wrap in atry/catchand render a JSONerrorMessageso the globalapiClientinterceptor can surface a useful toast.
…checks Addresses PR #1 review (xurxodev): the custom stock transfer document upload had no MIME allowlist, no size cap, and no filename sanitization on either side, contradicting .claude/rules/web/security.md which requires both client- and server-side validation on react-dropzone uploads. Backend (custom): - New UploadConstraints helper: PDF / image / Word / Excel / CSV / ZIP allowlist (MIME + extension, defense-in-depth) and sanitizeFilename() that strips path components, ISO control chars, and Windows-unsafe chars (<>:"|?*), then truncates to 255. - New UploadValidationException carrying an i18n messageCode + args. - CustomStockTransferDocumentService.uploadDocument now validates size, MIME, extension, and filename, persists the sanitized name, and reads the cap from openboxes.custom.stockTransferDocuments.maxUploadSizeBytes (default 10 MB — matches the upstream Document.fileContents GORM ceiling, so raising further would require an upstream-file edit). - Controller catches UploadValidationException, resolves the localized message via messageSource, returns 400 { errorMessage }, and emits a warn audit line (no document bytes). Frontend (custom): - <Dropzone> now sets accept (matching the backend allowlist) and maxSize: 10 MB; onDropRejected surfaces 'file-too-large' / 'file-invalid-type' codes as a localized inline warning. Upstream touch: 6 i18n keys (3 backend + 3 React) appended to the existing custom block in grails-app/i18n/messages.properties. Tests: 3 new Spock cases on the service, 21 cases on UploadConstraints, 2 new Jest cases on the panel. Existing test fixture updated to send a real application/pdf MIME type so it's accepted by the new dropzone.
Addresses PR #1 review (xurxodev): when one file in a batch upload failed, the previous logic left every file (including ones already persisted) in pendingFiles. Hitting Upload again re-sent the successful ones and created duplicate Document rows. Manual testing also surfaced a second failure mode the frontend alone can't fix — a flaky network can cancel a request browser-side after the server has already persisted the file, so the retry inserts a duplicate even with perfect client-side state. Two-layer fix: Frontend (StockTransferDocumentsPanel.jsx): - uploadPendingFiles wraps each upload in its own try/catch over a serial reduce-chain, accumulating failed files; only those are written back to pendingFiles so successful uploads disappear on resolve. - The boolean uploadError became uploadErrorMessage holding either the generic M.uploadError (every file failed) or the new M.partialUploadError (mix of success/failure). - The documents list refreshes only when at least one upload succeeded. Backend (CustomStockTransferDocumentService.uploadDocument): - Before inserting, look up order.documents for an existing Document with the same sanitized filename and byte size; if found, log a dedup line and return without inserting. The (filename, size) heuristic catches the network-flake retry case (same bytes re-sent) without paying for byte comparison or hashing on every upload. UI cleanup: - Removed the verbose contentType column from the uploaded-documents list; the filename link is enough. Upstream touch: 1 i18n key (react.custom.stockTransferDocuments.upload.partialError) appended to the existing custom block in messages.properties. Tests: 2 new Jest cases (partial-failure pending state asserting [a.pdf, b.pdf, c.pdf] with B failing leaves only B pending; retry idempotence asserting exactly 3 API calls across [A,B] + [B-retry]). 1 new Spock case asserting no addToDocuments / save when a same-name-same-size document already exists on the order.
Addresses PR #1 review openboxes#3: when the documents fetch fails on mount, the panel reported canComplete=false (Complete button disabled) but the "Unable to load documents" warning was hidden inside the collapsed body. Auto-expand on fetchError mirrors the existing documentRequired behavior, and the message now ends with "Please refresh to try again." so users have a clear action.
Addresses PR #1 review openboxes#4 (non-blocking): the controller's upload action only caught UploadValidationException, so an unknown order ID (IllegalArgumentException from getOrderOrThrow) or any other unexpected failure leaked a default Grails 500 HTML page instead of JSON the apiClient interceptor could toast. Adds two more catch blocks: IllegalArgumentException → 404 with the service's message (typically "No stock transfer order found for id X"); catch-all Exception → 500 with a generic "Please try again" message and log.error including the stack trace. That's all four PR comments resolved. Ready to commit and push when you are.
|
Thanks @xurxodev! Pushed updates to address the comments
|




📌 References
Purpose
📝 Implementation
CustomStockTransferDocumentService) underorg.pih.warehouse.custom.stocktransferdocuments.*src/js/custom/stockTransferDocuments/GET/POST /api/custom/stockTransfers/{id}/documentsREQUIRE_TRANSFER_OUT_DOCUMENT(outbound) andREQUIRE_TRANSFER_IN_DOCUMENT(inbound)StockTransferService.completeStockTransfer()delegationstockTransfer/show.gspvia custom template✨ Description of Change
Description:
Adds document upload/download to stock transfers with optional enforcement. Admins can enable
REQUIRE_TRANSFER_OUT_DOCUMENTorREQUIRE_TRANSFER_IN_DOCUMENTon a parent depot OR a specific bin (e.g., Quarantine). When enabled, the stock transfer cannot be completed until at least one document is attached.Key design decisions:
customCanCompletedefaults tofalse; the panel must explicitly enable the Complete button after loadingcustom/paths; upstream files have surgical, documented edits (see design.md upstream touch points)📹 Screenshots/Screen capture
Inventory Actions
2026-04-16.12-10-58.mp4
Hide expiry bin because it requires documents to transfer in


Hide transfer stock option because it requires documents to transfer out
🔥 Notes to the tester
To run
destination bin dropdown