diff --git a/docs/superpowers/specs/2026-05-19-adp-application-received-bundled-pages-design.md b/docs/superpowers/specs/2026-05-19-adp-application-received-bundled-pages-design.md new file mode 100644 index 00000000..e547d2ba --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-adp-application-received-bundled-pages-design.md @@ -0,0 +1,284 @@ +# ADP Application Received — Bundled Pages 11 & 12 (Design) + +**Date:** 2026-05-19 +**Module:** `fusion_claims` +**Owner:** Gurpreet +**Status:** Approved (ready for implementation plan) + +## Problem + +When marking an ADP application as Received, the `Application Received` wizard requires two separate PDF uploads: + +1. **Original ADP Application** (`x_fc_original_application`) +2. **Signed Pages 11 & 12** (`x_fc_signed_pages_11_12`) + +In day-to-day operations the office or the client often scans (or emails) the **entire** ADP application as a single PDF — already including signed pages 11 & 12. Today, staff have to manually split pages 11 & 12 out of the bundled PDF and upload them again as a separate file, even though the same signatures are already present in the original PDF. + +The wizard must continue to support the existing flows (separate signed-pages file, remote signing via Page 11 signing request), but it should also accept the bundled case without manual splitting. + +## Goals + +- Allow staff to mark Application Received with **one** PDF when pages 11 & 12 are inside it. +- Preserve the two existing modes (separate file, remote signing). +- Keep downstream audit/case-close checks correct without rewriting every consumer. +- Make the wizard easier to use and slightly safer (real PDF detection, friendlier messages). + +## Non-Goals + +- PDF page extraction or splitting (explicitly rejected by user — "no split"). +- Capturing Page 11 signer identity in the bundled / separate-file modes (existing gap; out of scope). +- Re-architecting the document-attachment model to de-duplicate identical binaries (out of scope). +- Changes to the remote signing wizard or `fusion.page11.sign.request` model. + +## High-Level Approach + +Add a **single boolean flag** on `sale.order` that records whether pages 11 & 12 are inside the original application PDF. Introduce a **computed helper field** that downstream consumers read instead of `x_fc_signed_pages_11_12` directly. Add a **three-mode radio** at the top of the Application Received wizard. + +Minimal blast radius: +- One new boolean, one new computed field on `sale.order`. +- Wizard view + Python rewritten to drive logic off the radio mode. +- Four downstream call sites change which field they read (no logic change). +- Three small complementary fixes folded in (status-gate text, PDF magic-bytes check, page-count indicator). + +## Data Model + +### `sale.order` — new fields + +```python +x_fc_pages_11_12_in_original = fields.Boolean( + string='Pages 11 & 12 in Original Application', + default=False, + tracking=True, + help='True when the original application PDF already contains the signed pages 11 & 12.', +) + +x_fc_has_signed_pages_11_12 = fields.Boolean( + string='Has Signed Pages 11 & 12', + compute='_compute_has_signed_pages_11_12', + store=True, + help='True if pages 11 & 12 are satisfied — either bundled, uploaded separately, ' + 'or signed via remote signing request.', +) + +@api.depends( + 'x_fc_signed_pages_11_12', + 'x_fc_pages_11_12_in_original', + 'page11_sign_request_ids.state', +) +def _compute_has_signed_pages_11_12(self): + for order in self: + order.x_fc_has_signed_pages_11_12 = bool( + order.x_fc_pages_11_12_in_original + or order.x_fc_signed_pages_11_12 + or order.page11_sign_request_ids.filtered(lambda r: r.state == 'signed') + ) +``` + +### Existing fields — unchanged meaning + +- `x_fc_original_application` — original (or bundled) PDF. +- `x_fc_signed_pages_11_12` — separate signed-pages file when one exists. Stays optional. +- `page11_sign_request_ids` — remote signing requests. Unchanged. + +### Audit trail field + +`x_fc_trail_has_signed_pages` already exists at [models/sale_order.py:3248](../../fusion_claims/models/sale_order.py:3248). Its compute body changes from `bool(order.x_fc_signed_pages_11_12)` to `order.x_fc_has_signed_pages_11_12`. + +### Migration + +None. Existing records get `x_fc_pages_11_12_in_original = False` by default; their existing `x_fc_signed_pages_11_12` binary continues to satisfy the new computed gate. Stored compute will populate `x_fc_has_signed_pages_11_12` for legacy rows on first read or recompute. + +## Wizard Changes — `fusion_claims.application.received.wizard` + +### New fields + +```python +intake_mode = fields.Selection( + [ + ('bundled', 'Pages 11 & 12 are INCLUDED in the original application'), + ('separate', 'Pages 11 & 12 are a SEPARATE file'), + ('remote', 'Pages 11 & 12 will be SIGNED REMOTELY'), + ], + string='Intake Mode', + required=True, + default='bundled', +) + +original_page_count = fields.Integer( + string='Original PDF Page Count', + compute='_compute_original_page_count', +) +``` + +`signed_pages_11_12` and `signed_pages_filename` keep their current definitions — they're only required in `separate` mode now. + +The existing computed fields `has_pending_page11_request` and `has_signed_page11` ([wizard/application_received_wizard.py:44-49](../../fusion_claims/wizard/application_received_wizard.py:44)) **stay** — they drive the "request pending" / "remote signature complete" banners now only shown when `intake_mode == 'remote'`. + +### `default_get` — pick an initial mode from existing state + +```python +# When re-opening the wizard on an order that already has some data: +if order.x_fc_pages_11_12_in_original: + res['intake_mode'] = 'bundled' +elif order.x_fc_signed_pages_11_12: + res['intake_mode'] = 'separate' +elif order.page11_sign_request_ids.filtered(lambda r: r.state in ('sent', 'signed')): + res['intake_mode'] = 'remote' +else: + res['intake_mode'] = 'bundled' # new default for fresh records +``` + +### View behaviour (declarative `invisible` on group containers) + +| Mode | Original upload | Signed Pages 11 & 12 upload | Remote-sign banner / button | +|---|---|---|---| +| `bundled` | shown, required | hidden | hidden | +| `separate` | shown, required | shown, required | hidden | +| `remote` | shown, required | hidden | shown (existing `action_request_page11_signature` button) | + +Page count is displayed read-only next to the original-application filename once a PDF is loaded. If `pdfrw` fails to parse, show *"(could not read PDF)"* — does not block confirmation. + +### `action_confirm` (new shape) + +```python +def action_confirm(self): + self.ensure_one() + order = self.sale_order_id + + if order.x_fc_adp_application_status not in ('assessment_completed', 'waiting_for_application'): + raise UserError( + "Can only mark application received from 'Assessment Completed' " + "or 'Waiting for Application' status." + ) + + if not self.original_application: + raise UserError("Please upload the Original ADP Application.") + + self._validate_pdf_bytes(self.original_application, 'Original ADP Application') + + vals = { + 'x_fc_adp_application_status': 'application_received', + 'x_fc_original_application': self.original_application, + 'x_fc_original_application_filename': self.original_application_filename, + 'x_fc_pages_11_12_in_original': (self.intake_mode == 'bundled'), + } + + if self.intake_mode == 'separate': + if not (self.signed_pages_11_12 or order.x_fc_signed_pages_11_12): + raise UserError("Pages 11 & 12 file is required for Separate-file mode.") + if self.signed_pages_11_12: + self._validate_pdf_bytes(self.signed_pages_11_12, 'Signed Pages 11 & 12') + vals['x_fc_signed_pages_11_12'] = self.signed_pages_11_12 + vals['x_fc_signed_pages_filename'] = self.signed_pages_filename + + elif self.intake_mode == 'remote': + has_request = order.page11_sign_request_ids.filtered( + lambda r: r.state in ('sent', 'signed') + ) + if not has_request: + raise UserError( + "Remote-signing request not found. Click 'Request Remote Signature' " + "first, or pick a different mode." + ) + # bundled flag stays False — signature lives in the request's signed_pdf + + order.with_context(skip_status_validation=True).write(vals) + self._post_chatter(order) + return {'type': 'ir.actions.act_window_close'} +``` + +When `intake_mode == 'bundled'`, any pre-existing `x_fc_signed_pages_11_12` from a prior wizard run is left alone (we don't clear it). The bundled flag plus the existing separate file together are harmless — the computed gate is `OR`. + +### PDF magic-bytes check + +```python +def _validate_pdf_bytes(self, b64_data, label): + import base64 + if not b64_data: + return + try: + head = base64.b64decode(b64_data)[:5] + except Exception: + raise UserError(f"{label}: could not decode uploaded file.") + if head != b'%PDF-': + raise UserError(f"{label} must be a PDF file (content check failed).") +``` + +The existing filename `.pdf` check stays in place as a defence-in-depth `@api.constrains`. + +### Chatter message — mode-aware + +| Mode | Headline | Detail line | +|---|---|---| +| `bundled` | *Application Received — bundled* | "Pages 11 & 12 included in original PDF" | +| `separate` | *Application Received — separate files* | "Original + separate signed pages uploaded" | +| `remote` | *Application Received — remote signature pending* | "Page 11 sent for remote signature (`N` request(s) outstanding)" where `N` is the count of `page11_sign_request_ids` in state `sent` or `signed`. | + +Notes from the wizard, if any, are appended below as today. + +## Downstream Consumer Changes + +These are mechanical: change which field they read. **No logic changes.** + +| File | Line | Old | New | +|---|---|---|---| +| [wizard/ready_for_submission_wizard.py:95](../../fusion_claims/wizard/ready_for_submission_wizard.py:95) | `_compute_field_status` | `bool(order.x_fc_original_application and order.x_fc_signed_pages_11_12)` | `bool(order.x_fc_original_application and order.x_fc_has_signed_pages_11_12)` | +| [wizard/ready_for_submission_wizard.py:148](../../fusion_claims/wizard/ready_for_submission_wizard.py:148) | gate check | `if not order.x_fc_signed_pages_11_12` | `if not order.x_fc_has_signed_pages_11_12` | +| [wizard/case_close_verification_wizard.py](../../fusion_claims/wizard/case_close_verification_wizard.py) | wherever pages-11-12 gate is checked | `x_fc_signed_pages_11_12` | `x_fc_has_signed_pages_11_12` | +| [models/sale_order.py:3248](../../fusion_claims/models/sale_order.py:3248) | `x_fc_trail_has_signed_pages` compute | `bool(order.x_fc_signed_pages_11_12)` | `order.x_fc_has_signed_pages_11_12` | + +The `x_fc_signed_pages_11_12` field stays in the data model. Any download / preview / "open document" button that points at the literal binary stays as-is — bundled-mode orders simply won't have this field populated, and the UI should hide the "Open signed pages" button when the field is empty (it already does — Odoo hides empty binary widgets by default). + +## Error / Edge Cases + +| Scenario | Behaviour | +|---|---| +| User toggles from `separate` to `bundled` after uploading a separate file | Wizard does not clear the upload field. On confirm, only the original application is written; bundled flag goes to True. The separate-file binary in the wizard is discarded (it was never written). | +| User picks `remote` but has no sent/signed request | Block with the message above; user must click *Request Remote Signature* first. | +| User picks `bundled` but the PDF is short (e.g. 4 pages) | Page-count indicator shows *"(4 pages)"* as a visual hint, but **does not block**. The 14-page ADP form is the norm but the system can't reliably enforce it across form versions. | +| Legacy record without `x_fc_pages_11_12_in_original` set | Defaults to False. As long as `x_fc_signed_pages_11_12` is present, `x_fc_has_signed_pages_11_12` is True — gate still passes. | +| Stored compute not populated for legacy rows | Triggered on first read or via a one-line `_recompute` on module load is **not** required — Odoo computes on first access. If users hit issues, a one-off psql `UPDATE` can be run manually. | +| Remote signing completes after `bundled` mode was used | `_compute_has_signed_pages_11_12` already ORs in `page11_sign_request_ids.state == 'signed'` — harmless overlap; trail stays correct. | +| Uploaded file is not really a PDF (wrong content) | Magic-byte check raises a UserError; record is not changed. | + +## Testing + +### Unit tests — wizard (`tests/test_application_received_wizard.py`, new) + +- `test_bundled_mode_marks_received_with_only_original` +- `test_separate_mode_requires_signed_pages` +- `test_remote_mode_requires_sent_or_signed_request` +- `test_invalid_pdf_bytes_rejected` +- `test_chatter_message_mentions_intake_mode` + +### Unit tests — downstream gates + +- `test_ready_for_submission_passes_with_bundled_flag` (no `x_fc_signed_pages_11_12` set) +- `test_case_close_audit_accepts_bundled_flag` +- `test_trail_has_signed_pages_true_when_bundled` + +### Manual smoke test on local dev DB + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims --stop-after-init +``` + +Then in the UI: +1. Take an order in *Waiting for Application*. +2. Click *Mark Application Received* → pick **Bundled** → upload a single PDF → confirm. +3. Confirm chatter shows the bundled message and `x_fc_pages_11_12_in_original = True`. +4. Click *Mark Ready for Submission* — the document gate should pass. +5. Repeat on another order with **Separate** mode to confirm the old flow still works. +6. Repeat on a third order with **Remote** mode after triggering a signing request. + +## Rollout + +- Bump `version` in [fusion_claims/__manifest__.py](../../fusion_claims/__manifest__.py). +- `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims --stop-after-init`. +- Reload browser with cache clear (per CLAUDE.md asset-bundle-cache rule). +- No production deploy steps unique to this change. + +## Open Questions (none blocking implementation) + +- Should bundled-mode capture Page 11 signer identity (signer name, relationship) the way the remote flow does? Currently neither bundled nor separate-file modes do — existing gap, deferred. +- Should the bundled-mode chatter automatically attach a one-line note like *"Operator confirms pages 11 & 12 are within the original application"* with the user's name? The default chatter post already records the user. Leaving as-is.