docs(spec): ADP application received — bundled pages 11 & 12 design

Design for refining the Application Received wizard so staff can mark
applications received with a single PDF when pages 11 & 12 are inside
the original application — without losing the existing separate-file
and remote-signing paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-19 14:19:32 -04:00
parent 8831176ec4
commit 5ff271a7b1

View File

@@ -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.