TJK: Require document on internal stock transfer (bin-aware)#1
TJK: Require document on internal stock transfer (bin-aware)#1gqcorneby wants to merge 18 commits into
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 #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
|
Translates the 22 keys added in this feature branch (4 ActivityCode entries
and 18 stock-transfer-document UI/error keys) into Russian and Tajik. New
strings appended in matching structural locations of each bundle. Placeholders
({0}) and ASCII-escaped Unicode are preserved per the existing file convention.
Quality is machine-tier (consistent with the existing bundles), with domain
terminology anchored against existing translations: stock = захира (TG) /
товар (RU), transfer = интиқол (TG) / перемещение (RU), document = ҳуҷҷат (TG)
/ документ (RU), upload = бор кардан (TG) / загрузить (RU).




📌 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