Skip to content

Require document on internal stock transfer (bin-aware)#1

Open
gqcorneby wants to merge 15 commits intorelease/est/tjk/0.9.7from
feature/require-document-location-action
Open

Require document on internal stock transfer (bin-aware)#1
gqcorneby wants to merge 15 commits intorelease/est/tjk/0.9.7from
feature/require-document-location-action

Conversation

@gqcorneby
Copy link
Copy Markdown

@gqcorneby gqcorneby commented Apr 16, 2026

📌 References

Purpose

  • Items in quarantine requires certification before being allowed to be moved out for distribution.
  • Items in distribution that go expired will need some proof or documentation. To "tag" them as expired prior to disposal, they should be transferred to a dedicated bin and upload supporting documents.

📝 Implementation

  • Custom backend service (CustomStockTransferDocumentService) under org.pih.warehouse.custom.stocktransferdocuments.*
  • Custom frontend panel under src/js/custom/stockTransferDocuments/
  • Custom API endpoint GET/POST /api/custom/stockTransfers/{id}/documents
  • Two new activity codes: REQUIRE_TRANSFER_OUT_DOCUMENT (outbound) and REQUIRE_TRANSFER_IN_DOCUMENT (inbound)
  • Bin-aware enforcement: checks order header origin/destination AND every orderItem's originBinLocation/destinationBinLocation
  • Backend gate via StockTransferService.completeStockTransfer() delegation
  • Frontend gate: fail-closed (Complete button disabled until panel confirms)
  • Read-only Documents tab on stockTransfer/show.gsp via custom template
  • updated inventory stock cards action to conditionally hide transfer stock and hide bins in selection that have corresponding activity codes
  • 15 Spock unit tests + 8 integration tests + 7 Jest tests

✨ Description of Change

Description:

Adds document upload/download to stock transfers with optional enforcement. Admins can enable REQUIRE_TRANSFER_OUT_DOCUMENT or REQUIRE_TRANSFER_IN_DOCUMENT on 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:

  • Bin-aware: the validator walks both order-header locations and every order item's bin locations, so the flag can be set on a Quarantine bin without enabling it depot-wide
  • Fail-closed frontend: customCanComplete defaults to false; the panel must explicitly enable the Complete button after loading
  • Upstream isolation: all new code under custom/ 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
image
Hide transfer stock option because it requires documents to transfer out
image

🔥 Notes to the tester

To run

cd docker && docker compose up -d db
./gradlew bootRun -Dserver.port=8081
  1. Enable REQUIRE_TRANSFER_OUT_DOCUMENT on a bin location (e.g., Quarantine) via Admin > Locations > edit location > Supported Activities
  2. Create a stock transfer picking items from that bin
  3. On the Check page, verify the Supporting Documents section is expanded and shows a warning
  4. Verify the Complete button is disabled
  5. Upload a document via the dropzone, click Upload
  6. Verify the Complete button becomes enabled
  7. Complete the transfer — verify redirect to stockTransfer/show with a Documents tab showing the upload
  8. Test without the activity code enabled — verify completion works normally (no regression)
  9. Without the activity code, verify the Supporting Documents section is collapsed by default
  10. Stock card — outbound block: go to the stock card for an item in the Quarantine bin. Verify the "Transfer Stock" action is not shown in the action menu
  11. Stock card — outbound allowed: go to the stock card for an item in a bin without the flag. Verify "Transfer Stock" is shown
  12. Enable REQUIRE_TRANSFER_IN_DOCUMENT on a bin (e.g., a Controlled bin). Open the stock card transfer dialog for any item — verify that bin is not listed in the
    destination bin dropdown
  13. Verify bins without the flag still appear in the destination dropdown

gqcorneby and others added 3 commits April 15, 2026 20:57
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>
@gqcorneby gqcorneby changed the title Require document on stock transfer (bin-aware) Require document on internal stock transfer (bin-aware) Apr 16, 2026
@gqcorneby gqcorneby changed the base branch from develop to release/est/tjk/0.9.7 April 16, 2026 03:31
…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
@gqcorneby gqcorneby requested a review from xurxodev April 24, 2026 09:24
Copy link
Copy Markdown

@xurxodev xurxodev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:176 uses <Dropzone> without accept or maxSize. grails-app/services/.../CustomStockTransferDocumentService.groovy:61-79 (uploadDocument) reads fileContents.bytes straight into a Document with no content-type allowlist, no size cap, and no filename sanitization. The repo's web/security.md explicitly requires both ("Validate MIME type and size on the client for UX, and always validate again server-side. react-dropzone is the standard — use its accept and maxSize props"). 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-115 runs uploads serially and on catch only sets uploadError, leaving the full pendingFiles array intact. If file 1/3 succeeds and file 2/3 fails, hitting "Upload" again resends file 1, creating a duplicate Document. Drop each file from pendingFiles as 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 reports canComplete=false (button disabled) but the Warning is rendered inside {!collapsed && ...} and the panel only auto-expands on documentRequired, not on fetchError. Users see a disabled "Complete" button with no explanation. Either auto-expand on fetchError, or render the warning above the collapsible body.

2. Recommendations non blocking

  • CustomStockTransferDocumentController.upload swallows the service's IllegalArgumentException. grails-app/controllers/.../CustomStockTransferDocumentController.groovy:50-62 — if uploadDocument throws (no order, save fails), the controller has no try/catch and the user gets a default Grails 500 error page. Wrap in a try/catch and render a JSON errorMessage so the global apiClient interceptor 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.
@gqcorneby
Copy link
Copy Markdown
Author

Thanks @xurxodev! Pushed updates to address the comments

File upload has no MIME/size guard, client or server.
Guards added.

  • PDF / image / Word / Excel / CSV / allowlist (MIME + extension)
  • sanitizeFilename() that strips path components, ISO control chars, and Windows-unsafe chars (<>:"|?*), then truncates to 255
  • Guards implemented in frontend and backend
image



Re-uploading after a partial failure duplicates already-uploaded files

  • per-file try/catch on the frontend upload chain (only failed files stay pending)
  • server-side dedup by (filename, size) for the network-flake-but-server-persisted case
image



Fetch error is invisible to the user when the panel is collapsed

  • auto-expand on fetchError
image



CustomStockTransferDocumentController.upload swallows the service's IllegalArgumentException

  • added try/catch for IllegalArgumentException and general catch and render as JSON errorMessage
image

@gqcorneby gqcorneby requested a review from xurxodev April 29, 2026 05:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants