diff --git a/docs/superpowers/plans/2026-05-19-adp-application-received-bundled-pages-plan.md b/docs/superpowers/plans/2026-05-19-adp-application-received-bundled-pages-plan.md new file mode 100644 index 00000000..a7fbc7a7 --- /dev/null +++ b/docs/superpowers/plans/2026-05-19-adp-application-received-bundled-pages-plan.md @@ -0,0 +1,1343 @@ +# ADP Application Received — Bundled Pages 11 & 12 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Refine the ADP `Application Received` wizard so staff can mark applications received with a single PDF when pages 11 & 12 are inside it — while preserving the separate-file and remote-signing paths. + +**Architecture:** Add a single boolean (`x_fc_pages_11_12_in_original`) and a computed helper (`x_fc_has_signed_pages_11_12`) on `sale.order`. Downstream gates (ready-for-submission, case-close, audit trail) read the computed field. The wizard gets a three-mode radio (`bundled` / `separate` / `remote`) that drives both view visibility and `action_confirm` validation. Fold in three small fixes: PDF magic-bytes check, status-gate message, and page-count indicator. + +**Tech Stack:** Odoo 19 (Python), `pdfrw` for PDF page count, XML views. + +**Spec:** [docs/superpowers/specs/2026-05-19-adp-application-received-bundled-pages-design.md](../specs/2026-05-19-adp-application-received-bundled-pages-design.md) + +--- + +## File Structure + +| File | Responsibility | Action | +|---|---|---| +| `fusion_claims/models/sale_order.py` | Order model — add new fields + computed gate; update trail compute | Modify | +| `fusion_claims/wizard/application_received_wizard.py` | Wizard logic — new mode + PDF check + page count + rewrite action_confirm | Modify (large) | +| `fusion_claims/wizard/application_received_wizard_views.xml` | Wizard view — three-mode radio + conditional visibility | Modify | +| `fusion_claims/wizard/ready_for_submission_wizard.py` | Downstream gate — switch to computed field | Modify (2 lines) | +| `fusion_claims/wizard/case_close_verification_wizard.py` | Downstream gate — switch to computed field | Modify (1 line) | +| `fusion_claims/__manifest__.py` | Bump version + add `tests` to data list if needed | Modify | +| `fusion_claims/tests/__init__.py` | Test package init | Create | +| `fusion_claims/tests/test_application_received_wizard.py` | Wizard unit tests | Create | +| `fusion_claims/tests/test_signed_pages_gate.py` | `x_fc_has_signed_pages_11_12` + downstream gate tests | Create | + +--- + +## Task 1: Add `x_fc_pages_11_12_in_original` and `x_fc_has_signed_pages_11_12` to sale.order + +**Files:** +- Modify: `fusion_claims/models/sale_order.py:2895-2911` (insert after existing pages_11_12 fields) +- Create: `fusion_claims/tests/__init__.py` +- Create: `fusion_claims/tests/test_signed_pages_gate.py` + +### Steps + +- [ ] **Step 1: Create tests package init** + +Create `fusion_claims/tests/__init__.py`: + +```python +# -*- coding: utf-8 -*- + +from . import test_signed_pages_gate +from . import test_application_received_wizard +``` + +(`test_application_received_wizard` is created in Task 5 — the import will fail until then. That's fine; we run tests per-file with `--test-tags` until the package is complete.) + +- [ ] **Step 2: Write the failing test for the new fields** + +Create `fusion_claims/tests/test_signed_pages_gate.py`: + +```python +# -*- coding: utf-8 -*- +import base64 + +from odoo.tests.common import TransactionCase, tagged + + +PDF_MAGIC = b'%PDF-1.4\n%fake pdf for tests' + + +def _b64_pdf(): + return base64.b64encode(PDF_MAGIC) + + +@tagged('-at_install', 'post_install', 'fusion_claims') +class TestSignedPagesGate(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env['res.partner'].create({'name': 'Test Client'}) + cls.order = cls.env['sale.order'].create({ + 'partner_id': cls.partner.id, + 'x_fc_adp_application_status': 'waiting_for_application', + }) + + def test_pages_11_12_in_original_defaults_false(self): + self.assertFalse(self.order.x_fc_pages_11_12_in_original) + + def test_has_signed_pages_false_when_nothing_set(self): + self.assertFalse(self.order.x_fc_has_signed_pages_11_12) + + def test_has_signed_pages_true_when_bundled_flag_set(self): + self.order.x_fc_pages_11_12_in_original = True + self.order.flush_recordset() + self.assertTrue(self.order.x_fc_has_signed_pages_11_12) + + def test_has_signed_pages_true_when_separate_file_uploaded(self): + self.order.x_fc_signed_pages_11_12 = _b64_pdf() + self.order.flush_recordset() + self.assertTrue(self.order.x_fc_has_signed_pages_11_12) + + def test_has_signed_pages_true_when_remote_request_signed(self): + self.env['fusion.page11.sign.request'].create({ + 'sale_order_id': self.order.id, + 'signer_email': 'test@example.com', + 'signer_type': 'client', + 'state': 'signed', + }) + self.order.invalidate_recordset() + self.assertTrue(self.order.x_fc_has_signed_pages_11_12) + + def test_has_signed_pages_false_when_remote_request_only_sent(self): + self.env['fusion.page11.sign.request'].create({ + 'sale_order_id': self.order.id, + 'signer_email': 'test@example.com', + 'signer_type': 'client', + 'state': 'sent', + }) + self.order.invalidate_recordset() + self.assertFalse(self.order.x_fc_has_signed_pages_11_12) +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims \ + --test-enable --test-tags=fusion_claims \ + --stop-after-init 2>&1 | tail -40 +``` + +Expected: FAILures referencing `x_fc_pages_11_12_in_original` and/or `x_fc_has_signed_pages_11_12` not existing. + +- [ ] **Step 4: Add the new fields to sale.order** + +Open `fusion_claims/models/sale_order.py`, find the existing block at line 2904-2911: + +```python + x_fc_signed_pages_11_12 = fields.Binary( + string='Page 11 & 12 (Signed)', + attachment=True, + help='Signed pages 11 and 12 of the ADP application', + ) + x_fc_signed_pages_filename = fields.Char( + string='Signed Pages Filename', + ) +``` + +Insert the two new fields immediately after (before the `# PAGE 11 SIGNATURE TRACKING` comment block at line 2913): + +```python + x_fc_pages_11_12_in_original = fields.Boolean( + string='Pages 11 & 12 in Original Application', + default=False, + tracking=True, + copy=False, + 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 in the original ' + 'application, uploaded as a separate file, or signed via remote signing.' + ), + ) + + @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') + ) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims \ + --test-enable --test-tags=fusion_claims:TestSignedPagesGate \ + --stop-after-init 2>&1 | tail -20 +``` + +Expected: 6 tests run, 0 failed. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/gurpreet/Github/Odoo-Modules +git add fusion_claims/models/sale_order.py fusion_claims/tests/__init__.py fusion_claims/tests/test_signed_pages_gate.py +git commit -m "$(cat <<'EOF' +feat(claims): add x_fc_pages_11_12_in_original + computed gate + +New boolean on sale.order tracks whether pages 11 & 12 are bundled +inside the original application PDF. Computed helper +x_fc_has_signed_pages_11_12 ORs bundled flag with separate-file and +remote-signing presence so downstream gates can read one field. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: Update audit trail `x_fc_trail_has_signed_pages` to use new gate + +**Files:** +- Modify: `fusion_claims/models/sale_order.py:3236-3248` +- Modify: `fusion_claims/tests/test_signed_pages_gate.py` (append test) + +### Steps + +- [ ] **Step 1: Add failing test** + +Append to `fusion_claims/tests/test_signed_pages_gate.py`: + +```python + def test_trail_has_signed_pages_true_when_bundled(self): + self.order.x_fc_pages_11_12_in_original = True + self.order.flush_recordset() + self.assertTrue(self.order.x_fc_trail_has_signed_pages) + + def test_trail_has_signed_pages_false_when_nothing(self): + self.assertFalse(self.order.x_fc_trail_has_signed_pages) + + def test_trail_has_signed_pages_true_when_separate_file(self): + self.order.x_fc_signed_pages_11_12 = _b64_pdf() + self.order.flush_recordset() + self.assertTrue(self.order.x_fc_trail_has_signed_pages) +``` + +- [ ] **Step 2: Run test to verify the bundled-flag case fails** + +Run: + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims \ + --test-enable --test-tags=fusion_claims:TestSignedPagesGate.test_trail_has_signed_pages_true_when_bundled \ + --stop-after-init 2>&1 | tail -20 +``` + +Expected: FAIL — `x_fc_trail_has_signed_pages` is False because the existing compute reads `x_fc_signed_pages_11_12` directly. + +- [ ] **Step 3: Update the compute body and depends** + +In `fusion_claims/models/sale_order.py`, change the `@api.depends` block at line 3235-3240 and the assignment at line 3248. + +Find: + +```python + @api.depends( + 'x_fc_assessment_start_date', 'x_fc_assessment_end_date', + 'x_fc_claim_authorization_date', 'x_fc_original_application', + 'x_fc_signed_pages_11_12', 'x_fc_final_submitted_application', + 'x_fc_xml_file', 'x_fc_approval_letter', 'x_fc_proof_of_delivery', + 'x_fc_vendor_bill_ids', 'invoice_ids', 'invoice_ids.state' + ) + def _compute_order_trail(self): +``` + +Replace `'x_fc_signed_pages_11_12'` with `'x_fc_has_signed_pages_11_12'` so the trail recomputes when the new gate flips: + +```python + @api.depends( + 'x_fc_assessment_start_date', 'x_fc_assessment_end_date', + 'x_fc_claim_authorization_date', 'x_fc_original_application', + 'x_fc_has_signed_pages_11_12', 'x_fc_final_submitted_application', + 'x_fc_xml_file', 'x_fc_approval_letter', 'x_fc_proof_of_delivery', + 'x_fc_vendor_bill_ids', 'invoice_ids', 'invoice_ids.state' + ) + def _compute_order_trail(self): +``` + +Then find line 3248: + +```python + order.x_fc_trail_has_signed_pages = bool(order.x_fc_signed_pages_11_12) +``` + +Replace with: + +```python + order.x_fc_trail_has_signed_pages = order.x_fc_has_signed_pages_11_12 +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims \ + --test-enable --test-tags=fusion_claims:TestSignedPagesGate \ + --stop-after-init 2>&1 | tail -20 +``` + +Expected: 9 tests run, 0 failed. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/gurpreet/Github/Odoo-Modules +git add fusion_claims/models/sale_order.py fusion_claims/tests/test_signed_pages_gate.py +git commit -m "$(cat <<'EOF' +feat(claims): audit trail honours bundled pages flag + +x_fc_trail_has_signed_pages now reads x_fc_has_signed_pages_11_12, so +the trail correctly shows complete when pages 11 & 12 are bundled inside +the original application. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: Update `ready_for_submission_wizard` to use the computed gate + +**Files:** +- Modify: `fusion_claims/wizard/ready_for_submission_wizard.py:95, 148` +- Modify: `fusion_claims/tests/test_signed_pages_gate.py` (append test) + +### Steps + +- [ ] **Step 1: Add failing test** + +Append to `fusion_claims/tests/test_signed_pages_gate.py`: + +```python + def test_ready_for_submission_passes_with_bundled_flag_only(self): + """Ready-for-submission gate passes when bundled flag is True even + without a separate signed-pages file.""" + # Move order into Application Received and fill required fields + self.order.write({ + 'x_fc_adp_application_status': 'application_received', + 'x_fc_original_application': _b64_pdf(), + 'x_fc_original_application_filename': 'app.pdf', + 'x_fc_pages_11_12_in_original': True, + 'x_fc_client_ref_1': 'JODO', + 'x_fc_client_ref_2': '1234', + 'x_fc_reason_for_application': 'first_access', + }) + self.order.flush_recordset() + + wizard = self.env['fusion_claims.ready.for.submission.wizard'].with_context( + active_id=self.order.id, active_model='sale.order', + ).create({ + 'sale_order_id': self.order.id, + 'claim_authorization_date': '2026-05-01', + }) + # Should not raise + wizard.action_confirm() + self.assertEqual(self.order.x_fc_adp_application_status, 'ready_submission') +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims \ + --test-enable --test-tags=fusion_claims:TestSignedPagesGate.test_ready_for_submission_passes_with_bundled_flag_only \ + --stop-after-init 2>&1 | tail -30 +``` + +Expected: FAIL — UserError raised by the gate at line 148, because `x_fc_signed_pages_11_12` is empty. + +- [ ] **Step 3: Update the wizard's two read sites** + +In `fusion_claims/wizard/ready_for_submission_wizard.py`, find line 95: + +```python + wizard.has_documents = bool(order.x_fc_original_application and order.x_fc_signed_pages_11_12) +``` + +Replace with: + +```python + wizard.has_documents = bool(order.x_fc_original_application and order.x_fc_has_signed_pages_11_12) +``` + +Then find line 148: + +```python + if not order.x_fc_signed_pages_11_12: + missing.append('Page 11 & 12 Signed (upload in Application Received step)') +``` + +Replace with: + +```python + if not order.x_fc_has_signed_pages_11_12: + missing.append('Page 11 & 12 Signed (upload in Application Received step)') +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims \ + --test-enable --test-tags=fusion_claims:TestSignedPagesGate \ + --stop-after-init 2>&1 | tail -20 +``` + +Expected: 10 tests run, 0 failed. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/gurpreet/Github/Odoo-Modules +git add fusion_claims/wizard/ready_for_submission_wizard.py fusion_claims/tests/test_signed_pages_gate.py +git commit -m "$(cat <<'EOF' +feat(claims): ready-for-submission gate accepts bundled pages flag + +Both the has_documents indicator and the action_confirm missing-items +gate now read x_fc_has_signed_pages_11_12, so orders with pages 11 & 12 +bundled inside the original PDF can move to Ready for Submission without +a separate signed-pages file. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Update `case_close_verification_wizard` to use the computed gate + +**Files:** +- Modify: `fusion_claims/wizard/case_close_verification_wizard.py:81` +- Modify: `fusion_claims/tests/test_signed_pages_gate.py` (append test) + +### Steps + +- [ ] **Step 1: Add failing test** + +Append to `fusion_claims/tests/test_signed_pages_gate.py`: + +```python + def test_case_close_audit_accepts_bundled_flag(self): + """Case-close audit treats bundled flag as 'signed pages present'.""" + self.order.x_fc_pages_11_12_in_original = True + self.order.flush_recordset() + + wizard = self.env['fusion_claims.case.close.verification.wizard'].with_context( + active_id=self.order.id, active_model='sale.order', + ).create({ + 'sale_order_id': self.order.id, + }) + # Force recompute by reading the field + self.assertTrue(wizard.has_signed_pages) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims \ + --test-enable --test-tags=fusion_claims:TestSignedPagesGate.test_case_close_audit_accepts_bundled_flag \ + --stop-after-init 2>&1 | tail -20 +``` + +Expected: FAIL — `wizard.has_signed_pages` is False because the compute reads `x_fc_signed_pages_11_12` directly. + +- [ ] **Step 3: Update the wizard's read site** + +In `fusion_claims/wizard/case_close_verification_wizard.py`, find line 81: + +```python + wizard.has_signed_pages = bool(order.x_fc_signed_pages_11_12) +``` + +Replace with: + +```python + wizard.has_signed_pages = order.x_fc_has_signed_pages_11_12 +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims \ + --test-enable --test-tags=fusion_claims:TestSignedPagesGate \ + --stop-after-init 2>&1 | tail -20 +``` + +Expected: 11 tests run, 0 failed. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/gurpreet/Github/Odoo-Modules +git add fusion_claims/wizard/case_close_verification_wizard.py fusion_claims/tests/test_signed_pages_gate.py +git commit -m "$(cat <<'EOF' +feat(claims): case-close audit accepts bundled pages flag + +The signed-pages verification step on case close now treats the bundled +flag as 'pages present', matching the ready-for-submission gate and the +audit trail. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: Rewrite `application_received_wizard.py` with three-mode `intake_mode` + +**Files:** +- Modify: `fusion_claims/wizard/application_received_wizard.py` (whole file rewrite) +- Create: `fusion_claims/tests/test_application_received_wizard.py` + +### Steps + +- [ ] **Step 1: Create failing tests for the new wizard behaviour** + +Create `fusion_claims/tests/test_application_received_wizard.py`: + +```python +# -*- coding: utf-8 -*- +import base64 + +from odoo.exceptions import UserError +from odoo.tests.common import TransactionCase, tagged + + +PDF_BYTES = b'%PDF-1.4\n%fake pdf for tests' +NOT_PDF_BYTES = b'this is not a pdf' + + +def _b64(data): + return base64.b64encode(data) + + +@tagged('-at_install', 'post_install', 'fusion_claims') +class TestApplicationReceivedWizard(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env['res.partner'].create({'name': 'ARW Test Client'}) + + def _make_order(self): + return self.env['sale.order'].create({ + 'partner_id': self.partner.id, + 'x_fc_adp_application_status': 'waiting_for_application', + }) + + def _open_wizard(self, order, vals=None): + wizard = self.env['fusion_claims.application.received.wizard'].with_context( + active_id=order.id, active_model='sale.order', + ).create({ + 'sale_order_id': order.id, + **(vals or {}), + }) + return wizard + + # ---- bundled mode ---- + def test_bundled_mode_marks_received_with_only_original(self): + order = self._make_order() + wizard = self._open_wizard(order, { + 'intake_mode': 'bundled', + 'original_application': _b64(PDF_BYTES), + 'original_application_filename': 'app.pdf', + }) + wizard.action_confirm() + + self.assertEqual(order.x_fc_adp_application_status, 'application_received') + self.assertTrue(order.x_fc_pages_11_12_in_original) + self.assertFalse(order.x_fc_signed_pages_11_12) + self.assertTrue(order.x_fc_has_signed_pages_11_12) + + # ---- separate mode ---- + def test_separate_mode_requires_signed_pages(self): + order = self._make_order() + wizard = self._open_wizard(order, { + 'intake_mode': 'separate', + 'original_application': _b64(PDF_BYTES), + 'original_application_filename': 'app.pdf', + }) + with self.assertRaises(UserError): + wizard.action_confirm() + + def test_separate_mode_writes_both_files(self): + order = self._make_order() + wizard = self._open_wizard(order, { + 'intake_mode': 'separate', + 'original_application': _b64(PDF_BYTES), + 'original_application_filename': 'app.pdf', + 'signed_pages_11_12': _b64(PDF_BYTES), + 'signed_pages_filename': 'p11_12.pdf', + }) + wizard.action_confirm() + self.assertEqual(order.x_fc_adp_application_status, 'application_received') + self.assertFalse(order.x_fc_pages_11_12_in_original) + self.assertTrue(order.x_fc_signed_pages_11_12) + + # ---- remote mode ---- + def test_remote_mode_requires_sent_or_signed_request(self): + order = self._make_order() + wizard = self._open_wizard(order, { + 'intake_mode': 'remote', + 'original_application': _b64(PDF_BYTES), + 'original_application_filename': 'app.pdf', + }) + with self.assertRaises(UserError): + wizard.action_confirm() + + def test_remote_mode_passes_when_request_sent(self): + order = self._make_order() + self.env['fusion.page11.sign.request'].create({ + 'sale_order_id': order.id, + 'signer_email': 'sign@example.com', + 'signer_type': 'client', + 'state': 'sent', + }) + wizard = self._open_wizard(order, { + 'intake_mode': 'remote', + 'original_application': _b64(PDF_BYTES), + 'original_application_filename': 'app.pdf', + }) + wizard.action_confirm() + self.assertEqual(order.x_fc_adp_application_status, 'application_received') + self.assertFalse(order.x_fc_pages_11_12_in_original) + + # ---- PDF magic-byte check ---- + def test_non_pdf_original_is_rejected(self): + order = self._make_order() + wizard = self._open_wizard(order, { + 'intake_mode': 'bundled', + 'original_application': _b64(NOT_PDF_BYTES), + 'original_application_filename': 'fake.pdf', + }) + with self.assertRaises(UserError): + wizard.action_confirm() + + def test_non_pdf_signed_pages_is_rejected(self): + order = self._make_order() + wizard = self._open_wizard(order, { + 'intake_mode': 'separate', + 'original_application': _b64(PDF_BYTES), + 'original_application_filename': 'app.pdf', + 'signed_pages_11_12': _b64(NOT_PDF_BYTES), + 'signed_pages_filename': 'p11_12.pdf', + }) + with self.assertRaises(UserError): + wizard.action_confirm() + + # ---- status gate ---- + def test_blocks_from_wrong_status(self): + order = self._make_order() + order.x_fc_adp_application_status = 'submitted' + wizard = self._open_wizard(order, { + 'intake_mode': 'bundled', + 'original_application': _b64(PDF_BYTES), + 'original_application_filename': 'app.pdf', + }) + with self.assertRaises(UserError): + wizard.action_confirm() + + # ---- default_get picks initial mode ---- + def _get_defaults(self, order, fields_list=('intake_mode',)): + return self.env['fusion_claims.application.received.wizard'].with_context( + active_id=order.id, active_model='sale.order', + ).default_get(list(fields_list)) + + def test_default_intake_mode_bundled_on_fresh_order(self): + order = self._make_order() + defaults = self._get_defaults(order) + self.assertEqual(defaults.get('intake_mode'), 'bundled') + + def test_default_intake_mode_bundled_when_flag_set(self): + order = self._make_order() + order.x_fc_pages_11_12_in_original = True + defaults = self._get_defaults(order) + self.assertEqual(defaults.get('intake_mode'), 'bundled') + + def test_default_intake_mode_separate_when_file_present(self): + order = self._make_order() + order.x_fc_signed_pages_11_12 = _b64(PDF_BYTES) + order.x_fc_signed_pages_filename = 'p.pdf' + defaults = self._get_defaults(order) + self.assertEqual(defaults.get('intake_mode'), 'separate') + + def test_default_intake_mode_remote_when_request_pending(self): + order = self._make_order() + self.env['fusion.page11.sign.request'].create({ + 'sale_order_id': order.id, + 'signer_email': 'a@b.com', + 'signer_type': 'client', + 'state': 'sent', + }) + defaults = self._get_defaults(order) + self.assertEqual(defaults.get('intake_mode'), 'remote') + + # ---- chatter ---- + def test_chatter_message_mentions_bundled(self): + order = self._make_order() + wizard = self._open_wizard(order, { + 'intake_mode': 'bundled', + 'original_application': _b64(PDF_BYTES), + 'original_application_filename': 'app.pdf', + }) + wizard.action_confirm() + messages = order.message_ids.mapped('body') + self.assertTrue( + any('bundled' in (m or '').lower() or 'included in original' in (m or '').lower() + for m in messages), + f"Expected bundled-mode chatter; got: {messages}", + ) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims \ + --test-enable --test-tags=fusion_claims:TestApplicationReceivedWizard \ + --stop-after-init 2>&1 | tail -30 +``` + +Expected: multiple FAILures, especially `intake_mode` field not found. + +- [ ] **Step 3: Rewrite `application_received_wizard.py`** + +Replace the entire contents of `fusion_claims/wizard/application_received_wizard.py` with: + +```python +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +import base64 +import logging +from datetime import date + +from markupsafe import Markup + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + +try: + import pdfrw +except ImportError: # pragma: no cover + pdfrw = None + + +class ApplicationReceivedWizard(models.TransientModel): + """Wizard to upload ADP application documents when application is received.""" + _name = 'fusion_claims.application.received.wizard' + _description = 'Application Received Wizard' + + sale_order_id = fields.Many2one( + 'sale.order', + string='Sale Order', + required=True, + readonly=True, + ) + + intake_mode = fields.Selection( + 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', + help=( + 'Bundled: a single PDF that already contains the signed pages 11 & 12.\n' + 'Separate: original application + a separate PDF with the signed pages 11 & 12.\n' + 'Remote: send Page 11 to a family member / agent for digital signing.' + ), + ) + + # Document uploads + original_application = fields.Binary( + string='Original ADP Application', + required=True, + help='Upload the original ADP application PDF received from the client', + ) + original_application_filename = fields.Char(string='Application Filename') + + original_page_count = fields.Integer( + string='Original PDF Page Count', + compute='_compute_original_page_count', + help='Number of pages detected in the uploaded original PDF.', + ) + + signed_pages_11_12 = fields.Binary( + string='Signed Pages 11 & 12', + help='Upload the signed pages 11 and 12 from the application ' + '(only used in Separate-file mode).', + ) + signed_pages_filename = fields.Char(string='Pages Filename') + + has_pending_page11_request = fields.Boolean( + compute='_compute_has_pending_page11_request', + ) + has_signed_page11 = fields.Boolean( + compute='_compute_has_pending_page11_request', + ) + + notes = fields.Text( + string='Notes', + help='Any notes about the received application', + ) + + # ------------------------------------------------------------------ + # COMPUTED + # ------------------------------------------------------------------ + @api.depends('sale_order_id') + def _compute_has_pending_page11_request(self): + for wiz in self: + order = wiz.sale_order_id + if order: + requests = order.page11_sign_request_ids + wiz.has_pending_page11_request = bool( + requests.filtered(lambda r: r.state in ('draft', 'sent')) + ) + wiz.has_signed_page11 = bool( + order.x_fc_signed_pages_11_12 + or requests.filtered(lambda r: r.state == 'signed') + ) + else: + wiz.has_pending_page11_request = False + wiz.has_signed_page11 = False + + @api.depends('original_application') + def _compute_original_page_count(self): + for wiz in self: + wiz.original_page_count = wiz._count_pdf_pages(wiz.original_application) + + @staticmethod + def _count_pdf_pages(b64_data): + """Return PDF page count, or 0 if unknown/unparseable.""" + if not b64_data or pdfrw is None: + return 0 + try: + import io + raw = base64.b64decode(b64_data) + reader = pdfrw.PdfReader(fdata=raw) + return len(reader.pages) if reader and reader.pages else 0 + except Exception: # pragma: no cover (corrupted PDFs) + return 0 + + # ------------------------------------------------------------------ + # DEFAULTS + # ------------------------------------------------------------------ + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + active_id = self._context.get('active_id') + if not active_id: + return res + + order = self.env['sale.order'].browse(active_id) + res['sale_order_id'] = order.id + + if order.x_fc_original_application: + res['original_application'] = order.x_fc_original_application + res['original_application_filename'] = order.x_fc_original_application_filename + if order.x_fc_signed_pages_11_12: + res['signed_pages_11_12'] = order.x_fc_signed_pages_11_12 + res['signed_pages_filename'] = order.x_fc_signed_pages_filename + + # Choose initial intake mode based on order state. + 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' + return res + + # ------------------------------------------------------------------ + # CONSTRAINTS (filename defence-in-depth) + # ------------------------------------------------------------------ + @api.constrains('original_application_filename') + def _check_application_file_type(self): + for wizard in self: + name = wizard.original_application_filename + if name and not name.lower().endswith('.pdf'): + raise UserError( + f"Original Application must be a PDF file.\n" + f"Uploaded file: '{name}'" + ) + + @api.constrains('signed_pages_filename') + def _check_pages_file_type(self): + for wizard in self: + name = wizard.signed_pages_filename + if name and not name.lower().endswith('.pdf'): + raise UserError( + f"Signed Pages 11 & 12 must be a PDF file.\n" + f"Uploaded file: '{name}'" + ) + + # ------------------------------------------------------------------ + # ACTIONS + # ------------------------------------------------------------------ + def action_confirm(self): + """Save documents and mark application as received.""" + 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( + "Signed Pages 11 & 12 file is required when " + "'Separate file' mode is selected." + ) + 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( + "No remote-signing request found. Click " + "'Request Remote Signature' first, or pick a different mode." + ) + + order.with_context(skip_status_validation=True).write(vals) + self._post_chatter(order) + return {'type': 'ir.actions.act_window_close'} + + def action_request_page11_signature(self): + """Open the Page 11 remote signing wizard from within this wizard.""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': 'Request Page 11 Signature', + 'res_model': 'fusion_claims.send.page11.wizard', + 'view_mode': 'form', + 'target': 'new', + 'context': {'default_sale_order_id': self.sale_order_id.id}, + } + + # ------------------------------------------------------------------ + # HELPERS + # ------------------------------------------------------------------ + @staticmethod + def _validate_pdf_bytes(b64_data, label): + """Raise UserError if the uploaded binary is not a real PDF.""" + 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 " + f"(content check failed — the file does not start with %PDF-)." + ) + + def _post_chatter(self, order): + """Post a mode-aware Application Received message to the chatter.""" + self.ensure_one() + mode = self.intake_mode + if mode == 'bundled': + headline = 'Application Received — bundled' + detail = 'Pages 11 & 12 included in original PDF' + elif mode == 'separate': + headline = 'Application Received — separate files' + detail = 'Original + separate signed pages uploaded' + else: # remote + n = len(order.page11_sign_request_ids.filtered( + lambda r: r.state in ('sent', 'signed') + )) + headline = 'Application Received — remote signature pending' + detail = f'Page 11 sent for remote signature ({n} request(s) outstanding)' + + notes_html = ( + f'

Notes: {self.notes}

' + if self.notes else '' + ) + body = Markup( + '
' + '

' + ' {headline}

' + '

Date: {today}

' + '

{detail}

' + '

' + 'Original: {orig_name}

' + '{notes}' + '
' + ).format( + headline=headline, + today=date.today().strftime('%B %d, %Y'), + detail=detail, + orig_name=self.original_application_filename or '(no filename)', + notes=notes_html, + ) + order.message_post( + body=body, + message_type='notification', + subtype_xmlid='mail.mt_note', + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims \ + --test-enable --test-tags=fusion_claims:TestApplicationReceivedWizard \ + --stop-after-init 2>&1 | tail -30 +``` + +Expected: 13 tests run, 0 failed. + +- [ ] **Step 5: Run all module tests to confirm nothing regressed** + +Run: + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims \ + --test-enable --test-tags=fusion_claims \ + --stop-after-init 2>&1 | tail -20 +``` + +Expected: 24 tests run (11 from `TestSignedPagesGate` + 13 from `TestApplicationReceivedWizard`), 0 failed. + +- [ ] **Step 6: Commit** + +```bash +cd /Users/gurpreet/Github/Odoo-Modules +git add fusion_claims/wizard/application_received_wizard.py fusion_claims/tests/test_application_received_wizard.py +git commit -m "$(cat <<'EOF' +feat(claims): three-mode Application Received wizard + +Adds intake_mode (bundled / separate / remote) so staff can mark +applications received with a single bundled PDF, the existing +separate-pages-file flow, or a pending remote signature. Folds in +content-based PDF validation, a friendlier status-gate message, +and a page-count helper for the original application. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: Update wizard view — radio + conditional visibility + page count + +**Files:** +- Modify: `fusion_claims/wizard/application_received_wizard_views.xml` + +### Steps + +- [ ] **Step 1: Replace the wizard form view** + +Replace the contents of `fusion_claims/wizard/application_received_wizard_views.xml` with: + +```xml + + + + fusion_claims.application.received.wizard.form + fusion_claims.application.received.wizard + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + Don't have signed pages? Send a remote signing link to a family + member or agent. + +
+ +
+
+ + A remote signing request has been sent. You can confirm now - + the signed PDF will be auto-attached when received. +
+
+ +
+
+ + Page 11 has been signed remotely. +
+
+
+
+ + + + + +
+
+ +
+
+ + + Application Received + fusion_claims.application.received.wizard + form + new + +
+``` + +- [ ] **Step 2: Update the module so the new XML loads** + +Run: + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims \ + --stop-after-init 2>&1 | tail -15 +``` + +Expected: no errors. The module reload should complete cleanly. + +- [ ] **Step 3: Manual smoke test (UI)** + +Open http://localhost:8069 in a browser. Hard-refresh to drop cached assets. + +1. Open a Sales Order whose `ADP Application Status` is `Waiting for Application` (or `Assessment Completed`). +2. Click *Mark Application Received*. +3. Confirm three radio options appear and `Bundled` is preselected. +4. Pick **Bundled**, upload any small PDF — the `Detected pages` field should show a number. +5. Click *Confirm Application Received*. +6. On the order, verify: + - Status moved to `Application Received`. + - Chatter shows the bundled message. + - `x_fc_pages_11_12_in_original` is checked (set to True). +7. Re-open the wizard on another order, switch radio to **Separate** — the Signed Pages group should appear and require a file. +8. Re-open the wizard on a third order, switch radio to **Remote** — the Signed Pages group should disappear and the *Request Remote Signature* button should appear (when no request exists yet). + +If any step misbehaves visually, inspect the browser console + the docker logs. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/gurpreet/Github/Odoo-Modules +git add fusion_claims/wizard/application_received_wizard_views.xml +git commit -m "$(cat <<'EOF' +feat(claims): wizard view — intake-mode radio + conditional groups + +Three-mode radio at the top of the Application Received wizard. The +Signed Pages 11 & 12 group is only shown in Separate mode; the remote +sign banner/button is only shown in Remote mode. Adds a read-only +'Detected pages' indicator next to the uploaded original PDF. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 7: Bump module version + +**Files:** +- Modify: `fusion_claims/__manifest__.py:8` + +### Steps + +- [ ] **Step 1: Bump version** + +In `fusion_claims/__manifest__.py`, find line 8: + +```python + 'version': '19.0.8.0.6', +``` + +Replace with: + +```python + 'version': '19.0.8.0.7', +``` + +- [ ] **Step 2: Reload module to apply version bump and bust asset cache** + +Run: + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims \ + --stop-after-init 2>&1 | tail -5 +``` + +Expected: no errors. + +- [ ] **Step 3: Final test suite run** + +Run: + +```bash +docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims \ + --test-enable --test-tags=fusion_claims \ + --stop-after-init 2>&1 | tail -15 +``` + +Expected: 24 tests, 0 failed. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/gurpreet/Github/Odoo-Modules +git add fusion_claims/__manifest__.py +git commit -m "$(cat <<'EOF' +chore(claims): bump version to 19.0.8.0.7 + +Bumps fusion_claims version to bust the asset bundle cache after the +Application Received wizard refactor. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Self-Review Notes + +**Spec coverage check:** + +| Spec section | Covered by | +|---|---| +| New fields on `sale.order` | Task 1 | +| `_compute_has_signed_pages_11_12` with three-way OR | Task 1 | +| Audit trail field update + dep refresh | Task 2 | +| `ready_for_submission_wizard.py:95` change | Task 3 | +| `ready_for_submission_wizard.py:148` change | Task 3 | +| `case_close_verification_wizard.py:81` change | Task 4 | +| `intake_mode` Selection + radio UX | Task 5 + Task 6 | +| `default_get` initial-mode selection | Task 5 | +| PDF magic-bytes check | Task 5 | +| Status-gate message fix | Task 5 | +| Page-count indicator | Task 5 + Task 6 | +| Mode-aware chatter | Task 5 | +| `action_confirm` three-mode logic | Task 5 | +| View — conditional visibility | Task 6 | +| Manual smoke test | Task 6 | +| Module version bump | Task 7 | + +No gaps. + +**Placeholder scan:** no TBD/TODO; every step has either complete code or an exact command. + +**Type consistency:** field names match between model (Task 1), wizard logic (Task 5), and view (Task 6). `_validate_pdf_bytes` defined in Task 5 is used inside the same module. `_post_chatter` helper defined in Task 5 is called inside the same wizard.